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 e59c349e0f939fb4bdc93c4bb0635df51f0fd521 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 10:27:15 +0530 Subject: [PATCH 3/9] feat: add paintable roof accessory materials --- packages/core/src/schema/index.ts | 8 +- packages/core/src/schema/material.ts | 1 + packages/core/src/schema/nodes/box-vent.ts | 7 + packages/core/src/schema/nodes/cupola.ts | 9 ++ .../core/src/schema/nodes/eyebrow-vent.ts | 7 + .../core/src/schema/nodes/turbine-vent.ts | 7 + packages/editor/src/lib/material-paint.ts | 2 + .../src/box-vent/__tests__/geometry.test.ts | 27 ++++ .../src/box-vent/__tests__/paint.test.ts | 71 +++++++++ packages/nodes/src/box-vent/definition.ts | 7 +- packages/nodes/src/box-vent/geometry.ts | 125 ++++++++++----- packages/nodes/src/box-vent/paint.ts | 83 ++++++++++ packages/nodes/src/box-vent/renderer.tsx | 38 ++++- .../src/cupola/__tests__/geometry.test.ts | 13 ++ .../nodes/src/cupola/__tests__/paint.test.ts | 33 ++++ packages/nodes/src/cupola/definition.ts | 7 +- packages/nodes/src/cupola/geometry.ts | 148 +++++++++++++++--- packages/nodes/src/cupola/paint.ts | 78 +++++++++ packages/nodes/src/cupola/renderer.tsx | 47 +++++- .../nodes/src/downspout/definition.test.ts | 24 +++ packages/nodes/src/downspout/definition.ts | 2 + packages/nodes/src/downspout/geometry.test.ts | 26 +++ packages/nodes/src/downspout/geometry.ts | 13 ++ .../eyebrow-vent/__tests__/geometry.test.ts | 16 ++ .../src/eyebrow-vent/__tests__/paint.test.ts | 27 ++++ packages/nodes/src/eyebrow-vent/definition.ts | 7 +- packages/nodes/src/eyebrow-vent/geometry.ts | 133 +++++++++++++--- packages/nodes/src/eyebrow-vent/paint.ts | 73 +++++++++ packages/nodes/src/eyebrow-vent/renderer.tsx | 33 +++- packages/nodes/src/gutter/definition.test.ts | 29 ++++ packages/nodes/src/gutter/definition.ts | 2 + packages/nodes/src/gutter/geometry.ts | 24 ++- packages/nodes/src/gutter/uv.test.ts | 24 +++ .../nodes/src/shared/primitive-uv.test.ts | 100 ++++++++++++ packages/nodes/src/shared/primitive-uv.ts | 133 ++++++++++++++++ packages/nodes/src/shared/surface-paint.ts | 7 +- .../turbine-vent/__tests__/geometry.test.ts | 11 ++ .../src/turbine-vent/__tests__/paint.test.ts | 58 +++++++ packages/nodes/src/turbine-vent/definition.ts | 7 +- packages/nodes/src/turbine-vent/geometry.ts | 79 ++++++++-- packages/nodes/src/turbine-vent/paint.ts | 70 +++++++++ packages/nodes/src/turbine-vent/renderer.tsx | 40 ++++- 42 files changed, 1497 insertions(+), 159 deletions(-) create mode 100644 packages/nodes/src/box-vent/__tests__/paint.test.ts create mode 100644 packages/nodes/src/box-vent/paint.ts create mode 100644 packages/nodes/src/cupola/__tests__/paint.test.ts create mode 100644 packages/nodes/src/cupola/paint.ts create mode 100644 packages/nodes/src/downspout/definition.test.ts create mode 100644 packages/nodes/src/downspout/geometry.test.ts create mode 100644 packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts create mode 100644 packages/nodes/src/eyebrow-vent/paint.ts create mode 100644 packages/nodes/src/gutter/definition.test.ts create mode 100644 packages/nodes/src/gutter/uv.test.ts create mode 100644 packages/nodes/src/shared/primitive-uv.test.ts create mode 100644 packages/nodes/src/shared/primitive-uv.ts create mode 100644 packages/nodes/src/turbine-vent/__tests__/paint.test.ts create mode 100644 packages/nodes/src/turbine-vent/paint.ts diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 8596a79bde..9df6acbf3d 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -54,7 +54,7 @@ export { getBlockFaceNormal, inspectBlockTopology, } from './nodes/block' -export { BoxVentNode } from './nodes/box-vent' +export { BoxVentMaterialRole, BoxVentNode } from './nodes/box-vent' export { BuildingNode } from './nodes/building' export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' export { CeilingNode } from './nodes/ceiling' @@ -94,7 +94,7 @@ export { setConstructionDimensionDrawingPresentation, setConstructionDimensionDrawingSuppressedSegments, } from './nodes/construction-dimension' -export { CupolaNode } from './nodes/cupola' +export { CupolaMaterialRole, CupolaNode } from './nodes/cupola' export { DoorNode, DoorSegment, @@ -122,7 +122,7 @@ export { ElevatorNode, ElevatorShaftStyle, } from './nodes/elevator' -export { EyebrowVentNode } from './nodes/eyebrow-vent' +export { EyebrowVentMaterialRole, EyebrowVentNode } from './nodes/eyebrow-vent' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' export { @@ -278,7 +278,7 @@ export { export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { StructuralGridNode } from './nodes/structural-grid' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' -export { TurbineVentNode } from './nodes/turbine-vent' +export { TurbineVentMaterialRole, TurbineVentNode } from './nodes/turbine-vent' export type { WallBandSurfaceSlotId, WallFaceBand, diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 5a07a96646..f1a3cf20c3 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -63,6 +63,7 @@ export const MaterialTarget = z.enum([ 'cupola', 'eyebrow-vent', 'gutter', + 'downspout', ]) export type MaterialTarget = z.infer diff --git a/packages/core/src/schema/nodes/box-vent.ts b/packages/core/src/schema/nodes/box-vent.ts index 23e27a59bd..f1b920b20f 100644 --- a/packages/core/src/schema/nodes/box-vent.ts +++ b/packages/core/src/schema/nodes/box-vent.ts @@ -3,6 +3,9 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +export const BoxVentMaterialRole = z.enum(['base', 'top']) +export type BoxVentMaterialRole = z.infer + export const BoxVentNode = BaseNode.extend({ id: objectId('bvent'), type: nodeType('box-vent'), @@ -14,6 +17,10 @@ export const BoxVentNode = BaseNode.extend({ // made it look like the vent had nothing applied even though the // renderer was falling back to white internally. materialPreset: z.string().default('preset-white'), + baseMaterial: MaterialSchema.optional(), + baseMaterialPreset: z.string().optional(), + topMaterial: MaterialSchema.optional(), + topMaterialPreset: z.string().optional(), roofSegmentId: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/cupola.ts b/packages/core/src/schema/nodes/cupola.ts index 6e8c12a795..d0e790a6a3 100644 --- a/packages/core/src/schema/nodes/cupola.ts +++ b/packages/core/src/schema/nodes/cupola.ts @@ -3,6 +3,9 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +export const CupolaMaterialRole = z.enum(['base', 'body', 'roof']) +export type CupolaMaterialRole = z.infer + export const CupolaNode = BaseNode.extend({ id: objectId('cupola'), type: nodeType('cupola'), @@ -11,6 +14,12 @@ export const CupolaNode = BaseNode.extend({ // Default to the white preset so a freshly-placed cupola reads as clean // painted metal and the paint inspector shows "White" (matches box-vent). materialPreset: z.string().default('preset-white'), + baseMaterial: MaterialSchema.optional(), + baseMaterialPreset: z.string().optional(), + bodyMaterial: MaterialSchema.optional(), + bodyMaterialPreset: z.string().optional(), + roofMaterial: MaterialSchema.optional(), + roofMaterialPreset: z.string().optional(), roofSegmentId: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/eyebrow-vent.ts b/packages/core/src/schema/nodes/eyebrow-vent.ts index 0797882fe3..4171afd925 100644 --- a/packages/core/src/schema/nodes/eyebrow-vent.ts +++ b/packages/core/src/schema/nodes/eyebrow-vent.ts @@ -3,6 +3,9 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +export const EyebrowVentMaterialRole = z.enum(['hood', 'front']) +export type EyebrowVentMaterialRole = z.infer + export const EyebrowVentNode = BaseNode.extend({ id: objectId('eyebrow-vent'), type: nodeType('eyebrow-vent'), @@ -11,6 +14,10 @@ export const EyebrowVentNode = BaseNode.extend({ // Default to the white preset so a freshly-placed vent reads as clean // painted metal and the paint inspector shows "White" (matches box-vent). materialPreset: z.string().default('preset-white'), + hoodMaterial: MaterialSchema.optional(), + hoodMaterialPreset: z.string().optional(), + frontMaterial: MaterialSchema.optional(), + frontMaterialPreset: z.string().optional(), roofSegmentId: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/turbine-vent.ts b/packages/core/src/schema/nodes/turbine-vent.ts index d828fe4cda..34dfa05ac2 100644 --- a/packages/core/src/schema/nodes/turbine-vent.ts +++ b/packages/core/src/schema/nodes/turbine-vent.ts @@ -3,6 +3,9 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +export const TurbineVentMaterialRole = z.enum(['base', 'head']) +export type TurbineVentMaterialRole = z.infer + export const TurbineVentNode = BaseNode.extend({ id: objectId('tvent'), type: nodeType('turbine-vent'), @@ -12,6 +15,10 @@ export const TurbineVentNode = BaseNode.extend({ // clean painted/galvanised metal and the paint inspector shows "White" // as the current selection (matches box-vent's reasoning). materialPreset: z.string().default('preset-white'), + baseMaterial: MaterialSchema.optional(), + baseMaterialPreset: z.string().optional(), + headMaterial: MaterialSchema.optional(), + headMaterialPreset: z.string().optional(), roofSegmentId: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 7c0af3e70f..f0b097bc77 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -47,6 +47,8 @@ export type PaintableMaterialTarget = | 'turbine-vent' | 'cupola' | 'eyebrow-vent' + | 'gutter' + | 'downspout' > | 'item' diff --git a/packages/nodes/src/box-vent/__tests__/geometry.test.ts b/packages/nodes/src/box-vent/__tests__/geometry.test.ts index 30ad034879..a57ba4a5ea 100644 --- a/packages/nodes/src/box-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/box-vent/__tests__/geometry.test.ts @@ -22,6 +22,22 @@ describe('buildBoxVentGeometry', () => { expect(box.getAttribute('position').count).toBe(384) }) + test.each([ + 'box', + 'cap', + 'dome', + ] as const)('%s style separates the lower base from the upper cover', (style) => { + const geometry = buildBoxVentGeometry(BoxVentNode.parse({ style })) + const vertexCount = geometry.getAttribute('position').count + + expect(geometry.groups).toHaveLength(2) + expect(geometry.groups[0]).toMatchObject({ start: 0, materialIndex: 0 }) + expect(geometry.groups[1]).toMatchObject({ materialIndex: 1 }) + expect(geometry.groups[0]!.count).toBeGreaterThan(0) + expect(geometry.groups[1]!.count).toBeGreaterThan(0) + expect(geometry.groups[0]!.count + geometry.groups[1]!.count).toBe(vertexCount) + }) + test('box style: zero bevel still produces a valid closed solid', () => { // With bevel=0 the wall-edge dedupe drops the degenerate corner // quads, but the bottom + top fan triangulations always include @@ -91,6 +107,17 @@ describe('buildBoxVentGeometry', () => { expect(maxX).toBeCloseTo(0.3) expect(maxZ).toBeCloseTo(0.25) }) + + test('unwraps the rounded base and cover at metre scale', () => { + const geometry = buildBoxVentGeometry( + BoxVentNode.parse({ style: 'box', width: 2, depth: 1.5, height: 0.6 }), + ) + const uv = geometry.getAttribute('uv') + expect(geometry.getAttribute('uv2').count).toBe(uv.count) + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + + expect(Math.max(...u) - Math.min(...u)).toBeGreaterThan(5) + }) }) describe('computeBoxVentSlopeTilt', () => { diff --git a/packages/nodes/src/box-vent/__tests__/paint.test.ts b/packages/nodes/src/box-vent/__tests__/paint.test.ts new file mode 100644 index 0000000000..3c2850c24f --- /dev/null +++ b/packages/nodes/src/box-vent/__tests__/paint.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test' +import { Group, Mesh, MeshBasicMaterial } from 'three' +import { + boxVentPaint, + buildBoxVentMaterialPatch, + getEffectiveBoxVentMaterial, + resolveBoxVentMaterialRole, +} from '../paint' +import { BoxVentNode } from '../schema' + +describe('box vent paint', () => { + test('maps geometry material groups to base and top roles', () => { + expect(resolveBoxVentMaterialRole(0)).toBe('base') + expect(resolveBoxVentMaterialRole(1)).toBe('top') + }) + + test('updates only the painted role', () => { + expect(buildBoxVentMaterialPatch('base', undefined, 'library:metal-steel')).toEqual({ + baseMaterial: undefined, + baseMaterialPreset: 'library:metal-steel', + }) + expect(buildBoxVentMaterialPatch('top', undefined, 'library:roof-shingle')).toEqual({ + topMaterial: undefined, + topMaterialPreset: 'library:roof-shingle', + }) + }) + + test('keeps the legacy whole-vent material as an independent fallback', () => { + const node = BoxVentNode.parse({ + materialPreset: 'preset-white', + baseMaterialPreset: 'library:metal-steel', + }) + + expect(getEffectiveBoxVentMaterial(node, 'base').materialPreset).toBe('library:metal-steel') + expect(getEffectiveBoxVentMaterial(node, 'top').materialPreset).toBe('preset-white') + }) + + test('previews the top without replacing the base material', () => { + const base = new MeshBasicMaterial() + const top = new MeshBasicMaterial() + const mesh = new Mesh(undefined, [base, top]) + mesh.name = 'box-vent-surface' + const root = new Group() + root.add(mesh) + + const restore = boxVentPaint.applyPreview({ + node: BoxVentNode.parse({}), + role: 'top', + material: { + preset: 'custom', + properties: { + color: '#123456', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + }, + materialPreset: undefined, + root, + }) + + expect(Array.isArray(mesh.material)).toBe(true) + expect(mesh.material[0]).toBe(base) + expect(mesh.material[1]).not.toBe(top) + + restore?.() + expect(mesh.material).toEqual([base, top]) + }) +}) diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index fef3b1b0f2..0f86e0769f 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -4,8 +4,8 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' -import { surfacePaintCapability } from '../shared/surface-paint' import { buildBoxVentFloorplan } from './floorplan' +import { boxVentPaint } from './paint' import { boxVentParametrics } from './parametrics' import { BoxVentNode } from './schema' @@ -175,7 +175,7 @@ const boxVentHandles: HandleDescriptor[] = [ */ export const boxVentDefinition: NodeDefinition = { kind: 'box-vent', - schemaVersion: 1, + schemaVersion: 2, schema: BoxVentNode, category: 'structure', surfaceRole: 'roof', @@ -190,8 +190,7 @@ export const boxVentDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, - // Single painted surface — registry-driven paint dispatch (see chimney). - paint: surfacePaintCapability, + paint: boxVentPaint, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the // slope — no `buildCut`, just the dirty cascade so the parent // roof's merged shell rebuilds when the vent moves / resizes. diff --git a/packages/nodes/src/box-vent/geometry.ts b/packages/nodes/src/box-vent/geometry.ts index 20cc59a08b..dee0ec1521 100644 --- a/packages/nodes/src/box-vent/geometry.ts +++ b/packages/nodes/src/box-vent/geometry.ts @@ -1,5 +1,11 @@ import { type BoxVentNode, getActiveRoofHeight, type RoofType } from '@pascal-app/core' import * as THREE from 'three' +import { copyUvToSecondaryChannel } from '../shared/primitive-uv' + +export const BOX_VENT_MATERIAL_INDEX = { + base: 0, + top: 1, +} as const /** * Pure builder for the box-vent mesh. Models a real attic box vent: @@ -68,11 +74,12 @@ function buildBoxShape(node: BoxVentNode): THREE.BufferGeometry { // Lower (smaller) riser. Top is hidden under the cover but include // it anyway — overlap is invisible and the geometry stays simple. buildRoundedExtrusion(positions, normals, uvs, baseW, baseD, 0, baseH, cornerBevel) + const topStartVertex = positions.length / 3 // Upper (larger) cover. Bottom partially shows where it overhangs the // riser, so it's always rendered. buildRoundedExtrusion(positions, normals, uvs, w, d, baseH, h, cornerBevel) - return buildBufferGeometry(positions, normals, uvs) + return buildBufferGeometry(positions, normals, uvs, topStartVertex) } // Extruded rounded rectangle: walls follow a rounded-rect profile, @@ -90,6 +97,7 @@ function buildRoundedExtrusion( ): void { const profile = roundedRectProfile(w, d, bevel, BOX_CORNER_SEGS) const n = profile.length + let perimeterU = 0 // Walls: each edge in the closed profile becomes an outward-facing quad. for (let i = 0; i < n; i++) { @@ -110,7 +118,9 @@ function buildRoundedExtrusion( [b.x, y1, b.z], [a.x, y1, a.z], [nx, 0, nz], + perimeterU, ) + perimeterU += len } // Top cap (+Y normal): wind triangles CW from above so the cross @@ -118,14 +128,14 @@ function buildRoundedExtrusion( for (let i = 0; i < n; i++) { const a = profile[i]! const b = profile[(i + 1) % n]! - pushTri(positions, normals, uvs, [0, y1, 0], [b.x, y1, b.z], [a.x, y1, a.z], [0, 1, 0]) + pushTri(positions, normals, uvs, [0, y1, 0], [b.x, y1, b.z], [a.x, y1, a.z], [0, 1, 0], 'xz') } // Bottom cap (-Y normal): wind CCW from above. for (let i = 0; i < n; i++) { const a = profile[i]! const b = profile[(i + 1) % n]! - pushTri(positions, normals, uvs, [0, y0, 0], [a.x, y0, a.z], [b.x, y0, b.z], [0, -1, 0]) + pushTri(positions, normals, uvs, [0, y0, 0], [a.x, y0, a.z], [b.x, y0, b.z], [0, -1, 0], 'xz') } } @@ -286,6 +296,8 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry { ) } + const topStartVertex = positions.length / 3 + // ── Flange underside (the bit of the cap base that overhangs the body) if (overhang > 0 || capGap > 0) { pushQuad( @@ -364,7 +376,7 @@ function buildCapShape(node: BoxVentNode): THREE.BufferGeometry { [0, 1, 0], ) - return buildBufferGeometry(positions, normals, uvs) + return buildBufferGeometry(positions, normals, uvs, topStartVertex) } function clamp01(value: number): number { @@ -432,8 +444,10 @@ function buildDomeStyleShape(node: BoxVentNode): THREE.BufferGeometry { addBand(positions, normals, uvs, flangeBottom, center, lng, down) addBand(positions, normals, uvs, flangeBottom, flangeTop, lng, radial) addBand(positions, normals, uvs, flangeTop, collarFoot, lng, up) - // Lifted collar wall (radial) + the overhanging dome-lip underside (down). + // Lifted collar wall (radial). addBand(positions, normals, uvs, collarFoot, collarTop, lng, radial) + const topStartVertex = positions.length / 3 + // The overhanging dome-lip underside belongs to the upper cover. addBand(positions, normals, uvs, collarTop, domeBase, lng, down) // Dome cap, base ring → apex. @@ -446,16 +460,17 @@ function buildDomeStyleShape(node: BoxVentNode): THREE.BufferGeometry { return [x / l, y / l, z / l] } let prev = domeBase + let domeV = 0 for (let i = 1; i <= lat; i++) { const phi = (Math.PI / 2) * (i / lat) const rf = Math.cos(phi) ** power const y = domeBaseY + domeH * Math.sin(phi) const ring = ringAt(rx * rf, rz * rf, y, lng) - addBand(positions, normals, uvs, prev, ring, lng, domeHint) + domeV += addBand(positions, normals, uvs, prev, ring, lng, domeHint, domeV) prev = ring } - return buildBufferGeometry(positions, normals, uvs) + return buildBufferGeometry(positions, normals, uvs, topStartVertex) } // One ellipse ring of `lng` segments at height `y`. First and last points @@ -479,14 +494,20 @@ function addBand( rB: number[][], lng: number, hintFn: (a: number[], b: number[], c: number[], d: number[]) => number[], -): void { + vOffset = 0, +): number { + let uOffset = 0 + let vStep = 0 for (let j = 0; j < lng; j++) { const a = rA[j]! const b = rA[j + 1]! const c = rB[j + 1]! const d = rB[j]! - pushQuadOriented(positions, normals, uvs, a, b, c, d, hintFn(a, b, c, d)) + pushQuadOriented(positions, normals, uvs, a, b, c, d, hintFn(a, b, c, d), uOffset, vOffset) + uOffset += Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!) + vStep += Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!) } + return vStep / lng } // Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so @@ -500,6 +521,8 @@ function pushQuadOriented( c: number[], d: number[], hint: number[], + uOffset = 0, + vOffset = 0, ) { let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!) let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!) @@ -515,19 +538,18 @@ function pushQuadOriented( ny /= len nz /= len - const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!) - const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!) + const quadUvs = surfaceQuadUvs(a, b, c, d, [nx, ny, nz], uOffset, vOffset) if (flip) { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, u, 0, u, v) + uvs.push(...quadUvs.a, ...quadUvs.b, ...quadUvs.c) positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!) - uvs.push(0, 0, u, v, 0, v) + uvs.push(...quadUvs.a, ...quadUvs.c, ...quadUvs.d) } else { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) - uvs.push(0, 0, u, v, u, 0) + uvs.push(...quadUvs.a, ...quadUvs.c, ...quadUvs.b) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, 0, v, u, v) + uvs.push(...quadUvs.a, ...quadUvs.d, ...quadUvs.c) } for (let i = 0; i < 6; i++) normals.push(nx, ny, nz) } @@ -538,11 +560,16 @@ function buildBufferGeometry( positions: number[], normals: number[], uvs: number[], + topStartVertex: number, ): THREE.BufferGeometry { const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + const vertexCount = positions.length / 3 + geo.addGroup(0, topStartVertex, BOX_VENT_MATERIAL_INDEX.base) + geo.addGroup(topStartVertex, vertexCount - topStartVertex, BOX_VENT_MATERIAL_INDEX.top) + copyUvToSecondaryChannel(geo) return geo } @@ -555,35 +582,55 @@ function pushQuad( c: number[], d: number[], n: number[], + uOffset = 0, ) { const nLen = Math.sqrt(n[0]! * n[0]! + n[1]! * n[1]! + n[2]! * n[2]!) || 1 const nx = n[0]! / nLen const ny = n[1]! / nLen const nz = n[2]! / nLen - // Dimension-based planar UVs: U follows |b-a| (the quad's "right" - // edge) and V follows |d-a| ("up"). Textures then tile at world - // scale across every face — a 0.4m vent face uses 0.4 UV units, not - // a fixed 0..1 — so a brick / metal / shingle preset reads at a - // consistent density on the body, hood, and louvers. - const abx = b[0]! - a[0]! - const aby = b[1]! - a[1]! - const abz = b[2]! - a[2]! - const adx = d[0]! - a[0]! - const ady = d[1]! - a[1]! - const adz = d[2]! - a[2]! - const u = Math.sqrt(abx * abx + aby * aby + abz * abz) - const v = Math.sqrt(adx * adx + ady * ady + adz * adz) + const quadUvs = surfaceQuadUvs(a, b, c, d, [nx, ny, nz], uOffset) // Winding is (a, c, b) + (a, d, c) so the triangle face direction // matches the stored normal (see earlier note on the dark-shading // regression this fixed). positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz) - uvs.push(0, 0, u, v, u, 0) + uvs.push(...quadUvs.a, ...quadUvs.c, ...quadUvs.b) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz) - uvs.push(0, 0, 0, v, u, v) + uvs.push(...quadUvs.a, ...quadUvs.d, ...quadUvs.c) +} + +function surfaceQuadUvs( + a: number[], + b: number[], + c: number[], + d: number[], + normal: number[], + uOffset = 0, + vOffset = 0, +): Record<'a' | 'b' | 'c' | 'd', [number, number]> { + const ux = b[0]! - a[0]! + const uy = b[1]! - a[1]! + const uz = b[2]! - a[2]! + const uLength = Math.hypot(ux, uy, uz) || 1 + const unitU = [ux / uLength, uy / uLength, uz / uLength] + const unitV = [ + normal[1]! * unitU[2]! - normal[2]! * unitU[1]!, + normal[2]! * unitU[0]! - normal[0]! * unitU[2]!, + normal[0]! * unitU[1]! - normal[1]! * unitU[0]!, + ] + const project = (point: number[]): [number, number] => { + const x = point[0]! - a[0]! + const y = point[1]! - a[1]! + const z = point[2]! - a[2]! + return [ + uOffset + x * unitU[0]! + y * unitU[1]! + z * unitU[2]!, + vOffset + x * unitV[0]! + y * unitV[1]! + z * unitV[2]!, + ] + } + return { a: project(a), b: project(b), c: project(c), d: project(d) } } // pushTri: single-triangle counterpart to pushQuad. Caller orders (a, b, c) @@ -598,24 +645,24 @@ function pushTri( b: number[], c: number[], n: number[], + projection: 'surface' | 'xz' = 'surface', ) { const nLen = Math.sqrt(n[0]! * n[0]! + n[1]! * n[1]! + n[2]! * n[2]!) || 1 const nx = n[0]! / nLen const ny = n[1]! / nLen const nz = n[2]! / nLen - const abx = b[0]! - a[0]! - const aby = b[1]! - a[1]! - const abz = b[2]! - a[2]! - const acx = c[0]! - a[0]! - const acy = c[1]! - a[1]! - const acz = c[2]! - a[2]! - const u = Math.sqrt(abx * abx + aby * aby + abz * abz) - const v = Math.sqrt(acx * acx + acy * acy + acz * acz) + const uv = (point: number[]): [number, number] => { + if (projection === 'xz') return [point[0]!, point[2]!] + const mapped = surfaceQuadUvs(a, b, c, c, [nx, ny, nz]) + if (point === a) return mapped.a + if (point === b) return mapped.b + return mapped.c + } positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) normals.push(nx, ny, nz, nx, ny, nz, nx, ny, nz) - uvs.push(0, 0, u, 0, 0, v) + uvs.push(...uv(a), ...uv(b), ...uv(c)) } /** diff --git a/packages/nodes/src/box-vent/paint.ts b/packages/nodes/src/box-vent/paint.ts new file mode 100644 index 0000000000..b1d51e86b6 --- /dev/null +++ b/packages/nodes/src/box-vent/paint.ts @@ -0,0 +1,83 @@ +import type { + BoxVentMaterialRole, + BoxVentNode, + MaterialSchema, + PaintCapability, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' +import type { Material, Mesh, Object3D } from 'three' +import { BOX_VENT_MATERIAL_INDEX } from './geometry' + +export function resolveBoxVentMaterialRole(materialIndex: number | null): BoxVentMaterialRole { + return materialIndex === BOX_VENT_MATERIAL_INDEX.top ? 'top' : 'base' +} + +export function buildBoxVentMaterialPatch( + role: BoxVentMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + return role === 'top' + ? { topMaterial: material, topMaterialPreset: materialPreset } + : { baseMaterial: material, baseMaterialPreset: materialPreset } +} + +export function getEffectiveBoxVentMaterial( + node: BoxVentNode, + role: BoxVentMaterialRole, +): { material: MaterialSchema | undefined; materialPreset: string | undefined } { + const material = role === 'top' ? node.topMaterial : node.baseMaterial + const materialPreset = role === 'top' ? node.topMaterialPreset : node.baseMaterialPreset + if (material !== undefined || materialPreset !== undefined) { + return { material, materialPreset } + } + return { material: node.material, materialPreset: node.materialPreset } +} + +function buildPreviewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + if (materialPreset) return createMaterialFromPresetRef(materialPreset) + if (material) return createMaterial(material) + return null +} + +function applyBoxVentPreview( + role: BoxVentMaterialRole, + previewMaterial: Material, + root: Object3D, +): (() => void) | null { + const materialIndex = BOX_VENT_MATERIAL_INDEX[role] + const restores: Array<() => void> = [] + root.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.name !== 'box-vent-surface' || !Array.isArray(mesh.material)) return + const previous = [...mesh.material] + if (!previous[materialIndex]) return + const next = [...previous] + next[materialIndex] = previewMaterial + mesh.material = next + restores.push(() => { + mesh.material = previous + }) + }) + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } +} + +export const boxVentPaint: PaintCapability = { + materialTarget: 'box-vent', + resolveRole: ({ materialIndex }) => resolveBoxVentMaterialRole(materialIndex), + buildPatch: ({ role, material, materialPreset }) => + buildBoxVentMaterialPatch(role as BoxVentMaterialRole, material, materialPreset), + applyPreview: ({ role, material, materialPreset, root }) => { + const previewMaterial = buildPreviewMaterial(material, materialPreset) + if (!previewMaterial) return null + return applyBoxVentPreview(role as BoxVentMaterialRole, previewMaterial, root) + }, + getEffectiveMaterial: ({ node, role }) => + getEffectiveBoxVentMaterial(node as BoxVentNode, role as BoxVentMaterialRole), +} diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index f72d71a24a..e9a0a5e367 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -108,21 +108,43 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) - // Paint surface: explicit material wins, then preset, then the cached - // default. FrontSide everywhere — DoubleSide on the role material's + // Paint surfaces: the lower base and upper cover resolve independently. + // FrontSide everywhere — DoubleSide on the role material's // NodeMaterial poisons the MRT scene pass (see `materials.ts` line 77 / // glazing fix 9400f1c5). Earlier this path forced DoubleSide so back // faces of the vent body / hood wouldn't drop out when looking up at the // eaves; that's now a known visual tradeoff — a closed-solid extrude in // `geometry.ts` is the right fix if undersides become noticeable. + const hasBaseMaterial = node.baseMaterial !== undefined || node.baseMaterialPreset !== undefined + const baseMaterial = hasBaseMaterial ? node.baseMaterial : node.material + const baseMaterialPreset = hasBaseMaterial ? node.baseMaterialPreset : node.materialPreset + const hasTopMaterial = node.topMaterial !== undefined || node.topMaterialPreset !== undefined + const topMaterial = hasTopMaterial ? node.topMaterial : node.material + const topMaterialPreset = hasTopMaterial ? node.topMaterialPreset : node.materialPreset const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + if (!textures) return [roleDefault, roleDefault] + const resolve = ( + roleMaterial: BoxVentNode['material'], + roleMaterialPreset: string | undefined, + ) => { + if (roleMaterial) return createMaterial(roleMaterial, shading) + if (roleMaterialPreset) { + return createMaterialFromPresetRef(roleMaterialPreset, shading) ?? defaultMaterial + } + return roleDefault } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + return [resolve(baseMaterial, baseMaterialPreset), resolve(topMaterial, topMaterialPreset)] + }, [ + textures, + colorPreset, + sceneTheme, + shading, + baseMaterial, + baseMaterialPreset, + topMaterial, + topMaterialPreset, + ]) // Compose slope tilt + yaw onto a single quaternion so the registered // ref's local frame is vent-mesh-local. `NodeArrowHandles` reads this diff --git a/packages/nodes/src/cupola/__tests__/geometry.test.ts b/packages/nodes/src/cupola/__tests__/geometry.test.ts index ffcf1ae978..fe92e9bb00 100644 --- a/packages/nodes/src/cupola/__tests__/geometry.test.ts +++ b/packages/nodes/src/cupola/__tests__/geometry.test.ts @@ -17,6 +17,9 @@ describe('buildCupolaGeometry', () => { expect(p.count).toBeGreaterThan(0) expect(geo.getAttribute('normal').count).toBe(p.count) expect(geo.getAttribute('uv').count).toBe(p.count) + expect(geo.getAttribute('uv2').count).toBe(p.count) + expect(new Set(geo.groups.map((group) => group.materialIndex))).toEqual(new Set([0, 1, 2])) + expect(geo.groups.reduce((count, group) => count + group.count, 0)).toBe(p.count) }) test('both roof styles build finite geometry', () => { @@ -27,6 +30,16 @@ describe('buildCupolaGeometry', () => { } }) + test('unwraps the dome perimeter continuously at metre scale', () => { + const geo = buildCupolaGeometry( + CupolaNode.parse({ width: 2, depth: 2, height: 2, roofStyle: 'dome' }), + ) + const uv = geo.getAttribute('uv') + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + + expect(Math.max(...u) - Math.min(...u)).toBeGreaterThan(6) + }) + test('finial adds vertices', () => { const withFinial = buildCupolaGeometry(CupolaNode.parse({ finial: true })).getAttribute( 'position', diff --git a/packages/nodes/src/cupola/__tests__/paint.test.ts b/packages/nodes/src/cupola/__tests__/paint.test.ts new file mode 100644 index 0000000000..d1b24a0b1a --- /dev/null +++ b/packages/nodes/src/cupola/__tests__/paint.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test' +import { + buildCupolaMaterialPatch, + getEffectiveCupolaMaterial, + resolveCupolaMaterialRole, +} from '../paint' +import { CupolaNode } from '../schema' + +describe('cupola paint', () => { + test('maps geometry groups to base, body, and roof', () => { + expect(resolveCupolaMaterialRole(0)).toBe('base') + expect(resolveCupolaMaterialRole(1)).toBe('body') + expect(resolveCupolaMaterialRole(2)).toBe('roof') + }) + + test('updates only the selected construction part', () => { + expect(buildCupolaMaterialPatch('body', undefined, 'library:louver')).toEqual({ + bodyMaterial: undefined, + bodyMaterialPreset: 'library:louver', + }) + expect(buildCupolaMaterialPatch('roof', undefined, 'library:copper')).toEqual({ + roofMaterial: undefined, + roofMaterialPreset: 'library:copper', + }) + }) + + test('uses the legacy material only for roles without an override', () => { + const node = CupolaNode.parse({ bodyMaterialPreset: 'library:louver' }) + expect(getEffectiveCupolaMaterial(node, 'body').materialPreset).toBe('library:louver') + expect(getEffectiveCupolaMaterial(node, 'base').materialPreset).toBe('preset-white') + expect(getEffectiveCupolaMaterial(node, 'roof').materialPreset).toBe('preset-white') + }) +}) diff --git a/packages/nodes/src/cupola/definition.ts b/packages/nodes/src/cupola/definition.ts index cf72149a5b..1b588d2e0e 100644 --- a/packages/nodes/src/cupola/definition.ts +++ b/packages/nodes/src/cupola/definition.ts @@ -4,8 +4,8 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' -import { surfacePaintCapability } from '../shared/surface-paint' import { buildCupolaFloorplan } from './floorplan' +import { cupolaPaint } from './paint' import { cupolaParametrics } from './parametrics' import { CupolaNode } from './schema' @@ -108,7 +108,7 @@ const cupolaHandles: HandleDescriptor[] = [ */ export const cupolaDefinition: NodeDefinition = { kind: 'cupola', - schemaVersion: 1, + schemaVersion: 2, schema: CupolaNode, category: 'structure', surfaceRole: 'roof', @@ -123,8 +123,7 @@ export const cupolaDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, - // Single painted surface — registry-driven paint dispatch (see chimney). - paint: surfacePaintCapability, + paint: cupolaPaint, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the // slope — no `buildCut`, just the dirty cascade so the parent roof's // merged shell rebuilds when the cupola moves / resizes. diff --git a/packages/nodes/src/cupola/geometry.ts b/packages/nodes/src/cupola/geometry.ts index f7535d4ec3..0359b3663c 100644 --- a/packages/nodes/src/cupola/geometry.ts +++ b/packages/nodes/src/cupola/geometry.ts @@ -1,5 +1,17 @@ import type { CupolaNode } from '@pascal-app/core' import * as THREE from 'three' +import { + copyUvToSecondaryChannel, + cumulativeProfileDistances, + type MetricUv, + planarMetricUvs, +} from '../shared/primitive-uv' + +export const CUPOLA_MATERIAL_INDEX = { + base: 0, + body: 1, + roof: 2, +} as const /** * Pure builder for the cupola mesh — a small louvered roof lantern: @@ -47,13 +59,17 @@ export function buildCupolaGeometry(node: CupolaNode): THREE.BufferGeometry { // Base plinth (slightly wider than the body) — closed box. addBox(p, n, uv, hw + baseOvh, hd + baseOvh, 0, baseTop) + const baseEnd = p.length / 3 // Body — closed box; the louvers are applied as relief on its walls. addBox(p, n, uv, hw, hd, baseTop, bodyTop) + const bodyEnd = p.length / 3 // Cornice — overhanging slab the roof sits on. addBox(p, n, uv, hw + cornOvh, hd + cornOvh, bodyTop, corniceTop) + const corniceEnd = p.length / 3 // Louvered slats on all four body faces. addLouvers(p, n, uv, hw, hd, baseTop, bodyTop) + const louversEnd = p.length / 3 // Roof. const rhw = hw + cornOvh @@ -77,6 +93,12 @@ export function buildCupolaGeometry(node: CupolaNode): THREE.BufferGeometry { geo.setAttribute('position', new THREE.Float32BufferAttribute(p, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(n, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)) + geo.addGroup(0, baseEnd, CUPOLA_MATERIAL_INDEX.base) + geo.addGroup(baseEnd, bodyEnd - baseEnd, CUPOLA_MATERIAL_INDEX.body) + geo.addGroup(bodyEnd, corniceEnd - bodyEnd, CUPOLA_MATERIAL_INDEX.roof) + geo.addGroup(corniceEnd, louversEnd - corniceEnd, CUPOLA_MATERIAL_INDEX.body) + geo.addGroup(louversEnd, p.length / 3 - louversEnd, CUPOLA_MATERIAL_INDEX.roof) + copyUvToSecondaryChannel(geo) geo.computeBoundingSphere() return geo } @@ -191,18 +213,36 @@ function addDomeRoof( const lng = 20 const lat = 6 let prev = ringAt(rx, rz, y0, lng) + let prevU = cumulativeProfileDistances(prev) + let domeV = 0 for (let i = 1; i <= lat; i++) { const phi = (Math.PI / 2) * (i / lat) const rf = Math.cos(phi) const y = y0 + domeH * Math.sin(phi) const ring = ringAt(rx * rf, rz * rf, y, lng) - addBand(p, n, uv, prev, ring, lng, (a, _b, c) => { - const x = (a[0]! + c[0]!) / 2 - const yy = (a[1]! + c[1]!) / 2 - y0 - const z = (a[2]! + c[2]!) / 2 - return [x, yy, z] - }) + const ringU = cumulativeProfileDistances(ring) + const nextV = domeV + averageProfileDistance(prev, ring) + addBand( + p, + n, + uv, + prev, + ring, + lng, + (a, _b, c) => { + const x = (a[0]! + c[0]!) / 2 + const yy = (a[1]! + c[1]!) / 2 - y0 + const z = (a[2]! + c[2]!) / 2 + return [x, yy, z] + }, + prevU, + ringU, + domeV, + nextV, + ) prev = ring + prevU = ringU + domeV = nextV } } @@ -219,29 +259,60 @@ function addCylinder( const lng = 12 const bottom = ringAt(r, r, y0, lng) const top = ringAt(r, r, y1, lng) - addBand(p, n, uv, bottom, top, lng, (a, _b, c) => { - const x = (a[0]! + c[0]!) / 2 - const z = (a[2]! + c[2]!) / 2 - return [x, 0, z] - }) + const ringU = cumulativeProfileDistances(bottom) + addBand( + p, + n, + uv, + bottom, + top, + lng, + (a, _b, c) => { + const x = (a[0]! + c[0]!) / 2 + const z = (a[2]! + c[2]!) / 2 + return [x, 0, z] + }, + ringU, + ringU, + y0, + y1, + ) } function addSphere(p: number[], n: number[], uv: number[], r: number, cy: number): void { const lng = 14 const lat = 8 let prev = ringAt(0, 0, cy - r, lng) + let prevU = cumulativeProfileDistances(prev) + let sphereV = 0 for (let i = 1; i <= lat; i++) { const theta = Math.PI * (i / lat) - Math.PI / 2 const ry = r * Math.sin(theta) const rr = r * Math.cos(theta) const ring = ringAt(rr, rr, cy + ry, lng) - addBand(p, n, uv, prev, ring, lng, (a, _b, c) => { - const x = (a[0]! + c[0]!) / 2 - const yy = (a[1]! + c[1]!) / 2 - cy - const z = (a[2]! + c[2]!) / 2 - return [x, yy, z] - }) + const ringU = cumulativeProfileDistances(ring) + const nextV = sphereV + averageProfileDistance(prev, ring) + addBand( + p, + n, + uv, + prev, + ring, + lng, + (a, _b, c) => { + const x = (a[0]! + c[0]!) / 2 + const yy = (a[1]! + c[1]!) / 2 - cy + const z = (a[2]! + c[2]!) / 2 + return [x, yy, z] + }, + prevU, + ringU, + sphereV, + nextV, + ) prev = ring + prevU = ringU + sphereV = nextV } } @@ -264,14 +335,35 @@ function addBand( rB: number[][], lng: number, hintFn: (a: number[], b: number[], c: number[], d: number[]) => number[], + uA = cumulativeProfileDistances(rA), + uB = cumulativeProfileDistances(rB), + vA = 0, + vB = averageProfileDistance(rA, rB), ): void { for (let j = 0; j < lng; j++) { const a = rA[j]! const b = rA[j + 1]! const c = rB[j + 1]! const d = rB[j]! - pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d)) + pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d), [ + [uA[j]!, vA], + [uA[j + 1]!, vA], + [uB[j + 1]!, vB], + [uB[j]!, vB], + ]) + } +} + +function averageProfileDistance(a: number[][], b: number[][]): number { + let total = 0 + for (let index = 0; index < a.length; index += 1) { + total += Math.hypot( + b[index]![0]! - a[index]![0]!, + b[index]![1]! - a[index]![1]!, + b[index]![2]! - a[index]![2]!, + ) } + return total / a.length } function sub(a: number[], b: number[]): number[] { @@ -289,6 +381,7 @@ function pushQuad( c: number[], d: number[], hint: number[], + authoredUvs?: readonly MetricUv[], ) { let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!) let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!) @@ -304,19 +397,19 @@ function pushQuad( ny /= len nz /= len - const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!) - const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!) + const faceUvs = authoredUvs ?? planarMetricUvs([a, b, c, d], [nx, ny, nz]) + const [uvA, uvB, uvC, uvD] = faceUvs as readonly [MetricUv, MetricUv, MetricUv, MetricUv] if (flip) { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, u, 0, u, v) + uvs.push(...uvA, ...uvB, ...uvC) positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!) - uvs.push(0, 0, u, v, 0, v) + uvs.push(...uvA, ...uvC, ...uvD) } else { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) - uvs.push(0, 0, u, v, u, 0) + uvs.push(...uvA, ...uvC, ...uvB) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, 0, v, u, v) + uvs.push(...uvA, ...uvD, ...uvC) } for (let i = 0; i < 6; i++) normals.push(nx, ny, nz) } @@ -344,11 +437,16 @@ function pushTri( ny /= len nz /= len + const faceUvs = planarMetricUvs([a, b, c], [nx, ny, nz]) + const uvA = faceUvs[0]! + const uvB = faceUvs[1]! + const uvC = faceUvs[2]! if (flip) { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) + uvs.push(...uvA, ...uvC, ...uvB) } else { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) + uvs.push(...uvA, ...uvB, ...uvC) } - uvs.push(0, 0, 1, 0, 0, 1) for (let i = 0; i < 3; i++) normals.push(nx, ny, nz) } diff --git a/packages/nodes/src/cupola/paint.ts b/packages/nodes/src/cupola/paint.ts new file mode 100644 index 0000000000..fb3e469188 --- /dev/null +++ b/packages/nodes/src/cupola/paint.ts @@ -0,0 +1,78 @@ +import type { + CupolaMaterialRole, + CupolaNode, + MaterialSchema, + PaintCapability, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' +import type { Material, Mesh, Object3D } from 'three' +import { CUPOLA_MATERIAL_INDEX } from './geometry' + +export function resolveCupolaMaterialRole(materialIndex: number | null): CupolaMaterialRole { + if (materialIndex === CUPOLA_MATERIAL_INDEX.body) return 'body' + if (materialIndex === CUPOLA_MATERIAL_INDEX.roof) return 'roof' + return 'base' +} + +export function buildCupolaMaterialPatch( + role: CupolaMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + if (role === 'body') return { bodyMaterial: material, bodyMaterialPreset: materialPreset } + if (role === 'roof') return { roofMaterial: material, roofMaterialPreset: materialPreset } + return { baseMaterial: material, baseMaterialPreset: materialPreset } +} + +export function getEffectiveCupolaMaterial( + node: CupolaNode, + role: CupolaMaterialRole, +): { material: MaterialSchema | undefined; materialPreset: string | undefined } { + const material = + role === 'body' ? node.bodyMaterial : role === 'roof' ? node.roofMaterial : node.baseMaterial + const materialPreset = + role === 'body' + ? node.bodyMaterialPreset + : role === 'roof' + ? node.roofMaterialPreset + : node.baseMaterialPreset + return material !== undefined || materialPreset !== undefined + ? { material, materialPreset } + : { material: node.material, materialPreset: node.materialPreset } +} + +function previewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + if (materialPreset) return createMaterialFromPresetRef(materialPreset) + if (material) return createMaterial(material) + return null +} + +export const cupolaPaint: PaintCapability = { + materialTarget: 'cupola', + resolveRole: ({ materialIndex }) => resolveCupolaMaterialRole(materialIndex), + buildPatch: ({ role, material, materialPreset }) => + buildCupolaMaterialPatch(role as CupolaMaterialRole, material, materialPreset), + applyPreview: ({ role, material, materialPreset, root }) => { + const preview = previewMaterial(material, materialPreset) + if (!preview) return null + const index = CUPOLA_MATERIAL_INDEX[role as CupolaMaterialRole] + let restore: (() => void) | null = null + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.name !== 'cupola-surface' || !Array.isArray(mesh.material)) return + const previous = [...mesh.material] + const next = [...previous] + next[index] = preview + mesh.material = next + restore = () => { + mesh.material = previous + } + }) + return restore + }, + getEffectiveMaterial: ({ node, role }) => + getEffectiveCupolaMaterial(node as CupolaNode, role as CupolaMaterialRole), +} diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index 71251ae76b..b0bafb8361 100644 --- a/packages/nodes/src/cupola/renderer.tsx +++ b/packages/nodes/src/cupola/renderer.tsx @@ -21,6 +21,7 @@ import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildCupolaGeometry } from './geometry' +import { getEffectiveCupolaMaterial } from './paint' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, @@ -67,14 +68,48 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) + const { material: baseMaterial, materialPreset: baseMaterialPreset } = getEffectiveCupolaMaterial( + node, + 'base', + ) + const { material: bodyMaterial, materialPreset: bodyMaterialPreset } = getEffectiveCupolaMaterial( + node, + 'body', + ) + const { material: roofMaterial, materialPreset: roofMaterialPreset } = getEffectiveCupolaMaterial( + node, + 'roof', + ) const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const resolve = ( + roleMaterial: CupolaNode['material'], + roleMaterialPreset: string | undefined, + ) => { + if (!textures) return roleDefault + if (roleMaterial) return createMaterial(roleMaterial, shading) + if (roleMaterialPreset) { + return createMaterialFromPresetRef(roleMaterialPreset, shading) ?? defaultMaterial + } + return roleDefault } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + return [ + resolve(baseMaterial, baseMaterialPreset), + resolve(bodyMaterial, bodyMaterialPreset), + resolve(roofMaterial, roofMaterialPreset), + ] + }, [ + textures, + colorPreset, + sceneTheme, + shading, + baseMaterial, + baseMaterialPreset, + bodyMaterial, + bodyMaterialPreset, + roofMaterial, + roofMaterialPreset, + ]) const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), []) const composedQuat = useMemo(() => { diff --git a/packages/nodes/src/downspout/definition.test.ts b/packages/nodes/src/downspout/definition.test.ts new file mode 100644 index 0000000000..cdcbd2ceba --- /dev/null +++ b/packages/nodes/src/downspout/definition.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { DownspoutNode } from '@pascal-app/core' +import { downspoutDefinition } from './definition' + +describe('downspout paint capability', () => { + test('paints the complete downspout as one surface', () => { + const node = DownspoutNode.parse({ id: 'downspout_test', type: 'downspout' }) + const paint = downspoutDefinition.capabilities.paint + + expect(paint?.materialTarget).toBe('downspout') + expect(paint?.resolveRole({ node, materialIndex: null })).toBe('surface') + expect( + paint?.buildPatch({ + node, + role: 'surface', + material: undefined, + materialPreset: 'library:metal-steel', + }), + ).toEqual({ + material: undefined, + materialPreset: 'library:metal-steel', + }) + }) +}) diff --git a/packages/nodes/src/downspout/definition.ts b/packages/nodes/src/downspout/definition.ts index 0af06ccdd4..7b39b72249 100644 --- a/packages/nodes/src/downspout/definition.ts +++ b/packages/nodes/src/downspout/definition.ts @@ -9,6 +9,7 @@ import { useLiveNodeOverrides, useScene, } from '@pascal-app/core' +import { surfacePaintCapability } from '../shared/surface-paint' import { downspoutParametrics } from './parametrics' import { computeDownspoutPath, @@ -179,6 +180,7 @@ export const downspoutDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + paint: { ...surfacePaintCapability, materialTarget: 'downspout' }, // Logically a roof accessory — registers under the segment, has // no buildCut, just the standard dirty cascade. roofAccessory: {}, diff --git a/packages/nodes/src/downspout/geometry.test.ts b/packages/nodes/src/downspout/geometry.test.ts new file mode 100644 index 0000000000..94e385c249 --- /dev/null +++ b/packages/nodes/src/downspout/geometry.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { DownspoutNode } from '@pascal-app/core' +import { buildDownspoutGeometry } from './geometry' + +describe('downspout geometry', () => { + test('preserves metre scale along a straight run', () => { + const geometry = buildDownspoutGeometry( + DownspoutNode.parse({ + id: 'downspout_uv', + type: 'downspout', + length: 3, + shape: 'rect', + strapStyle: 'none', + terminal: 'straight', + }), + ) + const uv = geometry.getAttribute('uv') + expect(geometry.getAttribute('uv2').count).toBe(uv.count) + const values = Array.from({ length: uv.count }, (_, index) => [ + uv.getX(index), + uv.getY(index), + ]).flat() + + expect(Math.max(...values) - Math.min(...values)).toBeGreaterThanOrEqual(2.9) + }) +}) diff --git a/packages/nodes/src/downspout/geometry.ts b/packages/nodes/src/downspout/geometry.ts index aa5f563296..f4a85e057b 100644 --- a/packages/nodes/src/downspout/geometry.ts +++ b/packages/nodes/src/downspout/geometry.ts @@ -2,6 +2,12 @@ import type { DownspoutNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import type { OutletDims } from '../gutter/profile-geometry' +import { + applyCylinderWorldUvs, + applyPlanarWorldUvs, + applySphereWorldUvs, + copyUvToSecondaryChannel, +} from '../shared/primitive-uv' import { computeDownspoutPath, type DownspoutPath, @@ -97,6 +103,7 @@ export function buildDownspoutGeometry( for (const p of pieces) p.dispose() } merged.computeVertexNormals() + copyUvToSecondaryChannel(merged) return merged } @@ -117,6 +124,8 @@ function segmentBetween( dims.shape === 'round' ? new THREE.CylinderGeometry(dims.halfX, dims.halfX, len, RADIAL_SEGMENTS).toNonIndexed() : new THREE.BoxGeometry(2 * dims.halfX, len, 2 * dims.halfZ).toNonIndexed() + if (dims.shape === 'round') applyCylinderWorldUvs(geo, dims.halfX, len) + else applyPlanarWorldUvs(geo) // The primitive runs along +Y centred at origin; rotate +Y onto the // segment direction, then drop it on the midpoint. geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, dir.normalize())) @@ -181,6 +190,7 @@ function jointAt( ): THREE.BufferGeometry { if (dims.shape === 'round') { const geo = new THREE.SphereGeometry(dims.halfX, JOINT_SEGMENTS, JOINT_SEGMENTS).toNonIndexed() + applySphereWorldUvs(geo, dims.halfX) geo.translate(p.x, p.y, p.z) return geo } @@ -190,6 +200,7 @@ function jointAt( if (bis.lengthSq() < 1e-8) bis.copy(dirOut) // straight-through; degenerate bis.normalize() const geo = new THREE.BoxGeometry(2 * dims.halfX, 2 * dims.halfZ, 2 * dims.halfZ).toNonIndexed() + applyPlanarWorldUvs(geo) geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, bis)) geo.translate(p.x, p.y, p.z) return geo @@ -221,6 +232,7 @@ function buildStraps( for (let i = 0; i < count; i++) { const y = count > 1 ? top - STRAP_END_MARGIN - i * stride : (top + bottom) / 2 const band = new THREE.BoxGeometry(w, STRAP_THICKNESS, d).toNonIndexed() + applyPlanarWorldUvs(band) band.translate(0, y, z) straps.push(band) } @@ -234,6 +246,7 @@ function buildStraps( function buildSplash(path: DownspoutPath): THREE.BufferGeometry | null { const [bx, by, bz] = path.bottom const slab = new THREE.BoxGeometry(SPLASH_WIDTH, SPLASH_THICKNESS, SPLASH_LENGTH).toNonIndexed() + applyPlanarWorldUvs(slab) // Tilt the far (+Z) end down so it slopes away from the wall. slab.rotateX(SPLASH_TILT) slab.translate(bx, by - SPLASH_THICKNESS / 2, bz + SPLASH_LENGTH / 2) diff --git a/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts b/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts index 59d61e3184..fe7cc3c152 100644 --- a/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/eyebrow-vent/__tests__/geometry.test.ts @@ -17,6 +17,9 @@ describe('buildEyebrowVentGeometry', () => { expect(p.count).toBeGreaterThan(0) expect(geo.getAttribute('normal').count).toBe(p.count) expect(geo.getAttribute('uv').count).toBe(p.count) + expect(geo.getAttribute('uv2').count).toBe(p.count) + expect(new Set(geo.groups.map((group) => group.materialIndex))).toEqual(new Set([0, 1])) + expect(geo.groups.reduce((count, group) => count + group.count, 0)).toBe(p.count) expect(allFinite(geo)).toBe(true) }) @@ -25,9 +28,22 @@ describe('buildEyebrowVentGeometry', () => { const geo = buildEyebrowVentGeometry(EyebrowVentNode.parse({ style })) expect(geo.getAttribute('position').count).toBeGreaterThan(0) expect(allFinite(geo)).toBe(true) + if (style === 'slant-box') { + expect(new Set(geo.groups.map((group) => group.materialIndex))).toEqual(new Set([0, 1])) + } } }) + test('unwraps the curved hood continuously at metre scale', () => { + const geo = buildEyebrowVentGeometry( + EyebrowVentNode.parse({ width: 2, depth: 3, height: 1, style: 'half-round' }), + ) + const uv = geo.getAttribute('uv') + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + + expect(Math.max(...u) - Math.min(...u)).toBeGreaterThan(3) + }) + test('louvers add vertices', () => { const withLouvers = buildEyebrowVentGeometry( EyebrowVentNode.parse({ louverCount: 4 }), diff --git a/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts b/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts new file mode 100644 index 0000000000..741b450e61 --- /dev/null +++ b/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test' +import { + buildEyebrowVentMaterialPatch, + getEffectiveEyebrowVentMaterial, + resolveEyebrowVentMaterialRole, +} from '../paint' +import { EyebrowVentNode } from '../schema' + +describe('eyebrow vent paint', () => { + test('maps geometry groups to hood and front', () => { + expect(resolveEyebrowVentMaterialRole(0)).toBe('hood') + expect(resolveEyebrowVentMaterialRole(1)).toBe('front') + }) + + test('updates only the selected construction part', () => { + expect(buildEyebrowVentMaterialPatch('front', undefined, 'library:louver')).toEqual({ + frontMaterial: undefined, + frontMaterialPreset: 'library:louver', + }) + }) + + test('uses the legacy material only for roles without an override', () => { + const node = EyebrowVentNode.parse({ hoodMaterialPreset: 'library:metal' }) + expect(getEffectiveEyebrowVentMaterial(node, 'hood').materialPreset).toBe('library:metal') + expect(getEffectiveEyebrowVentMaterial(node, 'front').materialPreset).toBe('preset-white') + }) +}) diff --git a/packages/nodes/src/eyebrow-vent/definition.ts b/packages/nodes/src/eyebrow-vent/definition.ts index 2bd1e97eda..eb218af9ca 100644 --- a/packages/nodes/src/eyebrow-vent/definition.ts +++ b/packages/nodes/src/eyebrow-vent/definition.ts @@ -4,8 +4,8 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' -import { surfacePaintCapability } from '../shared/surface-paint' import { buildEyebrowVentFloorplan } from './floorplan' +import { eyebrowVentPaint } from './paint' import { eyebrowVentParametrics } from './parametrics' import { EyebrowVentNode } from './schema' @@ -112,7 +112,7 @@ const eyebrowVentHandles: HandleDescriptor[] = [ */ export const eyebrowVentDefinition: NodeDefinition = { kind: 'eyebrow-vent', - schemaVersion: 1, + schemaVersion: 2, schema: EyebrowVentNode, category: 'structure', surfaceRole: 'roof', @@ -130,8 +130,7 @@ export const eyebrowVentDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, - // Single painted surface — registry-driven paint dispatch (see chimney). - paint: surfacePaintCapability, + paint: eyebrowVentPaint, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the slope — // no `buildCut`, just the dirty cascade so the parent roof's merged shell // rebuilds when the vent moves / resizes. diff --git a/packages/nodes/src/eyebrow-vent/geometry.ts b/packages/nodes/src/eyebrow-vent/geometry.ts index 72462046db..a3f8112f99 100644 --- a/packages/nodes/src/eyebrow-vent/geometry.ts +++ b/packages/nodes/src/eyebrow-vent/geometry.ts @@ -1,5 +1,16 @@ import type { EyebrowVentNode } from '@pascal-app/core' import * as THREE from 'three' +import { + copyUvToSecondaryChannel, + cumulativeProfileDistances, + type MetricUv, + planarMetricUvs, +} from '../shared/primitive-uv' + +export const EYEBROW_VENT_MATERIAL_INDEX = { + hood: 0, + front: 1, +} as const /** * Pure builder for the eyebrow-vent mesh. Three styles, all seated directly on @@ -35,12 +46,13 @@ export function buildEyebrowVentGeometry(node: EyebrowVentNode): THREE.BufferGeo const uv: number[] = [] // The hood seats directly on the roof at y=0 — no flashing plate. + let frontStart: number if (node.style === 'half-round') { - addHalfRound(p, n, uv, w, d, h, 0, slats) + frontStart = addHalfRound(p, n, uv, w, d, h, 0, slats) } else if (node.style === 'slant-box') { - addSlantBox(p, n, uv, w, d, h, 0, slats, backRatio) + frontStart = addSlantBox(p, n, uv, w, d, h, 0, slats, backRatio) } else { - addScoop(p, n, uv, w, d, h, 0, slats) + frontStart = addScoop(p, n, uv, w, d, h, 0, slats) } // Double-side the whole mesh at the geometry level: append a back-facing @@ -50,12 +62,22 @@ export function buildEyebrowVentGeometry(node: EyebrowVentNode): THREE.BufferGeo // material, which poisons the MRT scene pass (see the ridge-vent renderer // note). Only one of each coplanar pair front-faces any camera, so there's // no z-fighting. + const frontEnd = p.length / 3 doubleSide(p, n, uv) const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(p, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(n, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)) + geo.addGroup(0, frontStart, EYEBROW_VENT_MATERIAL_INDEX.hood) + if (frontEnd > frontStart) { + geo.addGroup(frontStart, frontEnd - frontStart, EYEBROW_VENT_MATERIAL_INDEX.front) + } + geo.addGroup(frontEnd, frontStart, EYEBROW_VENT_MATERIAL_INDEX.hood) + if (frontEnd > frontStart) { + geo.addGroup(frontEnd + frontStart, frontEnd - frontStart, EYEBROW_VENT_MATERIAL_INDEX.front) + } + copyUvToSecondaryChannel(geo) geo.computeBoundingSphere() return geo } @@ -71,7 +93,7 @@ function addScoop( h: number, yB: number, slats: number, -): void { +): number { const a = w / 2 const b = h const zF = d / 2 @@ -87,16 +109,35 @@ function addScoop( const z = zF - v * d rings.push(halfRing(a, b, yB, z, scale, NF)) } + const ringUvs = rings.map((ring) => cumulativeProfileDistances(ring)) + const ringV = [0] + for (let i = 1; i <= NZ; i++) { + ringV.push(ringV[i - 1]! + averageProfileDistance(rings[i - 1]!, rings[i]!)) + } for (let i = 0; i < NZ; i++) { - addBand(p, n, uv, rings[i + 1]!, rings[i]!, NF, (qa, qb, qc, qd) => { - const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4 - const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4 - return [mx, my - yB, 0] // radial-out from the spine - }) + addBand( + p, + n, + uv, + rings[i + 1]!, + rings[i]!, + NF, + (qa, qb, qc, qd) => { + const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4 + const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4 + return [mx, my - yB, 0] // radial-out from the spine + }, + ringUvs[i + 1], + ringUvs[i], + ringV[i + 1], + ringV[i], + ) } // Horizontal louvers filling the front half-ellipse opening. + const frontStart = p.length / 3 addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats) + return frontStart } // ─── Style: half-round (D-shaped louver vent) ───────────────────────────── @@ -110,7 +151,7 @@ function addHalfRound( h: number, yB: number, slats: number, -): void { +): number { const a = w / 2 // Cap the crown at a true half-round — never bulge past a semicircle, so the // top reads as a clean, smaller-radius arch. `height` flattens it further. @@ -121,13 +162,26 @@ function addHalfRound( const ringF = halfRing(a, b, yB, zF, 1, NF) const ringB = halfRing(a, b, yB, zB, 1, NF) + const ringU = cumulativeProfileDistances(ringF) // Curved top shell (constant cross section). - addBand(p, n, uv, ringB, ringF, NF, (qa, qb, qc, qd) => { - const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4 - const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4 - return [mx, my - yB, 0] - }) + addBand( + p, + n, + uv, + ringB, + ringF, + NF, + (qa, qb, qc, qd) => { + const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4 + const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4 + return [mx, my - yB, 0] + }, + ringU, + ringU, + d, + 0, + ) // Back cap — fan the rear semicircle, facing -Z. const backCenter = [0, yB, zB] @@ -136,7 +190,9 @@ function addHalfRound( } // Louvered front face (a slat count bumped up — the D-vent reads denser). + const frontStart = p.length / 3 addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats > 0 ? Math.max(slats, 4) : 0) + return frontStart } // ─── Style: slant-box (low hooded box) ──────────────────────────────────── @@ -151,7 +207,7 @@ function addSlantBox( yB: number, slats: number, backRatio: number, -): void { +): number { const hw = w / 2 const zF = d / 2 const zB = -d / 2 @@ -189,6 +245,7 @@ function addSlantBox( pushQuad(p, n, uv, [oR, oB, zF], [hw, oB, zF], [hw, oT, zF], [oR, oT, zF], [0, 0, 1]) // right // Recessed screen panel at the back of the pocket (blocks see-through). + const frontStart = p.length / 3 const screenZ = zF - d * 0.2 pushQuad( p, @@ -204,6 +261,7 @@ function addSlantBox( // Horizontal louvers inside the pocket — bounded by the opening in height // and recessed in depth between the frame face and the screen. addRectLouvers(p, n, uv, oR, oB, oT, zF - d * 0.07, slats) + return frontStart } // ─── Louver helpers ─────────────────────────────────────────────────────── @@ -354,14 +412,35 @@ function addBand( rB: number[][], lng: number, hintFn: (a: number[], b: number[], c: number[], d: number[]) => number[], + uA = cumulativeProfileDistances(rA), + uB = cumulativeProfileDistances(rB), + vA = 0, + vB = averageProfileDistance(rA, rB), ): void { for (let j = 0; j < lng; j++) { const a = rA[j]! const b = rA[j + 1]! const c = rB[j + 1]! const d = rB[j]! - pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d)) + pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d), [ + [uA[j]!, vA], + [uA[j + 1]!, vA], + [uB[j + 1]!, vB], + [uB[j]!, vB], + ]) + } +} + +function averageProfileDistance(a: number[][], b: number[][]): number { + let total = 0 + for (let index = 0; index < a.length; index += 1) { + total += Math.hypot( + b[index]![0]! - a[index]![0]!, + b[index]![1]! - a[index]![1]!, + b[index]![2]! - a[index]![2]!, + ) } + return total / a.length } // Append a reversed-winding, negated-normal copy of every triangle already in @@ -410,6 +489,7 @@ function pushQuad( c: number[], d: number[], hint: number[], + authoredUvs?: readonly MetricUv[], ) { let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!) let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!) @@ -425,19 +505,19 @@ function pushQuad( ny /= len nz /= len - const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!) - const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!) + const faceUvs = authoredUvs ?? planarMetricUvs([a, b, c, d], [nx, ny, nz]) + const [uvA, uvB, uvC, uvD] = faceUvs as readonly [MetricUv, MetricUv, MetricUv, MetricUv] if (flip) { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, u, 0, u, v) + uvs.push(...uvA, ...uvB, ...uvC) positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!) - uvs.push(0, 0, u, v, 0, v) + uvs.push(...uvA, ...uvC, ...uvD) } else { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) - uvs.push(0, 0, u, v, u, 0) + uvs.push(...uvA, ...uvC, ...uvB) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, 0, v, u, v) + uvs.push(...uvA, ...uvD, ...uvC) } for (let i = 0; i < 6; i++) normals.push(nx, ny, nz) } @@ -465,11 +545,16 @@ function pushTri( ny /= len nz /= len + const faceUvs = planarMetricUvs([a, b, c], [nx, ny, nz]) + const uvA = faceUvs[0]! + const uvB = faceUvs[1]! + const uvC = faceUvs[2]! if (flip) { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) + uvs.push(...uvA, ...uvC, ...uvB) } else { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) + uvs.push(...uvA, ...uvB, ...uvC) } - uvs.push(0, 0, 1, 0, 0, 1) for (let i = 0; i < 3; i++) normals.push(nx, ny, nz) } diff --git a/packages/nodes/src/eyebrow-vent/paint.ts b/packages/nodes/src/eyebrow-vent/paint.ts new file mode 100644 index 0000000000..f6d0ed8468 --- /dev/null +++ b/packages/nodes/src/eyebrow-vent/paint.ts @@ -0,0 +1,73 @@ +import type { + EyebrowVentMaterialRole, + EyebrowVentNode, + MaterialSchema, + PaintCapability, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' +import type { Material, Mesh, Object3D } from 'three' +import { EYEBROW_VENT_MATERIAL_INDEX } from './geometry' + +export function resolveEyebrowVentMaterialRole( + materialIndex: number | null, +): EyebrowVentMaterialRole { + return materialIndex === EYEBROW_VENT_MATERIAL_INDEX.front ? 'front' : 'hood' +} + +export function buildEyebrowVentMaterialPatch( + role: EyebrowVentMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + return role === 'front' + ? { frontMaterial: material, frontMaterialPreset: materialPreset } + : { hoodMaterial: material, hoodMaterialPreset: materialPreset } +} + +export function getEffectiveEyebrowVentMaterial( + node: EyebrowVentNode, + role: EyebrowVentMaterialRole, +): { material: MaterialSchema | undefined; materialPreset: string | undefined } { + const material = role === 'front' ? node.frontMaterial : node.hoodMaterial + const materialPreset = role === 'front' ? node.frontMaterialPreset : node.hoodMaterialPreset + return material !== undefined || materialPreset !== undefined + ? { material, materialPreset } + : { material: node.material, materialPreset: node.materialPreset } +} + +function previewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + if (materialPreset) return createMaterialFromPresetRef(materialPreset) + if (material) return createMaterial(material) + return null +} + +export const eyebrowVentPaint: PaintCapability = { + materialTarget: 'eyebrow-vent', + resolveRole: ({ materialIndex }) => resolveEyebrowVentMaterialRole(materialIndex), + buildPatch: ({ role, material, materialPreset }) => + buildEyebrowVentMaterialPatch(role as EyebrowVentMaterialRole, material, materialPreset), + applyPreview: ({ role, material, materialPreset, root }) => { + const preview = previewMaterial(material, materialPreset) + if (!preview) return null + const index = EYEBROW_VENT_MATERIAL_INDEX[role as EyebrowVentMaterialRole] + let restore: (() => void) | null = null + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.name !== 'eyebrow-vent-surface' || !Array.isArray(mesh.material)) + return + const previous = [...mesh.material] + const next = [...previous] + next[index] = preview + mesh.material = next + restore = () => { + mesh.material = previous + } + }) + return restore + }, + getEffectiveMaterial: ({ node, role }) => + getEffectiveEyebrowVentMaterial(node as EyebrowVentNode, role as EyebrowVentMaterialRole), +} diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index cf019e6c66..805df10ae9 100644 --- a/packages/nodes/src/eyebrow-vent/renderer.tsx +++ b/packages/nodes/src/eyebrow-vent/renderer.tsx @@ -21,6 +21,7 @@ import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildEyebrowVentGeometry } from './geometry' +import { getEffectiveEyebrowVentMaterial } from './paint' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, @@ -69,14 +70,34 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) + const { material: hoodMaterial, materialPreset: hoodMaterialPreset } = + getEffectiveEyebrowVentMaterial(node, 'hood') + const { material: frontMaterial, materialPreset: frontMaterialPreset } = + getEffectiveEyebrowVentMaterial(node, 'front') const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const resolve = ( + roleMaterial: EyebrowVentNode['material'], + roleMaterialPreset: string | undefined, + ) => { + if (!textures) return roleDefault + if (roleMaterial) return createMaterial(roleMaterial, shading) + if (roleMaterialPreset) { + return createMaterialFromPresetRef(roleMaterialPreset, shading) ?? defaultMaterial + } + return roleDefault } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + return [resolve(hoodMaterial, hoodMaterialPreset), resolve(frontMaterial, frontMaterialPreset)] + }, [ + textures, + colorPreset, + sceneTheme, + shading, + hoodMaterial, + hoodMaterialPreset, + frontMaterial, + frontMaterialPreset, + ]) const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), []) const composedQuat = useMemo(() => { diff --git a/packages/nodes/src/gutter/definition.test.ts b/packages/nodes/src/gutter/definition.test.ts new file mode 100644 index 0000000000..d733c779e4 --- /dev/null +++ b/packages/nodes/src/gutter/definition.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { GutterNode } from '@pascal-app/core' +import { gutterDefinition } from './definition' + +describe('gutter paint capability', () => { + test('paints the complete gutter as one surface', () => { + const node = GutterNode.parse({ id: 'gutter_test', type: 'gutter' }) + const paint = gutterDefinition.capabilities.paint + + expect(paint?.materialTarget).toBe('gutter') + expect( + paint?.resolveRole({ + node, + materialIndex: null, + }), + ).toBe('surface') + expect( + paint?.buildPatch({ + node, + role: 'surface', + material: undefined, + materialPreset: 'library:metal-steel', + }), + ).toEqual({ + material: undefined, + materialPreset: 'library:metal-steel', + }) + }) +}) diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index a1dfdb15c4..81eb62f6c4 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -4,6 +4,7 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' +import { surfacePaintCapability } from '../shared/surface-paint' import { buildGutterFloorplan } from './floorplan' import { snapLengthToCorner } from './length-snap' import { gutterParametrics } from './parametrics' @@ -159,6 +160,7 @@ export const gutterDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + paint: { ...surfacePaintCapability, materialTarget: 'gutter' }, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the // eave fascia — no `buildCut`, just the dirty cascade so the // parent roof's merged shell rebuilds when the gutter moves / diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index d0722694b7..906df1631f 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -8,6 +8,11 @@ import { } from '@pascal-app/viewer' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { + applyCylinderWorldUvs, + applyPlanarWorldUvs, + copyUvToSecondaryChannel, +} from '../shared/primitive-uv' import { type GutterMitres, NO_MITRES } from './corner-mitre' import { OUTLET_STUB_LENGTH, @@ -238,10 +243,14 @@ export function buildGutterGeometry( } const cutGeometry = csgGeometry(workingBrush) merged.dispose() - return bendGutterGeometryAlongArc(cutGeometry, node, mitres) + const finished = bendGutterGeometryAlongArc(cutGeometry, node, mitres) + copyUvToSecondaryChannel(finished) + return finished } - return bendGutterGeometryAlongArc(merged, node, mitres) + const finished = bendGutterGeometryAlongArc(merged, node, mitres) + copyUvToSecondaryChannel(finished) + return finished } function gutterArcSteps(node: GutterNode, length: number): number { @@ -610,6 +619,7 @@ function buildHangers( HANGER_BAR_THICKNESS, strapDepth, ).toNonIndexed() + applyPlanarWorldUvs(bar) // Center the bar at X = position, Y just above the rim line, Z // straddling 0 so the strap covers the full back-to-front span. bar.translate(x, HANGER_BAR_THICKNESS / 2 + 0.001, rimWidth / 2) @@ -683,14 +693,18 @@ function resolveOutletPlacements( /** Cylinder (round) or box (rect) sized to `dims`, height `h` along Y. */ function outletSolid(dims: OutletDims, h: number): THREE.BufferGeometry { if (dims.shape === 'round') { - return new THREE.CylinderGeometry( + const geometry = new THREE.CylinderGeometry( dims.halfX, dims.halfX, h, OUTLET_RADIAL_SEGMENTS, ).toNonIndexed() + applyCylinderWorldUvs(geometry, dims.halfX, h) + return geometry } - return new THREE.BoxGeometry(2 * dims.halfX, h, 2 * dims.halfZ).toNonIndexed() + const geometry = new THREE.BoxGeometry(2 * dims.halfX, h, 2 * dims.halfZ).toNonIndexed() + applyPlanarWorldUvs(geometry) + return geometry } /** @@ -719,12 +733,14 @@ function buildOutletFunnel(p: OutletPlacement, size: number): THREE.BufferGeomet OUTLET_FLARE_HEIGHT, OUTLET_RADIAL_SEGMENTS, ).toNonIndexed() + applyCylinderWorldUvs(funnel, p.outer.halfX * OUTLET_FLARE_SCALE, OUTLET_FLARE_HEIGHT) } else { funnel = new THREE.BoxGeometry( 2 * p.outer.halfX * OUTLET_FLARE_SCALE, OUTLET_FLARE_HEIGHT, 2 * p.outer.halfZ * OUTLET_FLARE_SCALE, ).toNonIndexed() + applyPlanarWorldUvs(funnel) } funnel.translate(p.x, centerY, p.z) return funnel diff --git a/packages/nodes/src/gutter/uv.test.ts b/packages/nodes/src/gutter/uv.test.ts new file mode 100644 index 0000000000..e96d994c05 --- /dev/null +++ b/packages/nodes/src/gutter/uv.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { GutterNode } from '@pascal-app/core' +import { buildGutterGeometry } from './geometry' + +describe('gutter UVs', () => { + test('preserves metre scale along the gutter run', () => { + const geometry = buildGutterGeometry( + GutterNode.parse({ + id: 'gutter_uv', + type: 'gutter', + length: 4, + hangerStyle: 'none', + }), + ) + const uv = geometry.getAttribute('uv') + expect(geometry.getAttribute('uv2').count).toBe(uv.count) + const values = Array.from({ length: uv.count }, (_, index) => [ + uv.getX(index), + uv.getY(index), + ]).flat() + + expect(Math.max(...values) - Math.min(...values)).toBeGreaterThanOrEqual(3.9) + }) +}) diff --git a/packages/nodes/src/shared/primitive-uv.test.ts b/packages/nodes/src/shared/primitive-uv.test.ts new file mode 100644 index 0000000000..a1077d8f70 --- /dev/null +++ b/packages/nodes/src/shared/primitive-uv.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test' +import { BoxGeometry, CylinderGeometry, SphereGeometry } from 'three' +import { + applyCylinderWorldUvs, + applyPlanarWorldUvs, + applySphereWorldUvs, + cumulativeProfileDistances, + planarMetricUvs, +} from './primitive-uv' + +function span(values: number[]): number { + return Math.max(...values) - Math.min(...values) +} + +describe('primitive world-scale UVs', () => { + test('measures sampled curved profiles in metres', () => { + expect( + cumulativeProfileDistances([ + [0, 0, 0], + [0.3, 0.4, 0], + [0.3, 0.4, 1], + ]), + ).toEqual([0, 0.5, 1.5]) + }) + + test('projects trapezoids at their physical size', () => { + expect( + planarMetricUvs( + [ + [0, 0, 0], + [2, 0, 0], + [1.5, 1, 0], + [0.5, 1, 0], + ], + [0, 0, 1], + ), + ).toEqual([ + [0, 0], + [2, 0], + [1.5, 1], + [0.5, 1], + ]) + }) + + test('maps an axis-aligned box in metres', () => { + const geometry = new BoxGeometry(2, 3, 4).toNonIndexed() + applyPlanarWorldUvs(geometry) + const position = geometry.getAttribute('position') + const uv = geometry.getAttribute('uv') + + for (let triangle = 0; triangle < position.count; triangle += 3) { + for (const [from, to] of [ + [0, 1], + [1, 2], + [2, 0], + ] as const) { + const a = triangle + from + const b = triangle + to + const worldLength = Math.hypot( + position.getX(b) - position.getX(a), + position.getY(b) - position.getY(a), + position.getZ(b) - position.getZ(a), + ) + const uvLength = Math.hypot(uv.getX(b) - uv.getX(a), uv.getY(b) - uv.getY(a)) + expect(uvLength).toBeCloseTo(worldLength) + } + } + }) + + test('unwraps cylinder sides by circumference and height', () => { + const radius = 0.5 + const height = 3 + const geometry = new CylinderGeometry(radius, radius, height, 16).toNonIndexed() + applyCylinderWorldUvs(geometry, radius, height) + const normal = geometry.getAttribute('normal') + const uv = geometry.getAttribute('uv') + const sideU: number[] = [] + const sideV: number[] = [] + for (let index = 0; index < normal.count; index += 1) { + if (Math.abs(normal.getY(index)) >= 0.5) continue + sideU.push(uv.getX(index)) + sideV.push(uv.getY(index)) + } + + expect(span(sideU)).toBeCloseTo(Math.PI * 2 * radius) + expect(span(sideV)).toBeCloseTo(height) + }) + + test('unwraps a sphere by circumference and pole distance', () => { + const radius = 0.5 + const geometry = new SphereGeometry(radius, 12, 8).toNonIndexed() + applySphereWorldUvs(geometry, radius) + const uv = geometry.getAttribute('uv') + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + const v = Array.from({ length: uv.count }, (_, index) => uv.getY(index)) + + expect(span(u)).toBeCloseTo(Math.PI * 2 * radius) + expect(span(v)).toBeCloseTo(Math.PI * radius) + }) +}) diff --git a/packages/nodes/src/shared/primitive-uv.ts b/packages/nodes/src/shared/primitive-uv.ts new file mode 100644 index 0000000000..182e690274 --- /dev/null +++ b/packages/nodes/src/shared/primitive-uv.ts @@ -0,0 +1,133 @@ +import * as THREE from 'three' + +export type MetricUv = readonly [number, number] + +type Point3 = readonly [number, number, number] | number[] + +/** Return cumulative metre distances along a sampled open or closed profile. */ +export function cumulativeProfileDistances(points: readonly Point3[]): number[] { + const distances = [0] + for (let index = 1; index < points.length; index += 1) { + const previous = points[index - 1]! + const current = points[index]! + distances.push( + distances[index - 1]! + + Math.hypot( + current[0]! - previous[0]!, + current[1]! - previous[1]!, + current[2]! - previous[2]!, + ), + ) + } + return distances +} + +/** Project a flat polygon into metre-scaled UV coordinates without shearing trapezoids. */ +export function planarMetricUvs( + points: readonly Point3[], + normal: Point3, + uOffset = 0, + vOffset = 0, +): MetricUv[] { + const origin = points[0]! + const uTarget = points[1]! + const ux = uTarget[0]! - origin[0]! + const uy = uTarget[1]! - origin[1]! + const uz = uTarget[2]! - origin[2]! + const uLength = Math.hypot(ux, uy, uz) || 1 + const unitU = [ux / uLength, uy / uLength, uz / uLength] + const normalLength = Math.hypot(normal[0]!, normal[1]!, normal[2]!) || 1 + const unitNormal = [ + normal[0]! / normalLength, + normal[1]! / normalLength, + normal[2]! / normalLength, + ] + const unitV = [ + unitNormal[1]! * unitU[2]! - unitNormal[2]! * unitU[1]!, + unitNormal[2]! * unitU[0]! - unitNormal[0]! * unitU[2]!, + unitNormal[0]! * unitU[1]! - unitNormal[1]! * unitU[0]!, + ] + + return points.map((point) => { + const x = point[0]! - origin[0]! + const y = point[1]! - origin[1]! + const z = point[2]! - origin[2]! + return [ + uOffset + x * unitU[0]! + y * unitU[1]! + z * unitU[2]!, + vOffset + x * unitV[0]! + y * unitV[1]! + z * unitV[2]!, + ] as const + }) +} + +/** Reuse the authored unwrap for AO and light maps, which read texture channel 2. */ +export function copyUvToSecondaryChannel(geometry: THREE.BufferGeometry): void { + const uv = geometry.getAttribute('uv') + if (uv) geometry.setAttribute('uv2', uv.clone()) +} + +/** Apply metre-scaled planar UVs to a non-indexed, axis-aligned primitive. */ +export function applyPlanarWorldUvs(geometry: THREE.BufferGeometry): void { + const position = geometry.getAttribute('position') + const normal = geometry.getAttribute('normal') + const uvs = new Float32Array(position.count * 2) + + for (let triangle = 0; triangle < position.count; triangle += 3) { + const nx = Math.abs(normal.getX(triangle)) + const ny = Math.abs(normal.getY(triangle)) + const nz = Math.abs(normal.getZ(triangle)) + for (let corner = 0; corner < 3; corner += 1) { + const index = triangle + corner + const x = position.getX(index) + const y = position.getY(index) + const z = position.getZ(index) + if (ny >= nx && ny >= nz) { + uvs[index * 2] = x + uvs[index * 2 + 1] = z + } else if (nx >= nz) { + uvs[index * 2] = z + uvs[index * 2 + 1] = y + } else { + uvs[index * 2] = x + uvs[index * 2 + 1] = y + } + } + } + + geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)) +} + +/** Scale cylinder/cone side UVs by circumference and height; caps use XZ metres. */ +export function applyCylinderWorldUvs( + geometry: THREE.BufferGeometry, + radius: number, + height: number, +): void { + const position = geometry.getAttribute('position') + const normal = geometry.getAttribute('normal') + const sourceUv = geometry.getAttribute('uv') + const uvs = new Float32Array(position.count * 2) + const circumference = Math.PI * 2 * radius + + for (let index = 0; index < position.count; index += 1) { + if (Math.abs(normal.getY(index)) < 0.5) { + uvs[index * 2] = sourceUv.getX(index) * circumference + uvs[index * 2 + 1] = (sourceUv.getY(index) - 0.5) * height + } else { + uvs[index * 2] = position.getX(index) + uvs[index * 2 + 1] = position.getZ(index) + } + } + + geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)) +} + +/** Scale a sphere's equirectangular UVs to its circumference and pole distance. */ +export function applySphereWorldUvs(geometry: THREE.BufferGeometry, radius: number): void { + const sourceUv = geometry.getAttribute('uv') + const uvs = new Float32Array(sourceUv.count * 2) + for (let index = 0; index < sourceUv.count; index += 1) { + uvs[index * 2] = sourceUv.getX(index) * Math.PI * 2 * radius + uvs[index * 2 + 1] = sourceUv.getY(index) * Math.PI * radius + } + geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)) +} diff --git a/packages/nodes/src/shared/surface-paint.ts b/packages/nodes/src/shared/surface-paint.ts index cc97dffef4..9770a2c098 100644 --- a/packages/nodes/src/shared/surface-paint.ts +++ b/packages/nodes/src/shared/surface-paint.ts @@ -4,10 +4,9 @@ import type { Material, Mesh, Object3D } from 'three' /** * Paint capability for kinds with a single painted surface (`role: 'surface'`) - * that register a `` of meshes all sharing one material — the roof - * vents (box / ridge / turbine / cupola / eyebrow). Replaces the editor's - * hardcoded `node.type === ''` paint arms with registry-driven dispatch, - * the same way chimney / dormer / wall declare their own `paint` capability. + * that register a mesh or group whose children all share one material. Used by + * ridge vents, gutters, and downspouts. Multi-part roof accessories declare + * role-aware paint capabilities beside their geometry instead. */ type SurfaceNode = AnyNode & { diff --git a/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts b/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts index 62337275fb..d99a3bc7e3 100644 --- a/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/turbine-vent/__tests__/geometry.test.ts @@ -19,6 +19,7 @@ describe('turbine vent geometry', () => { expect(positions.count).toBeGreaterThan(0) expect(normals.count).toBe(positions.count) expect(uvs.count).toBe(positions.count) + expect(geo.getAttribute('uv2').count).toBe(positions.count) }) test('base and head both produce finite, non-empty geometry', () => { @@ -31,6 +32,16 @@ describe('turbine vent geometry', () => { expect(allFinite(head)).toBe(true) }) + test('unwraps the circular base continuously at metre scale', () => { + const base = buildTurbineVentBase( + TurbineVentNode.parse({ diameter: 2, baseOverhang: 0.1, neckHeight: 0.5, height: 2 }), + ) + const uv = base.getAttribute('uv') + const u = Array.from({ length: uv.count }, (_, index) => uv.getX(index)) + + expect(Math.max(...u) - Math.min(...u)).toBeGreaterThan(6.5) + }) + test('both styles build finite geometry', () => { for (const style of ['globe', 'cylinder'] as const) { const geo = buildTurbineVentGeometry(TurbineVentNode.parse({ style })) diff --git a/packages/nodes/src/turbine-vent/__tests__/paint.test.ts b/packages/nodes/src/turbine-vent/__tests__/paint.test.ts new file mode 100644 index 0000000000..33d1758a5e --- /dev/null +++ b/packages/nodes/src/turbine-vent/__tests__/paint.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' +import { Group, Mesh, MeshBasicMaterial } from 'three' +import { + buildTurbineVentMaterialPatch, + getEffectiveTurbineVentMaterial, + resolveTurbineVentMaterialRole, + turbineVentPaint, +} from '../paint' +import { TurbineVentNode } from '../schema' + +describe('turbine vent paint', () => { + test('maps the fixed and spinning meshes to separate roles', () => { + expect(resolveTurbineVentMaterialRole('turbine-vent-base')).toBe('base') + expect(resolveTurbineVentMaterialRole('turbine-vent-head')).toBe('head') + }) + + test('updates one role and falls back to the legacy whole-vent material', () => { + expect(buildTurbineVentMaterialPatch('head', undefined, 'library:copper')).toEqual({ + headMaterial: undefined, + headMaterialPreset: 'library:copper', + }) + const node = TurbineVentNode.parse({ baseMaterialPreset: 'library:steel' }) + expect(getEffectiveTurbineVentMaterial(node, 'base').materialPreset).toBe('library:steel') + expect(getEffectiveTurbineVentMaterial(node, 'head').materialPreset).toBe('preset-white') + }) + + test('previews only the selected mesh', () => { + const baseMaterial = new MeshBasicMaterial() + const headMaterial = new MeshBasicMaterial() + const base = new Mesh(undefined, baseMaterial) + const head = new Mesh(undefined, headMaterial) + base.name = 'turbine-vent-base' + head.name = 'turbine-vent-head' + const root = new Group() + root.add(base, head) + const restore = turbineVentPaint.applyPreview({ + node: TurbineVentNode.parse({}), + role: 'head', + material: { + preset: 'custom', + properties: { + color: '#123456', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + }, + materialPreset: undefined, + root, + }) + expect(base.material).toBe(baseMaterial) + expect(head.material).not.toBe(headMaterial) + restore?.() + expect(head.material).toBe(headMaterial) + }) +}) diff --git a/packages/nodes/src/turbine-vent/definition.ts b/packages/nodes/src/turbine-vent/definition.ts index 995d9618cc..324907f567 100644 --- a/packages/nodes/src/turbine-vent/definition.ts +++ b/packages/nodes/src/turbine-vent/definition.ts @@ -4,8 +4,8 @@ import { TurbineVentNode as TurbineVentNodeSchema, type TurbineVentNode as TurbineVentNodeType, } from '@pascal-app/core' -import { surfacePaintCapability } from '../shared/surface-paint' import { buildTurbineVentFloorplan } from './floorplan' +import { turbineVentPaint } from './paint' import { turbineVentParametrics } from './parametrics' import { TurbineVentNode } from './schema' @@ -81,7 +81,7 @@ const turbineVentHandles: HandleDescriptor[] = [ */ export const turbineVentDefinition: NodeDefinition = { kind: 'turbine-vent', - schemaVersion: 1, + schemaVersion: 2, schema: TurbineVentNode, category: 'structure', surfaceRole: 'roof', @@ -96,8 +96,7 @@ export const turbineVentDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, - // Single painted surface — registry-driven paint dispatch (see chimney). - paint: surfacePaintCapability, + paint: turbineVentPaint, // Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the // slope — no `buildCut`, just the dirty cascade so the parent roof's // merged shell rebuilds when the vent moves / resizes. diff --git a/packages/nodes/src/turbine-vent/geometry.ts b/packages/nodes/src/turbine-vent/geometry.ts index 579dbb6b7a..01edb0ec38 100644 --- a/packages/nodes/src/turbine-vent/geometry.ts +++ b/packages/nodes/src/turbine-vent/geometry.ts @@ -1,6 +1,12 @@ import type { TurbineVentNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { + copyUvToSecondaryChannel, + cumulativeProfileDistances, + type MetricUv, + planarMetricUvs, +} from '../shared/primitive-uv' /** * Pure builders for the turbine vent (whirlybird). The mesh is split into @@ -206,6 +212,11 @@ function cylinderWall( y1: number, segs: number, ): void { + const ring = Array.from({ length: segs + 1 }, (_, index) => { + const angle = (index / segs) * Math.PI * 2 + return [r * Math.cos(angle), y0, r * Math.sin(angle)] + }) + const ringU = cumulativeProfileDistances(ring) for (let i = 0; i < segs; i++) { const a = (i / segs) * Math.PI * 2 const b = ((i + 1) / segs) * Math.PI * 2 @@ -223,6 +234,12 @@ function cylinderWall( [r * cb, y1, r * sb], [r * ca, y1, r * sa], out, + [ + [ringU[i]!, y0], + [ringU[i + 1]!, y0], + [ringU[i + 1]!, y1], + [ringU[i]!, y1], + ], ) } } @@ -240,7 +257,13 @@ function disc( for (let i = 0; i < segs; i++) { const a = (i / segs) * Math.PI * 2 const b = ((i + 1) / segs) * Math.PI * 2 - pushTri(p, n, uv, center, polar(a, r, y), polar(b, r, y), hint) + const edgeA = polar(a, r, y) + const edgeB = polar(b, r, y) + pushTri(p, n, uv, center, edgeA, edgeB, hint, [ + [center.x, center.z], + [edgeA.x, edgeA.z], + [edgeB.x, edgeB.z], + ]) } } @@ -268,6 +291,15 @@ function dome( grid.push(row) } const center = new THREE.Vector3(0, y0, 0) + const ringU = grid.map((row) => + cumulativeProfileDistances(row.map((point) => [point.x, point.y, point.z])), + ) + const ringV = [0] + for (let i = 1; i <= lat; i++) { + let distance = 0 + for (let j = 0; j <= lng; j++) distance += grid[i - 1]![j]!.distanceTo(grid[i]![j]!) + ringV.push(ringV[i - 1]! + distance / (lng + 1)) + } for (let i = 0; i < lat; i++) { for (let j = 0; j < lng; j++) { const a = grid[i]![j]! @@ -276,7 +308,22 @@ function dome( const d = grid[i + 1]![j]! const mid = new THREE.Vector3().add(a).add(b).add(c).add(d).multiplyScalar(0.25) const hint = mid.clone().sub(center).normalize() - pushQuad(p, n, uv, a, b, c, d, [hint.x, hint.y, hint.z]) + pushQuad( + p, + n, + uv, + a, + b, + c, + d, + [hint.x, hint.y, hint.z], + [ + [ringU[i]![j]!, ringV[i]!], + [ringU[i]![j + 1]!, ringV[i]!], + [ringU[i + 1]![j + 1]!, ringV[i + 1]!], + [ringU[i + 1]![j]!, ringV[i + 1]!], + ], + ) } } } @@ -314,6 +361,7 @@ function pushQuad( cp: THREE.Vector3 | number[], dp: THREE.Vector3 | number[], hint: [number, number, number], + authoredUvs?: readonly MetricUv[], ): void { const a = v(ap) const b = v(bp) @@ -334,27 +382,21 @@ function pushQuad( ny /= len nz /= len - const abx = b[0]! - a[0]! - const aby = b[1]! - a[1]! - const abz = b[2]! - a[2]! - const adx = d[0]! - a[0]! - const ady = d[1]! - a[1]! - const adz = d[2]! - a[2]! - const u = Math.sqrt(abx * abx + aby * aby + abz * abz) - const vv = Math.sqrt(adx * adx + ady * ady + adz * adz) + const faceUvs = authoredUvs ?? planarMetricUvs([a, b, c, d], [nx, ny, nz]) + const [uvA, uvB, uvC, uvD] = faceUvs as readonly [MetricUv, MetricUv, MetricUv, MetricUv] if (flip) { // Reversed winding: (a,b,c) + (a,c,d). positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, u, 0, u, vv) + uvs.push(...uvA, ...uvB, ...uvC) positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!) - uvs.push(0, 0, u, vv, 0, vv) + uvs.push(...uvA, ...uvC, ...uvD) } else { // Default winding: (a,c,b) + (a,d,c). positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) - uvs.push(0, 0, u, vv, u, 0) + uvs.push(...uvA, ...uvC, ...uvB) positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!) - uvs.push(0, 0, 0, vv, u, vv) + uvs.push(...uvA, ...uvD, ...uvC) } for (let i = 0; i < 6; i++) normals.push(nx, ny, nz) } @@ -367,6 +409,7 @@ function pushTri( bp: THREE.Vector3 | number[], cp: THREE.Vector3 | number[], hint: [number, number, number], + authoredUvs?: readonly MetricUv[], ): void { const a = v(ap) const b = v(bp) @@ -385,12 +428,17 @@ function pushTri( ny /= len nz /= len + const faceUvs = authoredUvs ?? planarMetricUvs([a, b, c], [nx, ny, nz]) + const uvA = faceUvs[0]! + const uvB = faceUvs[1]! + const uvC = faceUvs[2]! if (flip) { positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!) + uvs.push(...uvA, ...uvC, ...uvB) } else { positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!) + uvs.push(...uvA, ...uvB, ...uvC) } - uvs.push(0, 0, 1, 0, 0, 1) for (let i = 0; i < 3; i++) normals.push(nx, ny, nz) } @@ -399,6 +447,7 @@ function toGeometry(positions: number[], normals: number[], uvs: number[]): THRE geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + copyUvToSecondaryChannel(geo) geo.computeBoundingSphere() return geo } diff --git a/packages/nodes/src/turbine-vent/paint.ts b/packages/nodes/src/turbine-vent/paint.ts new file mode 100644 index 0000000000..6ac4455603 --- /dev/null +++ b/packages/nodes/src/turbine-vent/paint.ts @@ -0,0 +1,70 @@ +import type { + MaterialSchema, + PaintCapability, + TurbineVentMaterialRole, + TurbineVentNode, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' +import type { Material, Mesh, Object3D } from 'three' + +export function resolveTurbineVentMaterialRole(hitObjectName?: string): TurbineVentMaterialRole { + return hitObjectName === 'turbine-vent-head' ? 'head' : 'base' +} + +export function buildTurbineVentMaterialPatch( + role: TurbineVentMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + return role === 'head' + ? { headMaterial: material, headMaterialPreset: materialPreset } + : { baseMaterial: material, baseMaterialPreset: materialPreset } +} + +export function getEffectiveTurbineVentMaterial( + node: TurbineVentNode, + role: TurbineVentMaterialRole, +): { material: MaterialSchema | undefined; materialPreset: string | undefined } { + const material = role === 'head' ? node.headMaterial : node.baseMaterial + const materialPreset = role === 'head' ? node.headMaterialPreset : node.baseMaterialPreset + return material !== undefined || materialPreset !== undefined + ? { material, materialPreset } + : { material: node.material, materialPreset: node.materialPreset } +} + +function previewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + if (materialPreset) return createMaterialFromPresetRef(materialPreset) + if (material) return createMaterial(material) + return null +} + +export const turbineVentPaint: PaintCapability = { + materialTarget: 'turbine-vent', + resolveRole: ({ hitObjectName }) => resolveTurbineVentMaterialRole(hitObjectName), + buildPatch: ({ role, material, materialPreset }) => + buildTurbineVentMaterialPatch(role as TurbineVentMaterialRole, material, materialPreset), + applyPreview: ({ role, material, materialPreset, root }) => { + const preview = previewMaterial(material, materialPreset) + if (!preview) return null + const targetName = `turbine-vent-${role}` + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.name !== targetName) return + const previous = mesh.material + mesh.material = preview + restores.push(() => { + mesh.material = previous + }) + }) + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } + }, + getEffectiveMaterial: ({ node, role }) => + getEffectiveTurbineVentMaterial(node as TurbineVentNode, role as TurbineVentMaterialRole), +} diff --git a/packages/nodes/src/turbine-vent/renderer.tsx b/packages/nodes/src/turbine-vent/renderer.tsx index e430cd63f9..e89493f966 100644 --- a/packages/nodes/src/turbine-vent/renderer.tsx +++ b/packages/nodes/src/turbine-vent/renderer.tsx @@ -22,6 +22,7 @@ import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildTurbineVentBase, buildTurbineVentHead } from './geometry' +import { getEffectiveTurbineVentMaterial } from './paint' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, @@ -98,14 +99,37 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) + const { material: baseMaterial, materialPreset: baseMaterialPreset } = + getEffectiveTurbineVentMaterial(node, 'base') + const { material: headMaterial, materialPreset: headMaterialPreset } = + getEffectiveTurbineVentMaterial(node, 'head') const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const resolve = ( + roleMaterial: TurbineVentNode['material'], + roleMaterialPreset: string | undefined, + ) => { + if (!textures) return roleDefault + if (roleMaterial) return createMaterial(roleMaterial, shading) + if (roleMaterialPreset) { + return createMaterialFromPresetRef(roleMaterialPreset, shading) ?? defaultMaterial + } + return roleDefault } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + return { + base: resolve(baseMaterial, baseMaterialPreset), + head: resolve(headMaterial, headMaterialPreset), + } + }, [ + textures, + colorPreset, + sceneTheme, + shading, + baseMaterial, + baseMaterialPreset, + headMaterial, + headMaterialPreset, + ]) // Compose slope tilt + yaw onto a single quaternion so the registered // ref's local frame is vent-mesh-local (handles read this frame). @@ -163,7 +187,7 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => Date: Thu, 20 Aug 2026 10:51:28 +0530 Subject: [PATCH 4/9] feat(editor): rotate roof direction while drawing --- .../src/components/editor/floorplan-panel.tsx | 29 +++++++ .../tools/roof/roof-draft-orientation.test.ts | 48 +++++++++++ .../tools/roof/roof-draft-orientation.ts | 18 ++++ .../src/components/tools/roof/roof-tool.tsx | 82 ++++++++++++++++--- .../src/components/ui/helpers/roof-helper.tsx | 1 + packages/editor/src/hooks/use-keyboard.ts | 28 ++++--- .../src/store/use-floorplan-draft-preview.ts | 9 ++ 7 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 packages/editor/src/components/tools/roof/roof-draft-orientation.test.ts create mode 100644 packages/editor/src/components/tools/roof/roof-draft-orientation.ts diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index d37ff43219..2fef770bc7 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -4673,6 +4673,7 @@ function FloorplanLinearDraftLayer({ const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd) const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd) const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd) + const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn) const draftPolygon = useMemo(() => { if ( @@ -4709,6 +4710,21 @@ function FloorplanLinearDraftLayer({ return draftPolygon ? formatPolygonPoints(draftPolygon) : null }, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart]) + const roofDraftDirectionLine = useMemo(() => { + if (!(isRoofBuildActive && roofDraftStart && roofDraftEnd)) return null + const minX = Math.min(roofDraftStart[0], roofDraftEnd[0]) + const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0]) + const minY = Math.min(roofDraftStart[1], roofDraftEnd[1]) + const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1]) + if (maxX - minX < 1e-6 || maxY - minY < 1e-6) return null + + const centerX = (minX + maxX) / 2 + const centerY = (minY + maxY) / 2 + return roofDraftQuarterTurn + ? { x1: centerX, y1: minY, x2: centerX, y2: maxY } + : { x1: minX, y1: centerY, x2: maxX, y2: centerY } + }, [isRoofBuildActive, roofDraftEnd, roofDraftQuarterTurn, roofDraftStart]) + const fenceDraftSegment = useMemo(() => { if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) { return null @@ -4887,6 +4903,19 @@ function FloorplanLinearDraftLayer({ unitsPerPixel={unitsPerPixel} /> + {roofDraftDirectionLine && ( + + )} + {draftWallMeasurement && ( { + test('keeps the default draft axes', () => { + expect(resolveRoofDraftPlacement(8, 5, false)).toEqual({ + width: 8, + depth: 5, + rotation: 0, + }) + }) + + test('swaps dimensions and turns the segment by 90 degrees', () => { + const placement = resolveRoofDraftPlacement(8, 5, true) + expect(placement).toEqual({ + width: 5, + depth: 8, + rotation: Math.PI / 2, + }) + + const halfWidth = placement.width / 2 + const halfDepth = placement.depth / 2 + const cos = Math.cos(placement.rotation) + const sin = Math.sin(placement.rotation) + const localCorners: Array<[number, number]> = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + const corners: Array<[number, number]> = localCorners.map(([x, z]) => [ + x * cos + z * sin, + -x * sin + z * cos, + ]) + const xs = corners.map(([x]) => x) + const zs = corners.map(([, z]) => z) + expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(8) + expect(Math.max(...zs) - Math.min(...zs)).toBeCloseTo(5) + }) + + test('keeps the drafted world axes inside a rotated parent roof', () => { + expect(resolveRoofDraftPlacement(8, 5, true, Math.PI / 4)).toEqual({ + width: 5, + depth: 8, + rotation: Math.PI / 4, + }) + }) +}) diff --git a/packages/editor/src/components/tools/roof/roof-draft-orientation.ts b/packages/editor/src/components/tools/roof/roof-draft-orientation.ts new file mode 100644 index 0000000000..3f6515d28e --- /dev/null +++ b/packages/editor/src/components/tools/roof/roof-draft-orientation.ts @@ -0,0 +1,18 @@ +export type RoofDraftPlacement = { + depth: number + rotation: number + width: number +} + +export function resolveRoofDraftPlacement( + footprintWidth: number, + footprintDepth: number, + quarterTurn: boolean, + parentRotation = 0, +): RoofDraftPlacement { + return { + width: quarterTurn ? footprintDepth : footprintWidth, + depth: quarterTurn ? footprintWidth : footprintDepth, + rotation: -parentRotation + (quarterTurn ? Math.PI / 2 : 0), + } +} diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 703dde667a..be23defa61 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -33,6 +33,7 @@ import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview' import { CursorSphere } from '../shared/cursor-sphere' +import { resolveRoofDraftPlacement } from './roof-draft-orientation' const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_PITCH_DEG = 40 @@ -106,6 +107,7 @@ const commitRoofPlacement = ( corner1: [number, number, number], corner2: [number, number, number], selectedIds: string[], + quarterTurn: boolean, ): AnyNode['id'] => { const { createNode, createNodes, nodes } = useScene.getState() @@ -119,8 +121,8 @@ const commitRoofPlacement = ( const centerX = (corner1[0] + corner2[0]) / 2 const centerZ = (corner1[2] + corner2[2]) / 2 - const width = Math.max(Math.abs(corner2[0] - corner1[0]), 1) - const depth = Math.max(Math.abs(corner2[2] - corner1[2]), 1) + const footprintWidth = Math.max(Math.abs(corner2[0] - corner1[0]), 1) + const footprintDepth = Math.max(Math.abs(corner2[2] - corner1[2]), 1) // Determine if there is an active roof node we should add to let targetRoofId: RoofNode['id'] | null = null @@ -155,14 +157,22 @@ const commitRoofPlacement = ( localZ = dx * Math.sin(angle) + dz * Math.cos(angle) } + const placement = resolveRoofDraftPlacement( + footprintWidth, + footprintDepth, + quarterTurn, + targetRoof.rotation, + ) + const segment = RoofSegmentNode.parse({ wallHeight: DEFAULT_WALL_HEIGHT, pitch: DEFAULT_PITCH_DEG, roofType: 'gable', ...defaults, - width, - depth, + width: placement.width, + depth: placement.depth, position: [localX, 0, localZ], + rotation: placement.rotation, }) createNode(segment, targetRoofId as AnyNode['id']) @@ -173,6 +183,13 @@ const commitRoofPlacement = ( // Count existing roofs for naming const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length const name = `Roof ${roofCount + 1}` + const roofRotation = typeof defaults.rotation === 'number' ? defaults.rotation : 0 + const placement = resolveRoofDraftPlacement( + footprintWidth, + footprintDepth, + quarterTurn, + roofRotation, + ) // Create the segment first (centered in its new parent) const segment = RoofSegmentNode.parse({ @@ -180,9 +197,10 @@ const commitRoofPlacement = ( pitch: DEFAULT_PITCH_DEG, roofType: 'gable', ...defaults, - width, - depth, + width: placement.width, + depth: placement.depth, position: [0, 0, 0], + rotation: placement.rotation, }) // Create the roof container. Segment-shaped params (roofType, pitch, …) are @@ -384,6 +402,8 @@ export const RoofTool: React.FC = () => { const corner1Ref = useRef<[number, number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) + const quarterTurnRef = useRef(false) + const [quarterTurn, setQuarterTurn] = useState(false) const [preview, setPreview] = useState({ corner1: null, cursorPosition: [0, 0, 0], @@ -394,6 +414,7 @@ export const RoofTool: React.FC = () => { if (!currentLevelId) return outlineRef.current.geometry = new BufferGeometry() + useFloorplanDraftPreview.getState().setRoofDraftQuarterTurn(quarterTurnRef.current) // Alignment candidates — anchors of every alignable object on the active // level plus the wall corners of the floor directly below, so a roof drawn @@ -496,6 +517,7 @@ export const RoofTool: React.FC = () => { corner1Ref.current, [gridX, y, gridZ], selectedIdsRef.current, + quarterTurnRef.current, ) setSelection({ selectedIds: [roofId as AnyNode['id']] }) @@ -533,20 +555,49 @@ export const RoofTool: React.FC = () => { clearSurfacePlanSnapFeedback() } + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLElement && event.target.isContentEditable) + ) { + return + } + if ( + (event.key !== 'r' && event.key !== 'R') || + event.repeat || + event.metaKey || + event.ctrlKey || + event.altKey + ) { + return + } + + event.preventDefault() + const nextQuarterTurn = !quarterTurnRef.current + quarterTurnRef.current = nextQuarterTurn + setQuarterTurn(nextQuarterTurn) + useFloorplanDraftPreview.getState().setRoofDraftQuarterTurn(nextQuarterTurn) + sfxEmitter.emit('sfx:item-rotate') + } + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) clearSurfacePlanSnapFeedback() corner1Ref.current = null const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setRoofDraftStart(null) draftPreview.setRoofDraftEnd(null) + draftPreview.setRoofDraftQuarterTurn(false) } }, [currentLevelId, setSelection]) @@ -563,23 +614,33 @@ export const RoofTool: React.FC = () => { const roofGhostGeometry = useMemo(() => { if (!previewDimensions) return null - return buildRoofGhostGeometry( + const placement = resolveRoofDraftPlacement( previewDimensions.length, previewDimensions.width, + quarterTurn, + ) + return buildRoofGhostGeometry( + placement.width, + placement.depth, DEFAULT_WALL_HEIGHT, DEFAULT_PITCH_DEG, ) - }, [previewDimensions]) + }, [previewDimensions, quarterTurn]) const roofGhostEdges = useMemo(() => { if (!previewDimensions) return null - return buildRoofGhostEdges( + const placement = resolveRoofDraftPlacement( previewDimensions.length, previewDimensions.width, + quarterTurn, + ) + return buildRoofGhostEdges( + placement.width, + placement.depth, DEFAULT_WALL_HEIGHT, DEFAULT_PITCH_DEG, ) - }, [previewDimensions]) + }, [previewDimensions, quarterTurn]) useEffect( () => () => { @@ -625,6 +686,7 @@ export const RoofTool: React.FC = () => { {roofGhostGeometry && ( diff --git a/packages/editor/src/components/ui/helpers/roof-helper.tsx b/packages/editor/src/components/ui/helpers/roof-helper.tsx index 3056f5fe42..3fc89249f8 100644 --- a/packages/editor/src/components/ui/helpers/roof-helper.tsx +++ b/packages/editor/src/components/ui/helpers/roof-helper.tsx @@ -6,6 +6,7 @@ export function RoofHelper({ snapContext }: { snapContext?: SnapContext | null } { + // 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') + return ( + ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window' || ed.tool === 'roof') + ) } // Shift cycles the snapping mode (and a clean-tap Ctrl the grid step) @@ -480,7 +481,7 @@ export const useKeyboard = ({ !e.metaKey && !e.ctrlKey && !isVersionPreviewMode && - !isPlacingOpening() + !isToolOwnedRotation() ) { // `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead // of rotating/flipping the selected node. @@ -489,10 +490,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 is active - // (`isPlacingOpening`): the placement tool owns R then (flip the draft - // before commit), and the user can have a node selected at the same - // time — without this guard both would fire (double flip + sfx). + // 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. // // References (guide/scan) live in `selectedReferenceId`, not the viewer // selection — check them first, like the Delete arm below. @@ -572,7 +572,11 @@ export const useKeyboard = ({ sfxEmitter.emit('sfx:item-rotate') } } - } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) { + } else if ( + (e.key === 't' || e.key === 'T') && + !isVersionPreviewMode && + !isToolOwnedRotation() + ) { // Rotate selected node counter-clockwise // Multi-selection → group rotate, mirroring the R arm above. if (rotateGroupSelection(-1)) { diff --git a/packages/editor/src/store/use-floorplan-draft-preview.ts b/packages/editor/src/store/use-floorplan-draft-preview.ts index 8414b82c76..62b6bdc7cc 100644 --- a/packages/editor/src/store/use-floorplan-draft-preview.ts +++ b/packages/editor/src/store/use-floorplan-draft-preview.ts @@ -40,6 +40,7 @@ type FloorplanDraftPreviewState = { wallDraftStart: WallPlanPoint | null fenceDraftStart: WallPlanPoint | null roofDraftStart: WallPlanPoint | null + roofDraftQuarterTurn: boolean polygonDraftType: FloorplanPolygonDraftType | null polygonDraftPoints: WallPlanPoint[] /** Set the snapped cursor point. No-ops (skips the store update, so @@ -54,6 +55,7 @@ type FloorplanDraftPreviewState = { setWallDraftStart(point: WallPlanPoint | null): void setFenceDraftStart(point: WallPlanPoint | null): void setRoofDraftStart(point: WallPlanPoint | null): void + setRoofDraftQuarterTurn(quarterTurn: boolean): void setPolygonDraft(type: FloorplanPolygonDraftType | null, points: readonly WallPlanPoint[]): void reset(): void } @@ -94,6 +96,7 @@ export const useFloorplanDraftPreview = create((set) wallDraftStart: null, fenceDraftStart: null, roofDraftStart: null, + roofDraftQuarterTurn: false, polygonDraftType: null, polygonDraftPoints: [], setCursorPoint: (point) => @@ -116,6 +119,10 @@ export const useFloorplanDraftPreview = create((set) setWallDraftStart: (point) => set(setPlanPointField('wallDraftStart', point)), setFenceDraftStart: (point) => set(setPlanPointField('fenceDraftStart', point)), setRoofDraftStart: (point) => set(setPlanPointField('roofDraftStart', point)), + setRoofDraftQuarterTurn: (quarterTurn) => + set((state) => + state.roofDraftQuarterTurn === quarterTurn ? state : { roofDraftQuarterTurn: quarterTurn }, + ), setPolygonDraft: (type, points) => set((state) => state.polygonDraftType === type && planPointsEqual(state.polygonDraftPoints, points) @@ -132,6 +139,7 @@ export const useFloorplanDraftPreview = create((set) state.wallDraftStart === null && state.fenceDraftStart === null && state.roofDraftStart === null && + state.roofDraftQuarterTurn === false && state.polygonDraftType === null && state.polygonDraftPoints.length === 0 ? state @@ -144,6 +152,7 @@ export const useFloorplanDraftPreview = create((set) wallDraftStart: null, fenceDraftStart: null, roofDraftStart: null, + roofDraftQuarterTurn: false, polygonDraftType: null, polygonDraftPoints: [], }, From 930b677a7ae72a14bc3ced6c59c879fe32990580 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 11:11:32 +0530 Subject: [PATCH 5/9] Fix roof face consistency and editing flow --- packages/core/src/schema/index.ts | 2 + packages/core/src/schema/nodes/gutter.ts | 11 +-- .../src/schema/nodes/roof-segment-shape.ts | 14 +++ .../src/dormer/__tests__/geometry.test.ts | 35 ++++++- packages/nodes/src/dormer/csg-geometry.ts | 50 +--------- packages/nodes/src/dormer/geometry.ts | 96 ++++++++++++------- packages/nodes/src/gutter/eave-snap.test.ts | 21 ++++ packages/nodes/src/gutter/eave-snap.ts | 14 ++- packages/nodes/src/roof-segment/panel.tsx | 87 ++++++++++++++--- packages/nodes/src/roof/panel.tsx | 20 ++-- 10 files changed, 226 insertions(+), 124 deletions(-) create mode 100644 packages/nodes/src/gutter/eave-snap.test.ts diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 9df6acbf3d..8ab8725948 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -225,6 +225,7 @@ export { } from './nodes/roof-segment' export type { DutchRoofShapeMetrics, + RoofShapeEaveSide, RoofShapeFaceVertex, RoofShapeInsets, RoofShapeRatios, @@ -233,6 +234,7 @@ export { getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getRoofModuleFaces, + getRoofShapeEaveSides, getRoofShapeInsets, getRoofShapeRatios, } from './nodes/roof-segment-shape' diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts index 5852ebb9c4..57e1517309 100644 --- a/packages/core/src/schema/nodes/gutter.ts +++ b/packages/core/src/schema/nodes/gutter.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' import { normalizeRoofSegmentTrim, type RoofSegmentNode } from './roof-segment' +import { getRoofShapeEaveSides } from './roof-segment-shape' const MIN_DEFAULT_GUTTER_LENGTH_M = 0.2 const DEFAULT_GUTTER_GENERATOR = 'default-gutter' @@ -143,15 +144,7 @@ export function computeGutterEaveY( } function getDefaultGutterSides(segment: RoofSegmentNode): GutterEaveSide[] { - switch (segment.roofType) { - case 'shed': - return ['+Z'] - case 'gable': - case 'gambrel': - return ['+Z', '-Z'] - default: - return ['+Z', '-Z', '+X', '-X'] - } + return getRoofShapeEaveSides(segment.roofType) } function getGutterEnvelope(segment: RoofSegmentNode) { diff --git a/packages/core/src/schema/nodes/roof-segment-shape.ts b/packages/core/src/schema/nodes/roof-segment-shape.ts index ac1d5787db..d4d7b2db45 100644 --- a/packages/core/src/schema/nodes/roof-segment-shape.ts +++ b/packages/core/src/schema/nodes/roof-segment-shape.ts @@ -6,6 +6,20 @@ export type RoofShapeFaceVertex = { z: number } +export type RoofShapeEaveSide = '+X' | '-X' | '+Z' | '-Z' + +export function getRoofShapeEaveSides(type: RoofType): RoofShapeEaveSide[] { + switch (type) { + case 'shed': + return ['+Z'] + case 'gable': + case 'gambrel': + return ['+Z', '-Z'] + default: + return ['+Z', '-Z', '+X', '-X'] + } +} + export type RoofShapeInsets = { iF?: number iB?: number diff --git a/packages/nodes/src/dormer/__tests__/geometry.test.ts b/packages/nodes/src/dormer/__tests__/geometry.test.ts index 8774d2007d..4ba74f659f 100644 --- a/packages/nodes/src/dormer/__tests__/geometry.test.ts +++ b/packages/nodes/src/dormer/__tests__/geometry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { getRoofSegmentSurfaceY, type RoofSegmentNode } from '@pascal-app/core' +import { getRoofSegmentSurfaceY, type RoofSegmentNode, type RoofType } from '@pascal-app/core' import { getDormerExposedFaces } from '../csg-geometry' import { buildDormerGhostGeometry, @@ -29,6 +29,39 @@ describe('buildDormerGhostGeometry (placement preview)', () => { b.computeBoundingBox() expect(b.boundingBox!.max.y).toBeGreaterThan(a.boundingBox!.max.y) }) + + test.each([ + ['flat', 1], + ['gable', 2], + ['hip', 2], + ['shed', 2], + ['gambrel', 3], + ['mansard', 3], + ['dutch', 4], + ] satisfies [ + RoofType, + number, + ][])('builds the canonical %s height profile', (roofType, levels) => { + const wallHeight = 1 + const geo = buildDormerGhostGeometry( + DormerNode.parse({ roofType, width: 4, depth: 3, height: wallHeight, roofHeight: 1.2 }), + ) + const position = geo.getAttribute('position') + const roofLevels = new Set() + for (let index = 0; index < position.count; index++) { + const y = position.getY(index) + if (y >= wallHeight - 0.001) roofLevels.add(Math.round(y * 1000)) + } + + expect(roofLevels.size).toBe(levels) + }) + + test('assigns roof faces to the roof material slot', () => { + const geo = buildDormerGhostGeometry(DormerNode.parse({ roofType: 'mansard' })) + + expect(geo.groups.some((group) => group.materialIndex === 0)).toBe(true) + expect(geo.groups.some((group) => group.materialIndex === 3)).toBe(true) + }) }) describe('windowShape predicates', () => { diff --git a/packages/nodes/src/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index f4d6c6c898..8b5d786119 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -20,7 +20,7 @@ import { SUBTRACTION, } from '@pascal-app/viewer' import * as THREE from 'three' -import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { buildDormerShellGeometry } from './geometry' // Legacy default for the hung-wall (skirt) height. Used as a fallback // when `dormer.wallSkirtHeight` is undefined (e.g. old saved scenes). @@ -41,57 +41,11 @@ const _scale = new THREE.Vector3(1, 1, 1) * the live preview during slider drags so we don't re-run CSG on every * pointer move. Also used by the placement / move-tool ghost. * - * Builds a rectangular body + simple roof in dormer-mesh-local. For - * `flat` dormers the roof triangle is skipped. Other roof types use - * the gable approximation — it's a rough silhouette by design. - * * The wall sits at material slot 0 and the roof at slot 3 so it picks * up the same material array the renderer passes for the CSG output. */ export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeometry { - const w = Math.max(0.05, dormer.width) - const d = Math.max(0.05, dormer.depth) - const wallH = Math.max(0.05, dormer.height) - const roofH = Math.max(0, dormer.roofHeight) - const skirt = dormerSkirtHeight(dormer) - const isFlat = dormer.roofType === 'flat' || roofH === 0 - - // Body box: foot at y = -skirt, top at y = wallH. - // BoxGeometry is indexed; ExtrudeGeometry below is not. mergeGeometries - // refuses mixed input ("index attribute exists among all geometries, - // or in none of them") — drop the body's index so both inputs match. - const indexedBody = new THREE.BoxGeometry(w, wallH + skirt, d) - indexedBody.translate(0, (wallH - skirt) / 2, 0) - const body = indexedBody.toNonIndexed() - indexedBody.dispose() - const bVtx = body.getAttribute('position').count - body.clearGroups() - body.addGroup(0, bVtx, 0) - - if (isFlat) { - if (!body.getAttribute('normal')) body.computeVertexNormals() - return body - } - - // Roof: extruded triangle from eave (y = wallH) to peak (y = wallH + roofH). - // Apex points along +Y, base spans the width. Extrude along Z (depth). - const roofShape = new THREE.Shape() - roofShape.moveTo(-w / 2, 0) - roofShape.lineTo(w / 2, 0) - roofShape.lineTo(0, roofH) - roofShape.lineTo(-w / 2, 0) - const roof = new THREE.ExtrudeGeometry(roofShape, { depth: d, bevelEnabled: false }) - roof.translate(0, wallH, -d / 2) - - const rVtx = roof.getAttribute('position').count - roof.clearGroups() - roof.addGroup(0, rVtx, 3) - - const merged = mergeGeometries([body, roof], true) ?? body - body.dispose() - roof.dispose() - if (!merged.getAttribute('normal')) merged.computeVertexNormals() - return merged + return buildDormerShellGeometry(dormer) } export function createDormerArchShape(w: number, h: number, archHeight: number): THREE.Shape { diff --git a/packages/nodes/src/dormer/geometry.ts b/packages/nodes/src/dormer/geometry.ts index 10294f6b80..a13b290536 100644 --- a/packages/nodes/src/dormer/geometry.ts +++ b/packages/nodes/src/dormer/geometry.ts @@ -1,4 +1,10 @@ -import type { DormerNode } from '@pascal-app/core' +import { + type DormerNode, + getPitchFromActiveRoofHeight, + getRoofModuleFaces, + getRoofShapeRatios, + ROOF_SHAPE_DEFAULTS, +} from '@pascal-app/core' import * as THREE from 'three' /** @@ -17,44 +23,70 @@ export const DORMER_PLACEMENT_SNAP_M = 0.05 export const DORMER_PLACEMENT_ROTATION_STEP = (15 * Math.PI) / 180 /** - * Lightweight silhouette geometry used by the placement / move-tool - * ghost preview only. Renders the dormer as an extruded pentagon - * (rectangle body + triangular gable) dropped by `wallSkirtHeight` below - * the anchor so the cursor sits at the floor of the dormer the way the - * committed CSG geometry does. - * - * For `roofType === 'flat'` (or `roofHeight === 0`) the gable apex is - * skipped and the shape collapses to a rectangle. Other roof types use - * the gable approximation — exact per-type silhouettes are a future - * improvement. - * - * Kept self-contained (no `@pascal-app/viewer` imports) so the geometry - * test doesn't drag in the CSG / BVH module graph, which fails to load - * outside of a browser/WebGL context. The viewer has its own - * `buildDormerFallbackGeometry` that mirrors this shape — used both as - * the CSG fallback when boolean ops fail and as the live-drag preview - * in the dormer renderer. + * Builds the lightweight placement and live-edit shell from the same + * per-type face generator used by committed roof geometry. */ -export function buildDormerGhostGeometry(node: DormerNode): THREE.BufferGeometry { +export function buildDormerShellGeometry(node: DormerNode): THREE.BufferGeometry { const w = Math.max(0.05, node.width) const wallH = Math.max(0.05, node.height) const roofH = Math.max(0, node.roofHeight) const d = Math.max(0.05, node.depth) - const skirt = Math.max(0.05, node.wallSkirtHeight) - const hw = w / 2 - const isFlat = node.roofType === 'flat' || roofH === 0 + const skirt = Math.max(0.05, node.wallSkirtHeight ?? 2) + const isShed = node.roofType === 'shed' + const segW = isShed ? w : d + const segD = isShed ? d : w + const pitch = getPitchFromActiveRoofHeight({ + roofType: node.roofType, + width: segW, + depth: segD, + roofHeight: roofH, + }) + const faces = getRoofModuleFaces({ + type: node.roofType, + w: segW, + d: segD, + wh: wallH, + rh: roofH, + baseY: -skirt, + insets: {}, + baseW: segW, + baseD: segD, + tanTheta: Math.tan((pitch * Math.PI) / 180), + shapeRatios: getRoofShapeRatios(ROOF_SHAPE_DEFAULTS), + }) + const positions: number[] = [] + const materialGroups: Array<{ start: number; count: number; materialIndex: number }> = [] + + for (const face of faces) { + if (face.length < 3) continue + const a = new THREE.Vector3(face[0]!.x, face[0]!.y, face[0]!.z) + const b = new THREE.Vector3(face[1]!.x, face[1]!.y, face[1]!.z) + const c = new THREE.Vector3(face[2]!.x, face[2]!.y, face[2]!.z) + const normal = b.clone().sub(a).cross(c.clone().sub(a)).normalize() + const start = positions.length / 3 + for (let index = 1; index < face.length - 1; index++) { + for (const point of [face[0]!, face[index]!, face[index + 1]!]) { + positions.push(point.x, point.y, point.z) + } + } + materialGroups.push({ + start, + count: positions.length / 3 - start, + materialIndex: normal.y > 0.01 ? 3 : 0, + }) + } - const shape = new THREE.Shape() - shape.moveTo(-hw, -skirt) - shape.lineTo(hw, -skirt) - shape.lineTo(hw, wallH) - if (!isFlat) shape.lineTo(0, wallH + roofH) - shape.lineTo(-hw, wallH) - shape.closePath() + const geometry = new 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) + geometry.computeVertexNormals() + return geometry +} - const geo = new THREE.ExtrudeGeometry(shape, { depth: d, bevelEnabled: false }) - geo.translate(0, 0, -d / 2) - return geo +export function buildDormerGhostGeometry(node: DormerNode): THREE.BufferGeometry { + return buildDormerShellGeometry(node) } /** diff --git a/packages/nodes/src/gutter/eave-snap.test.ts b/packages/nodes/src/gutter/eave-snap.test.ts new file mode 100644 index 0000000000..cb3c4688e4 --- /dev/null +++ b/packages/nodes/src/gutter/eave-snap.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { resolveEaveSnap } from './eave-snap' + +describe('resolveEaveSnap', () => { + test('snaps a mansard roof to all four canonical eaves', () => { + const segment = RoofSegmentNode.parse({ roofType: 'mansard', width: 8, depth: 6 }) + + expect(resolveEaveSnap(segment, 3.5, 0).side).toBe('+X') + expect(resolveEaveSnap(segment, -3.5, 0).side).toBe('-X') + expect(resolveEaveSnap(segment, 0, 2.5).side).toBe('+Z') + expect(resolveEaveSnap(segment, 0, -2.5).side).toBe('-Z') + }) + + test('keeps gable snapping on its two eave sides', () => { + const segment = RoofSegmentNode.parse({ roofType: 'gable', width: 8, depth: 6 }) + + expect(resolveEaveSnap(segment, 3.5, 0.1).side).toBe('+Z') + expect(resolveEaveSnap(segment, -3.5, -0.1).side).toBe('-Z') + }) +}) diff --git a/packages/nodes/src/gutter/eave-snap.ts b/packages/nodes/src/gutter/eave-snap.ts index 6638f03dd7..e79114e11e 100644 --- a/packages/nodes/src/gutter/eave-snap.ts +++ b/packages/nodes/src/gutter/eave-snap.ts @@ -2,6 +2,7 @@ import { computeGutterEaveY, GUTTER_EAVE_TUCK_INWARD, GUTTER_EAVE_TUCK_UP, + getRoofShapeEaveSides, type RoofSegmentNode, type RoofType, } from '@pascal-app/core' @@ -70,7 +71,7 @@ export function computeEaveY( * regardless of which side the cursor is on — clicking on the high * side still rolls the gutter down to the low eave. * - * - `hip` / `flat` / `dutch`: 4-way. The slope the user is standing + * - Four-eave roofs: the slope the user is standing * on is determined by whichever of `|lx|/halfW` or `|lz|/halfD` is * larger — same `max(fx, fz)` discriminator the segment-hit's * `analyticalSurfaceY` uses for hip. Sign of the dominant axis @@ -78,11 +79,7 @@ export function computeEaveY( * lower run has all four eaves at the eave line — it gets the same * 4-way snap as hip. * - * - `gable` / `gambrel` / `mansard`: 2-way `±Z`. Mansard has real - * 4-side eaves in plan, but the segment-hit formula approximates it - * as 2-slope (depth-only), so we stay consistent here — the user - * can re-place the gutter manually on a side eave if mansard - * becomes important. + * - `gable` / `gambrel`: 2-way `±Z`. */ function pickEaveSide( roofType: RoofType, @@ -91,9 +88,10 @@ function pickEaveSide( halfW: number, halfD: number, ): EaveSide { - if (roofType === 'shed') return '+Z' + const sides = getRoofShapeEaveSides(roofType) + if (sides.length === 1) return sides[0]! - if (roofType === 'hip' || roofType === 'flat' || roofType === 'dutch') { + if (sides.includes('+X')) { const fx = halfW > 0 ? Math.abs(localX) / halfW : 0 const fz = halfD > 0 ? Math.abs(localZ) / halfD : 0 if (fx > fz) return localX < 0 ? '-X' : '+X' diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index a88bd45f45..0f5ee3c4e9 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -7,9 +7,11 @@ import { isAutoGutterEnabled, isAutoRidgeVentEnabled, isDefaultRidgeVentNode, + normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, + type RoofSegmentTrim, type RoofType, useScene, } from '@pascal-app/core' @@ -25,7 +27,7 @@ import { useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { Copy, Move, Trash2 } from 'lucide-react' +import { Check, Copy, Move, Pencil, RotateCcw, Trash2 } from 'lucide-react' import { useCallback } from 'react' const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [ @@ -50,6 +52,29 @@ const PITCH_PRESETS: { label: string; deg: number }[] = [ { label: '12/12', deg: 45 }, ] +const EMPTY_TRIM: RoofSegmentTrim = { + left: 0, + right: 0, + front: 0, + back: 0, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, +} + +function hasSegmentTrim(node: RoofSegmentNode): boolean { + return Object.values(normalizeRoofSegmentTrim(node)).some((value) => value > 0) +} + function shouldShowTrimPlanes(metadata: unknown): boolean { return metadataRecord(metadata).showTrimPlanes === true } @@ -122,15 +147,25 @@ export default function RoofSegmentPanel() { ) const handleClose = useCallback(() => { + if (node && shouldShowTrimPlanes(node.metadata)) { + updateNode(node.id, { + metadata: { ...metadataRecord(node.metadata), showTrimPlanes: false }, + }) + } setSelection({ selectedIds: [] }) - }, [setSelection]) + }, [node, setSelection, updateNode]) const handleBack = useCallback(() => { if (node?.parentId) { + if (shouldShowTrimPlanes(node.metadata)) { + updateNode(node.id, { + metadata: { ...metadataRecord(node.metadata), showTrimPlanes: false }, + }) + } setRoofHostDragArmedId(node.parentId as AnyNodeId) setSelection({ selectedIds: [node.parentId] }) } - }, [node?.parentId, setRoofHostDragArmedId, setSelection]) + }, [node, setRoofHostDragArmedId, setSelection, updateNode]) const handleDuplicate = useCallback(() => { if (!node?.parentId) return @@ -232,6 +267,23 @@ export default function RoofSegmentPanel() { [selectedId], ) + const handleTrimEditing = useCallback( + (editing: boolean) => { + if (!node) return + triggerSFX('sfx:item-pick') + handleUpdate({ + metadata: { ...metadataRecord(node.metadata), showTrimPlanes: editing }, + }) + }, + [handleUpdate, node], + ) + + const handleResetTrim = useCallback(() => { + if (!node || !hasSegmentTrim(node)) return + triggerSFX('sfx:item-pick') + handleUpdate({ trim: EMPTY_TRIM }) + }, [handleUpdate, node]) + if (!(node && node.type === 'roof-segment' && selectedId)) return null const showTrimPlanes = shouldShowTrimPlanes(node.metadata) @@ -261,15 +313,26 @@ export default function RoofSegmentPanel() { - - handleUpdate({ - metadata: { ...metadataRecord(node.metadata), showTrimPlanes: checked }, - }) - } - /> + + + ) : ( + + ) + } + label={showTrimPlanes ? 'Done editing' : 'Edit footprint'} + onClick={() => (showTrimPlanes ? handleBack() : handleTrimEditing(true))} + /> + } + label="Reset" + onClick={handleResetTrim} + /> + {node.roofType !== 'shed' && node.roofType !== 'flat' && ( s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) - const createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => @@ -168,17 +166,11 @@ export default function RoofPanel() { const handleAddSegment = useCallback(() => { if (!node) return - const segment = RoofSegmentNodeSchema.parse({ - width: 6, - depth: 6, - wallHeight: 0.5, - pitch: 40, - roofType: 'gable', - position: [2, 0, 2], - metadata: { autoRidgeVent: false }, - }) - createNodes([{ node: segment, parentId: node.id as AnyNodeId }]) - }, [node, createNodes]) + triggerSFX('sfx:item-pick') + const editor = useEditor.getState() + editor.setTool('roof') + if (editor.mode !== 'build') editor.setMode('build') + }, [node]) const handleSelectSegment = useCallback( (segmentId: string) => { @@ -272,7 +264,7 @@ export default function RoofPanel() { } - label="Add Segment" + label="Draw Segment" onClick={handleAddSegment} /> From d03c9aef18c4ca4e87d8b268404cc32d9c3da9b1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 12:27:40 +0530 Subject: [PATCH 6/9] feat: add automatic open roof valleys Clip entering roof segments against their host without removing host surfaces. --- packages/core/src/schema/index.ts | 2 + .../core/src/schema/nodes/roof-valley.test.ts | 109 +++++ packages/core/src/schema/nodes/roof-valley.ts | 385 ++++++++++++++++++ packages/core/src/schema/nodes/roof.ts | 5 + .../systems/roof/roof-edit-system.tsx | 26 +- .../systems/roof/roof-edit-visibility.test.ts | 18 + .../systems/roof/roof-edit-visibility.ts | 9 + packages/nodes/src/roof/floorplan.ts | 88 +--- packages/nodes/src/roof/panel.tsx | 21 + packages/nodes/src/roof/renderer.tsx | 32 +- .../systems/roof/open-valley-geometry.test.ts | 52 +++ .../src/systems/roof/open-valley-geometry.ts | 64 +++ .../src/systems/roof/roof-layer-trim.test.ts | 123 ++++++ .../src/systems/roof/roof-layer-trim.ts | 8 + .../viewer/src/systems/roof/roof-system.tsx | 170 ++++++-- 15 files changed, 992 insertions(+), 120 deletions(-) create mode 100644 packages/core/src/schema/nodes/roof-valley.test.ts create mode 100644 packages/core/src/schema/nodes/roof-valley.ts create mode 100644 packages/editor/src/components/systems/roof/roof-edit-visibility.test.ts create mode 100644 packages/editor/src/components/systems/roof/roof-edit-visibility.ts create mode 100644 packages/viewer/src/systems/roof/open-valley-geometry.test.ts create mode 100644 packages/viewer/src/systems/roof/open-valley-geometry.ts create mode 100644 packages/viewer/src/systems/roof/roof-layer-trim.test.ts create mode 100644 packages/viewer/src/systems/roof/roof-layer-trim.ts diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 8ab8725948..1e0e1f7c26 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -249,6 +249,8 @@ export { roofFacePointToSegment, segmentPointToRoofWallFace, } from './nodes/roof-segment-walls' +export type { OpenRoofValley, RoofValleyPoint } from './nodes/roof-valley' +export { getOpenRoofValleys } from './nodes/roof-valley' export { ScanNode } from './nodes/scan' export { ShelfNode } from './nodes/shelf' export { SiteNode } from './nodes/site' diff --git a/packages/core/src/schema/nodes/roof-valley.test.ts b/packages/core/src/schema/nodes/roof-valley.test.ts new file mode 100644 index 0000000000..a08d8701fc --- /dev/null +++ b/packages/core/src/schema/nodes/roof-valley.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from './roof-segment' +import { getOpenRoofValleys } from './roof-valley' + +function segment(id: `rseg_${string}`, overrides: Partial = {}): RoofSegmentNode { + return RoofSegmentNode.parse({ + id, + type: 'roof-segment', + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 3, + pitch: 30, + ...overrides, + }) +} + +describe('getOpenRoofValleys', () => { + test('creates valley pans where perpendicular gable segments overlap', () => { + const main = segment('rseg_main') + const wing = segment('rseg_wing', { + position: [2.5, 0, 2.5], + rotation: Math.PI / 2, + }) + + const valleys = getOpenRoofValleys([main, wing], 0.4) + + expect(valleys.length).toBeGreaterThan(0) + for (const valley of valleys) { + const length = Math.hypot( + valley.end.x - valley.start.x, + valley.end.y - valley.start.y, + valley.end.z - valley.start.z, + ) + expect(length).toBeGreaterThan(0.08) + expect(valley.segmentIds).toEqual([main.id, wing.id]) + expect(valley.firstEdge[0].y).toBeFinite() + expect(valley.secondEdge[1].y).toBeFinite() + } + }) + + test('does not create a valley for disjoint segments', () => { + const first = segment('rseg_first') + const second = segment('rseg_second', { position: [20, 0, 0] }) + + expect(getOpenRoofValleys([first, second])).toEqual([]) + }) + + test('supports unequal pitches without assuming a 45 degree plan line', () => { + const first = segment('rseg_first', { pitch: 22 }) + const second = segment('rseg_second', { + pitch: 38, + position: [2.5, 0, 2.5], + rotation: Math.PI / 2, + }) + + const valleys = getOpenRoofValleys([first, second]) + + expect(valleys.length).toBeGreaterThan(0) + expect( + valleys.some((valley) => { + const dx = Math.abs(valley.end.x - valley.start.x) + const dz = Math.abs(valley.end.z - valley.start.z) + return Math.abs(dx - dz) > 0.05 + }), + ).toBe(true) + }) + + test('creates valleys where a gable wing enters a mansard roof', () => { + const mansard = segment('rseg_mansard', { + roofType: 'mansard', + width: 10, + depth: 8, + pitch: 30, + }) + const gable = segment('rseg_gable', { + width: 8, + depth: 5, + pitch: 35, + position: [3, 0, 0], + rotation: Math.PI / 2, + }) + + const valleys = getOpenRoofValleys([mansard, gable], 0.4) + + expect(valleys.length).toBe(5) + expect(valleys.every((valley) => valley.segmentIds.includes(gable.id))).toBe(true) + }) + + test('creates valleys when an editor-sized gable wing enters a mansard roof', () => { + const mansard = segment('rseg_mansard', { + roofType: 'mansard', + width: 8, + depth: 10, + wallHeight: 0.5, + pitch: 40, + }) + const gable = segment('rseg_gable', { + width: 2.5, + depth: 7, + wallHeight: 0.5, + pitch: 40, + position: [3, 0, 0.75], + rotation: Math.PI / 2, + }) + + expect(getOpenRoofValleys([mansard, gable], 0.35).length).toBeGreaterThan(0) + }) +}) diff --git a/packages/core/src/schema/nodes/roof-valley.ts b/packages/core/src/schema/nodes/roof-valley.ts new file mode 100644 index 0000000000..ab0b70964a --- /dev/null +++ b/packages/core/src/schema/nodes/roof-valley.ts @@ -0,0 +1,385 @@ +import type { RoofSegmentNode } from './roof-segment' +import { getSegmentSlopeFrame, ROOF_SHAPE_DEFAULTS } from './roof-segment' +import { + getRoofModuleFaces, + getRoofShapeInsets, + getRoofShapeRatios, + type RoofShapeFaceVertex, +} from './roof-segment-shape' + +export type RoofValleyPoint = { x: number; y: number; z: number } + +export type OpenRoofValley = { + start: RoofValleyPoint + end: RoofValleyPoint + firstEdge: [RoofValleyPoint, RoofValleyPoint] + secondEdge: [RoofValleyPoint, RoofValleyPoint] + segmentIds: [RoofSegmentNode['id'], RoofSegmentNode['id']] +} + +type Point2 = [number, number] +type Plane = { x: number; z: number; constant: number } +type SurfaceFace = { + plane: Plane + polygon: Point2[] + segmentId: RoofSegmentNode['id'] +} + +const EPSILON = 1e-6 +const MIN_VALLEY_LENGTH = 0.08 +const PAN_LIFT = 0.012 + +export function getOpenRoofValleys( + segments: readonly RoofSegmentNode[], + width = 0.35, +): OpenRoofValley[] { + const facesBySegment = segments.map((segment) => buildSurfaceFaces(segment)) + const allFaces = facesBySegment.flat() + const valleys: OpenRoofValley[] = [] + + for (let firstIndex = 0; firstIndex < segments.length; firstIndex++) { + for (let secondIndex = firstIndex + 1; secondIndex < segments.length; secondIndex++) { + const firstFaces = facesBySegment[firstIndex] ?? [] + const secondFaces = facesBySegment[secondIndex] ?? [] + for (const first of firstFaces) { + for (const second of secondFaces) { + const valley = intersectFaces(first, second, allFaces, width) + if (valley && !valleys.some((existing) => sameValley(existing, valley))) { + valleys.push(valley) + } + } + } + } + } + + return valleys +} + +function buildSurfaceFaces(segment: RoofSegmentNode): SurfaceFace[] { + const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment + const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment) + const verticalDeckThickness = activeRh > 0 ? deckThickness / cosTheta : deckThickness + const deckExtension = wallThickness / 2 + overhang * cosTheta + const shingleThickness = segment.shingleThickness ?? 0 + const shingleHorizontalOffset = shingleThickness * sinTheta + const shingleVerticalOffset = shingleThickness * cosTheta + const bottomWidth = Math.max(0.01, width + 2 * deckExtension) + const bottomDepth = Math.max(0.01, depth + 2 * deckExtension) + const deckDrop = deckExtension * tanTheta + const bottomWallHeight = wallHeight - deckDrop + verticalDeckThickness + + let bottomRoofHeight = activeRh + if (activeRh > 0) { + bottomRoofHeight += deckDrop + if (roofType === 'shed') bottomRoofHeight += deckDrop + } + + let topWidth = bottomWidth + let topDepth = bottomDepth + let topTranslationZ = 0 + if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') { + topWidth += 2 * shingleHorizontalOffset + topDepth += 2 * shingleHorizontalOffset + } else if (roofType === 'gable' || roofType === 'gambrel') { + topDepth += 2 * shingleHorizontalOffset + } else if (roofType === 'shed') { + topDepth += shingleHorizontalOffset + topTranslationZ = shingleHorizontalOffset / 2 + } + + const topWallHeight = bottomWallHeight + shingleVerticalOffset + const topRoofHeight = + activeRh > 0 ? bottomRoofHeight + shingleHorizontalOffset * tanTheta : bottomRoofHeight + const availableRadius = (Math.min(bottomWidth, bottomDepth) / 2) * 0.95 + const maximumDrop = tanTheta > 0.001 ? availableRadius / tanTheta : 2 + const topBaseY = bottomWallHeight - Math.min(1, maximumDrop * 0.4) + const dutchHipWidthRatio = segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio + const insets = getRoofShapeInsets({ + roofType, + width, + depth, + wh: topWallHeight, + baseY: topBaseY, + isVoid: false, + brushW: topWidth, + brushD: topDepth, + tanTheta, + shingleThickness, + dutchHipWidthRatio, + }) + const shapeRatios = getRoofShapeRatios({ + gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio, + mansardSteepWidthRatio: segment.mansardSteepWidthRatio, + dutchHipWidthRatio, + dutchHipHeightRatio: segment.dutchHipHeightRatio, + dutchWaistLengthRatio: segment.dutchWaistLengthRatio, + dutchGabletRake: segment.dutchGabletRake, + }) + + return getRoofModuleFaces({ + type: roofType, + w: topWidth, + d: topDepth, + wh: topWallHeight, + rh: topRoofHeight, + baseY: topBaseY, + insets, + baseW: width, + baseD: depth, + tanTheta, + shapeRatios, + dutchTopRakeThickness: segment.dutchTopRakeThickness, + }) + .map((face) => + face.map((point) => + transformPoint( + { ...point, z: point.z + topTranslationZ }, + segment.position, + segment.rotation, + ), + ), + ) + .map((vertices) => ({ vertices, plane: planeFromFace(vertices) })) + .filter( + (face): face is { vertices: RoofShapeFaceVertex[]; plane: Plane } => face.plane !== null, + ) + .map(({ vertices, plane }) => ({ + plane, + polygon: dedupePolygon(vertices.map((point) => [point.x, point.z])), + segmentId: segment.id, + })) + .filter((face) => face.polygon.length >= 3) +} + +function transformPoint( + point: RoofShapeFaceVertex, + position: readonly [number, number, number], + rotation: number, +): RoofShapeFaceVertex { + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return { + x: position[0] + point.x * cos + point.z * sin, + y: position[1] + point.y, + z: position[2] - point.x * sin + point.z * cos, + } +} + +function planeFromFace(vertices: readonly RoofShapeFaceVertex[]): Plane | null { + const a = vertices[0] + const b = vertices[1] + const c = vertices[2] + if (!(a && b && c)) return null + const ab = { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z } + const ac = { x: c.x - a.x, y: c.y - a.y, z: c.z - a.z } + let nx = ab.y * ac.z - ab.z * ac.y + let ny = ab.z * ac.x - ab.x * ac.z + let nz = ab.x * ac.y - ab.y * ac.x + if (ny < 0) { + nx = -nx + ny = -ny + nz = -nz + } + if (ny <= EPSILON) return null + return { + x: -nx / ny, + z: -nz / ny, + constant: (nx * a.x + ny * a.y + nz * a.z) / ny, + } +} + +function intersectFaces( + first: SurfaceFace, + second: SurfaceFace, + allFaces: readonly SurfaceFace[], + width: number, +): OpenRoofValley | null { + const equationX = first.plane.x - second.plane.x + const equationZ = first.plane.z - second.plane.z + const equationConstant = first.plane.constant - second.plane.constant + const equationLengthSq = equationX * equationX + equationZ * equationZ + if (equationLengthSq <= EPSILON * EPSILON) return null + + const equationLength = Math.sqrt(equationLengthSq) + const direction: Point2 = [-equationZ / equationLength, equationX / equationLength] + const linePoint: Point2 = [ + (-equationConstant * equationX) / equationLengthSq, + (-equationConstant * equationZ) / equationLengthSq, + ] + const firstInterval = lineIntervalInPolygon(linePoint, direction, first.polygon) + const secondInterval = lineIntervalInPolygon(linePoint, direction, second.polygon) + if (!(firstInterval && secondInterval)) return null + + const startT = Math.max(firstInterval[0], secondInterval[0]) + const endT = Math.min(firstInterval[1], secondInterval[1]) + if (endT - startT < MIN_VALLEY_LENGTH) return null + + const gradient: Point2 = [equationX / equationLength, equationZ / equationLength] + const midpointT = (startT + endT) / 2 + const midpoint: Point2 = [ + linePoint[0] + direction[0] * midpointT, + linePoint[1] + direction[1] * midpointT, + ] + const sampleDistance = Math.min(0.04, (endT - startT) / 4) + const positiveSample: Point2 = [ + midpoint[0] + gradient[0] * sampleDistance, + midpoint[1] + gradient[1] * sampleDistance, + ] + const negativeSample: Point2 = [ + midpoint[0] - gradient[0] * sampleDistance, + midpoint[1] - gradient[1] * sampleDistance, + ] + if ( + !pointInPolygon(positiveSample, first.polygon) || + !pointInPolygon(positiveSample, second.polygon) || + !pointInPolygon(negativeSample, first.polygon) || + !pointInPolygon(negativeSample, second.polygon) + ) { + return null + } + + const firstDominatesPositive = + heightAt(first.plane, positiveSample) > heightAt(second.plane, positiveSample) + const positiveFace = firstDominatesPositive ? first : second + const negativeFace = firstDominatesPositive ? second : first + const seamHeight = heightAt(first.plane, midpoint) + if ( + heightAt(positiveFace.plane, positiveSample) <= seamHeight + EPSILON || + heightAt(negativeFace.plane, negativeSample) <= seamHeight + EPSILON || + !isUpperEnvelopeFace(positiveFace, positiveSample, allFaces) || + !isUpperEnvelopeFace(negativeFace, negativeSample, allFaces) + ) { + return null + } + const halfWidth = Math.max(0.05, width / 2) + const firstOffset: Point2 = firstDominatesPositive + ? [gradient[0] * halfWidth, gradient[1] * halfWidth] + : [-gradient[0] * halfWidth, -gradient[1] * halfWidth] + const secondOffset: Point2 = [-firstOffset[0], -firstOffset[1]] + const start2: Point2 = [ + linePoint[0] + direction[0] * startT, + linePoint[1] + direction[1] * startT, + ] + const end2: Point2 = [linePoint[0] + direction[0] * endT, linePoint[1] + direction[1] * endT] + + return { + start: pointOnPlane(first.plane, start2), + end: pointOnPlane(first.plane, end2), + firstEdge: [ + pointOnPlane(first.plane, [start2[0] + firstOffset[0], start2[1] + firstOffset[1]]), + pointOnPlane(first.plane, [end2[0] + firstOffset[0], end2[1] + firstOffset[1]]), + ], + secondEdge: [ + pointOnPlane(second.plane, [start2[0] + secondOffset[0], start2[1] + secondOffset[1]]), + pointOnPlane(second.plane, [end2[0] + secondOffset[0], end2[1] + secondOffset[1]]), + ], + segmentIds: [first.segmentId, second.segmentId], + } +} + +function isUpperEnvelopeFace( + candidate: SurfaceFace, + point: Point2, + faces: readonly SurfaceFace[], +): boolean { + const candidateHeight = heightAt(candidate.plane, point) + return faces.every( + (face) => + !pointInPolygon(point, face.polygon) || + heightAt(face.plane, point) <= candidateHeight + EPSILON, + ) +} + +function pointOnPlane(plane: Plane, point: Point2): RoofValleyPoint { + return { x: point[0], y: heightAt(plane, point) + PAN_LIFT, z: point[1] } +} + +function heightAt(plane: Plane, point: Point2): number { + return plane.x * point[0] + plane.z * point[1] + plane.constant +} + +function lineIntervalInPolygon( + linePoint: Point2, + direction: Point2, + polygon: readonly Point2[], +): [number, number] | null { + const hits: number[] = [] + for (let index = 0; index < polygon.length; index++) { + const a = polygon[index]! + const b = polygon[(index + 1) % polygon.length]! + const edge: Point2 = [b[0] - a[0], b[1] - a[1]] + const offset: Point2 = [a[0] - linePoint[0], a[1] - linePoint[1]] + const denominator = cross(direction, edge) + if (Math.abs(denominator) <= EPSILON) { + if (Math.abs(cross(offset, direction)) <= EPSILON) { + hits.push(dot(offset, direction)) + hits.push(dot([b[0] - linePoint[0], b[1] - linePoint[1]], direction)) + } + continue + } + const t = cross(offset, edge) / denominator + const edgeT = cross(offset, direction) / denominator + if (edgeT >= -EPSILON && edgeT <= 1 + EPSILON) hits.push(t) + } + if (hits.length < 2) return null + return [Math.min(...hits), Math.max(...hits)] +} + +function pointInPolygon(point: Point2, polygon: readonly Point2[]): boolean { + let inside = false + for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { + const a = polygon[index]! + const b = polygon[previous]! + if (pointOnSegment(point, a, b)) return true + if ( + a[1] > point[1] !== b[1] > point[1] && + point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0] + ) { + inside = !inside + } + } + return inside +} + +function pointOnSegment(point: Point2, a: Point2, b: Point2): boolean { + const ab: Point2 = [b[0] - a[0], b[1] - a[1]] + const ap: Point2 = [point[0] - a[0], point[1] - a[1]] + return ( + Math.abs(cross(ab, ap)) <= EPSILON && + dot(ap, ab) >= -EPSILON && + dot(ap, ab) <= dot(ab, ab) + EPSILON + ) +} + +function dedupePolygon(points: Point2[]): Point2[] { + const result: Point2[] = [] + for (const point of points) { + const previous = result.at(-1) + if (previous && Math.hypot(previous[0] - point[0], previous[1] - point[1]) <= EPSILON) continue + result.push(point) + } + const first = result[0] + const last = result.at(-1) + if (first && last && Math.hypot(first[0] - last[0], first[1] - last[1]) <= EPSILON) result.pop() + return result +} + +function sameValley(first: OpenRoofValley, second: OpenRoofValley): boolean { + const sameDirection = + distance(first.start, second.start) < 0.02 && distance(first.end, second.end) < 0.02 + const oppositeDirection = + distance(first.start, second.end) < 0.02 && distance(first.end, second.start) < 0.02 + return sameDirection || oppositeDirection +} + +function distance(a: RoofValleyPoint, b: RoofValleyPoint): number { + return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) +} + +function cross(a: Point2, b: Point2): number { + return a[0] * b[1] - a[1] * b[0] +} + +function dot(a: Point2, b: Point2): number { + return a[0] * b[0] + a[1] * b[1] +} diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index 89d806b288..da2e4b5552 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -22,6 +22,10 @@ export const RoofNode = BaseNode.extend({ edgeMaterialPreset: z.string().optional(), wallMaterial: MaterialSchema.optional(), wallMaterialPreset: z.string().optional(), + openValleyEnabled: z.boolean().default(true), + openValleyWidth: z.number().min(0.1).max(1.2).default(0.35), + valleyMaterial: MaterialSchema.optional(), + valleyMaterialPreset: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), @@ -35,6 +39,7 @@ export const RoofNode = BaseNode.extend({ - position: center position of the roof group - rotation: rotation around Y axis - children: array of RoofSegmentNode IDs + - openValleyEnabled / openValleyWidth: generated metal pans at concave segment junctions `, ) diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx index 32bbf86676..01abbea798 100644 --- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx +++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx @@ -35,6 +35,7 @@ import { isHistoryShortcut } from '../../../lib/history' import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy' import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope' import { swallowNextClick } from '../../editor/handles/use-handle-drag' +import { getRoofEditVisibility } from './roof-edit-visibility' // Empty placeholder geometry used when we reveal segments-wrapper for // accessory editing. The roof's CSG-merged shell is the only thing @@ -1788,16 +1789,14 @@ export const RoofEditSystem = () => { useEffect(() => { const nodes = useScene.getState().nodes - // Roofs where a segment itself is selected -> full edit mode (hide - // merged, show wrapper). + // Roofs where a segment itself is selected enter full edit mode. const activeRoofIds = new Set() // Roofs where an accessory (dormer/chimney/etc.) is selected -> only // reveal the wrapper so handle portals into the segment mesh become // visible. Merged stays on. const revealRoofIds = new Set() - // Roofs whose selected segment is currently being moved in 3D. During this - // transient state we reveal the wrapper so the moving segment mesh is - // visible and hide the merged roof to avoid the duplicate shell fighting it. + // Roofs whose selected segment is currently being moved in 3D. The merged + // roof remains the visual source and rebuilds from the live move override. const movingRoofIds = new Set() for (const id of selectedIds) { @@ -1842,14 +1841,9 @@ export const RoofEditSystem = () => { const isMoving = movingRoofIds.has(roofId) const isReveal = revealRoofIds.has(roofId) - // Keep the clean merged shell visible during trim editing too (not just - // when deselected). The merged shell rebuilds live from each segment's - // trim override (RoofSystem reads getEffectiveNode), so the dragged - // cutaway matches the commit. Showing the individual per-segment meshes - // instead would expose their abutting end-cap faces (the white planes the - // merged union removes) — exactly what the commit doesn't show. - if (mergedMesh) mergedMesh.visible = !isMoving - if (segmentsWrapper) segmentsWrapper.visible = isReveal || isMoving + const visibility = getRoofEditVisibility({ isMoving, isReveal }) + if (mergedMesh) mergedMesh.visible = visibility.merged + if (segmentsWrapper) segmentsWrapper.visible = visibility.segments const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined if (roofNode?.children?.length) { @@ -1857,10 +1851,8 @@ export const RoofEditSystem = () => { const wasMoving = prevMovingRoofIds.current.has(roofId) const wasReveal = prevRevealRoofIds.current.has(roofId) if (isActive !== wasActive || isMoving !== wasMoving) { - // Entering / exiting full edit mode: rebuild segment / merged - // geometries. Segment-move reveal uses the same rebuild so any - // wrapper mesh previously stripped to an empty placeholder is - // restored before the drag begins. + // Entering or exiting edit and move modes rebuilds the merged shell + // from the current segment values. const { markDirty } = useScene.getState() for (const childId of roofNode.children) { markDirty(childId as AnyNodeId) diff --git a/packages/editor/src/components/systems/roof/roof-edit-visibility.test.ts b/packages/editor/src/components/systems/roof/roof-edit-visibility.test.ts new file mode 100644 index 0000000000..452528f535 --- /dev/null +++ b/packages/editor/src/components/systems/roof/roof-edit-visibility.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test' +import { getRoofEditVisibility } from './roof-edit-visibility' + +describe('getRoofEditVisibility', () => { + test('keeps the merged shell visible while a roof segment moves', () => { + expect(getRoofEditVisibility({ isMoving: true, isReveal: false })).toEqual({ + merged: true, + segments: false, + }) + }) + + test('reveals accessory portals without replacing the merged shell', () => { + expect(getRoofEditVisibility({ isMoving: false, isReveal: true })).toEqual({ + merged: true, + segments: true, + }) + }) +}) diff --git a/packages/editor/src/components/systems/roof/roof-edit-visibility.ts b/packages/editor/src/components/systems/roof/roof-edit-visibility.ts new file mode 100644 index 0000000000..91af68b7ac --- /dev/null +++ b/packages/editor/src/components/systems/roof/roof-edit-visibility.ts @@ -0,0 +1,9 @@ +export function getRoofEditVisibility(input: { isMoving: boolean; isReveal: boolean }): { + merged: boolean + segments: boolean +} { + return { + merged: true, + segments: input.isReveal, + } +} diff --git a/packages/nodes/src/roof/floorplan.ts b/packages/nodes/src/roof/floorplan.ts index 2346281063..ccb6800e10 100644 --- a/packages/nodes/src/roof/floorplan.ts +++ b/packages/nodes/src/roof/floorplan.ts @@ -5,48 +5,13 @@ import type { RoofNode, RoofSegmentNode, } from '@pascal-app/core' +import { getOpenRoofValleys } from '@pascal-app/core' import { unionPolygons } from '@pascal-app/viewer' import { getRoofSegmentPlanLinework } from '../roof-segment/floorplan' type Pt = [number, number] type Seg = [Pt, Pt] -function signedArea(ring: readonly Pt[]): number { - let a = 0 - const n = ring.length - for (let i = 0; i < n; i++) { - const p = ring[i] as Pt - const q = ring[(i + 1) % n] as Pt - a += p[0] * q[1] - q[0] * p[1] - } - return a / 2 -} - -/** Distance `t >= 0` from `V` along unit dir `(dx,dz)` to where the ray first - * meets segment `A→B`, or null. (Used to terminate valleys at ridges.) */ -function rayHitT( - vx: number, - vz: number, - dx: number, - dz: number, - ax: number, - az: number, - bx: number, - bz: number, -): number | null { - const ex = bx - ax - const ez = bz - az - const denom = dx * ez - dz * ex - if (Math.abs(denom) < 1e-9) return null - const wx = ax - vx - const wz = az - vz - const t = (wx * ez - wz * ex) / denom - const s = (wx * dz - wz * dx) / denom - if (t < 0) return null - if (s < -1e-6 || s > 1 + 1e-6) return null - return t -} - function pointInPolygon(px: number, pz: number, poly: readonly Pt[]): boolean { let inside = false for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { @@ -147,44 +112,19 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp const rings = unionPolygons(plans.map((p) => p.footprint)) as Pt[][] if (rings.length === 0) return null - // Valleys at concave (reflex) corners of the merged outline. Each runs - // along the interior angle bisector and terminates at the nearest segment - // ridge — the diagonal where two merged slopes meet. - const allRidges: Seg[] = plans.flatMap((p) => p.ridges) - const valleys: Seg[] = [] - for (const ring of rings) { - const n = ring.length - if (n < 3) continue - const orient = signedArea(ring) > 0 ? 1 : -1 - for (let i = 0; i < n; i++) { - const prev = ring[(i - 1 + n) % n] as Pt - const V = ring[i] as Pt - const next = ring[(i + 1) % n] as Pt - const ax = prev[0] - V[0] - const az = prev[1] - V[1] - const bx = next[0] - V[0] - const bz = next[1] - V[1] - if ((ax * bz - az * bx) * orient <= 0) continue // not reflex - const la = Math.hypot(ax, az) || 1 - const lb = Math.hypot(bx, bz) || 1 - let dx = -(ax / la + bx / lb) - let dz = -(az / la + bz / lb) - const dl = Math.hypot(dx, dz) - if (dl < 1e-6) continue - dx /= dl - dz /= dl - let bestT = Number.POSITIVE_INFINITY - for (const [A, B] of allRidges) { - const t = rayHitT(V[0], V[1], dx, dz, A[0], A[1], B[0], B[1]) - if (t !== null && t > 1e-4 && t < bestT) bestT = t - } - if (!Number.isFinite(bestT)) continue - valleys.push([ - [V[0], V[1]], - [V[0] + dx * bestT, V[1] + dz * bestT], - ]) - } - } + const cosRoof = Math.cos(-node.rotation) + const sinRoof = Math.sin(-node.rotation) + const toPlan = (point: { x: number; z: number }): Pt => [ + node.position[0] + point.x * cosRoof - point.z * sinRoof, + node.position[2] + point.x * sinRoof + point.z * cosRoof, + ] + const valleys: Seg[] = + node.openValleyEnabled !== false + ? getOpenRoofValleys(segments, node.openValleyWidth ?? 0.35).map((valley) => [ + toPlan(valley.start), + toPlan(valley.end), + ]) + : [] const view = ctx.viewState const palette = view?.palette diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index a14400bc43..7893cd648a 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -23,6 +23,7 @@ import { PanelWrapper, SegmentedControl, SliderControl, + ToggleControl, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -343,6 +344,26 @@ export default function RoofPanel() { + + handleUpdate({ openValleyEnabled })} + /> + {node.openValleyEnabled !== false && ( + handleUpdate({ openValleyWidth })} + precision={2} + step={0.05} + unit="m" + value={node.openValleyWidth ?? 0.35} + /> + )} + +
diff --git a/packages/nodes/src/roof/renderer.tsx b/packages/nodes/src/roof/renderer.tsx index 45a288711d..83cb7f2d1e 100644 --- a/packages/nodes/src/roof/renderer.tsx +++ b/packages/nodes/src/roof/renderer.tsx @@ -9,9 +9,17 @@ import { useRegistry, useScene, } from '@pascal-app/core' -import { getRoofMaterialArray, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer' +import { + createDefaultMaterial, + createMaterial, + createMaterialFromPresetRef, + getRoofMaterialArray, + NodeRenderer, + useNodeEvents, + useViewer, +} from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import type * as THREE from 'three' +import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials' @@ -92,6 +100,18 @@ export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => { const material = debugColors ? getRoofDebugMaterials(shading) : customMaterial || getRoofMaterials(shading, textures, colorPreset) + const valleyMaterial = useMemo(() => { + const material = node.valleyMaterial + ? createMaterial(node.valleyMaterial, shading) + : node.valleyMaterialPreset + ? createMaterialFromPresetRef(node.valleyMaterialPreset, shading) + : null + const result = material ?? createDefaultMaterial('#d5dde5', 0.24, shading, THREE.DoubleSide) + result.polygonOffset = true + result.polygonOffsetFactor = -2 + result.polygonOffsetUnits = -2 + return result + }, [node.valleyMaterial, node.valleyMaterialPreset, shading]) useEffect(() => { return () => { @@ -114,6 +134,14 @@ export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => { name="merged-roof" receiveShadow /> + {}} + receiveShadow + renderOrder={2} + /> {unpaintedSegmentIds.map((childId) => ( diff --git a/packages/viewer/src/systems/roof/open-valley-geometry.test.ts b/packages/viewer/src/systems/roof/open-valley-geometry.test.ts new file mode 100644 index 0000000000..5c7354c00f --- /dev/null +++ b/packages/viewer/src/systems/roof/open-valley-geometry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { buildOpenValleyGeometry } from './open-valley-geometry' + +describe('buildOpenValleyGeometry', () => { + test('builds finite indexed pan geometry for a roof junction', () => { + const first = RoofSegmentNode.parse({ + id: 'rseg_first', + type: 'roof-segment', + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 3, + pitch: 30, + }) + const second = RoofSegmentNode.parse({ + id: 'rseg_second', + type: 'roof-segment', + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 3, + pitch: 30, + position: [2.5, 0, 2.5], + rotation: Math.PI / 2, + }) + + const geometry = buildOpenValleyGeometry([first, second], 0.35) + const position = geometry.getAttribute('position') + + expect(position.count).toBeGreaterThan(0) + expect(geometry.getIndex()?.count).toBeGreaterThan(0) + for (const value of position.array) expect(value).toBeFinite() + geometry.dispose() + }) + + test('returns a render-safe placeholder when no valley exists', () => { + const first = RoofSegmentNode.parse({ id: 'rseg_first', type: 'roof-segment' }) + const second = RoofSegmentNode.parse({ + id: 'rseg_second', + type: 'roof-segment', + position: [20, 0, 0], + }) + + const geometry = buildOpenValleyGeometry([first, second], 0.35) + + expect(geometry.getAttribute('position').count).toBe(3) + expect(geometry.getAttribute('normal').count).toBe(3) + expect(geometry.getIndex()?.count).toBe(3) + geometry.dispose() + }) +}) diff --git a/packages/viewer/src/systems/roof/open-valley-geometry.ts b/packages/viewer/src/systems/roof/open-valley-geometry.ts new file mode 100644 index 0000000000..d3f13c3d07 --- /dev/null +++ b/packages/viewer/src/systems/roof/open-valley-geometry.ts @@ -0,0 +1,64 @@ +import { getOpenRoofValleys, type RoofSegmentNode, type RoofValleyPoint } from '@pascal-app/core' +import * as THREE from 'three' + +export function buildOpenValleyGeometry( + segments: readonly RoofSegmentNode[], + width: number, +): THREE.BufferGeometry { + const valleys = getOpenRoofValleys(segments, width) + const positions: number[] = [] + const uvs: number[] = [] + const indices: number[] = [] + + for (const valley of valleys) { + appendPanel(valley.start, valley.end, valley.firstEdge[0], valley.firstEdge[1]) + appendPanel(valley.end, valley.start, valley.secondEdge[1], valley.secondEdge[0]) + } + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + geometry.setIndex(indices) + if (positions.length > 0) { + geometry.computeVertexNormals() + geometry.computeBoundingBox() + geometry.computeBoundingSphere() + } else { + geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) + geometry.setIndex([0, 1, 2]) + } + return geometry + + function appendPanel( + centerStart: RoofValleyPoint, + centerEnd: RoofValleyPoint, + edgeStart: RoofValleyPoint, + edgeEnd: RoofValleyPoint, + ) { + const baseIndex = positions.length / 3 + const length = Math.hypot( + centerEnd.x - centerStart.x, + centerEnd.y - centerStart.y, + centerEnd.z - centerStart.z, + ) + pushPoint(centerStart) + pushPoint(edgeStart) + pushPoint(centerEnd) + pushPoint(edgeEnd) + uvs.push(0, 0, width / 2, 0, 0, length, width / 2, length) + indices.push( + baseIndex, + baseIndex + 1, + baseIndex + 2, + baseIndex + 2, + baseIndex + 1, + baseIndex + 3, + ) + } + + function pushPoint(point: RoofValleyPoint) { + positions.push(point.x, point.y, point.z) + } +} diff --git a/packages/viewer/src/systems/roof/roof-layer-trim.test.ts b/packages/viewer/src/systems/roof/roof-layer-trim.test.ts new file mode 100644 index 0000000000..9c1927d7a4 --- /dev/null +++ b/packages/viewer/src/systems/roof/roof-layer-trim.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from 'bun:test' +import { RoofNode, RoofSegmentNode } from '@pascal-app/core' +import * as THREE from 'three' +import { Brush, Evaluator } from 'three-bvh-csg' +import { prepareBrushForCSG } from '../../lib/csg-utils' +import { subtractRoofInterior } from './roof-layer-trim' +import { generateRoofSegmentGeometry } from './roof-system' + +function box(size: [number, number, number], position: [number, number, number]): Brush { + const brush = new Brush(new THREE.BoxGeometry(...size)) + brush.position.set(...position) + prepareBrushForCSG(brush) + return brush +} + +describe('subtractRoofInterior', () => { + 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]) + const evaluator = new Evaluator() + evaluator.attributes = ['position', 'normal', 'uv'] + + const result = subtractRoofInterior(layer, siblingInterior, evaluator) + const mesh = new THREE.Mesh(result.geometry) + const centerHits = new THREE.Raycaster( + new THREE.Vector3(0, 3, 0), + new THREE.Vector3(0, -1, 0), + ).intersectObject(mesh) + const edgeHits = new THREE.Raycaster( + new THREE.Vector3(1.5, 3, 0), + new THREE.Vector3(0, -1, 0), + ).intersectObject(mesh) + + expect(centerHits).toHaveLength(0) + expect(edgeHits.length).toBeGreaterThan(0) + + layer.geometry.dispose() + siblingInterior.geometry.dispose() + result.geometry.dispose() + }) + + test('clips a painted gable segment against its mansard sibling', () => { + const roof = RoofNode.parse({ + id: 'roof_join', + type: 'roof', + children: ['rseg_mansard', 'rseg_gable'], + }) + const mansard = RoofSegmentNode.parse({ + id: 'rseg_mansard', + type: 'roof-segment', + parentId: roof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const gable = RoofSegmentNode.parse({ + id: 'rseg_gable', + type: 'roof-segment', + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + position: [3, 0, 0], + rotation: Math.PI / 2, + materialPreset: 'library:roof-shingle', + }) + const nodes = { [roof.id]: roof, [mansard.id]: mansard, [gable.id]: gable } + + const unclipped = generateRoofSegmentGeometry(gable) + const clipped = generateRoofSegmentGeometry(gable, nodes) + const ray = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + + const unclippedHits = ray.intersectObject(new THREE.Mesh(unclipped)) + const clippedHits = ray.intersectObject(new THREE.Mesh(clipped)) + expect(unclippedHits.length).toBeGreaterThan(0) + expect(clippedHits).toHaveLength(0) + + unclipped.dispose() + clipped.dispose() + }) + + test('keeps the host mansard shell beneath an entering gable', () => { + const roof = RoofNode.parse({ + id: 'roof_join', + type: 'roof', + children: ['rseg_mansard', 'rseg_gable'], + }) + const mansard = RoofSegmentNode.parse({ + id: 'rseg_mansard', + type: 'roof-segment', + parentId: roof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const gable = RoofSegmentNode.parse({ + id: 'rseg_gable', + type: 'roof-segment', + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + position: [3, 0, 0], + rotation: Math.PI / 2, + }) + const nodes = { [roof.id]: roof, [mansard.id]: mansard, [gable.id]: gable } + + const mansardWithSibling = generateRoofSegmentGeometry(mansard, nodes) + const ray = new THREE.Raycaster(new THREE.Vector3(3, 10, 0), new THREE.Vector3(0, -1, 0)) + + expect(ray.intersectObject(new THREE.Mesh(mansardWithSibling)).length).toBeGreaterThan(0) + + mansardWithSibling.dispose() + }) +}) diff --git a/packages/viewer/src/systems/roof/roof-layer-trim.ts b/packages/viewer/src/systems/roof/roof-layer-trim.ts new file mode 100644 index 0000000000..0312fe2584 --- /dev/null +++ b/packages/viewer/src/systems/roof/roof-layer-trim.ts @@ -0,0 +1,8 @@ +import { type Brush, type Evaluator, SUBTRACTION } from 'three-bvh-csg' +import { prepareBrushForCSG } from '../../lib/csg-utils' + +export function subtractRoofInterior(layer: Brush, interior: Brush, evaluator: Evaluator): Brush { + const result = evaluator.evaluate(layer, interior, SUBTRACTION) as Brush + prepareBrushForCSG(result) + return result +} diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index affd5c07b5..c76c32d061 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -28,6 +28,8 @@ import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' +import { buildOpenValleyGeometry } from './open-valley-geometry' +import { subtractRoofInterior } from './roof-layer-trim' function csgGeometry(brush: Brush): THREE.BufferGeometry { return brush.geometry as unknown as THREE.BufferGeometry @@ -482,6 +484,14 @@ function updateMergedRoofGeometry( const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined if (!mergedMesh) return + const allChildren = (roofNode.children ?? []) + .map((id) => { + const sceneNode = nodes[id] as RoofSegmentNode | undefined + return sceneNode ? getEffectiveNode(sceneNode) : undefined + }) + .filter((node): node is RoofSegmentNode => node !== undefined) + updateOpenValleyGeometry(roofNode, group, allChildren) + // Segments that carry their own material / preset (catch-all or any of // the role-specific fields) are rendered as their own per-segment mesh // in `RoofRenderer` so the painted material is preserved. Exclude them @@ -508,8 +518,7 @@ function updateMergedRoofGeometry( let totalShinSlab: Brush | null = null let totalDeckSlab: Brush | null = null - let totalWall: Brush | null = null - let totalInner: Brush | null = null + let totalWallShell: Brush | null = null const rakeBoardGeometries: THREE.BufferGeometry[] = [] const directSegmentGeometries: THREE.BufferGeometry[] = [] const csgChildren: RoofSegmentNode[] = [] @@ -542,6 +551,21 @@ function updateMergedRoofGeometry( rakeBoardGeometries.push(brushes.rakeBoards) } + const occludingInterior = buildOccludingRoofInterior(child, nodes, 'roof') + if (occludingInterior) { + const exposedShingles = subtractRoofInterior( + brushes.shinSlab, + occludingInterior, + csgEvaluator, + ) + brushes.shinSlab.geometry.dispose() + brushes.shinSlab = exposedShingles + + const exposedDeck = subtractRoofInterior(brushes.deckSlab, occludingInterior, csgEvaluator) + brushes.deckSlab.geometry.dispose() + brushes.deckSlab = exposedDeck + } + if (totalShinSlab) { const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush totalShinSlab.geometry.dispose() @@ -566,26 +590,32 @@ function updateMergedRoofGeometry( brushes.wallBrush.geometry.dispose() brushes.innerBrush.geometry.dispose() } else { - if (totalWall) { - const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush - totalWall.geometry.dispose() - brushes.wallBrush.geometry.dispose() - prepareBrushForCSG(next) - totalWall = next - } else { - totalWall = brushes.wallBrush + let wallShell = csgEvaluator.evaluate( + brushes.wallBrush, + brushes.innerBrush, + SUBTRACTION, + ) as Brush + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + prepareBrushForCSG(wallShell) + + if (occludingInterior) { + const exposedWall = subtractRoofInterior(wallShell, occludingInterior, csgEvaluator) + wallShell.geometry.dispose() + wallShell = exposedWall } - if (totalInner) { - const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush - totalInner.geometry.dispose() - brushes.innerBrush.geometry.dispose() + if (totalWallShell) { + const next = csgEvaluator.evaluate(totalWallShell, wallShell, ADDITION) as Brush + totalWallShell.geometry.dispose() + wallShell.geometry.dispose() prepareBrushForCSG(next) - totalInner = next + totalWallShell = next } else { - totalInner = brushes.innerBrush + totalWallShell = wallShell } } + occludingInterior?.geometry.dispose() } if (totalShinSlab && totalDeckSlab) { @@ -593,11 +623,8 @@ function updateMergedRoofGeometry( const shinDeck = csgEvaluator.evaluate(totalShinSlab, totalDeckSlab, ADDITION) prepareBrushForCSG(shinDeck) let combined = shinDeck - let finalWallTrimmed: Brush | null = null - if (totalWall && totalInner) { - finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) - prepareBrushForCSG(finalWallTrimmed) - combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) + if (totalWallShell) { + combined = csgEvaluator.evaluate(shinDeck, totalWallShell, ADDITION) } prepareBrushForCSG(combined) @@ -611,12 +638,10 @@ function updateMergedRoofGeometry( warnedMergedRoofNaNIds.add(roofNode.id) } resultGeo.dispose() - finalWallTrimmed?.geometry.dispose() if (combined !== shinDeck) shinDeck.geometry.dispose() totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() - totalWall?.geometry.dispose() - totalInner?.geometry.dispose() + totalWallShell?.geometry.dispose() for (const geometry of rakeBoardGeometries) geometry.dispose() for (const geometry of directSegmentGeometries) geometry.dispose() return @@ -653,7 +678,6 @@ function updateMergedRoofGeometry( mergedMesh.geometry.dispose() mergedMesh.geometry = finalGeo - finalWallTrimmed?.geometry.dispose() if (combined !== shinDeck) shinDeck.geometry.dispose() } catch (e) { console.error('Merged roof CSG failed:', e) @@ -661,8 +685,7 @@ function updateMergedRoofGeometry( totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() - totalWall?.geometry.dispose() - totalInner?.geometry.dispose() + totalWallShell?.geometry.dispose() for (const geometry of rakeBoardGeometries) geometry.dispose() } @@ -683,6 +706,21 @@ function updateMergedRoofGeometry( } } +function updateOpenValleyGeometry( + roofNode: RoofNode, + group: THREE.Group, + segments: readonly RoofSegmentNode[], +) { + const mesh = group.getObjectByName('open-valleys') as THREE.Mesh | undefined + if (!mesh) return + const geometry = + roofNode.openValleyEnabled !== false + ? buildOpenValleyGeometry(segments, roofNode.openValleyWidth ?? 0.35) + : new THREE.BufferGeometry() + mesh.geometry.dispose() + mesh.geometry = geometry +} + function geometryHasInvalidAttributes(geometry: THREE.BufferGeometry) { const position = geometry.getAttribute('position') if (!(position && position.count > 0)) return true @@ -1615,6 +1653,14 @@ export function generateRoofSegmentGeometry( } prepareBrushForCSG(combined) + const siblingInterior = nodes ? buildOccludingRoofInterior(node, nodes, 'segment') : null + if (siblingInterior) { + const unclipped = combined + combined = subtractRoofInterior(unclipped, siblingInterior, csgEvaluator) + unclipped.geometry.dispose() + siblingInterior.geometry.dispose() + } + resultGeo = csgGeometry(combined) if (geometryHasInvalidAttributes(resultGeo)) { resultGeo.dispose() @@ -1667,6 +1713,76 @@ export function generateRoofSegmentGeometry( return resultGeo } +function buildOccludingRoofInterior( + node: RoofSegmentNode, + nodes: Record, + space: 'roof' | 'segment', +): Brush | null { + if (!node.parentId) return null + const parent = nodes[node.parentId as AnyNodeId] + if (parent?.type !== 'roof') return null + + const currentInverse = + space === 'segment' + ? new THREE.Matrix4() + .compose( + new THREE.Vector3(...node.position), + new THREE.Quaternion().setFromAxisAngle(_yAxis, node.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ) + .invert() + : new THREE.Matrix4() + const currentIndex = parent.children?.indexOf(node.id) ?? -1 + const currentArea = node.width * node.depth + let combinedInterior: Brush | null = null + + for (const siblingId of parent.children ?? []) { + if (siblingId === node.id) continue + const storedSibling = nodes[siblingId as AnyNodeId] + if (storedSibling?.type !== 'roof-segment') continue + const sibling = getEffectiveNode(storedSibling) + if (sibling.roofType === 'shed') continue + const siblingArea = sibling.width * sibling.depth + const siblingIndex = parent.children?.indexOf(siblingId) ?? -1 + const siblingOwnsOverlap = + siblingArea > currentArea + 1e-6 || + (Math.abs(siblingArea - currentArea) <= 1e-6 && siblingIndex < currentIndex) + if (!siblingOwnsOverlap) continue + const siblingBrushes = getRoofSegmentBrushes(sibling) + if (!siblingBrushes) continue + + const siblingMatrix = new THREE.Matrix4().compose( + new THREE.Vector3(...sibling.position), + new THREE.Quaternion().setFromAxisAngle(_yAxis, sibling.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ) + const relativeMatrix = new THREE.Matrix4().multiplyMatrices(currentInverse, siblingMatrix) + csgGeometry(siblingBrushes.innerBrush).applyMatrix4(relativeMatrix) + siblingBrushes.innerBrush.updateMatrixWorld() + + siblingBrushes.shinSlab.geometry.dispose() + siblingBrushes.deckSlab.geometry.dispose() + siblingBrushes.wallBrush.geometry.dispose() + siblingBrushes.rakeBoards?.dispose() + + if (combinedInterior) { + const next = csgEvaluator.evaluate( + combinedInterior, + siblingBrushes.innerBrush, + ADDITION, + ) as Brush + combinedInterior.geometry.dispose() + siblingBrushes.innerBrush.geometry.dispose() + prepareBrushForCSG(next) + combinedInterior = next + } else { + combinedInterior = siblingBrushes.innerBrush + } + } + + return combinedInterior +} + // ============================================================================ // FACE-BASED GEOMETRY HELPERS (ported from prototype) // ============================================================================ From 6818b7c2c249e8d84d3fb14c0910a747660753d2 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 13:39:19 +0530 Subject: [PATCH 7/9] fix: complete roof intersection trimming --- packages/core/src/schema/index.ts | 2 - .../core/src/schema/nodes/roof-valley.test.ts | 109 ----- packages/core/src/schema/nodes/roof-valley.ts | 385 ------------------ packages/core/src/schema/nodes/roof.ts | 5 - packages/nodes/src/roof-segment/floorplan.ts | 12 +- packages/nodes/src/roof/floorplan.test.ts | 85 ++++ packages/nodes/src/roof/floorplan.ts | 235 ++++++----- packages/nodes/src/roof/panel.tsx | 21 - packages/nodes/src/roof/renderer.tsx | 33 +- packages/viewer/src/index.ts | 2 +- .../systems/roof/open-valley-geometry.test.ts | 52 --- .../src/systems/roof/open-valley-geometry.ts | 64 --- .../src/systems/roof/roof-layer-trim.test.ts | 340 +++++++++++++++- .../viewer/src/systems/roof/roof-system.tsx | 175 +++++--- 14 files changed, 686 insertions(+), 834 deletions(-) delete mode 100644 packages/core/src/schema/nodes/roof-valley.test.ts delete mode 100644 packages/core/src/schema/nodes/roof-valley.ts create mode 100644 packages/nodes/src/roof/floorplan.test.ts delete mode 100644 packages/viewer/src/systems/roof/open-valley-geometry.test.ts delete mode 100644 packages/viewer/src/systems/roof/open-valley-geometry.ts diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 1e0e1f7c26..8ab8725948 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -249,8 +249,6 @@ export { roofFacePointToSegment, segmentPointToRoofWallFace, } from './nodes/roof-segment-walls' -export type { OpenRoofValley, RoofValleyPoint } from './nodes/roof-valley' -export { getOpenRoofValleys } from './nodes/roof-valley' export { ScanNode } from './nodes/scan' export { ShelfNode } from './nodes/shelf' export { SiteNode } from './nodes/site' diff --git a/packages/core/src/schema/nodes/roof-valley.test.ts b/packages/core/src/schema/nodes/roof-valley.test.ts deleted file mode 100644 index a08d8701fc..0000000000 --- a/packages/core/src/schema/nodes/roof-valley.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { RoofSegmentNode } from './roof-segment' -import { getOpenRoofValleys } from './roof-valley' - -function segment(id: `rseg_${string}`, overrides: Partial = {}): RoofSegmentNode { - return RoofSegmentNode.parse({ - id, - type: 'roof-segment', - roofType: 'gable', - width: 8, - depth: 6, - wallHeight: 3, - pitch: 30, - ...overrides, - }) -} - -describe('getOpenRoofValleys', () => { - test('creates valley pans where perpendicular gable segments overlap', () => { - const main = segment('rseg_main') - const wing = segment('rseg_wing', { - position: [2.5, 0, 2.5], - rotation: Math.PI / 2, - }) - - const valleys = getOpenRoofValleys([main, wing], 0.4) - - expect(valleys.length).toBeGreaterThan(0) - for (const valley of valleys) { - const length = Math.hypot( - valley.end.x - valley.start.x, - valley.end.y - valley.start.y, - valley.end.z - valley.start.z, - ) - expect(length).toBeGreaterThan(0.08) - expect(valley.segmentIds).toEqual([main.id, wing.id]) - expect(valley.firstEdge[0].y).toBeFinite() - expect(valley.secondEdge[1].y).toBeFinite() - } - }) - - test('does not create a valley for disjoint segments', () => { - const first = segment('rseg_first') - const second = segment('rseg_second', { position: [20, 0, 0] }) - - expect(getOpenRoofValleys([first, second])).toEqual([]) - }) - - test('supports unequal pitches without assuming a 45 degree plan line', () => { - const first = segment('rseg_first', { pitch: 22 }) - const second = segment('rseg_second', { - pitch: 38, - position: [2.5, 0, 2.5], - rotation: Math.PI / 2, - }) - - const valleys = getOpenRoofValleys([first, second]) - - expect(valleys.length).toBeGreaterThan(0) - expect( - valleys.some((valley) => { - const dx = Math.abs(valley.end.x - valley.start.x) - const dz = Math.abs(valley.end.z - valley.start.z) - return Math.abs(dx - dz) > 0.05 - }), - ).toBe(true) - }) - - test('creates valleys where a gable wing enters a mansard roof', () => { - const mansard = segment('rseg_mansard', { - roofType: 'mansard', - width: 10, - depth: 8, - pitch: 30, - }) - const gable = segment('rseg_gable', { - width: 8, - depth: 5, - pitch: 35, - position: [3, 0, 0], - rotation: Math.PI / 2, - }) - - const valleys = getOpenRoofValleys([mansard, gable], 0.4) - - expect(valleys.length).toBe(5) - expect(valleys.every((valley) => valley.segmentIds.includes(gable.id))).toBe(true) - }) - - test('creates valleys when an editor-sized gable wing enters a mansard roof', () => { - const mansard = segment('rseg_mansard', { - roofType: 'mansard', - width: 8, - depth: 10, - wallHeight: 0.5, - pitch: 40, - }) - const gable = segment('rseg_gable', { - width: 2.5, - depth: 7, - wallHeight: 0.5, - pitch: 40, - position: [3, 0, 0.75], - rotation: Math.PI / 2, - }) - - expect(getOpenRoofValleys([mansard, gable], 0.35).length).toBeGreaterThan(0) - }) -}) diff --git a/packages/core/src/schema/nodes/roof-valley.ts b/packages/core/src/schema/nodes/roof-valley.ts deleted file mode 100644 index ab0b70964a..0000000000 --- a/packages/core/src/schema/nodes/roof-valley.ts +++ /dev/null @@ -1,385 +0,0 @@ -import type { RoofSegmentNode } from './roof-segment' -import { getSegmentSlopeFrame, ROOF_SHAPE_DEFAULTS } from './roof-segment' -import { - getRoofModuleFaces, - getRoofShapeInsets, - getRoofShapeRatios, - type RoofShapeFaceVertex, -} from './roof-segment-shape' - -export type RoofValleyPoint = { x: number; y: number; z: number } - -export type OpenRoofValley = { - start: RoofValleyPoint - end: RoofValleyPoint - firstEdge: [RoofValleyPoint, RoofValleyPoint] - secondEdge: [RoofValleyPoint, RoofValleyPoint] - segmentIds: [RoofSegmentNode['id'], RoofSegmentNode['id']] -} - -type Point2 = [number, number] -type Plane = { x: number; z: number; constant: number } -type SurfaceFace = { - plane: Plane - polygon: Point2[] - segmentId: RoofSegmentNode['id'] -} - -const EPSILON = 1e-6 -const MIN_VALLEY_LENGTH = 0.08 -const PAN_LIFT = 0.012 - -export function getOpenRoofValleys( - segments: readonly RoofSegmentNode[], - width = 0.35, -): OpenRoofValley[] { - const facesBySegment = segments.map((segment) => buildSurfaceFaces(segment)) - const allFaces = facesBySegment.flat() - const valleys: OpenRoofValley[] = [] - - for (let firstIndex = 0; firstIndex < segments.length; firstIndex++) { - for (let secondIndex = firstIndex + 1; secondIndex < segments.length; secondIndex++) { - const firstFaces = facesBySegment[firstIndex] ?? [] - const secondFaces = facesBySegment[secondIndex] ?? [] - for (const first of firstFaces) { - for (const second of secondFaces) { - const valley = intersectFaces(first, second, allFaces, width) - if (valley && !valleys.some((existing) => sameValley(existing, valley))) { - valleys.push(valley) - } - } - } - } - } - - return valleys -} - -function buildSurfaceFaces(segment: RoofSegmentNode): SurfaceFace[] { - const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment - const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment) - const verticalDeckThickness = activeRh > 0 ? deckThickness / cosTheta : deckThickness - const deckExtension = wallThickness / 2 + overhang * cosTheta - const shingleThickness = segment.shingleThickness ?? 0 - const shingleHorizontalOffset = shingleThickness * sinTheta - const shingleVerticalOffset = shingleThickness * cosTheta - const bottomWidth = Math.max(0.01, width + 2 * deckExtension) - const bottomDepth = Math.max(0.01, depth + 2 * deckExtension) - const deckDrop = deckExtension * tanTheta - const bottomWallHeight = wallHeight - deckDrop + verticalDeckThickness - - let bottomRoofHeight = activeRh - if (activeRh > 0) { - bottomRoofHeight += deckDrop - if (roofType === 'shed') bottomRoofHeight += deckDrop - } - - let topWidth = bottomWidth - let topDepth = bottomDepth - let topTranslationZ = 0 - if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') { - topWidth += 2 * shingleHorizontalOffset - topDepth += 2 * shingleHorizontalOffset - } else if (roofType === 'gable' || roofType === 'gambrel') { - topDepth += 2 * shingleHorizontalOffset - } else if (roofType === 'shed') { - topDepth += shingleHorizontalOffset - topTranslationZ = shingleHorizontalOffset / 2 - } - - const topWallHeight = bottomWallHeight + shingleVerticalOffset - const topRoofHeight = - activeRh > 0 ? bottomRoofHeight + shingleHorizontalOffset * tanTheta : bottomRoofHeight - const availableRadius = (Math.min(bottomWidth, bottomDepth) / 2) * 0.95 - const maximumDrop = tanTheta > 0.001 ? availableRadius / tanTheta : 2 - const topBaseY = bottomWallHeight - Math.min(1, maximumDrop * 0.4) - const dutchHipWidthRatio = segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio - const insets = getRoofShapeInsets({ - roofType, - width, - depth, - wh: topWallHeight, - baseY: topBaseY, - isVoid: false, - brushW: topWidth, - brushD: topDepth, - tanTheta, - shingleThickness, - dutchHipWidthRatio, - }) - const shapeRatios = getRoofShapeRatios({ - gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio, - mansardSteepWidthRatio: segment.mansardSteepWidthRatio, - dutchHipWidthRatio, - dutchHipHeightRatio: segment.dutchHipHeightRatio, - dutchWaistLengthRatio: segment.dutchWaistLengthRatio, - dutchGabletRake: segment.dutchGabletRake, - }) - - return getRoofModuleFaces({ - type: roofType, - w: topWidth, - d: topDepth, - wh: topWallHeight, - rh: topRoofHeight, - baseY: topBaseY, - insets, - baseW: width, - baseD: depth, - tanTheta, - shapeRatios, - dutchTopRakeThickness: segment.dutchTopRakeThickness, - }) - .map((face) => - face.map((point) => - transformPoint( - { ...point, z: point.z + topTranslationZ }, - segment.position, - segment.rotation, - ), - ), - ) - .map((vertices) => ({ vertices, plane: planeFromFace(vertices) })) - .filter( - (face): face is { vertices: RoofShapeFaceVertex[]; plane: Plane } => face.plane !== null, - ) - .map(({ vertices, plane }) => ({ - plane, - polygon: dedupePolygon(vertices.map((point) => [point.x, point.z])), - segmentId: segment.id, - })) - .filter((face) => face.polygon.length >= 3) -} - -function transformPoint( - point: RoofShapeFaceVertex, - position: readonly [number, number, number], - rotation: number, -): RoofShapeFaceVertex { - const cos = Math.cos(rotation) - const sin = Math.sin(rotation) - return { - x: position[0] + point.x * cos + point.z * sin, - y: position[1] + point.y, - z: position[2] - point.x * sin + point.z * cos, - } -} - -function planeFromFace(vertices: readonly RoofShapeFaceVertex[]): Plane | null { - const a = vertices[0] - const b = vertices[1] - const c = vertices[2] - if (!(a && b && c)) return null - const ab = { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z } - const ac = { x: c.x - a.x, y: c.y - a.y, z: c.z - a.z } - let nx = ab.y * ac.z - ab.z * ac.y - let ny = ab.z * ac.x - ab.x * ac.z - let nz = ab.x * ac.y - ab.y * ac.x - if (ny < 0) { - nx = -nx - ny = -ny - nz = -nz - } - if (ny <= EPSILON) return null - return { - x: -nx / ny, - z: -nz / ny, - constant: (nx * a.x + ny * a.y + nz * a.z) / ny, - } -} - -function intersectFaces( - first: SurfaceFace, - second: SurfaceFace, - allFaces: readonly SurfaceFace[], - width: number, -): OpenRoofValley | null { - const equationX = first.plane.x - second.plane.x - const equationZ = first.plane.z - second.plane.z - const equationConstant = first.plane.constant - second.plane.constant - const equationLengthSq = equationX * equationX + equationZ * equationZ - if (equationLengthSq <= EPSILON * EPSILON) return null - - const equationLength = Math.sqrt(equationLengthSq) - const direction: Point2 = [-equationZ / equationLength, equationX / equationLength] - const linePoint: Point2 = [ - (-equationConstant * equationX) / equationLengthSq, - (-equationConstant * equationZ) / equationLengthSq, - ] - const firstInterval = lineIntervalInPolygon(linePoint, direction, first.polygon) - const secondInterval = lineIntervalInPolygon(linePoint, direction, second.polygon) - if (!(firstInterval && secondInterval)) return null - - const startT = Math.max(firstInterval[0], secondInterval[0]) - const endT = Math.min(firstInterval[1], secondInterval[1]) - if (endT - startT < MIN_VALLEY_LENGTH) return null - - const gradient: Point2 = [equationX / equationLength, equationZ / equationLength] - const midpointT = (startT + endT) / 2 - const midpoint: Point2 = [ - linePoint[0] + direction[0] * midpointT, - linePoint[1] + direction[1] * midpointT, - ] - const sampleDistance = Math.min(0.04, (endT - startT) / 4) - const positiveSample: Point2 = [ - midpoint[0] + gradient[0] * sampleDistance, - midpoint[1] + gradient[1] * sampleDistance, - ] - const negativeSample: Point2 = [ - midpoint[0] - gradient[0] * sampleDistance, - midpoint[1] - gradient[1] * sampleDistance, - ] - if ( - !pointInPolygon(positiveSample, first.polygon) || - !pointInPolygon(positiveSample, second.polygon) || - !pointInPolygon(negativeSample, first.polygon) || - !pointInPolygon(negativeSample, second.polygon) - ) { - return null - } - - const firstDominatesPositive = - heightAt(first.plane, positiveSample) > heightAt(second.plane, positiveSample) - const positiveFace = firstDominatesPositive ? first : second - const negativeFace = firstDominatesPositive ? second : first - const seamHeight = heightAt(first.plane, midpoint) - if ( - heightAt(positiveFace.plane, positiveSample) <= seamHeight + EPSILON || - heightAt(negativeFace.plane, negativeSample) <= seamHeight + EPSILON || - !isUpperEnvelopeFace(positiveFace, positiveSample, allFaces) || - !isUpperEnvelopeFace(negativeFace, negativeSample, allFaces) - ) { - return null - } - const halfWidth = Math.max(0.05, width / 2) - const firstOffset: Point2 = firstDominatesPositive - ? [gradient[0] * halfWidth, gradient[1] * halfWidth] - : [-gradient[0] * halfWidth, -gradient[1] * halfWidth] - const secondOffset: Point2 = [-firstOffset[0], -firstOffset[1]] - const start2: Point2 = [ - linePoint[0] + direction[0] * startT, - linePoint[1] + direction[1] * startT, - ] - const end2: Point2 = [linePoint[0] + direction[0] * endT, linePoint[1] + direction[1] * endT] - - return { - start: pointOnPlane(first.plane, start2), - end: pointOnPlane(first.plane, end2), - firstEdge: [ - pointOnPlane(first.plane, [start2[0] + firstOffset[0], start2[1] + firstOffset[1]]), - pointOnPlane(first.plane, [end2[0] + firstOffset[0], end2[1] + firstOffset[1]]), - ], - secondEdge: [ - pointOnPlane(second.plane, [start2[0] + secondOffset[0], start2[1] + secondOffset[1]]), - pointOnPlane(second.plane, [end2[0] + secondOffset[0], end2[1] + secondOffset[1]]), - ], - segmentIds: [first.segmentId, second.segmentId], - } -} - -function isUpperEnvelopeFace( - candidate: SurfaceFace, - point: Point2, - faces: readonly SurfaceFace[], -): boolean { - const candidateHeight = heightAt(candidate.plane, point) - return faces.every( - (face) => - !pointInPolygon(point, face.polygon) || - heightAt(face.plane, point) <= candidateHeight + EPSILON, - ) -} - -function pointOnPlane(plane: Plane, point: Point2): RoofValleyPoint { - return { x: point[0], y: heightAt(plane, point) + PAN_LIFT, z: point[1] } -} - -function heightAt(plane: Plane, point: Point2): number { - return plane.x * point[0] + plane.z * point[1] + plane.constant -} - -function lineIntervalInPolygon( - linePoint: Point2, - direction: Point2, - polygon: readonly Point2[], -): [number, number] | null { - const hits: number[] = [] - for (let index = 0; index < polygon.length; index++) { - const a = polygon[index]! - const b = polygon[(index + 1) % polygon.length]! - const edge: Point2 = [b[0] - a[0], b[1] - a[1]] - const offset: Point2 = [a[0] - linePoint[0], a[1] - linePoint[1]] - const denominator = cross(direction, edge) - if (Math.abs(denominator) <= EPSILON) { - if (Math.abs(cross(offset, direction)) <= EPSILON) { - hits.push(dot(offset, direction)) - hits.push(dot([b[0] - linePoint[0], b[1] - linePoint[1]], direction)) - } - continue - } - const t = cross(offset, edge) / denominator - const edgeT = cross(offset, direction) / denominator - if (edgeT >= -EPSILON && edgeT <= 1 + EPSILON) hits.push(t) - } - if (hits.length < 2) return null - return [Math.min(...hits), Math.max(...hits)] -} - -function pointInPolygon(point: Point2, polygon: readonly Point2[]): boolean { - let inside = false - for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { - const a = polygon[index]! - const b = polygon[previous]! - if (pointOnSegment(point, a, b)) return true - if ( - a[1] > point[1] !== b[1] > point[1] && - point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0] - ) { - inside = !inside - } - } - return inside -} - -function pointOnSegment(point: Point2, a: Point2, b: Point2): boolean { - const ab: Point2 = [b[0] - a[0], b[1] - a[1]] - const ap: Point2 = [point[0] - a[0], point[1] - a[1]] - return ( - Math.abs(cross(ab, ap)) <= EPSILON && - dot(ap, ab) >= -EPSILON && - dot(ap, ab) <= dot(ab, ab) + EPSILON - ) -} - -function dedupePolygon(points: Point2[]): Point2[] { - const result: Point2[] = [] - for (const point of points) { - const previous = result.at(-1) - if (previous && Math.hypot(previous[0] - point[0], previous[1] - point[1]) <= EPSILON) continue - result.push(point) - } - const first = result[0] - const last = result.at(-1) - if (first && last && Math.hypot(first[0] - last[0], first[1] - last[1]) <= EPSILON) result.pop() - return result -} - -function sameValley(first: OpenRoofValley, second: OpenRoofValley): boolean { - const sameDirection = - distance(first.start, second.start) < 0.02 && distance(first.end, second.end) < 0.02 - const oppositeDirection = - distance(first.start, second.end) < 0.02 && distance(first.end, second.start) < 0.02 - return sameDirection || oppositeDirection -} - -function distance(a: RoofValleyPoint, b: RoofValleyPoint): number { - return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) -} - -function cross(a: Point2, b: Point2): number { - return a[0] * b[1] - a[1] * b[0] -} - -function dot(a: Point2, b: Point2): number { - return a[0] * b[0] + a[1] * b[1] -} diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index da2e4b5552..89d806b288 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -22,10 +22,6 @@ export const RoofNode = BaseNode.extend({ edgeMaterialPreset: z.string().optional(), wallMaterial: MaterialSchema.optional(), wallMaterialPreset: z.string().optional(), - openValleyEnabled: z.boolean().default(true), - openValleyWidth: z.number().min(0.1).max(1.2).default(0.35), - valleyMaterial: MaterialSchema.optional(), - valleyMaterialPreset: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), @@ -39,7 +35,6 @@ export const RoofNode = BaseNode.extend({ - position: center position of the roof group - rotation: rotation around Y axis - children: array of RoofSegmentNode IDs - - openValleyEnabled / openValleyWidth: generated metal pans at concave segment junctions `, ) diff --git a/packages/nodes/src/roof-segment/floorplan.ts b/packages/nodes/src/roof-segment/floorplan.ts index fa8b93c186..43a36b634e 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -108,11 +108,9 @@ export function buildRoofSegmentFloorplan( // NOTE: the ridge / hip / break / slope linework is NOT drawn here — the // parent roof's builder (`buildRoofFloorplan`) draws it for every segment, - // clipped against the merged-roof valleys so a segment's ridge stops at - // the junction instead of running on into a neighbour it overlaps. This - // builder owns only the per-segment interaction chrome below. The shape - // math lives in `getRoofSegmentPlanLinework` (exported for the roof - // builder to consume). + // while this builder owns only the per-segment interaction chrome below. + // 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 @@ -192,8 +190,8 @@ export type PlanSeg = readonly [PlanPt, PlanPt] * - break: horizontal fold where the slope angle changes (gambrel kink, * mansard/dutch waist) * - * Exported so the roof-level builder can reuse it to terminate the valley - * diagonals it draws at merged-roof junctions against the segments' ridges. + * Exported so the roof-level builder can reuse the same architectural + * linework for the complete roof plan. */ export function getRoofSegmentPlanLinework(node: RoofSegmentNode): { ridges: PlanSeg[] diff --git a/packages/nodes/src/roof/floorplan.test.ts b/packages/nodes/src/roof/floorplan.test.ts new file mode 100644 index 0000000000..dc53153e1a --- /dev/null +++ b/packages/nodes/src/roof/floorplan.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + type FloorplanGeometry, + type GeometryContext, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' +import { buildRoofFloorplan } from './floorplan' + +function buildContext( + node: ReturnType, + children: AnyNode[], + siblings: AnyNode[], + nodes: Record, +): GeometryContext { + return { + resolve: (id: AnyNodeId) => nodes[id] as N | undefined, + children, + siblings, + parent: null, + } +} + +function outlinePoints(geometry: FloorplanGeometry | null): [number, number][] { + if (geometry?.kind !== 'group') return [] + return geometry.children.flatMap((child) => + child.kind === 'polygon' && child.fill === 'none' ? (child.points as [number, number][]) : [], + ) +} + +describe('buildRoofFloorplan roof intersections', () => { + test('clips the smaller roof footprint and keeps the larger host outline', () => { + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + children: ['rseg_host'], + }) + const enteringRoof = RoofNode.parse({ + id: 'roof_entering', + type: 'roof', + position: [3, 0, 0], + children: ['rseg_entering'], + }) + const hostSegment = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'mansard', + width: 10, + depth: 8, + }) + const enteringSegment = RoofSegmentNode.parse({ + id: 'rseg_entering', + type: 'roof-segment', + parentId: enteringRoof.id, + roofType: 'gable', + width: 8, + depth: 4, + }) + const nodes = { + [hostRoof.id]: hostRoof, + [enteringRoof.id]: enteringRoof, + [hostSegment.id]: hostSegment, + [enteringSegment.id]: enteringSegment, + } + + const enteringGeometry = buildRoofFloorplan( + enteringRoof, + buildContext(enteringRoof, [enteringSegment], [hostRoof], nodes), + ) + const enteringOutline = outlinePoints(enteringGeometry) + expect(Math.min(...enteringOutline.map(([x]) => x))).toBeCloseTo(5, 6) + expect(Math.max(...enteringOutline.map(([x]) => x))).toBeCloseTo(7, 6) + + const hostGeometry = buildRoofFloorplan( + hostRoof, + buildContext(hostRoof, [hostSegment], [enteringRoof], nodes), + ) + const hostOutline = outlinePoints(hostGeometry) + expect(Math.min(...hostOutline.map(([x]) => x))).toBeCloseTo(-5, 6) + expect(Math.max(...hostOutline.map(([x]) => x))).toBeCloseTo(5, 6) + }) +}) diff --git a/packages/nodes/src/roof/floorplan.ts b/packages/nodes/src/roof/floorplan.ts index ccb6800e10..d7c64c2c55 100644 --- a/packages/nodes/src/roof/floorplan.ts +++ b/packages/nodes/src/roof/floorplan.ts @@ -5,45 +5,12 @@ import type { RoofNode, RoofSegmentNode, } from '@pascal-app/core' -import { getOpenRoofValleys } from '@pascal-app/core' -import { unionPolygons } from '@pascal-app/viewer' +import { subtractPolygonsFromPolygon, unionPolygons } from '@pascal-app/viewer' import { getRoofSegmentPlanLinework } from '../roof-segment/floorplan' type Pt = [number, number] type Seg = [Pt, Pt] -function pointInPolygon(px: number, pz: number, poly: readonly Pt[]): boolean { - let inside = false - for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { - const pi = poly[i] as Pt - const pj = poly[j] as Pt - if ( - pi[1] > pz !== pj[1] > pz && - px < ((pj[0] - pi[0]) * (pz - pi[1])) / (pj[1] - pi[1]) + pi[0] - ) { - inside = !inside - } - } - return inside -} - -/** Parametric `t` in (0,1) along `p1→p2` where it crosses segment `a→b`, else null. */ -function segCrossT(p1: Pt, p2: Pt, a: Pt, b: Pt): number | null { - const rx = p2[0] - p1[0] - const rz = p2[1] - p1[1] - const ex = b[0] - a[0] - const ez = b[1] - a[1] - const denom = rx * ez - rz * ex - if (Math.abs(denom) < 1e-12) return null - const wx = a[0] - p1[0] - const wz = a[1] - p1[1] - const t = (wx * ez - wz * ex) / denom - const s = (wx * rz - wz * rx) / denom - if (t <= 1e-4 || t >= 1 - 1e-9) return null - if (s < -1e-6 || s > 1 + 1e-6) return null - return t -} - type SegPlan = { footprint: Pt[] ridges: Seg[] @@ -52,6 +19,12 @@ type SegPlan = { slope: { tail: Pt; head: Pt } | null } +type PlanEntry = { + roof: RoofNode + segment: RoofSegmentNode + plan: SegPlan +} + /** 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) @@ -86,17 +59,72 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { } } +function comparePlanEntryIdentity(a: PlanEntry, b: PlanEntry): number { + const roofOrder = String(a.roof.id).localeCompare(String(b.roof.id)) + return roofOrder !== 0 ? roofOrder : String(a.segment.id).localeCompare(String(b.segment.id)) +} + +function pointInPolygon(point: Pt, polygon: Pt[]): boolean { + let inside = false + for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { + const [x, y] = polygon[index]! + const [px, py] = polygon[previous]! + if (y > point[1] === py > point[1]) continue + const crossingX = ((px - x) * (point[1] - y)) / (py - y) + x + if (point[0] < crossingX) inside = !inside + } + return inside +} + +function segmentIntersectionParameter(line: Seg, edge: Seg): number | null { + const lineX = line[1][0] - line[0][0] + const lineY = line[1][1] - line[0][1] + const edgeX = edge[1][0] - edge[0][0] + const edgeY = edge[1][1] - edge[0][1] + const determinant = lineX * edgeY - lineY * edgeX + if (Math.abs(determinant) <= 1e-9) return null + const offsetX = edge[0][0] - line[0][0] + const offsetY = edge[0][1] - line[0][1] + const lineT = (offsetX * edgeY - offsetY * edgeX) / determinant + const edgeT = (offsetX * lineY - offsetY * lineX) / determinant + return lineT > 1e-9 && lineT < 1 - 1e-9 && edgeT >= -1e-9 && edgeT <= 1 + 1e-9 ? lineT : null +} + +function clipLineByCutters(line: Seg, cutters: Pt[][]): Seg[] { + const parameters = [0, 1] + for (const cutter of cutters) { + for (let index = 0; index < cutter.length; index++) { + const parameter = segmentIntersectionParameter(line, [ + cutter[index]!, + cutter[(index + 1) % cutter.length]!, + ]) + if (parameter !== null) parameters.push(parameter) + } + } + parameters.sort((a, b) => a - b) + + const dx = line[1][0] - line[0][0] + const dy = line[1][1] - line[0][1] + const result: Seg[] = [] + for (let index = 0; index < parameters.length - 1; index++) { + const startT = parameters[index]! + const endT = parameters[index + 1]! + if (endT - startT <= 1e-9) continue + const midT = (startT + endT) / 2 + const midpoint: Pt = [line[0][0] + dx * midT, line[0][1] + dy * midT] + if (cutters.some((cutter) => pointInPolygon(midpoint, cutter))) continue + result.push([ + [line[0][0] + dx * startT, line[0][1] + dy * startT], + [line[0][0] + dx * endT, line[0][1] + dy * endT], + ]) + } + return result +} + /** * Roof-level floor-plan builder. Draws the whole merged-roof plan: the - * unioned silhouette, the valley diagonals at concave junctions, and every - * segment's ridge/hip/break linework — clipped so a line stops at the valley - * where its segment overlaps a neighbour, instead of running on at the - * segment's full length into the cut-away part. - * - * Drawing all the linework here (rather than per-segment) is what lets the - * clip work: the valleys and the neighbouring footprints are all in hand, so - * each line can be trimmed to the actual merged geometry. The segment - * builder keeps only its hit-target / selection chrome. + * unioned silhouette and every segment's ridge/hip/break linework. The + * segment builder keeps only its hit-target / selection chrome. * * Composition uses the floor plan's negated-rotation convention * (segment-local → roof-local → plan). `unionPolygons` returns one ring per @@ -108,23 +136,42 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp const segments = ctx.children.filter((c): c is RoofSegmentNode => c.type === 'roof-segment') if (segments.length === 0) return null - const plans = segments.map((s) => buildSegPlan(node, s)) - const rings = unionPolygons(plans.map((p) => p.footprint)) as Pt[][] - if (rings.length === 0) return null + const entries: PlanEntry[] = segments.map((segment) => ({ + roof: node, + segment, + plan: buildSegPlan(node, segment), + })) + for (const sibling of ctx.siblings) { + if (sibling.type !== 'roof') continue + for (const childId of sibling.children ?? []) { + const segment = ctx.resolve(childId) + if (segment?.type !== 'roof-segment') continue + entries.push({ roof: sibling, segment, plan: buildSegPlan(sibling, segment) }) + } + } - const cosRoof = Math.cos(-node.rotation) - const sinRoof = Math.sin(-node.rotation) - const toPlan = (point: { x: number; z: number }): Pt => [ - node.position[0] + point.x * cosRoof - point.z * sinRoof, - node.position[2] + point.x * sinRoof + point.z * cosRoof, - ] - const valleys: Seg[] = - node.openValleyEnabled !== false - ? getOpenRoofValleys(segments, node.openValleyWidth ?? 0.35).map((valley) => [ - toPlan(valley.start), - toPlan(valley.end), - ]) - : [] + const currentEntries = entries.filter((entry) => entry.roof.id === node.id) + const visiblePlans = currentEntries.map((entry) => { + const area = entry.segment.width * entry.segment.depth + const cutters = entries + .filter((candidate) => { + if (candidate.segment.id === entry.segment.id) return false + if (candidate.segment.roofType === 'shed') return false + const candidateArea = candidate.segment.width * candidate.segment.depth + return ( + candidateArea > area + 1e-6 || + (Math.abs(candidateArea - area) <= 1e-6 && comparePlanEntryIdentity(candidate, entry) < 0) + ) + }) + .map((candidate) => candidate.plan.footprint) + return { + plan: entry.plan, + cutters, + footprints: subtractPolygonsFromPolygon(entry.plan.footprint, cutters) as Pt[][], + } + }) + const rings = unionPolygons(visiblePlans.flatMap(({ footprints }) => footprints)) as Pt[][] + if (rings.length === 0) return null const view = ctx.viewState const palette = view?.palette @@ -163,62 +210,42 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp }) } - // Valley diagonals. - for (const v of valleys) pushLine(v[0], v[1], hipWidth) - - // Per-segment ridge / hip / break linework, clipped to the merged geometry: - // an endpoint that overshoots into another segment is pulled back to the - // valley it crosses (the junction), so a ridge stops at the diagonal. - const footprints = plans.map((p) => p.footprint) - const clipEnd = (pt: Pt, other: Pt, ownIndex: number): Pt => { - let inOther = false - for (let i = 0; i < footprints.length; i++) { - if (i === ownIndex) continue - if (pointInPolygon(pt[0], pt[1], footprints[i] as Pt[])) { - inOther = true - break + for (const { plan, cutters } of visiblePlans) { + for (const line of plan.breaks) { + for (const visible of clipLineByCutters(line, cutters)) { + pushLine(visible[0], visible[1], hipWidth) } } - if (!inOther) return pt - let bestT = Number.POSITIVE_INFINITY // nearest valley crossing to the overshoot - for (const v of valleys) { - const t = segCrossT(pt, other, v[0], v[1]) - if (t !== null && t < bestT) bestT = t + for (const line of plan.hips) { + for (const visible of clipLineByCutters(line, cutters)) { + pushLine(visible[0], visible[1], hipWidth) + } + } + for (const line of plan.ridges) { + for (const visible of clipLineByCutters(line, cutters)) { + pushLine(visible[0], visible[1], ridgeWidth) + } } - if (!Number.isFinite(bestT)) return pt // overshoots but no valley to stop at - return [pt[0] + (other[0] - pt[0]) * bestT, pt[1] + (other[1] - pt[1]) * bestT] - } - const clipPush = (line: Seg, width: number, ownIndex: number) => { - const a = clipEnd(line[0], line[1], ownIndex) - const b = clipEnd(line[1], a, ownIndex) - const dx = a[0] - b[0] - const dz = a[1] - b[1] - if (dx * dx + dz * dz < 1e-8) return - pushLine(a, b, width) - } - - plans.forEach((p, idx) => { - for (const s of p.breaks) clipPush(s, hipWidth, idx) - for (const s of p.hips) clipPush(s, hipWidth, idx) - for (const s of p.ridges) clipPush(s, ridgeWidth, idx) - // Shed downslope arrow (no overshoot to clip). - if (p.slope) { - const { tail, head } = p.slope - const dx = head[0] - tail[0] - const dz = head[1] - tail[1] + if (plan.slope) { + const { tail, head } = plan.slope + const visibleSlope = clipLineByCutters([tail, head], cutters).at(-1) + if (!visibleSlope) continue + const [visibleTail, visibleHead] = visibleSlope + const dx = visibleHead[0] - visibleTail[0] + const dz = visibleHead[1] - visibleTail[1] const len = Math.hypot(dx, dz) || 1 const ux = dx / len const uz = dz / len const headLen = Math.min(0.22, len * 0.4) const wing = headLen * 0.6 - pushLine(tail, head, hipWidth) + pushLine(visibleTail, visibleHead, hipWidth) children.push({ kind: 'polyline', points: [ - [head[0] - headLen * ux - wing * uz, head[1] - headLen * uz + wing * ux], - [head[0], head[1]], - [head[0] - headLen * ux + wing * uz, head[1] - headLen * uz - wing * ux], + [visibleHead[0] - headLen * ux - wing * uz, visibleHead[1] - headLen * uz + wing * ux], + [visibleHead[0], visibleHead[1]], + [visibleHead[0] - headLen * ux + wing * uz, visibleHead[1] - headLen * uz - wing * ux], ], stroke: ink, strokeWidth: hipWidth, @@ -227,7 +254,7 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp pointerEvents: 'none', }) } - }) + } return children.length > 0 ? { kind: 'group', children } : null } diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index 7893cd648a..a14400bc43 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -23,7 +23,6 @@ import { PanelWrapper, SegmentedControl, SliderControl, - ToggleControl, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -344,26 +343,6 @@ export default function RoofPanel() {
- - handleUpdate({ openValleyEnabled })} - /> - {node.openValleyEnabled !== false && ( - handleUpdate({ openValleyWidth })} - precision={2} - step={0.05} - unit="m" - value={node.openValleyWidth ?? 0.35} - /> - )} - -
diff --git a/packages/nodes/src/roof/renderer.tsx b/packages/nodes/src/roof/renderer.tsx index 83cb7f2d1e..1dad773f6e 100644 --- a/packages/nodes/src/roof/renderer.tsx +++ b/packages/nodes/src/roof/renderer.tsx @@ -9,17 +9,9 @@ import { useRegistry, useScene, } from '@pascal-app/core' -import { - createDefaultMaterial, - createMaterial, - createMaterialFromPresetRef, - getRoofMaterialArray, - NodeRenderer, - useNodeEvents, - useViewer, -} from '@pascal-app/viewer' +import { getRoofMaterialArray, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import * as THREE from 'three' +import type * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' import { getRoofDebugMaterials, getRoofMaterials } from './roof-materials' @@ -100,19 +92,6 @@ export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => { const material = debugColors ? getRoofDebugMaterials(shading) : customMaterial || getRoofMaterials(shading, textures, colorPreset) - const valleyMaterial = useMemo(() => { - const material = node.valleyMaterial - ? createMaterial(node.valleyMaterial, shading) - : node.valleyMaterialPreset - ? createMaterialFromPresetRef(node.valleyMaterialPreset, shading) - : null - const result = material ?? createDefaultMaterial('#d5dde5', 0.24, shading, THREE.DoubleSide) - result.polygonOffset = true - result.polygonOffsetFactor = -2 - result.polygonOffsetUnits = -2 - return result - }, [node.valleyMaterial, node.valleyMaterialPreset, shading]) - useEffect(() => { return () => { placeholderGeometry.dispose() @@ -134,14 +113,6 @@ export const RoofRenderer = ({ node: rawNode }: { node: RoofNode }) => { name="merged-roof" receiveShadow /> - {}} - receiveShadow - renderOrder={2} - /> {unpaintedSegmentIds.map((childId) => ( diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 5fcd55eaba..f1d478f599 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -128,7 +128,7 @@ export { WHITE_PALETTE, } from './lib/materials' export { mergedOutline } from './lib/merged-outline-node' -export { unionPolygons } from './lib/polygon-union' +export { subtractPolygonsFromPolygon, unionPolygons } from './lib/polygon-union' export { detectRendererCapability, initializeGpuRenderer, diff --git a/packages/viewer/src/systems/roof/open-valley-geometry.test.ts b/packages/viewer/src/systems/roof/open-valley-geometry.test.ts deleted file mode 100644 index 5c7354c00f..0000000000 --- a/packages/viewer/src/systems/roof/open-valley-geometry.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { RoofSegmentNode } from '@pascal-app/core' -import { buildOpenValleyGeometry } from './open-valley-geometry' - -describe('buildOpenValleyGeometry', () => { - test('builds finite indexed pan geometry for a roof junction', () => { - const first = RoofSegmentNode.parse({ - id: 'rseg_first', - type: 'roof-segment', - roofType: 'gable', - width: 8, - depth: 6, - wallHeight: 3, - pitch: 30, - }) - const second = RoofSegmentNode.parse({ - id: 'rseg_second', - type: 'roof-segment', - roofType: 'gable', - width: 8, - depth: 6, - wallHeight: 3, - pitch: 30, - position: [2.5, 0, 2.5], - rotation: Math.PI / 2, - }) - - const geometry = buildOpenValleyGeometry([first, second], 0.35) - const position = geometry.getAttribute('position') - - expect(position.count).toBeGreaterThan(0) - expect(geometry.getIndex()?.count).toBeGreaterThan(0) - for (const value of position.array) expect(value).toBeFinite() - geometry.dispose() - }) - - test('returns a render-safe placeholder when no valley exists', () => { - const first = RoofSegmentNode.parse({ id: 'rseg_first', type: 'roof-segment' }) - const second = RoofSegmentNode.parse({ - id: 'rseg_second', - type: 'roof-segment', - position: [20, 0, 0], - }) - - const geometry = buildOpenValleyGeometry([first, second], 0.35) - - expect(geometry.getAttribute('position').count).toBe(3) - expect(geometry.getAttribute('normal').count).toBe(3) - expect(geometry.getIndex()?.count).toBe(3) - geometry.dispose() - }) -}) diff --git a/packages/viewer/src/systems/roof/open-valley-geometry.ts b/packages/viewer/src/systems/roof/open-valley-geometry.ts deleted file mode 100644 index d3f13c3d07..0000000000 --- a/packages/viewer/src/systems/roof/open-valley-geometry.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { getOpenRoofValleys, type RoofSegmentNode, type RoofValleyPoint } from '@pascal-app/core' -import * as THREE from 'three' - -export function buildOpenValleyGeometry( - segments: readonly RoofSegmentNode[], - width: number, -): THREE.BufferGeometry { - const valleys = getOpenRoofValleys(segments, width) - const positions: number[] = [] - const uvs: number[] = [] - const indices: number[] = [] - - for (const valley of valleys) { - appendPanel(valley.start, valley.end, valley.firstEdge[0], valley.firstEdge[1]) - appendPanel(valley.end, valley.start, valley.secondEdge[1], valley.secondEdge[0]) - } - - const geometry = new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) - geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) - geometry.setIndex(indices) - if (positions.length > 0) { - geometry.computeVertexNormals() - geometry.computeBoundingBox() - geometry.computeBoundingSphere() - } else { - geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) - geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) - geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) - geometry.setIndex([0, 1, 2]) - } - return geometry - - function appendPanel( - centerStart: RoofValleyPoint, - centerEnd: RoofValleyPoint, - edgeStart: RoofValleyPoint, - edgeEnd: RoofValleyPoint, - ) { - const baseIndex = positions.length / 3 - const length = Math.hypot( - centerEnd.x - centerStart.x, - centerEnd.y - centerStart.y, - centerEnd.z - centerStart.z, - ) - pushPoint(centerStart) - pushPoint(edgeStart) - pushPoint(centerEnd) - pushPoint(edgeEnd) - uvs.push(0, 0, width / 2, 0, 0, length, width / 2, length) - indices.push( - baseIndex, - baseIndex + 1, - baseIndex + 2, - baseIndex + 2, - baseIndex + 1, - baseIndex + 3, - ) - } - - function pushPoint(point: RoofValleyPoint) { - positions.push(point.x, point.y, point.z) - } -} diff --git a/packages/viewer/src/systems/roof/roof-layer-trim.test.ts b/packages/viewer/src/systems/roof/roof-layer-trim.test.ts index 9c1927d7a4..1d522a17bb 100644 --- a/packages/viewer/src/systems/roof/roof-layer-trim.test.ts +++ b/packages/viewer/src/systems/roof/roof-layer-trim.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { RoofNode, RoofSegmentNode } from '@pascal-app/core' +import { LevelNode, RoofNode, RoofSegmentNode } from '@pascal-app/core' import * as THREE from 'three' import { Brush, Evaluator } from 'three-bvh-csg' import { prepareBrushForCSG } from '../../lib/csg-utils' @@ -120,4 +120,342 @@ describe('subtractRoofInterior', () => { mansardWithSibling.dispose() }) + + test('clips an entering gable created as a separate roof on the same level', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_mansard', 'roof_gable'], + }) + const mansardRoof = RoofNode.parse({ + id: 'roof_mansard', + type: 'roof', + parentId: level.id, + children: ['rseg_mansard'], + }) + const gableRoof = RoofNode.parse({ + id: 'roof_gable', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_gable'], + }) + const mansard = RoofSegmentNode.parse({ + id: 'rseg_mansard', + type: 'roof-segment', + parentId: mansardRoof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const gable = RoofSegmentNode.parse({ + id: 'rseg_gable', + type: 'roof-segment', + parentId: gableRoof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + rotation: Math.PI / 2, + }) + const nodes = { + [level.id]: level, + [mansardRoof.id]: mansardRoof, + [gableRoof.id]: gableRoof, + [mansard.id]: mansard, + [gable.id]: gable, + } + + const unclipped = generateRoofSegmentGeometry(gable) + const clipped = generateRoofSegmentGeometry(gable, nodes) + const ray = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + + expect(ray.intersectObject(new THREE.Mesh(unclipped)).length).toBeGreaterThan(0) + expect(ray.intersectObject(new THREE.Mesh(clipped))).toHaveLength(0) + + unclipped.dispose() + clipped.dispose() + }) + + test('keeps equal-area roof ownership stable when level children are reordered', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_z_gable', 'roof_a_mansard'], + }) + const mansardRoof = RoofNode.parse({ + id: 'roof_a_mansard', + type: 'roof', + parentId: level.id, + children: ['rseg_mansard'], + }) + const gableRoof = RoofNode.parse({ + id: 'roof_z_gable', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_gable'], + }) + const mansard = RoofSegmentNode.parse({ + id: 'rseg_mansard', + type: 'roof-segment', + parentId: mansardRoof.id, + roofType: 'mansard', + width: 10, + depth: 4, + wallHeight: 3, + pitch: 30, + }) + const gable = RoofSegmentNode.parse({ + id: 'rseg_gable', + type: 'roof-segment', + parentId: gableRoof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + rotation: Math.PI / 2, + }) + const nodes = { + [level.id]: level, + [mansardRoof.id]: mansardRoof, + [gableRoof.id]: gableRoof, + [mansard.id]: mansard, + [gable.id]: gable, + } + const ray = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + + const unclipped = generateRoofSegmentGeometry(gable) + const clippedBeforeReorder = generateRoofSegmentGeometry(gable, nodes) + const unclippedHitCount = ray.intersectObject(new THREE.Mesh(unclipped)).length + const clippedBeforeHitCount = ray.intersectObject(new THREE.Mesh(clippedBeforeReorder)).length + expect(clippedBeforeHitCount).toBeLessThan(unclippedHitCount) + + const reorderedLevel = LevelNode.parse({ + ...level, + children: ['roof_a_mansard', 'roof_z_gable'], + }) + const clippedAfterReorder = generateRoofSegmentGeometry(gable, { + ...nodes, + [level.id]: reorderedLevel, + }) + expect(ray.intersectObject(new THREE.Mesh(clippedAfterReorder))).toHaveLength( + clippedBeforeHitCount, + ) + + unclipped.dispose() + clippedBeforeReorder.dispose() + clippedAfterReorder.dispose() + }) + + test('clips two entering roofs against one larger host roof', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_host', 'roof_east', 'roof_west'], + }) + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + parentId: level.id, + children: ['rseg_host'], + }) + const eastRoof = RoofNode.parse({ + id: 'roof_east', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_east'], + }) + const westRoof = RoofNode.parse({ + id: 'roof_west', + type: 'roof', + parentId: level.id, + position: [-3, 0, 0], + children: ['rseg_west'], + }) + const host = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'mansard', + width: 12, + depth: 10, + wallHeight: 3, + pitch: 30, + }) + const east = RoofSegmentNode.parse({ + id: 'rseg_east', + type: 'roof-segment', + parentId: eastRoof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + rotation: Math.PI / 2, + }) + const west = RoofSegmentNode.parse({ + ...east, + id: 'rseg_west', + parentId: westRoof.id, + }) + const nodes = { + [level.id]: level, + [hostRoof.id]: hostRoof, + [eastRoof.id]: eastRoof, + [westRoof.id]: westRoof, + [host.id]: host, + [east.id]: east, + [west.id]: west, + } + + const eastGeometry = generateRoofSegmentGeometry(east, nodes) + const westGeometry = generateRoofSegmentGeometry(west, nodes) + const eastRay = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + const westRay = new THREE.Raycaster(new THREE.Vector3(0, 10, 2), new THREE.Vector3(0, -1, 0)) + + expect(eastRay.intersectObject(new THREE.Mesh(eastGeometry))).toHaveLength(0) + expect(westRay.intersectObject(new THREE.Mesh(westGeometry))).toHaveLength(0) + + eastGeometry.dispose() + westGeometry.dispose() + }) + + test('clips a custom-footprint lean-to deck against a separate host roof', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_host', 'roof_lean_to'], + }) + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + parentId: level.id, + children: ['rseg_host'], + }) + const leanToRoof = RoofNode.parse({ + id: 'roof_lean_to', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_lean_to'], + }) + const host = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const leanTo = RoofSegmentNode.parse({ + id: 'rseg_lean_to', + type: 'roof-segment', + parentId: leanToRoof.id, + roofType: 'shed', + width: 8, + depth: 4, + wallHeight: 3, + pitch: 15, + overhang: 0, + shedFootprintPieces: [ + [ + [-4, -2], + [4, -2], + [4, 2], + [-4, 2], + ], + ], + }) + const nodes = { + [level.id]: level, + [hostRoof.id]: hostRoof, + [leanToRoof.id]: leanToRoof, + [host.id]: host, + [leanTo.id]: leanTo, + } + const unclipped = generateRoofSegmentGeometry(leanTo) + const clipped = generateRoofSegmentGeometry(leanTo, nodes) + + expect(clipped.getAttribute('position').count).not.toBe( + unclipped.getAttribute('position').count, + ) + + unclipped.dispose() + clipped.dispose() + }) + + test('uses every segment in a multi-segment host roof as an occluder', () => { + const level = LevelNode.parse({ + id: 'level_main', + type: 'level', + children: ['roof_host', 'roof_entering'], + }) + const hostRoof = RoofNode.parse({ + id: 'roof_host', + type: 'roof', + parentId: level.id, + children: ['rseg_far', 'rseg_host'], + }) + const enteringRoof = RoofNode.parse({ + id: 'roof_entering', + type: 'roof', + parentId: level.id, + position: [3, 0, 0], + children: ['rseg_entering'], + }) + const farSegment = RoofSegmentNode.parse({ + id: 'rseg_far', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'gable', + width: 2, + depth: 2, + wallHeight: 3, + pitch: 30, + position: [20, 0, 0], + }) + const hostSegment = RoofSegmentNode.parse({ + id: 'rseg_host', + type: 'roof-segment', + parentId: hostRoof.id, + roofType: 'mansard', + width: 10, + depth: 8, + wallHeight: 3, + pitch: 30, + }) + const enteringSegment = RoofSegmentNode.parse({ + id: 'rseg_entering', + type: 'roof-segment', + parentId: enteringRoof.id, + roofType: 'gable', + width: 8, + depth: 5, + wallHeight: 3, + pitch: 35, + rotation: Math.PI / 2, + }) + const nodes = { + [level.id]: level, + [hostRoof.id]: hostRoof, + [enteringRoof.id]: enteringRoof, + [farSegment.id]: farSegment, + [hostSegment.id]: hostSegment, + [enteringSegment.id]: enteringSegment, + } + const ray = new THREE.Raycaster(new THREE.Vector3(0, 10, -2), new THREE.Vector3(0, -1, 0)) + + const clipped = generateRoofSegmentGeometry(enteringSegment, nodes) + expect(ray.intersectObject(new THREE.Mesh(clipped))).toHaveLength(0) + + clipped.dispose() + }) }) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index c76c32d061..d09204f498 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -28,7 +28,6 @@ import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' -import { buildOpenValleyGeometry } from './open-valley-geometry' import { subtractRoofInterior } from './roof-layer-trim' function csgGeometry(brush: Brush): THREE.BufferGeometry { @@ -158,6 +157,19 @@ const warnedMergedRoofNaNIds = new Set() const MAX_ROOFS_PER_FRAME = 1 const MAX_SEGMENTS_PER_FRAME = 3 +function queueSiblingRoofUpdates(roofId: AnyNodeId, nodes: Record) { + pendingRoofUpdates.add(roofId) + const roof = nodes[roofId] + if (roof?.type !== 'roof' || !roof.parentId) return + const parent = nodes[roof.parentId as AnyNodeId] + if (!parent || !('children' in parent) || !Array.isArray(parent.children)) return + for (const siblingId of parent.children) { + if (nodes[siblingId as AnyNodeId]?.type === 'roof') { + pendingRoofUpdates.add(siblingId as AnyNodeId) + } + } +} + // ============================================================================ // ROOF SYSTEM // ============================================================================ @@ -261,10 +273,10 @@ export const RoofSystem = () => { } // Queue the parent roof for a merged geometry update if (effectiveSegment.parentId) { - pendingRoofUpdates.add(effectiveSegment.parentId as AnyNodeId) + queueSiblingRoofUpdates(effectiveSegment.parentId as AnyNodeId, nodes) } } else if (node.type === 'roof') { - pendingRoofUpdates.add(id as AnyNodeId) + queueSiblingRoofUpdates(id as AnyNodeId, nodes) clearDirty(id as AnyNodeId) } }) @@ -484,14 +496,6 @@ function updateMergedRoofGeometry( const mergedMesh = group.getObjectByName('merged-roof') as THREE.Mesh | undefined if (!mergedMesh) return - const allChildren = (roofNode.children ?? []) - .map((id) => { - const sceneNode = nodes[id] as RoofSegmentNode | undefined - return sceneNode ? getEffectiveNode(sceneNode) : undefined - }) - .filter((node): node is RoofSegmentNode => node !== undefined) - updateOpenValleyGeometry(roofNode, group, allChildren) - // Segments that carry their own material / preset (catch-all or any of // the role-specific fields) are rendered as their own per-segment mesh // in `RoofRenderer` so the painted material is preserved. Exclude them @@ -534,13 +538,14 @@ function updateMergedRoofGeometry( () => buildCustomShedGeometry(child), ) if (directGeometry) { - const withPanels = addShedInsetEndPanels(directGeometry, [child], false) + let withPanels = addShedInsetEndPanels(directGeometry, [child], false) _matrix.compose( _position.set(child.position[0], child.position[1], child.position[2]), _quaternion.setFromAxisAngle(_yAxis, child.rotation), _scale, ) withPanels.applyMatrix4(_matrix) + withPanels = clipDirectRoofGeometryAgainstSiblings(withPanels, child, nodes, 'roof') directSegmentGeometries.push(withPanels) continue } @@ -706,21 +711,6 @@ function updateMergedRoofGeometry( } } -function updateOpenValleyGeometry( - roofNode: RoofNode, - group: THREE.Group, - segments: readonly RoofSegmentNode[], -) { - const mesh = group.getObjectByName('open-valleys') as THREE.Mesh | undefined - if (!mesh) return - const geometry = - roofNode.openValleyEnabled !== false - ? buildOpenValleyGeometry(segments, roofNode.openValleyWidth ?? 0.35) - : new THREE.BufferGeometry() - mesh.geometry.dispose() - mesh.geometry = geometry -} - function geometryHasInvalidAttributes(geometry: THREE.BufferGeometry) { const position = geometry.getAttribute('position') if (!(position && position.count > 0)) return true @@ -1623,7 +1613,10 @@ export function generateRoofSegmentGeometry( buildCustomShedGeometry(node), ) if (directShedGeometry) { - const result = addShedInsetEndPanels(directShedGeometry, [node], false) + let result = addShedInsetEndPanels(directShedGeometry, [node], false) + if (nodes) { + result = clipDirectRoofGeometryAgainstSiblings(result, node, nodes, 'segment') + } result.computeVertexNormals() ensureRenderableGeometryAttributes(result) return result @@ -1713,6 +1706,46 @@ export function generateRoofSegmentGeometry( return resultGeo } +function clipDirectRoofGeometryAgainstSiblings( + geometry: THREE.BufferGeometry, + node: RoofSegmentNode, + nodes: Record, + space: 'roof' | 'segment', +): THREE.BufferGeometry { + const siblingInterior = buildOccludingRoofInterior(node, nodes, space) + if (!siblingInterior) return geometry + + const brush = new Brush(geometry, dummyMats) + prepareBrushForCSG(brush) + try { + const clipped = subtractRoofInterior(brush, siblingInterior, csgEvaluator) + const clippedGeometry = csgGeometry(clipped) + const clippedMaterials = csgMaterials(clipped) + const materialIndices = new Map([ + [dummyMats[0], 0], + [dummyMats[1], 1], + [dummyMats[2], 2], + [dummyMats[3], 3], + ]) + for (const group of clippedGeometry.groups) { + group.materialIndex = mapRoofGroupMaterialIndex( + group.materialIndex, + clippedMaterials, + materialIndices, + ) + } + geometry.dispose() + clippedGeometry.computeVertexNormals() + ensureRenderableGeometryAttributes(clippedGeometry) + return clippedGeometry + } catch (error) { + console.error('Direct roof intersection CSG failed:', error) + return geometry + } finally { + siblingInterior.geometry.dispose() + } +} + function buildOccludingRoofInterior( node: RoofSegmentNode, nodes: Record, @@ -1722,41 +1755,35 @@ function buildOccludingRoofInterior( const parent = nodes[node.parentId as AnyNodeId] if (parent?.type !== 'roof') return null - const currentInverse = - space === 'segment' - ? new THREE.Matrix4() - .compose( - new THREE.Vector3(...node.position), - new THREE.Quaternion().setFromAxisAngle(_yAxis, node.rotation ?? 0), - new THREE.Vector3(1, 1, 1), - ) - .invert() - : new THREE.Matrix4() - const currentIndex = parent.children?.indexOf(node.id) ?? -1 + const roofEntries = collectSiblingRoofEntries(parent, nodes) + const currentEntry = roofEntries.find(({ segment }) => segment.id === node.id) + if (!currentEntry) return null const currentArea = node.width * node.depth + const targetRoofInverse = composeRoofTransform(parent).invert() + const targetSegmentInverse = composeSegmentTransform(node).invert() let combinedInterior: Brush | null = null - for (const siblingId of parent.children ?? []) { - if (siblingId === node.id) continue - const storedSibling = nodes[siblingId as AnyNodeId] - if (storedSibling?.type !== 'roof-segment') continue - const sibling = getEffectiveNode(storedSibling) + for (let siblingIndex = 0; siblingIndex < roofEntries.length; siblingIndex++) { + const entry = roofEntries[siblingIndex]! + const sibling = entry.segment + if (sibling.id === node.id) continue if (sibling.roofType === 'shed') continue const siblingArea = sibling.width * sibling.depth - const siblingIndex = parent.children?.indexOf(siblingId) ?? -1 const siblingOwnsOverlap = siblingArea > currentArea + 1e-6 || - (Math.abs(siblingArea - currentArea) <= 1e-6 && siblingIndex < currentIndex) + (Math.abs(siblingArea - currentArea) <= 1e-6 && + compareRoofEntryIdentity(entry, currentEntry) < 0) if (!siblingOwnsOverlap) continue const siblingBrushes = getRoofSegmentBrushes(sibling) if (!siblingBrushes) continue - const siblingMatrix = new THREE.Matrix4().compose( - new THREE.Vector3(...sibling.position), - new THREE.Quaternion().setFromAxisAngle(_yAxis, sibling.rotation ?? 0), - new THREE.Vector3(1, 1, 1), - ) - const relativeMatrix = new THREE.Matrix4().multiplyMatrices(currentInverse, siblingMatrix) + const siblingInTargetRoof = new THREE.Matrix4() + .multiplyMatrices(targetRoofInverse, composeRoofTransform(entry.roof)) + .multiply(composeSegmentTransform(sibling)) + const relativeMatrix = + space === 'segment' + ? new THREE.Matrix4().multiplyMatrices(targetSegmentInverse, siblingInTargetRoof) + : siblingInTargetRoof csgGeometry(siblingBrushes.innerBrush).applyMatrix4(relativeMatrix) siblingBrushes.innerBrush.updateMatrixWorld() @@ -1783,6 +1810,50 @@ function buildOccludingRoofInterior( return combinedInterior } +function compareRoofEntryIdentity( + a: { roof: RoofNode; segment: RoofSegmentNode }, + b: { roof: RoofNode; segment: RoofSegmentNode }, +): number { + const roofOrder = String(a.roof.id).localeCompare(String(b.roof.id)) + return roofOrder !== 0 ? roofOrder : String(a.segment.id).localeCompare(String(b.segment.id)) +} + +function collectSiblingRoofEntries( + targetRoof: RoofNode, + nodes: Record, +): Array<{ roof: RoofNode; segment: RoofSegmentNode }> { + const parent = targetRoof.parentId ? nodes[targetRoof.parentId as AnyNodeId] : undefined + const orderedRoofIds = + parent && 'children' in parent && Array.isArray(parent.children) + ? parent.children.filter((id): id is RoofNode['id'] => nodes[id]?.type === 'roof') + : [targetRoof.id] + if (!orderedRoofIds.includes(targetRoof.id)) orderedRoofIds.push(targetRoof.id) + + return orderedRoofIds.flatMap((roofId) => { + const roof = getEffectiveNode(nodes[roofId] as RoofNode) + return (roof.children ?? []).flatMap((segmentId) => { + const segment = nodes[segmentId as AnyNodeId] + return segment?.type === 'roof-segment' ? [{ roof, segment: getEffectiveNode(segment) }] : [] + }) + }) +} + +function composeRoofTransform(roof: RoofNode): THREE.Matrix4 { + return new THREE.Matrix4().compose( + new THREE.Vector3(...roof.position), + new THREE.Quaternion().setFromAxisAngle(_yAxis, roof.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ) +} + +function composeSegmentTransform(segment: RoofSegmentNode): THREE.Matrix4 { + return new THREE.Matrix4().compose( + new THREE.Vector3(...segment.position), + new THREE.Quaternion().setFromAxisAngle(_yAxis, segment.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ) +} + // ============================================================================ // FACE-BASED GEOMETRY HELPERS (ported from prototype) // ============================================================================ From 9d72e228e170d6ba2c7f01976d050cdb7eb2988f Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 14:14:35 +0530 Subject: [PATCH 8/9] refactor: align roof features with architecture --- packages/core/src/index.ts | 15 +++ .../src/lib/polygon-union.test.ts | 2 +- .../{viewer => core}/src/lib/polygon-union.ts | 0 packages/core/src/lib/roof-overlap.test.ts | 28 +++++ packages/core/src/lib/roof-overlap.ts | 95 ++++++++++++++++ packages/core/src/schema/nodes/box-vent.ts | 5 +- packages/core/src/schema/nodes/cupola.ts | 7 +- packages/core/src/schema/nodes/downspout.ts | 1 + .../core/src/schema/nodes/eyebrow-vent.ts | 5 +- packages/core/src/schema/nodes/gutter.ts | 1 + .../core/src/schema/nodes/turbine-vent.ts | 5 +- .../use-scene-wall-slot-migration.test.ts | 31 ++++++ packages/core/src/store/use-scene.ts | 67 +++++++++++ .../systems/roof/roof-edit-system.tsx | 6 +- .../systems/roof/roof-edit-visibility.test.ts | 18 --- .../systems/roof/roof-edit-visibility.ts | 9 -- .../tools/roof/roof-draft-orientation.test.ts | 48 -------- .../tools/roof/roof-draft-orientation.ts | 18 --- .../src/components/tools/roof/roof-tool.tsx | 14 ++- .../src/box-vent/__tests__/paint.test.ts | 35 +++--- packages/nodes/src/box-vent/definition.ts | 6 +- packages/nodes/src/box-vent/paint.ts | 104 ++++++------------ packages/nodes/src/box-vent/renderer.tsx | 31 +++--- .../nodes/src/cupola/__tests__/paint.test.ts | 34 +++--- packages/nodes/src/cupola/definition.ts | 7 +- packages/nodes/src/cupola/paint.ts | 67 +++-------- packages/nodes/src/cupola/renderer.tsx | 44 +++----- packages/nodes/src/downspout/definition.ts | 3 +- packages/nodes/src/downspout/renderer.tsx | 20 +++- .../src/eyebrow-vent/__tests__/paint.test.ts | 29 +++-- packages/nodes/src/eyebrow-vent/definition.ts | 6 +- packages/nodes/src/eyebrow-vent/paint.ts | 61 +++------- packages/nodes/src/eyebrow-vent/renderer.tsx | 30 +++-- packages/nodes/src/gutter/definition.ts | 3 +- packages/nodes/src/gutter/renderer.tsx | 20 +++- packages/nodes/src/roof/floorplan.ts | 39 ++++--- packages/nodes/src/shared/slot-paint.ts | 4 +- packages/nodes/src/shared/surface-paint.ts | 45 ++------ packages/nodes/src/site/renderer.tsx | 2 +- .../src/turbine-vent/__tests__/paint.test.ts | 29 +++-- packages/nodes/src/turbine-vent/definition.ts | 6 +- packages/nodes/src/turbine-vent/paint.ts | 57 ++-------- packages/nodes/src/turbine-vent/renderer.tsx | 32 +++--- packages/viewer/src/index.ts | 1 - packages/viewer/src/lib/csg-utils.ts | 8 +- .../src/systems/roof/roof-layer-trim.ts | 8 -- ...st.ts => roof-system-intersection.test.ts} | 7 +- .../viewer/src/systems/roof/roof-system.tsx | 101 ++++++++++++----- .../viewer/src/systems/slab/slab-system.tsx | 2 +- .../src/systems/surface-hole-geometry.ts | 2 +- 50 files changed, 632 insertions(+), 586 deletions(-) rename packages/{viewer => core}/src/lib/polygon-union.test.ts (99%) rename packages/{viewer => core}/src/lib/polygon-union.ts (100%) create mode 100644 packages/core/src/lib/roof-overlap.test.ts create mode 100644 packages/core/src/lib/roof-overlap.ts delete mode 100644 packages/editor/src/components/systems/roof/roof-edit-visibility.test.ts delete mode 100644 packages/editor/src/components/systems/roof/roof-edit-visibility.ts delete mode 100644 packages/editor/src/components/tools/roof/roof-draft-orientation.test.ts delete mode 100644 packages/editor/src/components/tools/roof/roof-draft-orientation.ts delete mode 100644 packages/viewer/src/systems/roof/roof-layer-trim.ts rename packages/viewer/src/systems/roof/{roof-layer-trim.test.ts => roof-system-intersection.test.ts} (98%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cc7825ff01..72ff0972fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,21 @@ export { polygonsOverlap, segmentsIntersect, } from './lib/polygon-relations' +export { + type Point2D as PolygonBooleanPoint2D, + subtractPolygonsFromPolygon, + unionPolygons, +} from './lib/polygon-union' +export { + compareRoofOverlapIdentity, + getRoofPlanBounds, + type RoofOverlapEntry, + type RoofPlan, + type RoofPlanBounds, + type RoofPlanSegment, + roofOverlapEntryOwns, + roofPlanBoundsOverlap, +} from './lib/roof-overlap' export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' export { getRenderableSlabPolygon, diff --git a/packages/viewer/src/lib/polygon-union.test.ts b/packages/core/src/lib/polygon-union.test.ts similarity index 99% rename from packages/viewer/src/lib/polygon-union.test.ts rename to packages/core/src/lib/polygon-union.test.ts index 0621d94f1a..d0c872eef4 100644 --- a/packages/viewer/src/lib/polygon-union.test.ts +++ b/packages/core/src/lib/polygon-union.test.ts @@ -1,4 +1,4 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// @ts-expect-error — bun:test is provided by the Bun runtime; core does not // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' import { type Point2D, subtractPolygonsFromPolygon, unionPolygons } from './polygon-union' diff --git a/packages/viewer/src/lib/polygon-union.ts b/packages/core/src/lib/polygon-union.ts similarity index 100% rename from packages/viewer/src/lib/polygon-union.ts rename to packages/core/src/lib/polygon-union.ts diff --git a/packages/core/src/lib/roof-overlap.test.ts b/packages/core/src/lib/roof-overlap.test.ts new file mode 100644 index 0000000000..8fc9a814c4 --- /dev/null +++ b/packages/core/src/lib/roof-overlap.test.ts @@ -0,0 +1,28 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; core does not depend on @types/bun. +import { describe, expect, test } from 'bun:test' +import { getRoofPlanBounds, roofOverlapEntryOwns, roofPlanBoundsOverlap } from './roof-overlap' + +describe('roof overlap', () => { + test('larger segments own intersections with stable ID tie-breaking', () => { + const current = { roofId: 'roof_b', segmentId: 'seg_b', width: 4, depth: 4 } + expect( + roofOverlapEntryOwns({ ...current, roofId: 'roof_a', segmentId: 'seg_a' }, current), + ).toBe(true) + expect(roofOverlapEntryOwns({ ...current, width: 5 }, current)).toBe(true) + expect(roofOverlapEntryOwns({ ...current, width: 3 }, current)).toBe(false) + }) + + test('computes rotated world bounds and rejects distant roofs', () => { + const bounds = getRoofPlanBounds({ + position: [10, 0, 4], + rotation: Math.PI / 2, + segments: [{ position: [0, 0, 0], rotation: 0, width: 6, depth: 2 }], + })! + expect(bounds.minX).toBeCloseTo(9) + expect(bounds.maxX).toBeCloseTo(11) + expect(bounds.minZ).toBeCloseTo(1) + expect(bounds.maxZ).toBeCloseTo(7) + expect(roofPlanBoundsOverlap(bounds, { minX: 10, minZ: 6, maxX: 12, maxZ: 8 })).toBe(true) + expect(roofPlanBoundsOverlap(bounds, { minX: 20, minZ: 20, maxX: 22, maxZ: 22 })).toBe(false) + }) +}) diff --git a/packages/core/src/lib/roof-overlap.ts b/packages/core/src/lib/roof-overlap.ts new file mode 100644 index 0000000000..aaec60551e --- /dev/null +++ b/packages/core/src/lib/roof-overlap.ts @@ -0,0 +1,95 @@ +export type RoofOverlapEntry = { + roofId: string + segmentId: string + width: number + depth: number +} + +export type RoofPlanBounds = { + minX: number + minZ: number + maxX: number + maxZ: number +} + +export type RoofPlanSegment = { + position: readonly [number, number, number] + rotation?: number + width: number + depth: number +} + +export type RoofPlan = { + position: readonly [number, number, number] + rotation?: number + segments: readonly RoofPlanSegment[] +} + +export function compareRoofOverlapIdentity(a: RoofOverlapEntry, b: RoofOverlapEntry): number { + const roofOrder = a.roofId.localeCompare(b.roofId) + return roofOrder !== 0 ? roofOrder : a.segmentId.localeCompare(b.segmentId) +} + +export function roofOverlapEntryOwns( + candidate: RoofOverlapEntry, + current: RoofOverlapEntry, + epsilon = 1e-6, +): boolean { + const candidateArea = candidate.width * candidate.depth + const currentArea = current.width * current.depth + return ( + candidateArea > currentArea + epsilon || + (Math.abs(candidateArea - currentArea) <= epsilon && + compareRoofOverlapIdentity(candidate, current) < 0) + ) +} + +export function getRoofPlanBounds(roof: RoofPlan): RoofPlanBounds | null { + if (roof.segments.length === 0) return null + const roofRotation = roof.rotation ?? 0 + const roofCos = Math.cos(roofRotation) + const roofSin = Math.sin(roofRotation) + const bounds: RoofPlanBounds = { + minX: Number.POSITIVE_INFINITY, + minZ: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxZ: Number.NEGATIVE_INFINITY, + } + + for (const segment of roof.segments) { + const segmentRotation = segment.rotation ?? 0 + const segmentCos = Math.cos(segmentRotation) + const segmentSin = Math.sin(segmentRotation) + const halfWidth = Math.max(0, segment.width) / 2 + const halfDepth = Math.max(0, segment.depth) / 2 + for (const [x, z] of [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] as const) { + const roofX = segment.position[0] + x * segmentCos + z * segmentSin + const roofZ = segment.position[2] - x * segmentSin + z * segmentCos + const worldX = roof.position[0] + roofX * roofCos + roofZ * roofSin + const worldZ = roof.position[2] - roofX * roofSin + roofZ * roofCos + bounds.minX = Math.min(bounds.minX, worldX) + bounds.minZ = Math.min(bounds.minZ, worldZ) + bounds.maxX = Math.max(bounds.maxX, worldX) + bounds.maxZ = Math.max(bounds.maxZ, worldZ) + } + } + return bounds +} + +export function roofPlanBoundsOverlap( + a: RoofPlanBounds, + b: RoofPlanBounds, + epsilon = 1e-6, +): boolean { + return !( + a.maxX < b.minX - epsilon || + b.maxX < a.minX - epsilon || + a.maxZ < b.minZ - epsilon || + b.maxZ < a.minZ - epsilon + ) +} diff --git a/packages/core/src/schema/nodes/box-vent.ts b/packages/core/src/schema/nodes/box-vent.ts index f1b920b20f..124ade6f73 100644 --- a/packages/core/src/schema/nodes/box-vent.ts +++ b/packages/core/src/schema/nodes/box-vent.ts @@ -11,16 +11,13 @@ export const BoxVentNode = BaseNode.extend({ type: nodeType('box-vent'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Default to the white preset so newly-placed vents read as clean // painted metal — and so the paint inspector shows "White" as the // current selection instead of an empty "no material" state, which // made it look like the vent had nothing applied even though the // renderer was falling back to white internally. materialPreset: z.string().default('preset-white'), - baseMaterial: MaterialSchema.optional(), - baseMaterialPreset: z.string().optional(), - topMaterial: MaterialSchema.optional(), - topMaterialPreset: z.string().optional(), roofSegmentId: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/cupola.ts b/packages/core/src/schema/nodes/cupola.ts index d0e790a6a3..98d3805883 100644 --- a/packages/core/src/schema/nodes/cupola.ts +++ b/packages/core/src/schema/nodes/cupola.ts @@ -11,15 +11,10 @@ export const CupolaNode = BaseNode.extend({ type: nodeType('cupola'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Default to the white preset so a freshly-placed cupola reads as clean // painted metal and the paint inspector shows "White" (matches box-vent). materialPreset: z.string().default('preset-white'), - baseMaterial: MaterialSchema.optional(), - baseMaterialPreset: z.string().optional(), - bodyMaterial: MaterialSchema.optional(), - bodyMaterialPreset: z.string().optional(), - roofMaterial: MaterialSchema.optional(), - roofMaterialPreset: z.string().optional(), roofSegmentId: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/downspout.ts b/packages/core/src/schema/nodes/downspout.ts index 174e3de092..d89ac96b44 100644 --- a/packages/core/src/schema/nodes/downspout.ts +++ b/packages/core/src/schema/nodes/downspout.ts @@ -10,6 +10,7 @@ export const DownspoutNode = BaseNode.extend({ type: nodeType('downspout'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Match the gutter family default — paint inspector reads "White" // instead of "no material" on a freshly placed downspout. materialPreset: z.string().default('preset-white'), diff --git a/packages/core/src/schema/nodes/eyebrow-vent.ts b/packages/core/src/schema/nodes/eyebrow-vent.ts index 4171afd925..e77074899e 100644 --- a/packages/core/src/schema/nodes/eyebrow-vent.ts +++ b/packages/core/src/schema/nodes/eyebrow-vent.ts @@ -11,13 +11,10 @@ export const EyebrowVentNode = BaseNode.extend({ type: nodeType('eyebrow-vent'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Default to the white preset so a freshly-placed vent reads as clean // painted metal and the paint inspector shows "White" (matches box-vent). materialPreset: z.string().default('preset-white'), - hoodMaterial: MaterialSchema.optional(), - hoodMaterialPreset: z.string().optional(), - frontMaterial: MaterialSchema.optional(), - frontMaterialPreset: z.string().optional(), roofSegmentId: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts index 57e1517309..3372108fa8 100644 --- a/packages/core/src/schema/nodes/gutter.ts +++ b/packages/core/src/schema/nodes/gutter.ts @@ -51,6 +51,7 @@ export const GutterNode = BaseNode.extend({ type: nodeType('gutter'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // White preset by default — matches the rest of the roof accessory // family (box-vent / ridge-vent) so the paint inspector reads as // "White" instead of "no material" on a freshly-placed gutter. diff --git a/packages/core/src/schema/nodes/turbine-vent.ts b/packages/core/src/schema/nodes/turbine-vent.ts index 34dfa05ac2..3fbc5cbb41 100644 --- a/packages/core/src/schema/nodes/turbine-vent.ts +++ b/packages/core/src/schema/nodes/turbine-vent.ts @@ -11,14 +11,11 @@ export const TurbineVentNode = BaseNode.extend({ type: nodeType('turbine-vent'), material: MaterialSchema.optional(), + slots: z.record(z.string(), z.string()).optional(), // Default to the white preset so a freshly-placed turbine reads as // clean painted/galvanised metal and the paint inspector shows "White" // as the current selection (matches box-vent's reasoning). materialPreset: z.string().default('preset-white'), - baseMaterial: MaterialSchema.optional(), - baseMaterialPreset: z.string().optional(), - headMaterial: MaterialSchema.optional(), - headMaterialPreset: z.string().optional(), roofSegmentId: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/store/use-scene-wall-slot-migration.test.ts b/packages/core/src/store/use-scene-wall-slot-migration.test.ts index ef1e90af82..76e0f647d5 100644 --- a/packages/core/src/store/use-scene-wall-slot-migration.test.ts +++ b/packages/core/src/store/use-scene-wall-slot-migration.test.ts @@ -261,4 +261,35 @@ describe('procedural kind surface-material → slots migration', () => { expect(slab.slots).toBeUndefined() expect(Object.keys(useScene.getState().materials)).toHaveLength(0) }) + + test('roof accessory role materials migrate to their matching slots', () => { + useScene.getState().setScene( + sceneWithNode({ + type: 'box-vent', + baseMaterialPreset: 'library:metal-steel', + topMaterialPreset: 'library:metal-copper', + }), + ['site_test'] as never, + ) + + const vent = (useScene.getState().nodes as Record).node_test! + expect(vent.slots).toEqual({ + base: 'library:metal-steel', + top: 'library:metal-copper', + }) + expect((vent as { baseMaterialPreset?: unknown }).baseMaterialPreset).toBeUndefined() + expect((vent as { topMaterialPreset?: unknown }).topMaterialPreset).toBeUndefined() + }) + + test('gutter and downspout legacy paint migrates to the surface slot', () => { + for (const type of ['gutter', 'downspout'] as const) { + useScene + .getState() + .setScene(sceneWithNode({ type, materialPreset: 'library:metal-steel' }), [ + 'site_test', + ] as never) + const node = (useScene.getState().nodes as Record).node_test! + expect(node.slots).toEqual({ surface: 'library:metal-steel' }) + } + }) }) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 3481bab410..cd5f23a772 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -381,6 +381,37 @@ function migrateSingleMaterialSlots( return { ...node, slots, material: undefined, materialPreset: undefined } } +function migrateRoleMaterialSlots( + node: Record, + roles: readonly string[], + mintedMaterials: Record, +) { + const slots: Record = { ...(node.slots ?? {}) } + const next = { ...node } + let changed = false + + for (const role of roles) { + if (slots[role] === undefined) { + const ref = legacySpecToMaterialRef( + { + material: node[`${role}Material`] ?? node.material, + materialPreset: node[`${role}MaterialPreset`] ?? node.materialPreset, + }, + mintedMaterials, + ) + if (ref) { + slots[role] = ref + changed = true + } + } + if (`${role}Material` in next || `${role}MaterialPreset` in next) changed = true + delete next[`${role}Material`] + delete next[`${role}MaterialPreset`] + } + + return changed ? { ...next, slots } : node +} + // Stair carries per-role legacy fields (`treadMaterial*` / `sideMaterial*` / // `railingMaterial*`) plus a catch-all. Map each to its slot via the same // fallback chain the renderer uses (`getEffectiveStairSurfaceMaterial`): @@ -838,6 +869,42 @@ function migrateNodes(nodes: Record): { ) } + if (node.type === 'gutter' || node.type === 'downspout') { + patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials) + } + + if (node.type === 'box-vent') { + patchedNodes[id] = migrateRoleMaterialSlots( + patchedNodes[id], + ['base', 'top'], + mintedMaterials, + ) + } + + if (node.type === 'cupola') { + patchedNodes[id] = migrateRoleMaterialSlots( + patchedNodes[id], + ['base', 'body', 'roof'], + mintedMaterials, + ) + } + + if (node.type === 'eyebrow-vent') { + patchedNodes[id] = migrateRoleMaterialSlots( + patchedNodes[id], + ['hood', 'front'], + mintedMaterials, + ) + } + + if (node.type === 'turbine-vent') { + patchedNodes[id] = migrateRoleMaterialSlots( + patchedNodes[id], + ['base', 'head'], + mintedMaterials, + ) + } + if (node.type === 'shelf') { const normalized = normalizeShelfNode(node) if (normalized) { diff --git a/packages/editor/src/components/systems/roof/roof-edit-system.tsx b/packages/editor/src/components/systems/roof/roof-edit-system.tsx index 01abbea798..33a0b9fcfa 100644 --- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx +++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx @@ -35,7 +35,6 @@ import { isHistoryShortcut } from '../../../lib/history' import { getHoveredRoofSegmentOutlineProxyName } from '../../../lib/roof-hover-outline-proxy' import useInteractionScope, { useMovingNode } from '../../../store/use-interaction-scope' import { swallowNextClick } from '../../editor/handles/use-handle-drag' -import { getRoofEditVisibility } from './roof-edit-visibility' // Empty placeholder geometry used when we reveal segments-wrapper for // accessory editing. The roof's CSG-merged shell is the only thing @@ -1841,9 +1840,8 @@ export const RoofEditSystem = () => { const isMoving = movingRoofIds.has(roofId) const isReveal = revealRoofIds.has(roofId) - const visibility = getRoofEditVisibility({ isMoving, isReveal }) - if (mergedMesh) mergedMesh.visible = visibility.merged - if (segmentsWrapper) segmentsWrapper.visible = visibility.segments + if (mergedMesh) mergedMesh.visible = true + if (segmentsWrapper) segmentsWrapper.visible = isReveal const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined if (roofNode?.children?.length) { diff --git a/packages/editor/src/components/systems/roof/roof-edit-visibility.test.ts b/packages/editor/src/components/systems/roof/roof-edit-visibility.test.ts deleted file mode 100644 index 452528f535..0000000000 --- a/packages/editor/src/components/systems/roof/roof-edit-visibility.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { getRoofEditVisibility } from './roof-edit-visibility' - -describe('getRoofEditVisibility', () => { - test('keeps the merged shell visible while a roof segment moves', () => { - expect(getRoofEditVisibility({ isMoving: true, isReveal: false })).toEqual({ - merged: true, - segments: false, - }) - }) - - test('reveals accessory portals without replacing the merged shell', () => { - expect(getRoofEditVisibility({ isMoving: false, isReveal: true })).toEqual({ - merged: true, - segments: true, - }) - }) -}) diff --git a/packages/editor/src/components/systems/roof/roof-edit-visibility.ts b/packages/editor/src/components/systems/roof/roof-edit-visibility.ts deleted file mode 100644 index 91af68b7ac..0000000000 --- a/packages/editor/src/components/systems/roof/roof-edit-visibility.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function getRoofEditVisibility(input: { isMoving: boolean; isReveal: boolean }): { - merged: boolean - segments: boolean -} { - return { - merged: true, - segments: input.isReveal, - } -} diff --git a/packages/editor/src/components/tools/roof/roof-draft-orientation.test.ts b/packages/editor/src/components/tools/roof/roof-draft-orientation.test.ts deleted file mode 100644 index dc729ed4d8..0000000000 --- a/packages/editor/src/components/tools/roof/roof-draft-orientation.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { resolveRoofDraftPlacement } from './roof-draft-orientation' - -describe('resolveRoofDraftPlacement', () => { - test('keeps the default draft axes', () => { - expect(resolveRoofDraftPlacement(8, 5, false)).toEqual({ - width: 8, - depth: 5, - rotation: 0, - }) - }) - - test('swaps dimensions and turns the segment by 90 degrees', () => { - const placement = resolveRoofDraftPlacement(8, 5, true) - expect(placement).toEqual({ - width: 5, - depth: 8, - rotation: Math.PI / 2, - }) - - const halfWidth = placement.width / 2 - const halfDepth = placement.depth / 2 - const cos = Math.cos(placement.rotation) - const sin = Math.sin(placement.rotation) - const localCorners: Array<[number, number]> = [ - [-halfWidth, -halfDepth], - [halfWidth, -halfDepth], - [halfWidth, halfDepth], - [-halfWidth, halfDepth], - ] - const corners: Array<[number, number]> = localCorners.map(([x, z]) => [ - x * cos + z * sin, - -x * sin + z * cos, - ]) - const xs = corners.map(([x]) => x) - const zs = corners.map(([, z]) => z) - expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(8) - expect(Math.max(...zs) - Math.min(...zs)).toBeCloseTo(5) - }) - - test('keeps the drafted world axes inside a rotated parent roof', () => { - expect(resolveRoofDraftPlacement(8, 5, true, Math.PI / 4)).toEqual({ - width: 5, - depth: 8, - rotation: Math.PI / 4, - }) - }) -}) diff --git a/packages/editor/src/components/tools/roof/roof-draft-orientation.ts b/packages/editor/src/components/tools/roof/roof-draft-orientation.ts deleted file mode 100644 index 3f6515d28e..0000000000 --- a/packages/editor/src/components/tools/roof/roof-draft-orientation.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type RoofDraftPlacement = { - depth: number - rotation: number - width: number -} - -export function resolveRoofDraftPlacement( - footprintWidth: number, - footprintDepth: number, - quarterTurn: boolean, - parentRotation = 0, -): RoofDraftPlacement { - return { - width: quarterTurn ? footprintDepth : footprintWidth, - depth: quarterTurn ? footprintWidth : footprintDepth, - rotation: -parentRotation + (quarterTurn ? Math.PI / 2 : 0), - } -} diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index be23defa61..5eec0908ea 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -33,12 +33,24 @@ import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview' import { CursorSphere } from '../shared/cursor-sphere' -import { resolveRoofDraftPlacement } from './roof-draft-orientation' const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_PITCH_DEG = 40 const GRID_OFFSET = 0.02 +function resolveRoofDraftPlacement( + footprintWidth: number, + footprintDepth: number, + quarterTurn: boolean, + parentRotation = 0, +) { + return { + width: quarterTurn ? footprintDepth : footprintWidth, + depth: quarterTurn ? footprintWidth : footprintDepth, + rotation: -parentRotation + (quarterTurn ? Math.PI / 2 : 0), + } +} + // Walls that are direct children of a level. function getLevelWalls( levelId: string | null, diff --git a/packages/nodes/src/box-vent/__tests__/paint.test.ts b/packages/nodes/src/box-vent/__tests__/paint.test.ts index 3c2850c24f..e8cf1d0ecb 100644 --- a/packages/nodes/src/box-vent/__tests__/paint.test.ts +++ b/packages/nodes/src/box-vent/__tests__/paint.test.ts @@ -1,11 +1,6 @@ import { describe, expect, test } from 'bun:test' import { Group, Mesh, MeshBasicMaterial } from 'three' -import { - boxVentPaint, - buildBoxVentMaterialPatch, - getEffectiveBoxVentMaterial, - resolveBoxVentMaterialRole, -} from '../paint' +import { boxVentPaint, resolveBoxVentMaterialRole } from '../paint' import { BoxVentNode } from '../schema' describe('box vent paint', () => { @@ -15,24 +10,24 @@ describe('box vent paint', () => { }) test('updates only the painted role', () => { - expect(buildBoxVentMaterialPatch('base', undefined, 'library:metal-steel')).toEqual({ - baseMaterial: undefined, - baseMaterialPreset: 'library:metal-steel', - }) - expect(buildBoxVentMaterialPatch('top', undefined, 'library:roof-shingle')).toEqual({ - topMaterial: undefined, - topMaterialPreset: 'library:roof-shingle', + const node = BoxVentNode.parse({ slots: { base: 'library:metal-steel' } }) + expect( + boxVentPaint.buildPatch({ + node, + role: 'top', + material: undefined, + materialPreset: 'library:roof-shingle', + }), + ).toEqual({ + slots: { base: 'library:metal-steel', top: 'library:roof-shingle' }, }) }) test('keeps the legacy whole-vent material as an independent fallback', () => { - const node = BoxVentNode.parse({ - materialPreset: 'preset-white', - baseMaterialPreset: 'library:metal-steel', - }) - - expect(getEffectiveBoxVentMaterial(node, 'base').materialPreset).toBe('library:metal-steel') - expect(getEffectiveBoxVentMaterial(node, 'top').materialPreset).toBe('preset-white') + const node = BoxVentNode.parse({ materialPreset: 'preset-white' }) + expect( + boxVentPaint.getEffectiveMaterial?.({ node, role: 'top', nodes: {} })?.materialPreset, + ).toBe('preset-white') }) test('previews the top without replacing the base material', () => { diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index 0f86e0769f..4ef4295c2e 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -175,7 +175,7 @@ const boxVentHandles: HandleDescriptor[] = [ */ export const boxVentDefinition: NodeDefinition = { kind: 'box-vent', - schemaVersion: 2, + schemaVersion: 3, schema: BoxVentNode, category: 'structure', surfaceRole: 'roof', @@ -187,6 +187,10 @@ export const boxVentDefinition: NodeDefinition = { }, capabilities: { + slots: () => [ + { slotId: 'base', label: 'Base', default: 'library:preset-softwhite' }, + { slotId: 'top', label: 'Top', default: 'library:preset-softwhite' }, + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/box-vent/paint.ts b/packages/nodes/src/box-vent/paint.ts index b1d51e86b6..00e7cc751f 100644 --- a/packages/nodes/src/box-vent/paint.ts +++ b/packages/nodes/src/box-vent/paint.ts @@ -1,83 +1,41 @@ -import type { - BoxVentMaterialRole, - BoxVentNode, - MaterialSchema, - PaintCapability, -} from '@pascal-app/core' -import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' -import type { Material, Mesh, Object3D } from 'three' +import type { AnyNode, BoxVentMaterialRole, MaterialSchema } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' import { BOX_VENT_MATERIAL_INDEX } from './geometry' +type LegacyBoxVent = AnyNode & { material?: MaterialSchema; materialPreset?: string } + export function resolveBoxVentMaterialRole(materialIndex: number | null): BoxVentMaterialRole { return materialIndex === BOX_VENT_MATERIAL_INDEX.top ? 'top' : 'base' } -export function buildBoxVentMaterialPatch( - role: BoxVentMaterialRole, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - return role === 'top' - ? { topMaterial: material, topMaterialPreset: materialPreset } - : { baseMaterial: material, baseMaterialPreset: materialPreset } -} - -export function getEffectiveBoxVentMaterial( - node: BoxVentNode, - role: BoxVentMaterialRole, -): { material: MaterialSchema | undefined; materialPreset: string | undefined } { - const material = role === 'top' ? node.topMaterial : node.baseMaterial - const materialPreset = role === 'top' ? node.topMaterialPreset : node.baseMaterialPreset - if (material !== undefined || materialPreset !== undefined) { - return { material, materialPreset } - } - return { material: node.material, materialPreset: node.materialPreset } -} - -function buildPreviewMaterial( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Material | null { - if (materialPreset) return createMaterialFromPresetRef(materialPreset) - if (material) return createMaterial(material) - return null -} - -function applyBoxVentPreview( - role: BoxVentMaterialRole, - previewMaterial: Material, - root: Object3D, -): (() => void) | null { - const materialIndex = BOX_VENT_MATERIAL_INDEX[role] - const restores: Array<() => void> = [] - root.traverse((object) => { - const mesh = object as Mesh - if (!mesh.isMesh || mesh.name !== 'box-vent-surface' || !Array.isArray(mesh.material)) return - const previous = [...mesh.material] - if (!previous[materialIndex]) return - const next = [...previous] - next[materialIndex] = previewMaterial - mesh.material = next - restores.push(() => { - mesh.material = previous - }) - }) - if (restores.length === 0) return null - return () => { - for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() - } -} - -export const boxVentPaint: PaintCapability = { +export const boxVentPaint = createSlotPaintCapability({ materialTarget: 'box-vent', resolveRole: ({ materialIndex }) => resolveBoxVentMaterialRole(materialIndex), - buildPatch: ({ role, material, materialPreset }) => - buildBoxVentMaterialPatch(role as BoxVentMaterialRole, material, materialPreset), applyPreview: ({ role, material, materialPreset, root }) => { - const previewMaterial = buildPreviewMaterial(material, materialPreset) - if (!previewMaterial) return null - return applyBoxVentPreview(role as BoxVentMaterialRole, previewMaterial, root) + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return null + const materialIndex = BOX_VENT_MATERIAL_INDEX[role as BoxVentMaterialRole] + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.name !== 'box-vent-surface' || !Array.isArray(mesh.material)) return + const previous = [...mesh.material] + if (!previous[materialIndex]) return + const next = [...previous] + next[materialIndex] = preview + mesh.material = next + restores.push(() => { + mesh.material = previous + }) + }) + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } }, - getEffectiveMaterial: ({ node, role }) => - getEffectiveBoxVentMaterial(node as BoxVentNode, role as BoxVentMaterialRole), -} + legacyEffective: (node) => { + const legacy = node as LegacyBoxVent + return { material: legacy.material, materialPreset: legacy.materialPreset } + }, +}) diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index e9a0a5e367..83e078df5b 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -56,6 +57,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) // Merge live overrides (panel slider drags) on top of the store node. // Sliders write here on every `onChange` and only flush to the scene @@ -115,35 +117,28 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { // faces of the vent body / hood wouldn't drop out when looking up at the // eaves; that's now a known visual tradeoff — a closed-solid extrude in // `geometry.ts` is the right fix if undersides become noticeable. - const hasBaseMaterial = node.baseMaterial !== undefined || node.baseMaterialPreset !== undefined - const baseMaterial = hasBaseMaterial ? node.baseMaterial : node.material - const baseMaterialPreset = hasBaseMaterial ? node.baseMaterialPreset : node.materialPreset - const hasTopMaterial = node.topMaterial !== undefined || node.topMaterialPreset !== undefined - const topMaterial = hasTopMaterial ? node.topMaterial : node.material - const topMaterialPreset = hasTopMaterial ? node.topMaterialPreset : node.materialPreset const material = useMemo(() => { const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) if (!textures) return [roleDefault, roleDefault] - const resolve = ( - roleMaterial: BoxVentNode['material'], - roleMaterialPreset: string | undefined, - ) => { - if (roleMaterial) return createMaterial(roleMaterial, shading) - if (roleMaterialPreset) { - return createMaterialFromPresetRef(roleMaterialPreset, shading) ?? defaultMaterial + const resolve = (role: 'base' | 'top') => { + const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (node.material) return createMaterial(node.material, shading) + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial } return roleDefault } - return [resolve(baseMaterial, baseMaterialPreset), resolve(topMaterial, topMaterialPreset)] + return [resolve('base'), resolve('top')] }, [ textures, colorPreset, sceneTheme, shading, - baseMaterial, - baseMaterialPreset, - topMaterial, - topMaterialPreset, + node.slots, + node.material, + node.materialPreset, + sceneMaterials, ]) // Compose slope tilt + yaw onto a single quaternion so the registered diff --git a/packages/nodes/src/cupola/__tests__/paint.test.ts b/packages/nodes/src/cupola/__tests__/paint.test.ts index d1b24a0b1a..db0de50e01 100644 --- a/packages/nodes/src/cupola/__tests__/paint.test.ts +++ b/packages/nodes/src/cupola/__tests__/paint.test.ts @@ -1,9 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { - buildCupolaMaterialPatch, - getEffectiveCupolaMaterial, - resolveCupolaMaterialRole, -} from '../paint' +import { cupolaPaint, resolveCupolaMaterialRole } from '../paint' import { CupolaNode } from '../schema' describe('cupola paint', () => { @@ -14,20 +10,26 @@ describe('cupola paint', () => { }) test('updates only the selected construction part', () => { - expect(buildCupolaMaterialPatch('body', undefined, 'library:louver')).toEqual({ - bodyMaterial: undefined, - bodyMaterialPreset: 'library:louver', - }) - expect(buildCupolaMaterialPatch('roof', undefined, 'library:copper')).toEqual({ - roofMaterial: undefined, - roofMaterialPreset: 'library:copper', + const node = CupolaNode.parse({ slots: { body: 'library:louver' } }) + expect( + cupolaPaint.buildPatch({ + node, + role: 'roof', + material: undefined, + materialPreset: 'library:copper', + }), + ).toEqual({ + slots: { body: 'library:louver', roof: 'library:copper' }, }) }) test('uses the legacy material only for roles without an override', () => { - const node = CupolaNode.parse({ bodyMaterialPreset: 'library:louver' }) - expect(getEffectiveCupolaMaterial(node, 'body').materialPreset).toBe('library:louver') - expect(getEffectiveCupolaMaterial(node, 'base').materialPreset).toBe('preset-white') - expect(getEffectiveCupolaMaterial(node, 'roof').materialPreset).toBe('preset-white') + const node = CupolaNode.parse({ slots: { body: 'library:louver' } }) + expect( + cupolaPaint.getEffectiveMaterial?.({ node, role: 'body', nodes: {} })?.materialPreset, + ).toBe('library:louver') + expect( + cupolaPaint.getEffectiveMaterial?.({ node, role: 'base', nodes: {} })?.materialPreset, + ).toBe('preset-white') }) }) diff --git a/packages/nodes/src/cupola/definition.ts b/packages/nodes/src/cupola/definition.ts index 1b588d2e0e..3eb88078aa 100644 --- a/packages/nodes/src/cupola/definition.ts +++ b/packages/nodes/src/cupola/definition.ts @@ -108,7 +108,7 @@ const cupolaHandles: HandleDescriptor[] = [ */ export const cupolaDefinition: NodeDefinition = { kind: 'cupola', - schemaVersion: 2, + schemaVersion: 3, schema: CupolaNode, category: 'structure', surfaceRole: 'roof', @@ -120,6 +120,11 @@ export const cupolaDefinition: NodeDefinition = { }, capabilities: { + slots: () => [ + { slotId: 'base', label: 'Base', default: 'library:preset-softwhite' }, + { slotId: 'body', label: 'Body', default: 'library:preset-softwhite' }, + { slotId: 'roof', label: 'Roof', default: 'library:preset-softwhite' }, + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/cupola/paint.ts b/packages/nodes/src/cupola/paint.ts index fb3e469188..ad7d41d7ef 100644 --- a/packages/nodes/src/cupola/paint.ts +++ b/packages/nodes/src/cupola/paint.ts @@ -1,71 +1,30 @@ -import type { - CupolaMaterialRole, - CupolaNode, - MaterialSchema, - PaintCapability, -} from '@pascal-app/core' -import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' -import type { Material, Mesh, Object3D } from 'three' +import type { AnyNode, CupolaMaterialRole, MaterialSchema } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' import { CUPOLA_MATERIAL_INDEX } from './geometry' +type LegacyCupola = AnyNode & { material?: MaterialSchema; materialPreset?: string } + export function resolveCupolaMaterialRole(materialIndex: number | null): CupolaMaterialRole { if (materialIndex === CUPOLA_MATERIAL_INDEX.body) return 'body' if (materialIndex === CUPOLA_MATERIAL_INDEX.roof) return 'roof' return 'base' } -export function buildCupolaMaterialPatch( - role: CupolaMaterialRole, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - if (role === 'body') return { bodyMaterial: material, bodyMaterialPreset: materialPreset } - if (role === 'roof') return { roofMaterial: material, roofMaterialPreset: materialPreset } - return { baseMaterial: material, baseMaterialPreset: materialPreset } -} - -export function getEffectiveCupolaMaterial( - node: CupolaNode, - role: CupolaMaterialRole, -): { material: MaterialSchema | undefined; materialPreset: string | undefined } { - const material = - role === 'body' ? node.bodyMaterial : role === 'roof' ? node.roofMaterial : node.baseMaterial - const materialPreset = - role === 'body' - ? node.bodyMaterialPreset - : role === 'roof' - ? node.roofMaterialPreset - : node.baseMaterialPreset - return material !== undefined || materialPreset !== undefined - ? { material, materialPreset } - : { material: node.material, materialPreset: node.materialPreset } -} - -function previewMaterial( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Material | null { - if (materialPreset) return createMaterialFromPresetRef(materialPreset) - if (material) return createMaterial(material) - return null -} - -export const cupolaPaint: PaintCapability = { +export const cupolaPaint = createSlotPaintCapability({ materialTarget: 'cupola', resolveRole: ({ materialIndex }) => resolveCupolaMaterialRole(materialIndex), - buildPatch: ({ role, material, materialPreset }) => - buildCupolaMaterialPatch(role as CupolaMaterialRole, material, materialPreset), applyPreview: ({ role, material, materialPreset, root }) => { - const preview = previewMaterial(material, materialPreset) + const preview = buildSlotPreviewMaterial(material, materialPreset) if (!preview) return null - const index = CUPOLA_MATERIAL_INDEX[role as CupolaMaterialRole] + const materialIndex = CUPOLA_MATERIAL_INDEX[role as CupolaMaterialRole] let restore: (() => void) | null = null ;(root as Object3D).traverse((object) => { const mesh = object as Mesh if (!mesh.isMesh || mesh.name !== 'cupola-surface' || !Array.isArray(mesh.material)) return const previous = [...mesh.material] const next = [...previous] - next[index] = preview + next[materialIndex] = preview mesh.material = next restore = () => { mesh.material = previous @@ -73,6 +32,8 @@ export const cupolaPaint: PaintCapability = { }) return restore }, - getEffectiveMaterial: ({ node, role }) => - getEffectiveCupolaMaterial(node as CupolaNode, role as CupolaMaterialRole), -} + legacyEffective: (node) => { + const legacy = node as LegacyCupola + return { material: legacy.material, materialPreset: legacy.materialPreset } + }, +}) diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index b0bafb8361..d704f68c7c 100644 --- a/packages/nodes/src/cupola/renderer.tsx +++ b/packages/nodes/src/cupola/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -21,7 +22,6 @@ import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildCupolaGeometry } from './geometry' -import { getEffectiveCupolaMaterial } from './paint' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, @@ -43,6 +43,7 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, @@ -68,47 +69,28 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) - const { material: baseMaterial, materialPreset: baseMaterialPreset } = getEffectiveCupolaMaterial( - node, - 'base', - ) - const { material: bodyMaterial, materialPreset: bodyMaterialPreset } = getEffectiveCupolaMaterial( - node, - 'body', - ) - const { material: roofMaterial, materialPreset: roofMaterialPreset } = getEffectiveCupolaMaterial( - node, - 'roof', - ) const material = useMemo(() => { const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) - const resolve = ( - roleMaterial: CupolaNode['material'], - roleMaterialPreset: string | undefined, - ) => { + const resolve = (role: 'base' | 'body' | 'roof') => { if (!textures) return roleDefault - if (roleMaterial) return createMaterial(roleMaterial, shading) - if (roleMaterialPreset) { - return createMaterialFromPresetRef(roleMaterialPreset, shading) ?? defaultMaterial + const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (node.material) return createMaterial(node.material, shading) + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial } return roleDefault } - return [ - resolve(baseMaterial, baseMaterialPreset), - resolve(bodyMaterial, bodyMaterialPreset), - resolve(roofMaterial, roofMaterialPreset), - ] + return [resolve('base'), resolve('body'), resolve('roof')] }, [ textures, colorPreset, sceneTheme, shading, - baseMaterial, - baseMaterialPreset, - bodyMaterial, - bodyMaterialPreset, - roofMaterial, - roofMaterialPreset, + node.slots, + node.material, + node.materialPreset, + sceneMaterials, ]) const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), []) diff --git a/packages/nodes/src/downspout/definition.ts b/packages/nodes/src/downspout/definition.ts index 7b39b72249..ff487c54dc 100644 --- a/packages/nodes/src/downspout/definition.ts +++ b/packages/nodes/src/downspout/definition.ts @@ -161,7 +161,7 @@ const downspoutHandles: HandleDescriptor[] = [ */ export const downspoutDefinition: NodeDefinition = { kind: 'downspout', - schemaVersion: 2, + schemaVersion: 3, schema: DownspoutNode, category: 'structure', surfaceRole: 'roof', @@ -177,6 +177,7 @@ export const downspoutDefinition: NodeDefinition = { }, capabilities: { + slots: () => [{ slotId: 'surface', label: 'Surface', default: 'library:preset-softwhite' }], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx index 9eebee1fd6..f16bb2040a 100644 --- a/packages/nodes/src/downspout/renderer.tsx +++ b/packages/nodes/src/downspout/renderer.tsx @@ -14,6 +14,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -55,6 +56,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, @@ -135,13 +137,27 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { useEffect(() => () => geometry.dispose(), [geometry]) const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { + if (!textures) { + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + } + const slotMaterial = resolveMaterialRef(node.slots?.surface, sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (!node.material && !node.materialPreset) { return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.slots?.surface, + node.material, + node.materialPreset, + sceneMaterials, + ]) // Map downspout-local geometry into the host segment's local frame (where the // trim cut prisms live). Recompose the same outlet pose the inner mesh group diff --git a/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts b/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts index 741b450e61..7d5060a953 100644 --- a/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts +++ b/packages/nodes/src/eyebrow-vent/__tests__/paint.test.ts @@ -1,9 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { - buildEyebrowVentMaterialPatch, - getEffectiveEyebrowVentMaterial, - resolveEyebrowVentMaterialRole, -} from '../paint' +import { eyebrowVentPaint, resolveEyebrowVentMaterialRole } from '../paint' import { EyebrowVentNode } from '../schema' describe('eyebrow vent paint', () => { @@ -13,15 +9,26 @@ describe('eyebrow vent paint', () => { }) test('updates only the selected construction part', () => { - expect(buildEyebrowVentMaterialPatch('front', undefined, 'library:louver')).toEqual({ - frontMaterial: undefined, - frontMaterialPreset: 'library:louver', + const node = EyebrowVentNode.parse({ slots: { hood: 'library:metal' } }) + expect( + eyebrowVentPaint.buildPatch({ + node, + role: 'front', + material: undefined, + materialPreset: 'library:louver', + }), + ).toEqual({ + slots: { hood: 'library:metal', front: 'library:louver' }, }) }) test('uses the legacy material only for roles without an override', () => { - const node = EyebrowVentNode.parse({ hoodMaterialPreset: 'library:metal' }) - expect(getEffectiveEyebrowVentMaterial(node, 'hood').materialPreset).toBe('library:metal') - expect(getEffectiveEyebrowVentMaterial(node, 'front').materialPreset).toBe('preset-white') + const node = EyebrowVentNode.parse({ slots: { hood: 'library:metal' } }) + expect( + eyebrowVentPaint.getEffectiveMaterial?.({ node, role: 'hood', nodes: {} })?.materialPreset, + ).toBe('library:metal') + expect( + eyebrowVentPaint.getEffectiveMaterial?.({ node, role: 'front', nodes: {} })?.materialPreset, + ).toBe('preset-white') }) }) diff --git a/packages/nodes/src/eyebrow-vent/definition.ts b/packages/nodes/src/eyebrow-vent/definition.ts index eb218af9ca..7496808933 100644 --- a/packages/nodes/src/eyebrow-vent/definition.ts +++ b/packages/nodes/src/eyebrow-vent/definition.ts @@ -112,7 +112,7 @@ const eyebrowVentHandles: HandleDescriptor[] = [ */ export const eyebrowVentDefinition: NodeDefinition = { kind: 'eyebrow-vent', - schemaVersion: 2, + schemaVersion: 3, schema: EyebrowVentNode, category: 'structure', surfaceRole: 'roof', @@ -127,6 +127,10 @@ export const eyebrowVentDefinition: NodeDefinition = { }, capabilities: { + slots: () => [ + { slotId: 'hood', label: 'Hood', default: 'library:preset-softwhite' }, + { slotId: 'front', label: 'Front', default: 'library:preset-softwhite' }, + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/eyebrow-vent/paint.ts b/packages/nodes/src/eyebrow-vent/paint.ts index f6d0ed8468..d384f7378d 100644 --- a/packages/nodes/src/eyebrow-vent/paint.ts +++ b/packages/nodes/src/eyebrow-vent/paint.ts @@ -1,58 +1,23 @@ -import type { - EyebrowVentMaterialRole, - EyebrowVentNode, - MaterialSchema, - PaintCapability, -} from '@pascal-app/core' -import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' -import type { Material, Mesh, Object3D } from 'three' +import type { AnyNode, EyebrowVentMaterialRole, MaterialSchema } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' import { EYEBROW_VENT_MATERIAL_INDEX } from './geometry' +type LegacyEyebrowVent = AnyNode & { material?: MaterialSchema; materialPreset?: string } + export function resolveEyebrowVentMaterialRole( materialIndex: number | null, ): EyebrowVentMaterialRole { return materialIndex === EYEBROW_VENT_MATERIAL_INDEX.front ? 'front' : 'hood' } -export function buildEyebrowVentMaterialPatch( - role: EyebrowVentMaterialRole, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - return role === 'front' - ? { frontMaterial: material, frontMaterialPreset: materialPreset } - : { hoodMaterial: material, hoodMaterialPreset: materialPreset } -} - -export function getEffectiveEyebrowVentMaterial( - node: EyebrowVentNode, - role: EyebrowVentMaterialRole, -): { material: MaterialSchema | undefined; materialPreset: string | undefined } { - const material = role === 'front' ? node.frontMaterial : node.hoodMaterial - const materialPreset = role === 'front' ? node.frontMaterialPreset : node.hoodMaterialPreset - return material !== undefined || materialPreset !== undefined - ? { material, materialPreset } - : { material: node.material, materialPreset: node.materialPreset } -} - -function previewMaterial( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Material | null { - if (materialPreset) return createMaterialFromPresetRef(materialPreset) - if (material) return createMaterial(material) - return null -} - -export const eyebrowVentPaint: PaintCapability = { +export const eyebrowVentPaint = createSlotPaintCapability({ materialTarget: 'eyebrow-vent', resolveRole: ({ materialIndex }) => resolveEyebrowVentMaterialRole(materialIndex), - buildPatch: ({ role, material, materialPreset }) => - buildEyebrowVentMaterialPatch(role as EyebrowVentMaterialRole, material, materialPreset), applyPreview: ({ role, material, materialPreset, root }) => { - const preview = previewMaterial(material, materialPreset) + const preview = buildSlotPreviewMaterial(material, materialPreset) if (!preview) return null - const index = EYEBROW_VENT_MATERIAL_INDEX[role as EyebrowVentMaterialRole] + const materialIndex = EYEBROW_VENT_MATERIAL_INDEX[role as EyebrowVentMaterialRole] let restore: (() => void) | null = null ;(root as Object3D).traverse((object) => { const mesh = object as Mesh @@ -60,7 +25,7 @@ export const eyebrowVentPaint: PaintCapability = { return const previous = [...mesh.material] const next = [...previous] - next[index] = preview + next[materialIndex] = preview mesh.material = next restore = () => { mesh.material = previous @@ -68,6 +33,8 @@ export const eyebrowVentPaint: PaintCapability = { }) return restore }, - getEffectiveMaterial: ({ node, role }) => - getEffectiveEyebrowVentMaterial(node as EyebrowVentNode, role as EyebrowVentMaterialRole), -} + legacyEffective: (node) => { + const legacy = node as LegacyEyebrowVent + return { material: legacy.material, materialPreset: legacy.materialPreset } + }, +}) diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index 805df10ae9..fe9bfe2ef4 100644 --- a/packages/nodes/src/eyebrow-vent/renderer.tsx +++ b/packages/nodes/src/eyebrow-vent/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -21,7 +22,6 @@ import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildEyebrowVentGeometry } from './geometry' -import { getEffectiveEyebrowVentMaterial } from './paint' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, @@ -43,6 +43,7 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, @@ -70,33 +71,28 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) - const { material: hoodMaterial, materialPreset: hoodMaterialPreset } = - getEffectiveEyebrowVentMaterial(node, 'hood') - const { material: frontMaterial, materialPreset: frontMaterialPreset } = - getEffectiveEyebrowVentMaterial(node, 'front') const material = useMemo(() => { const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) - const resolve = ( - roleMaterial: EyebrowVentNode['material'], - roleMaterialPreset: string | undefined, - ) => { + const resolve = (role: 'hood' | 'front') => { if (!textures) return roleDefault - if (roleMaterial) return createMaterial(roleMaterial, shading) - if (roleMaterialPreset) { - return createMaterialFromPresetRef(roleMaterialPreset, shading) ?? defaultMaterial + const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (node.material) return createMaterial(node.material, shading) + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial } return roleDefault } - return [resolve(hoodMaterial, hoodMaterialPreset), resolve(frontMaterial, frontMaterialPreset)] + return [resolve('hood'), resolve('front')] }, [ textures, colorPreset, sceneTheme, shading, - hoodMaterial, - hoodMaterialPreset, - frontMaterial, - frontMaterialPreset, + node.slots, + node.material, + node.materialPreset, + sceneMaterials, ]) const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), []) diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index 81eb62f6c4..3fb4ded5b4 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -141,7 +141,7 @@ const gutterHandles: HandleDescriptor[] = [ */ export const gutterDefinition: NodeDefinition = { kind: 'gutter', - schemaVersion: 2, + schemaVersion: 3, schema: GutterNode, category: 'structure', surfaceRole: 'roof', @@ -157,6 +157,7 @@ export const gutterDefinition: NodeDefinition = { }, capabilities: { + slots: () => [{ slotId: 'surface', label: 'Surface', default: 'library:preset-softwhite' }], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index b3684219a8..f08de5cfb3 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -70,6 +71,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, @@ -232,13 +234,27 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { // visible face. FrontSide is therefore sufficient and DoubleSide is not // needed. const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { + if (!textures) { + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + } + const slotMaterial = resolveMaterialRef(node.slots?.surface, sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (!node.material && !node.materialPreset) { return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.slots?.surface, + node.material, + node.materialPreset, + sceneMaterials, + ]) // Map gutter-local geometry into the host segment's local frame (where the // trim cut prisms live) — same pose the inner mesh group is mounted with diff --git a/packages/nodes/src/roof/floorplan.ts b/packages/nodes/src/roof/floorplan.ts index d7c64c2c55..02a6eec796 100644 --- a/packages/nodes/src/roof/floorplan.ts +++ b/packages/nodes/src/roof/floorplan.ts @@ -1,11 +1,13 @@ -import type { - FloorplanGeometry, - FloorplanPoint, - GeometryContext, - RoofNode, - RoofSegmentNode, +import { + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + type RoofNode, + type RoofSegmentNode, + roofOverlapEntryOwns, + subtractPolygonsFromPolygon, + unionPolygons, } from '@pascal-app/core' -import { subtractPolygonsFromPolygon, unionPolygons } from '@pascal-app/viewer' import { getRoofSegmentPlanLinework } from '../roof-segment/floorplan' type Pt = [number, number] @@ -59,11 +61,6 @@ function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan { } } -function comparePlanEntryIdentity(a: PlanEntry, b: PlanEntry): number { - const roofOrder = String(a.roof.id).localeCompare(String(b.roof.id)) - return roofOrder !== 0 ? roofOrder : String(a.segment.id).localeCompare(String(b.segment.id)) -} - function pointInPolygon(point: Pt, polygon: Pt[]): boolean { let inside = false for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { @@ -152,15 +149,23 @@ export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): Floorp const currentEntries = entries.filter((entry) => entry.roof.id === node.id) const visiblePlans = currentEntries.map((entry) => { - const area = entry.segment.width * entry.segment.depth const cutters = entries .filter((candidate) => { if (candidate.segment.id === entry.segment.id) return false if (candidate.segment.roofType === 'shed') return false - const candidateArea = candidate.segment.width * candidate.segment.depth - return ( - candidateArea > area + 1e-6 || - (Math.abs(candidateArea - area) <= 1e-6 && comparePlanEntryIdentity(candidate, entry) < 0) + 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, + }, ) }) .map((candidate) => candidate.plan.footprint) diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index 22cbe9a3cd..afd0bf29e9 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -21,8 +21,8 @@ import { type Material, type Mesh, type Object3D, Raycaster } from 'three' * Shared paint capability for procedural kinds on the unified slot model * (`node.slots: Record` + the shared scene-material * palette) — the same data shape items derive from their GLB and the shelf - * declares via `capabilities.slots`. Distinct from `surface-paint.ts`, which - * writes the legacy inline `node.material` copy the plan is retiring. + * declares via `capabilities.slots`. `surface-paint.ts` configures this helper + * for kinds whose entire rendered subtree is one paintable surface. * * The commit / resolve / effective-material logic is identical across kinds; * only the slot-resolution from a pointer hit and the mesh preview differ, so diff --git a/packages/nodes/src/shared/surface-paint.ts b/packages/nodes/src/shared/surface-paint.ts index 9770a2c098..904d1135c7 100644 --- a/packages/nodes/src/shared/surface-paint.ts +++ b/packages/nodes/src/shared/surface-paint.ts @@ -1,37 +1,14 @@ -import type { AnyNode, MaterialSchema, PaintCapability } from '@pascal-app/core' -import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' -import type { Material, Mesh, Object3D } from 'three' +import type { AnyNode, MaterialSchema } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from './slot-paint' -/** - * Paint capability for kinds with a single painted surface (`role: 'surface'`) - * that register a mesh or group whose children all share one material. Used by - * ridge vents, gutters, and downspouts. Multi-part roof accessories declare - * role-aware paint capabilities beside their geometry instead. - */ +type LegacySurfaceNode = AnyNode & { material?: MaterialSchema; materialPreset?: string } -type SurfaceNode = AnyNode & { - material?: MaterialSchema - materialPreset?: string -} - -function buildPreviewMaterial( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Material | null { - if (materialPreset) return createMaterialFromPresetRef(materialPreset) - if (material) return createMaterial(material) - return null -} - -export const surfacePaintCapability: PaintCapability = { - // One paintable surface — every face resolves to it. +export const surfacePaintCapability = createSlotPaintCapability({ resolveRole: () => 'surface', - buildPatch: ({ material, materialPreset }) => ({ material, materialPreset }) as Partial, applyPreview: ({ material, materialPreset, root }) => { - const preview = buildPreviewMaterial(material, materialPreset) + const preview = buildSlotPreviewMaterial(material, materialPreset) if (!preview) return null - // The kinds register a group, so walk the subtree and swap every child - // mesh's material, recording a restore for each. const restores: Array<() => void> = [] ;(root as Object3D).traverse((object) => { const mesh = object as Mesh @@ -44,11 +21,11 @@ export const surfacePaintCapability: PaintCapability = { }) if (restores.length === 0) return null return () => { - for (let i = restores.length - 1; i >= 0; i -= 1) restores[i]?.() + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() } }, - getEffectiveMaterial: ({ node }) => { - const n = node as SurfaceNode - return { material: n.material, materialPreset: n.materialPreset } + legacyEffective: (node) => { + const legacy = node as LegacySurfaceNode + return { material: legacy.material, materialPreset: legacy.materialPreset } }, -} +}) diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 7caa824f8f..21c58c622d 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -5,6 +5,7 @@ import { type SiteNode, type TerrainField, terrainFieldOf, + unionPolygons, useLiveNodeOverrides, useLiveTerrain, useRegistry, @@ -16,7 +17,6 @@ import { getSceneTheme, horizonHazeColor, NodeRenderer, - unionPolygons, useNodeEvents, useViewer, } from '@pascal-app/viewer' diff --git a/packages/nodes/src/turbine-vent/__tests__/paint.test.ts b/packages/nodes/src/turbine-vent/__tests__/paint.test.ts index 33d1758a5e..164725153d 100644 --- a/packages/nodes/src/turbine-vent/__tests__/paint.test.ts +++ b/packages/nodes/src/turbine-vent/__tests__/paint.test.ts @@ -1,11 +1,6 @@ import { describe, expect, test } from 'bun:test' import { Group, Mesh, MeshBasicMaterial } from 'three' -import { - buildTurbineVentMaterialPatch, - getEffectiveTurbineVentMaterial, - resolveTurbineVentMaterialRole, - turbineVentPaint, -} from '../paint' +import { resolveTurbineVentMaterialRole, turbineVentPaint } from '../paint' import { TurbineVentNode } from '../schema' describe('turbine vent paint', () => { @@ -15,13 +10,23 @@ describe('turbine vent paint', () => { }) test('updates one role and falls back to the legacy whole-vent material', () => { - expect(buildTurbineVentMaterialPatch('head', undefined, 'library:copper')).toEqual({ - headMaterial: undefined, - headMaterialPreset: 'library:copper', + const node = TurbineVentNode.parse({ slots: { base: 'library:steel' } }) + expect( + turbineVentPaint.buildPatch({ + node, + role: 'head', + material: undefined, + materialPreset: 'library:copper', + }), + ).toEqual({ + slots: { base: 'library:steel', head: 'library:copper' }, }) - const node = TurbineVentNode.parse({ baseMaterialPreset: 'library:steel' }) - expect(getEffectiveTurbineVentMaterial(node, 'base').materialPreset).toBe('library:steel') - expect(getEffectiveTurbineVentMaterial(node, 'head').materialPreset).toBe('preset-white') + expect( + turbineVentPaint.getEffectiveMaterial?.({ node, role: 'base', nodes: {} })?.materialPreset, + ).toBe('library:steel') + expect( + turbineVentPaint.getEffectiveMaterial?.({ node, role: 'head', nodes: {} })?.materialPreset, + ).toBe('preset-white') }) test('previews only the selected mesh', () => { diff --git a/packages/nodes/src/turbine-vent/definition.ts b/packages/nodes/src/turbine-vent/definition.ts index 324907f567..c48ecde0e6 100644 --- a/packages/nodes/src/turbine-vent/definition.ts +++ b/packages/nodes/src/turbine-vent/definition.ts @@ -81,7 +81,7 @@ const turbineVentHandles: HandleDescriptor[] = [ */ export const turbineVentDefinition: NodeDefinition = { kind: 'turbine-vent', - schemaVersion: 2, + schemaVersion: 3, schema: TurbineVentNode, category: 'structure', surfaceRole: 'roof', @@ -93,6 +93,10 @@ export const turbineVentDefinition: NodeDefinition = { }, capabilities: { + slots: () => [ + { slotId: 'base', label: 'Base', default: 'library:preset-softwhite' }, + { slotId: 'head', label: 'Head', default: 'library:preset-softwhite' }, + ], selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, diff --git a/packages/nodes/src/turbine-vent/paint.ts b/packages/nodes/src/turbine-vent/paint.ts index 6ac4455603..d689ec4a31 100644 --- a/packages/nodes/src/turbine-vent/paint.ts +++ b/packages/nodes/src/turbine-vent/paint.ts @@ -1,53 +1,18 @@ -import type { - MaterialSchema, - PaintCapability, - TurbineVentMaterialRole, - TurbineVentNode, -} from '@pascal-app/core' -import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer' -import type { Material, Mesh, Object3D } from 'three' +import type { AnyNode, MaterialSchema, TurbineVentMaterialRole } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' + +type LegacyTurbineVent = AnyNode & { material?: MaterialSchema; materialPreset?: string } export function resolveTurbineVentMaterialRole(hitObjectName?: string): TurbineVentMaterialRole { return hitObjectName === 'turbine-vent-head' ? 'head' : 'base' } -export function buildTurbineVentMaterialPatch( - role: TurbineVentMaterialRole, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - return role === 'head' - ? { headMaterial: material, headMaterialPreset: materialPreset } - : { baseMaterial: material, baseMaterialPreset: materialPreset } -} - -export function getEffectiveTurbineVentMaterial( - node: TurbineVentNode, - role: TurbineVentMaterialRole, -): { material: MaterialSchema | undefined; materialPreset: string | undefined } { - const material = role === 'head' ? node.headMaterial : node.baseMaterial - const materialPreset = role === 'head' ? node.headMaterialPreset : node.baseMaterialPreset - return material !== undefined || materialPreset !== undefined - ? { material, materialPreset } - : { material: node.material, materialPreset: node.materialPreset } -} - -function previewMaterial( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Material | null { - if (materialPreset) return createMaterialFromPresetRef(materialPreset) - if (material) return createMaterial(material) - return null -} - -export const turbineVentPaint: PaintCapability = { +export const turbineVentPaint = createSlotPaintCapability({ materialTarget: 'turbine-vent', resolveRole: ({ hitObjectName }) => resolveTurbineVentMaterialRole(hitObjectName), - buildPatch: ({ role, material, materialPreset }) => - buildTurbineVentMaterialPatch(role as TurbineVentMaterialRole, material, materialPreset), applyPreview: ({ role, material, materialPreset, root }) => { - const preview = previewMaterial(material, materialPreset) + const preview = buildSlotPreviewMaterial(material, materialPreset) if (!preview) return null const targetName = `turbine-vent-${role}` const restores: Array<() => void> = [] @@ -65,6 +30,8 @@ export const turbineVentPaint: PaintCapability = { for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() } }, - getEffectiveMaterial: ({ node, role }) => - getEffectiveTurbineVentMaterial(node as TurbineVentNode, role as TurbineVentMaterialRole), -} + legacyEffective: (node) => { + const legacy = node as LegacyTurbineVent + return { material: legacy.material, materialPreset: legacy.materialPreset } + }, +}) diff --git a/packages/nodes/src/turbine-vent/renderer.tsx b/packages/nodes/src/turbine-vent/renderer.tsx index e89493f966..992bb9c8f6 100644 --- a/packages/nodes/src/turbine-vent/renderer.tsx +++ b/packages/nodes/src/turbine-vent/renderer.tsx @@ -13,6 +13,7 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + resolveMaterialRef, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -22,7 +23,6 @@ import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildTurbineVentBase, buildTurbineVentHead } from './geometry' -import { getEffectiveTurbineVentMaterial } from './paint' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, @@ -51,6 +51,7 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneMaterials = useScene((s) => s.materials) // Merge live overrides (panel slider drags) on top of the store node so // the mesh updates frame-by-frame without polluting undo history. @@ -99,36 +100,31 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => return surfaceQuatFromNormal(normal, new THREE.Quaternion()) }, [segment, node.position[0], node.position[2]]) - const { material: baseMaterial, materialPreset: baseMaterialPreset } = - getEffectiveTurbineVentMaterial(node, 'base') - const { material: headMaterial, materialPreset: headMaterialPreset } = - getEffectiveTurbineVentMaterial(node, 'head') const material = useMemo(() => { const roleDefault = createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) - const resolve = ( - roleMaterial: TurbineVentNode['material'], - roleMaterialPreset: string | undefined, - ) => { + const resolve = (role: 'base' | 'head') => { if (!textures) return roleDefault - if (roleMaterial) return createMaterial(roleMaterial, shading) - if (roleMaterialPreset) { - return createMaterialFromPresetRef(roleMaterialPreset, shading) ?? defaultMaterial + const slotMaterial = resolveMaterialRef(node.slots?.[role], sceneMaterials, shading) + if (slotMaterial) return slotMaterial + if (node.material) return createMaterial(node.material, shading) + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial } return roleDefault } return { - base: resolve(baseMaterial, baseMaterialPreset), - head: resolve(headMaterial, headMaterialPreset), + base: resolve('base'), + head: resolve('head'), } }, [ textures, colorPreset, sceneTheme, shading, - baseMaterial, - baseMaterialPreset, - headMaterial, - headMaterialPreset, + node.slots, + node.material, + node.materialPreset, + sceneMaterials, ]) // Compose slope tilt + yaw onto a single quaternion so the registered diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index f1d478f599..91a6d99fbe 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -128,7 +128,6 @@ export { WHITE_PALETTE, } from './lib/materials' export { mergedOutline } from './lib/merged-outline-node' -export { subtractPolygonsFromPolygon, unionPolygons } from './lib/polygon-union' export { detectRendererCapability, initializeGpuRenderer, diff --git a/packages/viewer/src/lib/csg-utils.ts b/packages/viewer/src/lib/csg-utils.ts index a59e900eb1..2a0b6adf53 100644 --- a/packages/viewer/src/lib/csg-utils.ts +++ b/packages/viewer/src/lib/csg-utils.ts @@ -1,5 +1,5 @@ import * as THREE from 'three' -import { type Brush, Evaluator } from 'three-bvh-csg' +import { type Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' /** @@ -111,6 +111,12 @@ export function prepareBrushForCSG(brush: Brush) { brush.updateMatrixWorld() } +export function subtractCsgBrush(left: Brush, right: Brush, evaluator: Evaluator): Brush { + const result = evaluator.evaluate(left, right, SUBTRACTION) as Brush + prepareBrushForCSG(result) + return result +} + // Re-export Brush + SUBTRACTION + ADDITION + INTERSECTION so kinds don't need a // direct `three-bvh-csg` dependency. export { ADDITION, Brush, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' diff --git a/packages/viewer/src/systems/roof/roof-layer-trim.ts b/packages/viewer/src/systems/roof/roof-layer-trim.ts deleted file mode 100644 index 0312fe2584..0000000000 --- a/packages/viewer/src/systems/roof/roof-layer-trim.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type Brush, type Evaluator, SUBTRACTION } from 'three-bvh-csg' -import { prepareBrushForCSG } from '../../lib/csg-utils' - -export function subtractRoofInterior(layer: Brush, interior: Brush, evaluator: Evaluator): Brush { - const result = evaluator.evaluate(layer, interior, SUBTRACTION) as Brush - prepareBrushForCSG(result) - return result -} diff --git a/packages/viewer/src/systems/roof/roof-layer-trim.test.ts b/packages/viewer/src/systems/roof/roof-system-intersection.test.ts similarity index 98% rename from packages/viewer/src/systems/roof/roof-layer-trim.test.ts rename to packages/viewer/src/systems/roof/roof-system-intersection.test.ts index 1d522a17bb..805ba87eb2 100644 --- a/packages/viewer/src/systems/roof/roof-layer-trim.test.ts +++ b/packages/viewer/src/systems/roof/roof-system-intersection.test.ts @@ -2,8 +2,7 @@ import { describe, expect, test } from 'bun:test' import { LevelNode, RoofNode, RoofSegmentNode } from '@pascal-app/core' import * as THREE from 'three' import { Brush, Evaluator } from 'three-bvh-csg' -import { prepareBrushForCSG } from '../../lib/csg-utils' -import { subtractRoofInterior } from './roof-layer-trim' +import { prepareBrushForCSG, subtractCsgBrush } from '../../lib/csg-utils' import { generateRoofSegmentGeometry } from './roof-system' function box(size: [number, number, number], position: [number, number, number]): Brush { @@ -13,14 +12,14 @@ function box(size: [number, number, number], position: [number, number, number]) return brush } -describe('subtractRoofInterior', () => { +describe('roof system intersections', () => { 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]) const evaluator = new Evaluator() evaluator.attributes = ['position', 'normal', 'uv'] - const result = subtractRoofInterior(layer, siblingInterior, evaluator) + const result = subtractCsgBrush(layer, siblingInterior, evaluator) const mesh = new THREE.Mesh(result.geometry) const centerHits = new THREE.Raycaster( new THREE.Vector3(0, 3, 0), diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index d09204f498..88577f7b95 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -5,6 +5,7 @@ import { getDutchRoofShapeMetrics, getEffectiveNode, getRoofModuleFaces, + getRoofPlanBounds, getRoofSegmentSurfaceY, getRoofShapeInsets, getRoofShapeRatios, @@ -15,8 +16,11 @@ import { normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, type RoofNode, + type RoofPlanBounds, type RoofSegmentNode, type RoofType, + roofOverlapEntryOwns, + roofPlanBoundsOverlap, sceneRegistry, useLiveNodeOverrides, useScene, @@ -27,8 +31,7 @@ import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferG import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { applyWorldScaleBoxUVs } from '../../lib/box-uv' -import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' -import { subtractRoofInterior } from './roof-layer-trim' +import { ensureRenderableGeometryAttributes, subtractCsgBrush } from '../../lib/csg-utils' function csgGeometry(brush: Brush): THREE.BufferGeometry { return brush.geometry as unknown as THREE.BufferGeometry @@ -153,19 +156,64 @@ function createDegenerateRoofPlaceholder(): THREE.BufferGeometry { // Pending merged-roof updates carried across frames (for throttling) const pendingRoofUpdates = new Set() +const previousRoofPlanBounds = new Map() const warnedMergedRoofNaNIds = new Set() const MAX_ROOFS_PER_FRAME = 1 const MAX_SEGMENTS_PER_FRAME = 3 function queueSiblingRoofUpdates(roofId: AnyNodeId, nodes: Record) { pendingRoofUpdates.add(roofId) - const roof = nodes[roofId] + const roof = nodes[roofId]?.type === 'roof' ? getEffectiveNode(nodes[roofId]) : undefined if (roof?.type !== 'roof' || !roof.parentId) return + const currentBounds = getRoofPlanBounds({ + position: roof.position, + rotation: roof.rotation, + segments: (roof.children ?? []).flatMap((id) => { + const segment = nodes[id as AnyNodeId] + if (segment?.type !== 'roof-segment') return [] + const effective = getEffectiveNode(segment) + return [ + { + position: effective.position, + rotation: effective.rotation, + width: effective.width, + depth: effective.depth, + }, + ] + }), + }) + const oldBounds = previousRoofPlanBounds.get(roofId) + if (currentBounds) previousRoofPlanBounds.set(roofId, currentBounds) const parent = nodes[roof.parentId as AnyNodeId] if (!parent || !('children' in parent) || !Array.isArray(parent.children)) return for (const siblingId of parent.children) { - if (nodes[siblingId as AnyNodeId]?.type === 'roof') { - pendingRoofUpdates.add(siblingId as AnyNodeId) + const sibling = nodes[siblingId as AnyNodeId] + if (sibling?.type !== 'roof' || sibling.id === roofId) continue + const effectiveSibling = getEffectiveNode(sibling) + const siblingBounds = getRoofPlanBounds({ + position: effectiveSibling.position, + rotation: effectiveSibling.rotation, + segments: (effectiveSibling.children ?? []).flatMap((id) => { + const segment = nodes[id as AnyNodeId] + if (segment?.type !== 'roof-segment') return [] + const effective = getEffectiveNode(segment) + return [ + { + position: effective.position, + rotation: effective.rotation, + width: effective.width, + depth: effective.depth, + }, + ] + }), + }) + if (!siblingBounds) continue + previousRoofPlanBounds.set(sibling.id, siblingBounds) + if ( + (currentBounds && roofPlanBoundsOverlap(currentBounds, siblingBounds)) || + (oldBounds && roofPlanBoundsOverlap(oldBounds, siblingBounds)) + ) { + pendingRoofUpdates.add(sibling.id) } } } @@ -188,6 +236,7 @@ export const RoofSystem = () => { // Clear stale pending updates when the scene is unloaded if (rootNodeIds.length === 0) { pendingRoofUpdates.clear() + previousRoofPlanBounds.clear() warnedMergedRoofNaNIds.clear() for (const cached of mergedRoofSegmentGeometryCache.values()) { disposeCachedMergedRoofSegmentGeometrySet(cached) @@ -558,15 +607,11 @@ function updateMergedRoofGeometry( const occludingInterior = buildOccludingRoofInterior(child, nodes, 'roof') if (occludingInterior) { - const exposedShingles = subtractRoofInterior( - brushes.shinSlab, - occludingInterior, - csgEvaluator, - ) + const exposedShingles = subtractCsgBrush(brushes.shinSlab, occludingInterior, csgEvaluator) brushes.shinSlab.geometry.dispose() brushes.shinSlab = exposedShingles - const exposedDeck = subtractRoofInterior(brushes.deckSlab, occludingInterior, csgEvaluator) + const exposedDeck = subtractCsgBrush(brushes.deckSlab, occludingInterior, csgEvaluator) brushes.deckSlab.geometry.dispose() brushes.deckSlab = exposedDeck } @@ -605,7 +650,7 @@ function updateMergedRoofGeometry( prepareBrushForCSG(wallShell) if (occludingInterior) { - const exposedWall = subtractRoofInterior(wallShell, occludingInterior, csgEvaluator) + const exposedWall = subtractCsgBrush(wallShell, occludingInterior, csgEvaluator) wallShell.geometry.dispose() wallShell = exposedWall } @@ -1649,7 +1694,7 @@ export function generateRoofSegmentGeometry( const siblingInterior = nodes ? buildOccludingRoofInterior(node, nodes, 'segment') : null if (siblingInterior) { const unclipped = combined - combined = subtractRoofInterior(unclipped, siblingInterior, csgEvaluator) + combined = subtractCsgBrush(unclipped, siblingInterior, csgEvaluator) unclipped.geometry.dispose() siblingInterior.geometry.dispose() } @@ -1718,7 +1763,7 @@ function clipDirectRoofGeometryAgainstSiblings( const brush = new Brush(geometry, dummyMats) prepareBrushForCSG(brush) try { - const clipped = subtractRoofInterior(brush, siblingInterior, csgEvaluator) + const clipped = subtractCsgBrush(brush, siblingInterior, csgEvaluator) const clippedGeometry = csgGeometry(clipped) const clippedMaterials = csgMaterials(clipped) const materialIndices = new Map([ @@ -1758,7 +1803,6 @@ function buildOccludingRoofInterior( const roofEntries = collectSiblingRoofEntries(parent, nodes) const currentEntry = roofEntries.find(({ segment }) => segment.id === node.id) if (!currentEntry) return null - const currentArea = node.width * node.depth const targetRoofInverse = composeRoofTransform(parent).invert() const targetSegmentInverse = composeSegmentTransform(node).invert() let combinedInterior: Brush | null = null @@ -1768,11 +1812,20 @@ function buildOccludingRoofInterior( const sibling = entry.segment if (sibling.id === node.id) continue if (sibling.roofType === 'shed') continue - const siblingArea = sibling.width * sibling.depth - const siblingOwnsOverlap = - siblingArea > currentArea + 1e-6 || - (Math.abs(siblingArea - currentArea) <= 1e-6 && - compareRoofEntryIdentity(entry, currentEntry) < 0) + 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, + }, + ) if (!siblingOwnsOverlap) continue const siblingBrushes = getRoofSegmentBrushes(sibling) if (!siblingBrushes) continue @@ -1810,14 +1863,6 @@ function buildOccludingRoofInterior( return combinedInterior } -function compareRoofEntryIdentity( - a: { roof: RoofNode; segment: RoofSegmentNode }, - b: { roof: RoofNode; segment: RoofSegmentNode }, -): number { - const roofOrder = String(a.roof.id).localeCompare(String(b.roof.id)) - return roofOrder !== 0 ? roofOrder : String(a.segment.id).localeCompare(String(b.segment.id)) -} - function collectSiblingRoofEntries( targetRoof: RoofNode, nodes: Record, diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index b265b22969..13c52c3a59 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -5,9 +5,9 @@ import { polygonsIntersect, type SlabNode, type SlabPolygonContext, + subtractPolygonsFromPolygon, } from '@pascal-app/core' import * as THREE from 'three' -import { subtractPolygonsFromPolygon } from '../../lib/polygon-union' import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' // ============================================================================ diff --git a/packages/viewer/src/systems/surface-hole-geometry.ts b/packages/viewer/src/systems/surface-hole-geometry.ts index bd514ea008..6f427ae515 100644 --- a/packages/viewer/src/systems/surface-hole-geometry.ts +++ b/packages/viewer/src/systems/surface-hole-geometry.ts @@ -1,4 +1,4 @@ -import { type Point2D, unionPolygons } from '../lib/polygon-union' +import { type PolygonBooleanPoint2D as Point2D, unionPolygons } from '@pascal-app/core' export function mergeSurfaceHolePolygons(holes: Point2D[][]): Point2D[][] { return unionPolygons(holes) From 11878fa941086dbefe0c7634516653970a04ecd0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 14:20:46 +0530 Subject: [PATCH 9/9] test: align roof accessory paint expectations --- packages/nodes/src/downspout/definition.test.ts | 3 +-- packages/nodes/src/gutter/definition.test.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/nodes/src/downspout/definition.test.ts b/packages/nodes/src/downspout/definition.test.ts index cdcbd2ceba..7ea355a9f0 100644 --- a/packages/nodes/src/downspout/definition.test.ts +++ b/packages/nodes/src/downspout/definition.test.ts @@ -17,8 +17,7 @@ describe('downspout paint capability', () => { materialPreset: 'library:metal-steel', }), ).toEqual({ - material: undefined, - materialPreset: 'library:metal-steel', + slots: { surface: 'library:metal-steel' }, }) }) }) diff --git a/packages/nodes/src/gutter/definition.test.ts b/packages/nodes/src/gutter/definition.test.ts index d733c779e4..429c04d026 100644 --- a/packages/nodes/src/gutter/definition.test.ts +++ b/packages/nodes/src/gutter/definition.test.ts @@ -22,8 +22,7 @@ describe('gutter paint capability', () => { materialPreset: 'library:metal-steel', }), ).toEqual({ - material: undefined, - materialPreset: 'library:metal-steel', + slots: { surface: 'library:metal-steel' }, }) }) })