From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/35] 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 5b017ed20..eefaa2a79 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 49eacf304..112273a41 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 3e8724081..5563268b8 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 538286580..69a3d5ee3 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 fdafe3635..bac2b78fc 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 02/35] 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 2d7f85723..d7c86be96 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 fae9694e9..df67ca169 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 0a593ca75..a3eccc116 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 362ddd1dd..b86e426c4 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 a7f7924ab6b7c29cf7ccc0d8421b63dc00bcf222 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 11:44:57 +0530 Subject: [PATCH 03/35] fix(editor): ceiling-attached item placement from 2D floor plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3D viewer drives ceiling-item placement via ceiling:enter/move/click raycast events on the ceiling mesh. The floor plan has no such mesh, so ceiling-attached items (lights, fans) never transitioned out of surface: 'floor' — the draft sat at floor height while the 2D cursor moved freely, reading as a 2D/3D sync bug. Synthesise the same ceiling events from 2D plan points by hit-testing ceiling polygons on the active level, and publish the building-local cursor (not world-space) to useLiveTransforms so the floorplan registry override renders the draft under the cursor regardless of building position / rotation. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ifc-converter/next-env.d.ts | 2 +- .../src/components/editor/floorplan-panel.tsx | 197 ++++++++++++++++++ .../use-floorplan-background-placement.ts | 18 ++ .../tools/item/use-placement-coordinator.tsx | 10 +- 4 files changed, 224 insertions(+), 3 deletions(-) diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index e41a9ab75..05012ebeb 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -59,6 +59,7 @@ import { useState, } from 'react' import { createPortal } from 'react-dom' +import { Vector3 } from 'three' import { useShallow } from 'zustand/react/shallow' import { buildFloorplanItemEntry, @@ -4516,6 +4517,21 @@ export function FloorplanPanel() { const displaySlabPolygons = useMemo(() => [], []) const ceilingPolygons = useMemo(() => [], []) const displayCeilingPolygons = useMemo(() => [], []) + // Ceilings on the active level, projected to 2D polygons for hit-testing + // ceiling-item placement clicks/moves. The legacy `ceilingPolygons` above + // is intentionally empty (ceilings render via the registry layer); this + // memo is the placement-side counterpart, separate from rendering. + const ceilingHitEntries = useMemo( + () => + ceilings.map((ceiling) => ({ + ceiling, + polygon: toFloorplanPolygon(ceiling.polygon), + holes: (ceiling.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3), + })), + [ceilings], + ) // Zone fully registry-driven via `def.floorplan`. const zonePolygons = useMemo(() => [], []) const displayZonePolygons = useMemo(() => [], []) @@ -4799,6 +4815,18 @@ export function FloorplanPanel() { (mode === 'build' && tool === 'item') || movingNode?.type === 'item' const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo + // Ceiling-attached items (lights, fans). The 3D viewer drives these via + // `ceiling:enter/move/click` raycast events on the ceiling mesh; the 2D + // floor plan has no such mesh, so we synthesise the same events when the + // cursor is over a ceiling polygon. Without this the placement system + // never transitions out of `surface: 'floor'` and the draft sits at floor + // height while the 2D cursor moves freely — what the user perceived as + // "2D and 3D positions are out of sync". + const isCeilingItemBuildActive = + mode === 'build' && tool === 'item' && selectedItem?.attachTo === 'ceiling' + const isCeilingItemMoveActive = + movingNode?.type === 'item' && movingNode.asset.attachTo === 'ceiling' + const isCeilingItemPlacementActive = isCeilingItemBuildActive || isCeilingItemMoveActive // Any registry-driven kind whose tool is currently active. Lets the floor // plan emit `grid:click` / `grid:move` events to that kind's placement tool // (shelf today; future Phase 5 kinds the moment they register a `tool`). @@ -7117,6 +7145,7 @@ export function FloorplanPanel() { }, []) const hoveredWallIdRef = useRef(null) + const hoveredCeilingIdRef = useRef(null) const floorplanGridLocalY = useMemo(() => { if (movingNode?.type === 'item' || movingNode?.type === 'spawn') { return movingNode.position[1] @@ -7169,6 +7198,156 @@ export function FloorplanPanel() { [buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY], ) + // Build a synthetic `CeilingEvent` from a 2D plan point so the placement + // coordinator's existing ceiling handlers (which expect the same payload + // shape the 3D raycaster produces) can drive a ceiling-attached item + // placement from the floor plan. Returns null if the ceiling mesh isn't + // registered yet — the placement strategy needs `event.object` for the + // ceiling-local↔world transform. + const buildFloorplanCeilingEventPayload = useCallback( + ( + ceiling: CeilingNode, + planPoint: WallPlanPoint, + nativeEvent?: ReactMouseEvent | ReactPointerEvent, + ) => { + const ceilingMesh = sceneRegistry.nodes.get(ceiling.id as AnyNodeId) + if (!ceilingMesh) return null + + const cos = Math.cos(buildingRotationY) + const sin = Math.sin(buildingRotationY) + const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin + const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos + const worldY = ceilingMesh.getWorldPosition(new Vector3()).y + const localVec = ceilingMesh.worldToLocal(new Vector3(worldX, worldY, worldZ)) + + return { + node: ceiling, + position: [worldX, worldY, worldZ] as [number, number, number], + localPosition: [localVec.x, localVec.y, localVec.z] as [number, number, number], + normal: [0, -1, 0] as [number, number, number], + object: ceilingMesh, + stopPropagation: () => {}, + nativeEvent: nativeEvent?.nativeEvent, + } + }, + [buildingPosition, buildingRotationY], + ) + + const emitFloorplanCeilingLeave = useCallback((ceilingId: string | null) => { + if (!ceilingId) return + const ceilingNode = useScene.getState().nodes[ceilingId as AnyNodeId] + if (!ceilingNode || ceilingNode.type !== 'ceiling') return + + emitter.emit('ceiling:leave', { + node: ceilingNode, + position: [0, 0, 0], + localPosition: [0, 0, 0], + normal: [0, -1, 0], + stopPropagation: () => {}, + } as any) + }, []) + + // Clear the hovered-ceiling tracker whenever placement mode ends, so we + // don't carry a stale ceiling id into the next placement session. + useEffect(() => { + if (!isCeilingItemPlacementActive && hoveredCeilingIdRef.current) { + hoveredCeilingIdRef.current = null + } + }, [isCeilingItemPlacementActive]) + + const findCeilingAtPlanPoint = useCallback( + (planPoint: WallPlanPoint): CeilingNode | null => { + if (ceilingHitEntries.length === 0) return null + const point: Point2D = { x: planPoint[0], y: planPoint[1] } + for (const entry of ceilingHitEntries) { + if (isPointInsidePolygonWithHoles(point, entry.polygon, entry.holes)) { + return entry.ceiling + } + } + return null + }, + [ceilingHitEntries], + ) + + // Route a 2D plan point through ceiling enter/move/leave events when a + // ceiling-attached item is being placed. Returns true when the panel + // should stop further pointer-move processing (we either emitted a + // ceiling event or are between ceilings). + const handleCeilingItemPlacementMove = useCallback( + ( + planPoint: WallPlanPoint, + nativeEvent: ReactPointerEvent, + ): boolean => { + if (!isCeilingItemPlacementActive) return false + + const ceiling = findCeilingAtPlanPoint(planPoint) + if (!ceiling) { + if (hoveredCeilingIdRef.current) { + emitFloorplanCeilingLeave(hoveredCeilingIdRef.current) + hoveredCeilingIdRef.current = null + } + return true + } + + const payload = buildFloorplanCeilingEventPayload(ceiling, planPoint, nativeEvent) + if (!payload) return true + + if (hoveredCeilingIdRef.current !== ceiling.id) { + if (hoveredCeilingIdRef.current) { + emitFloorplanCeilingLeave(hoveredCeilingIdRef.current) + } + hoveredCeilingIdRef.current = ceiling.id + emitter.emit('ceiling:enter', payload as any) + } else { + emitter.emit('ceiling:move', payload as any) + } + return true + }, + [ + buildFloorplanCeilingEventPayload, + emitFloorplanCeilingLeave, + findCeilingAtPlanPoint, + isCeilingItemPlacementActive, + ], + ) + + // Click counterpart — used by the background placement click handler. + // Returns true if the click was a valid ceiling-item placement. + const handleCeilingItemPlacementClick = useCallback( + ( + planPoint: WallPlanPoint, + nativeEvent: ReactMouseEvent, + ): boolean => { + if (!isCeilingItemPlacementActive) return false + const ceiling = findCeilingAtPlanPoint(planPoint) + if (!ceiling) return true + + // Ensure the placement state has entered the ceiling surface; the + // user can click without a prior move (e.g. fresh placement after + // selecting the asset from the catalog). + if (hoveredCeilingIdRef.current !== ceiling.id) { + const enterPayload = buildFloorplanCeilingEventPayload(ceiling, planPoint, nativeEvent) + if (!enterPayload) return true + if (hoveredCeilingIdRef.current) { + emitFloorplanCeilingLeave(hoveredCeilingIdRef.current) + } + hoveredCeilingIdRef.current = ceiling.id + emitter.emit('ceiling:enter', enterPayload as any) + } + + const clickPayload = buildFloorplanCeilingEventPayload(ceiling, planPoint, nativeEvent) + if (!clickPayload) return true + emitter.emit('ceiling:click', clickPayload as any) + return true + }, + [ + buildFloorplanCeilingEventPayload, + emitFloorplanCeilingLeave, + findCeilingAtPlanPoint, + isCeilingItemPlacementActive, + ], + ) + const handlePointerMove = useCallback( (event: ReactPointerEvent) => { if (panStateRef.current?.pointerId === event.pointerId) { @@ -7355,6 +7534,20 @@ export function FloorplanPanel() { return } + // Ceiling-attached item placement. Same shape as the opening branch + // above: synthesise the surface events the placement coordinator + // already listens for (ceiling:enter / move / leave) instead of + // routing through `grid:move`, which would otherwise be processed + // by the floor strategy and drop the item to floor height. + if (isCeilingItemPlacementActive) { + const snappedPoint = getSnappedFloorplanPoint(planPoint) + setCursorPoint((previousPoint) => + previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, + ) + handleCeilingItemPlacementMove(snappedPoint, event) + return + } + // Registry-driven catch-all for kinds without bespoke 2D handling // (shelf, etc.). Must run AFTER the opening branch above (door / // window are also registered kinds, but need wall events — see @@ -7425,7 +7618,9 @@ export function FloorplanPanel() { fittedViewport, getPlanPointFromClientPoint, activePolygonDraftPoints, + handleCeilingItemPlacementMove, isCeilingBuildActive, + isCeilingItemPlacementActive, isFenceBuildActive, isFloorplanGridInteractionActive, isMarqueeSelectionToolActive, @@ -7648,11 +7843,13 @@ export function FloorplanPanel() { findClosestWallPoint, floorplanOpeningLocalY, getSnappedFloorplanPoint, + handleCeilingItemPlacementClick, handleCeilingPlacementPoint, handleSlabPlacementPoint, handleWallPlacementPoint, handleZonePlacementPoint, isCeilingBuildActive, + isCeilingItemPlacementActive, isFenceBuildActive, isFloorplanGridInteractionActive, isOpeningPlacementActive, diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 126b3ebe4..be09c7483 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -30,11 +30,16 @@ type UseFloorplanBackgroundPlacementArgs = { } | null floorplanOpeningLocalY: number getSnappedFloorplanPoint: (point: WallPlanPoint) => WallPlanPoint + handleCeilingItemPlacementClick: ( + planPoint: WallPlanPoint, + nativeEvent: ReactMouseEvent, + ) => boolean handleCeilingPlacementPoint: (point: WallPlanPoint) => void handleSlabPlacementPoint: (point: WallPlanPoint) => void handleWallPlacementPoint: (point: WallPlanPoint) => void handleZonePlacementPoint: (point: WallPlanPoint) => void isCeilingBuildActive: boolean + isCeilingItemPlacementActive: boolean isFenceBuildActive: boolean isFloorplanGridInteractionActive: boolean isOpeningPlacementActive: boolean @@ -76,11 +81,13 @@ export function useFloorplanBackgroundPlacement({ findClosestWallPoint, floorplanOpeningLocalY, getSnappedFloorplanPoint, + handleCeilingItemPlacementClick, handleCeilingPlacementPoint, handleSlabPlacementPoint, handleWallPlacementPoint, handleZonePlacementPoint, isCeilingBuildActive, + isCeilingItemPlacementActive, isFenceBuildActive, isFloorplanGridInteractionActive, isOpeningPlacementActive, @@ -225,6 +232,15 @@ export function useFloorplanBackgroundPlacement({ return true } + // Ceiling-attached item placement (lights, fans). Routes the click + // through `ceiling:click` instead of `grid:click` so the placement + // strategy parents the new item to the ceiling at the correct + // height — mirrors the pointer-move handler in `floorplan-panel`. + if (isCeilingItemPlacementActive) { + handleCeilingItemPlacementClick(planPoint, event) + return true + } + // Generic catch-all — registry-driven tool whose kind has no // local floor-plan draft handler (column / spawn / shelf / etc.). // The tool's `grid:click` subscriber owns the placement. @@ -247,10 +263,12 @@ export function useFloorplanBackgroundPlacement({ findClosestWallPoint, floorplanOpeningLocalY, getSnappedFloorplanPoint, + handleCeilingItemPlacementClick, handleCeilingPlacementPoint, handleSlabPlacementPoint, handleZonePlacementPoint, isCeilingBuildActive, + isCeilingItemPlacementActive, isFenceBuildActive, isFloorplanGridInteractionActive, isOpeningPlacementActive, 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 8edc1207d..1624a0934 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1168,9 +1168,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const mesh = sceneRegistry.nodes.get(draft.id) if (mesh) mesh.position.copy(gridPosition.current) - // Publish live transform for 2D floorplan + // Publish live transform for 2D floorplan. The item override in + // `floorplan-registry-layer` treats `live.position` as building-local + // plan coords (parentId forced to null so the resolver renders it + // directly), so publish the building-local cursor — not the + // world-space `result.cursorPosition`, which otherwise lands the 2D + // visual off the cursor whenever the building isn't at the origin + // with zero rotation. useLiveTransforms.getState().set(draft.id, { - position: result.cursorPosition, + position: [cc.x, cc.y, cc.z], rotation: cursorGroupRef.current.rotation.y, }) } From 942ef289addd2871ca4bc3f41204129410e4b809 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 11:45:05 +0530 Subject: [PATCH 04/35] fix(editor): chevron arrows render on SCENE_LAYER so ink-edge shader outlines them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node arrow handles and polygon-editor edge arrows were tagged for EDITOR_LAYER, which hides them from the post-processing scenePass — the ink-edge shader reads the depth/normal MRT from that pass, so the chevrons rendered flat with no outlined edges. Drop the EDITOR_LAYER tagging on both, matching the wall-height arrow which already stays on SCENE_LAYER for the same reason. Pair with depthWrite: true on the chevron materials so their silhouettes enter the depth buffer; depthTest stays off to keep the chevron drawn on top of underlying geometry. Without depthWrite, only the normal-discontinuity branch of the ink shader can detect the chevron, and the lines drop out when faces align with whatever sits behind them in screen space. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/editor/node-arrow-handles.tsx | 29 +++++++++++-------- .../tools/shared/polygon-editor.tsx | 17 +++++++++-- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index b9d5e1a03..3125d8aab 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -39,7 +39,6 @@ import { } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' -import { EDITOR_LAYER } from '../../lib/constants' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' @@ -342,16 +341,14 @@ function NodeArrowHandlesForNode({ const outerRef = useRef(null) const innerRef = useRef(null) - // Tag all arrow objects for EDITOR_LAYER so the ThumbnailGenerator camera - // (which calls cam.layers.disable(EDITOR_LAYER)) excludes them from captures. - // descriptors is used as a trigger dep: when handles change, new mesh objects - // are created and need their layers set even though it isn't read in the body. - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional trigger dep - useEffect(() => { - outerRef.current?.traverse((obj) => { - obj.layers.set(EDITOR_LAYER) - }) - }, [descriptors]) + // Keep arrow objects on SCENE_LAYER so the post-processing scenePass + // captures them in the depth/normal MRT — that's what feeds the ink-edge + // shader, and it's the reason the wall height arrow (which also stays on + // SCENE_LAYER) reads as a proper 3D plate with outlined edges. Putting + // them on EDITOR_LAYER hides them from scenePass and the chevron renders + // flat. Arrows are only mounted while a node is selected, so thumbnail + // captures (which never have selection) don't need the layer-based + // exclusion the wall arrow also goes without. useFrame(() => { if (outerRef.current && outerRide) { @@ -421,8 +418,16 @@ function useArrowMaterial(): MeshBasicNodeMaterial { new MeshBasicNodeMaterial({ color: new Color(ARROW_COLOR), side: DoubleSide, + // `depthTest: false` keeps the chevron drawing on top of any + // geometry under it; `depthWrite: true` puts the chevron's depth + // into the scenePass buffer so the ink-edge shader's depth + // Laplacian fires on its silhouette from every angle. Without + // depthWrite, only the normal-discontinuity branch can detect + // the chevron, and that signal collapses when the arrow's faces + // happen to align with whatever sits behind them in screen space + // — which is why the lines used to drop out depending on the view. depthTest: false, - depthWrite: false, + depthWrite: true, transparent: true, opacity: 1, }), diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index fba9917a9..b59ac0562 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -611,10 +611,17 @@ export const PolygonEditor: React.FC = ({ {/* Per-side resize arrow — points outward from the edge. Dragging it pulls (or pushes) only this edge's two vertices along the outward normal; the opposite side - of the polygon stays put. */} + of the polygon stays put. + + Stays on SCENE_LAYER (no `layers={EDITOR_LAYER}`) so the + post-processing scenePass picks it up in the depth/normal + MRT and the ink-edge shader paints dark outlines on the + chevron — same treatment as the wall and registry height + arrows. The surrounding line/vertex/edge-box handles stay + on EDITOR_LAYER because they're not chevrons and reading + as flat overlays is the intended look there. */} { if (e.button !== 0) return e.stopPropagation() @@ -640,8 +647,12 @@ export const PolygonEditor: React.FC = ({ color={ isDragging ? '#22c55e' : isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR } + // depthTest off → still drawn on top of underlying surface. + // depthWrite on → silhouette enters the depth buffer so the + // ink-edge shader paints it from every angle, like all the + // other registry chevrons. depthTest={false} - depthWrite={false} + depthWrite={true} transparent /> From b1f1b9011d0f120039ba67dc7d3a74da61aeb8df Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 12:44:35 +0530 Subject: [PATCH 05/35] fix(editor): ceiling grid overlay no longer blocks selecting items under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues kept the ceiling grid overlay covering the room after the user moved on from a ceiling-related action: 1. CeilingSystem treated any selected descendant of a ceiling as "reveal the grid" — so after placing a ceiling light and the new item became selected, the grid stayed on and its mesh intercepted every subsequent 3D click, re-selecting the ceiling instead of the items below. Restrict the reveal to directly-selected ceilings. 2. The ceiling top material used the opaque surface-role material, so a top-down camera lost view of everything under the ceiling the moment the overlay turned on. Swap the top material for the transparent grid-pattern material (bottom stays opaque so the in-room view still reads as a solid surface). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../systems/ceiling/ceiling-system.tsx | 18 +++++++++++------- packages/nodes/src/ceiling/renderer.tsx | 12 +++++++++++- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx index 71917ba44..f557b3fdf 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-system.tsx @@ -25,15 +25,19 @@ export const CeilingSystem = () => { } for (const id of selectedIds) { - let currentId: string | null = id - let isCeilingRelated = false - let levelId: string | null = null + // Only treat a directly-selected ceiling as "reveal the grid"; a + // selected descendant (e.g. a freshly-placed ceiling light) used to + // count too, which left the opaque grid overlay covering the room + // even after the user moved on. With the grid still in front, every + // subsequent click in 3D hit the grid mesh (its `useNodeEvents` + // handlers re-selected the ceiling) instead of the items below. + const selectedNode = nodes[id as AnyNodeId] + if (selectedNode?.type !== 'ceiling') continue + let currentId: string | null = selectedNode.parentId as string | null + let levelId: string | null = null while (currentId && nodes[currentId as AnyNodeId]) { const node = nodes[currentId as AnyNodeId] - if (node?.type === 'ceiling') { - isCeilingRelated = true - } if (node?.type === 'level') { levelId = node.id break @@ -41,7 +45,7 @@ export const CeilingSystem = () => { currentId = node?.parentId as string | null } - if (isCeilingRelated && levelId) { + if (levelId) { levelsToShowCeilings.add(levelId) } } diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 1b743ef81..82a985e7a 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -9,6 +9,7 @@ import { import { createSurfaceRoleMaterial, NodeRenderer, + resolveSurfaceColor, useNodeEvents, useViewer, } from '@pascal-app/viewer' @@ -86,8 +87,17 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { // 'ceiling' role colour; only an explicit preset/material keeps a texture. const hasExplicit = Boolean(node.materialPreset || node.material) if (!textures || !hasExplicit) { + // Bottom (seen from inside the room, looking up) stays opaque so the + // ceiling reads as a solid surface. Top uses the transparent + // grid-pattern material so the ceiling stays see-through whenever + // the editor reveals the `ceiling-grid` overlay (placing a + // ceiling-hosted item, or selecting one of its children — e.g. + // after committing a placement). Without this the top mesh shipped + // an opaque surface-role material, so a top-down camera lost view + // of everything under the ceiling once the overlay turned on. + const ceilingColor = resolveSurfaceColor('ceiling', colorPreset, sceneTheme) return { - topMaterial: createSurfaceRoleMaterial('ceiling', colorPreset, FrontSide, sceneTheme), + topMaterial: getCeilingMaterials(ceilingColor).topMaterial, bottomMaterial: createSurfaceRoleMaterial('ceiling', colorPreset, BackSide, sceneTheme), } } From c84ac208cecf32a94d514238bbbf648c16fca919 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 13:03:16 +0530 Subject: [PATCH 06/35] fix(core): curve- and thickness-aware wall/slab overlap detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wallOverlapsPolygon` and `getSlabElevationForWall` previously treated a wall as an infinitely thin chord from start to end. Two failure modes fell out of that: 1. Curved walls whose chord lies outside the slab but whose centerline bows into the slab interior were missed entirely. The wall stayed at Y=0 while the slab elevation moved, and `markNodesOverlappingSlab` never re-dirtied it when the slab Y changed. 2. Perimeter walls of a room — whose centerline sits exactly on (or just outside) the slab's polygon edge — also missed detection, because pointInPolygon on the boundary is unreliable. Half the wall's body is inside the slab; it should follow the slab elevation. Switch `wallOverlapsPolygon` to a wall-shaped input (start/end + optional curveOffset + thickness), sample the centerline for curved walls, and add a ±halfThickness perpendicular test for straight walls. Threaded through `getSlabElevationForWall`, the wall system, and the `markNodesOverlappingSlab` pass. Legacy chord-only call shape preserved for callers that don't yet have a wall in hand. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../spatial-grid/spatial-grid-manager.ts | 119 ++++++++++++++++-- .../hooks/spatial-grid/spatial-grid-sync.ts | 12 +- .../viewer/src/systems/wall/wall-system.tsx | 8 +- 3 files changed, 126 insertions(+), 13 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index b9708753f..49d8a354c 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,6 +1,8 @@ import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import useScene from '../../store/use-scene' +import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve' +import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -284,21 +286,82 @@ function segmentsCollinearAndOverlap( return a1OnB && a2OnB } +type WallOverlapInput = { + start: [number, number] + end: [number, number] + curveOffset?: number + thickness?: number +} + /** * Test if a wall segment overlaps with a polygon. * A wall is considered to overlap if: * - Its midpoint is inside the polygon (wall crosses through) * - At least one endpoint is inside (wall partially or fully in slab) * - It's collinear with and overlaps a polygon edge (wall on slab boundary) + * - (curved walls) any sample along the centerline is inside * * Note: A wall with just one endpoint touching the edge but the rest outside * is NOT considered overlapping (adjacent only). */ export function wallOverlapsPolygon( - start: [number, number], - end: [number, number], - polygon: Array<[number, number]>, + startOrWall: [number, number] | WallOverlapInput, + endOrPolygon: [number, number] | Array<[number, number]>, + polygonArg?: Array<[number, number]>, ): boolean { + // Two call shapes: + // wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware + // wallOverlapsPolygon(start, end, polygon) — legacy chord-only + let start: [number, number] + let end: [number, number] + let polygon: Array<[number, number]> + let curveOffset = 0 + let thickness = DEFAULT_WALL_THICKNESS + if (Array.isArray(startOrWall)) { + start = startOrWall as [number, number] + end = endOrPolygon as [number, number] + polygon = polygonArg as Array<[number, number]> + } else { + start = startOrWall.start + end = startOrWall.end + curveOffset = startOrWall.curveOffset ?? 0 + thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS + polygon = endOrPolygon as Array<[number, number]> + } + const halfThickness = Math.max(thickness / 2, 0) + + // Curved walls: sample the centerline. The chord-based checks below miss + // walls that bow into/out of the slab — e.g. endpoints on the slab + // boundary with the curve arcing inward through the slab interior. Without + // this, `getSlabElevationForWall` returns 0 (wall drops to floor) and + // `markNodesOverlappingSlab` never re-dirties the wall when the slab Y + // moves. + if (curveOffset !== 0) { + const wallLike = { start, end, curveOffset } + if (isCurvedWall(wallLike)) { + const samples = sampleWallCenterline(wallLike, 16) + for (let i = 0; i < samples.length; i++) { + const point = samples[i]! + if (pointInPolygon(point.x, point.y, polygon)) return true + // Also test ±halfThickness perpendicular at each sample so a curve + // skirting the slab edge with its centerline just outside still + // registers — its body sits inside the slab. + if (halfThickness > 0 && i > 0) { + const prev = samples[i - 1]! + const sx = point.x - prev.x + const sz = point.y - prev.y + const sl = Math.sqrt(sx * sx + sz * sz) + if (sl > 1e-10) { + const tnx = (-sz / sl) * halfThickness + const tnz = (sx / sl) * halfThickness + if (pointInPolygon(point.x + tnx, point.y + tnz, polygon)) return true + if (pointInPolygon(point.x - tnx, point.y - tnz, polygon)) return true + } + } + } + } + } + const dx = end[0] - start[0] const dz = end[1] - start[1] const len = Math.sqrt(dx * dx + dz * dz) @@ -331,6 +394,26 @@ export function wallOverlapsPolygon( if (pointInPolygon(bx + pnx, bz + pnz, polygon)) return true if (pointInPolygon(bx - pnx, bz - pnz, polygon)) return true } + + // Wall-thickness perpendicular test. Walls aren't infinitely thin lines; + // a wall whose centerline sits just outside the slab boundary still has + // half its body inside the slab and should follow the slab elevation. + // Without this, the perimeter walls of a room often miss the slab-overlap + // detection (the slab polygon is the room's interior, the wall centerline + // sits on or just outside its edge) and stay at Y=0 while the slab moves + // up. + if (halfThickness > 0) { + const ux = dx / len + const uz = dz / len + const tnx = -uz * halfThickness + const tnz = ux * halfThickness + for (const t of [0, 0.25, 0.5, 0.75, 1]) { + const bx = start[0] + dx * t + const bz = start[1] + dz * t + if (pointInPolygon(bx + tnx, bz + tnz, polygon)) return true + if (pointInPolygon(bx - tnx, bz - tnz, polygon)) return true + } + } } // Check if midpoint is inside (catches walls crossing through) @@ -762,15 +845,33 @@ export class SpatialGridManager { * Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes). * Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments). * Returns the highest slab elevation found, or 0 if none. + * + * Accepts an optional `curveOffset` so curved walls evaluate overlap + * against their actual centerline samples, not just the chord. */ - getSlabElevationForWall(levelId: string, start: [number, number], end: [number, number]): number { + getSlabElevationForWall( + levelId: string, + start: [number, number], + end: [number, number], + curveOffset = 0, + thickness = DEFAULT_WALL_THICKNESS, + ): number { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) return 0 + const wallLike: WallOverlapInput = { start, end, curveOffset, thickness } + const isCurved = curveOffset !== 0 && isCurvedWall(wallLike) + const holeSamplePoints: Array<{ x: number; y: number }> = isCurved + ? sampleWallCenterline(wallLike, 8) + : [0, 0.25, 0.5, 0.75, 1].map((t) => ({ + x: start[0] + (end[0] - start[0]) * t, + y: start[1] + (end[1] - start[1]) * t, + })) + let maxElevation = Number.NEGATIVE_INFINITY for (const slab of slabMap.values()) { if (slab.polygon.length < 3) continue - if (!wallOverlapsPolygon(start, end, slab.polygon)) continue + if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue const holes = slab.holes || [] if (holes.length === 0) { @@ -783,15 +884,11 @@ export class SpatialGridManager { // Sample multiple points along the wall to check whether any portion lies on // solid slab (not inside any hole). Checking only the midpoint fails when the // midpoint falls in a staircase hole but the wall's endpoints are on solid slab. - const dx = end[0] - start[0] - const dz = end[1] - start[1] let hasValidPoint = false - for (const t of [0, 0.25, 0.5, 0.75, 1]) { - const px = start[0] + dx * t - const pz = start[1] + dz * t + for (const sample of holeSamplePoints) { let inHole = false for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(px, pz, hole)) { + if (hole.length >= 3 && pointInPolygon(sample.x, sample.y, hole)) { inHole = true break } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 5825bcb0a..ba669c218 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -166,7 +166,17 @@ function markNodesOverlappingSlab( if (node.type === 'wall') { const wall = node as WallNode if (resolveLevelId(node, nodes) !== slabLevelId) continue - if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) { + if ( + wallOverlapsPolygon( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset ?? 0, + thickness: wall.thickness, + }, + slab.polygon, + ) + ) { markDirty(node.id) } continue diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 5a578cc49..365cbccdd 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -476,7 +476,13 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { if (!mesh) return const levelId = resolveLevelId(node, nodes) - const slabElevation = spatialGridManager.getSlabElevationForWall(levelId, node.start, node.end) + const slabElevation = spatialGridManager.getSlabElevationForWall( + levelId, + node.start, + node.end, + node.curveOffset ?? 0, + node.thickness, + ) const childrenIds = node.children || [] // Merge live overrides into door / window children so cutouts track an From 4ca8c3e08a5e2cab9f15d40f550e5640759d7435 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 13:03:23 +0530 Subject: [PATCH 07/35] fix(editor): show Move button for legacy-movable kinds in floating action menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isRegistryMovable` only sees kinds wired through `capabilities.movable`, `floorplanMoveTarget`, or `affordanceTools.move`. The legacy tail of `MoveTool` (tools/item/move-tool.tsx) still handles roof, roof-segment, stair, stair-segment, building, and elevator, but the floating action menu was hiding the Move button for them because the registry check returned false. The mover worked once invoked — the entry point was missing. Add a `LEGACY_MOVABLE_KINDS` set alongside the registry check so those kinds get the Move button until they migrate onto kind-owned affordances; drop a kind from the set once it does. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/floating-action-menu.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 2efd1ec27..ae0af888b 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -52,6 +52,20 @@ const ALLOWED_TYPES = [ ] const DELETE_ONLY_TYPES: string[] = [] const HOLE_TYPES = ['slab', 'ceiling'] +// Kinds whose move tool is wired in the legacy tail of `MoveTool` +// (tools/item/move-tool.tsx) rather than through `affordanceTools.move`. +// `isRegistryMovable` only sees the registry-native paths, so it returns +// false for these even though `MoveTool` knows how to drive them — keep +// this list in sync with the tail of that dispatcher. Drop a kind once +// it migrates to a kind-owned affordance. +const LEGACY_MOVABLE_KINDS = new Set([ + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'building', + 'elevator', +]) // Menu scales with camera zoom so it feels anchored to the object, but is // clamped on both ends so it stays readable when zoomed way out and doesn't @@ -447,13 +461,17 @@ export function FloatingActionMenu() { : undefined } onMove={ - // Registry-driven: any kind that declares + // Registry-driven for kinds that declare // `capabilities.movable`, a `floorplanMoveTarget`, or a - // 3D `affordanceTools.move` mover gets the Move button. - // Replaces the previous 13-arm `node?.type === '…'` - // chain so adding a new movable kind doesn't touch this - // file. - node && isRegistryMovable(node.type) ? handleMove : undefined + // 3D `affordanceTools.move` mover. The legacy tail of + // `MoveTool` (see `tools/item/move-tool.tsx`) also handles + // a small set of kinds that haven't been ported to a + // kind-owned affordance yet — list them here so their + // Move buttons render too. Drop a kind from the set when + // its bespoke mover migrates onto `affordanceTools.move`. + node && (isRegistryMovable(node.type) || LEGACY_MOVABLE_KINDS.has(node.type)) + ? handleMove + : undefined } onDelete={handleDelete} onDuplicate={ From 2a6882c92e0c64054862db16bc812fe04ff6dea0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 14:46:40 +0530 Subject: [PATCH 08/35] feat(nodes): in-world registry handles for roof accessories + axis-stable surface basis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the registry-driven chevron / tracker / rotate-gizmo handle set to skylight, solar-panel, chimney, and roof-segment so every roof-mounted kind has consistent in-world manipulation, and fixes the underlying renderer + surface-basis bugs that made those handles land on the wrong spot or behave inconsistently across mirrored slopes. Handles - `LinearResizeHandle` gains `shape: 'arrow' | 'tracker'`. `'tracker'` renders a dashed vertical leader from the surface up to a draggable cube, reusing the linear-resize drag pipeline. Roof-segment's wall-height handle adopts it. - Roof-segment: width chevrons split into two asymmetric handles (each grows its own edge, opposite stays world-fixed via `apply` recomputing `position`). - Skylight: width × 2 (asymmetric), height × 2 (asymmetric), curb-height tracker, rotate gizmo (corner, lifted off surface), and a diagonal frame-thickness chevron at the -X+Z corner. - Solar panel: same six handles operating on total array dimensions (back-solving `panelWidth` / `panelHeight` from `columns` / `rows`), plus a frame-depth chevron above the array. - Chimney: registry handle set following the same idioms. Skylight / solar-panel renderer - Collapse the previously nested `position → surfaceQuat → rotation-y → rotation-x` groups into a single registered transform group whose local `position` + composed `quaternion` carry the full pose in segment frame. The registry handles read this Object3D's local matrix (via `portal: 'grandparent'`), and a split tree exposed only the bottom group's local pose so handles landed at the segment origin on the roof floor. Surface basis (solar-panel/geometry.ts) - `surfaceQuatFromNormal` builds `right` by projecting world +X onto the surface plane instead of `up × normal`. The cross-product version flipped sign when the normal's Z component flipped (e.g. the two slopes of a gable roof), so hosted children's local +X pointed in opposite world directions across the ridge and asymmetric chevrons anchored the wrong edge. Projecting +X keeps the basis stable across mirror-image slopes. Skylight move-tool ghost - Switch from the raycast normal (`event.normal × normalMatrix`) to the analytical normal (`getAnalyticalNormal`) on every pointer move, and mirror the placement tool's transform stack: `position → yaw (roof + segment) → surfaceQuat → skylight rotation → preview`. Re-engaging Move from the floating action menu now shows the same correctly oriented ghost the first-placement tool does. --- packages/core/src/registry/handles.ts | 35 ++ .../components/editor/node-arrow-handles.tsx | 180 +++++++++- packages/nodes/src/chimney/definition.ts | 321 +++++++++++++++++- packages/nodes/src/chimney/geometry.ts | 15 +- packages/nodes/src/chimney/holes.ts | 12 +- packages/nodes/src/chimney/renderer.tsx | 98 +++--- packages/nodes/src/chimney/roof-trim.ts | 22 +- packages/nodes/src/roof-segment/definition.ts | 65 +++- packages/nodes/src/skylight/definition.ts | 200 +++++++++++ packages/nodes/src/skylight/move-tool.tsx | 66 ++-- packages/nodes/src/skylight/renderer.tsx | 56 ++- packages/nodes/src/solar-panel/definition.ts | 214 +++++++++++- packages/nodes/src/solar-panel/geometry.ts | 22 +- packages/nodes/src/solar-panel/renderer.tsx | 48 ++- 14 files changed, 1206 insertions(+), 148 deletions(-) diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 754a3c3d2..181b47ed8 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -122,6 +122,29 @@ export type LinearResizeHandle = { cursor?: Cursor /** Optional visual guide shown while the arrow is hovered or dragging. */ decoration?: HandleDecoration + /** + * Visual override. Defaults to the standard chevron arrow. + * + * `'tracker'` swaps the chevron for a dashed vertical leader + a small + * cube at `placement.position`. The leader runs from the floor (local + * y=0) up to the cube; the cube is the drag target and reuses the same + * linear-resize drag pipeline as the chevron. Intended for vertical + * height handles where the dashed leader makes the "this is the wall + * top" relationship readable at a glance — mirrors the `corner-picker` + * shape on `tap-action` handles but with a draggable cube instead of a + * one-tap hex disc. Use with `axis: 'y'`; horizontal axes will render + * the leader vertically and look wrong. + */ + shape?: 'arrow' | 'tracker' + /** + * Optional override for the bottom Y of the tracker leader. Defaults + * to 0 (floor of the rideObject's local frame). Use when the value + * being tracked spans a region that doesn't start at the floor — e.g. + * a chimney's body height runs from the roof deck up to the body top, + * so the leader should start at the deck plane and not climb through + * the roof shell below it. Only consulted when `shape === 'tracker'`. + */ + trackerBaseY?: (node: N, sceneApi: SceneApi) => number } /** @@ -176,6 +199,18 @@ export type ArcResizeHandle = { * arrow icon, intended for whole-node rotation handles. */ shape?: 'chevron' | 'rotate' + /** + * Pivot point for the angular drag, in the rideObject's local space. + * The renderer measures cursor angle (atan2 on the drag plane) around + * this point — descriptors that write `rotation` should anchor it to + * the node's visual center. Defaults to the rideObject's own origin, + * which is correct for nodes whose mesh origin coincides with the + * field they're rotating (roof-segment, elevator). Use this when the + * node's pose is baked into its geometry (chimney) so the mesh origin + * sits at the parent frame's origin rather than the rotating shape's + * center. + */ + rotationCenter?: (node: N, sceneApi: SceneApi) => readonly [number, number, number] } /** diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 3125d8aab..e42b0de92 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -21,6 +21,7 @@ import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { + BoxGeometry, type BufferGeometry, Color, CylinderGeometry, @@ -649,6 +650,59 @@ function LinearArrow({ const showLabel = isHovered || isDragging const labelText = showLabel ? formatDimension(descriptor.currentValue(node), unit) : '' + // `tracker` shape on a linear-resize handle: render a dashed vertical + // leader from the floor up to a small cube at `placement.position`. The + // cube is the drag target and reuses the same `activate` pointer handler + // as the chevron path, so all the override/commit plumbing is unchanged. + // Only valid for axis='y' resize handles — the leader is rendered at + // (0,0,0)→(0,position.y,0) in the same group as the cube, so for x/z + // axes the leader would still climb vertically and look wrong. + const shape = + descriptor.kind === 'linear-resize' && descriptor.shape === 'tracker' ? 'tracker' : 'arrow' + + if (shape === 'tracker') { + // Descriptors can pin the leader's bottom Y above the floor — e.g. + // chimney body height starts at the deck plane, not at y=0, so the + // dashed leader spans only the body's visible extent. + const trackerDescriptor = descriptor as LinearResizeHandle + const baseY = + trackerDescriptor.trackerBaseY?.(node as never, placementSceneApi) ?? 0 + const leaderHeight = Math.max(position[1] - baseY, 0) + return ( + <> + {showDecoration && decoration ? ( + + ) : null} + {showLabel ? ( + + ) : null} + { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = cursor + }} + onLeave={(event) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === cursor) { + document.body.style.cursor = '' + } + }} + zoom={zoom} + /> + + ) + } + return ( <> {showDecoration && decoration ? ( @@ -781,11 +835,23 @@ function ArcArrow({ event.stopPropagation() // Horizontal drag plane at the arrow's world Y. Atan2 around the - // node's local origin (= rideObject world center) gives the cursor's - // bearing — delta between samples is the angular drag. + // rotation pivot gives the cursor's bearing — delta between samples + // is the angular drag. + // + // Default pivot is the rideObject's world origin (= node-local + // origin) which is correct when the mesh origin coincides with the + // shape being rotated (roof-segment, elevator). Nodes that bake + // pose into their geometry (chimney) can override via + // `descriptor.rotationCenter`, which we apply through the + // rideObject's matrixWorld so the descriptor stays in node-local + // coordinates. rideObject.updateMatrixWorld() - const centerWorld = new Vector3() - rideObject.getWorldPosition(centerWorld) + const centerWorld = + descriptor.rotationCenter !== undefined + ? new Vector3(...descriptor.rotationCenter(node as never, createSceneApi(useScene))).applyMatrix4( + rideObject.matrixWorld, + ) + : new Vector3().setFromMatrixPosition(rideObject.matrixWorld) const arrowWorld = new Vector3(...position).applyMatrix4(rideObject.matrixWorld) const planeY = arrowWorld.y const plane = new Plane(new Vector3(0, 1, 0), -planeY) @@ -1171,3 +1237,109 @@ function CornerPickerShape({ ) } + +// Tracker visual for the `linear-resize` handle's `shape: 'tracker'` option. +// Mirrors the corner picker (dashed vertical leader from the floor) but caps +// the leader with a small draggable cube instead of a hex disc, and the cube +// sits at the TOP of the leader rather than the floor — the visual reads as +// "this cube is the wall top; drag it to raise/lower." All interactivity +// (pointer-down → linear-resize drag) is wired by the parent `LinearArrow`. +const TRACKER_CUBE_SIZE = 0.16 + +function TrackerShape({ + basePosition, + cubePosition, + leaderHeight, + zoom, + isHovered, + onActivate, + onEnter, + onLeave, +}: { + basePosition: readonly [number, number, number] + cubePosition: readonly [number, number, number] + leaderHeight: number + zoom: number + isHovered: boolean + onActivate: (event: ThreeEvent) => void + onEnter: (event: ThreeEvent) => void + onLeave: (event: ThreeEvent) => void +}) { + // `leaderHeight === 0` (wallHeight collapsed to floor) would make the + // dashed builder return an empty geometry — skip the mesh entirely in + // that case so the cube still renders by itself. + const hasLeader = leaderHeight > 0.0001 + const dashedGeometry = useMemo( + () => (hasLeader ? buildDashedVerticalGeometry(leaderHeight) : null), + [hasLeader, leaderHeight], + ) + useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry]) + + const cubeGeometry = useMemo( + () => new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE), + [], + ) + useEffect(() => () => cubeGeometry.dispose(), [cubeGeometry]) + + const dashMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + transparent: true, + opacity: 0.85, + depthTest: false, + depthWrite: false, + }), + [], + ) + const cubeMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + transparent: true, + opacity: 1, + // depthTest off keeps the cube visible through any geometry sitting + // between camera and wall top; depthWrite on so the ink-edge pass + // catches the cube silhouette from every angle (same reasoning as + // the chevron — without it the lines fade in/out by view angle). + depthTest: false, + depthWrite: true, + }), + [], + ) + useEffect(() => { + const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR + dashMaterial.color.set(next) + cubeMaterial.color.set(next) + }, [dashMaterial, cubeMaterial, isHovered]) + useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) + useEffect(() => () => cubeMaterial.dispose(), [cubeMaterial]) + + const cubeScale = (isHovered ? 1.25 : 1) * zoom + + return ( + <> + {dashedGeometry ? ( + + ) : null} + + + ) +} diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index 4cb0d276c..db3cd094e 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -1,8 +1,326 @@ -import { ChimneyNode as ChimneyNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { + type AnyNodeId, + type ChimneyNode as ChimneyNodeType, + ChimneyNode as ChimneyNodeSchema, + getActiveRoofHeight, + type HandleDescriptor, + type NodeDefinition, + type RoofSegmentNode as RoofSegmentNodeType, + type SceneApi, +} from '@pascal-app/core' import { chimneyPaint } from './paint' import { chimneyParametrics } from './parametrics' import { ChimneyNode } from './schema' +// Side handle offsets in metres. Match the roof-segment values so a +// segment + chimney selected back-to-back use the same visual rhythm. +const SIDE_HANDLE_OFFSET = 0.25 +const HEIGHT_HANDLE_OFFSET = 0.25 +const ROTATE_CORNER_OFFSET = 0.35 +const MIN_BODY_DIM = 0.3 +const MIN_HEIGHT_ABOVE_RIDGE = 0.05 +const MIN_FLUE_HEIGHT = 0.05 +const MAX_FLUE_HEIGHT = 1.5 +const MIN_CAP_THICKNESS = 0.02 +const MAX_CAP_THICKNESS = 0.5 +const MIN_CAP_OVERHANG = 0 +const MAX_CAP_OVERHANG = 0.3 +// Cap-reveal gap between body top and cap base — mirrors the +// `CAP_REVEAL` constant in `geometry.ts`. Local copy because handle +// placements need to know the cap's Y range and the geometry's +// constant isn't exported. If you change it here, change it there. +const CAP_REVEAL = 0.003 +// Fallback Y when the host segment can't be resolved (shouldn't happen +// for a placed chimney, but `placement.position` runs synchronously and +// must always return a vector). +const FALLBACK_BODY_MID_Y = 1.5 + +// Resolve the segment that hosts this chimney. Returns undefined if the +// chimney is unparented or the parent isn't in the scene yet. +function resolveHostSegment( + node: ChimneyNodeType, + sceneApi: SceneApi, +): RoofSegmentNodeType | undefined { + if (!node.roofSegmentId) return undefined + return sceneApi.get(node.roofSegmentId as AnyNodeId) +} + +// Mid-Y of the visible chimney body in the host segment's local frame. +// Matches the geometry builder: body runs from `baseY = max(0, wallHeight +// - 0.2)` up to `peakY + heightAboveRidge`. The handle Y picks the +// midpoint of the *visible* portion (deck plane → top) so chevrons sit +// next to the body, not buried inside the roof deck or floating over +// the eave. +function getBodyMidY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number { + const peakY = segment.wallHeight + getActiveRoofHeight(segment) + const topY = peakY + node.heightAboveRidge + return (segment.wallHeight + topY) / 2 +} + +// Top of the chimney body (where the cap reveal gap begins). Tracker +// handle and cap-thickness handle both reference this Y. +function getBodyTopY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number { + return segment.wallHeight + getActiveRoofHeight(segment) + node.heightAboveRidge +} + +// Cap base Y — the bottom of the cap slab. Sits just above the body +// top with a small reveal gap so a shadow line separates them. +function getCapBaseY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number { + return getBodyTopY(node, segment) + CAP_REVEAL +} + +// Cap top Y — the top of the cap slab. Falls back to body top when no +// cap is rendered (flues mount on whichever is the upper surface). +function getCapTopY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number { + if (!node.cap || node.capShape === 'none') return getBodyTopY(node, segment) + return getCapBaseY(node, segment) + node.capThickness +} + +// Width arrow on the +X (right) or -X (left) side. Asymmetric resize: +// dragging one arrow grows the chimney outward from its own edge while +// the opposite edge stays world-fixed. Handles live in the chimney's +// registered ref frame (the nested inner group in the renderer that +// applies `node.position` / `node.rotation`), so placements are in +// chimney-local coordinates — no per-arrow rotation/translation +// compensation. `apply` keeps the world-fixed edge anchored even when +// the chimney is rotated by recentering `position` along the chimney's +// own +X arm in segment frame. +function chimneyWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + // Portal into the roof (grandparent), not the segment (parent). Unpainted + // roof segments live inside a `visible={false}` wrapper, which would + // hide the handles. The roof itself is always visible. Skylight does + // the same. + portal: 'grandparent', + min: MIN_BODY_DIM, + currentValue: (n) => n.width, + apply: (initial, newWidth) => { + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initial.width / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ + const newCenterX = anchorX + sign * (newWidth / 2) * armX + const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ + return { + width: newWidth, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + const y = segment ? getBodyMidY(n, segment) : FALLBACK_BODY_MID_Y + return [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), y, 0] + }, + // Chevron faces along the chimney's own ±X; the left arrow flips + // 180°. No node.rotation here — the registered inner group is + // already rotated by `node.rotation`, so chimney-local +X is the + // chevron's natural direction. + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Depth arrow — symmetric on the +Z side. Only meaningful for square +// bodies; round chimneys are circular (depth field is ignored by the +// geometry builder, so a depth handle would just resize an invisible +// field). The chimneys factory below omits this descriptor for round +// bodies. +function chimneyDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_BODY_DIM, + currentValue: (n) => n.depth, + apply: (_n, newValue) => ({ depth: newValue }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + const y = segment ? getBodyMidY(n, segment) : FALLBACK_BODY_MID_Y + return [0, y, n.depth / 2 + SIDE_HANDLE_OFFSET] + }, + }, + } +} + +// Height-above-ridge tracker. Dashed leader spans the chimney body's +// visible extent — from the roof deck plane up to the body top — and +// terminates in a draggable cube at the body top. Cap, flues, cricket +// and bands sit ABOVE the body and are explicitly excluded from the +// leader so the height affordance reads as "this is the body height", +// not "this is the whole stack height". Dragging the cube vertically +// adjusts `heightAboveRidge` 1:1. +function chimneyHeightAboveRidgeHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + shape: 'tracker', + min: MIN_HEIGHT_ABOVE_RIDGE, + currentValue: (n) => n.heightAboveRidge, + apply: (initial, newValue) => ({ + heightAboveRidge: Math.max(MIN_HEIGHT_ABOVE_RIDGE, newValue), + }), + placement: { + // Cube sits AT the body top (no offset) so the leader terminates + // exactly at the body's top edge — visually the "ceiling" of the + // body, before the cap reveal gap. + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + const y = segment ? getBodyTopY(n, segment) : FALLBACK_BODY_MID_Y + return [0, y, 0] + }, + }, + // Leader bottom = deck plane (segment.wallHeight). The chimney body + // geometry actually extends a touch below the deck so the bottom + // doesn't show above the eave on low-slope roofs (`baseY = max(0, + // wallHeight - 0.2)`), but the visible portion starts at the deck — + // and starting the leader there is what reads as "body height" to + // the user. + trackerBaseY: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + return segment?.wallHeight ?? 0 + }, + } +} + +// Whole-chimney rotation gizmo at the +X/+Z corner of the body +// footprint. The registered inner group already centers on the chimney +// and applies its yaw, so the default rotation pivot (rideObject origin) +// is correct — no `rotationCenter` override needed. +function chimneyRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + const y = segment ? getBodyMidY(n, segment) : FALLBACK_BODY_MID_Y + const isRound = n.bodyShape === 'round' + const halfX = n.width / 2 + ROTATE_CORNER_OFFSET + const halfZ = (isRound ? n.width : n.depth) / 2 + ROTATE_CORNER_OFFSET + return [halfX, y, halfZ] + }, + // The two-headed icon's natural bias points along +X; aim it + // toward the corner (45° outward from the chimney's local frame). + rotationY: () => -Math.PI / 4, + }, + } +} + +// Flue-height chevron at the center of the cap top, pointing upward. +// Drag adjusts `flueHeight` for ALL flues uniformly — the schema only +// carries a single scalar. Placed at chimney center (X=Z=0) so the +// handle stays valid regardless of `flueCount` / `flueSpacing`. Anchor +// is 'min' so the flue base stays pinned to the cap and the top edge +// follows the pointer. +function chimneyFlueHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + portal: 'grandparent', + min: MIN_FLUE_HEIGHT, + max: MAX_FLUE_HEIGHT, + currentValue: (n) => n.flueHeight, + apply: (_n, newValue) => ({ flueHeight: newValue }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + // Sit the chevron at the flue top so it visually attaches to + // the thing being dragged. Fallback Y mirrors the body-top + // fallback above. + const baseY = segment ? getCapTopY(n, segment) : FALLBACK_BODY_MID_Y + return [0, baseY + n.flueHeight, 0] + }, + }, + } +} + +// Cap-thickness chevron above the cap top, pointing upward. Anchor is +// 'min' so the cap base stays at body-top + reveal and the top edge +// follows the pointer. +function chimneyCapThicknessHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + portal: 'grandparent', + min: MIN_CAP_THICKNESS, + max: MAX_CAP_THICKNESS, + currentValue: (n) => n.capThickness, + apply: (_n, newValue) => ({ capThickness: newValue }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + // Offset toward +Z (away from chimney center on the depth axis) + // so the cap-thickness chevron doesn't overlap the flue-height + // chevron at X=0,Z=0. Pick the cap edge minus a small margin so + // it sits on top of the cap, not floating off to the side. + const isRound = n.bodyShape === 'round' + const halfZ = (isRound ? n.width : n.depth) / 2 + const z = halfZ * 0.35 + const y = segment ? getCapTopY(n, segment) : FALLBACK_BODY_MID_Y + return [0, y, z] + }, + }, + } +} + +// Cap-overhang radial chevron on the +X edge of the cap. Outward 1:1 +// drag grows the overhang; the cap's half-extent is `width/2 + overhang`. +function chimneyCapOverhangHandle(): HandleDescriptor { + return { + kind: 'radial-resize', + axis: 'x', + portal: 'grandparent', + min: MIN_CAP_OVERHANG, + max: MAX_CAP_OVERHANG, + currentValue: (n) => n.capOverhang, + apply: (_n, newValue) => ({ capOverhang: newValue }), + placement: { + position: (n, sceneApi) => { + const segment = resolveHostSegment(n, sceneApi) + // Cap mid-height in the chimney-local frame so the chevron sits + // on the cap edge, not above or below it. + const capBaseY = segment ? getCapBaseY(n, segment) : FALLBACK_BODY_MID_Y + const y = capBaseY + n.capThickness / 2 + return [n.width / 2 + n.capOverhang + SIDE_HANDLE_OFFSET, y, 0] + }, + }, + } +} + +const chimneyHandles = (node: ChimneyNodeType): HandleDescriptor[] => { + const descriptors: HandleDescriptor[] = [ + chimneyWidthHandle('right'), + chimneyWidthHandle('left'), + ] + if (node.bodyShape !== 'round') descriptors.push(chimneyDepthHandle()) + descriptors.push(chimneyHeightAboveRidgeHandle(), chimneyRotateHandle()) + // Conditional flue/cap handles are temporarily disabled — they fired + // a "Color target has no corresponding fragment stage output" WebGPU + // validation error that the original four handles don't trigger. The + // descriptor shapes (linear-resize y / radial-resize x) match other + // working handles in the codebase, so the cause is likely a TSL/MRT + // pipeline interaction we haven't pinned down. Re-enable one at a + // time after isolating the trigger; the factory + helpers are kept so + // we can flip them back on without re-deriving the placement math. + // if (node.cap && node.capShape !== 'none') { + // descriptors.push(chimneyCapThicknessHandle(), chimneyCapOverhangHandle()) + // } + // if (node.flueCount > 0) descriptors.push(chimneyFlueHeightHandle()) + return descriptors +} + // Every fresh chimney starts as plain white (body + top). The paint // flow / material picker writes preset refs or full `MaterialSchema` // objects on top of this; until then both roles render `#ffffff`. @@ -73,6 +391,7 @@ export const chimneyDefinition: NodeDefinition = { }, parametrics: chimneyParametrics, + handles: chimneyHandles, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/chimney/geometry.ts b/packages/nodes/src/chimney/geometry.ts index 1cf71eb33..540b8d680 100644 --- a/packages/nodes/src/chimney/geometry.ts +++ b/packages/nodes/src/chimney/geometry.ts @@ -138,7 +138,6 @@ function buildBodyGeometry(node: ChimneyNode, baseY: number, topY: number): THRE parts.push(buildSmoothCylinder(baseY + sh, topY, r, r)) } const merged = mergeAndDispose(parts) - applyNodeTransform(merged, node) return merged } @@ -213,7 +212,6 @@ function buildCapGeometry(node: ChimneyNode, capBaseY: number): THREE.BufferGeom break } const merged = mergeAndDispose(parts) - applyNodeTransform(merged, node) return merged } @@ -450,7 +448,6 @@ function buildBandsGeometry( } if (parts.length === 0) return null const merged = mergeAndDispose(parts) - applyNodeTransform(merged, node) return merged } @@ -484,10 +481,14 @@ function buildBandsGeometry( // ─── Helpers ───────────────────────────────────────────────────────── -function applyNodeTransform(geo: THREE.BufferGeometry, node: ChimneyNode) { - if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation) - geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0) -} +// Each builder returns geometry in chimney-local frame (chimney center +// at X/Z origin, Y absolute in the host segment's frame). The renderer +// applies `node.position` / `node.rotation` via a nested registered +// group, which lets `NodeArrowHandles` read a chimney-local mesh frame +// when placing the resize / rotation arrows. Kept as a no-op shim so +// the existing call sites don't need to be touched if a future refactor +// re-introduces per-builder baking. +function applyNodeTransform(_geo: THREE.BufferGeometry, _node: ChimneyNode) {} function buildBufferGeometry(positions: number[], uvs: number[]): THREE.BufferGeometry { const geo = new THREE.BufferGeometry() diff --git a/packages/nodes/src/chimney/holes.ts b/packages/nodes/src/chimney/holes.ts index e8fef23f8..a260f06fd 100644 --- a/packages/nodes/src/chimney/holes.ts +++ b/packages/nodes/src/chimney/holes.ts @@ -270,8 +270,9 @@ function buildPanelCutters(node: ChimneyNode, topY: number): Brush[] { for (const f of faces) { const geo = new THREE.BoxGeometry(f.sizeX, h, f.sizeZ) geo.translate(f.cx, midY, f.cz) - if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation) - geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0) + // Body geometry is in chimney-local frame (node.position/rotation are + // applied by the renderer's nested ref'd group, not baked into the + // buffer geometry), so cutters need to stay in chimney-local too. const idx = geo.getIndex()?.count ?? 0 geo.clearGroups() @@ -298,10 +299,9 @@ function buildCutter( ? new THREE.CylinderGeometry(spec.sizeX / 2, spec.sizeX / 2, h, 24, 1, false) : new THREE.BoxGeometry(spec.sizeX, h, spec.sizeZ) geo.translate(spec.xCenter, midY, 0) - // Match the same node-local transform that `geometry.ts:applyNodeTransform` - // bakes into the body/cap/flue vertices. - if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation) - geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0) + // Cutter stays in chimney-local frame to match the body/cap/flue + // geometry (node.position/rotation are applied via the renderer's + // nested ref'd group, not baked into the buffer geometry). const idx = geo.getIndex()?.count ?? 0 geo.clearGroups() diff --git a/packages/nodes/src/chimney/renderer.tsx b/packages/nodes/src/chimney/renderer.tsx index 508475dd6..16aaa2f65 100644 --- a/packages/nodes/src/chimney/renderer.tsx +++ b/packages/nodes/src/chimney/renderer.tsx @@ -223,67 +223,71 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => { if (!segment || !geo) return null - // The chimney's geometry bakes its baseY using segment.wallHeight inside - // the builder, so the outer group only needs the segment-local X/Z - // offset. Y stays at 0 here. - // Chimneys are mounted inside `RoofRenderer`'s `roof-elements` group, // which sits at the ROOF's origin — not inside the host segment's - // transform. Apply the segment's own position/rotation here so a - // chimney parented to segment N lands on segment N (and not on the - // first segment) once the chimney's segment-local `node.position[0/2]` - // is layered in by `geometry.ts`. Mirrors skylight's renderer. + // transform. Apply the segment's pose on the outer group, then nest a + // ref'd inner group at the chimney's segment-local position + + // rotation so the registered Object3D's local frame is *chimney-local* + // — that's what `NodeArrowHandles` reads to place its arrows. + // Mirrors skylight's renderer; geometry comes from `geometry.ts` in + // chimney-local frame (no transform baking) and lands in the right + // world spot via the two-group stack below. return ( - - {geo.cap && ( + - )} - {geo.flues && ( - - )} - {geo.cricket && ( - - )} - {geo.bands && ( - - )} + {geo.cap && ( + + )} + {geo.flues && ( + + )} + {geo.cricket && ( + + )} + {geo.bands && ( + + )} + ) } diff --git a/packages/nodes/src/chimney/roof-trim.ts b/packages/nodes/src/chimney/roof-trim.ts index 6fc76e23d..225b0c96d 100644 --- a/packages/nodes/src/chimney/roof-trim.ts +++ b/packages/nodes/src/chimney/roof-trim.ts @@ -40,12 +40,21 @@ export function trimChimneyBodyAgainstRoof( ): THREE.BufferGeometry { const { shinSlab, wallBrush } = segBrushes - // Wrap the chimney body in a Brush. The body has `node.position` / - // `node.rotation` baked into its vertices via `applyNodeTransform` - // in `geometry.ts`, so it's already in segment-local space — the - // same frame as the roof brushes from `getRoofSegmentBrushes`. + // The body comes in chimney-local frame — `node.position` / + // `node.rotation` are applied by the renderer's nested ref'd group + // rather than baked into the geometry. Segment brushes from + // `getRoofSegmentBrushes` are in segment-local frame, so we move the + // chimney brush into segment-local space for the CSG pass, then strip + // the same transform back off the result before returning, so the + // mesh stays in chimney-local for the renderer to position via the + // inner ref group. const indexed = mergeVertices(body, 1e-4) if (!indexed.getAttribute('normal')) indexed.computeVertexNormals() + const hasRotation = Math.abs(node.rotation) > 1e-4 + const posX = node.position[0] ?? 0 + const posZ = node.position[2] ?? 0 + if (hasRotation) indexed.rotateY(node.rotation) + indexed.translate(posX, 0, posZ) const indexCount = indexed.getIndex()?.count ?? 0 indexed.clearGroups() if (indexCount > 0) indexed.addGroup(0, indexCount, 0) @@ -70,6 +79,11 @@ export function trimChimneyBodyAgainstRoof( const step2 = csgEvaluator.evaluate(step1, shinSlab, SUBTRACTION) as Brush const out = csgGeometry(step2).clone() + // Strip the same node transform we baked onto the input so the + // returned geometry is back in chimney-local frame (the renderer's + // inner ref'd group applies `node.position` / `node.rotation`). + out.translate(-posX, 0, -posZ) + if (hasRotation) out.rotateY(-node.rotation) const ic = out.getIndex()?.count ?? 0 out.clearGroups() if (ic > 0) out.addGroup(0, ic, 0) diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 397ccd822..107a750c1 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -35,23 +35,51 @@ function getPeakHeight(n: RoofSegmentNodeType): number { return n.wallHeight + getActiveRoofHeight(n) } -// Width arrow — anchor='center' so dragging the +X side grows the full -// footprint symmetrically (both edges move ±delta). Same idiom as the -// elevator / column / shelf width arrow. -function roofSegmentWidthHandle(): HandleDescriptor { +// Width arrow on the +X (right) or -X (left) side. Asymmetric resize: +// dragging one arrow grows the segment outward from its own edge while +// the opposite edge stays world-fixed — the same pattern doors use +// (`door/definition.ts:35-73`). The arrow's chevron points outward +// (`rotationY: Math.PI` flips the left arrow's chevron to face -X) so +// you read "this edge is what moves" at a glance. +// +// `apply` recomputes `position` so the anchored edge stays at the same +// world point even when the segment is Y-rotated: project the segment's +// local +X onto world via (cos r, -sin r), find the anchored edge's +// world XZ from the pre-drag node, then place the new center half a +// new-width away from that anchor in the same direction. +function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 return { kind: 'linear-resize', axis: 'x', - anchor: 'center', + // 'min' = -X edge anchored (right arrow grows the +X edge outward). + // 'max' = +X edge anchored (left arrow grows the -X edge outward). + anchor: side === 'right' ? 'min' : 'max', min: MIN_ROOF_DIM, currentValue: (n) => n.width, - apply: (_n, newValue) => ({ width: newValue }), + apply: (initial, newWidth) => { + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initial.width / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ + const newCenterX = anchorX + sign * (newWidth / 2) * armX + const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ + return { + width: newWidth, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, placement: { position: (n) => [ - n.width / 2 + SIDE_HANDLE_OFFSET, + sign * (n.width / 2 + SIDE_HANDLE_OFFSET), Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2, 0, ], + // Flip the left chevron so it points outward toward -X. The + // generic LinearArrow only auto-orients for axis 'z' (rotates the + // chevron 90° to face +Z); +X / -X facing is up to the descriptor. + rotationY: () => (side === 'right' ? 0 : Math.PI), }, } } @@ -75,24 +103,26 @@ function roofSegmentDepthHandle(): HandleDescriptor { } } -// Wall-height arrow — `anchor: 'min'` keeps the base on the floor and -// grows the wall upward. Placed on the -X side at the wall's top edge -// so it doesn't stack on the centered pitch arrow when wallHeight ≈ 0 -// (flat roof / no walls). +// Wall-height tracker — dashed vertical leader from the floor up to a +// draggable cube at the wall top, centred on the footprint. Replaces +// the old -X-side chevron so the wall-top control reads as "the wall is +// THIS tall" instead of "there's an arrow on the side." Drag math is +// unchanged: same linear-resize axis='y' / anchor='min' pipeline as +// every other height handle; the `shape: 'tracker'` flag only swaps the +// visual. Wall-height clamps to MIN_WALL_DISPLAY for placement so the +// cube stays grabbable on flat / wall-less segments where the real +// `wallHeight` is ~0 and the leader would collapse to nothing. function roofSegmentWallHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', axis: 'y', anchor: 'min', + shape: 'tracker', min: MIN_WALL_HEIGHT, currentValue: (n) => n.wallHeight, apply: (_n, newValue) => ({ wallHeight: newValue }), placement: { - position: (n) => [ - -(n.width / 2 + SIDE_HANDLE_OFFSET), - Math.max(n.wallHeight, MIN_WALL_DISPLAY), - 0, - ], + position: (n) => [0, Math.max(n.wallHeight, MIN_WALL_DISPLAY), 0], }, } } @@ -169,7 +199,8 @@ function roofSegmentRotateHandle(): HandleDescriptor { } const roofSegmentHandles: HandleDescriptor[] = [ - roofSegmentWidthHandle(), + roofSegmentWidthHandle('right'), + roofSegmentWidthHandle('left'), roofSegmentDepthHandle(), roofSegmentWallHeightHandle(), roofSegmentPitchHandle(), diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index 719cdeae7..52557a653 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -1,5 +1,6 @@ import { type AnyNode, + type HandleDescriptor, type NodeDefinition, type RoofSegmentNode, SkylightNode as SkylightNodeSchema, @@ -14,6 +15,204 @@ import { skylightParametrics } from './parametrics' import { buildSkylightRoofCut } from './roof-cut' import { SkylightNode } from './schema' +// In-world handle constants. Offsets are measured from the skylight's +// edge to the arrow's CENTER, so they need to clear half the chevron's +// own length (~13 cm at default scale) plus the frame's outward depth +// before there's any visible gap. 0.35 m leaves ~15 cm of clear space +// between the chevron's tail and the skylight frame for a typical +// flat / opening skylight; rotate gizmo gets a matching cushion. +const SIDE_HANDLE_OFFSET = 0.35 +const ROTATE_CORNER_OFFSET = 0.35 +// Small +X bump so the rotate gizmo reads as sitting *beside* the corner +// instead of perched on its edge — visually separates it from the +// width chevron that shares the +X side. +const ROTATE_CORNER_X_OFFSET = 0.2 +// Lift the rotate gizmo a little off the surface so it floats above the +// frame instead of sinking into the curb / shingles around the corner. +const ROTATE_CORNER_Y_OFFSET = 0.18 +const ROTATE_RING_OFFSET = 0.06 +// Curb arrow sits a small distance above the current curb top so it stays +// grabbable when the curb collapses to zero (flat / walk-on look) and +// doesn't sink into the frame for tall lantern curbs. +const CURB_HANDLE_OFFSET = 0.18 +const MIN_SKYLIGHT_DIM = 0.2 +const MIN_FRAME_THICKNESS = 0.005 + +// Width arrows live on ±X (left / right edges of the skylight footprint). +// Asymmetric resize: each arrow only moves its own edge, the opposite +// edge stays put — same pattern as the door and the roof-segment width +// handles. `position` is in segment-surface-tangent coords, so after +// changing width we recenter `position` by half the delta along the +// skylight's own +X axis (rotated by `node.rotation` since the inner +// rotation-y group puts skylight-local X at an angle relative to +// segment-local X). +function skylightWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_SKYLIGHT_DIM, + currentValue: (n) => n.width, + apply: (initial, newWidth) => { + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initial.width / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ + const newCenterX = anchorX + sign * (newWidth / 2) * armX + const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ + return { + width: newWidth, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, + placement: { + position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), 0, 0], + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + // Grandparent so handles render inside the roof (visible) instead of + // the segment mesh, which lives inside the roof renderer's + // `` segments wrapper. See skylight/renderer + // for the matching composed-transform group that backs this. + portal: 'grandparent', + } +} + +// Height arrows on ±Z. Skylight `height` is the dimension along the +// roof's slope direction (X is across-slope). Same asymmetric pattern as +// width — anchored edge stays world-fixed, position recenters by half +// delta along the skylight's own +Z axis (rotated by `node.rotation`). +function skylightHeightHandle(side: 'top' | 'bottom'): HandleDescriptor { + const sign = side === 'top' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'z', + anchor: side === 'top' ? 'min' : 'max', + min: MIN_SKYLIGHT_DIM, + currentValue: (n) => n.height, + apply: (initial, newHeight) => { + const rotY = initial.rotation ?? 0 + // Skylight-local +Z projects onto segment-surface +X / +Z as + // (sin r, cos r) — orthogonal to the (cos r, -sin r) basis used + // for the +X axis above. + const armX = Math.sin(rotY) + const armZ = Math.cos(rotY) + const anchorX = initial.position[0] - sign * (initial.height / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.height / 2) * armZ + const newCenterX = anchorX + sign * (newHeight / 2) * armX + const newCenterZ = anchorZ + sign * (newHeight / 2) * armZ + return { + height: newHeight, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, + placement: { + position: (n) => [0, 0, sign * (n.height / 2 + SIDE_HANDLE_OFFSET)], + // The +Z chevron points along +Z by default (axis 'z' auto-rotates + // -π/2 in LinearArrow). For the -Z handle, add +π so it flips. + rotationY: () => (side === 'top' ? 0 : Math.PI), + }, + portal: 'grandparent', + } +} + +// Rotate gizmo at the +X+Z corner of the footprint, with a ring guide +// traced around the corner-diagonal radius — same idiom as the roof- +// segment / column / elevator rotate gizmos. Negate the cursor delta to +// match three.js Y-rotation handedness. +function skylightRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n) => { + const halfX = n.width / 2 + const halfZ = n.height / 2 + return [ + halfX + ROTATE_CORNER_X_OFFSET, + ROTATE_CORNER_Y_OFFSET, + halfZ + ROTATE_CORNER_OFFSET, + ] + }, + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: (n) => Math.hypot(n.width / 2, n.height / 2) + ROTATE_RING_OFFSET, + y: () => 0, + }, + portal: 'grandparent', + } +} + +// Curb-height arrow — vertical chevron above the skylight, dragged up +// to raise the curb. Auto-enables `curb: true` when the user grows from +// zero so the geometry actually has a curb to show; preserves `curb` +// flag once it's been set. Placement floats just above the curb top so +// it tracks the value as the user drags and stays above the frame for +// tall lantern curbs. +function skylightCurbHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: 0, + currentValue: (n) => n.curbHeight ?? 0, + apply: (initial, newValue) => ({ + curbHeight: newValue, + // Without this, dragging the arrow on a walk-on (curb: false) + // skylight would change `curbHeight` invisibly because the + // geometry path ignores it when `curb` is false. + curb: initial.curb || newValue > 0, + }), + placement: { + position: (n) => [0, (n.curbHeight ?? 0) + CURB_HANDLE_OFFSET, 0], + }, + portal: 'grandparent', + } +} + +// Frame-thickness arrow — diagonal chevron at the -X+Z (top-left) corner, +// pointing outward along the corner bisector. Lives on the opposite +// corner from the rotate gizmo so they don't compete. axis='z' (instead +// of 'x') so dragging outward toward +Z grows the value 1:1; LinearArrow +// auto-rotates a 'z'-axis chevron by -π/2 (so it points +Z), and the +// extra -π/4 here swings the chevron to -X+Z. The X+Z offsets match +// SIDE_HANDLE_OFFSET on each axis, so the arrow sits the same visual +// distance from the corner that the side chevrons sit from their edges. +function skylightFrameThicknessHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'min', + min: MIN_FRAME_THICKNESS, + currentValue: (n) => n.frameThickness ?? 0.05, + apply: (_n, newValue) => ({ frameThickness: newValue }), + placement: { + position: (n) => [ + -(n.width / 2) - SIDE_HANDLE_OFFSET, + 0, + n.height / 2 + SIDE_HANDLE_OFFSET, + ], + rotationY: () => -Math.PI / 4, + }, + portal: 'grandparent', + } +} + +const skylightHandles: HandleDescriptor[] = [ + skylightWidthHandle('right'), + skylightWidthHandle('left'), + skylightHeightHandle('top'), + skylightHeightHandle('bottom'), + skylightCurbHeightHandle(), + skylightFrameThicknessHandle(), + skylightRotateHandle(), +] + /** * Skylight — a framed glass opening hosted on a roof segment. All five * type variants (flat / walk-on / lantern / opening / sliding) render @@ -51,6 +250,7 @@ export const skylightDefinition: NodeDefinition = { }, parametrics: skylightParametrics, + handles: skylightHandles, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/skylight/move-tool.tsx b/packages/nodes/src/skylight/move-tool.tsx index d498a87b9..dc73757c0 100644 --- a/packages/nodes/src/skylight/move-tool.tsx +++ b/packages/nodes/src/skylight/move-tool.tsx @@ -14,6 +14,8 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' +import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' import SkylightPreview from './preview' function resolveSegmentFromWorldPoint( @@ -45,7 +47,15 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { const previewRef = useRef(null!) const [previewPos, setPreviewPos] = useState<[number, number, number]>([0, 0, 0]) - const [previewQuat, setPreviewQuat] = useState<[number, number, number, number]>([0, 0, 0, 1]) + // Mirror the placement tool's transform stack so the ghost reads the + // same in both flows: outer rotation-y aligns the segment's world yaw + // (roof + segment), inner quaternion tilts to the segment surface in + // segment-local space (analytical, not raycast — raycast normals can + // be flipped or in the wrong frame depending on hit-object state). + // The skylight's own `rotation` is applied on a deeper group so it + // stays editable on top of the surface alignment. + const [previewYaw, setPreviewYaw] = useState(0) + const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState(null) const [hasHit, setHasHit] = useState(false) useEffect(() => { @@ -85,20 +95,25 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { let lastSnapX = 0 let lastSnapZ = 0 - const captureNormal = (event: RoofEvent) => { - if (!event.normal) return - const n = new THREE.Vector3(event.normal[0], event.normal[1], event.normal[2]) - const nm = new THREE.Matrix3().getNormalMatrix(event.object.matrixWorld) - n.applyMatrix3(nm).normalize() - - const up = new THREE.Vector3(0, 1, 0) - const right = new THREE.Vector3().crossVectors(up, n) - if (right.lengthSq() < 1e-6) right.set(1, 0, 0) - else right.normalize() - const forward = new THREE.Vector3().crossVectors(right, n).normalize() - const m = new THREE.Matrix4().makeBasis(right, n, forward) - const q = new THREE.Quaternion().setFromRotationMatrix(m) - setPreviewQuat([q.x, q.y, q.z, q.w]) + // Resolve which segment the cursor is over, then derive the same + // preview transform stack the placement tool uses (`skylight/tool.tsx`): + // analytical surface normal in segment-local frame → outer yaw = + // roof + segment rotation. Falls back to leaving the preview hidden + // if the cursor is between segments — the placement tool does the + // same via its `if (!hit) return` guard. + const updateFromHit = (event: RoofEvent) => { + const roof = event.node as RoofNode + const hit = resolveRoofSegmentHit(roof, event.position[0], event.position[1], event.position[2]) + if (!hit) { + setHasHit(false) + return false + } + const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment) + setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) + setPreviewYaw((roof.rotation ?? 0) + (hit.segment.rotation ?? 0)) + setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2])) + setHasHit(true) + return true } const onRoofMove = (event: RoofEvent) => { @@ -109,16 +124,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { lastSnapX = sx lastSnapZ = sz } - captureNormal(event) - setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2])) - setHasHit(true) + updateFromHit(event) event.stopPropagation() } const onRoofEnter = (event: RoofEvent) => { - captureNormal(event) - setPreviewPos(worldToBuildingLocal(event.position[0], event.position[1], event.position[2])) - setHasHit(true) + updateFromHit(event) event.stopPropagation() } @@ -147,7 +158,6 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { }) useScene.temporal.getState().resume() - captureNormal(event) st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, @@ -241,10 +251,16 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { } }, [exitMoveMode, node]) + if (!previewSurfaceQuat) return null + return ( - - - + + + + + + + ) diff --git a/packages/nodes/src/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index 8ef51fc99..25b7f5683 100644 --- a/packages/nodes/src/skylight/renderer.tsx +++ b/packages/nodes/src/skylight/renderer.tsx @@ -26,6 +26,8 @@ import { surfaceQuatFromNormal } from '../solar-panel/geometry' import { buildFrameGeometry } from './frame-csg' import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry' +const yAxis = new THREE.Vector3(0, 1, 0) + const defaultFrameMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, roughness: 0.3, @@ -662,6 +664,19 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { [surfaceFrame.normal], ) + // Compose the surface tilt with the skylight's own yaw so the + // registered ref group below carries the complete "skylight pose in + // segment frame" as a single local position+quaternion. Registry + // handles (`portal: 'grandparent'`) read this Object3D's *local* + // pose, so splitting the tilt and the yaw across nested groups would + // leave the registered group with just the yaw and put the handles + // at the wrong spot. + const composedQuat = useMemo(() => { + const q = new THREE.Quaternion().copy(surfaceQuat) + q.multiply(new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0)) + return q + }, [surfaceQuat, node.rotation]) + const hasCurb = node.curb ?? false const curbH = hasCurb ? Math.max(0, node.curbHeight ?? 0.1) : 0 @@ -672,15 +687,26 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { return ( - - - - + { openAmount={openAmount} /> )} - {(activeType === 'flat' || activeType === 'walk-on') && ( - - )} - - + {(activeType === 'flat' || activeType === 'walk-on') && ( + + )} ) diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index 3525519ef..bb8ae1822 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -1,7 +1,218 @@ -import { type NodeDefinition, SolarPanelNode as SolarPanelNodeSchema } from '@pascal-app/core' +import { + type HandleDescriptor, + type NodeDefinition, + SolarPanelNode as SolarPanelNodeSchema, + type SolarPanelNode as SolarPanelNodeType, +} from '@pascal-app/core' import { solarPanelParametrics } from './parametrics' import { SolarPanelNode } from './schema' +// Handle constants — same scheme as the skylight: edge-to-arrow-center +// offsets that need to clear half the chevron's body (~13 cm at default +// scale) plus the frame thickness before the gap reads. 0.35 m matches +// the skylight's visual cadence; rotate gizmo gets a matching offset + +// a small +X bump so it sits beside (not on) the +X corner. +const SIDE_HANDLE_OFFSET = 0.35 +const ROTATE_CORNER_OFFSET = 0.35 +const ROTATE_CORNER_X_OFFSET = 0.2 +const ROTATE_CORNER_Y_OFFSET = 0.18 +const ROTATE_RING_OFFSET = 0.06 +const MIN_PANEL_DIM = 0.1 +const MIN_FRAME_THICKNESS = 0.005 +const MIN_FRAME_DEPTH = 0.005 +// Small gap above the current frame top so the chevron stays grabbable +// when frameDepth collapses near zero and floats above the frame for +// thicker frames. +const FRAME_DEPTH_HANDLE_OFFSET = 0.15 + +// Total array footprint (meters). `panelWidth` / `panelHeight` are +// per-cell dimensions; arrays multiply by columns/rows and add the +// inter-cell gaps. Handles operate on the TOTAL footprint so the +// dimension label and drag distance feel intuitive (drag the right edge +// by 1 m → array grows 1 m on that side, not 1 m per cell). +function totalArrayWidth(n: SolarPanelNodeType): number { + return n.columns * n.panelWidth + Math.max(0, n.columns - 1) * (n.gapX ?? 0) +} + +function totalArrayHeight(n: SolarPanelNodeType): number { + return n.rows * n.panelHeight + Math.max(0, n.rows - 1) * (n.gapY ?? 0) +} + +// Width arrows on ±X (left / right edges of the array). Asymmetric +// resize — same pattern as the skylight: anchored edge stays world-fixed, +// per-cell `panelWidth` is back-solved from the new total width, and +// `position` shifts by half the actual change along the panel's local +// +X axis (projected to segment-local via the panel's yaw). Clears +// `panelTypePreset` since the dimensions no longer match a saved preset. +function solarPanelWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_PANEL_DIM, + currentValue: (n) => totalArrayWidth(n), + apply: (initial, newTotalWidth) => { + const cols = initial.columns + const gapTotal = Math.max(0, cols - 1) * (initial.gapX ?? 0) + const newPanelWidth = Math.max(MIN_PANEL_DIM, (newTotalWidth - gapTotal) / cols) + const actualNewTotal = cols * newPanelWidth + gapTotal + const initialTotal = totalArrayWidth(initial) + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initialTotal / 2) * armX + const anchorZ = initial.position[2] - sign * (initialTotal / 2) * armZ + const newCenterX = anchorX + sign * (actualNewTotal / 2) * armX + const newCenterZ = anchorZ + sign * (actualNewTotal / 2) * armZ + return { + panelWidth: newPanelWidth, + position: [newCenterX, initial.position[1], newCenterZ], + panelTypePreset: undefined, + } + }, + placement: { + position: (n) => [sign * (totalArrayWidth(n) / 2 + SIDE_HANDLE_OFFSET), 0, 0], + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + portal: 'grandparent', + } +} + +// Height arrows on ±Z. Same shape as width but acts on `panelHeight` +// (back-solved from new total height) and projects onto +Z instead of +// +X. The skylight-axis convention applies: +Z is the dimension along +// the roof's slope direction. +function solarPanelHeightHandle(side: 'top' | 'bottom'): HandleDescriptor { + const sign = side === 'top' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'z', + anchor: side === 'top' ? 'min' : 'max', + min: MIN_PANEL_DIM, + currentValue: (n) => totalArrayHeight(n), + apply: (initial, newTotalHeight) => { + const rws = initial.rows + const gapTotal = Math.max(0, rws - 1) * (initial.gapY ?? 0) + const newPanelHeight = Math.max(MIN_PANEL_DIM, (newTotalHeight - gapTotal) / rws) + const actualNewTotal = rws * newPanelHeight + gapTotal + const initialTotal = totalArrayHeight(initial) + const rotY = initial.rotation ?? 0 + // Panel-local +Z projects to segment-local (sin r, cos r) — + // orthogonal to the panel-local +X basis used for width. + const armX = Math.sin(rotY) + const armZ = Math.cos(rotY) + const anchorX = initial.position[0] - sign * (initialTotal / 2) * armX + const anchorZ = initial.position[2] - sign * (initialTotal / 2) * armZ + const newCenterX = anchorX + sign * (actualNewTotal / 2) * armX + const newCenterZ = anchorZ + sign * (actualNewTotal / 2) * armZ + return { + panelHeight: newPanelHeight, + position: [newCenterX, initial.position[1], newCenterZ], + panelTypePreset: undefined, + } + }, + placement: { + position: (n) => [0, 0, sign * (totalArrayHeight(n) / 2 + SIDE_HANDLE_OFFSET)], + rotationY: () => (side === 'top' ? 0 : Math.PI), + }, + portal: 'grandparent', + } +} + +// Rotate gizmo at the +X+Z corner of the total array footprint, lifted +// slightly off the surface so it doesn't sink into the frame. Negate +// the cursor delta to match three.js Y-rotation handedness. +function solarPanelRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n) => { + const halfX = totalArrayWidth(n) / 2 + const halfZ = totalArrayHeight(n) / 2 + return [ + halfX + ROTATE_CORNER_X_OFFSET, + ROTATE_CORNER_Y_OFFSET, + halfZ + ROTATE_CORNER_OFFSET, + ] + }, + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: (n) => + Math.hypot(totalArrayWidth(n) / 2, totalArrayHeight(n) / 2) + ROTATE_RING_OFFSET, + y: () => 0, + }, + portal: 'grandparent', + } +} + +// Frame-depth arrow — vertical chevron above the array, centred on the +// panel surface. Drag up to grow `frameDepth` (how far the frame sticks +// out from the surface). axis='y' / anchor='min' so the bottom of the +// frame stays pinned at the surface (Y=0 in panel-local) and the top +// follows the cursor. Clears `panelTypePreset` since dimensions no +// longer match a saved preset. +function solarPanelFrameDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_FRAME_DEPTH, + currentValue: (n) => n.frameDepth ?? 0.04, + apply: (_n, newValue) => ({ + frameDepth: newValue, + panelTypePreset: undefined, + }), + placement: { + position: (n) => [0, (n.frameDepth ?? 0.04) + FRAME_DEPTH_HANDLE_OFFSET, 0], + }, + portal: 'grandparent', + } +} + +// Frame-thickness arrow — diagonal chevron at the -X+Z (top-left) corner, +// mirroring the skylight handle. axis='z' so dragging outward toward +Z +// grows the value; rotationY = -π/4 swings the auto-rotated +Z chevron +// to point along the -X+Z corner bisector. Clears `panelTypePreset` +// since the frame dimensions no longer match a saved preset. +function solarPanelFrameThicknessHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'min', + min: MIN_FRAME_THICKNESS, + currentValue: (n) => n.frameThickness ?? 0.04, + apply: (_n, newValue) => ({ + frameThickness: newValue, + panelTypePreset: undefined, + }), + placement: { + position: (n) => [ + -(totalArrayWidth(n) / 2) - SIDE_HANDLE_OFFSET, + 0, + totalArrayHeight(n) / 2 + SIDE_HANDLE_OFFSET, + ], + rotationY: () => -Math.PI / 4, + }, + portal: 'grandparent', + } +} + +const solarPanelHandles: HandleDescriptor[] = [ + solarPanelWidthHandle('right'), + solarPanelWidthHandle('left'), + solarPanelHeightHandle('top'), + solarPanelHeightHandle('bottom'), + solarPanelFrameDepthHandle(), + solarPanelRotateHandle(), + solarPanelFrameThicknessHandle(), +] + /** * Solar panel array — a grid of photovoltaic panels mounted on a roof * segment. Position is segment-local; the surface normal stored on @@ -40,6 +251,7 @@ export const solarPanelDefinition: NodeDefinition = { }, parametrics: solarPanelParametrics, + handles: solarPanelHandles, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/solar-panel/geometry.ts b/packages/nodes/src/solar-panel/geometry.ts index b11e7a7a8..66ce04623 100644 --- a/packages/nodes/src/solar-panel/geometry.ts +++ b/packages/nodes/src/solar-panel/geometry.ts @@ -322,10 +322,24 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode // renderer and the placement preview share one source of truth. export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaternion) { - const up = new THREE.Vector3(0, 1, 0) - const right = new THREE.Vector3().crossVectors(up, normal) - if (right.lengthSq() < 1e-6) right.set(1, 0, 0) - else right.normalize() + // Build `right` by projecting world +X onto the surface plane instead of + // using `up × normal`. The cross-product version flips sign when the + // normal's Z component flips (e.g. the two slopes of a gable roof), so + // the resulting basis has its +X axis reversed on one slope — which + // makes hosted children's local +X point in opposite world directions + // depending on which slope they sit on, and registry chevrons end up + // anchored to the wrong edge. Projecting +X keeps the basis stable + // across slope-flips that share the same X axis. + const wx = new THREE.Vector3(1, 0, 0) + const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal))) + if (right.lengthSq() < 1e-6) { + // Degenerate: normal is parallel to ±X. Fall back to +Z so the basis + // is still well-defined; this is the wall-like edge case (vertical + // surface facing along X) where any in-plane convention is OK. + right.set(0, 0, 1) + } else { + right.normalize() + } const forward = new THREE.Vector3().crossVectors(right, normal).normalize() const m = new THREE.Matrix4().makeBasis(right, normal, forward) return out.setFromRotationMatrix(m) diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index 088938690..28ea07e06 100644 --- a/packages/nodes/src/solar-panel/renderer.tsx +++ b/packages/nodes/src/solar-panel/renderer.tsx @@ -27,6 +27,14 @@ import { surfaceQuatFromNormal, } from './geometry' +// Module-scope scratch vectors and quaternions for composing the panel's +// local orientation each render — surfaceQuat · Y(rotation) · X(tilt). +// Reused so we don't allocate four objects per frame. +const yAxis = new THREE.Vector3(0, 1, 0) +const xAxis = new THREE.Vector3(1, 0, 0) +const panelYawQuat = new THREE.Quaternion() +const panelTiltQuat = new THREE.Quaternion() + // MeshStandardNodeMaterial: WebGPU-native so it integrates correctly with // the MRT pass (normal + roughness attachments). The legacy WebGL // MeshStandardMaterial triggers "Color target has no corresponding fragment @@ -124,32 +132,40 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { const tiltRad = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0 + // Compose surfaceQuat · Y(rotation) · X(tilt) into a single quaternion + // so the registered group below carries the panel's complete local pose + // (position + orientation) as its own *local* matrix. Registry handles + // (`portal: 'grandparent'`) read this Object3D's local position + + // quaternion to ride the panel; splitting the rotation across nested + // groups would leave the registered group with only the position and + // an identity quaternion, so the arrows would land on the segment-flat + // axes instead of on the tilted panel. + const composedQuat = new THREE.Quaternion() + .copy(surfaceQuat) + .multiply(panelYawQuat.setFromAxisAngle(yAxis, node.rotation ?? 0)) + .multiply(panelTiltQuat.setFromAxisAngle(xAxis, tiltRad)) + // Roof accessories are mounted under `` // in the roof renderer — that group has NO transform, so the segment // frame is NOT inherited from the React tree. Apply segment.position - // and segment.rotation here, then the panel's segment-local offset, - // then surface quat / yaw / tilt. + // and segment.rotation here, then the panel's segment-local offset + + // composed orientation on a single registered group. return ( - - - - - - - + ) From 820e80d85a49e82a8c6f048d7d0df6fd8a03a279 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 14:56:38 +0530 Subject: [PATCH 09/35] feat(nodes): split roof-segment depth chevron into asymmetric front/back arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the depth handle in line with the width handles: one chevron on each Z edge, each anchored to the opposite edge so dragging only moves its own side. `apply` recomputes `position` along the segment's local +Z arm (yaw-aware) so the anchored edge stays world-fixed. Depth also feeds the slope-frame math via `getActiveRoofHeight`, so a naïve depth change would also raise/lower the peak (constant pitch across a larger run). We hold the peak fixed by back-solving a new `pitch` for the new depth via `getPitchFromActiveRoofHeight`, clamped to the schema's pitch range — the segment grows along the deck plane without ramping up. --- packages/nodes/src/roof-segment/definition.ts | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 107a750c1..bb5a959b8 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -84,21 +84,66 @@ function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor { +// Depth arrow on the +Z (front) or -Z (back) side. Asymmetric: the +// dragged edge follows the pointer, the opposite edge stays world-fixed +// — mirrors the width-handle pattern (`roofSegmentWidthHandle`). Because +// segment depth feeds the pitch math via `getActiveRoofHeight`, growing +// depth at constant pitch ramps the peak up too, which reads as +// scaling. We hold the peak height constant by back-solving a new pitch +// for the new depth (same recipe the pitch handle uses, run in +// reverse). MIN/MAX_PITCH clamps cover degenerate cases where the new +// depth would demand a negative or beyond-vertical pitch. +function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor { + const sign = side === 'front' ? 1 : -1 return { kind: 'linear-resize', axis: 'z', - anchor: 'center', + anchor: side === 'front' ? 'min' : 'max', min: MIN_ROOF_DIM, currentValue: (n) => n.depth, - apply: (_n, newValue) => ({ depth: newValue }), + apply: (initial, newDepth) => { + // Recenter so the anchored Z edge stays at the same world point. + // Same math as the width handle but along the Z arm: yaw maps + // segment-local +Z to (sin r, cos r) in world. + const rotY = initial.rotation ?? 0 + const armX = Math.sin(rotY) + const armZ = Math.cos(rotY) + const anchorX = initial.position[0] - sign * (initial.depth / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.depth / 2) * armZ + const newCenterX = anchorX + sign * (newDepth / 2) * armX + const newCenterZ = anchorZ + sign * (newDepth / 2) * armZ + + // Preserve peak height — back-solve pitch for the new depth so + // the assembled roof height matches what it was before the drag. + const originalRoofHeight = getActiveRoofHeight(initial) + const newPitch = getPitchFromActiveRoofHeight({ + roofType: initial.roofType, + width: initial.width, + depth: newDepth, + roofHeight: originalRoofHeight, + gambrelLowerWidthRatio: initial.gambrelLowerWidthRatio, + gambrelLowerHeightRatio: initial.gambrelLowerHeightRatio, + mansardSteepWidthRatio: initial.mansardSteepWidthRatio, + mansardSteepHeightRatio: initial.mansardSteepHeightRatio, + dutchHipWidthRatio: initial.dutchHipWidthRatio, + dutchHipHeightRatio: initial.dutchHipHeightRatio, + }) + + return { + depth: newDepth, + position: [newCenterX, initial.position[1], newCenterZ], + pitch: Math.max(MIN_PITCH, Math.min(MAX_PITCH, newPitch)), + } + }, placement: { position: (n) => [ 0, Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2, - n.depth / 2 + SIDE_HANDLE_OFFSET, + sign * (n.depth / 2 + SIDE_HANDLE_OFFSET), ], + // For axis 'z', `LinearArrow` adds -π/2 around Y so the chevron + // points +Z by default. Flip the back arrow by π so it points -Z. + rotationY: () => (side === 'front' ? 0 : Math.PI), }, } } @@ -201,7 +246,8 @@ function roofSegmentRotateHandle(): HandleDescriptor { const roofSegmentHandles: HandleDescriptor[] = [ roofSegmentWidthHandle('right'), roofSegmentWidthHandle('left'), - roofSegmentDepthHandle(), + roofSegmentDepthHandle('front'), + roofSegmentDepthHandle('back'), roofSegmentWallHeightHandle(), roofSegmentPitchHandle(), roofSegmentRotateHandle(), From d7072b1d88daeee94747f27ddf413ee0a54c20d4 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 16:39:04 +0530 Subject: [PATCH 10/35] feat(nodes): in-world handles for dormer + window-bottom clipping check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dormer joins chimney / roof-segment with chevron handles on the selected node. Five body handles (width L/R, depth, wall-height tracker, rotate) plus four window-opening handles (width L/R, height top/bottom) — the window handles re-emit windowOffsetX / windowOffsetY in `apply` so the anchored edge stays put as the dragged edge follows the pointer. Handle visibility on roof accessories needed an editor-side assist: the host segment's mesh registers inside RoofRenderer's ``, which hides anything portaled into it. Chimney's `portal: 'grandparent'` escape trips a WebGPU "Color target has no corresponding fragment stage output" pipeline error on dormer (likely an MRT interaction with the window-assembly's transparent glazing), so RoofEditSystem now flips the wrapper visible whenever ANY accessory hosted on a segment of this roof is selected — and resets each segment mesh to an empty 4-group placeholder on the transition so stale per-segment CSG from a prior edit doesn't double-render on top of the merged shell. Window clipping was using wall-top-above-slope as the exposure threshold, but the window sits in the skirt well below the eave — so a dormer whose eave barely cleared the host roof rendered a fully-buried window. `getDormerExposedFaces` now gates on window-bottom-above-slope; both the CSG cut decision and the window-assembly render path feed off the same number. The in-world window chevrons resolve the host segment via sceneApi and flip to whichever face is currently exposed, so dragging the dormer across the ridge moves the chevrons to the visible gable. Also fixes the BoxGeometry vs ExtrudeGeometry mismatch in `buildDormerFallbackGeometry` — body was indexed, roof was not, so `mergeGeometries` rejected the pair and spammed the console on every height-drag frame. Body is now `.toNonIndexed()` before the merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../systems/roof/roof-edit-system.tsx | 94 ++++- packages/nodes/src/dormer/csg-geometry.ts | 63 ++- packages/nodes/src/dormer/definition.ts | 367 +++++++++++++++++- packages/nodes/src/dormer/renderer.tsx | 41 +- packages/nodes/src/dormer/window-assembly.tsx | 13 + 5 files changed, 529 insertions(+), 49 deletions(-) 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 e0055dd09..8708b7e4f 100644 --- a/packages/editor/src/components/systems/roof/roof-edit-system.tsx +++ b/packages/editor/src/components/systems/roof/roof-edit-system.tsx @@ -1,41 +1,92 @@ import { type AnyNodeId, type RoofNode, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' +import * as THREE from 'three' + +// Empty placeholder geometry used when we reveal segments-wrapper for +// accessory editing. The roof's CSG-merged shell is the only thing +// that should render the roof surface in this mode — the per-segment +// CSG geometry (if any was left over from a prior edit) would visually +// double the cut shape, so we strip each segment mesh back to nothing. +// `RoofSystem` rebuilds CSG on demand if the user later selects a +// segment, so destroying the cached geometry here only costs one +// recomputation per segment when the user actually wants it back. +function makeEmptySegmentGeometry(): THREE.BufferGeometry { + const g = new THREE.BufferGeometry() + g.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + // Match the four material slots the roof-segment renderer's material + // array expects (0=top, 1=side, 2=interior, 3=shingle). Without these + // groups, mesh.material is a single-material lookup that mismatches + // the array — same crash mode the BoxGeometry workaround in + // `roof-system.tsx:144` guards against. + g.addGroup(0, 0, 0) + g.addGroup(0, 0, 1) + g.addGroup(0, 0, 2) + g.addGroup(0, 0, 3) + return g +} /** * Imperatively toggles the Three.js visibility of roof objects based on the * editor selection — without causing React re-renders in RoofRenderer. * - * When a roof (or one of its segments) is selected: + * Full edit-mode (segment selected): * - merged-roof mesh is hidden * - segments-wrapper group is shown (individual segments visible for editing) * - all children are marked dirty so RoofSystem rebuilds their geometry * - * When deselected: - * - merged-roof mesh is shown - * - segments-wrapper group is hidden + * Accessory-reveal mode (a dormer/chimney/etc. hosted on a segment is selected): + * - merged-roof mesh stays visible (we don't want the appearance to jump) + * - segments-wrapper group is shown ANYWAY so anything portaled into a + * segment's registered mesh (e.g. dormer in-world handle arrows that + * don't use `portal: 'grandparent'`) is no longer inheriting the + * wrapper's hidden flag + * - segment placeholder geometry is empty, so revealing the wrapper has + * no visible cost beyond letting the handle arrows render + * + * When deselected: merged-roof shown, segments-wrapper hidden. */ export const RoofEditSystem = () => { const selectedIds = useViewer((s) => s.selection.selectedIds) const prevActiveRoofIds = useRef(new Set()) + const prevRevealRoofIds = useRef(new Set()) useEffect(() => { const nodes = useScene.getState().nodes - // Collect which roof nodes should be in "edit mode". - // Selecting the roof itself should keep the merged visual intact so - // material appearance does not jump between merged and per-segment meshes. + // Roofs where a segment itself is selected -> full edit mode (hide + // merged, show wrapper). 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() + for (const id of selectedIds) { const node = nodes[id as AnyNodeId] if (!node) continue if (node.type === 'roof-segment' && node.parentId) { activeRoofIds.add(node.parentId) + continue + } + // Walk up one level: if the parent is a roof-segment, this is a + // hosted accessory and we want to reveal its grandparent roof's + // wrapper. Two-step lookup keeps it scoped to roof children + // without enumerating all accessory kinds. + if (!node.parentId) continue + const parent = nodes[node.parentId as AnyNodeId] + if (parent?.type === 'roof-segment' && parent.parentId) { + revealRoofIds.add(parent.parentId) } } - // Update all roofs that are currently active OR were previously active - const roofIdsToUpdate = new Set([...activeRoofIds, ...prevActiveRoofIds.current]) + // Union of roofs that need ANY state change this tick. + const roofIdsToUpdate = new Set([ + ...activeRoofIds, + ...revealRoofIds, + ...prevActiveRoofIds.current, + ...prevRevealRoofIds.current, + ]) for (const roofId of roofIdsToUpdate) { const group = sceneRegistry.nodes.get(roofId) @@ -44,25 +95,44 @@ export const RoofEditSystem = () => { const mergedMesh = group.getObjectByName('merged-roof') const segmentsWrapper = group.getObjectByName('segments-wrapper') const isActive = activeRoofIds.has(roofId) + const isReveal = revealRoofIds.has(roofId) if (mergedMesh) mergedMesh.visible = !isActive - if (segmentsWrapper) segmentsWrapper.visible = isActive + if (segmentsWrapper) segmentsWrapper.visible = isActive || isReveal const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined if (roofNode?.children?.length) { const wasActive = prevActiveRoofIds.current.has(roofId) + const wasReveal = prevRevealRoofIds.current.has(roofId) if (isActive !== wasActive) { - // Entering edit mode: rebuild individual segment geometries - // Exiting edit mode: sync transforms + rebuild merged mesh + // Entering / exiting full edit mode: rebuild segment / merged + // geometries. Accessory-reveal doesn't need this — segments + // keep their placeholder; only their visibility flips. const { markDirty } = useScene.getState() for (const childId of roofNode.children) { markDirty(childId as AnyNodeId) } } + // Entering reveal mode (and NOT also full-edit, which already + // owns its own rebuild path): strip each segment mesh back to + // an empty placeholder so the wrapper-now-visible doesn't + // re-show stale CSG geometry from a previous segment edit. + // Without this, the host segment's CSG cut renders ON TOP of + // the merged-roof, doubling the dormer's cut shape and + // bleeding the host wall material through the dormer body. + if (isReveal && !isActive && !wasReveal && segmentsWrapper) { + for (const child of segmentsWrapper.children) { + const mesh = child as THREE.Mesh + if (!mesh.isMesh) continue + mesh.geometry?.dispose() + mesh.geometry = makeEmptySegmentGeometry() + } + } } } prevActiveRoofIds.current = activeRoofIds + prevRevealRoofIds.current = revealRoofIds }, [selectedIds]) return null diff --git a/packages/nodes/src/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index 4141e5af3..27ebc1621 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -57,11 +57,16 @@ export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeo const isFlat = dormer.roofType === 'flat' || roofH === 0 // Body box: foot at y = -skirt, top at y = wallH. - const body = new THREE.BoxGeometry(w, wallH + skirt, d) - body.translate(0, (wallH - skirt) / 2, 0) - const bIdx = body.getIndex()?.count ?? 0 + // 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, bIdx, 0) + body.addGroup(0, bVtx, 0) if (isFlat) { if (!body.getAttribute('normal')) body.computeVertexNormals() @@ -78,9 +83,9 @@ export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeo const roof = new THREE.ExtrudeGeometry(roofShape, { depth: d, bevelEnabled: false }) roof.translate(0, wallH, -d / 2) - const rIdx = roof.getIndex()?.count ?? 0 + const rVtx = roof.getAttribute('position').count roof.clearGroups() - roof.addGroup(0, rIdx, 3) + roof.addGroup(0, rVtx, 3) const merged = mergeGeometries([body, roof], true) ?? body body.dispose() @@ -187,11 +192,20 @@ function createDormerWindowCutGeometry( } /** - * Which faces of a dormer are exposed (not fully buried in the host - * roof). "front" = mesh-local +Z, "back" = mesh-local −Z (after the - * +π/2 yaw bake for non-shed roofs). A face is exposed when the - * dormer's total wall top exceeds the host roof surface at that face's - * Z position. + * Which gable faces of a dormer have a *fully visible window opening* + * (not clipped by the host roof slope). "front" = mesh-local +Z, + * "back" = mesh-local −Z (after the +π/2 yaw bake for non-shed roofs). + * + * The criterion is window-bottom-above-slope, not wall-top-above-slope: + * the dormer wall extends well below the window into the skirt that's + * buried inside the roof, so checking just "does any wall poke above + * the slope" is far too lenient — a dormer whose eave barely clears + * the roof would pass even though the entire window (which sits inside + * the skirt, well below the eave) is buried. Switching to the window + * bottom collapses both the CSG window-cut decision (which calls into + * this function in `generateDormerGeometry`) and the live render gate + * (window-assembly.tsx) onto the right line: the window only renders + * where it's actually visible from outside. */ export function getDormerExposedFaces( dormer: DormerNode, @@ -206,7 +220,18 @@ export function getDormerExposedFaces( const frontZ = dormerZ + halfDepth * Math.cos(rot) const backZ = dormerZ - halfDepth * Math.cos(rot) - const dormerWallTop = dormerY + dormer.height + // Window bottom in dormer-local Y. Mirrors `getDormerSkirtWindowDims` + // so both functions read the same window position. The window sits + // in the skirt below the eave (dormer-local Y=0), so `centerY` is + // typically negative; subtracting half the window height lands us at + // the bottom edge. + const skirtH = dormerSkirtHeight(dormer) + const winH = Math.max(0, dormer.windowHeight ?? 0) + const winOffsetY = dormer.windowOffsetY ?? 0 + const windowCenterDormerY = -(skirtH / 2) + winOffsetY + const windowBottomDormerY = windowCenterDormerY - winH / 2 + // Lift into segment-local Y: dormer-local Y=0 sits at `dormer.position[1]`. + const windowBottomSegY = dormerY + windowBottomDormerY const hostWh = hostSegment.wallHeight ?? 0.5 const hostRh = getActiveRoofHeight(hostSegment) @@ -224,15 +249,15 @@ export function getDormerExposedFaces( return hostWh + hostRh * (1 - t) } - // A face is "exposed" only if the dormer's wall actually pokes above - // the host roof there by a meaningful amount — otherwise the wall is - // CSG-buried and any window we render at that face will hover with - // no wall behind it. A 5cm threshold suppresses the borderline-cases - // where the wall top is essentially level with the slope. + // A face is "exposed" only if the *window bottom* clears the host + // slope at that face's Z by a meaningful amount — borderline cases + // (slope grazing the window bottom) suppress the window so we don't + // render a partially-clipped frame poking out of the roof. 5cm + // matches the threshold the prior wall-top check used. const minPokeOut = 0.05 return { - front: dormerWallTop - roofHeightAtZ(frontZ) > minPokeOut, - back: dormerWallTop - roofHeightAtZ(backZ) > minPokeOut, + front: windowBottomSegY - roofHeightAtZ(frontZ) > minPokeOut, + back: windowBottomSegY - roofHeightAtZ(backZ) > minPokeOut, } } diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 30ba96cd8..73f7d850b 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -1,14 +1,378 @@ import { type AnyNode, + type AnyNodeId, DormerNode as DormerNodeSchema, type DormerNode as DormerNodeType, + type HandleDescriptor, type NodeDefinition, + type RoofSegmentNode as RoofSegmentNodeType, + type SceneApi, } from '@pascal-app/core' -import { buildDormerRoofCut } from './csg-geometry' +import { buildDormerRoofCut, getDormerExposedFaces } from './csg-geometry' import { dormerPaint } from './paint' import { dormerParametrics } from './parametrics' import { DormerNode } from './schema' +const SIDE_HANDLE_OFFSET = 0.25 +const HEIGHT_HANDLE_OFFSET = 0.25 +const ROTATE_CORNER_OFFSET = 0.35 +const ROTATE_RING_OFFSET = 0.08 +// Schema/parametrics ranges — keep these aligned with `dormerParametrics` +// so the in-world drag and the inspector slider clamp identically. +const MIN_DIM = 0.5 +const MIN_HEIGHT = 0 +const MIN_ROOF_HEIGHT = 0 +const MAX_ROOF_HEIGHT = 2 +const MIN_SKIRT = 0.2 +const MAX_SKIRT = 6 +// Window-handle constants. The window opening is parametric geometry +// on the dormer's +Z gable face; chevrons sit just outside its rim +// with a small forward Z offset so they pop in front of the wall plane +// instead of z-fighting with the frame bars. +const WINDOW_SIDE_HANDLE_OFFSET = 0.15 +const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15 +const WINDOW_FACE_Z_OFFSET = 0.05 +// Lower clamp for window dims matches the geometry's internal clamp +// in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the +// dormer dimensions and are resolved per-handle via the function form +// of `max`. +const MIN_WINDOW_DIM = 0.1 +// Clamp used for handle Y placement so side chevrons stay reachable on +// dormers whose wall is flat (`height ≈ 0`). The dormer body is +// `height + roofHeight` tall; if that collapses too, the side arrows +// would bury into the deck — this floor keeps them visible. +const MIN_BODY_DISPLAY = 0.3 + +// Mid-Y of the dormer body in dormer-local frame. Y=0 is the eave +// (where wall meets skirt); body extends up to `height + roofHeight`. +// Side chevrons sit at the body midpoint so they read as "this is the +// dormer's footprint" rather than floating at the apex or the eave. +function getBodyMidY(n: DormerNodeType): number { + return Math.max(n.height + n.roofHeight, MIN_BODY_DISPLAY) / 2 +} + +// Width arrow on the +X (right) or -X (left) side. Asymmetric resize: +// dragging one arrow grows the dormer outward from its own edge while +// the opposite edge stays world-fixed in segment frame. The dormer's +// registered ref frame is dormer-local (renderer applies position + +// rotation on the registered group), so placements are in dormer-local +// coords — no per-arrow rotation/translation compensation here. +// +// `apply` recomputes `position` so the anchored edge stays at the same +// segment-local point even when the dormer is Y-rotated: project the +// dormer's local +X onto segment frame via (cos r, -sin r), find the +// anchored edge's segment-local XZ from the pre-drag node, then place +// the new center half a new-width away from that anchor in the same +// direction. Mirrors chimney + roof-segment width handle math. +function dormerWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + // 'min' = -X edge anchored (right arrow grows the +X edge outward). + // 'max' = +X edge anchored (left arrow grows the -X edge outward). + anchor: side === 'right' ? 'min' : 'max', + // Default 'parent' portal (no `'grandparent'` escape). Arrows + // portal into the host roof segment's registered mesh, which lives + // inside the roof renderer's ``. + // That wrapper is `visible={false}` by default; `RoofEditSystem` + // imperatively flips it to `visible={true}` whenever any accessory + // hosted on a segment of this roof is selected, so the portaled + // arrows become visible during selection without us reaching for + // `portal: 'grandparent'` — which trips the same "Color target has + // no corresponding fragment stage output" WebGPU pipeline error + // chimney already documents (likely an MRT interaction with the + // window-assembly's transparent glazing meshes). + min: MIN_DIM, + currentValue: (n) => n.width, + apply: (initial, newWidth) => { + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initial.width / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ + const newCenterX = anchorX + sign * (newWidth / 2) * armX + const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ + return { + width: newWidth, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, + placement: { + position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), 0], + // Flip the left chevron so it points outward toward -X. The + // generic LinearArrow only auto-orients for axis 'z'; +X / -X + // facing is up to the descriptor. + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Depth arrow on the +Z side. Symmetric (anchor 'center') to match +// chimney's known-working handle count — splitting depth into asymmetric +// front + back chevrons puts the dormer over the per-node MRT/TSL +// budget that chimney already documents (see `chimneyHandles` factory). +// Re-evaluate the split once that pipeline issue is pinned down. +function dormerDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_DIM, + currentValue: (n) => n.depth, + apply: (_n, newValue) => ({ depth: newValue }), + placement: { + position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET], + }, + } +} + +// Wall-height tracker — dashed vertical leader from the eave (y=0) up +// to a draggable cube at the wall top (y=height), centred on the +// footprint. Reads as "the dormer wall is THIS tall" without claiming +// the roof apex. Same `linear-resize axis='y'` pipeline as every other +// height handle; `shape: 'tracker'` only swaps the visual. +function dormerWallHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + shape: 'tracker', + min: MIN_HEIGHT, + currentValue: (n) => n.height, + apply: (_n, newValue) => ({ height: newValue }), + placement: { + position: (n) => [0, Math.max(n.height, 0.001), 0], + }, + trackerBaseY: () => 0, + } +} + +// Wall-skirt chevron — sits BELOW the eave, at the bottom of the +// hung-wall skirt that extends down into the host roof. Drag pulls the +// skirt's bottom edge further down (or up) to grow / shrink +// `wallSkirtHeight`. Anchor 'max' keeps the eave (y=0) fixed; the +// linear-resize factor (-1 for anchor 'max') flips the drag sign so +// dragging the chevron downward increases the value 1:1. +// +// Plain arrow (not tracker) because tracker only renders an upward +// leader; the dashed line would point the wrong way for a downward +// span. The auto-orient logic in `ArrowHandle` flips the chevron to +// point -Y when placement.y < 0, so the arrow visibly points down. +function dormerWallSkirtHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'max', + min: MIN_SKIRT, + max: MAX_SKIRT, + currentValue: (n) => n.wallSkirtHeight, + apply: (_n, newValue) => ({ wallSkirtHeight: newValue }), + placement: { + position: (n) => [0, -(n.wallSkirtHeight + HEIGHT_HANDLE_OFFSET), 0], + }, + } +} + +// Roof-height chevron at the dormer's peak. Drag adjusts `roofHeight` +// directly — unlike roof-segment there's no pitch back-solve because +// dormer stores roof height as a literal scalar, not a pitch angle. +// Placed slightly above the apex (height + roofHeight) so the chevron +// visually attaches to the ridge. +function dormerRoofHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_ROOF_HEIGHT, + max: MAX_ROOF_HEIGHT, + currentValue: (n) => n.roofHeight, + apply: (_n, newValue) => ({ roofHeight: newValue }), + placement: { + position: (n) => [0, n.height + n.roofHeight + HEIGHT_HANDLE_OFFSET, 0], + }, + } +} + +// Whole-dormer rotation gizmo at the +X / +Z corner of the footprint, +// guide ring traces the corner-diagonal radius on hover / drag. +// Dormer-local frame is the registered group (renderer applies +// node.position + node.rotation there), so the default rotation pivot +// (rideObject origin = dormer center) is correct — no +// `rotationCenter` override needed. +function dormerRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + // Negate the cursor delta to match three.js Y-rotation handedness + // (cursor atan2 ticks opposite-handed from `rotation-y`). + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n) => { + const halfX = n.width / 2 + ROTATE_CORNER_OFFSET + const halfZ = n.depth / 2 + ROTATE_CORNER_OFFSET + return [halfX, getBodyMidY(n), halfZ] + }, + // The two-headed icon's natural bias points along +X; aim it at + // the corner (45° outward from the dormer's local frame). + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: (n) => Math.hypot(n.width / 2, n.depth / 2) + ROTATE_RING_OFFSET, + y: (n) => getBodyMidY(n), + }, + } +} + +// Window-center Y in dormer-local frame. The schema stores +// `windowOffsetY` as the bottom-relative offset of the window center +// from the bottom of the skirt; the geometry then maps it to +// `centerY = -(skirtH / 2) + offsetY`. We mirror that here so handle +// placements line up with what the inspector + window-assembly use. +function getWindowCenterY(n: DormerNodeType): number { + return -(n.wallSkirtHeight / 2) + n.windowOffsetY +} + +// Sign of the dormer-local Z direction where the visible window face +// sits. The dormer renders the window on both +Z (front) and -Z (back) +// gable faces, but only whichever face actually pokes above the host +// roof slope is exposed — `getDormerExposedFaces` is the source of +// truth there. The in-world handles need to attach to that exposed +// face so the user is editing the window they can see; as the dormer +// drags across the ridge, the exposed face flips and the chevrons +// follow. +// +// Preference order when both faces are exposed (e.g. a tall gable that +// pokes above the roof on both ends): keep handles on +Z so the +// affordance stays put visually instead of flipping when the slope +// math grazes the threshold from the other side. When neither face is +// exposed (degenerate — wall buried on both sides), fall back to +Z so +// the placement still produces a valid vector; the chevrons are just +// not useful there. +function getExposedFaceZSign(n: DormerNodeType, sceneApi: SceneApi): 1 | -1 { + if (!n.roofSegmentId) return 1 + const segment = sceneApi.get(n.roofSegmentId as AnyNodeId) + if (!segment) return 1 + const exposed = getDormerExposedFaces(n, segment) + if (exposed.front) return 1 + if (exposed.back) return -1 + return 1 +} + +// Window-width chevron on the +X (right) or -X (left) edge of the +// opening. Asymmetric: dragging one arrow grows the window outward +// from its own edge while the opposite edge stays put. The framework +// only knows about the scalar `windowWidth`; we re-emit `windowOffsetX` +// in `apply` so the anchored edge stays at the same X in dormer-local. +// Placement sits on the dormer's +Z gable face, where the window opens. +function dormerWindowWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_WINDOW_DIM, + // Cap at the dormer's window field — keep a 0.1m gap on each side + // to match the geometry's interior clamp (`maxW = width - 0.1`). + max: (n) => Math.max(MIN_WINDOW_DIM, n.width - 0.1), + currentValue: (n) => n.windowWidth, + apply: (initial, newWidth) => { + // Anchored edge stays fixed: anchor X = initial.windowOffsetX - + // sign * initial.windowWidth/2. New center = anchor + sign * + // newWidth/2 → new windowOffsetX. + const anchorX = initial.windowOffsetX - sign * (initial.windowWidth / 2) + const newOffsetX = anchorX + sign * (newWidth / 2) + return { + windowWidth: newWidth, + windowOffsetX: newOffsetX, + } + }, + placement: { + position: (n, sceneApi) => { + const faceSign = getExposedFaceZSign(n, sceneApi) + return [ + n.windowOffsetX + sign * (n.windowWidth / 2 + WINDOW_SIDE_HANDLE_OFFSET), + getWindowCenterY(n), + faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET), + ] + }, + // Left chevron points -X; right points +X. LinearArrow doesn't + // auto-orient axis 'x' — descriptor handles the flip. + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Window-height chevron on the +Y (top) or -Y (bottom) edge of the +// opening. Same asymmetric pattern as the width handle, projected onto +// the Y axis. The schema stores the window's vertical position as +// `windowOffsetY` (distance from the BOTTOM of the skirt to the window +// CENTER), not as a centerY in dormer-local — so `apply` translates +// back through that mapping when it re-emits the offset. +function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor { + const sign = side === 'top' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'y', + // 'min' = bottom edge anchored (top arrow grows the top edge up). + // 'max' = top edge anchored (bottom arrow drops the bottom edge). + anchor: side === 'top' ? 'min' : 'max', + min: MIN_WINDOW_DIM, + // Cap at the skirt with a 0.1m interior margin — matches + // `maxH = skirtH - 0.1` from `getDormerSkirtWindowDims`. + max: (n) => Math.max(MIN_WINDOW_DIM, n.wallSkirtHeight - 0.1), + currentValue: (n) => n.windowHeight, + apply: (initial, newHeight) => { + // Compute the anchored edge in dormer-local Y, derive the new + // centerY, then map back to schema-form `windowOffsetY`. + const initialCenterY = -(initial.wallSkirtHeight / 2) + initial.windowOffsetY + const anchorY = initialCenterY - sign * (initial.windowHeight / 2) + const newCenterY = anchorY + sign * (newHeight / 2) + const newOffsetY = newCenterY + initial.wallSkirtHeight / 2 + return { + windowHeight: newHeight, + windowOffsetY: newOffsetY, + } + }, + placement: { + position: (n, sceneApi) => { + const faceSign = getExposedFaceZSign(n, sceneApi) + return [ + n.windowOffsetX, + getWindowCenterY(n) + sign * (n.windowHeight / 2 + WINDOW_HEIGHT_HANDLE_OFFSET), + faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET), + ] + }, + }, + } +} + +const dormerHandles: HandleDescriptor[] = [ + dormerWidthHandle('right'), + dormerWidthHandle('left'), + dormerDepthHandle(), + dormerWallHeightHandle(), + dormerRotateHandle(), + dormerWindowWidthHandle('right'), + dormerWindowWidthHandle('left'), + dormerWindowHeightHandle('top'), + dormerWindowHeightHandle('bottom'), + // The wall-skirt (downward chevron), roof-height (peak chevron), and + // the asymmetric front/back depth split stay out for now. Re-adding + // any of them previously fired the "Color target has no + // corresponding fragment stage output" WebGPU pipeline error chimney + // already documented for its flue / cap-thickness / cap-overhang + // extras — only reproducible while `portal: 'grandparent'` was set, + // which we no longer rely on (RoofEditSystem reveals the wrapper + // instead). The shapes themselves are valid; if the count budget + // turns out to also be sensitive without grandparent portal, drop + // the window handles first since the inspector covers them too. + // dormerWallSkirtHandle(), + // dormerRoofHeightHandle(), +] + /** * Dormer — a small house-shaped protrusion sitting on top of a roof * segment. The window opening is inlined into the dormer's schema @@ -69,6 +433,7 @@ export const dormerDefinition: NodeDefinition = { }, parametrics: dormerParametrics, + handles: dormerHandles, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/dormer/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index 5c7fe580b..01f8ab0f4 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -152,29 +152,36 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { // dormer-mesh-local with `dormer.position` + `dormer.rotation` // already accounted for by `segToMesh`, so we layer them as group // transforms here too. + // + // The registered ref sits on the inner group that applies the + // dormer's own position + rotation so the registered Object3D's + // local frame is *dormer-local* — that's what `NodeArrowHandles` + // reads to place its chevrons. Mirrors chimney's structure. return ( - - - - - + + + ) diff --git a/packages/nodes/src/dormer/window-assembly.tsx b/packages/nodes/src/dormer/window-assembly.tsx index f35825ded..c20f062b8 100644 --- a/packages/nodes/src/dormer/window-assembly.tsx +++ b/packages/nodes/src/dormer/window-assembly.tsx @@ -113,6 +113,19 @@ const DormerWindowAssembly = ({ node.position[0], node.position[1], node.position[2], + // Rotation flips which dormer-local face projects to which Z in + // segment frame, so dragging the dormer across the ridge with a + // non-zero yaw needs to recompute exposure to know which gable + // is now poking above the slope. + node.rotation, + // Window position + height feed `getDormerExposedFaces` now that + // it's gating on window-bottom-above-slope (not wall-top-above- + // slope) — dragging the window down via inspector or the new + // window-height/offset handles must re-evaluate which gable + // still has a fully-visible opening. + node.windowHeight, + node.windowOffsetY, + node.wallSkirtHeight, ], ) From 9400f1c5c5a585d2f7b2c9538c5f159c25fafa68 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 22:53:27 +0530 Subject: [PATCH 11/35] fix(viewer): glazing role uses FrontSide to avoid MRT back-face pipeline error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DoubleSide on a NodeMaterial inside the MRT scene pass makes WebGPU compile a back-face shader variant that doesn't declare outputs for every MRT target — the validator rejects it and poisons the render context with "Color target has no corresponding fragment stage output". The warning was already documented on `glassMaterial` (materials.ts:77), but `createSurfaceRoleMaterial` was still forcing DoubleSide for the glazing role. Manifested on scene open as soon as a dormer was present: the dormer's window-assembly mounts the glazing material on both gable faces on the first frame, so the back-face pipeline gets compiled immediately. Glazing now resolves to FrontSide; the dormer's back gable group flips 180° so its FrontSide normals point outward (the sill no longer needs its per-face Z mirror since the group rotation handles it). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/nodes/src/dormer/window-assembly.tsx | 20 ++++++++++++++----- packages/viewer/src/lib/materials.ts | 10 +++++++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/nodes/src/dormer/window-assembly.tsx b/packages/nodes/src/dormer/window-assembly.tsx index c20f062b8..5237b4eb7 100644 --- a/packages/nodes/src/dormer/window-assembly.tsx +++ b/packages/nodes/src/dormer/window-assembly.tsx @@ -133,8 +133,18 @@ const DormerWindowAssembly = ({ const winX = skirtWin.offsetX const winY = skirtWin.centerY - const renderFace = (zPos: number, outDir: number, keyPrefix: string) => ( - + // The glazing role material is FrontSide (DoubleSide on a NodeMaterial + // poisons the MRT scene pass — see `createSurfaceRoleMaterial`). The + // back gable face therefore renders inside a Y-rotated group so its + // FrontSide points outward (-Z in segment frame). With the rotation, + // the sill always extrudes along the group's local +Z, so its position + // no longer needs to flip per-face. + const renderFace = (zPos: number, yRot: number, keyPrefix: string) => ( + {winGeo.glassPanes.map((pane, i) => ( )} @@ -171,8 +181,8 @@ const DormerWindowAssembly = ({ return ( <> - {exposed.front && renderFace(gableHalfZ, +1, 'front')} - {exposed.back && renderFace(-gableHalfZ, -1, 'back')} + {exposed.front && renderFace(gableHalfZ, 0, 'front')} + {exposed.back && renderFace(-gableHalfZ, Math.PI, 'back')} ) } diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 0e4b9264a..b5b36529f 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -524,7 +524,15 @@ export function createSurfaceRoleMaterial( side: THREE.Side = THREE.FrontSide, sceneThemeId?: string, ): THREE.Material { - const resolvedSide = role === 'glazing' ? THREE.DoubleSide : side + // DoubleSide on glazing trips the MRT back-face pipeline issue documented + // on `glassMaterial` above — the validator rejects the back-face variant + // for missing MRT outputs and poisons the render context (manifests as + // "Color target has no corresponding fragment stage output" on scene + // open, since the dormer's window-assembly mounts the glazing material + // on both gable faces on the first frame). Callers that need both sides + // visible (e.g. dormer back gable) must rotate the host mesh 180° so the + // FrontSide faces the viewer. + const resolvedSide = role === 'glazing' ? THREE.FrontSide : side const cacheKey = `${role}-${preset}-${resolvedSide}-${sceneThemeId ?? 'base'}` const cached = surfaceRoleMaterialCache.get(cacheKey) if (cached) return cached From 7b54f8864b074baeaf831edffddb3ff776b7a1d1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 23:26:46 +0530 Subject: [PATCH 12/35] feat(nodes): in-world handles for box-vent + ridge-vent; freeze non-active arrows during drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 5 chevron handles to box-vent (width L/R, depth, height, rotate) and ridge-vent (length L/R, width, height, rotate). Box-vent's renderer now composes slope tilt + yaw onto the registered ref's quaternion (mirrors solar-panel) so handle placements use vent-mesh-local coords directly; ridge-vent's registered ref was already at the vent frame. Ridge-vent renderer now merges `useLiveNodeOverrides` so the mesh updates in-flight during a handle drag instead of freezing until commit. Box-vent / dormer / chimney already did this; ridge-vent was the only roof accessory not subscribed. `NodeArrowHandles` now tracks the active drag descriptor + a pre-drag store snapshot. Non-active arrows render against the snapshot with a node-local freeze offset that cancels the mesh's `position` drift — asymmetric resize (width / length L+R) recomputes position to anchor the opposite edge, and without the freeze every other chevron would slide along with the moving mesh center. The active arrow's freeze offset is null, so it tracks the cursor as before. Rotation drags collapse the offset to zero (position doesn't change), so non-active chevrons naturally rotate with the mesh. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/editor/node-arrow-handles.tsx | 194 +++++++++++++++++- packages/nodes/src/box-vent/definition.ts | 130 +++++++++++- packages/nodes/src/box-vent/renderer.tsx | 32 +-- packages/nodes/src/ridge-vent/definition.ts | 130 +++++++++++- packages/nodes/src/ridge-vent/renderer.tsx | 18 +- 5 files changed, 479 insertions(+), 25 deletions(-) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index e42b0de92..8ec8acbf3 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -372,13 +372,40 @@ function NodeArrowHandlesForNode({ // wall-riding outer wrapper. const arrowFrame = innerRide ?? outerRide + // Active-drag tracking. When a handle starts dragging, it claims its + // descriptor index here and snapshots the store node at drag-start. + // Non-active arrows re-render against the snapshot + a freeze offset + // that undoes the mesh's `position` drift in node-local frame — so + // asymmetric resize (width L/R, length L/R) doesn't visually slide the + // depth / height / rotate chevrons. They stay anchored at their + // pre-drag world positions for the duration of the drag. + const [activeIndex, setActiveIndex] = useState(null) + const [preDragNode, setPreDragNode] = useState(null) + const dragControls = useMemo( + () => ({ + onStart: (index: number, snapshot: AnyNode) => { + setActiveIndex(index) + setPreDragNode(snapshot) + }, + onEnd: () => { + setActiveIndex(null) + setPreDragNode(null) + }, + }), + [], + ) + const arrows = descriptors.map((descriptor, index) => ( )) @@ -391,23 +418,100 @@ function NodeArrowHandlesForNode({ ) } +type DragControls = { + onStart: (index: number, snapshot: AnyNode) => void + onEnd: () => void +} + +// Offset, in node-local frame, that compensates for `position` drift on +// the mesh during an asymmetric resize. Width/length L+R recompute +// `position` so the anchored edge stays world-fixed — the renderer +// follows that override, the ride object moves, and every arrow under +// it would drift along with the mesh center. Subtracting this offset +// from a non-active arrow's local placement undoes that drift so it +// stays at its pre-drag world position. +// +// Rotation drags don't change `position`, so the offset collapses to +// zero and non-active arrows naturally rotate with the mesh — which is +// the desired behaviour (the whole rig rotates as a unit). +function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, number, number] { + // Not every node in the union carries a `position` field (sites are the + // notable holdout — they don't have handles anyway, but TypeScript still + // requires us to discriminate). Guarded access keeps the freeze logic + // safe for the few node kinds that lack the field. + const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [ + 0, 0, 0, + ] + const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [ + 0, 0, 0, + ] + const deltaWorldX = liveP[0] - preP[0] + const deltaWorldY = liveP[1] - preP[1] + const deltaWorldZ = liveP[2] - preP[2] + const rotY = (preDragNode as { rotation?: number }).rotation ?? 0 + // World → node-local for Y-axis rotation by rotY (THREE.Object3D + // rotation-y convention): inverse is rotation by -rotY around +Y. + const cosR = Math.cos(rotY) + const sinR = Math.sin(rotY) + const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ + const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ + return [deltaLocalX, deltaWorldY, deltaLocalZ] +} + function ArrowHandle({ descriptor, - node, + liveNode, + preDragNode, + activeIndex, + handleIndex, + dragControls, rideObject, }: { descriptor: HandleDescriptor - node: AnyNode + liveNode: AnyNode + preDragNode: AnyNode | null + activeIndex: number | null + handleIndex: number + dragControls: DragControls rideObject: Object3D }) { + // During a drag, non-active arrows render against the pre-drag store + // snapshot. The active arrow always uses the live (override-merged) + // node so it tracks the cursor. + const isOtherActive = + activeIndex !== null && activeIndex !== handleIndex && preDragNode !== null + const placementNode = isOtherActive ? (preDragNode as AnyNode) : liveNode + const freezeOffset = + isOtherActive && preDragNode ? computeFreezeOffset(liveNode, preDragNode) : null + if (descriptor.kind === 'linear-resize' || descriptor.kind === 'radial-resize') { - return + return ( + + ) } if (descriptor.kind === 'arc-resize') { - return + return ( + + ) } if (descriptor.kind === 'tap-action') { - return + return } // endpoint-move not yet implemented. return null @@ -454,10 +558,21 @@ function resolveBound( function LinearArrow({ descriptor, node, + liveNode, + freezeOffset, + handleIndex, + dragControls, rideObject, }: { descriptor: LinearResizeHandle | RadialResizeHandle + /** Effective node for placement (preDrag snapshot when another arrow is active). */ node: AnyNode + /** Always the live (override-merged) node — used inside drag handlers. */ + liveNode: AnyNode + /** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */ + freezeOffset: [number, number, number] | null + handleIndex: number + dragControls: DragControls rideObject: Object3D }) { const [isHovered, setIsHovered] = useState(false) @@ -477,9 +592,29 @@ function LinearArrow({ useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) useEffect(() => () => dragCleanupRef.current?.(), []) + // Suppress "declared but unused" for `liveNode` — LinearArrow's apply + // operates on `initialNode` (snapshot inside activate) and reads value + // updates back via `useLiveNodeOverrides`. The prop is required for + // uniformity with ArrowHandle's variant dispatch but isn't consumed in + // this variant's render path. + void liveNode + const cursor = pickCursor(descriptor) const placementSceneApi = useMemo(() => createSceneApi(useScene), []) - const position = descriptor.placement.position(node, placementSceneApi) + const basePosition = descriptor.placement.position(node, placementSceneApi) + // `freezeOffset` (in node-local frame) cancels the mesh's `position` + // drift while another arrow is being dragged — `basePosition` is + // computed against the pre-drag snapshot, then we subtract the offset + // so the arrow's WORLD location matches its pre-drag world location. + // Active arrows + idle state have `freezeOffset === null`, so the + // position passes through unchanged. + const position: [number, number, number] = freezeOffset + ? [ + basePosition[0] - freezeOffset[0], + basePosition[1] - freezeOffset[1], + basePosition[2] - freezeOffset[2], + ] + : [basePosition[0], basePosition[1], basePosition[2]] const baseRotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 // Default chevron points +X. Rotate around Y to face the chosen axis. const axisRotationY = descriptor.axis === 'z' ? -Math.PI / 2 : 0 @@ -555,6 +690,12 @@ function LinearArrow({ useViewer.getState().setInputDragging(true) useScene.temporal.getState().pause() setIsDragging(true) + // Claim active-drag status — `NodeArrowHandlesForNode` will pass the + // snapshot to every OTHER arrow so they render at their pre-drag + // world positions while this drag runs. The snapshot must be the + // pre-override store node (not the merged `liveNode`) so subsequent + // re-renders don't pollute it with this drag's own patch. + dragControls.onStart(handleIndex, initialNode) let lastPatch: Partial | null = null @@ -600,6 +741,9 @@ function LinearArrow({ useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) setIsDragging(false) + // Release the active-drag claim so non-active arrows return to + // live-tracking (and so the next drag can claim its own snapshot). + dragControls.onEnd() dragCleanupRef.current = null } const onUp = () => { @@ -785,10 +929,21 @@ function GuideRing({ radius, y }: { radius: number; y: number }) { function ArcArrow({ descriptor, node, + liveNode, + freezeOffset, + handleIndex, + dragControls, rideObject, }: { descriptor: ArcResizeHandle + /** Effective node for placement (preDrag snapshot when another arrow is active). */ node: AnyNode + /** Always the live (override-merged) node — used inside drag handlers. */ + liveNode: AnyNode + /** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */ + freezeOffset: [number, number, number] | null + handleIndex: number + dragControls: DragControls rideObject: Object3D }) { const [isHovered, setIsHovered] = useState(false) @@ -818,7 +973,19 @@ function ArcArrow({ useEffect(() => () => dragCleanupRef.current?.(), []) const placementSceneApi = useMemo(() => createSceneApi(useScene), []) - const position = descriptor.placement.position(node, placementSceneApi) + const basePosition = descriptor.placement.position(node, placementSceneApi) + // See the LinearArrow note on freezeOffset — for rotation drags the + // delta collapses to zero (position doesn't change), so the rotate + // gizmo naturally rotates with the mesh while another arrow is being + // dragged. The offset only kicks in for asymmetric resize drags that + // recompute `position` to anchor the opposite edge. + const position: [number, number, number] = freezeOffset + ? [ + basePosition[0] - freezeOffset[0], + basePosition[1] - freezeOffset[1], + basePosition[2] - freezeOffset[2], + ] + : [basePosition[0], basePosition[1], basePosition[2]] const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 // Rotation gizmo: hover signals "grabbable", active drag signals // "grabbed". `ew-resize` was wrong — it implies linear width drag. @@ -880,6 +1047,8 @@ function ArcArrow({ useViewer.getState().setInputDragging(true) useScene.temporal.getState().pause() setIsDragging(true) + // Claim active-drag status — see LinearArrow's onStart note. + dragControls.onStart(handleIndex, initialNode) let lastPatch: Partial | null = null @@ -915,6 +1084,8 @@ function ArcArrow({ useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) setIsDragging(false) + // Release the active-drag claim — see LinearArrow's onEnd note. + dragControls.onEnd() dragCleanupRef.current = null } const onUp = () => { @@ -943,6 +1114,13 @@ function ArcArrow({ window.addEventListener('pointercancel', onCancel) } + // Suppress "declared but unused" for `liveNode` — ArcArrow's apply + // operates entirely on `initialNode` (snapshot taken inside activate) + // and `delta` (live cursor angle), so the live store node doesn't + // appear in the rotation pipeline. The prop is still required because + // ArrowHandle passes it uniformly to every variant. + void liveNode + return ( <> {showDecoration && decoration ? ( diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index e0d61abd5..29426d42f 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -1,7 +1,134 @@ -import { BoxVentNode as BoxVentNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { + type BoxVentNode as BoxVentNodeType, + BoxVentNode as BoxVentNodeSchema, + type HandleDescriptor, + type NodeDefinition, +} from '@pascal-app/core' import { boxVentParametrics } from './parametrics' import { BoxVentNode } from './schema' +// Edge-to-arrow-center offset, matching the chimney / dormer cadence. +const SIDE_HANDLE_OFFSET = 0.25 +const HEIGHT_HANDLE_OFFSET = 0.2 +const ROTATE_CORNER_OFFSET = 0.25 +// Min sizes — vents are small (default 0.4 × 0.4 × 0.15), so the floor +// is well below the default values to allow shrinking without locking. +const MIN_DIM = 0.1 +const MIN_HEIGHT = 0.05 + +// Mid-Y of the vent body in vent-mesh-local. The vent sits with its base +// at y=0 on the slope, so mid is half the height. Side / depth / rotate +// chevrons all place their handle at this Y to read as "this dimension +// is the vent body". +function getBodyMidY(n: BoxVentNodeType): number { + return Math.max(0.001, n.height) / 2 +} + +// Width arrow on the +X (right) or -X (left) side of the vent body. +// Asymmetric resize — anchored edge stays world-fixed by recentering +// `position` along the vent's own +X arm in segment frame (matches the +// chimney / dormer width handle math). The slope tilt rotates around +// the vent's base point, so segment-local XZ of the anchored edge stays +// the same regardless of tilt; only the yaw matters for the projection. +function boxVentWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_DIM, + currentValue: (n) => n.width, + apply: (initial, newWidth) => { + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initial.width / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ + const newCenterX = anchorX + sign * (newWidth / 2) * armX + const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ + return { + width: newWidth, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, + placement: { + position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), 0], + // Flip the left chevron so it points outward toward -X. The + // generic LinearArrow auto-orients for axis 'z'; +X / -X facing + // is up to the descriptor. + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Depth arrow on the +Z side. Symmetric (anchor 'center') matches the +// chimney / dormer handle count budget — splitting into asymmetric front / +// back chevrons here would push the vent over the same TSL/MRT pipeline +// threshold those nodes already document. Single symmetric chevron grows +// the depth from the centre. +function boxVentDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_DIM, + currentValue: (n) => n.depth, + apply: (_n, newValue) => ({ depth: newValue }), + placement: { + position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET], + }, + } +} + +// Height arrow above the top of the vent. anchor='min' so the base stays +// pinned to the slope at vent-local y=0 and the top edge follows the +// pointer. Plain chevron (not tracker) — at default sizes (~0.15 m) a +// dashed leader from base to top reads as visual noise rather than a +// dimension cue. +function boxVentHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_HEIGHT, + currentValue: (n) => n.height, + apply: (_n, newValue) => ({ height: Math.max(MIN_HEIGHT, newValue) }), + placement: { + position: (n) => [0, Math.max(n.height, MIN_HEIGHT) + HEIGHT_HANDLE_OFFSET, 0], + }, + } +} + +// Whole-vent rotation gizmo at the +X/+Z corner of the body footprint. +// The registered group already centres on the vent and applies its +// composed slope+yaw quaternion, so the default rotation pivot is +// correct — no `rotationCenter` override needed. +function boxVentRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n) => [ + n.width / 2 + ROTATE_CORNER_OFFSET, + getBodyMidY(n), + n.depth / 2 + ROTATE_CORNER_OFFSET, + ], + // Aim the two-headed icon along the +X+Z corner bisector. + rotationY: () => -Math.PI / 4, + }, + } +} + +const boxVentHandles: HandleDescriptor[] = [ + boxVentWidthHandle('right'), + boxVentWidthHandle('left'), + boxVentDepthHandle(), + boxVentHeightHandle(), + boxVentRotateHandle(), +] + /** * Box vent — a small louvered ventilation box that sits on a roof * slope. Parented to a `roof-segment`; position is segment-local; @@ -52,6 +179,7 @@ export const boxVentDefinition: NodeDefinition = { }, parametrics: boxVentParametrics, + handles: boxVentHandles, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index a373ed818..94b421946 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -128,6 +128,17 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { return cloned }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + // Compose slope tilt + yaw onto a single quaternion so the registered + // ref's local frame is vent-mesh-local. `NodeArrowHandles` reads this + // frame to place its chevrons; collapsing the nested-group stack onto + // the registered group lets handles use vent-local coords directly, + // without per-arrow tilt compensation. Mirrors solar-panel's renderer. + const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), []) + const composedQuat = useMemo(() => { + const yawQuat = new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0) + return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat) + }, [surfaceQuat, node.rotation, yAxis]) + if (!segment) return null // `node.position` is segment-local (the placement + move tools resolve @@ -146,21 +157,18 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { - - - - - + ) diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index a0ff7c559..de92de66f 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -1,7 +1,134 @@ -import { type NodeDefinition, RidgeVentNode as RidgeVentNodeSchema } from '@pascal-app/core' +import { + type HandleDescriptor, + type NodeDefinition, + type RidgeVentNode as RidgeVentNodeType, + RidgeVentNode as RidgeVentNodeSchema, +} from '@pascal-app/core' import { ridgeVentParametrics } from './parametrics' import { RidgeVentNode } from './schema' +// Edge-to-arrow-center offset, matching the box-vent / chimney cadence. +const SIDE_HANDLE_OFFSET = 0.25 +const HEIGHT_HANDLE_OFFSET = 0.15 +const ROTATE_CORNER_OFFSET = 0.25 +// Ridge vents are long but thin — minimums let users shrink without +// collapsing the geometry past the point where the cross-section +// degenerates. Default length is 2.0, default width 0.3, default +// height 0.08, so these are well below the defaults. +const MIN_LENGTH = 0.2 +const MIN_WIDTH = 0.1 +const MIN_HEIGHT = 0.02 + +// Mid-Y of the vent body in vent-mesh-local frame. The base sits at the +// ridge line (Y=0) and the cap peaks at Y=height — so side / rotate +// chevrons place at half-height to read as "beside the body". +function getBodyMidY(n: RidgeVentNodeType): number { + return Math.max(MIN_HEIGHT, n.height) / 2 +} + +// Length arrow on ±X (the ridge direction). Asymmetric: drag one end +// outward and the opposite end stays world-fixed by recentering +// `position` along the vent's own +X arm in segment frame (yaw-aware +// math, matches box-vent / chimney). The ridge vent typically straddles +// a portion of the ridge, so dragging one end is the natural extend / +// shorten gesture. +function ridgeVentLengthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_LENGTH, + currentValue: (n) => n.length, + apply: (initial, newLength) => { + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initial.length / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.length / 2) * armZ + const newCenterX = anchorX + sign * (newLength / 2) * armX + const newCenterZ = anchorZ + sign * (newLength / 2) * armZ + return { + length: newLength, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, + placement: { + position: (n) => [sign * (n.length / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), 0], + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Width arrow on +Z (across the ridge). Symmetric — the vent geometry +// straddles the ridge line (Z=0) so growing the width pushes both edges +// outward by the same amount. A single chevron on +Z reads as "this is +// the width dimension"; keeping it symmetric also stays inside the same +// handle-count budget the chimney / dormer / box-vent already document. +function ridgeVentWidthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_WIDTH, + currentValue: (n) => n.width, + apply: (_n, newValue) => ({ width: newValue }), + placement: { + position: (n) => [0, getBodyMidY(n), n.width / 2 + SIDE_HANDLE_OFFSET], + }, + } +} + +// Height arrow above the cap peak. anchor='min' so the base stays +// pinned to the ridge line (Y=0) and the peak follows the cursor. Plain +// chevron — at default 0.08 m a dashed tracker leader would be visual +// noise rather than a dimension cue. +function ridgeVentHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_HEIGHT, + currentValue: (n) => n.height, + apply: (_n, newValue) => ({ height: Math.max(MIN_HEIGHT, newValue) }), + placement: { + position: (n) => [0, Math.max(n.height, MIN_HEIGHT) + HEIGHT_HANDLE_OFFSET, 0], + }, + } +} + +// Whole-vent rotation gizmo at the +X+Z corner of the body footprint. +// Negate the cursor delta to match three.js Y-rotation handedness. The +// registered group already centres on the vent and applies its yaw, +// so the default rotation pivot is correct. +function ridgeVentRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }), + placement: { + position: (n) => [ + n.length / 2 + ROTATE_CORNER_OFFSET, + getBodyMidY(n), + n.width / 2 + ROTATE_CORNER_OFFSET, + ], + // Two-headed icon's natural bias is along +X; aim along the + // +X+Z corner bisector so it sits visually flush with the + // rotate gesture's swing direction. + rotationY: () => -Math.PI / 4, + }, + } +} + +const ridgeVentHandles: HandleDescriptor[] = [ + ridgeVentLengthHandle('right'), + ridgeVentLengthHandle('left'), + ridgeVentWidthHandle(), + ridgeVentHeightHandle(), + ridgeVentRotateHandle(), +] + /** * Ridge vent — a ventilation strip running along the ridge of a roof * segment. Parented to a `roof-segment`; position is segment-local. @@ -41,6 +168,7 @@ export const ridgeVentDefinition: NodeDefinition = { }, parametrics: ridgeVentParametrics, + handles: ridgeVentHandles, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 505c4b2c8..73e5c78e0 100644 --- a/packages/nodes/src/ridge-vent/renderer.tsx +++ b/packages/nodes/src/ridge-vent/renderer.tsx @@ -4,6 +4,7 @@ import { type AnyNodeId, type RidgeVentNode, type RoofSegmentNode, + useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' @@ -45,15 +46,26 @@ const defaultMaterial = new THREE.MeshStandardMaterial({ * family (matte standard / shingled grey / brushed metal) before the * user opens the paint tray. */ -const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => { +const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { const ref = useRef(null!) - useRegistry(node.id, 'ridge-vent', ref) - const handlers = useNodeEvents(node, 'ridge-vent') + useRegistry(storeNode.id, 'ridge-vent', ref) + const handlers = useNodeEvents(storeNode, 'ridge-vent') const shading = useViewer((s) => s.shading) const textures = useViewer((s) => s.textures) const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + // Merge live drag overrides on top of the store node so handle drags + // update the mesh in-flight without flushing to zustand on every frame. + // Same pattern as box-vent / chimney / dormer — the override is set by + // `NodeArrowHandles`' drag handler and cleared on commit. + const overrides = useLiveNodeOverrides( + (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, + ) + const node: RidgeVentNode = overrides + ? ({ ...storeNode, ...overrides } as RidgeVentNode) + : storeNode + const segment = useScene((state) => node.roofSegmentId ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) From ff9283d85797134e7cec196c0a5be34674c61588 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 28 May 2026 23:57:33 +0530 Subject: [PATCH 13/35] =?UTF-8?q?feat(nodes):=20gutter=20accessory=20?= =?UTF-8?q?=E2=80=94=20eave-mounted=20rain=20channel=20with=20three=20prof?= =?UTF-8?q?iles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `gutter` node kind hosted on a roof-segment. Placement tool snaps to the eave line of the segment under the cursor (segment-local `Z = +depth/2`, `Y = wallHeight`); the back wall of the gutter then sits flush against the fascia and the trough hangs outward (+Z). Three cross-section profiles share the same outer-outline-minus-cavity extrude recipe and only differ in the outline curve: - `k-style`: ogee fascia (S-curve) — default residential look - `half-round`: semicircular trough — colonial / classical feel - `box`: rectangular u-channel — commercial / industrial Three in-world chevron handles via the registry: - length L + R (asymmetric — drag one end, the other stays world-fixed) - size (anchor='max', drops the trough downward as the cursor pulls) Wiring touches every node-kind ledger: schema (core/schema/nodes/ gutter.ts), AnyNode union, schema barrel, material targets, roof-segment hosted-accessory comment, event bus (`gutter:*`), nodes barrel + registry, plus the per-kind file set under `packages/nodes/src/gutter/` (geometry, schema re-export, parametrics, renderer, preview, tool, definition, index). V1 ships gutters only — downspouts deferred so the eave-snap + cross-section pipeline can be eyeballed before stacking the downspout-corner placement logic on top. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/events/bus.ts | 3 + packages/core/src/index.ts | 1 + packages/core/src/schema/index.ts | 1 + packages/core/src/schema/material.ts | 1 + packages/core/src/schema/nodes/gutter.ts | 50 +++++ .../core/src/schema/nodes/roof-segment.ts | 2 +- packages/core/src/schema/types.ts | 2 + packages/nodes/src/gutter/definition.ts | 171 ++++++++++++++++++ packages/nodes/src/gutter/geometry.ts | 161 +++++++++++++++++ packages/nodes/src/gutter/index.ts | 3 + packages/nodes/src/gutter/parametrics.ts | 33 ++++ packages/nodes/src/gutter/preview.tsx | 57 ++++++ packages/nodes/src/gutter/renderer.tsx | 123 +++++++++++++ packages/nodes/src/gutter/schema.ts | 3 + packages/nodes/src/gutter/tool.tsx | 163 +++++++++++++++++ packages/nodes/src/index.ts | 3 + 16 files changed, 776 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/schema/nodes/gutter.ts create mode 100644 packages/nodes/src/gutter/definition.ts create mode 100644 packages/nodes/src/gutter/geometry.ts create mode 100644 packages/nodes/src/gutter/index.ts create mode 100644 packages/nodes/src/gutter/parametrics.ts create mode 100644 packages/nodes/src/gutter/preview.tsx create mode 100644 packages/nodes/src/gutter/renderer.tsx create mode 100644 packages/nodes/src/gutter/schema.ts create mode 100644 packages/nodes/src/gutter/tool.tsx diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 1466770f1..f7815385b 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -12,6 +12,7 @@ import type { ElevatorNode, FenceNode, GuideNode, + GutterNode, ItemNode, LevelNode, RidgeVentNode, @@ -88,6 +89,7 @@ export type ScanEvent = NodeEvent export type GuideEvent = NodeEvent export type BoxVentEvent = NodeEvent export type RidgeVentEvent = NodeEvent +export type GutterEvent = NodeEvent export type ChimneyEvent = NodeEvent export type SolarPanelEvent = NodeEvent export type SkylightEvent = NodeEvent @@ -227,6 +229,7 @@ type EditorEvents = GridEvents & NodeEvents<'guide', GuideEvent> & NodeEvents<'box-vent', BoxVentEvent> & NodeEvents<'ridge-vent', RidgeVentEvent> & + NodeEvents<'gutter', GutterEvent> & NodeEvents<'chimney', ChimneyEvent> & NodeEvents<'solar-panel', SolarPanelEvent> & NodeEvents<'skylight', SkylightEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 54a3a47ba..663376cc2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export type { FenceEvent, GridEvent, GuideEvent, + GutterEvent, ItemEvent, LevelEvent, NodeEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 8d7732929..3cd29ba8b 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -83,6 +83,7 @@ export { isLowProfileItemSurface, LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, } from './nodes/item' +export { GutterNode } from './nodes/gutter' export { LevelNode } from './nodes/level' // Nodes export { RidgeVentNode } from './nodes/ridge-vent' diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 90e864f46..b7f5910fa 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -57,6 +57,7 @@ export const MaterialTarget = z.enum([ 'dormer', 'box-vent', 'ridge-vent', + 'gutter', ]) export type MaterialTarget = z.infer diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts new file mode 100644 index 000000000..f04b5c285 --- /dev/null +++ b/packages/core/src/schema/nodes/gutter.ts @@ -0,0 +1,50 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const GutterNode = BaseNode.extend({ + id: objectId('gutter'), + type: nodeType('gutter'), + + material: MaterialSchema.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. + materialPreset: z.string().default('preset-white'), + + roofSegmentId: z.string().optional(), + // Segment-local. The placement tool snaps to the eave line (Z = + // +depth/2, Y = wallHeight) of the segment under the cursor; X is + // wherever the user clicked. After placement the inspector + length + // handles can shift X along the eave. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Rotation around the gutter's own local Y. Kept at 0 by default + // because the gutter's length axis is constrained to the eave + // direction (segment-local +X) — but exposed in case the user wants + // to tilt for a custom run. + rotation: z.number().default(0), + + // Length along the eave (gutter-local +X). + length: z.number().default(2.0), + // Profile size — the vertical drop of the U-channel below the eave + // line. 5″ (0.127 m) is the most common residential gutter size; 6″ + // (0.152 m) is the common commercial / heavy-duty size. Default + // rounds the residential value to 0.13 m. + size: z.number().default(0.13), + // Wall thickness of the U-channel. Visible on the rim from above; too + // thin reads as a paper strip, too thick reads as a curb. + thickness: z.number().default(0.006), + + profile: z.enum(['k-style', 'half-round', 'box']).default('k-style'), +}).describe( + dedent` + Gutter — a rain-water channel running along the eave of a roof + segment. Parented to a roof-segment; position is segment-local. + - length: span along the eave (gutter-local +X) + - size: profile drop below the eave line (vertical extent) + - profile: k-style (ogee fascia), half-round, or square box + `, +) + +export type GutterNode = z.infer diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 22f9f6c1c..d6e8f56b4 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -97,7 +97,7 @@ export const RoofSegmentNode = BaseNode.extend({ .max(0.9) .default(ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio), // Hosted accessories — chimney, dormer, skylight, box-vent, - // ridge-vent, solar-panel. Each accessory's `parentId` points back + // ridge-vent, solar-panel, gutter. Each accessory's `parentId` points back // here; the segment renderer mounts them recursively via // `` so they inherit the segment's transform stack. // Required for `createNode(child, segmentId)` to append the child diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 1c27dd07a..ed9a308d3 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -9,6 +9,7 @@ import { DormerNode } from './nodes/dormer' import { ElevatorNode } from './nodes/elevator' import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' +import { GutterNode } from './nodes/gutter' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' import { RidgeVentNode } from './nodes/ridge-vent' @@ -51,6 +52,7 @@ export const AnyNode = z.discriminatedUnion('type', [ DoorNode, BoxVentNode, RidgeVentNode, + GutterNode, ChimneyNode, SolarPanelNode, SkylightNode, diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts new file mode 100644 index 000000000..7531f5eac --- /dev/null +++ b/packages/nodes/src/gutter/definition.ts @@ -0,0 +1,171 @@ +import { + GutterNode as GutterNodeSchema, + type GutterNode as GutterNodeType, + type HandleDescriptor, + type NodeDefinition, +} from '@pascal-app/core' +import { gutterParametrics } from './parametrics' +import { GutterNode } from './schema' + +// Edge-to-arrow-center offset, matching the box-vent / ridge-vent +// cadence so a roof's worth of accessories all read at the same scale. +const SIDE_HANDLE_OFFSET = 0.2 +// Gutter chevrons sit BELOW the gutter (the trough hangs below the +// eave); the Y handle places its arrow under the cross-section apex. +const SIZE_HANDLE_OFFSET = 0.15 +// Minimums — well below the inspector defaults (2.0 m length, 0.13 m +// profile) so users can shrink freely without locking. +const MIN_LENGTH = 0.2 +const MIN_SIZE = 0.05 + +// Centre of the gutter cross-section in vertical (Y) terms. The gutter +// hangs from the eave (Y=0 in vent-mesh-local) down to Y=-size; chevrons +// that want to read "beside the body" sit at -size/2. +function getBodyMidY(n: GutterNodeType): number { + return -Math.max(MIN_SIZE, n.size) / 2 +} + +// Outward Z midpoint — the gutter's back wall sits at Z=0 and the rim +// hangs out to Z≈+size (k-style) / +size (half-round / box). Side +// handles place at Z=0 so they sit ABOVE the eave's fascia line. +function getRimZ(n: GutterNodeType): number { + return Math.max(MIN_SIZE, n.size) / 2 +} + +// Length arrow on ±X (the eave direction). Asymmetric resize: drag one +// end outward while the opposite end stays world-fixed by recentering +// `position` along the gutter's own +X arm in segment frame. Same +// yaw-aware projection as the box-vent / ridge-vent / chimney width +// handles. +function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_LENGTH, + currentValue: (n) => n.length, + apply: (initial, newLength) => { + const rotY = initial.rotation ?? 0 + const armX = Math.cos(rotY) + const armZ = -Math.sin(rotY) + const anchorX = initial.position[0] - sign * (initial.length / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.length / 2) * armZ + const newCenterX = anchorX + sign * (newLength / 2) * armX + const newCenterZ = anchorZ + sign * (newLength / 2) * armZ + return { + length: newLength, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, + placement: { + position: (n) => [ + sign * (n.length / 2 + SIDE_HANDLE_OFFSET), + getBodyMidY(n), + getRimZ(n), + ], + rotationY: () => (side === 'right' ? 0 : Math.PI), + }, + } +} + +// Profile-size arrow below the rim. axis='y', anchor='max' pins the +// top of the trough (Y=0, the eave line) and grows the bottom edge +// downward as the user drags toward -Y. Plain chevron — at typical +// sizes (5″–6″) a dashed tracker would clutter the eave line. +function gutterSizeHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + // 'max' = +Y edge anchored (top of the trough stays at Y=0); drag + // pulls the bottom edge further down. The base linear-resize + // factor for `max` is -1, which flips the cursor delta so dragging + // downward grows the value 1:1. + anchor: 'max', + min: MIN_SIZE, + currentValue: (n) => n.size, + apply: (_n, newValue) => ({ size: Math.max(MIN_SIZE, newValue) }), + placement: { + // Sit at the bottom of the trough, pushed a bit further down so + // the chevron clears the rim and reads as a downward indicator. + position: (n) => [0, -Math.max(n.size, MIN_SIZE) - SIZE_HANDLE_OFFSET, getRimZ(n)], + }, + } +} + +const gutterHandles: HandleDescriptor[] = [ + gutterLengthHandle('right'), + gutterLengthHandle('left'), + gutterSizeHandle(), +] + +/** + * Gutter — a rain-water channel running along the eave of a roof + * segment. Parented to a `roof-segment`; position is segment-local. + * + * Three-checkbox model — same shape as box-vent / ridge-vent: custom + * `def.renderer` for the parent-segment transform lookup + live + * override merge, pure geometry builder in `./geometry` shared with + * the placement preview, no per-frame system (no animation, no + * cross-kind cascades). + * + * Placement tool snaps to the eave line (segment-local + * `Z = +depth/2, Y = wallHeight`) wherever the cursor lands on a + * segment. After commit, the length L/R handles cover trimming and + * the inspector covers profile + size adjustments. + */ +export const gutterDefinition: NodeDefinition = { + kind: 'gutter', + schemaVersion: 1, + schema: GutterNode, + category: 'structure', + surfaceRole: 'roof', + + defaults: () => { + const stub = GutterNodeSchema.parse({ + id: 'gutter_default' as never, + type: 'gutter', + }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + // 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 / + // resizes. + roofAccessory: {}, + }, + + parametrics: gutterParametrics, + handles: gutterHandles, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + + preview: () => import('./preview'), + tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Place gutter on roof eave' }, + { key: 'Esc', label: 'Cancel' }, + ], + + presentation: { + label: 'Gutter', + description: 'Rain-water channel running along the eave of a roof segment.', + icon: { kind: 'url', src: '/icons/roof.png' }, + paletteSection: 'structure', + paletteOrder: 122, + }, + + mcp: { + description: + 'A gutter strip running along the eave of a roof segment. Three profiles (k-style ogee fascia, half-round, square box), length / size / thickness parametric.', + }, +} diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts new file mode 100644 index 000000000..54d4594cd --- /dev/null +++ b/packages/nodes/src/gutter/geometry.ts @@ -0,0 +1,161 @@ +import type { GutterNode } from '@pascal-app/core' +import * as THREE from 'three' + +/** + * Pure builder for the gutter mesh. The gutter is a hollow trough that + * runs along the eave; we build its cross-section as a closed 2D Shape + * in the (Z, Y) plane with the channel cavity carved out as a Path + * hole, then extrude along the gutter's local +X (length direction). + * + * Three profiles share the same outer-outline-minus-cavity recipe; they + * differ only in the OUTLINE shape: + * + * - `k-style`: flat back + flat bottom + ogee (S-curve) fascia. + * Most common modern residential profile. + * - `half-round`: half-cylinder (semicircle cross-section). + * - `box`: square / rectangular u-channel; reads as commercial. + * + * The gutter mounts at the eave line (gutter-local Y=0) and drops + * downward (negative Y) by `size`. +Z is "away from the building" — + * positive Z is the outer face that hangs over the eave. + * + * Pure: no React, no scene access, no store mutation. + */ +export function buildGutterGeometry(node: GutterNode): THREE.BufferGeometry { + const len = Math.max(0.05, node.length) + const size = Math.max(0.04, node.size) + const t = Math.min(Math.max(0.001, node.thickness), size * 0.4) + + let cross: THREE.Shape + if (node.profile === 'half-round') { + cross = buildHalfRoundCross(size, t) + } else if (node.profile === 'box') { + cross = buildBoxCross(size, t) + } else { + cross = buildKStyleCross(size, t) + } + + // Extrude the cross-section along the gutter's local +X. The Shapes + // above are authored with cross-X going from 0 (outer rim) to +w + // (back, against the fascia). After extrude (which produces mesh-X = + // cross-X, mesh-Y = cross-Y, mesh-Z = extrusion axis = length), we + // rotateY(-π/2) so the LENGTH lands along mesh-+X and the OUTWARD + // direction lands along mesh-+Z (segment-local +Z is the downslope / + // outward direction at the eave). The two-line combination is: + // + // rotateY(-π/2): mesh-X (outward) → +Z, mesh-Z (length) → -X + // translate(+len/2, 0, 0): recenter the now-negative + // length span around X = 0 + // + // The renderer mounts this in segment-local frame with no extra + // rotation, so the gutter naturally aligns with the eave when + // `node.rotation = 0`. + const extruded = new THREE.ExtrudeGeometry(cross, { + depth: len, + bevelEnabled: false, + curveSegments: 16, + steps: 1, + }) + extruded.rotateY(-Math.PI / 2) + extruded.translate(len / 2, 0, 0) + extruded.computeVertexNormals() + return extruded +} + +// K-style cross-section in (X, Y) where X is the gutter's outward +// direction (positive = away from the wall) and Y is vertical (0 at +// eave line, -size at the bottom of the trough). +// +// Outer outline traces an ogee fascia: +// top-back (0, 0) → +// bottom-back (0, -size) → +// bottom-front (w_bot, -size) → +// front mid (w_top, -size + size*0.35) — curve outward and up +// top-front (w_top, 0) +// then a hollow (the water channel) is carved as a Path hole offset by +// `t` from each face. Closing the top of the outer outline keeps the +// extrude solid; the lid disappears against the open channel because +// the hole runs through the entire extrusion. +function buildKStyleCross(size: number, t: number): THREE.Shape { + const wBot = size * 0.8 // bottom width — narrower than the rim + const wTop = size * 0.95 + const ogeeY = -size * 0.65 // S-curve inflection + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + shape.lineTo(0, -size) // back, straight down + shape.lineTo(wBot, -size) // bottom + // Bezier the front fascia: bottom-front → ogee inflection → top-front. + shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0) + shape.closePath() + + // Inner hole, offset by `t` from the outer outline. Same shape with + // walls pushed inward. + const hole = new THREE.Path() + hole.moveTo(t, -t) + hole.lineTo(t, -size + t) + hole.lineTo(wBot - t, -size + t) + hole.bezierCurveTo( + wBot + size * 0.15 - t, + ogeeY, + wTop - size * 0.15 - t, + ogeeY * 0.4, + wTop - t, + -t, + ) + hole.closePath() + shape.holes.push(hole) + return shape +} + +// Half-round cross-section. The shape is approximated as a half-disc +// hanging below the eave line, with a thin lip pinned at Y=0 on each +// side so the gutter still reads as "mounted at the eave" rather than +// "floating below" it. +function buildHalfRoundCross(size: number, t: number): THREE.Shape { + const r = size // radius == size: half-circle drops `size` below eave + const segs = 24 + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + // Trace the outer semicircle from (0, 0) down through (r, -r) back to (2r, 0). + for (let i = 1; i <= segs; i++) { + const angle = Math.PI + (Math.PI * i) / segs // π → 2π (lower half) + shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle)) + } + shape.closePath() + + // Inner hole: smaller semicircle (radius r - t), same start/end. + const ri = r - t + const hole = new THREE.Path() + hole.moveTo(t, 0) + for (let i = 1; i <= segs; i++) { + const angle = Math.PI + (Math.PI * i) / segs + hole.lineTo(r + ri * Math.cos(angle), ri * Math.sin(angle)) + } + hole.closePath() + shape.holes.push(hole) + return shape +} + +// Simple square box (rectangular u-channel). Reads as commercial / +// industrial. Width equals size (deep-and-narrow ratio). +function buildBoxCross(size: number, t: number): THREE.Shape { + const w = size + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + shape.lineTo(0, -size) + shape.lineTo(w, -size) + shape.lineTo(w, 0) + shape.closePath() + + const hole = new THREE.Path() + hole.moveTo(t, -t) + hole.lineTo(t, -size + t) + hole.lineTo(w - t, -size + t) + hole.lineTo(w - t, -t) + hole.closePath() + shape.holes.push(hole) + return shape +} diff --git a/packages/nodes/src/gutter/index.ts b/packages/nodes/src/gutter/index.ts new file mode 100644 index 000000000..cf870cf3d --- /dev/null +++ b/packages/nodes/src/gutter/index.ts @@ -0,0 +1,3 @@ +export { gutterDefinition } from './definition' +export { buildGutterGeometry } from './geometry' +export { GutterNode } from './schema' diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts new file mode 100644 index 000000000..55cd2ad36 --- /dev/null +++ b/packages/nodes/src/gutter/parametrics.ts @@ -0,0 +1,33 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { GutterNode } from './schema' + +export const gutterParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Profile', + fields: [ + { + key: 'profile', + kind: 'enum', + options: ['k-style', 'half-round', 'box'], + display: 'segmented', + }, + ], + }, + { + label: 'Dimensions', + fields: [ + { key: 'length', kind: 'number', unit: 'm', min: 0.2, max: 12, step: 0.05 }, + { key: 'size', kind: 'number', unit: 'm', min: 0.05, max: 0.3, step: 0.005 }, + { + key: 'thickness', + kind: 'number', + unit: 'm', + min: 0.001, + max: 0.02, + step: 0.001, + }, + ], + }, + ], +} diff --git a/packages/nodes/src/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx new file mode 100644 index 000000000..951598115 --- /dev/null +++ b/packages/nodes/src/gutter/preview.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useEffect, useMemo } from 'react' +import * as THREE from 'three' +import { buildGutterGeometry } from './geometry' +import type { GutterNode } from './schema' + +const GutterPreview = ({ node }: { node: GutterNode }) => { + const geometry = useMemo( + () => buildGutterGeometry(node), + [node.length, node.size, node.thickness, node.profile], + ) + + const material = useMemo( + () => + new THREE.MeshStandardMaterial({ + color: 0xff_ff_ff, + emissive: 0xff_ff_ff, + emissiveIntensity: 0.12, + roughness: 0.7, + metalness: 0.2, + transparent: true, + opacity: 0.55, + depthWrite: false, + side: THREE.DoubleSide, + }), + [], + ) + + const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) + + useEffect( + () => () => { + geometry.dispose() + edgesGeometry.dispose() + material.dispose() + }, + [geometry, edgesGeometry, material], + ) + + return ( + + {}} + /> + + + + + ) +} + +export default GutterPreview diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx new file mode 100644 index 000000000..9eccd5dbe --- /dev/null +++ b/packages/nodes/src/gutter/renderer.tsx @@ -0,0 +1,123 @@ +'use client' + +import { + type AnyNodeId, + type GutterNode, + type RoofSegmentNode, + useLiveNodeOverrides, + useRegistry, + useScene, +} from '@pascal-app/core' +import { + type ColorPreset, + createMaterial, + createMaterialFromPresetRef, + createSurfaceRoleMaterial, + useNodeEvents, + useViewer, +} from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' +import { buildGutterGeometry } from './geometry' + +const defaultMaterial = new THREE.MeshStandardMaterial({ + color: 0xff_ff_ff, + roughness: 0.7, + metalness: 0.25, + side: THREE.DoubleSide, +}) + +/** + * Gutter renderer. Mounts at the eave of the host roof-segment — the + * gutter hangs level off the eave line (gravity wins; no slope tilt). + * Transform stack: + * + * segment.position → segment.rotation (Y) → gutter.position + * → gutter.rotation (Y) → mesh + * + * The registered ref sits on the inner group that applies position + + * rotation, so `NodeArrowHandles` reads gutter-mesh-local coords for + * its chevron placements (same pattern as ridge-vent). + * + * `useLiveNodeOverrides` merges in-flight handle drags onto the store + * node so the mesh tracks the drag without flushing zustand each + * frame. + */ +const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { + const ref = useRef(null!) + useRegistry(storeNode.id, 'gutter', ref) + const handlers = useNodeEvents(storeNode, 'gutter') + const shading = useViewer((s) => s.shading) + const textures = useViewer((s) => s.textures) + const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) + const sceneTheme = useViewer((s) => s.sceneTheme) + + const overrides = useLiveNodeOverrides( + (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, + ) + const node: GutterNode = overrides + ? ({ ...storeNode, ...overrides } as GutterNode) + : storeNode + + const segment = useScene((state) => + node.roofSegmentId + ? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined, + ) + + const geometry = useMemo( + () => buildGutterGeometry(node), + [node.length, node.size, node.thickness, node.profile], + ) + useEffect(() => () => geometry.dispose(), [geometry]) + + // Paint surface: explicit material wins, then preset, then the cached + // default. Same DoubleSide clone-on-mismatch dance as box-vent / + // ridge-vent — the gutter's underside is visible when the camera dips + // below the eave so FrontSide-only would carve out a hole. + const material = useMemo(() => { + if (!textures || (!node.material && !node.materialPreset)) { + return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme) + } + const base = node.material + ? createMaterial(node.material, shading) + : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) + if (base.side === THREE.DoubleSide) return base + const cloned = base.clone() + cloned.side = THREE.DoubleSide + return cloned + }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + + if (!segment) return null + + // `node.position` is segment-local — the placement tool resolves the + // eave click via `segObj.worldToLocal`. The renderer mounts under + // `roof-elements` (only the roof transform inherited), so we + // re-apply the segment's roof-local transform here. Mirrors the + // ridge-vent / box-vent pattern; without this gutters on rotated + // segments would land on the first segment instead. + const segPos = segment.position ?? [0, 0, 0] + const segRotY = segment.rotation ?? 0 + + return ( + + + + + + ) +} + +export default GutterRenderer diff --git a/packages/nodes/src/gutter/schema.ts b/packages/nodes/src/gutter/schema.ts new file mode 100644 index 000000000..537926e76 --- /dev/null +++ b/packages/nodes/src/gutter/schema.ts @@ -0,0 +1,3 @@ +// Schema lives in core (referenced by the AnyNode union). Re-export so +// every gutter-related import stays inside @pascal-app/nodes/gutter. +export { GutterNode } from '@pascal-app/core' diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx new file mode 100644 index 000000000..fea1c696b --- /dev/null +++ b/packages/nodes/src/gutter/tool.tsx @@ -0,0 +1,163 @@ +'use client' + +import { + type AnyNodeId, + emitter, + GutterNode, + type RoofEvent, + type RoofNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef, useState } from 'react' +import * as THREE from 'three' +import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { gutterDefinition } from './definition' +import GutterPreview from './preview' + +const worldPoint = new THREE.Vector3() + +/** + * Gutter placement tool. Cursor preview snaps to the eave line of the + * roof-segment under the cursor — eave = segment-local + * `(Z = +depth/2, Y = wallHeight)`. Click commits a new `GutterNode` + * parented to that segment with `position = [hitX, wallHeight, + * +depth/2]` so the back wall of the gutter sits flush against the + * fascia. + * + * X (along the eave) is taken from the cursor's segment-local X so the + * user controls where along the eave the gutter starts; the length L/R + * handles + inspector cover follow-up tweaks. + * + * Snapping is purely a placement convenience — the inspector / handles + * can move the gutter off the eave afterward if a custom run is needed. + */ +const GutterTool = () => { + const activeBuildingId = useViewer((s) => s.selection.buildingId) + const setSelection = useViewer((s) => s.setSelection) + + const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) + const [previewYaw, setPreviewYaw] = useState(0) + const lastSnapRef = useRef<[number, number] | null>(null) + + const previewNode = useMemo( + () => + GutterNode.parse({ + ...gutterDefinition.defaults(), + name: 'Gutter', + position: [0, 0, 0], + rotation: 0, + }), + [], + ) + + useEffect(() => { + if (!activeBuildingId) return + + const worldToBuildingLocal = ( + wx: number, + wy: number, + wz: number, + ): [number, number, number] => { + const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) + if (!buildingObj) return [wx, wy, wz] + worldPoint.set(wx, wy, wz) + buildingObj.worldToLocal(worldPoint) + return [worldPoint.x, worldPoint.y, worldPoint.z] + } + + const updatePreview = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return + + // Snap the cursor to the eave line of the hit segment by clamping + // localZ to +depth/2 and localY to wallHeight. Convert back to + // world via the segment's matrixWorld, then into building-local + // for the React group position. + const segObj = sceneRegistry.nodes.get(hit.segment.id) + const eaveZ = (hit.segment.depth ?? 0) / 2 + const eaveY = hit.segment.wallHeight ?? 0 + let eaveWorld: [number, number, number] + if (segObj) { + const eaveLocal = new THREE.Vector3(hit.localX, eaveY, eaveZ) + segObj.updateWorldMatrix(true, false) + eaveLocal.applyMatrix4(segObj.matrixWorld) + eaveWorld = [eaveLocal.x, eaveLocal.y, eaveLocal.z] + } else { + eaveWorld = [event.position[0], event.position[1], event.position[2]] + } + + const sx = Math.round(eaveWorld[0] * 20) / 20 + const sz = Math.round(eaveWorld[2] * 20) / 20 + const prev = lastSnapRef.current + if (!prev || prev[0] !== sx || prev[1] !== sz) { + triggerSFX('sfx:grid-snap') + lastSnapRef.current = [sx, sz] + } + + // Yaw the preview to match the segment's rotation so the + // gutter visually runs along the eave instead of holding the + // building's rotation. + setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) + setPreviewPos(worldToBuildingLocal(eaveWorld[0], eaveWorld[1], eaveWorld[2])) + event.stopPropagation() + } + + const onClick = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return + const state = useScene.getState() + + const eaveZ = (hit.segment.depth ?? 0) / 2 + const eaveY = hit.segment.wallHeight ?? 0 + + const gutter = GutterNode.parse({ + ...gutterDefinition.defaults(), + name: 'Gutter', + roofSegmentId: hit.segment.id, + // X follows the cursor's segment-local X; Y / Z snap to the eave. + position: [hit.localX, eaveY, eaveZ], + rotation: 0, + }) + state.createNode(gutter, hit.segment.id as AnyNodeId) + state.dirtyNodes.add(hit.segment.id as AnyNodeId) + setSelection({ selectedIds: [gutter.id] }) + triggerSFX('sfx:item-place') + event.stopPropagation() + } + + emitter.on('roof:move', updatePreview) + emitter.on('roof:enter', updatePreview) + emitter.on('roof:click', onClick) + + return () => { + emitter.off('roof:move', updatePreview) + emitter.off('roof:enter', updatePreview) + emitter.off('roof:click', onClick) + } + }, [activeBuildingId, setSelection]) + + if (!activeBuildingId || !previewPos) return null + + return ( + + + + + + ) +} + +export default GutterTool diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index d3c7745e1..b06f80ceb 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -9,6 +9,7 @@ import { dormerDefinition } from './dormer' import { elevatorDefinition } from './elevator' import { fenceDefinition } from './fence' import { guideDefinition } from './guide' +import { gutterDefinition } from './gutter' import { itemDefinition } from './item' import { levelDefinition } from './level' import { ridgeVentDefinition } from './ridge-vent' @@ -78,6 +79,7 @@ export const builtinPlugin: Plugin = { solarPanelDefinition as unknown as AnyNodeDefinition, skylightDefinition as unknown as AnyNodeDefinition, dormerDefinition as unknown as AnyNodeDefinition, + gutterDefinition as unknown as AnyNodeDefinition, ], } @@ -91,6 +93,7 @@ export { dormerDefinition } from './dormer' export { elevatorDefinition } from './elevator' export { fenceDefinition } from './fence' export { guideDefinition } from './guide' +export { gutterDefinition } from './gutter' export { itemDefinition } from './item' export { levelDefinition } from './level' export { ridgeVentDefinition } from './ridge-vent' From 5ef0f2c81141330d746ef993d28e3ec72253deaf Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 29 May 2026 00:27:29 +0530 Subject: [PATCH 14/35] feat(gutter): roof-panel entry, eave-edge snap, open-top U-channel, move tool UI surface area: - Roof inspector "Elements" section now lists existing gutters + an "Add Gutter" button. Adds `'gutter'` to the StructureTool union so setTool('gutter') typechecks. - Sidebar tree-node map gets a GutterTreeNode entry; selecting a gutter in the outline focuses it just like other roof accessories. - Floating action menu now shows a Move button on a selected gutter via the new `affordanceTools.move`. MoveGutterTool ghost-follows the cursor, eave-snaps on each frame, and commits to the new segment + side on click. Mirrors the ridge-vent move flow. Geometry fix: - Three cross-sections were authored as a closed outline + inset hole, which extrudes as a sealed box with a tunnel through it (top sealed). Real gutters need an OPEN top. Each profile now traces a single U-shape polygon around the channel material: outer wall down -> bottom -> outer wall up -> front rim -> inner wall down -> inner bottom -> inner wall up -> back rim. The interior of the U is empty space, not a hole inside a closed shape. Placement fix: - Snap now lands on the OUTER drip edge of the roof, not the wall line. Segment-local Z = sign * (depth/2 + overhang - 4 cm tuck), Y = wallHeight - overhang * tan(pitch) + 4 cm tuck. Sign of the cursor's localZ picks the near eave; back eave uses rotation = pi so the trough hangs outward in both directions. The 4 cm tuck offsets keep the gutter visually attached to the fascia rather than floating at the very tip of the overhang. Hook order fix (regression from the previous commit): - `NodeArrowHandlesForNode` had its new useState/useMemo hooks AFTER the `if (!portalObject ...) return null` guard. The registry-resolve useEffect flips portalObject from null to object one frame later, so the guard passed on render N+1 and three new hooks suddenly appeared in the hook list. Moved them above the early return. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/editor/node-arrow-handles.tsx | 25 +- .../panels/site-panel/gutter-tree-node.tsx | 83 ++++++ .../sidebar/panels/site-panel/tree-node.tsx | 2 + packages/editor/src/store/use-editor.tsx | 1 + packages/nodes/src/gutter/definition.ts | 3 + packages/nodes/src/gutter/geometry.ts | 120 ++++----- packages/nodes/src/gutter/move-tool.tsx | 241 ++++++++++++++++++ packages/nodes/src/gutter/tool.tsx | 101 ++++++-- packages/nodes/src/roof/panel.tsx | 46 +++- 9 files changed, 523 insertions(+), 99 deletions(-) create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx create mode 100644 packages/nodes/src/gutter/move-tool.tsx diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 8ec8acbf3..6c82ef814 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -362,16 +362,6 @@ function NodeArrowHandlesForNode({ } }) - if (!portalObject || !outerRide || (innerRideId !== null && !innerRide)) return null - - // `arrowFrame` is the Object3D used as the spatial reference for the - // per-arrow drag math — its world matrix maps node-local coords to - // world. In 'parent' mode that's the outer ride (= the node mesh - // itself). In 'grandparent' mode it's the inner ride (= the node mesh) - // because the inner group mirrors the node's local pose under the - // wall-riding outer wrapper. - const arrowFrame = innerRide ?? outerRide - // Active-drag tracking. When a handle starts dragging, it claims its // descriptor index here and snapshots the store node at drag-start. // Non-active arrows re-render against the snapshot + a freeze offset @@ -379,6 +369,11 @@ function NodeArrowHandlesForNode({ // asymmetric resize (width L/R, length L/R) doesn't visually slide the // depth / height / rotate chevrons. They stay anchored at their // pre-drag world positions for the duration of the drag. + // + // Hooks must sit ABOVE the early-return guard below — the registry- + // resolve `useEffect` flips `portalObject` from null → object after + // the first frame, so a guard between two hooks would change the + // hook count between renders and trip React's rules-of-hooks check. const [activeIndex, setActiveIndex] = useState(null) const [preDragNode, setPreDragNode] = useState(null) const dragControls = useMemo( @@ -395,6 +390,16 @@ function NodeArrowHandlesForNode({ [], ) + if (!portalObject || !outerRide || (innerRideId !== null && !innerRide)) return null + + // `arrowFrame` is the Object3D used as the spatial reference for the + // per-arrow drag math — its world matrix maps node-local coords to + // world. In 'parent' mode that's the outer ride (= the node mesh + // itself). In 'grandparent' mode it's the inner ride (= the node mesh) + // because the inner group mirrors the node's local pose under the + // wall-riding outer wrapper. + const arrowFrame = innerRide ?? outerRide + const arrows = descriptors.map((descriptor, index) => ( s.nodes[nodeId]?.visible !== false) + const node = useScene((s) => s.nodes[nodeId] as GutterNode | undefined) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const handled = handleTreeSelection( + e, + nodeId, + useViewer.getState().selection.selectedIds, + setSelection, + ) + if (!handled && useEditor.getState().phase === 'furnish') { + useEditor.getState().setPhase('structure') + } + }, + [nodeId, setSelection], + ) + + const defaultName = node?.name || 'Gutter' + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 6835a62dd..ce476de1c 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -63,6 +63,7 @@ import { DoorTreeNode } from './door-tree-node' import { DormerTreeNode } from './dormer-tree-node' import { ElevatorTreeNode } from './elevator-tree-node' import { FenceTreeNode } from './fence-tree-node' +import { GutterTreeNode } from './gutter-tree-node' import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' import { RidgeVentTreeNode } from './ridge-vent-tree-node' @@ -123,6 +124,7 @@ const treeNodeByType: Record< }>, wall: WallTreeNode, fence: FenceTreeNode, + gutter: GutterTreeNode, 'ridge-vent': RidgeVentTreeNode, roof: RoofTreeNode, stair: StairTreeNode, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 0b0d74248..7341e926e 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -76,6 +76,7 @@ export type StructureTool = | 'solar-panel' | 'skylight' | 'dormer' + | 'gutter' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index 7531f5eac..2af2d6a76 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -151,6 +151,9 @@ export const gutterDefinition: NodeDefinition = { preview: () => import('./preview'), tool: () => import('./tool'), + affordanceTools: { + move: () => import('./move-tool'), + }, toolHints: [ { key: 'Left click', label: 'Place gutter on roof eave' }, { key: 'Esc', label: 'Cancel' }, diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index 54d4594cd..be63c535f 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -62,84 +62,85 @@ export function buildGutterGeometry(node: GutterNode): THREE.BufferGeometry { return extruded } -// K-style cross-section in (X, Y) where X is the gutter's outward -// direction (positive = away from the wall) and Y is vertical (0 at -// eave line, -size at the bottom of the trough). +// Each cross-section below is authored as a single closed polygon that +// traces the U-channel's MATERIAL — outer wall down → bottom → outer +// wall up → rim across → inner wall down → inner bottom → inner wall +// up → closing rim. The interior of the U (where rainwater sits) is +// empty space, not a hole — so the extrude has an OPEN TOP, which is +// what makes the geometry read as a gutter rather than a sealed box +// with a tunnel through it. // -// Outer outline traces an ogee fascia: -// top-back (0, 0) → -// bottom-back (0, -size) → -// bottom-front (w_bot, -size) → -// front mid (w_top, -size + size*0.35) — curve outward and up -// top-front (w_top, 0) -// then a hollow (the water channel) is carved as a Path hole offset by -// `t` from each face. Closing the top of the outer outline keeps the -// extrude solid; the lid disappears against the open channel because -// the hole runs through the entire extrusion. +// Cross-section authoring frame: X is the gutter's outward direction +// (X=0 against the fascia, X=+w hanging outward); Y is vertical (Y=0 +// at the eave line, Y=-size at the bottom of the trough). After the +// rotateY(-π/2) in the parent builder, +X maps to segment-outward (+Z) +// and the extrude axis (length) maps to segment-+X. + function buildKStyleCross(size: number, t: number): THREE.Shape { - const wBot = size * 0.8 // bottom width — narrower than the rim + const wBot = size * 0.8 // bottom outer width — narrower than the rim const wTop = size * 0.95 - const ogeeY = -size * 0.65 // S-curve inflection + const ogeeY = -size * 0.65 // S-curve inflection on the fascia const shape = new THREE.Shape() + // Outer trace — top-back → down the back → across the bottom → up + // the ogee fascia. shape.moveTo(0, 0) - shape.lineTo(0, -size) // back, straight down - shape.lineTo(wBot, -size) // bottom - // Bezier the front fascia: bottom-front → ogee inflection → top-front. + shape.lineTo(0, -size) + shape.lineTo(wBot, -size) shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0) - shape.closePath() - - // Inner hole, offset by `t` from the outer outline. Same shape with - // walls pushed inward. - const hole = new THREE.Path() - hole.moveTo(t, -t) - hole.lineTo(t, -size + t) - hole.lineTo(wBot - t, -size + t) - hole.bezierCurveTo( - wBot + size * 0.15 - t, - ogeeY, + // Front rim (thin top of the front wall): step inward by `t`. + shape.lineTo(wTop - t, 0) + // Inner trace — back down the ogee, across the inner bottom, up the + // inner back wall. Same bezier control points pushed inward by `t`. + shape.bezierCurveTo( wTop - size * 0.15 - t, ogeeY * 0.4, - wTop - t, - -t, + wBot + size * 0.15 - t, + ogeeY, + wBot - t, + -size + t, ) - hole.closePath() - shape.holes.push(hole) + shape.lineTo(t, -size + t) + shape.lineTo(t, 0) + // closePath draws the back rim (t, 0) → (0, 0) — the thin top of + // the back wall, sealing the cross-section. + shape.closePath() return shape } -// Half-round cross-section. The shape is approximated as a half-disc -// hanging below the eave line, with a thin lip pinned at Y=0 on each -// side so the gutter still reads as "mounted at the eave" rather than -// "floating below" it. +// Half-round trough — a semicircular cross-section with a smaller +// concentric semicircle carved out. Single closed trace: outer half +// from (0,0) sweeping down and back up to (2r, 0), front rim across by +// `t`, inner half from (2r-t, 0) sweeping back to (t, 0), back rim +// closes the loop. function buildHalfRoundCross(size: number, t: number): THREE.Shape { - const r = size // radius == size: half-circle drops `size` below eave + const r = size // radius == size: half-circle drops `size` below the eave + const ri = r - t // inner radius const segs = 24 const shape = new THREE.Shape() shape.moveTo(0, 0) - // Trace the outer semicircle from (0, 0) down through (r, -r) back to (2r, 0). + // Outer semicircle, lower half (angle π → 2π). At i=0 we'd be at + // (0,0) — already there from moveTo — so start at i=1. for (let i = 1; i <= segs; i++) { - const angle = Math.PI + (Math.PI * i) / segs // π → 2π (lower half) + const angle = Math.PI + (Math.PI * i) / segs shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle)) } - shape.closePath() - - // Inner hole: smaller semicircle (radius r - t), same start/end. - const ri = r - t - const hole = new THREE.Path() - hole.moveTo(t, 0) + // Front rim — step inward by `t` to start the inner trace. + shape.lineTo(2 * r - t, 0) + // Inner semicircle, traced BACK toward the back wall (angle 2π → π). for (let i = 1; i <= segs; i++) { - const angle = Math.PI + (Math.PI * i) / segs - hole.lineTo(r + ri * Math.cos(angle), ri * Math.sin(angle)) + const angle = 2 * Math.PI - (Math.PI * i) / segs + shape.lineTo(r + ri * Math.cos(angle), ri * Math.sin(angle)) } - hole.closePath() - shape.holes.push(hole) + // closePath draws (t, 0) → (0, 0) — back rim. + shape.closePath() return shape } -// Simple square box (rectangular u-channel). Reads as commercial / -// industrial. Width equals size (deep-and-narrow ratio). +// Square / rectangular box U-channel. Width equals size (deep-and- +// narrow ratio reads as commercial). Traced as outer rect → front rim +// → inner rect (reverse) → back rim. function buildBoxCross(size: number, t: number): THREE.Shape { const w = size @@ -148,14 +149,13 @@ function buildBoxCross(size: number, t: number): THREE.Shape { shape.lineTo(0, -size) shape.lineTo(w, -size) shape.lineTo(w, 0) + // Front rim. + shape.lineTo(w - t, 0) + // Inner rect, reversed so the polygon doesn't self-intersect. + shape.lineTo(w - t, -size + t) + shape.lineTo(t, -size + t) + shape.lineTo(t, 0) + // closePath draws the back rim (t, 0) → (0, 0). shape.closePath() - - const hole = new THREE.Path() - hole.moveTo(t, -t) - hole.lineTo(t, -size + t) - hole.lineTo(w - t, -size + t) - hole.lineTo(w - t, -t) - hole.closePath() - shape.holes.push(hole) return shape } diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx new file mode 100644 index 000000000..bcd6dd4b9 --- /dev/null +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -0,0 +1,241 @@ +'use client' + +import { + type AnyNodeId, + emitter, + type GutterNode, + type RoofEvent, + type RoofNode, + type RoofSegmentNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useState } from 'react' +import * as THREE from 'three' +import { resolveRoofSegmentHit } from '../roof/segment-hit' +import GutterPreview from './preview' + +// Keep these in sync with the placement tool (`./tool.tsx`). Real +// gutters mount on the fascia (slightly inside the drip edge) with +// the rim at the deck-top line; the offsets nudge the bare-slope snap +// to read as "attached to fascia" rather than "floating at the very +// tip of the overhang". +const EAVE_TUCK_INWARD = 0.04 +const EAVE_TUCK_UP = 0.04 + +function resolveEaveSnap(segment: RoofSegmentNode, localZ: number) { + const halfD = (segment.depth ?? 0) / 2 + const overhang = segment.overhang ?? 0 + const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 + const sign = localZ < 0 ? -1 : 1 + const eaveZ = sign * Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD) + const eaveY = (segment.wallHeight ?? 0) - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP + const rotation = sign > 0 ? 0 : Math.PI + return { eaveZ, eaveY, rotation } +} + +/** + * Gutter move tool. Mirrors the ridge-vent move flow — ghost follows + * the cursor over any roof segment, click commits the new position + + * parent segment in one undoable step. The eave-snap math from the + * placement tool runs again on the new segment so the gutter lands on + * the correct side of the new ridge. + * + * On commit the gutter rotation may flip from 0 ↔ π if the user moves + * it from the front eave to the back eave (or vice versa). The + * pre-drag rotation is restored on cancel. + */ +export default function MoveGutterTool({ node }: { node: GutterNode }) { + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) + const [previewYaw, setPreviewYaw] = useState(0) + + useEffect(() => { + useScene.temporal.getState().pause() + + const original = { + position: [...node.position] as [number, number, number], + rotation: node.rotation ?? 0, + roofSegmentId: node.roofSegmentId, + parentId: node.parentId, + metadata: node.metadata, + } + const meta = + typeof node.metadata === 'object' && node.metadata !== null + ? (node.metadata as Record) + : {} + const isNew = !!meta.isNew + + const gutterObj = sceneRegistry.nodes.get(node.id) + if (gutterObj) gutterObj.visible = false + + const worldToBuildingLocal = ( + wx: number, + wy: number, + wz: number, + ): [number, number, number] => { + const buildingId = useViewer.getState().selection.buildingId + const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + if (!buildingObj) return [wx, wy, wz] + const v = new THREE.Vector3(wx, wy, wz) + buildingObj.worldToLocal(v) + return [v.x, v.y, v.z] + } + + let lastSnap: [number, number] | null = null + + const updatePreview = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return + + // Eave-snap to the segment's near drip edge — same math as the + // placement tool so picking-up + putting-down lands in the + // same place. + const snap = resolveEaveSnap(hit.segment, hit.localZ) + const segObj = sceneRegistry.nodes.get(hit.segment.id) + let eaveWorld: [number, number, number] + if (segObj) { + const eaveLocal = new THREE.Vector3(hit.localX, snap.eaveY, snap.eaveZ) + segObj.updateWorldMatrix(true, false) + eaveLocal.applyMatrix4(segObj.matrixWorld) + eaveWorld = [eaveLocal.x, eaveLocal.y, eaveLocal.z] + } else { + eaveWorld = [event.position[0], event.position[1], event.position[2]] + } + + const sx = Math.round(eaveWorld[0] * 20) / 20 + const sz = Math.round(eaveWorld[2] * 20) / 20 + if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { + triggerSFX('sfx:grid-snap') + lastSnap = [sx, sz] + } + + setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation) + setPreviewPos(worldToBuildingLocal(eaveWorld[0], eaveWorld[1], eaveWorld[2])) + event.stopPropagation() + } + + const onRoofClick = (event: RoofEvent) => { + const hit = resolveRoofSegmentHit( + event.node as RoofNode, + event.position[0], + event.position[1], + event.position[2], + ) + if (!hit) return + const targetSegmentId = hit.segment.id as AnyNodeId + const snap = resolveEaveSnap(hit.segment, hit.localZ) + const st = useScene.getState() + + const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined + if (prevSegmentId && prevSegmentId !== targetSegmentId) { + const oldSeg = st.nodes[prevSegmentId] as RoofSegmentNode | undefined + if (oldSeg) { + st.updateNode(prevSegmentId, { + children: (oldSeg.children ?? []).filter((id) => id !== node.id), + }) + } + const newSeg = st.nodes[targetSegmentId] as RoofSegmentNode | undefined + if (newSeg && !(newSeg.children ?? []).includes(node.id)) { + st.updateNode(targetSegmentId, { + children: [...(newSeg.children ?? []), node.id], + }) + } + st.dirtyNodes.add(prevSegmentId) + } + + useScene.temporal.getState().resume() + st.updateNode(node.id as AnyNodeId, { + roofSegmentId: targetSegmentId, + parentId: targetSegmentId, + position: [hit.localX, snap.eaveY, snap.eaveZ], + rotation: snap.rotation, + visible: true, + metadata: {}, + }) + useScene.temporal.getState().pause() + + st.dirtyNodes.add(targetSegmentId) + st.dirtyNodes.add(node.id as AnyNodeId) + + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + + triggerSFX('sfx:item-place') + exitMoveMode() + event.stopPropagation() + } + + const onCancel = () => { + if (isNew) { + const parentId = original.roofSegmentId as AnyNodeId | undefined + if (parentId) { + const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined + if (parent) { + useScene.getState().updateNode(parentId, { + children: (parent.children ?? []).filter((id) => id !== node.id), + }) + } + } + useScene.getState().deleteNode(node.id as AnyNodeId) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + return + } + + useScene.getState().updateNode(node.id as AnyNodeId, { + position: original.position, + rotation: original.rotation, + roofSegmentId: original.roofSegmentId as AnyNodeId | undefined, + parentId: original.parentId as AnyNodeId | undefined, + metadata: original.metadata, + }) + if (original.roofSegmentId) { + useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId) + } + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('roof:move', updatePreview) + emitter.on('roof:enter', updatePreview) + emitter.on('roof:click', onRoofClick) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('roof:move', updatePreview) + emitter.off('roof:enter', updatePreview) + emitter.off('roof:click', onRoofClick) + emitter.off('tool:cancel', onCancel) + + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + useScene.temporal.getState().resume() + } + }, [exitMoveMode, node]) + + if (!previewPos) return null + + return ( + + + + + + ) +} diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index fea1c696b..df640b725 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -6,6 +6,7 @@ import { GutterNode, type RoofEvent, type RoofNode, + type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' @@ -20,19 +21,30 @@ import GutterPreview from './preview' const worldPoint = new THREE.Vector3() /** - * Gutter placement tool. Cursor preview snaps to the eave line of the - * roof-segment under the cursor — eave = segment-local - * `(Z = +depth/2, Y = wallHeight)`. Click commits a new `GutterNode` - * parented to that segment with `position = [hitX, wallHeight, - * +depth/2]` so the back wall of the gutter sits flush against the - * fascia. + * Gutter placement tool. Cursor preview snaps to the OUTER eave — the + * drip edge of the roof, NOT the wall line. The eave sits at + * `Z = ±(depth/2 + overhang)` in segment-local frame; the gutter + * mounts against the fascia there, hanging outward from the building. * - * X (along the eave) is taken from the cursor's segment-local X so the - * user controls where along the eave the gutter starts; the length L/R - * handles + inspector cover follow-up tweaks. + * Which eave: the sign of the cursor's segment-local Z picks the near + * eave. `+Z` eave uses rotation=0 (length runs along +X, outward + * along +Z). `-Z` eave uses rotation=π so the gutter's local +Z + * (outward) maps to world -Z and the trough hangs away from the + * building on the back slope too. * - * Snapping is purely a placement convenience — the inspector / handles - * can move the gutter off the eave afterward if a custom run is needed. + * Eave Y: the slope keeps descending past the wall edge by the + * overhang span. For a slope of `pitch` radians, the slope drops + * `overhang * tan(pitch)` between the wall edge (Z = ±depth/2, + * Y = wallHeight) and the drip edge (Z = ±(depth/2 + overhang)). + * Same formula gives the right answer for gable / hip / shed in the + * common case — primary slope is the eave slope. + * + * X (along the eave) follows the cursor's segment-local X so the + * user controls where along the eave the gutter starts; the length + * L/R handles + inspector cover follow-up tweaks. + * + * Snapping is purely a placement convenience — the inspector / + * handles can move the gutter off the eave afterward. */ const GutterTool = () => { const activeBuildingId = useViewer((s) => s.selection.buildingId) @@ -68,6 +80,44 @@ const GutterTool = () => { return [worldPoint.x, worldPoint.y, worldPoint.z] } + // Small tuck-in offsets, in metres. Real gutters don't mount at + // the bare drip edge — they hook onto the fascia, which sits + // slightly inside the deck's outer end, and the rim aligns with + // the deck-top rather than the slope-surface-at-drip-edge. These + // small constants nudge the snap so the gutter reads as "attached + // to the fascia" rather than "floating at the very tip of the + // overhang". Tuned by feel — make them larger if the gutter still + // looks too low / outboard on your scenes. + const EAVE_TUCK_INWARD = 0.04 // shift inward (toward wall) by 4 cm + const EAVE_TUCK_UP = 0.04 // shift upward by 4 cm + + // Pure helper — given a hit, return the snapped segment-local + // (Z, Y) of the drip edge + the gutter's body-Y rotation, picking + // ±Z based on which side of the ridge the cursor is on. Pulled + // out so both preview and click paths stay in sync. + const resolveEaveSnap = (segment: RoofSegmentNode, localZ: number) => { + const halfD = (segment.depth ?? 0) / 2 + const overhang = segment.overhang ?? 0 + const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 + // Sign of the cursor's local Z picks the near eave. `localZ = 0` + // (ridge) falls to the +Z side — both are valid; the user will + // refine via the inspector if they want the back eave. + const sign = localZ < 0 ? -1 : 1 + // Drip edge is `depth/2 + overhang`; pull inward by EAVE_TUCK_INWARD + // so the gutter sits against the fascia, not the outer corner. + const eaveZ = sign * Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD) + // Slope keeps descending past the wall edge by the overhang + // span. tan(pitch) is the slope-direction-per-Z; the drop is + // negative because the eave is the LOW end of the slope. EAVE_TUCK_UP + // raises the rim back toward the deck-top line. + const eaveY = + (segment.wallHeight ?? 0) - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP + // Back eave needs a π yaw so the gutter's local +Z (outward) + // maps to world -Z (away from the back wall). + const rotation = sign > 0 ? 0 : Math.PI + return { eaveZ, eaveY, rotation } + } + const updatePreview = (event: RoofEvent) => { const hit = resolveRoofSegmentHit( event.node as RoofNode, @@ -77,16 +127,11 @@ const GutterTool = () => { ) if (!hit) return - // Snap the cursor to the eave line of the hit segment by clamping - // localZ to +depth/2 and localY to wallHeight. Convert back to - // world via the segment's matrixWorld, then into building-local - // for the React group position. + const snap = resolveEaveSnap(hit.segment, hit.localZ) const segObj = sceneRegistry.nodes.get(hit.segment.id) - const eaveZ = (hit.segment.depth ?? 0) / 2 - const eaveY = hit.segment.wallHeight ?? 0 let eaveWorld: [number, number, number] if (segObj) { - const eaveLocal = new THREE.Vector3(hit.localX, eaveY, eaveZ) + const eaveLocal = new THREE.Vector3(hit.localX, snap.eaveY, snap.eaveZ) segObj.updateWorldMatrix(true, false) eaveLocal.applyMatrix4(segObj.matrixWorld) eaveWorld = [eaveLocal.x, eaveLocal.y, eaveLocal.z] @@ -102,10 +147,10 @@ const GutterTool = () => { lastSnapRef.current = [sx, sz] } - // Yaw the preview to match the segment's rotation so the - // gutter visually runs along the eave instead of holding the - // building's rotation. - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) + // Yaw the preview to match the segment's rotation + the gutter's + // own back-eave flip, so the trough visually hangs outward on + // whichever eave the cursor is closer to. + setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation) setPreviewPos(worldToBuildingLocal(eaveWorld[0], eaveWorld[1], eaveWorld[2])) event.stopPropagation() } @@ -119,17 +164,17 @@ const GutterTool = () => { ) if (!hit) return const state = useScene.getState() - - const eaveZ = (hit.segment.depth ?? 0) / 2 - const eaveY = hit.segment.wallHeight ?? 0 + const snap = resolveEaveSnap(hit.segment, hit.localZ) const gutter = GutterNode.parse({ ...gutterDefinition.defaults(), name: 'Gutter', roofSegmentId: hit.segment.id, - // X follows the cursor's segment-local X; Y / Z snap to the eave. - position: [hit.localX, eaveY, eaveZ], - rotation: 0, + // X follows the cursor's segment-local X; Y / Z snap to the + // drip edge of the closer eave; rotation flips for the back + // eave so the trough hangs outward in both cases. + position: [hit.localX, snap.eaveY, snap.eaveZ], + rotation: snap.rotation, }) state.createNode(gutter, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index 9dadc262b..d8f27b4c4 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -6,6 +6,7 @@ import { type BoxVentNode, type ChimneyNode, type DormerNode, + type GutterNode, type RidgeVentNode, type RoofNode, type RoofSegmentNode, @@ -112,6 +113,19 @@ export default function RoofPanel() { }), ) + const gutters = useScene( + useShallow((s) => { + if (segmentIdSet.size === 0) return [] + const out: GutterNode[] = [] + for (const n of Object.values(s.nodes)) { + if (n?.type === 'gutter' && n.roofSegmentId && segmentIdSet.has(n.roofSegmentId)) { + out.push(n as GutterNode) + } + } + return out + }), + ) + // Box vents and ridge vents share the "Vents" UI group — same list, // type shown as the right-side label, and an `Add Vent` button with // a Box/Ridge segmented picker. @@ -207,7 +221,16 @@ export default function RoofPanel() { // Same code path as the top palette — see `tool-manager.tsx:28`'s // `nodeRegistry.get(tool)?.tool` dispatch. const activateTool = useCallback( - (kind: 'box-vent' | 'ridge-vent' | 'chimney' | 'solar-panel' | 'skylight' | 'dormer') => { + ( + kind: + | 'box-vent' + | 'ridge-vent' + | 'chimney' + | 'solar-panel' + | 'skylight' + | 'dormer' + | 'gutter', + ) => { triggerSFX('sfx:item-pick') useEditor.getState().setTool(kind) if (useEditor.getState().mode !== 'build') { @@ -441,6 +464,27 @@ export default function RoofPanel() { /> + +
+ {gutters.map((gutter, i) => ( + + ))} + + } + label="Add Gutter" + onClick={() => activateTool('gutter')} + /> + +
From e8937f8963adf83259ac09f174ccf9de0367443d Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 29 May 2026 10:57:16 +0530 Subject: [PATCH 15/35] fix(viewer/nodes): drop DoubleSide NodeMaterial MRT landmines across slab, vents, gutter, window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DoubleSide on any NodeMaterial inside the MRT scenePass makes WebGPU compile a back-face shader variant whose fragment outputs don't cover every MRT target — the validator rejects the pipeline and poisons the render context with "Color target has no corresponding fragment stage output but writeMask is not zero", manifesting as renderPipeline_NNN invalid on scene open. The pattern was already documented at materials.ts:77 and fixed for glazing in 9400f1c5, but several roof / floor renderers still requested DoubleSide on `createSurfaceRoleMaterial` (which returns a `MeshLambertNodeMaterial`) and on user-supplied materials (which may also be NodeMaterials): - slab/geometry.ts — fired on every untextured floor; the live culprit on scene reload after the gutter renderer was switched to FrontSide. - gutter/renderer.tsx — the U-channel cross-section is traced as a single closed polygon around the material, so ExtrudeGeometry already produces outward-facing normals on every visible face; DoubleSide was speculative. - box-vent / ridge-vent renderers — DoubleSide was deliberate (to keep back faces of thin extrudes visible from below); now a known visual tradeoff. Build the geometry as a closed solid in `geometry.ts` if the underside-view becomes noticeable; do not bring DoubleSide back. - viewer/lib/materials.ts `DEFAULT_WINDOW_MATERIAL` — same fix on the fallback window material. Local `defaultMaterial` constants in box-vent / ridge-vent / gutter also lose their `side: DoubleSide` for consistency (those are `MeshStandardMaterial`, hit only when a preset ref fails to resolve, but the same landmine pattern). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/nodes/src/box-vent/renderer.tsx | 22 ++++++++------------ packages/nodes/src/gutter/renderer.tsx | 22 +++++++++++--------- packages/nodes/src/ridge-vent/renderer.tsx | 24 ++++++++-------------- packages/nodes/src/slab/geometry.ts | 13 +++++++++--- packages/viewer/src/lib/materials.ts | 7 ++++++- 5 files changed, 45 insertions(+), 43 deletions(-) diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index 94b421946..56e424451 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -25,7 +25,6 @@ const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, roughness: 0.85, metalness: 0.1, - side: THREE.DoubleSide, }) /** @@ -108,24 +107,19 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { }, [segment, node.position[0], node.position[2]]) // Paint surface: explicit material wins, then preset, then the cached - // default. Mirrors the slab / stair / wall pattern. Preset materials - // come from the shared cache with `side: FrontSide`; clone + force - // DoubleSide locally so back faces of the vent body / hood don't drop - // out when the camera looks up at the eaves. + // default. 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 material = useMemo(() => { - // Untextured box vent (and textures-off mode) takes the themed 'roof' - // role colour. Request DoubleSide directly so the cached role material - // is the right side — no clone/mutation of a shared material. if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme) + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } - const base = node.material + return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - if (base.side === THREE.DoubleSide) return base - const cloned = base.clone() - cloned.side = THREE.DoubleSide - return cloned }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) // Compose slope tilt + yaw onto a single quaternion so the registered diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index 9eccd5dbe..c146bac9c 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -24,7 +24,6 @@ const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, roughness: 0.7, metalness: 0.25, - side: THREE.DoubleSide, }) /** @@ -72,20 +71,23 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { useEffect(() => () => geometry.dispose(), [geometry]) // Paint surface: explicit material wins, then preset, then the cached - // default. Same DoubleSide clone-on-mismatch dance as box-vent / - // ridge-vent — the gutter's underside is visible when the camera dips - // below the eave so FrontSide-only would carve out a hole. + // default. FrontSide everywhere — DoubleSide on any NodeMaterial inside + // the MRT scenePass compiles a back-face shader variant that doesn't + // declare outputs for every MRT target and poisons the render context + // (see `materials.ts` line 77, and the glazing FrontSide fix in + // 9400f1c5). The U-channel cross-section in `geometry.ts` is traced as + // a single closed polygon around the material — both the exterior shell + // and the interior trough walls are part of the same outward-wound + // boundary, so ExtrudeGeometry produces outward-facing normals on every + // visible face. FrontSide is therefore sufficient and DoubleSide is not + // needed. const material = useMemo(() => { if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme) + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } - const base = node.material + return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - if (base.side === THREE.DoubleSide) return base - const cloned = base.clone() - cloned.side = THREE.DoubleSide - return cloned }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) if (!segment) return null diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 73e5c78e0..f1699ad6b 100644 --- a/packages/nodes/src/ridge-vent/renderer.tsx +++ b/packages/nodes/src/ridge-vent/renderer.tsx @@ -29,7 +29,6 @@ const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, roughness: 0.85, metalness: 0.1, - side: THREE.DoubleSide, }) /** @@ -79,25 +78,20 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { useEffect(() => () => geometry.dispose(), [geometry]) - // The preset cache returns materials with `side: FrontSide` (that's - // what the preset payload encodes). For a thin extruded ridge cap that - // makes the underside disappear when the camera dips below the eaves - // — so clone the resolved material and force `DoubleSide` locally - // without mutating the shared cache entry. + // Paint surface: 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 the underside of the thin extruded ridge cap stayed visible from + // below; that's now a known visual tradeoff — building the cap as a + // closed solid in `geometry.ts` is the right fix if the underside-view + // becomes noticeable. const material = useMemo(() => { - // Untextured ridge vent (and textures-off mode) takes the themed - // 'roof' role colour. Request DoubleSide directly so the cached role - // material is the right side — no clone/mutation of a shared material. if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme) + return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) } - const base = node.material + return node.material ? createMaterial(node.material, shading) : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - if (base.side === THREE.DoubleSide) return base - const cloned = base.clone() - cloned.side = THREE.DoubleSide - return cloned }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) if (!segment) return null diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 32ae0631b..dd1a68080 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -9,7 +9,7 @@ import { generateSlabGeometry, type RenderShading, } from '@pascal-app/viewer' -import { DoubleSide, Group, type Material, Mesh, type Texture } from 'three' +import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' /** * Stage B builder for slab. Reuses `generateSlabGeometry` (pure @@ -40,8 +40,12 @@ function getSlabMaterial( // Untextured slabs (and everything in textures-off mode) take the themed // 'floor' role colour. createSurfaceRoleMaterial returns a shared cached // material, so it is returned as-is without the mutation below. + // FrontSide — DoubleSide on the role material's NodeMaterial poisons the + // MRT scene pass (see `materials.ts` line 77 / glazing fix 9400f1c5). + // Slab side faces still render correctly because `generateSlabGeometry` + // produces outward-facing normals on the top, bottom, and perimeter. if (!textures || (!node.materialPreset && !node.material)) { - return createSurfaceRoleMaterial('floor', colorPreset, DoubleSide, sceneTheme) + return createSurfaceRoleMaterial('floor', colorPreset, FrontSide, sceneTheme) } const cacheKey = JSON.stringify({ @@ -67,7 +71,10 @@ function getSlabMaterial( slabMaterial.transparent = false slabMaterial.opacity = 1 slabMaterial.alphaMap = null - slabMaterial.side = DoubleSide + // FrontSide — user-supplied materials may be NodeMaterials, and DoubleSide + // on any NodeMaterial in the MRT scene pass poisons the render context + // (see `materials.ts` line 77 / glazing fix 9400f1c5). + slabMaterial.side = FrontSide slabMaterial.depthWrite = true slabMaterial.needsUpdate = true diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index b5b36529f..9feadd5fa 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -577,11 +577,16 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH const cached = defaultMaterialCache.get(cacheKey) if (cached) return cached + // DoubleSide on a NodeMaterial inside the MRT scene pass compiles a back-face + // pipeline variant whose fragment outputs don't cover every MRT target — the + // validator rejects it and poisons the render context (see the note above + // `glassMaterial`). FrontSide; flip the consumer's back-face group 180° if a + // back face is actually visible. const params = { color: '#87ceeb', opacity: 0.3, transparent: true, - side: THREE.DoubleSide, + side: THREE.FrontSide, } const material = shading === 'solid' From a5b2a4f81fe4a720208f8f45bbc6d18478580d59 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 29 May 2026 10:57:20 +0530 Subject: [PATCH 16/35] =?UTF-8?q?feat(editor):=20Shift-snap=20rotate=20giz?= =?UTF-8?q?mos=20to=2015=C2=B0=20increments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding Shift while dragging a whole-node rotation arrow now snaps the delta to π/12 (15°) steps. Scoped to `descriptor.shape === 'rotate'` so curved-stair sweep handles keep their continuous feel. --- .../editor/src/components/editor/node-arrow-handles.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 6c82ef814..16377267f 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -1073,6 +1073,14 @@ function ArcArrow({ while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI + // Shift snaps whole-node rotation gizmos (stair, elevator, column…) to + // 15° increments. Scoped to `shape: 'rotate'` so curved-stair sweep + // handles keep their continuous feel. + if (e.shiftKey && descriptor.shape === 'rotate') { + const step = Math.PI / 12 + delta = Math.round(delta / step) * step + } + const patch = descriptor.apply(initialNode as never, delta, sceneApi) lastPatch = patch as Partial useLiveNodeOverrides.getState().set(nodeId, patch as Record) From 3426e94b567ef9f6c59e5c2a3c63907e746a19e7 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 29 May 2026 10:57:25 +0530 Subject: [PATCH 17/35] feat(nodes): stair railings track live segment-drag overrides `StairRailings` was reading each child segment from zustand only, so width/length/height drag handles (which publish to `useLiveNodeOverrides` and only flush on release) left the railing frozen at the pre-drag values until release. Subscribe to the override map and merge each child's override onto its zustand snapshot so the railing rebuilds every frame during the drag. --- packages/nodes/src/stair/renderer.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx index 9457c6206..f8a535b6d 100644 --- a/packages/nodes/src/stair/renderer.tsx +++ b/packages/nodes/src/stair/renderer.tsx @@ -155,16 +155,26 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { function StairRailings({ stair, material }: { stair: StairNode; material: THREE.Material }) { const nodes = useScene((state) => state.nodes) + // Stair segments' width/length/height arrow handles publish drag values to + // `useLiveNodeOverrides` and only commit to zustand on release. Subscribing + // here and merging each child segment's override means the railing tracks + // the drag in real time instead of freezing at the pre-drag values. + const overrides = useLiveNodeOverrides((s) => s.overrides) const segments = useMemo( () => (stair.children ?? []) - .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) + .map((childId) => { + const base = nodes[childId as AnyNodeId] as StairSegmentNode | undefined + if (!base) return undefined + const override = overrides.get(childId as AnyNodeId) + return (override ? { ...base, ...override } : base) as StairSegmentNode + }) .filter( (node): node is StairSegmentNode => node?.type === 'stair-segment' && node.visible !== false, ), - [nodes, stair.children], + [nodes, overrides, stair.children], ) const railPaths = useMemo( From b81ffe014e07d0772299e0292fa8c2f9459da893 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 29 May 2026 10:57:30 +0530 Subject: [PATCH 18/35] chore(ifc-converter): next-env routes path moves under .next/dev/types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-generated next-env.d.ts update from the local Next.js dev server — the routes type now lives under `.next/dev/types/routes.d.ts` rather than `.next/types/routes.d.ts`. --- apps/ifc-converter/next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 7d324c7be454a3b5de90ed8443f357e7649dafeb Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 29 May 2026 13:04:08 +0530 Subject: [PATCH 19/35] =?UTF-8?q?fix(gutter):=20roofType-aware=20eave=20sn?= =?UTF-8?q?ap=20=E2=80=94=204-way=20on=20hip/flat,=20low=20side=20on=20she?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gutter place + move tools were hard-coded to snap to ±Z eaves, working for gable / gambrel / mansard / dutch but missing: - Hip / flat: 4 eaves, not 2. Clicks on the side slopes (±X eaves) collapsed back onto ±Z, so users couldn't place a gutter on a hip's side eave at all. - Shed: only one real eave (the low side at +Z). Clicking on the high wall side used to snap to -Z, which is the rake / high end with no fascia to hang from. New shared `eave-snap.ts` module: - `resolveEaveSnap(segment, localX, localZ)` returns `{ eaveX, eaveY, eaveZ, rotation, side }`. - Hip / flat picker uses `max(|lx|/halfW, |lz|/halfD)` — same discriminator `analyticalSurfaceY` uses for hip — to pick which of the four slopes the cursor is on, then signs +/-. - ±X eaves rotate the gutter ±π/2 so its outward axis points away from the building. - Shed always returns +Z (the low side). - Gable / gambrel / mansard / dutch unchanged (±Z). Both tools collapsed their duplicated tuck constants + inlined resolver — the "keep these in sync" comment became a landmine once the resolver grew non-trivial. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/nodes/src/gutter/eave-snap.ts | 141 ++++++++++++++++++++++++ packages/nodes/src/gutter/move-tool.tsx | 33 ++---- packages/nodes/src/gutter/tool.tsx | 55 ++------- 3 files changed, 159 insertions(+), 70 deletions(-) create mode 100644 packages/nodes/src/gutter/eave-snap.ts diff --git a/packages/nodes/src/gutter/eave-snap.ts b/packages/nodes/src/gutter/eave-snap.ts new file mode 100644 index 000000000..296ad67d4 --- /dev/null +++ b/packages/nodes/src/gutter/eave-snap.ts @@ -0,0 +1,141 @@ +import type { RoofSegmentNode, RoofType } from '@pascal-app/core' + +/** + * Shared eave-snap math for the gutter's placement + move tools. + * + * `resolveEaveSnap` finds the drip edge of the eave closest to a + * cursor hit in segment-local coords. It supports every roof type the + * segment renderer can produce; the difference vs the original + * `±Z`-only resolver is hip/flat awareness (4-way eave instead of 2) + * and shed's single low eave. + * + * Why this lives outside the tools: the two tool files used to inline + * an identical copy of the resolver + the tuck constants, with a + * "keep in sync" comment that becomes a landmine as soon as the + * resolver grows non-trivial. Hip's 4-way picker pushed it past that + * threshold. + */ + +// Real gutters mount on the fascia (slightly inside the drip edge), +// with the rim at the deck-top line rather than the slope-surface-at- +// drip-edge. These tuck the snap so the gutter reads as "attached to +// the fascia" rather than "floating at the very tip of the overhang". +// Tuned by feel — bump them up if the gutter looks too low / outboard. +export const EAVE_TUCK_INWARD = 0.04 +export const EAVE_TUCK_UP = 0.04 + +export type EaveSide = '+X' | '-X' | '+Z' | '-Z' + +export type EaveSnap = { + /** Segment-local X of the snapped gutter position. */ + eaveX: number + /** Segment-local Y of the snapped gutter position (drip-edge Y). */ + eaveY: number + /** Segment-local Z of the snapped gutter position. */ + eaveZ: number + /** + * Gutter's segment-local Y rotation: orients gutter's outward axis + * (+Z local) toward the side picked. Length axis (+X local) falls + * out along the eave direction (±X or ±Z depending on the side). + */ + rotation: number + /** Which side of the segment the snap landed on. */ + side: EaveSide +} + +/** + * Pick which of the segment's eaves is closest to the cursor. + * + * - `shed`: low side only. The segment-hit's analytical surface for + * a shed has `t = (lz + depth/2)/depth`, so the eave is at +Z + * regardless of which side the cursor is on — clicking on the high + * side still rolls the gutter down to the low eave. + * + * - `hip` / `flat`: 4-way. 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 + * picks +/-. + * + * - `gable` / `gambrel` / `mansard` / `dutch`: 2-way `±Z`. Mansard + * and dutch have real 4-side eaves in plan, but the segment-hit + * formula approximates them as 2-slope (depth-only), so we stay + * consistent here — the user can re-place the gutter manually on + * a side eave if mansard/dutch becomes important. + */ +function pickEaveSide( + roofType: RoofType, + localX: number, + localZ: number, + halfW: number, + halfD: number, +): EaveSide { + if (roofType === 'shed') return '+Z' + + if (roofType === 'hip' || roofType === 'flat') { + 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' + return localZ < 0 ? '-Z' : '+Z' + } + + return localZ < 0 ? '-Z' : '+Z' +} + +export function resolveEaveSnap( + segment: RoofSegmentNode, + localX: number, + localZ: number, +): EaveSnap { + const halfW = (segment.width ?? 0) / 2 + const halfD = (segment.depth ?? 0) / 2 + const overhang = segment.overhang ?? 0 + const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 + + // The slope keeps descending past the wall edge by the overhang + // span; same drop on every eave (pitch is the segment-wide primary + // slope). EAVE_TUCK_UP raises the rim back toward the deck-top line. + const eaveY = (segment.wallHeight ?? 0) - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP + + const side = pickEaveSide(segment.roofType ?? 'gable', localX, localZ, halfW, halfD) + + // For `±Z` eaves the eave runs along ±X so the parallel axis stays + // free (snapped to cursor's X), and Z pins to the drip edge. `±X` + // eaves swap which axis is free vs pinned. Rotation aligns the + // gutter's outward (+Z local) with the picked side; length (+X + // local) then falls along the eave. + switch (side) { + case '+Z': + return { + eaveX: localX, + eaveY, + eaveZ: Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD), + rotation: 0, + side, + } + case '-Z': + return { + eaveX: localX, + eaveY, + eaveZ: -Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD), + rotation: Math.PI, + side, + } + case '+X': + return { + eaveX: Math.max(halfW, halfW + overhang - EAVE_TUCK_INWARD), + eaveY, + eaveZ: localZ, + rotation: Math.PI / 2, + side, + } + case '-X': + return { + eaveX: -Math.max(halfW, halfW + overhang - EAVE_TUCK_INWARD), + eaveY, + eaveZ: localZ, + rotation: -Math.PI / 2, + side, + } + } +} diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx index bcd6dd4b9..dc5419ff9 100644 --- a/packages/nodes/src/gutter/move-tool.tsx +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -15,27 +15,9 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveEaveSnap } from './eave-snap' import GutterPreview from './preview' -// Keep these in sync with the placement tool (`./tool.tsx`). Real -// gutters mount on the fascia (slightly inside the drip edge) with -// the rim at the deck-top line; the offsets nudge the bare-slope snap -// to read as "attached to fascia" rather than "floating at the very -// tip of the overhang". -const EAVE_TUCK_INWARD = 0.04 -const EAVE_TUCK_UP = 0.04 - -function resolveEaveSnap(segment: RoofSegmentNode, localZ: number) { - const halfD = (segment.depth ?? 0) / 2 - const overhang = segment.overhang ?? 0 - const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 - const sign = localZ < 0 ? -1 : 1 - const eaveZ = sign * Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD) - const eaveY = (segment.wallHeight ?? 0) - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP - const rotation = sign > 0 ? 0 : Math.PI - return { eaveZ, eaveY, rotation } -} - /** * Gutter move tool. Mirrors the ridge-vent move flow — ghost follows * the cursor over any roof segment, click commits the new position + @@ -100,12 +82,15 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { // Eave-snap to the segment's near drip edge — same math as the // placement tool so picking-up + putting-down lands in the - // same place. - const snap = resolveEaveSnap(hit.segment, hit.localZ) + // same place. The resolver is roofType-aware: hip/flat picks + // ±X or ±Z based on which slope the cursor is on; shed always + // snaps to its low (+Z) eave; gable / gambrel / mansard / dutch + // stay on ±Z. + const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) const segObj = sceneRegistry.nodes.get(hit.segment.id) let eaveWorld: [number, number, number] if (segObj) { - const eaveLocal = new THREE.Vector3(hit.localX, snap.eaveY, snap.eaveZ) + const eaveLocal = new THREE.Vector3(snap.eaveX, snap.eaveY, snap.eaveZ) segObj.updateWorldMatrix(true, false) eaveLocal.applyMatrix4(segObj.matrixWorld) eaveWorld = [eaveLocal.x, eaveLocal.y, eaveLocal.z] @@ -134,7 +119,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { ) if (!hit) return const targetSegmentId = hit.segment.id as AnyNodeId - const snap = resolveEaveSnap(hit.segment, hit.localZ) + const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) const st = useScene.getState() const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined @@ -158,7 +143,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [hit.localX, snap.eaveY, snap.eaveZ], + position: [snap.eaveX, snap.eaveY, snap.eaveZ], rotation: snap.rotation, visible: true, metadata: {}, diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index df640b725..64751d4f1 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -6,7 +6,6 @@ import { GutterNode, type RoofEvent, type RoofNode, - type RoofSegmentNode, sceneRegistry, useScene, } from '@pascal-app/core' @@ -16,6 +15,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { resolveRoofSegmentHit } from '../roof/segment-hit' import { gutterDefinition } from './definition' +import { resolveEaveSnap } from './eave-snap' import GutterPreview from './preview' const worldPoint = new THREE.Vector3() @@ -80,44 +80,6 @@ const GutterTool = () => { return [worldPoint.x, worldPoint.y, worldPoint.z] } - // Small tuck-in offsets, in metres. Real gutters don't mount at - // the bare drip edge — they hook onto the fascia, which sits - // slightly inside the deck's outer end, and the rim aligns with - // the deck-top rather than the slope-surface-at-drip-edge. These - // small constants nudge the snap so the gutter reads as "attached - // to the fascia" rather than "floating at the very tip of the - // overhang". Tuned by feel — make them larger if the gutter still - // looks too low / outboard on your scenes. - const EAVE_TUCK_INWARD = 0.04 // shift inward (toward wall) by 4 cm - const EAVE_TUCK_UP = 0.04 // shift upward by 4 cm - - // Pure helper — given a hit, return the snapped segment-local - // (Z, Y) of the drip edge + the gutter's body-Y rotation, picking - // ±Z based on which side of the ridge the cursor is on. Pulled - // out so both preview and click paths stay in sync. - const resolveEaveSnap = (segment: RoofSegmentNode, localZ: number) => { - const halfD = (segment.depth ?? 0) / 2 - const overhang = segment.overhang ?? 0 - const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 - // Sign of the cursor's local Z picks the near eave. `localZ = 0` - // (ridge) falls to the +Z side — both are valid; the user will - // refine via the inspector if they want the back eave. - const sign = localZ < 0 ? -1 : 1 - // Drip edge is `depth/2 + overhang`; pull inward by EAVE_TUCK_INWARD - // so the gutter sits against the fascia, not the outer corner. - const eaveZ = sign * Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD) - // Slope keeps descending past the wall edge by the overhang - // span. tan(pitch) is the slope-direction-per-Z; the drop is - // negative because the eave is the LOW end of the slope. EAVE_TUCK_UP - // raises the rim back toward the deck-top line. - const eaveY = - (segment.wallHeight ?? 0) - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP - // Back eave needs a π yaw so the gutter's local +Z (outward) - // maps to world -Z (away from the back wall). - const rotation = sign > 0 ? 0 : Math.PI - return { eaveZ, eaveY, rotation } - } - const updatePreview = (event: RoofEvent) => { const hit = resolveRoofSegmentHit( event.node as RoofNode, @@ -127,11 +89,11 @@ const GutterTool = () => { ) if (!hit) return - const snap = resolveEaveSnap(hit.segment, hit.localZ) + const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) const segObj = sceneRegistry.nodes.get(hit.segment.id) let eaveWorld: [number, number, number] if (segObj) { - const eaveLocal = new THREE.Vector3(hit.localX, snap.eaveY, snap.eaveZ) + const eaveLocal = new THREE.Vector3(snap.eaveX, snap.eaveY, snap.eaveZ) segObj.updateWorldMatrix(true, false) eaveLocal.applyMatrix4(segObj.matrixWorld) eaveWorld = [eaveLocal.x, eaveLocal.y, eaveLocal.z] @@ -164,16 +126,17 @@ const GutterTool = () => { ) if (!hit) return const state = useScene.getState() - const snap = resolveEaveSnap(hit.segment, hit.localZ) + const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) const gutter = GutterNode.parse({ ...gutterDefinition.defaults(), name: 'Gutter', roofSegmentId: hit.segment.id, - // X follows the cursor's segment-local X; Y / Z snap to the - // drip edge of the closer eave; rotation flips for the back - // eave so the trough hangs outward in both cases. - position: [hit.localX, snap.eaveY, snap.eaveZ], + // (X, Y, Z) all come from the eave snap — on ±Z eaves X stays + // free along the cursor; on ±X eaves Z stays free instead. + // Rotation orients the gutter's outward axis away from the + // building on whichever side the click landed. + position: [snap.eaveX, snap.eaveY, snap.eaveZ], rotation: snap.rotation, }) state.createNode(gutter, hit.segment.id as AnyNodeId) From 2cd3a18baf91a53539762ef901dce3d49d8f2d59 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 30 May 2026 12:59:01 +0530 Subject: [PATCH 20/35] feat(gutter): end caps + corner mitre + ghost-placed parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ghost/move previews mirror the GutterRenderer transform chain (roof → segment → snap) and use FrontSide. Removes drift between the placement ghost and the gutter that lands on click. - New endCapLeft / endCapRight booleans (default true) slice a solid-outer plug into the extrusion at each enabled end. Inspector exposes both toggles; caps subtract from node.length so the user-set span is preserved. - corner-mitre.ts detects sibling gutters meeting within 5 cm on the same segment and returns per-end mitre angles. The end-face skew holds back walls at the inner corner while front rims extend to the outer eave intersection; cap on a mitred end is force- suppressed so the L-junction stays open. Renderer pulls siblings via useShallow. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/schema/nodes/gutter.ts | 8 + packages/nodes/src/gutter/corner-mitre.ts | 134 +++++++++++++++ packages/nodes/src/gutter/geometry.ts | 197 +++++++++++++++++++--- packages/nodes/src/gutter/move-tool.tsx | 79 +++++---- packages/nodes/src/gutter/parametrics.ts | 7 + packages/nodes/src/gutter/preview.tsx | 30 +++- packages/nodes/src/gutter/renderer.tsx | 46 ++++- packages/nodes/src/gutter/tool.tsx | 106 ++++++------ 8 files changed, 477 insertions(+), 130 deletions(-) create mode 100644 packages/nodes/src/gutter/corner-mitre.ts diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts index f04b5c285..d2e23f383 100644 --- a/packages/core/src/schema/nodes/gutter.ts +++ b/packages/core/src/schema/nodes/gutter.ts @@ -37,6 +37,13 @@ export const GutterNode = BaseNode.extend({ thickness: z.number().default(0.006), profile: z.enum(['k-style', 'half-round', 'box']).default('k-style'), + + // End caps close the open ends of the U-channel so water can't run + // out the sides. Independent per-end because a downspout typically + // joins the gutter at one end while the other stays capped. Default + // true on both — matches a freshly-installed residential gutter. + endCapLeft: z.boolean().default(true), + endCapRight: z.boolean().default(true), }).describe( dedent` Gutter — a rain-water channel running along the eave of a roof @@ -44,6 +51,7 @@ export const GutterNode = BaseNode.extend({ - length: span along the eave (gutter-local +X) - size: profile drop below the eave line (vertical extent) - profile: k-style (ogee fascia), half-round, or square box + - endCapLeft / endCapRight: close the trough at gutter-local -X / +X `, ) diff --git a/packages/nodes/src/gutter/corner-mitre.ts b/packages/nodes/src/gutter/corner-mitre.ts new file mode 100644 index 000000000..ea10b84fc --- /dev/null +++ b/packages/nodes/src/gutter/corner-mitre.ts @@ -0,0 +1,134 @@ +import type { GutterNode } from '@pascal-app/core' + +/** + * Auto-mitre detector for two gutters meeting at a roof corner. + * + * When two gutters' endpoints land within `CORNER_EPSILON` of each + * other in segment-local space, the renderer treats them as a single + * L-junction and skews each end so the back walls meet at the inner + * corner while the front rims extend outward to a clean mitre. + * + * Why "back wall stays at the corner": the gutter mounts against the + * fascia (gutter-local +X is the length, +Z is outward over the eave). + * Two perpendicular fascias meet at the eave corner — that's the + * fixed point. The rims hang in space past the building, so they're + * the parts that need to extend to actually touch each other. + * + * For 90° (typical hip / rectangular plan) corners the mitre is 45° + * each side; arbitrary angles use the standard mitre formula + * `(π − interior) / 2`. Aligned gutters (interior ≈ π) → mitre 0 → no + * displacement, no cap suppression — they read as a straight run. + * + * Same-segment only in v1: hip roofs have all four eaves on one + * segment, so this covers the headline use-case. Cross-segment corners + * (e.g. gable + hip on adjacent sub-roofs) need parent-frame transform + * work; deferred. + */ + +export type GutterMitres = { + /** Mitre angle (radians) at the gutter's −X end; 0 = no mitre. */ + left: number + /** Mitre angle (radians) at the gutter's +X end; 0 = no mitre. */ + right: number +} + +export const NO_MITRES: GutterMitres = { left: 0, right: 0 } + +// 5 cm slack: the user is dragging endpoints by eye; eave snap is on a +// 5 cm grid, so anything closer than that reads as "they meant to +// meet" rather than "they're near each other". +const CORNER_EPSILON = 0.05 +const CORNER_EPSILON_SQ = CORNER_EPSILON * CORNER_EPSILON + +// Mitres beyond this are unphysical (an acute outer corner past 30° +// interior angle isn't a building corner, it's a CSG artefact). Capping +// keeps a misplaced gutter from producing a runaway skew that swallows +// the rest of the trough. +const MAX_MITRE = (75 * Math.PI) / 180 + +type Endpoint = { + pos: readonly [number, number, number] + /** Length-axis direction in segment frame, pointing from this end toward the other end. */ + awayDir: readonly [number, number] +} + +function gutterEndpoints(g: GutterNode): { plus: Endpoint; minus: Endpoint } { + const [px, py, pz] = g.position + const r = g.rotation ?? 0 + // Gutter-local +X (length axis) rotated by `r` around Y. THREE's + // rotation-y convention: local (1, 0, 0) → (cos r, 0, −sin r). + const dirX = Math.cos(r) + const dirZ = -Math.sin(r) + const half = g.length / 2 + return { + plus: { + pos: [px + dirX * half, py, pz + dirZ * half], + // From the +X endpoint, the rest of the gutter extends back + // toward the −X end — so "away from this end" is −dir. + awayDir: [-dirX, -dirZ], + }, + minus: { + pos: [px - dirX * half, py, pz - dirZ * half], + awayDir: [dirX, dirZ], + }, + } +} + +function distSq(a: readonly [number, number, number], b: readonly [number, number, number]): number { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + const dz = a[2] - b[2] + return dx * dx + dy * dy + dz * dz +} + +function mitreBetween(a: Endpoint, b: Endpoint): number { + // Both `awayDir`s point from the corner toward the FAR end of their + // gutter. The interior angle of the joint is the angle between them. + // Mitre = half the supplementary angle (standard carpenter formula). + const dot = a.awayDir[0] * b.awayDir[0] + a.awayDir[1] * b.awayDir[1] + const clamped = Math.max(-1, Math.min(1, dot)) + const interior = Math.acos(clamped) + const mitre = (Math.PI - interior) / 2 + // Aligned-or-nearly so → straight run, no mitre needed. + if (mitre < 1e-3) return 0 + return Math.min(mitre, MAX_MITRE) +} + +/** + * Compute mitres for `subject` against every other gutter under the + * same parent. Walks each endpoint pair (subject's +X / −X × sibling's + * +X / −X), keeps the first match within `CORNER_EPSILON`. Two corners + * on the same end (rare — would require three gutters meeting at one + * point) keep the first match found; order is the caller's siblings + * order, so the result is deterministic. + */ +export function computeGutterMitres(subject: GutterNode, siblings: readonly GutterNode[]): GutterMitres { + if (siblings.length === 0) return NO_MITRES + + const subj = gutterEndpoints(subject) + let leftMitre = 0 + let rightMitre = 0 + + for (const sib of siblings) { + if (sib.id === subject.id) continue + const other = gutterEndpoints(sib) + + if (leftMitre === 0) { + if (distSq(subj.minus.pos, other.plus.pos) <= CORNER_EPSILON_SQ) { + leftMitre = mitreBetween(subj.minus, other.plus) + } else if (distSq(subj.minus.pos, other.minus.pos) <= CORNER_EPSILON_SQ) { + leftMitre = mitreBetween(subj.minus, other.minus) + } + } + if (rightMitre === 0) { + if (distSq(subj.plus.pos, other.plus.pos) <= CORNER_EPSILON_SQ) { + rightMitre = mitreBetween(subj.plus, other.plus) + } else if (distSq(subj.plus.pos, other.minus.pos) <= CORNER_EPSILON_SQ) { + rightMitre = mitreBetween(subj.plus, other.minus) + } + } + if (leftMitre !== 0 && rightMitre !== 0) break + } + + return { left: leftMitre, right: rightMitre } +} diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index be63c535f..187a82f51 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -1,5 +1,7 @@ import type { GutterNode } from '@pascal-app/core' import * as THREE from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { type GutterMitres, NO_MITRES } from './corner-mitre' /** * Pure builder for the gutter mesh. The gutter is a hollow trough that @@ -19,47 +21,143 @@ import * as THREE from 'three' * downward (negative Y) by `size`. +Z is "away from the building" — * positive Z is the outer face that hangs over the eave. * + * End caps: when `endCapLeft` / `endCapRight` is true, the matching + * end gets a thin SOLID slice (depth = wall thickness) instead of the + * hollow U-channel. The solid slice's end face closes the trough so + * water can't run out the side. Caps subtract from the user-set + * `length` so the gutter's total span stays constant — capping doesn't + * silently grow the geometry past what the inspector reads. + * + * Corner mitres: when a sibling gutter meets this gutter at a roof + * corner, the corner-mitre detector passes a mitre angle for the + * affected end. The end-face vertices are skewed (back wall held in + * place, front rim extended outward) so two perpendicular gutters' + * front rims meet at the outer eave intersection. A mitred end's cap + * is force-suppressed — capping a corner would wall off the L. + * * Pure: no React, no scene access, no store mutation. */ -export function buildGutterGeometry(node: GutterNode): THREE.BufferGeometry { +export function buildGutterGeometry( + node: GutterNode, + mitres: GutterMitres = NO_MITRES, +): THREE.BufferGeometry { const len = Math.max(0.05, node.length) const size = Math.max(0.04, node.size) const t = Math.min(Math.max(0.001, node.thickness), size * 0.4) - let cross: THREE.Shape + const capLeft = (node.endCapLeft ?? true) && mitres.left === 0 + const capRight = (node.endCapRight ?? true) && mitres.right === 0 + + // Reserve cap slices at each capped end. Each cap is `t` thick + // (matches the wall thickness — a real end cap is a stamped plate + // welded onto the gutter). Clamp so a tiny gutter doesn't end up + // all-cap-no-channel. + const reserved = (capLeft ? t : 0) + (capRight ? t : 0) + const channelLen = Math.max(len * 0.1, len - reserved) + const totalCap = len - channelLen + const capLeftLen = capLeft ? (capRight ? totalCap / 2 : totalCap) : 0 + const capRightLen = capRight ? (capLeft ? totalCap / 2 : totalCap) : 0 + + let channelCross: THREE.Shape + let capCross: THREE.Shape if (node.profile === 'half-round') { - cross = buildHalfRoundCross(size, t) + channelCross = buildHalfRoundCross(size, t) + capCross = buildHalfRoundOuterOnly(size) } else if (node.profile === 'box') { - cross = buildBoxCross(size, t) + channelCross = buildBoxCross(size, t) + capCross = buildBoxOuterOnly(size) } else { - cross = buildKStyleCross(size, t) + channelCross = buildKStyleCross(size, t) + capCross = buildKStyleOuterOnly(size) } - // Extrude the cross-section along the gutter's local +X. The Shapes - // above are authored with cross-X going from 0 (outer rim) to +w - // (back, against the fascia). After extrude (which produces mesh-X = - // cross-X, mesh-Y = cross-Y, mesh-Z = extrusion axis = length), we - // rotateY(-π/2) so the LENGTH lands along mesh-+X and the OUTWARD - // direction lands along mesh-+Z (segment-local +Z is the downslope / - // outward direction at the eave). The two-line combination is: - // - // rotateY(-π/2): mesh-X (outward) → +Z, mesh-Z (length) → -X - // translate(+len/2, 0, 0): recenter the now-negative - // length span around X = 0 - // - // The renderer mounts this in segment-local frame with no extra - // rotation, so the gutter naturally aligns with the eave when - // `node.rotation = 0`. - const extruded = new THREE.ExtrudeGeometry(cross, { - depth: len, + // Each extrude below uses the same orient-and-recenter recipe: + // ExtrudeGeometry produces (mesh-X = cross-X, mesh-Y = cross-Y, + // mesh-Z = extrusion axis); we rotateY(-π/2) so the LENGTH lands + // along mesh-+X and the OUTWARD direction lands along mesh-+Z, then + // translate so the piece sits in its slot of the gutter's overall + // [-len/2, +len/2] span. Z_cs = 0 maps to mesh-+X (right end); + // Z_cs = depth maps to mesh--X (left end). + const pieces: THREE.BufferGeometry[] = [] + + const channel = new THREE.ExtrudeGeometry(channelCross, { + depth: channelLen, bevelEnabled: false, curveSegments: 16, steps: 1, }) - extruded.rotateY(-Math.PI / 2) - extruded.translate(len / 2, 0, 0) - extruded.computeVertexNormals() - return extruded + // Apply the corner-mitre skew while we're still in the source frame. + // Source axes (pre-rotation): X_cs = outward, Y_cs = vertical, + // Z_cs = length (0 at right end of mesh, `channelLen` at left end). + // After rotateY(-π/2): mesh-X = -Z_cs, mesh-Z = X_cs. + // + // OUTER-corner mitre rule: the back wall (X_cs = 0) stays at the + // original end (mesh-X = ±len/2); the front rim (X_cs = +outward) + // extends further along the gutter's length so it can reach the + // outer eave intersection of the L. In mesh coords: + // right end: Δmesh-X = +mesh-Z · tan(mitreRight) + // left end: Δmesh-X = −mesh-Z · tan(mitreLeft) + // Mapped back to source coords (Δmesh-X = −ΔZ_cs, mesh-Z = X_cs): + // right end (Z_cs = 0): new Z_cs = −X_cs · tan(mitreRight) + // left end (Z_cs = channelLen): new Z_cs = channelLen + X_cs · tan(mitreLeft) + if (mitres.right > 0 || mitres.left > 0) { + const tanRight = Math.tan(mitres.right) + const tanLeft = Math.tan(mitres.left) + const eps = 1e-5 + const pos = channel.attributes.position! + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i) + const z = pos.getZ(i) + if (mitres.right > 0 && Math.abs(z) < eps) { + pos.setZ(i, -x * tanRight) + } else if (mitres.left > 0 && Math.abs(z - channelLen) < eps) { + pos.setZ(i, channelLen + x * tanLeft) + } + } + pos.needsUpdate = true + channel.computeVertexNormals() + } + channel.rotateY(-Math.PI / 2) + // Channel spans [-len/2 + capLeftLen, +len/2 - capRightLen]: shift + // the recentered extrude so its right end butts against the right cap. + channel.translate(len / 2 - capRightLen, 0, 0) + pieces.push(channel) + + if (capLeft) { + const leftCap = new THREE.ExtrudeGeometry(capCross, { + depth: capLeftLen, + bevelEnabled: false, + curveSegments: 16, + steps: 1, + }) + leftCap.rotateY(-Math.PI / 2) + // Left cap spans [-len/2, -len/2 + capLeftLen]: translate by + // -len/2 + capLeftLen so Z_cs=0 (mesh-+X end of the cap slice) + // sits at -len/2 + capLeftLen and Z_cs=depth at -len/2. + leftCap.translate(-len / 2 + capLeftLen, 0, 0) + pieces.push(leftCap) + } + + if (capRight) { + const rightCap = new THREE.ExtrudeGeometry(capCross, { + depth: capRightLen, + bevelEnabled: false, + curveSegments: 16, + steps: 1, + }) + rightCap.rotateY(-Math.PI / 2) + // Right cap spans [+len/2 - capRightLen, +len/2]. + rightCap.translate(len / 2, 0, 0) + pieces.push(rightCap) + } + + const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!) + // Free the intermediate pieces when merge returned a new geometry. + if (merged !== pieces[0]) { + for (const p of pieces) p.dispose() + } + merged.computeVertexNormals() + return merged } // Each cross-section below is authored as a single closed polygon that @@ -159,3 +257,50 @@ function buildBoxCross(size: number, t: number): THREE.Shape { shape.closePath() return shape } + +// Solid-outer outlines used for the end-cap slices: same outer +// boundary as the channel cross-sections above but without the inner +// trough carved out, so the extruded slice is a solid plug that closes +// the open end of the trough. + +function buildKStyleOuterOnly(size: number): THREE.Shape { + const wBot = size * 0.8 + const wTop = size * 0.95 + const ogeeY = -size * 0.65 + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + shape.lineTo(0, -size) + shape.lineTo(wBot, -size) + shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0) + // closePath draws (wTop, 0) → (0, 0) — the cap's rim line across + // the top of the gutter cross-section. + shape.closePath() + return shape +} + +function buildHalfRoundOuterOnly(size: number): THREE.Shape { + const r = size + const segs = 24 + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + for (let i = 1; i <= segs; i++) { + const angle = Math.PI + (Math.PI * i) / segs + shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle)) + } + shape.closePath() + return shape +} + +function buildBoxOuterOnly(size: number): THREE.Shape { + const w = size + + const shape = new THREE.Shape() + shape.moveTo(0, 0) + shape.lineTo(0, -size) + shape.lineTo(w, -size) + shape.lineTo(w, 0) + shape.closePath() + return shape +} diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx index dc5419ff9..8b2a91343 100644 --- a/packages/nodes/src/gutter/move-tool.tsx +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -11,13 +11,17 @@ import { useScene, } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' -import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' -import * as THREE from 'three' import { resolveRoofSegmentHit } from '../roof/segment-hit' -import { resolveEaveSnap } from './eave-snap' +import { type EaveSnap, resolveEaveSnap } from './eave-snap' import GutterPreview from './preview' +type PreviewTarget = { + roof: { position: [number, number, number]; rotation: number } + segment: { position: [number, number, number]; rotation: number } + snap: EaveSnap +} + /** * Gutter move tool. Mirrors the ridge-vent move flow — ghost follows * the cursor over any roof segment, click commits the new position + @@ -28,14 +32,18 @@ import GutterPreview from './preview' * On commit the gutter rotation may flip from 0 ↔ π if the user moves * it from the front eave to the back eave (or vice versa). The * pre-drag rotation is restored on cancel. + * + * Ghost transform: mirrors the GutterRenderer chain (roof → segment → + * snap), so the cursor preview lands at the exact world coords the + * commit will store. GutterPreview applies no internal rotation, so + * the gutter's CURRENT `rotation` doesn't bleed into the new snap. */ export default function MoveGutterTool({ node }: { node: GutterNode }) { const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) - const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) - const [previewYaw, setPreviewYaw] = useState(0) + const [target, setTarget] = useState(null) useEffect(() => { useScene.temporal.getState().pause() @@ -56,57 +64,43 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { const gutterObj = sceneRegistry.nodes.get(node.id) if (gutterObj) gutterObj.visible = false - const worldToBuildingLocal = ( - wx: number, - wy: number, - wz: number, - ): [number, number, number] => { - const buildingId = useViewer.getState().selection.buildingId - const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null - if (!buildingObj) return [wx, wy, wz] - const v = new THREE.Vector3(wx, wy, wz) - buildingObj.worldToLocal(v) - return [v.x, v.y, v.z] - } - let lastSnap: [number, number] | null = null const updatePreview = (event: RoofEvent) => { + const roof = event.node as RoofNode const hit = resolveRoofSegmentHit( - event.node as RoofNode, + roof, event.position[0], event.position[1], event.position[2], ) if (!hit) return - // Eave-snap to the segment's near drip edge — same math as the - // placement tool so picking-up + putting-down lands in the - // same place. The resolver is roofType-aware: hip/flat picks + // Same snap math as the placement tool — picking-up and putting- + // down round-trip identically. roofType-aware: hip/flat picks // ±X or ±Z based on which slope the cursor is on; shed always // snaps to its low (+Z) eave; gable / gambrel / mansard / dutch // stay on ±Z. const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) - const segObj = sceneRegistry.nodes.get(hit.segment.id) - let eaveWorld: [number, number, number] - if (segObj) { - const eaveLocal = new THREE.Vector3(snap.eaveX, snap.eaveY, snap.eaveZ) - segObj.updateWorldMatrix(true, false) - eaveLocal.applyMatrix4(segObj.matrixWorld) - eaveWorld = [eaveLocal.x, eaveLocal.y, eaveLocal.z] - } else { - eaveWorld = [event.position[0], event.position[1], event.position[2]] - } - const sx = Math.round(eaveWorld[0] * 20) / 20 - const sz = Math.round(eaveWorld[2] * 20) / 20 + const sx = Math.round(snap.eaveX * 20) / 20 + const sz = Math.round(snap.eaveZ * 20) / 20 if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnap = [sx, sz] } - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation) - setPreviewPos(worldToBuildingLocal(eaveWorld[0], eaveWorld[1], eaveWorld[2])) + setTarget({ + roof: { + position: (roof.position ?? [0, 0, 0]) as [number, number, number], + rotation: roof.rotation ?? 0, + }, + segment: { + position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number], + rotation: hit.segment.rotation ?? 0, + }, + snap, + }) event.stopPropagation() } @@ -214,12 +208,17 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) { } }, [exitMoveMode, node]) - if (!previewPos) return null + if (!target) return null return ( - - - + + + + + ) diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts index 55cd2ad36..dc6891268 100644 --- a/packages/nodes/src/gutter/parametrics.ts +++ b/packages/nodes/src/gutter/parametrics.ts @@ -29,5 +29,12 @@ export const gutterParametrics: ParametricDescriptor = { }, ], }, + { + label: 'End caps', + fields: [ + { key: 'endCapLeft', kind: 'boolean' }, + { key: 'endCapRight', kind: 'boolean' }, + ], + }, ], } diff --git a/packages/nodes/src/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx index 951598115..c9e6615e1 100644 --- a/packages/nodes/src/gutter/preview.tsx +++ b/packages/nodes/src/gutter/preview.tsx @@ -5,10 +5,32 @@ import * as THREE from 'three' import { buildGutterGeometry } from './geometry' import type { GutterNode } from './schema' +/** + * Translucent ghost of a gutter — built from the same `buildGutterGeometry` + * the renderer commits, so the shape on screen during placement is the + * shape that lands on click. + * + * No internal transform wrapper. Callers (placement tool + move tool) + * mirror the GutterRenderer's transform chain around this component + * (roof → segment → snap), so the ghost shares one bulletproof chain + * with the committed mesh instead of a flattened-yaw shortcut that can + * drift in edge cases. + * + * FrontSide matches the renderer; DoubleSide would render the inside + * of the trough walls and visually thicken the ghost relative to the + * placed gutter. + */ const GutterPreview = ({ node }: { node: GutterNode }) => { const geometry = useMemo( () => buildGutterGeometry(node), - [node.length, node.size, node.thickness, node.profile], + [ + node.length, + node.size, + node.thickness, + node.profile, + node.endCapLeft, + node.endCapRight, + ], ) const material = useMemo( @@ -22,7 +44,7 @@ const GutterPreview = ({ node }: { node: GutterNode }) => { transparent: true, opacity: 0.55, depthWrite: false, - side: THREE.DoubleSide, + side: THREE.FrontSide, }), [], ) @@ -39,7 +61,7 @@ const GutterPreview = ({ node }: { node: GutterNode }) => { ) return ( - + <> { - + ) } diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index c146bac9c..9e026aeb7 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -18,6 +18,8 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' +import { useShallow } from 'zustand/react/shallow' +import { computeGutterMitres } from './corner-mitre' import { buildGutterGeometry } from './geometry' const defaultMaterial = new THREE.MeshStandardMaterial({ @@ -64,9 +66,49 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { : undefined, ) + // Same-segment sibling gutters drive the corner-mitre detector. Pull + // them as a fresh array each store update; `useShallow` keeps the + // reference stable when the array contents haven't changed, so the + // mitres useMemo below only re-runs when a sibling actually moves. + const siblingGutters = useScene( + useShallow((state) => { + const segmentId = node.roofSegmentId as AnyNodeId | undefined + if (!segmentId) return [] as GutterNode[] + const seg = state.nodes[segmentId] as RoofSegmentNode | undefined + if (!seg) return [] + const out: GutterNode[] = [] + for (const id of seg.children ?? []) { + const n = state.nodes[id as AnyNodeId] + if (n?.type === 'gutter' && n.id !== storeNode.id) out.push(n as GutterNode) + } + return out + }), + ) + + const mitres = useMemo( + () => computeGutterMitres(node, siblingGutters), + [ + node.position[0], + node.position[1], + node.position[2], + node.rotation, + node.length, + siblingGutters, + ], + ) + const geometry = useMemo( - () => buildGutterGeometry(node), - [node.length, node.size, node.thickness, node.profile], + () => buildGutterGeometry(node, mitres), + [ + node.length, + node.size, + node.thickness, + node.profile, + node.endCapLeft, + node.endCapRight, + mitres.left, + mitres.right, + ], ) useEffect(() => () => geometry.dispose(), [geometry]) diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index 64751d4f1..c72046395 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -6,19 +6,21 @@ import { GutterNode, type RoofEvent, type RoofNode, - sceneRegistry, useScene, } from '@pascal-app/core' import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' -import * as THREE from 'three' import { resolveRoofSegmentHit } from '../roof/segment-hit' import { gutterDefinition } from './definition' -import { resolveEaveSnap } from './eave-snap' +import { type EaveSnap, resolveEaveSnap } from './eave-snap' import GutterPreview from './preview' -const worldPoint = new THREE.Vector3() +type PreviewTarget = { + roof: { position: [number, number, number]; rotation: number } + segment: { position: [number, number, number]; rotation: number } + snap: EaveSnap +} /** * Gutter placement tool. Cursor preview snaps to the OUTER eave — the @@ -26,32 +28,27 @@ const worldPoint = new THREE.Vector3() * `Z = ±(depth/2 + overhang)` in segment-local frame; the gutter * mounts against the fascia there, hanging outward from the building. * - * Which eave: the sign of the cursor's segment-local Z picks the near - * eave. `+Z` eave uses rotation=0 (length runs along +X, outward - * along +Z). `-Z` eave uses rotation=π so the gutter's local +Z - * (outward) maps to world -Z and the trough hangs away from the - * building on the back slope too. - * - * Eave Y: the slope keeps descending past the wall edge by the - * overhang span. For a slope of `pitch` radians, the slope drops - * `overhang * tan(pitch)` between the wall edge (Z = ±depth/2, - * Y = wallHeight) and the drip edge (Z = ±(depth/2 + overhang)). - * Same formula gives the right answer for gable / hip / shed in the - * common case — primary slope is the eave slope. + * Which eave: `eave-snap.ts` picks the side closest to the cursor + * (roof-type aware — 4-way for hip/flat, low side only for shed, ±Z + * for the rest) and returns the segment-local snap pose. The same + * snap drives the ghost AND the commit, so picking-up + putting-down + * land at identical world coordinates. * - * X (along the eave) follows the cursor's segment-local X so the - * user controls where along the eave the gutter starts; the length - * L/R handles + inspector cover follow-up tweaks. - * - * Snapping is purely a placement convenience — the inspector / - * handles can move the gutter off the eave afterward. + * Ghost transform: we mount the GutterPreview under the exact same + * chain the GutterRenderer applies — roof.position + roof.rotation → + * segment.position + segment.rotation → snap.eave + snap.rotation. + * No `worldToBuildingLocal` + `previewYaw`-sum shortcut: that + * collapses three Y rotations into one scalar and converts world + * coords back into building-local, which is mathematically + * equivalent for pure-Y stacks but drifts under any future non-Y + * roof/segment transform. Sharing the renderer's chain means the + * ghost and the placed mesh are guaranteed pixel-identical. */ const GutterTool = () => { const activeBuildingId = useViewer((s) => s.selection.buildingId) const setSelection = useViewer((s) => s.setSelection) - const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null) - const [previewYaw, setPreviewYaw] = useState(0) + const [target, setTarget] = useState(null) const lastSnapRef = useRef<[number, number] | null>(null) const previewNode = useMemo( @@ -68,21 +65,10 @@ const GutterTool = () => { useEffect(() => { if (!activeBuildingId) return - const worldToBuildingLocal = ( - wx: number, - wy: number, - wz: number, - ): [number, number, number] => { - const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) - if (!buildingObj) return [wx, wy, wz] - worldPoint.set(wx, wy, wz) - buildingObj.worldToLocal(worldPoint) - return [worldPoint.x, worldPoint.y, worldPoint.z] - } - const updatePreview = (event: RoofEvent) => { + const roof = event.node as RoofNode const hit = resolveRoofSegmentHit( - event.node as RoofNode, + roof, event.position[0], event.position[1], event.position[2], @@ -90,30 +76,29 @@ const GutterTool = () => { if (!hit) return const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ) - const segObj = sceneRegistry.nodes.get(hit.segment.id) - let eaveWorld: [number, number, number] - if (segObj) { - const eaveLocal = new THREE.Vector3(snap.eaveX, snap.eaveY, snap.eaveZ) - segObj.updateWorldMatrix(true, false) - eaveLocal.applyMatrix4(segObj.matrixWorld) - eaveWorld = [eaveLocal.x, eaveLocal.y, eaveLocal.z] - } else { - eaveWorld = [event.position[0], event.position[1], event.position[2]] - } - const sx = Math.round(eaveWorld[0] * 20) / 20 - const sz = Math.round(eaveWorld[2] * 20) / 20 + // Grid-snap chime fires when the segment-local snap moves to a + // new 5 cm cell along the eave — keeps SFX in lockstep with what + // the commit will actually store. + const sx = Math.round(snap.eaveX * 20) / 20 + const sz = Math.round(snap.eaveZ * 20) / 20 const prev = lastSnapRef.current if (!prev || prev[0] !== sx || prev[1] !== sz) { triggerSFX('sfx:grid-snap') lastSnapRef.current = [sx, sz] } - // Yaw the preview to match the segment's rotation + the gutter's - // own back-eave flip, so the trough visually hangs outward on - // whichever eave the cursor is closer to. - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation) - setPreviewPos(worldToBuildingLocal(eaveWorld[0], eaveWorld[1], eaveWorld[2])) + setTarget({ + roof: { + position: (roof.position ?? [0, 0, 0]) as [number, number, number], + rotation: roof.rotation ?? 0, + }, + segment: { + position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number], + rotation: hit.segment.rotation ?? 0, + }, + snap, + }) event.stopPropagation() } @@ -157,12 +142,17 @@ const GutterTool = () => { } }, [activeBuildingId, setSelection]) - if (!activeBuildingId || !previewPos) return null + if (!activeBuildingId || !target) return null return ( - - - + + + + + ) From 82a54d72434d3526c36b8613e4392ffac547d403 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 30 May 2026 14:11:13 +0530 Subject: [PATCH 21/35] feat(gutter): drag-snap corners, live eave Y, hangers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Length L/R handles now snap to sibling gutter endpoints within 10 cm and pull BOTH gutters' lengths to the axis intersection — the geometric eave corner — so the 5 cm corner-mitre window fires reliably. Sibling adjustment writes through sceneApi.update; the drag pipeline's history pause batches it with the main commit into one undo step. - Renderer derives eave Y live from segment.wallHeight, overhang, and pitch via the new shared computeEaveY() — instead of trusting node.position[1] from placement time. Subscribes to the segment's useLiveNodeOverrides entry too, so a wall-height drag on the segment moves the gutter on every frame (not just at commit). - Hangers: new hangerStyle (strap / none) + hangerSpacing fields. buildHangers() lays thin 25mm × 3mm × rim-width box straps across the rim at the configured spacing, inset by 5 cm from each end and skipping any cap slabs. BoxGeometry is converted to non-indexed before merge — ExtrudeGeometry isn't indexed, and mergeGeometries rejects mixed-index sets. Inspector exposes both fields in a new "Hangers" group. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/schema/nodes/gutter.ts | 9 + packages/nodes/src/gutter/definition.ts | 37 ++++- packages/nodes/src/gutter/eave-snap.ts | 20 ++- packages/nodes/src/gutter/geometry.ts | 75 +++++++++ packages/nodes/src/gutter/length-snap.ts | 200 +++++++++++++++++++++++ packages/nodes/src/gutter/parametrics.ts | 20 +++ packages/nodes/src/gutter/preview.tsx | 2 + packages/nodes/src/gutter/renderer.tsx | 32 +++- 8 files changed, 387 insertions(+), 8 deletions(-) create mode 100644 packages/nodes/src/gutter/length-snap.ts diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts index d2e23f383..712b79c72 100644 --- a/packages/core/src/schema/nodes/gutter.ts +++ b/packages/core/src/schema/nodes/gutter.ts @@ -44,6 +44,14 @@ export const GutterNode = BaseNode.extend({ // true on both — matches a freshly-installed residential gutter. endCapLeft: z.boolean().default(true), endCapRight: z.boolean().default(true), + + // Hangers are the metal straps that hold the gutter onto the + // fascia. 'strap' renders periodic bars across the rim; 'none' + // hides them (some plastic gutters use hidden clips). Spacing is + // metres between hanger centers; real residential code is roughly + // 0.6 m for snow-load areas, 0.75 m elsewhere. + hangerStyle: z.enum(['strap', 'none']).default('strap'), + hangerSpacing: z.number().default(0.6), }).describe( dedent` Gutter — a rain-water channel running along the eave of a roof @@ -52,6 +60,7 @@ export const GutterNode = BaseNode.extend({ - size: profile drop below the eave line (vertical extent) - profile: k-style (ogee fascia), half-round, or square box - endCapLeft / endCapRight: close the trough at gutter-local -X / +X + - hangerStyle / hangerSpacing: visible metal straps across the rim `, ) diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index 2af2d6a76..8b82d8d7b 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 { snapLengthToCorner } from './length-snap' import { gutterParametrics } from './parametrics' import { GutterNode } from './schema' @@ -37,6 +38,12 @@ function getRimZ(n: GutterNodeType): number { // `position` along the gutter's own +X arm in segment frame. Same // yaw-aware projection as the box-vent / ridge-vent / chimney width // handles. +// +// Corner snap: when the dragged endpoint enters the catch radius of a +// sibling gutter's endpoint, `snapLengthToCorner` overrides the +// raw newLength so the endpoint lands EXACTLY on the sibling — the +// corner-mitre detector's 5 cm match window then fires reliably +// without needing pixel-perfect dragging. function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor { const sign = side === 'right' ? 1 : -1 return { @@ -45,16 +52,38 @@ function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor n.length, - apply: (initial, newLength) => { + apply: (initial, newLength, sceneApi) => { const rotY = initial.rotation ?? 0 const armX = Math.cos(rotY) const armZ = -Math.sin(rotY) const anchorX = initial.position[0] - sign * (initial.length / 2) * armX const anchorZ = initial.position[2] - sign * (initial.length / 2) * armZ - const newCenterX = anchorX + sign * (newLength / 2) * armX - const newCenterZ = anchorZ + sign * (newLength / 2) * armZ + const snap = snapLengthToCorner( + initial, + newLength, + sign, + anchorX, + anchorZ, + armX, + armZ, + MIN_LENGTH, + sceneApi, + ) + // Sibling adjustment — write directly to the store. The drag + // pipeline pauses history at drag start, so this batches with + // the main commit into a single undo step. Skipped when the + // snap is already at the sibling's current length (see the + // stable-state guard in length-snap.ts). + if (snap.sibling) { + sceneApi.update(snap.sibling.id, { + length: snap.sibling.length, + position: snap.sibling.position, + }) + } + const newCenterX = anchorX + sign * (snap.length / 2) * armX + const newCenterZ = anchorZ + sign * (snap.length / 2) * armZ return { - length: newLength, + length: snap.length, position: [newCenterX, initial.position[1], newCenterZ], } }, diff --git a/packages/nodes/src/gutter/eave-snap.ts b/packages/nodes/src/gutter/eave-snap.ts index 296ad67d4..8a275117b 100644 --- a/packages/nodes/src/gutter/eave-snap.ts +++ b/packages/nodes/src/gutter/eave-snap.ts @@ -43,6 +43,21 @@ export type EaveSnap = { side: EaveSide } +/** + * Live eave Y from a segment's wallHeight + overhang + pitch. Pulled + * out as a shared helper because the renderer derives Y from this same + * formula on every frame (the gutter tracks the segment's height + * instead of trusting `node.position[1]` from placement time), and + * `resolveEaveSnap` uses the same formula at placement. + */ +export function computeEaveY( + segment: Pick, +): number { + const overhang = segment.overhang ?? 0 + const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 + return (segment.wallHeight ?? 0) - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP +} + /** * Pick which of the segment's eaves is closest to the cursor. * @@ -90,12 +105,13 @@ export function resolveEaveSnap( const halfW = (segment.width ?? 0) / 2 const halfD = (segment.depth ?? 0) / 2 const overhang = segment.overhang ?? 0 - const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 // The slope keeps descending past the wall edge by the overhang // span; same drop on every eave (pitch is the segment-wide primary // slope). EAVE_TUCK_UP raises the rim back toward the deck-top line. - const eaveY = (segment.wallHeight ?? 0) - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP + // Shared formula with the renderer so placement and live tracking + // agree exactly. + const eaveY = computeEaveY(segment) const side = pickEaveSide(segment.roofType ?? 'gable', localX, localZ, halfW, halfD) diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index 187a82f51..1e040de2a 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -151,6 +151,17 @@ export function buildGutterGeometry( pieces.push(rightCap) } + // Hangers: thin metal straps spanning the rim from the back wall to + // the front rim, repeated along the length. Each strap is a small + // box centered on Y=0 (the eave line, where the rim sits) with its + // top ~3mm above the rim — so it reads as a clip resting on the + // gutter rather than buried in it. + if ((node.hangerStyle ?? 'strap') !== 'none') { + for (const hanger of buildHangers(node, len, size, capLeftLen, capRightLen)) { + pieces.push(hanger) + } + } + const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!) // Free the intermediate pieces when merge returned a new geometry. if (merged !== pieces[0]) { @@ -304,3 +315,67 @@ function buildBoxOuterOnly(size: number): THREE.Shape { shape.closePath() return shape } + +// ─── Hangers ─────────────────────────────────────────────────────── + +// Strap dimensions — a residential hidden hanger reads as a flat band +// roughly 25mm wide along the gutter, 3mm thick, sitting on the rim. +const HANGER_BAR_LEN = 0.025 +const HANGER_BAR_THICKNESS = 0.003 +// Extra spread past the rim's outward extent — so the strap looks like +// it "wraps over" both edges rather than ending flush. +const HANGER_OVERHANG = 0.005 +// Distance from each gutter end where a strap is allowed to sit; keeps +// straps from clashing with end caps and from looking pinned to the +// very edge. +const HANGER_END_MARGIN = 0.05 + +/** Outward Z extent of each profile, used to size the strap. */ +function profileRimWidth(profile: GutterNode['profile'], size: number): number { + if (profile === 'half-round') return 2 * size + if (profile === 'box') return size + return size * 0.95 // k-style wTop +} + +function buildHangers( + node: GutterNode, + len: number, + size: number, + capLeftLen: number, + capRightLen: number, +): THREE.BufferGeometry[] { + const spacing = Math.max(0.2, node.hangerSpacing ?? 0.6) + const profile = node.profile ?? 'k-style' + const rimWidth = profileRimWidth(profile, size) + const strapDepth = rimWidth + HANGER_OVERHANG * 2 + + // Inset by margin AND any cap so straps don't punch into the cap slab. + const leftBound = -len / 2 + capLeftLen + HANGER_END_MARGIN + const rightBound = len / 2 - capRightLen - HANGER_END_MARGIN + const usable = rightBound - leftBound + if (usable <= 0) return [] + + // Span the usable run with straps at `spacing` between centers, plus + // one at each end. Symmetric layout for any length, including very + // short gutters where two straps land at the bounds. + const count = Math.max(1, Math.floor(usable / spacing) + 1) + const stride = count > 1 ? usable / (count - 1) : 0 + + const pieces: THREE.BufferGeometry[] = [] + for (let i = 0; i < count; i++) { + const x = count > 1 ? leftBound + i * stride : (leftBound + rightBound) / 2 + // BoxGeometry is indexed; the channel + cap ExtrudeGeometries are + // not. `mergeGeometries` rejects mixed-index sets, so flatten the + // box to non-indexed before pushing. + const bar = new THREE.BoxGeometry(HANGER_BAR_LEN, HANGER_BAR_THICKNESS, strapDepth).toNonIndexed() + // 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, + ) + pieces.push(bar) + } + return pieces +} diff --git a/packages/nodes/src/gutter/length-snap.ts b/packages/nodes/src/gutter/length-snap.ts new file mode 100644 index 000000000..ddb5795f9 --- /dev/null +++ b/packages/nodes/src/gutter/length-snap.ts @@ -0,0 +1,200 @@ +import type { AnyNodeId, GutterNode, RoofSegmentNode, SceneApi } from '@pascal-app/core' + +/** + * Length-handle snap. When the user drags a gutter's ±X length handle + * and the proposed endpoint lands within `SNAP_RADIUS` of a sibling + * gutter's endpoint (in segment-local space), pull BOTH gutters' + * lengths so they meet at the geometric corner — the intersection of + * their length-axis lines. The corner-mitre detector's 5 cm match + * window then fires reliably without asking the user to land a + * pixel-perfect drag. + * + * Both-sides adjustment is the point: snapping A to wherever B + * currently sits glues the L to B's possibly-imprecise position, but + * the intersection point IS the eave corner (each eave-snapped gutter + * runs along its eave line, so the axis crossing is the eave corner + * in plan). Adjusting B too makes the L "click into" the eave + * intersection no matter which side the user dragged. + * + * Stable-state guard: when the snap is sustained across drag ticks the + * sibling's length / position won't visibly change from one tick to + * the next. We skip the sibling update in that case so we're not + * thrashing the store ~60×/sec for no visual change. + * + * Pure: no React, no THREE. Reads through SceneApi; writes are + * returned as a sibling adjustment for the caller to apply (so the + * caller decides when to commit). + */ + +// 10 cm catch radius — wide enough that the user doesn't need pixel- +// perfect dragging, narrow enough that unrelated gutters on the +// opposite eave don't accidentally bind. +const SNAP_RADIUS = 0.1 +const SNAP_RADIUS_SQ = SNAP_RADIUS * SNAP_RADIUS + +// Cross product below this counts as parallel axes — no intersection, +// fall back to snapping A onto B's current endpoint without modifying B. +const AXIS_PARALLEL_EPSILON = 1e-3 + +// Sibling update threshold: ~1 mm of change. Below this we treat the +// snap as "already at target" and skip the store write. +const STABLE_EPSILON = 1e-3 + +export type GutterLengthSnap = { + /** Length to apply to the dragged gutter. */ + length: number + /** + * When set, the named sibling needs to be re-lengthened so its + * matching endpoint meets the dragged gutter at the same corner. + * Caller writes via `sceneApi.update`; history is paused during the + * drag so it batches with the main commit at pointer-up. + */ + sibling?: { + id: AnyNodeId + length: number + position: [number, number, number] + } +} + +/** + * @param initial gutter at drag start (rotation, length, position) + * @param proposedLength length the linear-resize pipeline computed + * @param sign +1 for the gutter-local +X end being dragged, −1 for −X + * @param anchorX,anchorZ the held-fixed endpoint (opposite of `sign`) + * @param armX,armZ gutter +X direction in segment frame (cos r, −sin r) + * @param minLength floor — typically the descriptor's `min` value + * @param sceneApi scene access for sibling lookup + */ +export function snapLengthToCorner( + initial: GutterNode, + proposedLength: number, + sign: 1 | -1, + anchorX: number, + anchorZ: number, + armX: number, + armZ: number, + minLength: number, + sceneApi: SceneApi, +): GutterLengthSnap { + const segmentId = initial.roofSegmentId as AnyNodeId | undefined + if (!segmentId) return { length: proposedLength } + const seg = sceneApi.get(segmentId) + if (!seg) return { length: proposedLength } + + const proposedEndX = anchorX + sign * proposedLength * armX + const proposedEndZ = anchorZ + sign * proposedLength * armZ + + // Pass 1: pick the sibling whose endpoint is closest to the proposed + // end, and remember WHICH end (+X or −X) we matched on. + let bestSib: GutterNode | null = null + let bestSibEndIsPlus = false + let bestSibEndX = 0 + let bestSibEndZ = 0 + let bestDistSq = SNAP_RADIUS_SQ + + for (const sibId of seg.children ?? []) { + const sib = sceneApi.get(sibId as AnyNodeId) + if (!sib || sib.type !== 'gutter' || sib.id === initial.id) continue + const sibG = sib as GutterNode + const sibRot = sibG.rotation ?? 0 + const sibArmX = Math.cos(sibRot) + const sibArmZ = -Math.sin(sibRot) + const sibHalf = sibG.length / 2 + const plusX = sibG.position[0] + sibArmX * sibHalf + const plusZ = sibG.position[2] + sibArmZ * sibHalf + const minusX = sibG.position[0] - sibArmX * sibHalf + const minusZ = sibG.position[2] - sibArmZ * sibHalf + + const dPlusSq = (plusX - proposedEndX) ** 2 + (plusZ - proposedEndZ) ** 2 + if (dPlusSq < bestDistSq) { + bestDistSq = dPlusSq + bestSib = sibG + bestSibEndIsPlus = true + bestSibEndX = plusX + bestSibEndZ = plusZ + } + const dMinusSq = (minusX - proposedEndX) ** 2 + (minusZ - proposedEndZ) ** 2 + if (dMinusSq < bestDistSq) { + bestDistSq = dMinusSq + bestSib = sibG + bestSibEndIsPlus = false + bestSibEndX = minusX + bestSibEndZ = minusZ + } + } + + if (!bestSib) return { length: proposedLength } + + // Pass 2: find the geometric corner — intersection of A's axis and + // B's axis. For two eave-snapped gutters this IS the eave corner. + // Fall back to B's endpoint if the axes are parallel (rare; means + // both gutters point the same way and there's no real corner). + const sibRot = bestSib.rotation ?? 0 + const sibArmX = Math.cos(sibRot) + const sibArmZ = -Math.sin(sibRot) + const sibPosX = bestSib.position[0] + const sibPosZ = bestSib.position[2] + + const crossDirs = armX * sibArmZ - armZ * sibArmX + let targetX: number + let targetZ: number + + if (Math.abs(crossDirs) < AXIS_PARALLEL_EPSILON) { + targetX = bestSibEndX + targetZ = bestSibEndZ + } else { + // (sibPos − anchor) = t·d_A − s·d_B → t = cross(sibPos − anchor, d_B) / cross(d_A, d_B) + const dx = sibPosX - anchorX + const dz = sibPosZ - anchorZ + const t = (dx * sibArmZ - dz * sibArmX) / crossDirs + targetX = anchorX + t * armX + targetZ = anchorZ + t * armZ + + // Reject far-off intersections — if the axes cross out beyond the + // snap radius (e.g. user is mid-drag and only briefly clipped the + // sibling endpoint), don't yank the gutter across the roof. + const distSqFromProposed = + (targetX - proposedEndX) ** 2 + (targetZ - proposedEndZ) ** 2 + if (distSqFromProposed > SNAP_RADIUS_SQ) { + targetX = bestSibEndX + targetZ = bestSibEndZ + } + } + + // Snap A: project (target − anchor) onto A's axis direction. `sign` + // flips so the projection produces a positive length when the target + // sits on the dragged side of the anchor. + const projectedA = sign * ((targetX - anchorX) * armX + (targetZ - anchorZ) * armZ) + const snappedLength = Math.max(minLength, projectedA) + + // Snap B: the END that matched moves to the target; the OPPOSITE end + // stays fixed (B's anchor). Same asymmetric-resize math as A's apply + // — anchor + (sign · newLen) · arm gives the moving end at the + // target; new center sits at the midpoint. + const sibHalf = bestSib.length / 2 + const sibAnchorSign = bestSibEndIsPlus ? -1 : 1 // opposite end stays put + const sibAnchorX = sibPosX + sibAnchorSign * sibArmX * sibHalf + const sibAnchorZ = sibPosZ + sibAnchorSign * sibArmZ * sibHalf + + const sibMovingSign = bestSibEndIsPlus ? 1 : -1 + const sibProjected = + sibMovingSign * ((targetX - sibAnchorX) * sibArmX + (targetZ - sibAnchorZ) * sibArmZ) + const sibNewLength = Math.max(minLength, sibProjected) + const sibNewCenterX = sibAnchorX + sibMovingSign * (sibNewLength / 2) * sibArmX + const sibNewCenterZ = sibAnchorZ + sibMovingSign * (sibNewLength / 2) * sibArmZ + + const lengthDelta = Math.abs(sibNewLength - bestSib.length) + const posDelta = Math.abs(sibNewCenterX - sibPosX) + Math.abs(sibNewCenterZ - sibPosZ) + if (lengthDelta < STABLE_EPSILON && posDelta < STABLE_EPSILON) { + return { length: snappedLength } + } + + return { + length: snappedLength, + sibling: { + id: bestSib.id as AnyNodeId, + length: sibNewLength, + position: [sibNewCenterX, bestSib.position[1], sibNewCenterZ], + }, + } +} diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts index dc6891268..fd4ea2aba 100644 --- a/packages/nodes/src/gutter/parametrics.ts +++ b/packages/nodes/src/gutter/parametrics.ts @@ -36,5 +36,25 @@ export const gutterParametrics: ParametricDescriptor = { { key: 'endCapRight', kind: 'boolean' }, ], }, + { + label: 'Hangers', + fields: [ + { + key: 'hangerStyle', + kind: 'enum', + options: ['strap', 'none'], + display: 'segmented', + }, + { + key: 'hangerSpacing', + kind: 'number', + unit: 'm', + min: 0.2, + max: 2.0, + step: 0.05, + visibleIf: (n) => (n.hangerStyle ?? 'strap') !== 'none', + }, + ], + }, ], } diff --git a/packages/nodes/src/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx index c9e6615e1..b9a43ccfb 100644 --- a/packages/nodes/src/gutter/preview.tsx +++ b/packages/nodes/src/gutter/preview.tsx @@ -30,6 +30,8 @@ const GutterPreview = ({ node }: { node: GutterNode }) => { node.profile, node.endCapLeft, node.endCapRight, + node.hangerStyle, + node.hangerSpacing, ], ) diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index 9e026aeb7..336eea095 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -20,6 +20,7 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' import { computeGutterMitres } from './corner-mitre' +import { computeEaveY } from './eave-snap' import { buildGutterGeometry } from './geometry' const defaultMaterial = new THREE.MeshStandardMaterial({ @@ -66,6 +67,23 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { : undefined, ) + // While the user is dragging the segment's wall-height / overhang / + // pitch handle, the drag pipeline writes to useLiveNodeOverrides + // instead of the scene store — the scene entry above stays at the + // pre-drag value until pointer-up. Subscribing to the segment's live + // overrides too lets the gutter's `computeEaveY` see the in-flight + // height and slide up/down on every frame of the drag. + const segmentOverrides = useLiveNodeOverrides((s) => + node.roofSegmentId + ? (s.get(node.roofSegmentId as AnyNodeId) as Partial | undefined) + : undefined, + ) + const effectiveSegment: RoofSegmentNode | undefined = segment + ? segmentOverrides + ? ({ ...segment, ...segmentOverrides } as RoofSegmentNode) + : segment + : undefined + // Same-segment sibling gutters drive the corner-mitre detector. Pull // them as a fresh array each store update; `useShallow` keeps the // reference stable when the array contents haven't changed, so the @@ -106,6 +124,8 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { node.profile, node.endCapLeft, node.endCapRight, + node.hangerStyle, + node.hangerSpacing, mitres.left, mitres.right, ], @@ -132,7 +152,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) - if (!segment) return null + if (!segment || !effectiveSegment) return null // `node.position` is segment-local — the placement tool resolves the // eave click via `segObj.worldToLocal`. The renderer mounts under @@ -140,13 +160,21 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { // re-apply the segment's roof-local transform here. Mirrors the // ridge-vent / box-vent pattern; without this gutters on rotated // segments would land on the first segment instead. + // + // Y is derived live from `effectiveSegment` (scene + drag overrides) + // instead of trusting `node.position[1]` — so changing wallHeight, + // overhang, or pitch on the parent segment moves the gutter on the + // very next frame, including while a segment-height handle is + // mid-drag. Matches the chimney/box-vent pattern of pulling host- + // segment geometry at draw time rather than caching it at placement. const segPos = segment.position ?? [0, 0, 0] const segRotY = segment.rotation ?? 0 + const liveEaveY = computeEaveY(effectiveSegment) return ( Date: Sat, 30 May 2026 14:26:30 +0530 Subject: [PATCH 22/35] feat(gutter): downspout outlet with real CSG-drilled hole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New schema fields: outletSide ('none' | 'left' | 'right'), outletInset, outletDiameter. Default 'none' so existing gutters don't sprout outlets on schema upgrade. Geometry adds a solid cylindrical stub at bore + 3 mm wall radius descending 6 cm from the trough floor, profile-aware Z midpoint (k-style 0.4·size, half-round size, box size/2), X clamped between the caps. After merging into the channel + caps + hangers, a three-bvh-csg SUBTRACTION drills a bore-wide cylinder vertically through the floor and stub — so the result is a real hole in the trough floor with a hollow tube hanging through it. Drill overshoots floor + stub by 1 cm each side to keep cut planes from coinciding with mesh faces (csg-evaluator produces degenerate output on coplanar cuts). CSG only runs when outletSide ≠ 'none' — the existing merge path is the fast path for capped-only gutters. Inspector exposes the three fields under a new 'Outlet' group. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/schema/nodes/gutter.ts | 11 ++ packages/nodes/src/gutter/geometry.ts | 149 +++++++++++++++++++++++ packages/nodes/src/gutter/parametrics.ts | 29 +++++ packages/nodes/src/gutter/preview.tsx | 3 + packages/nodes/src/gutter/renderer.tsx | 3 + 5 files changed, 195 insertions(+) diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts index 712b79c72..1797ce152 100644 --- a/packages/core/src/schema/nodes/gutter.ts +++ b/packages/core/src/schema/nodes/gutter.ts @@ -52,6 +52,16 @@ export const GutterNode = BaseNode.extend({ // 0.6 m for snow-load areas, 0.75 m elsewhere. hangerStyle: z.enum(['strap', 'none']).default('strap'), hangerSpacing: z.number().default(0.6), + + // Downspout outlet — a short cylindrical drop tube descending from + // the gutter floor where a downspout connects. 'none' (default) so + // existing gutters don't sprout outlets on schema upgrade. Side + // picks the end the outlet sits closest to; inset is the segment- + // local distance from that end; diameter is the bore of the tube + // (default 0.07 m ≈ 3″ — standard residential downspout). + outletSide: z.enum(['none', 'left', 'right']).default('none'), + outletInset: z.number().default(0.15), + outletDiameter: z.number().default(0.07), }).describe( dedent` Gutter — a rain-water channel running along the eave of a roof @@ -61,6 +71,7 @@ export const GutterNode = BaseNode.extend({ - profile: k-style (ogee fascia), half-round, or square box - endCapLeft / endCapRight: close the trough at gutter-local -X / +X - hangerStyle / hangerSpacing: visible metal straps across the rim + - outletSide / outletInset / outletDiameter: drop-tube outlet `, ) diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index 1e040de2a..92647e4e8 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -1,4 +1,11 @@ import type { GutterNode } from '@pascal-app/core' +import { + Brush, + csgEvaluator, + csgGeometry, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { type GutterMitres, NO_MITRES } from './corner-mitre' @@ -162,12 +169,41 @@ export function buildGutterGeometry( } } + // Downspout outlet: short cylindrical drop tube hanging off the + // gutter floor at the chosen end. The stub itself is solid at the + // outer (bore + wall) radius; the bore is then drilled through both + // the floor and the stub via CSG, so the result is a real hole in + // the trough floor and a hollow tube descending from it. CSG only + // runs when the outlet is enabled — `'none'` keeps the fast merge + // path. + const outletStub = buildOutletStub(node, len, size, capLeftLen, capRightLen) + if (outletStub) pieces.push(outletStub) + const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!) // Free the intermediate pieces when merge returned a new geometry. if (merged !== pieces[0]) { for (const p of pieces) p.dispose() } merged.computeVertexNormals() + + // CSG drill — punches the bore through the merged geometry. Runs + // last so the floor + stub are already in one mesh; the drill cuts + // both at once. + if (outletStub) { + const drill = buildOutletDrill(node, len, size, capLeftLen, capRightLen) + if (drill) { + const mainBrush = new Brush(merged) + prepareBrushForCSG(mainBrush) + const drillBrush = new Brush(drill) + prepareBrushForCSG(drillBrush) + const cut = csgEvaluator.evaluate(mainBrush, drillBrush, SUBTRACTION) + const cutGeometry = csgGeometry(cut) + merged.dispose() + drill.dispose() + return cutGeometry + } + } + return merged } @@ -379,3 +415,116 @@ function buildHangers( } return pieces } + +// ─── Outlet ──────────────────────────────────────────────────────── + +// Stub length — 6 cm reads as "drop tube" without poking conspicuously +// far below the eave; the downspout pipe (eventually a child node) +// will visually continue downward from the stub's open end. +const OUTLET_STUB_LENGTH = 0.06 +// Radial subdivisions — 24 reads as smooth at typical outlet +// diameters; lower and the 3″ tube starts looking faceted from below. +const OUTLET_RADIAL_SEGMENTS = 24 +// Wall thickness of the drop tube — 3 mm matches typical residential +// gauge. After CSG the stub becomes a tube with outer radius = +// bore + wall and inner radius = bore. +const OUTLET_WALL_THICKNESS = 0.003 +// Drill overshoot past the floor and past the stub bottom — keeps the +// CSG cut planes from coinciding with merged-geometry surfaces +// (coplanar cuts produce degenerate output in three-bvh-csg). +const OUTLET_DRILL_OVERSHOOT = 0.01 + +/** Z (outward) coordinate of the trough floor's midpoint per profile. */ +function profileFloorMidZ(profile: GutterNode['profile'], size: number): number { + // k-style bottom is `wBot = 0.8 · size` wide; box bottom is `size` + // wide; half-round's lowest point sits at the centre of the + // semicircle at Z = r = size. + if (profile === 'half-round') return size + if (profile === 'box') return size / 2 + return size * 0.4 +} + +/** + * Common (X, Z) placement + radius math used by both the solid stub + * and the CSG drill. Returns null when the outlet is disabled or + * doesn't fit between the caps. + */ +function resolveOutletPlacement( + node: GutterNode, + len: number, + size: number, + capLeftLen: number, + capRightLen: number, +): { x: number; z: number; bore: number; outer: number } | null { + const side = node.outletSide ?? 'none' + if (side === 'none') return null + + const bore = Math.max(0.01, (node.outletDiameter ?? 0.07) / 2) + const outer = bore + OUTLET_WALL_THICKNESS + const inset = Math.max(outer, node.outletInset ?? 0.15) + + // Clamp inside the trough-floor span — use the OUTER radius so the + // tube body itself can't poke into a cap. + const minX = -len / 2 + capLeftLen + outer + const maxX = len / 2 - capRightLen - outer + if (maxX <= minX) return null + let x = side === 'left' ? -len / 2 + capLeftLen + inset : len / 2 - capRightLen - inset + x = Math.max(minX, Math.min(maxX, x)) + + const profile = node.profile ?? 'k-style' + const z = profileFloorMidZ(profile, size) + return { x, z, bore, outer } +} + +function buildOutletStub( + node: GutterNode, + len: number, + size: number, + capLeftLen: number, + capRightLen: number, +): THREE.BufferGeometry | null { + const p = resolveOutletPlacement(node, len, size, capLeftLen, capRightLen) + if (!p) return null + + // Solid cylinder at OUTER radius; the CSG drill below will hollow + // out the bore and leave a tube wall. Top of the stub sits flush + // with the gutter floor (Y = −size). CylinderGeometry is indexed; + // flatten to match the ExtrudeGeometries in `pieces`. + const tube = new THREE.CylinderGeometry( + p.outer, + p.outer, + OUTLET_STUB_LENGTH, + OUTLET_RADIAL_SEGMENTS, + ).toNonIndexed() + tube.translate(p.x, -size - OUTLET_STUB_LENGTH / 2, p.z) + return tube +} + +function buildOutletDrill( + node: GutterNode, + len: number, + size: number, + capLeftLen: number, + capRightLen: number, +): THREE.BufferGeometry | null { + const p = resolveOutletPlacement(node, len, size, capLeftLen, capRightLen) + if (!p) return null + + // Drill spans from slightly above the trough floor to slightly + // below the stub's bottom — the overshoots keep CSG cut planes + // from sitting coplanar with merged-geometry faces. + const top = -size + OUTLET_DRILL_OVERSHOOT + const bottom = -size - OUTLET_STUB_LENGTH - OUTLET_DRILL_OVERSHOOT + const height = top - bottom + const centerY = (top + bottom) / 2 + + const drill = new THREE.CylinderGeometry( + p.bore, + p.bore, + height, + OUTLET_RADIAL_SEGMENTS, + ) + // CSG doesn't care about indexed vs non-indexed for the input brushes. + drill.translate(p.x, centerY, p.z) + return drill +} diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts index fd4ea2aba..666beca3b 100644 --- a/packages/nodes/src/gutter/parametrics.ts +++ b/packages/nodes/src/gutter/parametrics.ts @@ -56,5 +56,34 @@ export const gutterParametrics: ParametricDescriptor = { }, ], }, + { + label: 'Outlet', + fields: [ + { + key: 'outletSide', + kind: 'enum', + options: ['none', 'left', 'right'], + display: 'segmented', + }, + { + key: 'outletInset', + kind: 'number', + unit: 'm', + min: 0.02, + max: 1.0, + step: 0.01, + visibleIf: (n) => (n.outletSide ?? 'none') !== 'none', + }, + { + key: 'outletDiameter', + kind: 'number', + unit: 'm', + min: 0.02, + max: 0.15, + step: 0.005, + visibleIf: (n) => (n.outletSide ?? 'none') !== 'none', + }, + ], + }, ], } diff --git a/packages/nodes/src/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx index b9a43ccfb..e5bd5e7d1 100644 --- a/packages/nodes/src/gutter/preview.tsx +++ b/packages/nodes/src/gutter/preview.tsx @@ -32,6 +32,9 @@ const GutterPreview = ({ node }: { node: GutterNode }) => { node.endCapRight, node.hangerStyle, node.hangerSpacing, + node.outletSide, + node.outletInset, + node.outletDiameter, ], ) diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index 336eea095..6aa425c41 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -126,6 +126,9 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { node.endCapRight, node.hangerStyle, node.hangerSpacing, + node.outletSide, + node.outletInset, + node.outletDiameter, mitres.left, mitres.right, ], From ac309f794b1d281489159a567a293818e143d36c Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 30 May 2026 15:14:46 +0530 Subject: [PATCH 23/35] feat(downspout): new node + gutter inspector list section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DownspoutNode lives next to the other roof accessories. Scene-graph parent is the same roof-segment the host gutter sits on; logical attach is via `gutterId`. Schema: length (default 2.5 m), diameter (default 0.07 m / 3″ — matches gutter outlet default), material. Renderer mounts a CylinderGeometry under the same transform chain the gutter uses (segment → gutter-mesh-local → outlet); pulls `computeEaveY` from the live + drag-override segment so wallHeight / overhang / pitch changes track on the same frame as the gutter. `resolveGutterOutletPlacement` (gutter/outlet-lookup.ts) is the shared helper both the downspout renderer and the inline Add path use to compute (x, y, z, bore) in gutter-mesh-local space. Two arrow handles on a selected downspout: - length: tracker shape (dashed leader + draggable cube). Anchored to the outlet, cube at the pipe bottom — readable even when the cube ends up below ground. - diameter: symmetric `z`-axis chevron sitting at a fixed −20 cm Y below the outlet with 25 cm of outward clearance past the worst- case k-style rim, so it stays inside the gutter's camera frame instead of floating mid-pipe. Inspector UX: new optional `trailingSection` slot on ParametricDescriptor — a lazy-loaded React subsection rendered between groups and the Actions section. Gutter's slot loads a `downspouts-panel` that lists every attached downspout (button per item, click → select), and an "Add Downspout" button below that immediately creates a new one parented to the gutter's segment — matches the roof inspector's gutter list pattern. Disabled with a helper line when `outletSide === 'none'`. Multiple downspouts per gutter are allowed. `StructureTool` union picks up 'downspout' so the placement tool remains addressable (used by the legacy roof-panel button before the inspector list took over). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/events/bus.ts | 3 + packages/core/src/registry/index.ts | 1 + packages/core/src/registry/types.ts | 25 +++ packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/downspout.ts | 41 +++++ packages/core/src/schema/types.ts | 2 + .../ui/panels/parametric-inspector.tsx | 31 +++- packages/editor/src/store/use-editor.tsx | 1 + packages/nodes/src/downspout/definition.ts | 157 +++++++++++++++++ packages/nodes/src/downspout/geometry.ts | 29 ++++ packages/nodes/src/downspout/index.ts | 3 + packages/nodes/src/downspout/parametrics.ts | 14 ++ packages/nodes/src/downspout/preview.tsx | 54 ++++++ packages/nodes/src/downspout/renderer.tsx | 147 ++++++++++++++++ packages/nodes/src/downspout/schema.ts | 3 + packages/nodes/src/downspout/tool.tsx | 158 ++++++++++++++++++ .../nodes/src/gutter/downspouts-panel.tsx | 123 ++++++++++++++ packages/nodes/src/gutter/outlet-lookup.ts | 67 ++++++++ packages/nodes/src/gutter/parametrics.ts | 4 + packages/nodes/src/index.ts | 3 + packages/nodes/src/roof/panel.tsx | 3 +- 21 files changed, 868 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/schema/nodes/downspout.ts create mode 100644 packages/nodes/src/downspout/definition.ts create mode 100644 packages/nodes/src/downspout/geometry.ts create mode 100644 packages/nodes/src/downspout/index.ts create mode 100644 packages/nodes/src/downspout/parametrics.ts create mode 100644 packages/nodes/src/downspout/preview.tsx create mode 100644 packages/nodes/src/downspout/renderer.tsx create mode 100644 packages/nodes/src/downspout/schema.ts create mode 100644 packages/nodes/src/downspout/tool.tsx create mode 100644 packages/nodes/src/gutter/downspouts-panel.tsx create mode 100644 packages/nodes/src/gutter/outlet-lookup.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index f7815385b..7922673f8 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -9,6 +9,7 @@ import type { ColumnNode, DoorNode, DormerNode, + DownspoutNode, ElevatorNode, FenceNode, GuideNode, @@ -94,6 +95,7 @@ export type ChimneyEvent = NodeEvent export type SolarPanelEvent = NodeEvent export type SkylightEvent = NodeEvent export type DormerEvent = NodeEvent +export type DownspoutEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -234,6 +236,7 @@ type EditorEvents = GridEvents & NodeEvents<'solar-panel', SolarPanelEvent> & NodeEvents<'skylight', SkylightEvent> & NodeEvents<'dormer', DormerEvent> & + NodeEvents<'downspout', DownspoutEvent> & CameraControlEvents & ToolEvents & GuideEvents & diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 69ff7ebe9..9eef5afc6 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -70,6 +70,7 @@ export type { PaintPatchArgs, PaintPreviewArgs, PaintResolveArgs, + ParamAction, ParametricDescriptor, ParamField, ParamGroup, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 6f80cfdc3..a60410373 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1186,6 +1186,31 @@ export type ParametricDescriptor = { invariants?: ReadonlyArray<(n: N) => Issue[]> derive?: (n: N) => Partial customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> + /** + * Extra buttons rendered in the inspector's Actions section + * (below Move/Delete). Lets a kind declare "do this thing to the + * current node" affordances without escaping to a full custom + * panel. Buttons whose `enabledIf` returns false stay disabled. + */ + actions?: ParamAction[] + /** + * Lazy-loaded React subsection rendered AFTER the auto-derived + * groups and BEFORE the Actions section. Used by kinds that want + * to list their child nodes inline — e.g. the gutter's downspout + * list with an "Add Downspout" button at the bottom, same shape as + * the roof panel's gutter / vent lists. Kind owns the layout; the + * inspector just slots it in. + */ + trailingSection?: () => Promise<{ default: ComponentType<{ node: N }> }> +} + +export type ParamAction = { + label: string + /** Optional asset URL for a leading icon — same shape as palette icons. */ + iconSrc?: string + enabledIf?: (n: N) => boolean + /** Click handler. Receives the current node value at click time. */ + onClick: (n: N) => void } export type ParamGroup = { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 3cd29ba8b..4592d0bdf 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -83,6 +83,7 @@ export { isLowProfileItemSurface, LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, } from './nodes/item' +export { DownspoutNode } from './nodes/downspout' export { GutterNode } from './nodes/gutter' export { LevelNode } from './nodes/level' // Nodes diff --git a/packages/core/src/schema/nodes/downspout.ts b/packages/core/src/schema/nodes/downspout.ts new file mode 100644 index 000000000..98cf24104 --- /dev/null +++ b/packages/core/src/schema/nodes/downspout.ts @@ -0,0 +1,41 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +export const DownspoutNode = BaseNode.extend({ + id: objectId('downspout'), + type: nodeType('downspout'), + + material: MaterialSchema.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'), + + // Logical attachment: the gutter this downspout drains. Scene-graph + // parent is the same roof-segment that hosts the gutter, so the + // renderer can be reached through the segment's children list (like + // every other roof accessory). gutterId then drives the LOOKUP of + // outlet X/Z/diameter — the downspout's actual mount position is + // derived from the gutter, not stored. + gutterId: z.string().optional(), + + // Length the pipe extends DOWN from the gutter outlet, in metres. + // Default 2.5 m covers a typical residential storey; the placement + // tool can default to the gutter's eave-Y minus building floor on + // commit so the user doesn't have to set it on every drop. + length: z.number().default(2.5), + // Bore diameter, default 0.07 m ≈ 3″ to match the gutter outlet + // default. Larger downspouts are common on commercial gutters. + diameter: z.number().default(0.07), +}).describe( + dedent` + Downspout — a vertical pipe that takes water from a gutter outlet + down to ground level. Parented to a roof-segment (scene-graph), + linked to a specific gutter via gutterId for outlet position. + - length: vertical pipe length below the gutter outlet + - diameter: bore diameter; should match the host gutter's outletDiameter + `, +) + +export type DownspoutNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index ed9a308d3..e514d11a0 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -6,6 +6,7 @@ import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' import { DoorNode } from './nodes/door' import { DormerNode } from './nodes/dormer' +import { DownspoutNode } from './nodes/downspout' import { ElevatorNode } from './nodes/elevator' import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' @@ -57,6 +58,7 @@ export const AnyNode = z.discriminatedUnion('type', [ SolarPanelNode, SkylightNode, DormerNode, + DownspoutNode, ]) export type AnyNode = z.infer diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index fe7d5d173..3e13a70e6 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -100,6 +100,10 @@ export function ParametricInspector() { const canMove = !!def.capabilities.movable const canDelete = def.capabilities.deletable !== false + const TrailingSection = parametrics.trailingSection + ? resolveCustomPanel(parametrics.trailingSection) + : null + return ( {parametrics.groups.map((group, gi) => ( @@ -114,12 +118,37 @@ export function ParametricInspector() { ))} ))} - {(canMove || canDelete) && ( + {TrailingSection && ( + + + + )} + {(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && ( {canMove && ( } label="Move" onClick={handleMove} /> )} + {parametrics.actions?.map((action, i) => { + const node = useScene.getState().nodes[selectedId] + const disabled = action.enabledIf && node ? !action.enabledIf(node) : false + return ( + + ) : undefined + } + key={`paramaction-${i}`} + label={action.label} + onClick={() => { + const live = useScene.getState().nodes[selectedId] + if (live) action.onClick(live) + }} + /> + ) + })} {canDelete && ( { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'max', + shape: 'tracker', + min: MIN_LENGTH, + currentValue: (n) => n.length, + apply: (_n, newValue) => ({ length: Math.max(MIN_LENGTH, newValue) }), + placement: { + // Cube sits at the bottom of the pipe (the dimension terminus). + position: (n) => [0, -Math.max(n.length, MIN_LENGTH), 0], + }, + // Leader starts at Y = 0 (outlet / gutter floor) and runs DOWN to + // the cube — same logic the existing tracker handles use for + // "the dimension's other end is up here, against the host." + trackerBaseY: () => 0, + } +} + +/** + * Diameter chevron — symmetric radial growth, dragged outward (away + * from the building) along the gutter-local +Z axis. `anchor: + * 'center'` grows the value by 2× the cursor delta so the visible + * +Z edge tracks the pointer. + * + * Sits at a FIXED Y near the top of the pipe (DIAMETER_HANDLE_Y) so + * it stays in the gutter's camera frame regardless of pipe length, + * and at a Z far enough outward that the gutter's rim — which + * extends up to ~ 1.5 × `size` past the outlet axis on k-style + * profiles — doesn't occlude the chevron. + */ +function downspoutDiameterHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'center', + min: MIN_DIAMETER, + currentValue: (n) => n.diameter, + apply: (_n, newValue) => ({ diameter: Math.max(MIN_DIAMETER, newValue) }), + placement: { + position: (n) => [ + 0, + DIAMETER_HANDLE_Y, + Math.max(n.diameter, MIN_DIAMETER) / 2 + DIAMETER_HANDLE_CLEARANCE, + ], + }, + } +} + +const downspoutHandles: HandleDescriptor[] = [ + downspoutLengthHandle(), + downspoutDiameterHandle(), +] + +/** + * Downspout — vertical drop pipe taking water from a gutter outlet to + * the ground. Scene-graph parent is the same roof-segment the host + * gutter sits on (so it renders under `roof-elements` like every + * other accessory); the logical link to the gutter is via the + * `gutterId` field, which the renderer uses to look up the outlet + * position. + * + * No `handles` yet — the downspout's geometry is anchored to the + * gutter's outlet, so length / diameter live in the inspector rather + * than as draggable arrows for v1. Future passes can add a length + * tracker handle similar to the gutter's size chevron. + */ +export const downspoutDefinition: NodeDefinition = { + kind: 'downspout', + schemaVersion: 1, + schema: DownspoutNode, + category: 'structure', + surfaceRole: 'roof', + + defaults: () => { + const stub = DownspoutNodeSchema.parse({ + id: 'downspout_default' as never, + type: 'downspout', + }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + // Logically a roof accessory — registers under the segment, has + // no buildCut, just the standard dirty cascade. + roofAccessory: {}, + }, + + parametrics: downspoutParametrics, + handles: downspoutHandles, + + renderer: { + kind: 'parametric', + module: () => import('./renderer'), + }, + + preview: () => import('./preview'), + tool: () => import('./tool'), + toolHints: [ + { key: 'Hover gutter', label: 'Highlight outlet' }, + { key: 'Left click', label: 'Drop downspout from outlet' }, + { key: 'Esc', label: 'Cancel' }, + ], + + presentation: { + label: 'Downspout', + description: 'Vertical drop pipe from a gutter outlet to the ground.', + icon: { kind: 'url', src: '/icons/roof.png' }, + paletteSection: 'structure', + paletteOrder: 123, + }, + + mcp: { + description: + 'A downspout — vertical drop pipe attached to a gutter outlet. length / diameter parametric; future passes will add elbows and a kickout.', + }, +} + diff --git a/packages/nodes/src/downspout/geometry.ts b/packages/nodes/src/downspout/geometry.ts new file mode 100644 index 000000000..c07361c29 --- /dev/null +++ b/packages/nodes/src/downspout/geometry.ts @@ -0,0 +1,29 @@ +import type { DownspoutNode } from '@pascal-app/core' +import * as THREE from 'three' + +/** + * Downspout pipe builder. The pipe is a vertical cylinder hanging from + * the gutter outlet down to ground (or wherever `length` ends). Mesh + * frame is centred on the outlet — local Y = 0 is the TOP of the pipe + * (flush with the gutter floor / outlet stub bottom) and Y = −length + * is where the bottom of the pipe sits. + * + * Single piece, single material: when a kickout or splash block lands + * we'll merge them in like the gutter does with its hangers / outlet. + * + * Pure: no React, no scene access. + */ +const RADIAL_SEGMENTS = 24 + +export function buildDownspoutGeometry(node: DownspoutNode): THREE.BufferGeometry { + const radius = Math.max(0.01, node.diameter / 2) + const length = Math.max(0.1, node.length) + + // CylinderGeometry's default axis is +Y, centred at the origin. + // We want the TOP at Y = 0 and the BOTTOM at Y = −length, so + // translate down by half the length. + const pipe = new THREE.CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS).toNonIndexed() + pipe.translate(0, -length / 2, 0) + pipe.computeVertexNormals() + return pipe +} diff --git a/packages/nodes/src/downspout/index.ts b/packages/nodes/src/downspout/index.ts new file mode 100644 index 000000000..ffe441894 --- /dev/null +++ b/packages/nodes/src/downspout/index.ts @@ -0,0 +1,3 @@ +export { downspoutDefinition } from './definition' +export { buildDownspoutGeometry } from './geometry' +export { DownspoutNode } from './schema' diff --git a/packages/nodes/src/downspout/parametrics.ts b/packages/nodes/src/downspout/parametrics.ts new file mode 100644 index 000000000..dd0e0797d --- /dev/null +++ b/packages/nodes/src/downspout/parametrics.ts @@ -0,0 +1,14 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { DownspoutNode } from './schema' + +export const downspoutParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'length', kind: 'number', unit: 'm', min: 0.1, max: 8, step: 0.05 }, + { key: 'diameter', kind: 'number', unit: 'm', min: 0.02, max: 0.15, step: 0.005 }, + ], + }, + ], +} diff --git a/packages/nodes/src/downspout/preview.tsx b/packages/nodes/src/downspout/preview.tsx new file mode 100644 index 000000000..7e5a50bad --- /dev/null +++ b/packages/nodes/src/downspout/preview.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useEffect, useMemo } from 'react' +import * as THREE from 'three' +import { buildDownspoutGeometry } from './geometry' +import type { DownspoutNode } from './schema' + +/** + * Translucent ghost of a downspout — same geometry as the committed + * pipe so the placement ghost matches what lands on click. No + * internal transform wrapper; the placement tool nests this under + * the gutter / outlet chain so the position math stays in one place. + */ +const DownspoutPreview = ({ node }: { node: DownspoutNode }) => { + const geometry = useMemo(() => buildDownspoutGeometry(node), [node.length, node.diameter]) + + const material = useMemo( + () => + new THREE.MeshStandardMaterial({ + color: 0xff_ff_ff, + emissive: 0xff_ff_ff, + emissiveIntensity: 0.12, + roughness: 0.7, + metalness: 0.2, + transparent: true, + opacity: 0.55, + depthWrite: false, + side: THREE.FrontSide, + }), + [], + ) + + const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) + + useEffect( + () => () => { + geometry.dispose() + edgesGeometry.dispose() + material.dispose() + }, + [geometry, edgesGeometry, material], + ) + + return ( + <> + {}} /> + + + + + ) +} + +export default DownspoutPreview diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx new file mode 100644 index 000000000..ebcaab94c --- /dev/null +++ b/packages/nodes/src/downspout/renderer.tsx @@ -0,0 +1,147 @@ +'use client' + +import { + type AnyNodeId, + type DownspoutNode, + type GutterNode, + type RoofSegmentNode, + useLiveNodeOverrides, + useRegistry, + useScene, +} from '@pascal-app/core' +import { + type ColorPreset, + createMaterial, + createMaterialFromPresetRef, + createSurfaceRoleMaterial, + useNodeEvents, + useViewer, +} from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' +import { computeEaveY } from '../gutter/eave-snap' +import { resolveGutterOutletPlacement } from '../gutter/outlet-lookup' +import { buildDownspoutGeometry } from './geometry' + +const defaultMaterial = new THREE.MeshStandardMaterial({ + color: 0xff_ff_ff, + roughness: 0.7, + metalness: 0.25, +}) + +/** + * Downspout renderer. Mount chain mirrors the gutter's, then nests + * one level deeper into the outlet position in gutter-mesh-local: + * + * segment.position → segment.rotation (Y) + * → [gutter.position[0], computeEaveY(segment), gutter.position[2]] + * → gutter.rotation (Y) + * → [outlet.x, outlet.y, outlet.z] + * → mesh (pipe descends from Y = 0) + * + * Pulling the gutter's eave Y from `computeEaveY(effectiveSegment)` + * means the downspout follows wallHeight / overhang / pitch changes + * live, on the same frame as the gutter. The gutter and segment also + * subscribe to `useLiveNodeOverrides` so drag-in-flight changes flow + * through too. + */ +const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { + const ref = useRef(null!) + useRegistry(storeNode.id, 'downspout', ref) + const handlers = useNodeEvents(storeNode, 'downspout') + const shading = useViewer((s) => s.shading) + const textures = useViewer((s) => s.textures) + const colorPreset: ColorPreset = useViewer((s) => s.colorPreset) + const sceneTheme = useViewer((s) => s.sceneTheme) + + const overrides = useLiveNodeOverrides( + (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, + ) + const node: DownspoutNode = overrides + ? ({ ...storeNode, ...overrides } as DownspoutNode) + : storeNode + + // Host gutter — both scene + live overrides so drag-in-flight gutter + // moves (length / position) reposition the downspout immediately. + const gutter = useScene((s) => + node.gutterId ? (s.nodes[node.gutterId as AnyNodeId] as GutterNode | undefined) : undefined, + ) + const gutterOverrides = useLiveNodeOverrides((s) => + node.gutterId + ? (s.get(node.gutterId as AnyNodeId) as Partial | undefined) + : undefined, + ) + const effectiveGutter: GutterNode | undefined = gutter + ? gutterOverrides + ? ({ ...gutter, ...gutterOverrides } as GutterNode) + : gutter + : undefined + + // Segment of the host gutter (the downspout's own scene-graph parent + // is the same segment — same as roof accessories — so the chain + // segment → gutter-mesh-local is what we need to reach the outlet). + const segment = useScene((s) => + effectiveGutter?.roofSegmentId + ? (s.nodes[effectiveGutter.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined) + : undefined, + ) + const segmentOverrides = useLiveNodeOverrides((s) => + effectiveGutter?.roofSegmentId + ? (s.get(effectiveGutter.roofSegmentId as AnyNodeId) as + | Partial + | undefined) + : undefined, + ) + const effectiveSegment: RoofSegmentNode | undefined = segment + ? segmentOverrides + ? ({ ...segment, ...segmentOverrides } as RoofSegmentNode) + : segment + : undefined + + const geometry = useMemo(() => buildDownspoutGeometry(node), [node.length, node.diameter]) + useEffect(() => () => geometry.dispose(), [geometry]) + + const material = useMemo(() => { + if (!textures || (!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]) + + if (!effectiveGutter || !effectiveSegment) return null + const outlet = resolveGutterOutletPlacement(effectiveGutter) + if (!outlet) return null + + const segPos = effectiveSegment.position ?? [0, 0, 0] + const segRotY = effectiveSegment.rotation ?? 0 + const liveEaveY = computeEaveY(effectiveSegment) + const gutterRotY = effectiveGutter.rotation ?? 0 + + return ( + + + + + + + + ) +} + +export default DownspoutRenderer diff --git a/packages/nodes/src/downspout/schema.ts b/packages/nodes/src/downspout/schema.ts new file mode 100644 index 000000000..d4fded13a --- /dev/null +++ b/packages/nodes/src/downspout/schema.ts @@ -0,0 +1,3 @@ +// Schema lives in core (referenced by the AnyNode union). Re-export so +// every downspout-related import stays inside @pascal-app/nodes/downspout. +export { DownspoutNode } from '@pascal-app/core' diff --git a/packages/nodes/src/downspout/tool.tsx b/packages/nodes/src/downspout/tool.tsx new file mode 100644 index 000000000..2b7e2454c --- /dev/null +++ b/packages/nodes/src/downspout/tool.tsx @@ -0,0 +1,158 @@ +'use client' + +import { + type AnyNodeId, + DownspoutNode, + emitter, + type GutterEvent, + type RoofSegmentNode, + useScene, +} from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' +import { computeEaveY } from '../gutter/eave-snap' +import { resolveGutterOutletPlacement } from '../gutter/outlet-lookup' +import { downspoutDefinition } from './definition' +import DownspoutPreview from './preview' + +type PreviewTarget = { + segment: { position: [number, number, number]; rotation: number; eaveY: number } + gutter: { position: [number, number, number]; rotation: number } + outlet: { x: number; y: number; z: number; bore: number } +} + +/** + * Downspout placement tool. Listens for `gutter:*` events and only + * highlights gutters whose `outletSide` is enabled — a downspout + * without an outlet is meaningless, so the user is gated to set the + * outlet on the gutter first. + * + * On click, the new downspout is parented (scene-graph) to the same + * roof-segment that hosts the gutter — that's the same lookup roof + * accessories already do, so the downspout naturally renders under + * the segment's `roof-elements` group alongside the gutter. + * + * Length defaults to the eave-Y at click time, so the pipe drops to + * Y = 0 (segment-local ground plane) without the user having to set + * it. They can tweak the length in the inspector afterward. + */ +const DownspoutTool = () => { + const activeBuildingId = useViewer((s) => s.selection.buildingId) + const setSelection = useViewer((s) => s.setSelection) + + const [target, setTarget] = useState(null) + + const previewNode = useMemo( + () => + DownspoutNode.parse({ + ...downspoutDefinition.defaults(), + name: 'Downspout', + }), + [], + ) + + useEffect(() => { + if (!activeBuildingId) return + + const computeTarget = (event: GutterEvent): PreviewTarget | null => { + const gutter = event.node + const outlet = resolveGutterOutletPlacement(gutter) + if (!outlet) return null + const segmentId = gutter.roofSegmentId as AnyNodeId | undefined + if (!segmentId) return null + const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined + if (!segment) return null + return { + segment: { + position: (segment.position ?? [0, 0, 0]) as [number, number, number], + rotation: segment.rotation ?? 0, + eaveY: computeEaveY(segment), + }, + gutter: { + position: (gutter.position ?? [0, 0, 0]) as [number, number, number], + rotation: gutter.rotation ?? 0, + }, + outlet, + } + } + + const updatePreview = (event: GutterEvent) => { + const next = computeTarget(event) + if (next) { + setTarget(next) + event.stopPropagation() + } + } + + const onClick = (event: GutterEvent) => { + const gutter = event.node + const outlet = resolveGutterOutletPlacement(gutter) + if (!outlet) return + const segmentId = gutter.roofSegmentId as AnyNodeId | undefined + if (!segmentId) return + const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined + if (!segment) return + + // Default length: drop from the gutter outlet down to the + // segment's local Y = 0 plane. The outlet sits at + // (eaveY + outlet.y) where outlet.y = −size. So the drop is + // (eaveY − size). + const dropLength = Math.max(0.1, computeEaveY(segment) + outlet.y) + + const downspout = DownspoutNode.parse({ + ...downspoutDefinition.defaults(), + name: 'Downspout', + gutterId: gutter.id, + length: dropLength, + diameter: outlet.bore * 2, + }) + const state = useScene.getState() + state.createNode(downspout, segmentId) + state.dirtyNodes.add(segmentId) + setSelection({ selectedIds: [downspout.id] }) + triggerSFX('sfx:item-place') + event.stopPropagation() + } + + emitter.on('gutter:move', updatePreview) + emitter.on('gutter:enter', updatePreview) + emitter.on('gutter:click', onClick) + + return () => { + emitter.off('gutter:move', updatePreview) + emitter.off('gutter:enter', updatePreview) + emitter.off('gutter:click', onClick) + } + }, [activeBuildingId, setSelection]) + + if (!activeBuildingId || !target) return null + + return ( + + + + + + + + ) +} + +function previewNodeWithDefaults( + base: ReturnType, + target: PreviewTarget, +): typeof base { + // Snap preview to the same dimensions a commit would use — bore + // diameter from the gutter, drop length to the segment Y=0 plane. + return { + ...base, + diameter: target.outlet.bore * 2, + length: Math.max(0.1, target.segment.eaveY + target.outlet.y), + } as typeof base +} + +export default DownspoutTool \ No newline at end of file diff --git a/packages/nodes/src/gutter/downspouts-panel.tsx b/packages/nodes/src/gutter/downspouts-panel.tsx new file mode 100644 index 000000000..18ed0656f --- /dev/null +++ b/packages/nodes/src/gutter/downspouts-panel.tsx @@ -0,0 +1,123 @@ +'use client' + +import { + type AnyNodeId, + DownspoutNode, + type DownspoutNode as DownspoutNodeType, + type GutterNode, + type RoofSegmentNode, + useScene, +} from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + PanelSection, + triggerSFX, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useShallow } from 'zustand/react/shallow' +import { computeEaveY } from './eave-snap' +import { resolveGutterOutletPlacement } from './outlet-lookup' + +/** + * Downspouts subsection rendered at the bottom of the gutter + * inspector. Same shape as the roof inspector's gutter / vent lists: + * one button per existing downspout that selects it (showing its own + * inspector), and an "Add Downspout" button below that immediately + * creates a new one parented to the same roof segment. + * + * Each Add click adds ANOTHER downspout to the list — multiple + * downspouts per gutter is allowed (real residential gutters + * sometimes split a long run between two downspouts). The Add button + * stays disabled when the gutter has no outlet, since the downspout + * has nowhere to attach. + */ +export default function DownspoutsPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined + const setSelection = useViewer((s) => s.setSelection) + + const gutter = useScene((s) => + selectedId ? (s.nodes[selectedId] as GutterNode | undefined) : undefined, + ) + + const downspouts = useScene( + useShallow((s) => { + if (!selectedId) return [] as DownspoutNodeType[] + const out: DownspoutNodeType[] = [] + for (const n of Object.values(s.nodes)) { + if (n?.type === 'downspout' && n.gutterId === selectedId) { + out.push(n as DownspoutNodeType) + } + } + return out + }), + ) + + if (!gutter || gutter.type !== 'gutter') return null + + const outletEnabled = (gutter.outletSide ?? 'none') !== 'none' + + const handleSelectDownspout = (id: AnyNodeId) => { + setSelection({ selectedIds: [id] }) + } + + const handleAddDownspout = () => { + if (!outletEnabled) return + const segmentId = gutter.roofSegmentId as AnyNodeId | undefined + if (!segmentId) return + const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined + if (!segment) return + const outlet = resolveGutterOutletPlacement(gutter) + if (!outlet) return + + // Default length: drop from outlet (at eaveY − size in segment + // frame) down to segment Y = 0. Matches the placement tool's + // default so click-to-add and click-on-gutter land the same drop. + const dropLength = Math.max(0.1, computeEaveY(segment) + outlet.y) + + const downspout = DownspoutNode.parse({ + ...{ + name: 'Downspout', + gutterId: gutter.id, + length: dropLength, + diameter: outlet.bore * 2, + }, + }) + const state = useScene.getState() + state.createNode(downspout, segmentId) + state.dirtyNodes.add(segmentId) + setSelection({ selectedIds: [downspout.id] }) + triggerSFX('sfx:item-place') + } + + return ( + +
+ {downspouts.map((d, i) => ( + + ))} + + + + {!outletEnabled && ( +

+ Turn the Outlet on to add a downspout. +

+ )} +
+
+ ) +} + diff --git a/packages/nodes/src/gutter/outlet-lookup.ts b/packages/nodes/src/gutter/outlet-lookup.ts new file mode 100644 index 000000000..160d735c6 --- /dev/null +++ b/packages/nodes/src/gutter/outlet-lookup.ts @@ -0,0 +1,67 @@ +import type { GutterNode } from '@pascal-app/core' + +/** + * Outlet position lookup — used by the downspout renderer to mount + * the pipe at the gutter's outlet without having to walk the gutter's + * geometry pipeline. + * + * Returns the outlet's center in GUTTER-MESH-LOCAL frame (i.e. after + * the gutter's own `position` + `rotation` have already been applied + * by the renderer chain): X is along the gutter length, Y is the + * gutter's vertical extent (−size, the trough floor), Z is outward + * (the profile-dependent floor midpoint). + * + * Ignores mitres — when a gutter end is mitred its cap collapses, + * which shifts the outlet's clamp bound by `wallThickness` (≤ 6 mm + * at default settings). The downspout drift in that case is below + * what reads visually; the gutter's own CSG drill still cuts in the + * exact spot since it sees the full mitre context. + */ + +const OUTLET_WALL_THICKNESS = 0.003 + +export type GutterOutletPlacement = { + /** Gutter-mesh-local X — along the length axis, signed from center. */ + x: number + /** Gutter-mesh-local Y — the trough floor at −size. */ + y: number + /** Gutter-mesh-local Z — profile-dependent floor midpoint. */ + z: number + /** Outlet bore radius (the open hole the downspout descends through). */ + bore: number +} + +function profileFloorMidZ(profile: GutterNode['profile'], size: number): number { + if (profile === 'half-round') return size + if (profile === 'box') return size / 2 + return size * 0.4 +} + +export function resolveGutterOutletPlacement(gutter: GutterNode): GutterOutletPlacement | null { + const side = gutter.outletSide ?? 'none' + if (side === 'none') return null + + const len = Math.max(0.05, gutter.length) + const size = Math.max(0.04, gutter.size) + const t = Math.min(Math.max(0.001, gutter.thickness), size * 0.4) + const bore = Math.max(0.01, (gutter.outletDiameter ?? 0.07) / 2) + const outer = bore + OUTLET_WALL_THICKNESS + const inset = Math.max(outer, gutter.outletInset ?? 0.15) + + // Default-cap reservation — no mitre awareness here; see header note. + const capLeftLen = (gutter.endCapLeft ?? true) ? t : 0 + const capRightLen = (gutter.endCapRight ?? true) ? t : 0 + + const minX = -len / 2 + capLeftLen + outer + const maxX = len / 2 - capRightLen - outer + if (maxX <= minX) return null + let x = side === 'left' ? -len / 2 + capLeftLen + inset : len / 2 - capRightLen - inset + x = Math.max(minX, Math.min(maxX, x)) + + return { + x, + y: -size, + z: profileFloorMidZ(gutter.profile ?? 'k-style', size), + bore, + } +} diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts index 666beca3b..6b82f8d07 100644 --- a/packages/nodes/src/gutter/parametrics.ts +++ b/packages/nodes/src/gutter/parametrics.ts @@ -86,4 +86,8 @@ export const gutterParametrics: ParametricDescriptor = { ], }, ], + // Lazy-loaded section that lists every downspout attached to this + // gutter and offers an Add button at the bottom — same layout as + // the roof inspector's gutter / vent lists. + trailingSection: () => import('./downspouts-panel'), } diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index b06f80ceb..c9620014c 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -6,6 +6,7 @@ import { chimneyDefinition } from './chimney' import { columnDefinition } from './column' import { doorDefinition } from './door' import { dormerDefinition } from './dormer' +import { downspoutDefinition } from './downspout' import { elevatorDefinition } from './elevator' import { fenceDefinition } from './fence' import { guideDefinition } from './guide' @@ -80,6 +81,7 @@ export const builtinPlugin: Plugin = { skylightDefinition as unknown as AnyNodeDefinition, dormerDefinition as unknown as AnyNodeDefinition, gutterDefinition as unknown as AnyNodeDefinition, + downspoutDefinition as unknown as AnyNodeDefinition, ], } @@ -90,6 +92,7 @@ export { chimneyDefinition } from './chimney' export { columnDefinition } from './column' export { doorDefinition } from './door' export { dormerDefinition } from './dormer' +export { downspoutDefinition } from './downspout' export { elevatorDefinition } from './elevator' export { fenceDefinition } from './fence' export { guideDefinition } from './guide' diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index d8f27b4c4..1c1813751 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -229,7 +229,8 @@ export default function RoofPanel() { | 'solar-panel' | 'skylight' | 'dormer' - | 'gutter', + | 'gutter' + | 'downspout', ) => { triggerSFX('sfx:item-pick') useEditor.getState().setTool(kind) From 16d1946e01b3d98090a28e61a8307993a78775e0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 30 May 2026 15:16:26 +0530 Subject: [PATCH 24/35] chore: pending floorplan alignment-guide work-in-progress Snapshot of in-flight alignment-guide files that have been sitting in the working tree (not authored in this session). New service + zustand store wire up the data; the floorplan layer + overlay are the visible consumers. Committed as-is to clear the tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/index.ts | 1 + packages/core/src/services/alignment.test.ts | 87 +++++++++ packages/core/src/services/alignment.ts | 176 ++++++++++++++++++ packages/core/src/services/index.ts | 10 + .../core/src/store/use-alignment-guides.ts | 21 +++ .../floorplan-alignment-guide-layer.tsx | 137 ++++++++++++++ .../floorplan-registry-move-overlay.tsx | 77 +++++++- .../src/components/editor/floorplan-panel.tsx | 7 + 8 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/services/alignment.test.ts create mode 100644 packages/core/src/services/alignment.ts create mode 100644 packages/core/src/store/use-alignment-guides.ts create mode 100644 packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 663376cc2..3ab07c8dd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -106,6 +106,7 @@ export { type LiveNodeOverrides, getEffectiveNode, } from './store/use-live-node-overrides' +export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch' diff --git a/packages/core/src/services/alignment.test.ts b/packages/core/src/services/alignment.test.ts new file mode 100644 index 000000000..3fa8aaaa6 --- /dev/null +++ b/packages/core/src/services/alignment.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test' +import { type AlignmentAnchor, bboxAnchors, resolveAlignment } from './alignment' + +function center(nodeId: string, x: number, z: number): AlignmentAnchor { + return { nodeId, kind: 'center', x, z } +} + +describe('resolveAlignment', () => { + test('returns empty when no candidates within threshold', () => { + const result = resolveAlignment({ + moving: [center('m', 0, 0)], + candidates: [center('a', 1, 1)], + threshold: 0.5, + }) + expect(result.guides).toEqual([]) + expect(result.snap).toBeNull() + }) + + test('snaps moving anchor onto candidate X when within threshold', () => { + const result = resolveAlignment({ + moving: [center('m', 0.03, 5)], + candidates: [center('a', 0, 2)], + threshold: 0.1, + }) + expect(result.snap).toEqual({ dx: -0.03, dz: 0 }) + expect(result.guides).toHaveLength(1) + expect(result.guides[0]!.axis).toBe('x') + expect(result.guides[0]!.coord).toBe(0) + expect(result.guides[0]!.from.z).toBe(2) + expect(result.guides[0]!.to.z).toBe(5) + }) + + test('snaps both axes when both match', () => { + const result = resolveAlignment({ + moving: [center('m', 0.03, 0.04)], + candidates: [center('a', 0, 0)], + threshold: 0.1, + }) + expect(result.snap).toEqual({ dx: -0.03, dz: -0.04 }) + expect(result.guides).toHaveLength(2) + }) + + test('picks closest candidate per axis', () => { + const result = resolveAlignment({ + moving: [center('m', 0.08, 0)], + candidates: [center('a', 0, 5), center('b', 0.1, 5), center('c', 0.05, 10)], + threshold: 0.1, + }) + // |0.1 - 0.08| = 0.02 wins over |0.05 - 0.08| = 0.03 and |0 - 0.08| = 0.08 + expect(result.snap?.dx).toBeCloseTo(0.02, 10) + expect(result.guides[0]!.candidateNodeId).toBe('b') + }) + + test('threshold = 0 disables alignment', () => { + const result = resolveAlignment({ + moving: [center('m', 0, 0)], + candidates: [center('a', 0, 0)], + threshold: 0, + }) + expect(result.guides).toEqual([]) + expect(result.snap).toBeNull() + }) + + test('distance is the perpendicular gap to the matched axis', () => { + const result = resolveAlignment({ + moving: [center('m', 0.02, 3)], + candidates: [center('a', 0, 0)], + threshold: 0.1, + }) + // After snap: moving at (0, 3). X guide runs along x=0 from z=0 to z=3. + expect(result.guides[0]!.distance).toBeCloseTo(3, 10) + }) +}) + +describe('bboxAnchors', () => { + test('returns 9 anchors with correct kinds and positions', () => { + const anchors = bboxAnchors('node', 0, 0, 2, 4) + expect(anchors).toHaveLength(9) + const corners = anchors.filter((a) => a.kind === 'corner') + const edges = anchors.filter((a) => a.kind === 'edge-mid') + const centers = anchors.filter((a) => a.kind === 'center') + expect(corners).toHaveLength(4) + expect(edges).toHaveLength(4) + expect(centers).toHaveLength(1) + expect(centers[0]).toEqual({ nodeId: 'node', kind: 'center', x: 1, z: 2 }) + }) +}) diff --git a/packages/core/src/services/alignment.ts b/packages/core/src/services/alignment.ts new file mode 100644 index 000000000..ce4f8ba66 --- /dev/null +++ b/packages/core/src/services/alignment.ts @@ -0,0 +1,176 @@ +/** + * Pure alignment-guide resolver — no React, no DOM, no scene access. + * + * Given a moving object's anchor points at its proposed position and a + * pool of candidate anchors from nearby static objects, returns: + * - the best per-axis matches as `Guide` rendering primitives, and + * - an optional `{ dx, dz }` snap delta the caller can apply. + * + * Anchors are 2D points on the floor plane (XZ, in world meters). The + * resolver picks at most one match per axis: the smallest |Δx| match + * snaps X; the smallest |Δz| match snaps Z. This mirrors Figma's + * behaviour — guides appear along the matched axes, regardless of how + * many neighbours could have matched. + * + * Two guides max per call keeps the visual signal sharp at the cost of + * not surfacing every possible alignment at once. Multi-guide ("this + * lines up with three things") is intentionally out of scope for v1. + */ + +export type AnchorKind = 'corner' | 'edge-mid' | 'center' + +export type AlignmentAnchor = { + /** Owning node id — informational; resolver does not use it. */ + nodeId: string + kind: AnchorKind + x: number + z: number +} + +export type AlignmentGuideAxis = 'x' | 'z' + +/** + * Rendering primitive — a guide line on the floor plane. + * + * `axis === 'x'`: vertical guide. Both endpoints share `coord` as their X. + * `axis === 'z'`: horizontal guide. Both endpoints share `coord` as their Z. + * + * The line spans from the matched candidate anchor to the moving anchor + * after snap. Renderers extend visually beyond the endpoints if they want + * Figma-style "infinite line" feel. + */ +export type AlignmentGuide = { + axis: AlignmentGuideAxis + coord: number + from: { x: number; z: number } + to: { x: number; z: number } + movingAnchorKind: AnchorKind + candidateAnchorKind: AnchorKind + candidateNodeId: string + /** Perpendicular distance between the two anchors (used by the distance pill). */ + distance: number +} + +export type ResolveAlignmentInput = { + /** Anchors of the moving node, positioned at the proposed (pre-snap) location. */ + moving: readonly AlignmentAnchor[] + /** Anchors from every other candidate node the caller has already filtered. */ + candidates: readonly AlignmentAnchor[] + /** + * Max |Δ| (meters) for an anchor pair to count as a match. Typically + * derived from a screen-pixel budget × current units-per-pixel so the + * snap feel is zoom-invariant. + */ + threshold: number +} + +export type ResolveAlignmentResult = { + guides: AlignmentGuide[] + /** + * Delta the caller should add to the moving node's planar position so + * its anchors land on the matched axes. `null` when no axis matched. + */ + snap: { dx: number; dz: number } | null +} + +const EMPTY: ResolveAlignmentResult = { guides: [], snap: null } + +export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignmentResult { + const { moving, candidates, threshold } = input + if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return EMPTY + + // Best match per axis: smallest |Δ| across all (moving, candidate) pairs. + // Tie-break by candidate anchor kind priority (center > edge-mid > corner) + // so visually meaningful matches win when |Δ| is equal. + let bestX: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null + let bestZ: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null + + for (const m of moving) { + for (const c of candidates) { + const dx = c.x - m.x + const dz = c.z - m.z + const adx = Math.abs(dx) + const adz = Math.abs(dz) + if (adx <= threshold && (bestX === null || adx < Math.abs(bestX.delta))) { + bestX = { delta: dx, m, c } + } + if (adz <= threshold && (bestZ === null || adz < Math.abs(bestZ.delta))) { + bestZ = { delta: dz, m, c } + } + } + } + + if (!bestX && !bestZ) return EMPTY + + const dxSnap = bestX?.delta ?? 0 + const dzSnap = bestZ?.delta ?? 0 + const guides: AlignmentGuide[] = [] + + if (bestX) { + // X-axis match: vertical guide at x = bestX.c.x. The moving anchor + // ends up at (c.x, m.z + dzSnap). Span the line between them. + const snappedMz = bestX.m.z + dzSnap + const z1 = Math.min(bestX.c.z, snappedMz) + const z2 = Math.max(bestX.c.z, snappedMz) + guides.push({ + axis: 'x', + coord: bestX.c.x, + from: { x: bestX.c.x, z: z1 }, + to: { x: bestX.c.x, z: z2 }, + movingAnchorKind: bestX.m.kind, + candidateAnchorKind: bestX.c.kind, + candidateNodeId: bestX.c.nodeId, + distance: Math.abs(snappedMz - bestX.c.z), + }) + } + + if (bestZ) { + const snappedMx = bestZ.m.x + dxSnap + const x1 = Math.min(bestZ.c.x, snappedMx) + const x2 = Math.max(bestZ.c.x, snappedMx) + guides.push({ + axis: 'z', + coord: bestZ.c.z, + from: { x: x1, z: bestZ.c.z }, + to: { x: x2, z: bestZ.c.z }, + movingAnchorKind: bestZ.m.kind, + candidateAnchorKind: bestZ.c.kind, + candidateNodeId: bestZ.c.nodeId, + distance: Math.abs(snappedMx - bestZ.c.x), + }) + } + + return { guides, snap: { dx: dxSnap, dz: dzSnap } } +} + +// ─── Anchor extractors (pure) ───────────────────────────────────────── + +/** + * Produces the 9 standard anchors for an axis-aligned bounding box on the + * floor plane: 4 corners, 4 edge midpoints, 1 center. Suitable for any + * floor-plan entity whose footprint can be expressed as a bbox. + * + * Caller is responsible for computing the bbox — the resolver doesn't + * care how (per-kind dimensions, SVG getBBox(), etc.). + */ +export function bboxAnchors( + nodeId: string, + minX: number, + minZ: number, + maxX: number, + maxZ: number, +): AlignmentAnchor[] { + const cx = (minX + maxX) / 2 + const cz = (minZ + maxZ) / 2 + return [ + { nodeId, kind: 'corner', x: minX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: minZ }, + { nodeId, kind: 'corner', x: maxX, z: maxZ }, + { nodeId, kind: 'corner', x: minX, z: maxZ }, + { nodeId, kind: 'edge-mid', x: cx, z: minZ }, + { nodeId, kind: 'edge-mid', x: maxX, z: cz }, + { nodeId, kind: 'edge-mid', x: cx, z: maxZ }, + { nodeId, kind: 'edge-mid', x: minX, z: cz }, + { nodeId, kind: 'center', x: cx, z: cz }, + ] +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index ac4826f80..bb0cffe89 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -1,3 +1,13 @@ +export { + type AlignmentAnchor, + type AlignmentGuide, + type AlignmentGuideAxis, + type AnchorKind, + bboxAnchors, + resolveAlignment, + type ResolveAlignmentInput, + type ResolveAlignmentResult, +} from './alignment' export { createDragSession, type DragSession, diff --git a/packages/core/src/store/use-alignment-guides.ts b/packages/core/src/store/use-alignment-guides.ts new file mode 100644 index 000000000..ae4991fb0 --- /dev/null +++ b/packages/core/src/store/use-alignment-guides.ts @@ -0,0 +1,21 @@ +// Ephemeral store for Figma-style alignment guides published during a +// move / placement drag. The producer (a tool or move overlay) writes +// guides on pointermove; the renderer (a 2D / 3D guide layer) subscribes +// and draws them. Both sides clear on commit, cancel, and unmount. + +import { create } from 'zustand' +import type { AlignmentGuide } from '../services/alignment' + +type AlignmentGuidesState = { + guides: AlignmentGuide[] + set(guides: AlignmentGuide[]): void + clear(): void +} + +const useAlignmentGuides = create((set) => ({ + guides: [], + set: (guides) => set({ guides }), + clear: () => set({ guides: [] }), +})) + +export default useAlignmentGuides diff --git a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx new file mode 100644 index 000000000..5244e9f4e --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx @@ -0,0 +1,137 @@ +'use client' + +import { useAlignmentGuides } from '@pascal-app/core' +import { memo } from 'react' +import { useFloorplanRender } from './floorplan-render-context' + +/** + * Figma-style alignment guides for the 2D floor plan. + * + * Subscribes to `useAlignmentGuides` — populated by + * `FloorplanRegistryMoveOverlay` (Path 2) during a generic free-translate + * drag. Each guide renders as a red line between the moving and matched + * candidate anchors with small `×` end-caps. A distance pill is drawn at + * the line's midpoint when the perpendicular gap is non-zero. + * + * Stroke widths and handle radii are scaled by `unitsPerPixel` so they + * stay a constant size on screen no matter the zoom. Text labels are + * counter-rotated by `sceneRotationDeg` so they read upright even when + * the building rotation rotates the scene ``. + * + * Mounted inside the `data-floorplan-scene` group so coordinates match + * world meters 1:1 with the rest of the floor plan. + */ +export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() { + const guides = useAlignmentGuides((s) => s.guides) + const ctx = useFloorplanRender() + + if (guides.length === 0) return null + + const upp = ctx?.unitsPerPixel ?? 0.01 + const sceneRot = ctx?.sceneRotationDeg ?? 0 + + // Pixel-budgeted sizes converted to world meters so visuals stay + // constant across zoom. Numbers picked to mirror Figma's snap chrome. + const stroke = 1 * upp + const xCapSize = 4 * upp + const pillFontSize = 11 * upp + const pillPadX = 5 * upp + const pillPadY = 3 * upp + const pillRadius = 3 * upp + const pillOffset = 8 * upp + + const color = '#ef4444' // tailwind red-500 — matches Figma's snap red + + return ( + + {guides.map((guide, i) => { + const { from, to, axis } = guide + const midX = (from.x + to.x) / 2 + const midZ = (from.z + to.z) / 2 + const distMeters = guide.distance + + // Pill placed offset perpendicular to the guide's axis so it + // doesn't sit on top of the line itself. For an X-axis guide + // (vertical line) we offset along Z; for a Z-axis guide we + // offset along X. + const pillX = axis === 'x' ? midX + pillOffset : midX + const pillZ = axis === 'z' ? midZ + pillOffset : midZ + const distLabel = formatMeters(distMeters) + const charWidth = pillFontSize * 0.55 + const pillWidth = distLabel.length * charWidth + pillPadX * 2 + const pillHeight = pillFontSize + pillPadY * 2 + + return ( + + + + + {distMeters > 1e-4 && ( + // Counter-rotate the pill so it stays upright when the + // scene `` is rotated by building rotation. SVG's + // `transform` runs in the local coord system, so the + // rotation pivots around the pill's center. + + + + {distLabel} + + + )} + + ) + })} + + ) +}) + +function XCap({ + color, + size, + stroke, + x, + y, +}: { + color: string + size: number + stroke: number + x: number + y: number +}) { + return ( + + + + + ) +} + +function formatMeters(meters: number): string { + // Sub-centimetre = "0". Otherwise show with up to 2 decimals, trimmed. + if (meters < 0.005) return '0' + const fixed = meters.toFixed(2) + return `${fixed.replace(/\.?0+$/, '')}m` +} diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 433534708..67da92ef6 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -1,13 +1,17 @@ 'use client' import { + type AlignmentAnchor, type AnyNode, type AnyNodeId, + bboxAnchors, type FloorplanMoveTargetSession, nodeRegistry, pauseSceneHistory, + resolveAlignment, resumeSceneHistory, snapPointToGrid, + useAlignmentGuides, useLiveNodeOverrides, useLiveTransforms, useScene, @@ -20,6 +24,12 @@ import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts' const GRID_STEP = 0.5 +// Figma-style alignment snap threshold. Meters in world space; 8cm gives +// a comfortable "magnetic" pull at default zoom without fighting the +// grid snap. Held fixed for v1 — a future revision can scale this with +// the SVG's units-per-pixel so the feel stays constant across zoom. +const ALIGNMENT_THRESHOLD_M = 0.08 + /** * Cursor-driven placement for registered kinds in the floor plan. * @@ -374,6 +384,24 @@ export function FloorplanRegistryMoveOverlay() { } ).position ?? [0, 0, 0]) as [number, number, number] + // SVG units in this floorplan map 1:1 to world meters, and the + // `` entry has no transform of its own when at rest, + // so its untransformed bbox IS the world-space footprint. Cache the + // moving entry's local bbox once (relative to originalPosition) and + // derive anchors at any proposed (sx, sz) by translating it. + const movingLocalBBox = entry.getBBox() + const candidateAnchors: AlignmentAnchor[] = [] + const allEntries = scene.querySelectorAll('[data-node-id]') + for (const el of Array.from(allEntries)) { + const otherId = el.getAttribute('data-node-id') + if (!otherId || otherId === movingNode.id) continue + const b = (el as SVGGraphicsElement).getBBox() + if (b.width <= 0 || b.height <= 0) continue + candidateAnchors.push( + ...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height), + ) + } + let lastSnapped: [number, number] | null = null const onMove = (event: PointerEvent) => { @@ -384,11 +412,49 @@ export function FloorplanRegistryMoveOverlay() { if (!target?.closest('[data-floorplan-scene]')) return const m = toMeters(event.clientX, event.clientY) if (!m) return - const [sx, sz] = snapPointToGrid([m[0], m[1]], GRID_STEP) - const dx = sx - originalPosition[0] - const dz = sz - originalPosition[2] + + // 1) Grid snap baseline (unchanged behaviour with Alt held). + const [gridX, gridZ] = snapPointToGrid([m[0], m[1]], GRID_STEP) + + // 2) Alignment snap layered on top. Treat the grid-snapped point + // as the "proposed" position so alignment competes from a stable + // base rather than the raw cursor jitter. Alt bypasses alignment + // entirely — same affordance Path 1 advertises in its "No Snap" + // hint chip. + let finalX = gridX + let finalZ = gridZ + if (!event.altKey && candidateAnchors.length > 0) { + // Translate the cached local bbox to the proposed pos to get the + // moving anchors at that location. The entry's untransformed + // bbox is in world meters relative to the node's origin, so a + // simple translate suffices. + const dxProposed = gridX - originalPosition[0] + const dzProposed = gridZ - originalPosition[2] + const movingAnchors = bboxAnchors( + movingNode.id, + movingLocalBBox.x + dxProposed, + movingLocalBBox.y + dzProposed, + movingLocalBBox.x + movingLocalBBox.width + dxProposed, + movingLocalBBox.y + movingLocalBBox.height + dzProposed, + ) + const result = resolveAlignment({ + moving: movingAnchors, + candidates: candidateAnchors, + threshold: ALIGNMENT_THRESHOLD_M, + }) + if (result.snap) { + finalX += result.snap.dx + finalZ += result.snap.dz + } + useAlignmentGuides.getState().set(result.guides) + } else { + useAlignmentGuides.getState().clear() + } + + const dx = finalX - originalPosition[0] + const dz = finalZ - originalPosition[2] entry.setAttribute('transform', `translate(${dx} ${dz})`) - lastSnapped = [sx, sz] + lastSnapped = [finalX, finalZ] } const onPointerUp = (event: PointerEvent) => { @@ -414,12 +480,14 @@ export function FloorplanRegistryMoveOverlay() { } } entry.removeAttribute('transform') + useAlignmentGuides.getState().clear() setMovingNode(null) } const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { entry.removeAttribute('transform') + useAlignmentGuides.getState().clear() setMovingNode(null) } } @@ -432,6 +500,7 @@ export function FloorplanRegistryMoveOverlay() { window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKey) entry.removeAttribute('transform') + useAlignmentGuides.getState().clear() } }, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def]) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 05012ebeb..fed85ad0c 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -74,6 +74,7 @@ import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' import type { GuideUiState } from '../../store/use-editor' import useEditor from '../../store/use-editor' +import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers' import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu' @@ -9132,6 +9133,12 @@ export function FloorplanPanel() { attribute below); see floorplan-registry-move-overlay.tsx. */} + {/* Figma-style alignment guides published by the move + overlay during a free-translate drag. Sits above the + registry layer so the red lines and distance pills + paint on top of node geometry. */} + + Date: Mon, 1 Jun 2026 14:40:19 +0530 Subject: [PATCH 25/35] feat(core): gutter multi-outlet model + downspout routing options Replace the gutter's single outletSide/outletInset/outletDiameter triple with an `outlets` array of `{ id, offset, diameter }` so one run can host several downspouts on independent drops instead of stacking on one. Each downspout links to an outlet by `outletId`. Add downspout routing/styling fields: `standoff` (gap proud of the wall), `shape` (auto/round/rect), `strapStyle`/`strapSpacing`, and `terminal` (splash/kickout/straight). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/schema/index.ts | 4 +-- packages/core/src/schema/nodes/downspout.ts | 33 +++++++++++++++++++++ packages/core/src/schema/nodes/gutter.ts | 32 +++++++++++++------- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 4592d0bdf..2a104da61 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -57,6 +57,7 @@ export { type DormerSurfaceMaterialSpec, getEffectiveDormerSurfaceMaterial, } from './nodes/dormer' +export { DownspoutNode } from './nodes/downspout' export { ElevatorDoorPanelStyle, ElevatorDoorStyle, @@ -65,6 +66,7 @@ export { } from './nodes/elevator' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' +export { GutterNode, GutterOutlet } from './nodes/gutter' export type { AnimationEffect, Asset, @@ -83,8 +85,6 @@ export { isLowProfileItemSurface, LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, } from './nodes/item' -export { DownspoutNode } from './nodes/downspout' -export { GutterNode } from './nodes/gutter' export { LevelNode } from './nodes/level' // Nodes export { RidgeVentNode } from './nodes/ridge-vent' diff --git a/packages/core/src/schema/nodes/downspout.ts b/packages/core/src/schema/nodes/downspout.ts index 98cf24104..e3c1de8f1 100644 --- a/packages/core/src/schema/nodes/downspout.ts +++ b/packages/core/src/schema/nodes/downspout.ts @@ -19,6 +19,11 @@ export const DownspoutNode = BaseNode.extend({ // outlet X/Z/diameter — the downspout's actual mount position is // derived from the gutter, not stored. gutterId: z.string().optional(), + // Which of the host gutter's `outlets` this downspout drains, by the + // outlet's `id`. The mount position, bore, and cross-section shape are + // looked up from that outlet — so several downspouts on one gutter no + // longer stack on a single drop. + outletId: z.string().optional(), // Length the pipe extends DOWN from the gutter outlet, in metres. // Default 2.5 m covers a typical residential storey; the placement @@ -28,6 +33,30 @@ export const DownspoutNode = BaseNode.extend({ // Bore diameter, default 0.07 m ≈ 3″ to match the gutter outlet // default. Larger downspouts are common on commercial gutters. diameter: z.number().default(0.07), + // Gap between the pipe's wall-facing surface and the wall face, in + // metres. The downspout's offset elbows step it back from the eave + // overhang to the wall; this controls how far proud of the wall it + // sits (real downspouts mount on standoff brackets ~1–2 cm off the + // wall). Larger values pull the run back OUT toward the eave — the + // escape hatch when the auto-routed wall position doesn't match the + // actual wall (overshoots into it). Default 0.02 m. + standoff: z.number().default(0.02), + + // Cross-section shape. 'auto' follows the host gutter's profile (round + // on half-round, rectangular on k-style / box); 'round' / 'rect' + // override it for a mixed look. + shape: z.enum(['auto', 'round', 'rect']).default('auto'), + + // Wall straps clamping the run to the wall. 'band' renders periodic + // bands; 'none' hides them. Spacing is metres between bands. + strapStyle: z.enum(['band', 'none']).default('band'), + strapSpacing: z.number().default(1.8), + + // What happens at the bottom of the run: + // - 'splash': kickout elbow + a splash block on the ground (default) + // - 'kickout': kickout elbow only (e.g. into a drain pipe) + // - 'straight': no kick — runs straight down (into a grade drain) + terminal: z.enum(['splash', 'kickout', 'straight']).default('splash'), }).describe( dedent` Downspout — a vertical pipe that takes water from a gutter outlet @@ -35,6 +64,10 @@ export const DownspoutNode = BaseNode.extend({ linked to a specific gutter via gutterId for outlet position. - length: vertical pipe length below the gutter outlet - diameter: bore diameter; should match the host gutter's outletDiameter + - standoff: gap the pipe sits proud of the wall (pulls the run out toward the eave) + - shape: cross-section (auto follows the gutter profile, or force round / rect) + - strapStyle / strapSpacing: wall straps clamping the run + - terminal: bottom treatment (splash block, kickout only, or straight down) `, ) diff --git a/packages/core/src/schema/nodes/gutter.ts b/packages/core/src/schema/nodes/gutter.ts index 1797ce152..d9fbc7e44 100644 --- a/packages/core/src/schema/nodes/gutter.ts +++ b/packages/core/src/schema/nodes/gutter.ts @@ -3,6 +3,22 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +// A single drop outlet drilled in the gutter floor. A gutter can carry +// several so a long run can split between multiple downspouts (each +// downspout links to one outlet via its `outletId`). +export const GutterOutlet = z.object({ + // Stable id the downspout references. Generated with `generateId('outlet')`. + id: z.string(), + // Position along the gutter length (gutter-local +X), signed from the + // CENTER. The geometry clamps it inside the end caps at build time, so + // a stored value that no longer fits just rides the nearest bound. + offset: z.number().default(0), + // Bore diameter of this drop. Default 0.07 m ≈ 3″. The cross-section + // SHAPE (round vs rectangular) follows the gutter's profile, not this. + diameter: z.number().default(0.07), +}) +export type GutterOutlet = z.infer + export const GutterNode = BaseNode.extend({ id: objectId('gutter'), type: nodeType('gutter'), @@ -53,15 +69,11 @@ export const GutterNode = BaseNode.extend({ hangerStyle: z.enum(['strap', 'none']).default('strap'), hangerSpacing: z.number().default(0.6), - // Downspout outlet — a short cylindrical drop tube descending from - // the gutter floor where a downspout connects. 'none' (default) so - // existing gutters don't sprout outlets on schema upgrade. Side - // picks the end the outlet sits closest to; inset is the segment- - // local distance from that end; diameter is the bore of the tube - // (default 0.07 m ≈ 3″ — standard residential downspout). - outletSide: z.enum(['none', 'left', 'right']).default('none'), - outletInset: z.number().default(0.15), - outletDiameter: z.number().default(0.07), + // Downspout outlets — short drop tubes descending from the gutter + // floor where downspouts connect. Empty by default so existing + // gutters don't sprout outlets on schema upgrade. Each is drilled + // through the trough floor via CSG; a downspout links to one by id. + outlets: z.array(GutterOutlet).default([]), }).describe( dedent` Gutter — a rain-water channel running along the eave of a roof @@ -71,7 +83,7 @@ export const GutterNode = BaseNode.extend({ - profile: k-style (ogee fascia), half-round, or square box - endCapLeft / endCapRight: close the trough at gutter-local -X / +X - hangerStyle / hangerSpacing: visible metal straps across the rim - - outletSide / outletInset / outletDiameter: drop-tube outlet + - outlets: drop-tube outlets (id + along-length offset + bore diameter) `, ) From fd69b069d2c53db45c5a7fcba18f72442ede986b Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 1 Jun 2026 14:40:32 +0530 Subject: [PATCH 26/35] feat(editor): in-world rotate & move gizmos with live-drag dimension pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new handle-descriptor capabilities to the core registry — a free `translate` handle (ground-plane or wall-normal move cross) and `rotationPlane` for arc-resize (yaw vs spin-flat-against-wall) — plus `measureLabel`, which routes a resize handle's readout to a floating dimension pill instead of its inline chip, and `overrideTarget`, a cross-node redirect for handles that edit a sibling's value. node-arrow-handles implements all four, merging the in-flight drag into `useLiveNodeOverrides` so the mesh moves in real time and commits only on release. Item gains in-world rotate + move gizmos (floor items: world-Y rotate + floor-plane move; wall items: wall-normal spin + wall-face move). Add the shared MeasurementPill (H · L · T) and formatMeasurement, wired into the floating action menu (live wall/fence height drag) and the wall / fence endpoint move tools. Side handles merge live overrides so every affordance tracks the height mid-drag. Item duplicate now drag-to-places (no auto-insert) and the placement coordinator rotates in 45° steps to match the R-key step. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/registry/handles.ts | 81 ++- packages/core/src/registry/index.ts | 1 + .../editor/floating-action-menu.tsx | 116 +++- .../components/editor/measurement-pill.tsx | 75 +++ .../components/editor/node-arrow-handles.tsx | 591 ++++++++++++++++-- .../editor/wall-move-side-handles.tsx | 23 +- .../tools/item/use-placement-coordinator.tsx | 4 +- packages/editor/src/index.tsx | 1 + packages/nodes/src/fence/definition.ts | 3 + .../nodes/src/fence/move-endpoint-tool.tsx | 29 +- packages/nodes/src/item/definition.ts | 143 +++++ packages/nodes/src/item/renderer.tsx | 16 +- .../nodes/src/wall/move-endpoint-tool.tsx | 35 ++ 13 files changed, 1050 insertions(+), 68 deletions(-) create mode 100644 packages/editor/src/components/editor/measurement-pill.tsx diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 181b47ed8..9db7cabd2 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -22,7 +22,7 @@ // - endpoint-move : wall / fence endpoint drag (snapping is bespoke, // so it delegates to a kind-supplied callback) -import type { AnyNode } from '../schema/types' +import type { AnyNode, AnyNodeId } from '../schema/types' import type { SceneApi } from './types' /** @@ -110,9 +110,30 @@ export type LinearResizeHandle = { anchor: HandleAnchor currentValue: (node: N) => number apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** + * Cross-node redirect. By default the drag's live override + the + * committed write both land on the SELECTED node. When this returns + * another node's id, the editor publishes the override to / commits on + * THAT node instead (and `apply` should return that node's patch). + * Used when a node's handle edits a value owned by a sibling — e.g. a + * downspout's side-move arrows slide its outlet, which lives on the + * host gutter (`gutter.outlets[].offset`). The selected node is still + * what `currentValue` / `apply` receive, so the descriptor can read + * the downspout to find its gutter + outlet. + */ + overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined min?: number | ((node: N, sceneApi: SceneApi) => number) max?: number | ((node: N, sceneApi: SceneApi) => number) placement: HandlePlacement + /** + * Dimension this handle steers (e.g. `'height'`). When set, the editor + * publishes it to `activeHandleDrag.label` for the duration of the drag + * so out-of-band overlays (the floating dimension pill) can react, and + * the handle's own in-world value chip is suppressed to avoid showing + * the same number twice. Leave unset for handles that keep their inline + * chip and don't drive any external overlay. + */ + measureLabel?: string /** * Defaults to 'self' (arrow lives in the selected node's own mesh). * 'parent' uses the parent mesh — used by doors/windows whose handles @@ -199,6 +220,16 @@ export type ArcResizeHandle = { * arrow icon, intended for whole-node rotation handles. */ shape?: 'chevron' | 'rotate' + /** + * Plane the angular drag is measured in: + * - 'horizontal' (default): cursor bearing around +Y — whole-node yaw + * (floor items, elevator, stair, roof-segment). + * - 'node-normal': cursor bearing around the node's local +Z axis, in + * the plane perpendicular to it — spins a wall-mounted item flat + * against its wall. The descriptor's `apply` writes the roll + * component (rotation[2]). The gizmo icon stands up into that plane. + */ + rotationPlane?: 'horizontal' | 'node-normal' /** * Pivot point for the angular drag, in the rideObject's local space. * The renderer measures cursor angle (atan2 on the drag plane) around @@ -224,11 +255,7 @@ export type EndpointMoveHandle = { endpoint: 'start' | 'end' placement: HandlePlacement /** Called with the world-space hit on the ground plane. */ - apply: ( - node: N, - worldPoint: readonly [number, number, number], - sceneApi: SceneApi, - ) => Partial + apply: (node: N, worldPoint: readonly [number, number, number], sceneApi: SceneApi) => Partial portal?: HandlePortal } @@ -266,12 +293,54 @@ export type TapActionHandle = { cursor?: Cursor } +/** + * Free ground-plane move. Drag the handle and the node slides across the + * horizontal plane at its base — the renderer raycasts that plane, converts + * the hit into the node's parent-local frame, and reports the new local XZ + * (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release + * with the same live-override → commit-on-release flow as the resize / rotate + * handles. Rendered as a 4-way cross of double-headed arrows. + */ +export type TranslateHandle = { + kind: 'translate' + placement: HandlePlacement + /** + * Plane the drag is constrained to (through the node origin): + * - 'horizontal' (default): the ground plane (world-up normal) — slide + * across the floor. The free axes are parent-local X / Z. + * - 'node-normal': the plane perpendicular to the node's local +Z axis + * (its facing direction) — slide across a wall face. The free axes are + * parent-local X / Y; depth (Z) stays pinned to the surface. + */ + plane?: 'horizontal' | 'node-normal' + /** + * `localPos` is the dragged-to position in the node's PARENT-local frame, + * with the two in-plane axes already grid-snapped (if `snapExtents` is set) + * and the off-plane axis pinned to its drag-start value. Return the patch + * that writes it to the node's position field. + */ + apply: ( + initialNode: N, + localPos: readonly [number, number, number], + sceneApi: SceneApi, + ) => Partial + /** + * Optional grid-snap footprint for the two in-plane axes, in order + * `[alongX, alongOther]` — `alongOther` is Z for the 'horizontal' plane and + * Y for 'node-normal'. Used to align the node's edges to the grid (rotation- + * aware: swap the pair at 90°). Omit / return null for free movement. + */ + snapExtents?: (node: N) => readonly [number, number] | null + portal?: HandlePortal +} + export type HandleDescriptor = | LinearResizeHandle | RadialResizeHandle | ArcResizeHandle | EndpointMoveHandle | TapActionHandle + | TranslateHandle /** * Static array, or a function for shape-dependent cases (column diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 9eef5afc6..2d0c15e88 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -31,6 +31,7 @@ export type { LinearResizeHandle, RadialResizeHandle, TapActionHandle, + TranslateHandle, } from './handles' export { createSceneApi, type SceneStoreLike } from './scene-api' export type { diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index ae0af888b..dade84f1b 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -5,10 +5,13 @@ import { type AnyNodeId, type CeilingNode, ColumnNode, + DEFAULT_WALL_HEIGHT, DoorNode, ElevatorNode, FenceNode, generateId, + getWallCurveLength, + getWallThickness, ItemNode, isRegistryMovable, isRegistrySelectable, @@ -19,6 +22,7 @@ import { StairNode, StairSegmentNode, sceneRegistry, + useLiveNodeOverrides, useScene, WallNode, WindowNode, @@ -32,6 +36,7 @@ import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' import useEditor from '../../store/use-editor' +import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' const ALLOWED_TYPES = [ @@ -90,9 +95,10 @@ const MENU_Y_OFFSETS: Record = { door: 0.6, window: 0.6, column: 0.6, - // Fence: clears the height-resize arrow (sits at fence.height + 0.45) - // plus the chevron's own visual size, so the menu floats just above it. - fence: 1.05, + // Fence: still clears the height-resize arrow (sits at fence.height + + // 0.45) plus the chevron's visual size, but kept low so the menu sits + // close to the fence rather than floating well above it. + fence: 0.7, // Elevator: clears the cab-height arrow which sits above the SHAFT // top (resolved through level entries), so the menu floats above it. elevator: 0.9, @@ -121,6 +127,32 @@ function getMenuYOffset(node: AnyNode | null): number { return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT } +// Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the +// pill reads sensibly before an explicit height / thickness is set. +const FENCE_DEFAULT_HEIGHT = 1.8 +const FENCE_DEFAULT_THICKNESS = 0.08 + +// Dimensions for the height-drag pill. Walls and fences both carry +// start/end/curveOffset, so getWallCurveLength covers length for either. +function getHeightPillDimensions(node: WallNode | FenceNode): { + height: number + length: number + thickness: number +} { + if (node.type === 'wall') { + return { + height: node.height ?? DEFAULT_WALL_HEIGHT, + length: getWallCurveLength(node), + thickness: getWallThickness(node), + } + } + return { + height: node.height ?? FENCE_DEFAULT_HEIGHT, + length: getWallCurveLength(node), + thickness: node.thickness ?? FENCE_DEFAULT_THICKNESS, + } +} + export function FloatingActionMenu() { const selectedIds = useViewer((s) => s.selection.selectedIds) const updateNode = useScene((s) => s.updateNode) @@ -134,9 +166,15 @@ export function FloatingActionMenu() { const setCurvingFence = useEditor((s) => s.setCurvingFence) const setSelection = useViewer((s) => s.setSelection) const setEditingHole = useEditor((s) => s.setEditingHole) + const unit = useViewer((s) => s.unit) + // Drives the height-drag dimension pill below the menu. `activeHandleDrag` + // flips only at drag start / end, so subscribing here is cheap — the live + // height value is written imperatively in the useFrame below. + const activeHandleDrag = useEditor((s) => s.activeHandleDrag) const groupRef = useRef(null) const menuScaleRef = useRef(null) + const pillHeightRef = useRef(null) // Only show for single selection of specific types const selectedId = selectedIds.length === 1 ? selectedIds[0] : null @@ -151,6 +189,17 @@ export function FloatingActionMenu() { ? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type) : false + // Height-drag pill: shown just above the menu only while the selected + // wall/fence height arrow is being dragged. Length + thickness are fixed + // during a height drag, so they're computed here; the live height value + // is updated imperatively in the useFrame (same pattern as the scale). + const pillNode = node?.type === 'wall' || node?.type === 'fence' ? node : null + const isHeightDragPill = + pillNode !== null && + activeHandleDrag?.nodeId === selectedId && + activeHandleDrag?.label === 'height' + const pillDims = pillNode ? getHeightPillDimensions(pillNode) : null + // Boolean selector, only re-renders when curving availability actually flips. const canCurveSelectedWall = useScene((s) => { if (!selectedId) return false @@ -185,6 +234,23 @@ export function FloatingActionMenu() { menuScaleRef.current.style.transform = `scale(${scale})` } + // Live height readout for the drag pill. The dragged height lands in + // `useLiveNodeOverrides` (not the scene store) each frame, so read it + // imperatively here instead of forcing a per-frame React re-render. + if ( + pillHeightRef.current && + (node?.type === 'wall' || node?.type === 'fence') && + activeHandleDrag?.nodeId === selectedId && + activeHandleDrag?.label === 'height' + ) { + const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as + | { height?: number } + | undefined + const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT + const liveHeight = override?.height ?? node.height ?? fallbackHeight + pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}` + } + const obj = sceneRegistry.nodes.get(selectedId) if (obj) { // Calculate bounding box in world space @@ -330,15 +396,22 @@ export function FloatingActionMenu() { } // Duplicate children for stair nodes - } else if (duplicate.type === 'chimney' || duplicate.type === 'dormer') { - // Chimney & dormer use pure drag-to-place: NO node is - // inserted into the scene until the user clicks a roof - // segment. The `setMovingNode` call below hands the clone - // (with `metadata.isNew = true` + no id) to - // `MoveChimneyTool` / `MoveDormerTool`, which call - // `createNode` on the click that commits the placement. - // Skipping the auto-create avoids the "duplicate appears at - // +1 offset before drag" UX the other registry kinds use. + } else if ( + duplicate.type === 'item' || + duplicate.type === 'chimney' || + duplicate.type === 'dormer' + ) { + // Items, chimneys & dormers use pure drag-to-place: NO node is + // inserted into the scene until the user clicks to commit. The + // `setMovingNode` call below hands the clone (with + // `metadata.isNew = true` + no id) to its move tool — + // `MoveItemTool` / `MoveChimneyTool` / `MoveDormerTool` — which + // create a draft and call `createNode` on the commit click. + // Pre-creating here would drop a second copy into the scene + // before any click — the furnish-tab "duplicate auto-places an + // item without clicking" bug. (Item has its own + // draft-committing move tool, so it must skip the generic + // registry auto-create branch below.) } else if (nodeRegistry.has(duplicate.type)) { // Registry-driven kinds: offset the position slightly so the // duplicate doesn't overlap exactly, then create + hand to the @@ -452,7 +525,7 @@ export function FloatingActionMenu() { }} zIndexRange={[100, 0]} > -
+
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> + {/* Height-drag dimension pill. Absolutely positioned just above + the menu (away from the height arrow below it) so it rides the + same scale transform + anchor, never overlaps the menu, and + needs no menu lift — which is what caused the click flicker. + Non-interactive. */} + {isHeightDragPill && pillDims ? ( +
+ +
+ ) : null}
diff --git a/packages/editor/src/components/editor/measurement-pill.tsx b/packages/editor/src/components/editor/measurement-pill.tsx new file mode 100644 index 000000000..892f5d011 --- /dev/null +++ b/packages/editor/src/components/editor/measurement-pill.tsx @@ -0,0 +1,75 @@ +'use client' + +import { type ForwardedRef, Fragment, forwardRef } from 'react' + +// Canonical in-world dimension formatter — metric metres or imperial +// feet/inches. Shared by every measurement readout so they read the same. +export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): string { + if (unit === 'imperial') { + const feet = value * 3.280_84 + const wholeFeet = Math.floor(feet) + const inches = Math.round((feet - wholeFeet) * 12) + if (inches === 12) return `${wholeFeet + 1}'0"` + return `${wholeFeet}'${inches}"` + } + return `${Number.parseFloat(value.toFixed(2))}m` +} + +type MeasurePart = 'height' | 'length' | 'thickness' + +const PART_ORDER: { key: MeasurePart; prefix: string }[] = [ + { key: 'height', prefix: 'H' }, + { key: 'length', prefix: 'L' }, + { key: 'thickness', prefix: 'T' }, +] + +/** + * Floating dimension pill shown during wall / fence drags: `H · L · T` with + * the actively-dragged dimension emphasised. Styled to match the top-center + * floating info bar (rounded-full, design-token colours) so it tracks the + * app theme. + * + * The forwarded ref points at the `primary` value's `` so a caller + * driving a per-frame drag (the height arrow) can rewrite its text + * imperatively without a React re-render. Callers that re-render naturally + * (the endpoint tools) ignore the ref and just pass live values as props. + */ +export const MeasurementPill = forwardRef(function MeasurementPill( + { + height, + length, + thickness, + unit, + primary, + }: { + height: number + length: number + thickness: number + unit: 'metric' | 'imperial' + primary: MeasurePart + }, + primaryRef: ForwardedRef, +) { + const values: Record = { height, length, thickness } + return ( +
+ {PART_ORDER.map((part, index) => ( + + {index > 0 ? ( + + · + + ) : null} + + {`${part.prefix} ${formatMeasurement(values[part.key], unit)}`} + + + ))} +
+ ) +}) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 16377267f..40607cc65 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -13,6 +13,7 @@ import { type RadialResizeHandle, sceneRegistry, type TapActionHandle, + type TranslateHandle, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -22,11 +23,12 @@ import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/ import { useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, - type BufferGeometry, + BufferGeometry, Color, CylinderGeometry, DoubleSide, ExtrudeGeometry, + Float32BufferAttribute, type Group, Matrix4, type Object3D, @@ -40,12 +42,19 @@ import { } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { snapToGrid } from '../tools/item/placement-math' +import { formatAngleRadians } from '../tools/shared/segment-angle' const ARROW_SCALE = 0.65 const ARROW_COLOR = '#8381ed' +// How far a DOWNWARD tracker's dashed leader pokes past its cube so the +// dashes visibly thread through it (the cube sits ON the line, not at +// its end). Upward trackers — wall / chimney height — stop at the cube. +const TRACKER_THROUGH = 0.12 // Mirrors the formatter used by wall / fence measurement labels so all // in-world dimension chips read consistently. @@ -207,6 +216,65 @@ function createArrowHandleGeometry() { return geometry } +// Double-headed straight arrow silhouette, drawn in 2D pointing along ±X. +// A thin ribbon between two arrowheads. Two of these (one rotated 90°) +// merge into the 4-way move cross. +function createDoubleArrowShape(): Shape { + const L = 0.36 // half-length to each tip + const rw = 0.03 // ribbon half-width + const hw = 0.12 // arrowhead half-width + // Long inner ribbon so opposing arrowheads sit well apart rather than + // meeting in a cramped knot at the centre. + const hx = 0.2 // where each arrowhead meets the ribbon + const shape = new Shape() + shape.moveTo(L, 0) // right tip + shape.lineTo(hx, hw) + shape.lineTo(hx, rw) + shape.lineTo(-hx, rw) + shape.lineTo(-hx, hw) + shape.lineTo(-L, 0) // left tip + shape.lineTo(-hx, -hw) + shape.lineTo(-hx, -rw) + shape.lineTo(hx, -rw) + shape.lineTo(hx, -hw) + shape.closePath() + return shape +} + +// 4-way move cross: two double-headed arrows (±X and ±Z) lying flat in the +// XZ plane. Drawn on top (depthTest off, shared arrow material) so it reads +// as a floor-move grip centred on the item. +function createMoveCrossHandleGeometry() { + const shape = createDoubleArrowShape() + const extrudeOpts = { + depth: 0.06, + bevelEnabled: true, + bevelThickness: 0.018, + bevelSize: 0.012, + bevelOffset: 0, + bevelSegments: 6, + curveSegments: 8, + steps: 1, + } + const armX = new ExtrudeGeometry(shape, extrudeOpts) + armX.translate(0, 0, -0.03) + armX.rotateX(-Math.PI / 2) // lay flat → points along ±X in XZ + const armZ = armX.clone() + armZ.rotateY(Math.PI / 2) // second arm → points along ±Z + const merged = mergeGeometries([armX, armZ], false) + if (!merged) { + armZ.dispose() + armX.computeVertexNormals() + armX.computeBoundingSphere() + return armX + } + armX.dispose() + armZ.dispose() + merged.computeVertexNormals() + merged.computeBoundingSphere() + return merged +} + function swallowNextClick() { const swallow = (clickEvent: Event) => { clickEvent.stopPropagation() @@ -223,6 +291,14 @@ export function NodeArrowHandles() { const mode = useEditor((state) => state.mode) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const movingNode = useEditor((state) => state.movingNode) + // Endpoint / curve drags reshape the selected wall or fence; hide its + // resize arrows for the duration so they don't clutter (or get blocked + // by) the drag's own cursor + dimension overlays. Mirrors the same guard + // on the legacy wall handles (`WallMoveSideHandles`). + const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) + const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) + const curvingWall = useEditor((state) => state.curvingWall) + const curvingFence = useEditor((state) => state.curvingFence) const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const rawNode = useScene((state) => @@ -237,8 +313,7 @@ export function NodeArrowHandles() { rawNode ? s.overrides.get(rawNode.id) : undefined, ) const node = useMemo( - () => - rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode, + () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode), [rawNode, liveOverride], ) @@ -254,10 +329,21 @@ export function NodeArrowHandles() { Boolean(node && descriptors?.length) && !isFloorplanHovered && mode !== 'delete' && - !movingNode + !movingNode && + !movingWallEndpoint && + !movingFenceEndpoint && + !curvingWall && + !curvingFence if (!shouldRender || !node || !descriptors) return null - return + // Key by the selected node id so switching selection REMOUNTS the rig. + // The portal target + ride-mesh refs are seeded from the scene registry + // in `useState` initializers; without a remount they'd persist from the + // previous selection and the arrows would ride the old node's world pose + // (right local placements, wrong frame) until the resolve effect happened + // to catch up. Remounting re-resolves both refs synchronously for the new + // node, so the arrows land in the right place the instant it's selected. + return } // Resolves the portal target + ride mesh chain. Descriptor-level `portal` @@ -400,6 +486,13 @@ function NodeArrowHandlesForNode({ // wall-riding outer wrapper. const arrowFrame = innerRide ?? outerRide + // A translate drag moves `position`, so the whole handle rig should travel + // with the mesh — the freeze-at-pre-drag mechanism (built for asymmetric + // resize that re-centres the mesh) must NOT fire for the non-active arrows + // here, or they'd lag behind the moving item. + const activeIsTranslate = + activeIndex !== null && descriptors[activeIndex]?.kind === 'translate' + const arrows = descriptors.map((descriptor, index) => ( )) @@ -444,9 +538,7 @@ function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, // notable holdout — they don't have handles anyway, but TypeScript still // requires us to discriminate). Guarded access keeps the freeze logic // safe for the few node kinds that lack the field. - const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [ - 0, 0, 0, - ] + const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0] const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [ 0, 0, 0, ] @@ -471,6 +563,7 @@ function ArrowHandle({ handleIndex, dragControls, rideObject, + suppressFreeze, }: { descriptor: HandleDescriptor liveNode: AnyNode @@ -479,15 +572,19 @@ function ArrowHandle({ handleIndex: number dragControls: DragControls rideObject: Object3D + /** When the active drag is a translate, non-active arrows ride the moving + * mesh instead of freezing at their pre-drag world position. */ + suppressFreeze?: boolean }) { // During a drag, non-active arrows render against the pre-drag store // snapshot. The active arrow always uses the live (override-merged) // node so it tracks the cursor. - const isOtherActive = - activeIndex !== null && activeIndex !== handleIndex && preDragNode !== null + const isOtherActive = activeIndex !== null && activeIndex !== handleIndex && preDragNode !== null const placementNode = isOtherActive ? (preDragNode as AnyNode) : liveNode const freezeOffset = - isOtherActive && preDragNode ? computeFreezeOffset(liveNode, preDragNode) : null + isOtherActive && preDragNode && !suppressFreeze + ? computeFreezeOffset(liveNode, preDragNode) + : null if (descriptor.kind === 'linear-resize' || descriptor.kind === 'radial-resize') { return ( @@ -515,8 +612,25 @@ function ArrowHandle({ /> ) } + if (descriptor.kind === 'translate') { + return ( + + ) + } if (descriptor.kind === 'tap-action') { - return + // Tap-action handles (fence side-move arrows, corner pickers) aren't + // resize handles, so the freeze-at-pre-drag mechanism — which only + // exists to stop arrows sliding when an asymmetric width/length resize + // re-centers the mesh — doesn't apply to them. Track the live node so + // their height-dependent placement (side arrows ride the top, corner + // leaders span the full height) follows a height drag in real time. + return } // endpoint-move not yet implemented. return null @@ -551,7 +665,10 @@ function pickCursor(descriptor: LinearResizeHandle | RadialResizeHandle } function resolveBound( - bound: number | ((node: AnyNode, sceneApi: ReturnType) => number) | undefined, + bound: + | number + | ((node: AnyNode, sceneApi: ReturnType) => number) + | undefined, fallback: number, node: AnyNode, sceneApi: ReturnType, @@ -605,6 +722,10 @@ function LinearArrow({ void liveNode const cursor = pickCursor(descriptor) + // When a handle declares `measureLabel`, its readout is routed to the + // floating dimension pill (via `activeHandleDrag`) and its own in-world + // chip is suppressed — matches the wall height handle. + const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined const placementSceneApi = useMemo(() => createSceneApi(useScene), []) const basePosition = descriptor.placement.position(node, placementSceneApi) // `freezeOffset` (in node-local frame) cancels the mesh's `position` @@ -669,6 +790,13 @@ function LinearArrow({ const nodeId = node.id as AnyNodeId const sceneApi = createSceneApi(useScene) const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode + // Cross-node handles (a downspout sliding its gutter's outlet) redirect + // the live override + commit to another node; defaults to the selected + // node. `currentValue` / `apply` still see the selected node. + const overrideId = + (descriptor.kind === 'linear-resize' + ? descriptor.overrideTarget?.(initialNode as never, sceneApi) + : undefined) ?? nodeId const initialValue = descriptor.currentValue(initialNode) const initialPointer = descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z @@ -701,6 +829,10 @@ function LinearArrow({ // pre-override store node (not the merged `liveNode`) so subsequent // re-renders don't pollute it with this drag's own patch. dragControls.onStart(handleIndex, initialNode) + // Publish the dimension being steered so the floating pill can show it. + if (measureLabel) { + useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel }) + } let lastPatch: Partial | null = null @@ -724,16 +856,13 @@ function LinearArrow({ ? intersectionLocal.y : intersectionLocal.z const delta = currentPointer - initialPointer - const next = Math.min( - maxBound, - Math.max(minBound, initialValue + delta * factor), - ) + const next = Math.min(maxBound, Math.max(minBound, initialValue + delta * factor)) // apply sees the node-at-drag-start so it can compute anchors from // pre-drag geometry (door-width re-centers on the opposite edge). const patch = descriptor.apply(initialNode as never, next, sceneApi) lastPatch = patch as Partial - useLiveNodeOverrides.getState().set(nodeId, patch as Record) - useScene.getState().markDirty(nodeId) + useLiveNodeOverrides.getState().set(overrideId, patch as Record) + useScene.getState().markDirty(overrideId) } const cleanup = () => { @@ -746,6 +875,9 @@ function LinearArrow({ useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) setIsDragging(false) + if (measureLabel) { + useEditor.getState().setActiveHandleDrag(null) + } // Release the active-drag claim so non-active arrows return to // live-tracking (and so the next drag can claim its own snapshot). dragControls.onEnd() @@ -757,17 +889,17 @@ function LinearArrow({ // Commit: one tracked write to the scene store, then drop the // override so subscribers read from scene again. if (lastPatch) { - sceneApi.update(nodeId, lastPatch) + sceneApi.update(overrideId, lastPatch) } - useLiveNodeOverrides.getState().clear(nodeId) - useScene.getState().markDirty(nodeId) + useLiveNodeOverrides.getState().clear(overrideId) + useScene.getState().markDirty(overrideId) cleanup() } const onCancel = () => { // Revert: drop the override + mark dirty so the system rebuilds // against the original scene values. - useLiveNodeOverrides.getState().clear(nodeId) - useScene.getState().markDirty(nodeId) + useLiveNodeOverrides.getState().clear(overrideId) + useScene.getState().markDirty(overrideId) cleanup() } dragCleanupRef.current = cleanup @@ -796,7 +928,9 @@ function LinearArrow({ // is already the effective (override-merged) node, so currentValue // returns the in-flight value during a drag and the label tracks // smoothly without an extra subscription. - const showLabel = isHovered || isDragging + // `measureLabel` handles route their readout to the floating dimension + // pill, so suppress the inline chip here to avoid showing it twice. + const showLabel = (isHovered || isDragging) && !measureLabel const labelText = showLabel ? formatDimension(descriptor.currentValue(node), unit) : '' // `tracker` shape on a linear-resize handle: render a dashed vertical @@ -814,9 +948,16 @@ function LinearArrow({ // chimney body height starts at the deck plane, not at y=0, so the // dashed leader spans only the body's visible extent. const trackerDescriptor = descriptor as LinearResizeHandle - const baseY = - trackerDescriptor.trackerBaseY?.(node as never, placementSceneApi) ?? 0 - const leaderHeight = Math.max(position[1] - baseY, 0) + const baseY = trackerDescriptor.trackerBaseY?.(node as never, placementSceneApi) ?? 0 + // Leader spans base ↔ cube either direction. Upward (cube above base: + // wall / chimney height) it stops at the cube as before. Downward + // (cube below base: a downspout's length cube under the gutter + // outlet) it pokes `TRACKER_THROUGH` past the cube so the dashes + // thread through it instead of the leader collapsing to nothing. + const cubeY = position[1] + const cubeBelowBase = cubeY < baseY + const leaderBottomY = Math.min(baseY, cubeY) - (cubeBelowBase ? TRACKER_THROUGH : 0) + const leaderHeight = Math.max(Math.max(baseY, cubeY) - leaderBottomY, 0) return ( <> {showDecoration && decoration ? ( @@ -826,10 +967,13 @@ function LinearArrow({ /> ) : null} {showLabel ? ( - + ) : null} null + +// Live rotation readout shown while a whole-node rotate gizmo is dragged. +// Mirrors the wall-draft angle arc: a filled wedge + outline swept from the +// pointer's bearing at grab (`startAngle`) to its current bearing +// (`endAngle`) around the rotation pivot, plus a degree chip at the wedge's +// midpoint. All coordinates are world-space — the guide is portalled to the +// scene root so it stays fixed while the node mesh rotates underneath it. +type RotationGuideData = { + center: [number, number, number] + startAngle: number + endAngle: number + radius: number + labelPos: [number, number, number] + /** Swept magnitude in radians, for the degree chip. */ + sweep: number +} + +function RotationGuide({ data }: { data: RotationGuideData }) { + const { center, startAngle, endAngle, radius, labelPos, sweep } = data + const { outline, fill } = useMemo(() => { + const span = endAngle - startAngle + const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS)) + const arc = Array.from({ length: count + 1 }, (_, index) => { + const angle = startAngle + (span * index) / count + return new Vector3( + center[0] + Math.cos(angle) * radius, + center[1], + center[2] + Math.sin(angle) * radius, + ) + }) + const centerV = new Vector3(center[0], center[1], center[2]) + const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV]) + const positions: number[] = [] + for (let i = 0; i < arc.length - 1; i++) { + const a = arc[i] + const b = arc[i + 1] + if (!a || !b) continue + positions.push(centerV.x, centerV.y, centerV.z, a.x, a.y, a.z, b.x, b.y, b.z) + } + const fillGeo = new BufferGeometry() + fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3)) + return { outline: outlineGeo, fill: fillGeo } + }, [center, startAngle, endAngle, radius]) + useEffect(() => () => outline.dispose(), [outline]) + useEffect(() => () => fill.dispose(), [fill]) + + return ( + <> + + + + + + + ) +} + +function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) { + return ( + // @ts-expect-error - R3F accepts Three line primitives, matching the wall draft arc. + + + + ) +} + // Angular drag: project pointer to a horizontal plane at the arrow's Y // and measure the signed angle around the node's local origin (in world // XZ). Pass the normalised delta to `apply` — the descriptor owns the @@ -957,18 +1190,26 @@ function ArcArrow({ // corner) render a two-headed curved arrow; everything else (stair // sweep, etc.) keeps the chevron. const isRotateShape = descriptor.shape === 'rotate' + // 'node-normal' spins the node about its local +Z (a wall item flat against + // its wall) instead of yaw about world-Y. The drag plane and the icon both + // tilt into that plane, and the horizontal-only wedge/ring readout is + // suppressed. + const isNodeNormalRot = descriptor.rotationPlane === 'node-normal' const arrowGeometry = useMemo( () => (isRotateShape ? createRotateArrowHandleGeometry() : createArrowHandleGeometry()), [isRotateShape], ) const arrowMaterial = useArrowMaterial() - const { camera, raycaster, gl } = useThree() + const { camera, raycaster, gl, scene } = useThree() const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 // The rotate icon is denser than the chevron; pump scale a touch so the // ribbon reads at the same on-screen size as the other handles. const arrowScale = isRotateShape ? ARROW_SCALE * 1.05 : ARROW_SCALE const scale = (isHovered ? 1.12 : 1) * zoom * arrowScale const dragCleanupRef = useRef<(() => void) | null>(null) + // Live rotation readout (wedge + degree chip) — only populated while a + // `shape: 'rotate'` gizmo is mid-drag. See RotationGuide. + const [rotationGuide, setRotationGuide] = useState(null) useEffect(() => { arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) @@ -1020,13 +1261,52 @@ function ArcArrow({ rideObject.updateMatrixWorld() const centerWorld = descriptor.rotationCenter !== undefined - ? new Vector3(...descriptor.rotationCenter(node as never, createSceneApi(useScene))).applyMatrix4( - rideObject.matrixWorld, - ) + ? new Vector3( + ...descriptor.rotationCenter(node as never, createSceneApi(useScene)), + ).applyMatrix4(rideObject.matrixWorld) : new Vector3().setFromMatrixPosition(rideObject.matrixWorld) const arrowWorld = new Vector3(...position).applyMatrix4(rideObject.matrixWorld) const planeY = arrowWorld.y - const plane = new Plane(new Vector3(0, 1, 0), -planeY) + + // Rotation axis + drag plane. 'horizontal' spins about world-Y on a flat + // plane; 'node-normal' spins about the node's local +Z (the wall normal) + // on the plane perpendicular to it. The 2D basis (u, v) lets us measure a + // consistent bearing in either plane: for horizontal it collapses to the + // original atan2(z, x). + const axis = isNodeNormalRot + ? new Vector3().setFromMatrixColumn(rideObject.matrixWorld, 2).normalize() + : new Vector3(0, 1, 0) + const plane = isNodeNormalRot + ? new Plane().setFromNormalAndCoplanarPoint(axis, centerWorld) + : new Plane(new Vector3(0, 1, 0), -planeY) + let basisU: Vector3 + if (isNodeNormalRot) { + // In-plane reference: world-up projected onto the plane (falls back to + // world-X if the axis is near-vertical, e.g. a ceiling item). + const up = new Vector3(0, 1, 0) + basisU = up.clone().addScaledVector(axis, -up.dot(axis)) + if (basisU.lengthSq() < 1e-6) { + const x = new Vector3(1, 0, 0) + basisU = x.addScaledVector(axis, -x.dot(axis)) + } + basisU.normalize() + } else { + basisU = new Vector3(1, 0, 0) + } + const basisV = isNodeNormalRot + ? new Vector3().crossVectors(axis, basisU).normalize() + : new Vector3(0, 0, 1) + const angleOf = (p: Vector3) => { + const d = new Vector3().subVectors(p, centerWorld) + return Math.atan2(d.dot(basisV), d.dot(basisU)) + } + + // Wedge radius tracks the handle's orbit (its horizontal distance from + // the pivot), nudged inward so the swept fill reads as "the handle swung + // around" rather than overlapping the gizmo itself. Clamped so tiny + // footprints (column) and large ones (roof segment) both stay legible. + const handleRadius = Math.hypot(arrowWorld.x - centerWorld.x, arrowWorld.z - centerWorld.z) + const guideRadius = Math.min(Math.max(handleRadius * 0.72, 0.3), 1.6) const ndc = new Vector2() const setNDC = (clientX: number, clientY: number) => { @@ -1042,7 +1322,7 @@ function ArcArrow({ const hitWorld = new Vector3() if (!raycaster.ray.intersectPlane(plane, hitWorld)) return - const initialAngle = Math.atan2(hitWorld.z - centerWorld.z, hitWorld.x - centerWorld.x) + const initialAngle = angleOf(hitWorld) const nodeId = node.id as AnyNodeId const sceneApi = createSceneApi(useScene) const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode @@ -1067,7 +1347,7 @@ function ArcArrow({ raycaster.setFromCamera(ndc, camera) const hit = new Vector3() if (!raycaster.ray.intersectPlane(plane, hit)) return - const currentAngle = Math.atan2(hit.z - centerWorld.z, hit.x - centerWorld.x) + const currentAngle = angleOf(hit) // Normalise so a drag that crosses ±π doesn't flip sign mid-gesture. let delta = currentAngle - initialAngle while (delta > Math.PI) delta -= 2 * Math.PI @@ -1085,6 +1365,33 @@ function ArcArrow({ lastPatch = patch as Partial useLiveNodeOverrides.getState().set(nodeId, patch as Record) useScene.getState().markDirty(nodeId) + + // Whole-node rotate gizmos report how far the node has turned since + // grab. The wedge sweeps `delta` (the snapped amount, so it tracks the + // 15° steps under Shift), and the chip sits at the wedge midpoint just + // past its rim. Suppressed below ~0.5° so a fresh grab doesn't flash a + // zero-width sliver. The wedge is drawn in the horizontal plane, so it + // only applies to horizontal-axis rotation (not the wall-normal spin). + if (isRotateShape && !isNodeNormalRot) { + if (Math.abs(delta) < 0.0087) { + setRotationGuide(null) + } else { + const midAngle = initialAngle + delta / 2 + const labelRadius = guideRadius + 0.22 + setRotationGuide({ + center: [centerWorld.x, planeY, centerWorld.z], + startAngle: initialAngle, + endAngle: initialAngle + delta, + radius: guideRadius, + labelPos: [ + centerWorld.x + Math.cos(midAngle) * labelRadius, + planeY + 0.02, + centerWorld.z + Math.sin(midAngle) * labelRadius, + ], + sweep: Math.abs(delta), + }) + } + } } const cleanup = () => { @@ -1097,6 +1404,7 @@ function ArcArrow({ useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) setIsDragging(false) + setRotationGuide(null) // Release the active-drag claim — see LinearArrow's onEnd note. dragControls.onEnd() dragCleanupRef.current = null @@ -1142,7 +1450,16 @@ function ArcArrow({ y={decoration.y?.(node as never) ?? 0} /> ) : null} - + {/* World-space rotation readout, portalled to the scene root so it + stays fixed while this gizmo's frame rotates with the node mesh. */} + {rotationGuide ? createPortal(, scene) : null} + + node: AnyNode + handleIndex: number + dragControls: DragControls + rideObject: Object3D +}) { + const [isHovered, setIsHovered] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), []) + const arrowMaterial = useArrowMaterial() + const { camera, raycaster, gl } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE + const dragCleanupRef = useRef<(() => void) | null>(null) + + useEffect(() => { + arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [arrowMaterial, isHovered]) + useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) + useEffect(() => () => dragCleanupRef.current?.(), []) + + const placementSceneApi = useMemo(() => createSceneApi(useScene), []) + const position = descriptor.placement.position(node, placementSceneApi) + const cursor: Cursor = 'move' + // 'node-normal' constrains the drag to the wall face (plane ⟂ the node's + // local +Z). Its cross icon stands up into that plane (tilt about X). + const isWallPlane = descriptor.plane === 'node-normal' + + const activate = (event: ThreeEvent) => { + event.stopPropagation() + + // Drag plane through the node origin. 'horizontal' uses the world-up + // normal (slide on the floor); 'node-normal' uses the node's facing + // direction (its local +Z in world) so the item slides on the wall face. + // Hits map into the parent frame so the delta composes with `position` + // (which lives in parent-local space). + rideObject.updateMatrixWorld() + const worldOrigin = new Vector3().setFromMatrixPosition(rideObject.matrixWorld) + const planeNormal = isWallPlane + ? new Vector3().setFromMatrixColumn(rideObject.matrixWorld, 2).normalize() + : new Vector3(0, 1, 0) + const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin) + const parent = rideObject.parent + const parentInverse = new Matrix4() + if (parent) { + parent.updateMatrixWorld() + parentInverse.copy(parent.matrixWorld).invert() + } + + const ndc = new Vector2() + const setNDC = (clientX: number, clientY: number) => { + const rect = gl.domElement.getBoundingClientRect() + ndc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + } + + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) + const hitWorld = new Vector3() + if (!raycaster.ray.intersectPlane(plane, hitWorld)) return + const startLocal = hitWorld.clone().applyMatrix4(parentInverse) + + const nodeId = node.id as AnyNodeId + const sceneApi = createSceneApi(useScene) + const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode + const initialPos = (initialNode as { position?: readonly [number, number, number] }) + .position ?? [0, 0, 0] + + document.body.style.cursor = cursor + sfxEmitter.emit('sfx:item-pick') + useViewer.getState().setInputDragging(true) + useScene.temporal.getState().pause() + setIsDragging(true) + dragControls.onStart(handleIndex, initialNode) + + let lastPatch: Partial | null = null + + const onMove = (e: PointerEvent) => { + setNDC(e.clientX, e.clientY) + raycaster.setFromCamera(ndc, camera) + const hit = new Vector3() + if (!raycaster.ray.intersectPlane(plane, hit)) return + const curLocal = hit.applyMatrix4(parentInverse) + // Add the in-plane delta to the drag-start position; the off-plane axis + // (Y on the floor, Z/depth on a wall) keeps its value. Snap / clamp is + // the descriptor's job in `apply`. + const newPos: [number, number, number] = [initialPos[0], initialPos[1], initialPos[2]] + newPos[0] += curLocal.x - startLocal.x + if (isWallPlane) { + newPos[1] += curLocal.y - startLocal.y + } else { + newPos[2] += curLocal.z - startLocal.z + } + // Grid-snap the two in-plane axes (X + the plane's other free axis). + const extents = descriptor.snapExtents?.(initialNode as never) + if (extents) { + newPos[0] = snapToGrid(newPos[0], extents[0]) + if (isWallPlane) { + newPos[1] = snapToGrid(newPos[1], extents[1]) + } else { + newPos[2] = snapToGrid(newPos[2], extents[1]) + } + } + const patch = descriptor.apply(initialNode as never, newPos, sceneApi) + lastPatch = patch as Partial + useLiveNodeOverrides.getState().set(nodeId, patch as Record) + useScene.getState().markDirty(nodeId) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + if (document.body.style.cursor === cursor) { + document.body.style.cursor = '' + } + useScene.temporal.getState().resume() + useViewer.getState().setInputDragging(false) + setIsDragging(false) + dragControls.onEnd() + dragCleanupRef.current = null + } + const onUp = () => { + swallowNextClick() + sfxEmitter.emit('sfx:item-place') + if (lastPatch) { + sceneApi.update(nodeId, lastPatch) + } + useLiveNodeOverrides.getState().clear(nodeId) + useScene.getState().markDirty(nodeId) + cleanup() + } + const onCancel = () => { + useLiveNodeOverrides.getState().clear(nodeId) + useScene.getState().markDirty(nodeId) + cleanup() + } + dragCleanupRef.current = cleanup + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } + + const onEnter = (event: ThreeEvent) => { + event.stopPropagation() + setIsHovered(true) + document.body.style.cursor = cursor + } + const onLeave = (event: ThreeEvent) => { + event.stopPropagation() + setIsHovered(false) + if (document.body.style.cursor === cursor) document.body.style.cursor = '' + } + + // Suppress the unused `isDragging` lint — it only drives the React re-render + // that keeps hover/drag cursor state in sync. + void isDragging + + // The cross is built flat in the XZ plane. On a wall, tilt it up about X so + // it lies in the item-local XY plane (= the wall face). + const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0] + + return ( + + + + ) +} + // Click-to-engage affordance — no drag plumbing, just a click target. The // descriptor's `onActivate` receives sceneApi + editorApi so it can engage // move tools, endpoint drags, or any other editor-state transition without @@ -1190,8 +1702,7 @@ function TapActionArrow({ const position = descriptor.placement.position(node, placementSceneApi) const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 const shape = descriptor.shape ?? 'arrow' - const cursor: Cursor = - descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize') + const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize') const onActivate = (event: ThreeEvent) => { event.stopPropagation() diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 473063b8a..70beba530 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -143,6 +143,17 @@ export function WallMoveSideHandles() { } function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { + // Merge the in-flight drag override so every handle (side-move arrows, + // height arrow, corner leaders) tracks the live height in real time + // during a height drag — the scene store stays at the pre-drag value + // until commit, so reading `wall` alone would freeze them. Same pattern + // as node-arrow-handles. + const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id)) + const effectiveWall = useMemo( + () => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall), + [wall, liveOverride], + ) + const [levelObject, setLevelObject] = useState(() => wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null, ) @@ -175,18 +186,18 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) { } }, [wall.parentId]) - const handles = useMemo(() => getWallMoveHandles(wall), [wall]) + const handles = useMemo(() => getWallMoveHandles(effectiveWall), [effectiveWall]) if (!levelObject || handles.length === 0) return null return createPortal( {handles.map((handle) => ( - + ))} - - - + + + , levelObject, ) @@ -395,6 +406,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0] const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1] const wallAngle = Math.atan2(-dirZ, dirX) + // `wall` is the override-merged effective wall (see + // WallMoveSideHandlesForWall), so this height is already live during a drag. const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT const handleY = wallHeight + HEIGHT_HANDLE_OFFSET 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 1624a0934..872446408 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1330,7 +1330,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Keyboard rotation ---- - const ROTATION_STEP = Math.PI / 2 + // 45° increments — matches the R-key rotation step for already-placed + // items (use-keyboard.ts) so the ghost/duplicate rotates the same way. + const ROTATION_STEP = Math.PI / 4 const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Shift') { shiftFreeRef.current = true diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index c4ead4eed..5571ce581 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -1,5 +1,6 @@ export type { EditorProps } from './components/editor' export { default as Editor } from './components/editor' +export { formatMeasurement, MeasurementPill } from './components/editor/measurement-pill' export { type SnapshotCameraData, ThumbnailGenerator, diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index bc61ada5d..b0275376c 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -75,6 +75,9 @@ function fenceHeightHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_FENCE_HEIGHT, + // Drives the floating dimension pill (H · L · T) and suppresses the + // arrow's own inline chip, matching the wall height handle. + measureLabel: 'height', currentValue: (n) => n.height ?? 1.8, apply: (_n, newHeight) => ({ height: newHeight }), placement: { diff --git a/packages/nodes/src/fence/move-endpoint-tool.tsx b/packages/nodes/src/fence/move-endpoint-tool.tsx index 16c09e98a..e13101f06 100644 --- a/packages/nodes/src/fence/move-endpoint-tool.tsx +++ b/packages/nodes/src/fence/move-endpoint-tool.tsx @@ -1,12 +1,13 @@ 'use client' -import { type FenceNode, useScene, type WallNode } from '@pascal-app/core' +import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core' import { CursorSphere, type FencePlanPoint, formatAngleRadians, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + MeasurementPill, type MovingFenceEndpoint, triggerSFX, useDragAction, @@ -90,6 +91,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = : [target.fence.end[0], target.fence.end[1]] const [altPressed, setAltPressed] = useState(false) + const unit = useViewer((s) => s.unit) const exitMoveMode = (committed: boolean) => { if (committed) triggerSFX('sfx:item-place') @@ -174,9 +176,34 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = const cursorPos: [number, number, number] = [movingPoint[0], 0, movingPoint[1]] + // Live segment dimensions for the floating pill. Length tracks the drag; + // height + thickness are static during an endpoint move. + const liveLength = getWallCurveLength({ + start: liveStart, + end: liveEnd, + curveOffset: liveFence?.curveOffset ?? target.fence.curveOffset, + }) + const fenceHeight = target.fence.height ?? 1.8 + const dimMidX = (liveStart[0] + liveEnd[0]) / 2 + const dimMidZ = (liveStart[1] + liveEnd[1]) / 2 + return ( + + + { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + // Negate the cursor delta to match three.js Y-rotation handedness + // (positive Ry takes +X → −Z, while atan2(z, x) increases +X → +Z). + apply: (initial, delta) => { + const [rx, ry, rz] = initial.rotation ?? [0, 0, 0] + return { rotation: [rx, ry - delta, rz] } + }, + placement: { + // Front-right corner of the footprint at mid-height. The registered + // item mesh carries position + rotation only (scale lives on an + // inner mesh), so the scaled footprint maps straight to world. + position: (n) => { + const [w, h, d] = getScaledDimensions(n) + return [w / 2, h / 2, d / 2 + ROTATE_CORNER_OFFSET] + }, + // Fixed −45° tilt leans the curve toward the item's front face. + rotationY: () => -Math.PI / 4, + }, + decoration: { + kind: 'ring', + radius: (n) => { + const [w, , d] = getScaledDimensions(n) + return Math.hypot(w / 2, d / 2) + ROTATE_RING_OFFSET + }, + y: (n) => getScaledDimensions(n)[1] / 2, + }, + } +} + +// Free ground-plane move gizmo — the 4-way cross just outside the front edge. +// Press-drag-release slides the item across the floor (live preview, commit +// on release). `snapExtents` aligns the item's edges to the grid the same +// way placement does, swapping width / depth at 90° turns. +function itemMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + // Sit just outside the item's front edge (centred in X, clear of the + // model), low to the floor so it reads as a floor-move grip. + position: (n) => { + const [, , d] = getScaledDimensions(n) + return [0, 0.02, d / 2 + MOVE_FRONT_OFFSET] + }, + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: (n) => { + const [dimX, , dimZ] = getScaledDimensions(n) + const swap = Math.abs(Math.sin(n.rotation[1] ?? 0)) > 0.9 + return [swap ? dimZ : dimX, swap ? dimX : dimZ] + }, + } +} + +// ---- Wall-mounted items (attachTo 'wall' / 'wall-side') ---- +// These live in the wall's local frame: position is [along-wall, up, depth] +// and the item faces along the wall normal (its local +Z). Both gizmos use +// `portal: 'grandparent'` so they render in the wall frame like door / window +// handles, and sit a little off the wall surface (+Z) so they're grabbable. + +// How far off the wall surface (along the normal) the wall gizmos float, and +// how far to either side of the item they sit. +const WALL_GIZMO_LIFT = 0.12 +const WALL_SIDE_OFFSET = 0.3 + +// Spin the item flat against the wall — rotation about its local +Z (the wall +// normal), written to rotation[2]. Sits just past the item's right edge. +function itemWallRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + rotationPlane: 'node-normal', + portal: 'grandparent', + apply: (initial, delta) => { + const [rx, ry, rz] = initial.rotation ?? [0, 0, 0] + return { rotation: [rx, ry, rz + delta] } + }, + placement: { + position: (n) => { + const [w] = getScaledDimensions(n) + return [w / 2 + WALL_SIDE_OFFSET, 0, WALL_GIZMO_LIFT] + }, + }, + } +} + +// Slide the item across the wall face — constrained to the wall plane (along +// the wall + up/down), depth pinned. Sits just past the item's left edge. +function itemWallMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + plane: 'node-normal', + portal: 'grandparent', + placement: { + position: (n) => { + const [w] = getScaledDimensions(n) + return [-(w / 2 + WALL_SIDE_OFFSET), 0, WALL_GIZMO_LIFT] + }, + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: (n) => { + const [dimX, dimY] = getScaledDimensions(n) + // A 90° roll about the normal swaps the item's along-wall + vertical + // footprint. + const swap = Math.abs(Math.sin(n.rotation[2] ?? 0)) > 0.9 + return [swap ? dimY : dimX, swap ? dimX : dimY] + }, + } +} + /** * Item — Phase 5 batch kind. Catalog-backed, GLB-rendered, multi-host. * @@ -86,6 +215,20 @@ export const itemDefinition: NodeDefinition = { parametrics: itemParametrics, + // In-world rotate + move gizmos for selected items. + // - Floor items: world-Y rotate + free floor-plane move cross. + // - Wall items: wall-normal rotate (spin flat against the wall) + a move + // cross constrained to the wall face. Both ride the wall frame. + // - Ceiling items: no gizmos yet (move via the move tool). + handles: (node) => { + const attachTo = (node as ItemNodeType).asset.attachTo + if (attachTo === 'wall' || attachTo === 'wall-side') { + return [itemWallRotateHandle(), itemWallMoveHandle()] + } + if (attachTo) return [] + return [itemRotateHandle(), itemMoveHandle()] + }, + renderer: { kind: 'parametric', module: () => import('./renderer'), diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 0c1149d6a..37abb3607 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -7,6 +7,7 @@ import { type ItemNode, type LightEffect, useInteractive, + useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' @@ -75,10 +76,21 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => { ) } -export const ItemRenderer = ({ node }: { node: ItemNode }) => { +export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => { const ref = useRef(null!) - useRegistry(node.id, node.type, ref) + useRegistry(storeNode.id, storeNode.type, ref) + + // Merge live drag overrides so the mesh transforms in real time during a + // drag (e.g. the in-world rotate gizmo). The handle writes the in-flight + // rotation to `useLiveNodeOverrides` on every pointer move and commits to + // the store only on release — without this merge the item would stay put + // until commit. + const liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id as AnyNodeId)) + const node = useMemo( + () => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as ItemNode) : storeNode), + [storeNode, liveOverrides], + ) return ( diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index 019017f25..eb36b1d20 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -2,8 +2,11 @@ import { type AnyNodeId, + DEFAULT_WALL_HEIGHT, emitter, type GridEvent, + getWallCurveLength, + getWallThickness, pauseSceneHistory, resumeSceneHistory, useScene, @@ -15,6 +18,7 @@ import { getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, isSegmentLongEnough, + MeasurementPill, type MovingWallEndpoint, markToolCancelConsumed, snapWallDraftPoint, @@ -187,6 +191,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ return [point[0], 0, point[1]] }) const [altPressed, setAltPressed] = useState(false) + const unit = useViewer((s) => s.unit) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingWallEndpoint(null) @@ -398,9 +403,39 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } }, [exitMoveMode, target]) + // Live segment dimensions for the floating pill. The moving endpoint is + // `cursorLocalPos`; the other end is fixed. Length tracks the drag (curve + // offset is unchanged by an endpoint move); height + thickness are static. + const movingPlanPoint: WallPlanPoint = [cursorLocalPos[0], cursorLocalPos[2]] + const fixedPlanPoint = fixedPointRef.current + const previewStart = target.endpoint === 'start' ? movingPlanPoint : fixedPlanPoint + const previewEnd = target.endpoint === 'end' ? movingPlanPoint : fixedPlanPoint + const liveLength = getWallCurveLength({ + start: previewStart, + end: previewEnd, + curveOffset: target.wall.curveOffset, + }) + const wallHeight = target.wall.height ?? DEFAULT_WALL_HEIGHT + const dimMidX = (previewStart[0] + previewEnd[0]) / 2 + const dimMidZ = (previewStart[1] + previewEnd[1]) / 2 + return ( + + + Date: Mon, 1 Jun 2026 14:40:43 +0530 Subject: [PATCH 27/35] feat(gutter): multi-downspout outlets with CSG drops, routing & profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build out the multi-outlet gutter against the new schema. Each outlet is drilled through the trough floor via CSG (profile-geometry shares the trough cross-section), and a downspout links to one outlet by id so several no longer stack on a single drop. The downspouts panel manages the outlet list; outlet-lookup resolves a downspout's mount from its gutter + outlet. Downspout gains real routing (routing.ts): offset elbows step the run back from the eave overhang to the wall (standoff escape hatch), auto/round/rect cross-section following the gutter profile, wall straps, and splash / kickout / straight terminals, with inspector-editors for the new fields. Length-snap now snaps only the dragged gutter to the geometric corner — never moving its corner-mate — so dragging one gutter can't reset another the user placed deliberately. Adds gutter floorplan (eave-line silhouette). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nodes/src/downspout/definition.ts | 152 +++++--- packages/nodes/src/downspout/geometry.ts | 252 +++++++++++-- .../nodes/src/downspout/inspector-editors.tsx | 90 +++++ packages/nodes/src/downspout/parametrics.ts | 55 +++ packages/nodes/src/downspout/preview.tsx | 27 +- packages/nodes/src/downspout/renderer.tsx | 84 +++-- packages/nodes/src/downspout/routing.ts | 207 +++++++++++ packages/nodes/src/downspout/tool.tsx | 87 +++-- packages/nodes/src/gutter/corner-mitre.ts | 214 ++++++++--- packages/nodes/src/gutter/definition.ts | 26 +- .../nodes/src/gutter/downspouts-panel.tsx | 142 +++++--- packages/nodes/src/gutter/eave-align.ts | 97 +++++ packages/nodes/src/gutter/eave-snap.ts | 32 +- packages/nodes/src/gutter/floorplan.ts | 300 ++++++++++++++++ packages/nodes/src/gutter/geometry.ts | 340 ++++++++++++------ packages/nodes/src/gutter/length-snap.ts | 297 +++++++-------- packages/nodes/src/gutter/outlet-lookup.ts | 106 ++++-- packages/nodes/src/gutter/parametrics.ts | 34 +- packages/nodes/src/gutter/preview.tsx | 4 +- packages/nodes/src/gutter/profile-geometry.ts | 73 ++++ packages/nodes/src/gutter/renderer.tsx | 108 ++++-- 21 files changed, 2133 insertions(+), 594 deletions(-) create mode 100644 packages/nodes/src/downspout/inspector-editors.tsx create mode 100644 packages/nodes/src/downspout/routing.ts create mode 100644 packages/nodes/src/gutter/eave-align.ts create mode 100644 packages/nodes/src/gutter/floorplan.ts create mode 100644 packages/nodes/src/gutter/profile-geometry.ts diff --git a/packages/nodes/src/downspout/definition.ts b/packages/nodes/src/downspout/definition.ts index 6d54bcad9..e5fee0366 100644 --- a/packages/nodes/src/downspout/definition.ts +++ b/packages/nodes/src/downspout/definition.ts @@ -1,36 +1,52 @@ import { + type AnyNodeId, DownspoutNode as DownspoutNodeSchema, type DownspoutNode as DownspoutNodeType, + type GutterNode, + type GutterOutlet, type HandleDescriptor, type NodeDefinition, + useLiveNodeOverrides, + useScene, } from '@pascal-app/core' import { downspoutParametrics } from './parametrics' +import { + computeDownspoutPath, + downspoutPipeDims, + effectiveWallJog, + resolveDownspoutRouting, +} from './routing' import { DownspoutNode } from './schema' // Mirrors the parametric `min`s so handle drags can't shrink the pipe // past what the inspector would accept. const MIN_LENGTH = 0.1 -const MIN_DIAMETER = 0.02 -// Diameter chevron Y — fixed 20 cm below the outlet (so it sits in -// the same camera frame as the gutter the user is editing) rather -// than tracking the pipe length and floating off-screen. -const DIAMETER_HANDLE_Y = -0.2 -// Cleared past the worst-case k-style gutter rim (~ 1.5 × gutter size, -// ≤ 0.2 m at the inspector max). Beyond this the chevron is guaranteed -// to sit outside the gutter footprint and read cleanly from the side. -const DIAMETER_HANDLE_CLEARANCE = 0.25 +// The length cube + dashed leader ride the straight WALL RUN, offset +// outward (+Z, over the eave) past the pipe surface so they float clear +// of the pipe instead of touching it. +const LENGTH_HANDLE_PAD = 0.12 +// Lift the length cube a little up the run from the very bottom so it +// reads as a height grip rather than sitting at the pipe's end. Clamped +// to the run top so it never climbs above the straight section. +const CUBE_LIFT = 0.18 +// Side-move arrows: how far ±X (along the eave) they sit from the pipe, +// and how far below the gutter floor — near the top so they read as +// "grab and slide along the eave". +const SIDE_MOVE_OFFSET = 0.22 +const SIDE_MOVE_Y = -0.12 /** - * Length tracker — dashed vertical leader from the outlet (Y = 0, - * the gutter floor) down to a small cube at the bottom of the pipe, - * `anchor: 'max'` + `axis: 'y'` so dragging the cube down extends - * the pipe 1:1. + * Length tracker — a dashed vertical leader from the outlet (Y = 0, + * the gutter floor) down to a small cube near the bottom of the + * straight wall run, `anchor: 'max'` + `axis: 'y'` so dragging the + * cube down extends the pipe 1:1. * - * Tracker shape (instead of a plain chevron) because the downspout's - * default 2.5 m drop pushes the bottom well below the eave — often - * past the ground plane. The dashed leader keeps the dimension - * readable even when the cube is off-screen, and matches the - * existing tracker handles on the wall / chimney height fields. + * Both the cube and the leader sit on the wall-run line but offset + * outward (away from the pipe) by `radius + LENGTH_HANDLE_PAD`, so the + * whole dimension floats clear of the pipe — it reads as "change the + * height" rather than a box jammed onto the kicked-out mouth. The cube + * rides the run BOTTOM (above the kickout), not the mouth, so the + * dimension stays on the straight part. */ function downspoutLengthHandle(): HandleDescriptor { return { @@ -42,49 +58,86 @@ function downspoutLengthHandle(): HandleDescriptor { currentValue: (n) => n.length, apply: (_n, newValue) => ({ length: Math.max(MIN_LENGTH, newValue) }), placement: { - // Cube sits at the bottom of the pipe (the dimension terminus). - position: (n) => [0, -Math.max(n.length, MIN_LENGTH), 0], + position: (n, scene) => { + const routing = resolveDownspoutRouting(n, scene) + const path = computeDownspoutPath( + n.length, + effectiveWallJog(n, routing), + (n.terminal ?? 'splash') !== 'straight', + ) + const halfZ = downspoutPipeDims(n, routing).halfZ + const y = Math.min(path.wallRunTopY, path.wallRunBottomY + CUBE_LIFT) + return [0, y, path.wallRunZ + halfZ + LENGTH_HANDLE_PAD] + }, }, - // Leader starts at Y = 0 (outlet / gutter floor) and runs DOWN to - // the cube — same logic the existing tracker handles use for - // "the dimension's other end is up here, against the host." + // Leader starts at Y = 0 (outlet / gutter floor) and runs DOWN past + // the cube — same tracker the wall / chimney height fields use. trackerBaseY: () => 0, } } +// Usable half-span — keep the outlet a hair inside each end so the +// collar never lands on a cap (the geometry clamps too; this bounds the +// drag). Reads the host gutter's length. +function moveBound(n: DownspoutNodeType, gutter: GutterNode | undefined): number { + return Math.max(0.05, Math.max(0.05, gutter?.length ?? 2) / 2 - 0.1) +} + +// Effective outlet offset for `currentValue` — reads the gutter's live +// override first (so the dragged value tracks) then the store. +function readOutletOffset(n: DownspoutNodeType): number { + if (!n.gutterId) return 0 + const id = n.gutterId as AnyNodeId + const override = useLiveNodeOverrides.getState().get(id) as Partial | undefined + const gutter = useScene.getState().nodes[id] as GutterNode | undefined + const outlets = (override?.outlets as GutterOutlet[] | undefined) ?? gutter?.outlets ?? [] + return outlets.find((o) => o.id === n.outletId)?.offset ?? 0 +} + /** - * Diameter chevron — symmetric radial growth, dragged outward (away - * from the building) along the gutter-local +Z axis. `anchor: - * 'center'` grows the value by 2× the cursor delta so the visible - * +Z edge tracks the pointer. - * - * Sits at a FIXED Y near the top of the pipe (DIAMETER_HANDLE_Y) so - * it stays in the gutter's camera frame regardless of pipe length, - * and at a Z far enough outward that the gutter's rim — which - * extends up to ~ 1.5 × `size` past the outlet axis on k-style - * profiles — doesn't occlude the chevron. + * Side-move arrow — one of a ±X pair that slides the downspout along the + * eave. The position lives on the host gutter's outlet + * (`gutter.outlets[].offset`), not on the downspout, so `overrideTarget` + * redirects the drag's live override + commit to the gutter and `apply` + * returns the gutter's patch. The arrows sit near the top of the pipe + * and ride its group, which moves with the outlet — so they track the + * cursor 1:1 (`anchor: 'min'` → factor +1). */ -function downspoutDiameterHandle(): HandleDescriptor { +function downspoutMoveHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 return { kind: 'linear-resize', - axis: 'z', - anchor: 'center', - min: MIN_DIAMETER, - currentValue: (n) => n.diameter, - apply: (_n, newValue) => ({ diameter: Math.max(MIN_DIAMETER, newValue) }), + axis: 'x', + anchor: 'min', + cursor: 'ew-resize', + overrideTarget: (n) => (n.gutterId ? (n.gutterId as AnyNodeId) : undefined), + currentValue: (n) => readOutletOffset(n), + apply: (n, newOffset, scene) => { + const gutter = n.gutterId ? scene.get(n.gutterId as AnyNodeId) : undefined + if (!gutter) return {} + const outlets = (gutter.outlets ?? []).map((o) => + o.id === n.outletId ? { ...o, offset: newOffset } : o, + ) + // Patch targets the GUTTER (overrideTarget), not the downspout. + return { outlets } as unknown as Partial + }, + min: (n, scene) => + -moveBound(n, n.gutterId ? scene.get(n.gutterId as AnyNodeId) : undefined), + max: (n, scene) => + moveBound(n, n.gutterId ? scene.get(n.gutterId as AnyNodeId) : undefined), placement: { - position: (n) => [ - 0, - DIAMETER_HANDLE_Y, - Math.max(n.diameter, MIN_DIAMETER) / 2 + DIAMETER_HANDLE_CLEARANCE, - ], + // Static ±X beside the top of the pipe; the group it rides moves + // with the outlet, so the arrow stays under the cursor as it slides. + position: () => [sign * SIDE_MOVE_OFFSET, SIDE_MOVE_Y, 0], + rotationY: () => (side === 'right' ? 0 : Math.PI), }, } } const downspoutHandles: HandleDescriptor[] = [ downspoutLengthHandle(), - downspoutDiameterHandle(), + downspoutMoveHandle('left'), + downspoutMoveHandle('right'), ] /** @@ -96,9 +149,9 @@ const downspoutHandles: HandleDescriptor[] = [ * position. * * No `handles` yet — the downspout's geometry is anchored to the - * gutter's outlet, so length / diameter live in the inspector rather - * than as draggable arrows for v1. Future passes can add a length - * tracker handle similar to the gutter's size chevron. + * gutter's outlet. Length (tracker cube at the routed mouth) and + * diameter (chevron on the wall run) are draggable arrows; the wall + * standoff lives in the inspector. */ export const downspoutDefinition: NodeDefinition = { kind: 'downspout', @@ -151,7 +204,6 @@ export const downspoutDefinition: NodeDefinition = { mcp: { description: - 'A downspout — vertical drop pipe attached to a gutter outlet. length / diameter parametric; future passes will add elbows and a kickout.', + 'A downspout — drop pipe from a gutter outlet that elbows back to the wall, runs down the wall face, and kicks out at the bottom. length / diameter / standoff parametric.', }, } - diff --git a/packages/nodes/src/downspout/geometry.ts b/packages/nodes/src/downspout/geometry.ts index c07361c29..aa5f56329 100644 --- a/packages/nodes/src/downspout/geometry.ts +++ b/packages/nodes/src/downspout/geometry.ts @@ -1,29 +1,241 @@ 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 { + computeDownspoutPath, + type DownspoutPath, + type DownspoutRouting, + downspoutPipeDims, + effectiveWallJog, +} from './routing' /** - * Downspout pipe builder. The pipe is a vertical cylinder hanging from - * the gutter outlet down to ground (or wherever `length` ends). Mesh - * frame is centred on the outlet — local Y = 0 is the TOP of the pipe - * (flush with the gutter floor / outlet stub bottom) and Y = −length - * is where the bottom of the pipe sits. + * Downspout pipe builder. The pipe follows a real downspout's path — + * a short DROP out of the collar, an OFFSET ELBOW back to the wall, the + * VERTICAL RUN down the wall, and a bottom KICKOUT — plus the hardware + * that makes it read real: WALL STRAPS clamping the run to the wall, + * an open (hollow) mouth at the kickout, and a SPLASH BLOCK on the + * ground under the mouth. * - * Single piece, single material: when a kickout or splash block lands - * we'll merge them in like the gutter does with its hangers / outlet. + * Mesh frame is centred on the outlet: local Y = 0 is the gutter floor, + * −Y is down, −Z is toward the wall (+Z is outward over the eave). The + * path lives in the local Y/Z plane; X is the gutter-length axis. + * + * Cross-section follows the host gutter's profile: round on half-round, + * rectangular on k-style / box. Straight legs are solid cylinders / + * boxes welded at the corners with a small joint; the kickout leg is a + * hollow tube so the open mouth reads through. * * Pure: no React, no scene access. */ -const RADIAL_SEGMENTS = 24 - -export function buildDownspoutGeometry(node: DownspoutNode): THREE.BufferGeometry { - const radius = Math.max(0.01, node.diameter / 2) - const length = Math.max(0.1, node.length) - - // CylinderGeometry's default axis is +Y, centred at the origin. - // We want the TOP at Y = 0 and the BOTTOM at Y = −length, so - // translate down by half the length. - const pipe = new THREE.CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS).toNonIndexed() - pipe.translate(0, -length / 2, 0) - pipe.computeVertexNormals() - return pipe +const RADIAL_SEGMENTS = 16 +const JOINT_SEGMENTS = 12 +const FWD = new THREE.Vector3(0, 0, 1) +const UP = new THREE.Vector3(0, 1, 0) + +// Pipe wall thickness for the hollow (open-mouth) kickout leg. +const PIPE_WALL = 0.004 +// Wall straps — a thin band clamps the run to the wall, set in a margin +// from each end (spacing comes from the node). +const STRAP_END_MARGIN = 0.3 +const STRAP_THICKNESS = 0.022 +const STRAP_OVERHANG = 0.014 +// Splash block — a tilted slab on the ground under the mouth that +// carries water away from the foundation. +const SPLASH_WIDTH = 0.22 +const SPLASH_LENGTH = 0.34 +const SPLASH_THICKNESS = 0.05 +const SPLASH_TILT = 0.1 + +export function buildDownspoutGeometry( + node: DownspoutNode, + routing?: DownspoutRouting | null, +): THREE.BufferGeometry { + const dims = downspoutPipeDims(node, routing) + const terminal = node.terminal ?? 'splash' + // 'straight' runs the pipe to grade with no kickout leg. + const pathData = computeDownspoutPath( + node.length, + effectiveWallJog(node, routing), + terminal !== 'straight', + ) + + // Drop consecutive duplicates (jog == 0 collapses the elbow; no kick + // collapses the bottom two) so we never build a zero-length segment. + const path: THREE.Vector3[] = [] + for (const [x, y, z] of pathData.points) { + const p = new THREE.Vector3(x, y, z) + const last = path.at(-1) + if (!last || last.distanceTo(p) > 1e-4) path.push(p) + } + + const pieces: THREE.BufferGeometry[] = [] + const lastLeg = path.length - 2 + for (let i = 0; i < path.length - 1; i++) { + // The final leg (the kickout mouth) is a hollow tube so you can see + // up the open end; the rest stay solid (their outer surface reads + // identically, and they're capped by the collar / joints anyway). + pieces.push( + i === lastLeg + ? ringTube(path[i]!, path[i + 1]!, dims) + : segmentBetween(path[i]!, path[i + 1]!, dims), + ) + if (i > 0) pieces.push(jointAt(path[i]!, path[i - 1]!, path[i + 1]!, dims)) + } + + if ((node.strapStyle ?? 'band') !== 'none') { + for (const strap of buildStraps(pathData, dims, node.strapSpacing ?? 1.8)) pieces.push(strap) + } + if (terminal === 'splash') { + const splash = buildSplash(pathData) + if (splash) pieces.push(splash) + } + + const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!) + if (merged !== pieces[0]) { + for (const p of pieces) p.dispose() + } + merged.computeVertexNormals() + return merged +} + +/** + * Solid segment spanning two points. Round → a cylinder; rect → a box + * (2·halfX wide along the gutter length, 2·halfZ deep outward). The + * orient-onto-direction rotation is purely about X for our planar path, + * so the box's width stays aligned with the gutter length axis. + */ +function segmentBetween( + a: THREE.Vector3, + b: THREE.Vector3, + dims: OutletDims, +): THREE.BufferGeometry { + const dir = new THREE.Vector3().subVectors(b, a) + const len = dir.length() + const geo = + 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() + // 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())) + geo.translate((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2) + return geo +} + +/** + * Hollow tube spanning two points — a ring (round) / rectangular-ring + * cross-section extruded along the leg, so both ends are open and the + * bore reads through. Used for the kickout mouth. + */ +function ringTube(a: THREE.Vector3, b: THREE.Vector3, dims: OutletDims): THREE.BufferGeometry { + const dir = new THREE.Vector3().subVectors(b, a) + const len = dir.length() + const shape = new THREE.Shape() + const hole = new THREE.Path() + if (dims.shape === 'round') { + shape.absarc(0, 0, dims.halfX, 0, Math.PI * 2, false) + hole.absarc(0, 0, Math.max(0.002, dims.halfX - PIPE_WALL), 0, Math.PI * 2, true) + } else { + const ox = dims.halfX + const oz = dims.halfZ + const ix = Math.max(0.002, ox - PIPE_WALL) + const iz = Math.max(0.002, oz - PIPE_WALL) + shape.moveTo(-ox, -oz) + shape.lineTo(ox, -oz) + shape.lineTo(ox, oz) + shape.lineTo(-ox, oz) + shape.closePath() + hole.moveTo(-ix, -iz) + hole.lineTo(-ix, iz) + hole.lineTo(ix, iz) + hole.lineTo(ix, -iz) + hole.closePath() + } + shape.holes.push(hole) + // ExtrudeGeometry runs the shape (in XY) along +Z from 0 to depth; + // orient +Z onto the leg direction, then move the z=0 end to `a`. + // ExtrudeGeometry is already non-indexed, matching the merge set. + const geo = new THREE.ExtrudeGeometry(shape, { + depth: len, + bevelEnabled: false, + steps: 1, + curveSegments: RADIAL_SEGMENTS, + }) + geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(FWD, dir.normalize())) + geo.translate(a.x, a.y, a.z) + return geo +} + +/** + * Corner joint at `p` between the segments (prev→p) and (p→next). Round + * → a sphere; rect → a box aligned to the bend bisector so it bridges + * the wedge the two box ends leave open at the outer corner. + */ +function jointAt( + p: THREE.Vector3, + prev: THREE.Vector3, + next: THREE.Vector3, + dims: OutletDims, +): THREE.BufferGeometry { + if (dims.shape === 'round') { + const geo = new THREE.SphereGeometry(dims.halfX, JOINT_SEGMENTS, JOINT_SEGMENTS).toNonIndexed() + geo.translate(p.x, p.y, p.z) + return geo + } + const dirIn = new THREE.Vector3().subVectors(p, prev).normalize() + const dirOut = new THREE.Vector3().subVectors(next, p).normalize() + const bis = new THREE.Vector3().addVectors(dirIn, dirOut) + 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() + geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, bis)) + geo.translate(p.x, p.y, p.z) + return geo +} + +/** + * Thin bands clamping the wall run to the wall, ~`STRAP_SPACING` apart + * and set in from each end. Each is a flat box a touch proud of the + * pipe so it reads as a strap wrapping the run. + */ +function buildStraps( + path: DownspoutPath, + dims: OutletDims, + spacing: number, +): THREE.BufferGeometry[] { + const top = path.wallRunTopY + const bottom = path.wallRunBottomY + const z = path.wallRunZ + const runLen = top - bottom + if (runLen < STRAP_END_MARGIN * 2 + 0.05) return [] + + const usable = runLen - STRAP_END_MARGIN * 2 + const count = Math.max(1, Math.floor(usable / Math.max(0.2, spacing)) + 1) + const stride = count > 1 ? usable / (count - 1) : 0 + const w = 2 * dims.halfX + 2 * STRAP_OVERHANG + const d = 2 * dims.halfZ + 2 * STRAP_OVERHANG + + const straps: THREE.BufferGeometry[] = [] + 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() + band.translate(0, y, z) + straps.push(band) + } + return straps +} + +/** + * Tilted slab on the ground under the mouth, extending outward (+Z, + * away from the wall) so it carries water off from the foundation. + */ +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() + // 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) + return slab } diff --git a/packages/nodes/src/downspout/inspector-editors.tsx b/packages/nodes/src/downspout/inspector-editors.tsx new file mode 100644 index 000000000..69fdf06bc --- /dev/null +++ b/packages/nodes/src/downspout/inspector-editors.tsx @@ -0,0 +1,90 @@ +'use client' + +import { + type AnyNodeId, + type DownspoutNode, + type GutterNode, + type GutterOutlet, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { SliderControl } from '@pascal-app/editor' + +/** + * Position-along-the-eave editor for a downspout. The downspout's spot + * is owned by its outlet on the host gutter (`gutter.outlets[].offset`), + * not by the downspout itself — so this slider reads + writes that + * outlet on the gutter rather than patching the downspout. + * + * Mesh-first commit (same shape as the in-world handle drags): + * - `onChange` (live, every drag tick) publishes the new outlets to the + * gutter's `useLiveNodeOverrides`. The gutter renderer rebuilds its + * mesh from that override and the downspout renderer re-reads its + * outlet — both move immediately, with NO write to the scene store / + * history. The slider also reads the override back so its number + * tracks during the drag. + * - `onCommit` (on release) writes the final outlets to the store once + * (the single undoable change — `SliderControl` resumes history + * first) and drops the override so the renderers read the store again. + * + * Wired via `parametrics.fields[].kind: 'custom'`; hidden when the + * downspout isn't linked to an outlet. + */ +export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) { + const gutter = useScene((s) => + node.gutterId ? (s.nodes[node.gutterId as AnyNodeId] as GutterNode | undefined) : undefined, + ) + // Live override on the gutter, so the readout tracks the in-flight drag. + const override = useLiveNodeOverrides((s) => + node.gutterId + ? (s.get(node.gutterId as AnyNodeId) as Partial | undefined) + : undefined, + ) + + if (!gutter || gutter.type !== 'gutter') return null + const storeOutlets = gutter.outlets ?? [] + const effectiveOutlets = (override?.outlets as GutterOutlet[] | undefined) ?? storeOutlets + const outlet = effectiveOutlets.find((o) => o.id === node.outletId) + if (!outlet) return null + + // Usable half-span — keep the outlet a hair inside each end so the + // collar never lands on a cap. The geometry clamps too; this just + // keeps the slider honest. + const bound = Math.max(0.05, Math.max(0.05, gutter.length) / 2 - 0.1) + const gutterId = gutter.id as AnyNodeId + + // Set the dragged outlet's offset on a copy of the STORE outlets (the + // canonical base — the slider hands an absolute value each tick). + const withOffset = (offset: number): GutterOutlet[] => + storeOutlets.map((o) => (o.id === node.outletId ? { ...o, offset } : o)) + + const handleChange = (offset: number) => { + // Mesh-first: publish to the gutter override, no store write. + useLiveNodeOverrides.getState().set(gutterId, { outlets: withOffset(offset) }) + } + + const handleCommit = (offset: number) => { + // Commit once to the store, then drop the override. + const state = useScene.getState() + state.updateNode(gutterId, { outlets: withOffset(offset) }) + useLiveNodeOverrides.getState().clear(gutterId) + state.markDirty(gutterId) + } + + return ( + + ) +} diff --git a/packages/nodes/src/downspout/parametrics.ts b/packages/nodes/src/downspout/parametrics.ts index dd0e0797d..d87752950 100644 --- a/packages/nodes/src/downspout/parametrics.ts +++ b/packages/nodes/src/downspout/parametrics.ts @@ -1,4 +1,5 @@ import type { ParametricDescriptor } from '@pascal-app/core' +import { DownspoutPositionEditor } from './inspector-editors' import type { DownspoutNode } from './schema' export const downspoutParametrics: ParametricDescriptor = { @@ -8,6 +9,60 @@ export const downspoutParametrics: ParametricDescriptor = { fields: [ { key: 'length', kind: 'number', unit: 'm', min: 0.1, max: 8, step: 0.05 }, { key: 'diameter', kind: 'number', unit: 'm', min: 0.02, max: 0.15, step: 0.005 }, + // Cross-section: follow the gutter profile, or force round / rect. + { + key: 'shape', + kind: 'enum', + options: ['auto', 'round', 'rect'], + display: 'segmented', + }, + ], + }, + { + label: 'Hardware', + fields: [ + // Wall straps clamping the run, like the gutter's hangers. + { + key: 'strapStyle', + kind: 'enum', + options: ['band', 'none'], + display: 'segmented', + }, + { + key: 'strapSpacing', + kind: 'number', + unit: 'm', + min: 0.3, + max: 3, + step: 0.1, + visibleIf: (n) => (n.strapStyle ?? 'band') !== 'none', + }, + // Bottom treatment: splash block, kickout only, or straight to grade. + { + key: 'terminal', + kind: 'enum', + options: ['splash', 'kickout', 'straight'], + display: 'segmented', + }, + ], + }, + { + label: 'Placement', + fields: [ + // Slide the outlet (and so this downspout) along the eave. Edits + // the linked outlet's offset on the host gutter — the only way to + // reposition a drop after placing it. Hidden when unlinked. + { + key: 'outletPosition', + kind: 'custom', + component: DownspoutPositionEditor, + visibleIf: (n) => Boolean(n.outletId), + }, + // How far proud of the wall the pipe sits. Crank it up if the + // auto-routed run buries into the wall (the wall isn't where the + // roof overhang implies); 0 puts the pipe surface on the wall + // face; large values pull the run back out toward the eave. + { key: 'standoff', kind: 'number', unit: 'm', min: 0, max: 0.6, step: 0.01 }, ], }, ], diff --git a/packages/nodes/src/downspout/preview.tsx b/packages/nodes/src/downspout/preview.tsx index 7e5a50bad..596ebecbd 100644 --- a/packages/nodes/src/downspout/preview.tsx +++ b/packages/nodes/src/downspout/preview.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' import { buildDownspoutGeometry } from './geometry' +import type { DownspoutRouting } from './routing' import type { DownspoutNode } from './schema' /** @@ -10,9 +11,31 @@ import type { DownspoutNode } from './schema' * pipe so the placement ghost matches what lands on click. No * internal transform wrapper; the placement tool nests this under * the gutter / outlet chain so the position math stays in one place. + * + * `routing` mirrors the renderer's — when the tool resolves the host + * gutter it feeds the same wall-jog so the ghost already shows the + * elbowed path, not a straight drop. */ -const DownspoutPreview = ({ node }: { node: DownspoutNode }) => { - const geometry = useMemo(() => buildDownspoutGeometry(node), [node.length, node.diameter]) +const DownspoutPreview = ({ + node, + routing, +}: { + node: DownspoutNode + routing?: DownspoutRouting | null +}) => { + const geometry = useMemo( + () => buildDownspoutGeometry(node, routing), + [ + node.length, + node.diameter, + node.standoff, + node.shape, + node.strapStyle, + node.strapSpacing, + node.terminal, + routing, + ], + ) const material = useMemo( () => diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx index ebcaab94c..cc0cb6e63 100644 --- a/packages/nodes/src/downspout/renderer.tsx +++ b/packages/nodes/src/downspout/renderer.tsx @@ -20,8 +20,9 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { computeEaveY } from '../gutter/eave-snap' -import { resolveGutterOutletPlacement } from '../gutter/outlet-lookup' +import { resolveGutterOutletById } from '../gutter/outlet-lookup' import { buildDownspoutGeometry } from './geometry' +import { computeDownspoutRouting } from './routing' const defaultMaterial = new THREE.MeshStandardMaterial({ color: 0xff_ff_ff, @@ -87,9 +88,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { ) const segmentOverrides = useLiveNodeOverrides((s) => effectiveGutter?.roofSegmentId - ? (s.get(effectiveGutter.roofSegmentId as AnyNodeId) as - | Partial - | undefined) + ? (s.get(effectiveGutter.roofSegmentId as AnyNodeId) as Partial | undefined) : undefined, ) const effectiveSegment: RoofSegmentNode | undefined = segment @@ -98,7 +97,39 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { : segment : undefined - const geometry = useMemo(() => buildDownspoutGeometry(node), [node.length, node.diameter]) + // Routing back to the wall — memoised on the gutter/segment values + // that actually move the jog or the collar bore, so the pipe geometry + // only rebuilds when one of those changes (not on every override-merge + // render). Resolves to null when the gutter has no outlet. + const routing = useMemo( + () => + effectiveGutter && effectiveSegment + ? computeDownspoutRouting(effectiveGutter, effectiveSegment, node.outletId) + : null, + [ + effectiveGutter?.profile, + effectiveGutter?.size, + // The outlets array — its referenced entry's diameter / offset + // drives the collar bore + nesting. + effectiveGutter ? JSON.stringify(effectiveGutter.outlets) : undefined, + effectiveSegment?.overhang, + node.outletId, + ], + ) + + const geometry = useMemo( + () => buildDownspoutGeometry(node, routing), + [ + node.length, + node.diameter, + node.standoff, + node.shape, + node.strapStyle, + node.strapSpacing, + node.terminal, + routing, + ], + ) useEffect(() => () => geometry.dispose(), [geometry]) const material = useMemo(() => { @@ -111,7 +142,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) if (!effectiveGutter || !effectiveSegment) return null - const outlet = resolveGutterOutletPlacement(effectiveGutter) + const outlet = resolveGutterOutletById(effectiveGutter, node.outletId) if (!outlet) return null const segPos = effectiveSegment.position ?? [0, 0, 0] @@ -119,26 +150,37 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { const liveEaveY = computeEaveY(effectiveSegment) const gutterRotY = effectiveGutter.rotation ?? 0 + // Bake the gutter's position + Y-rotation into the registered ref so it + // sits as a DIRECT child of the segment-transform group — its local + // pose is the outlet's full segment-local placement. `NodeArrowHandles` + // copies the registered object's LOCAL transform into the segment's + // object (it assumes a flat node → scene-parent chain); with the old + // nested segment → gutter → outlet groups it only saw the innermost + // `[outlet.x …]` offset and the handles landed at the roof centre. + const gutterX = effectiveGutter.position[0] ?? 0 + const gutterZ = effectiveGutter.position[2] ?? 0 + const cos = Math.cos(gutterRotY) + const sin = Math.sin(gutterRotY) + const outletSegX = gutterX + (outlet.x * cos + outlet.z * sin) + const outletSegZ = gutterZ + (-outlet.x * sin + outlet.z * cos) + const outletSegY = liveEaveY + outlet.y + return ( - - - + ) diff --git a/packages/nodes/src/downspout/routing.ts b/packages/nodes/src/downspout/routing.ts new file mode 100644 index 000000000..c37cb4bf5 --- /dev/null +++ b/packages/nodes/src/downspout/routing.ts @@ -0,0 +1,207 @@ +import type { + AnyNodeId, + DownspoutNode, + GutterNode, + RoofSegmentNode, + SceneApi, +} from '@pascal-app/core' +import { EAVE_TUCK_INWARD } from '../gutter/eave-snap' +import { resolveGutterOutletById } from '../gutter/outlet-lookup' +import { + type OutletDims, + type OutletShape, + outletDims, + profileFloorMidZ, +} from '../gutter/profile-geometry' + +/** + * Routing parameters that turn the downspout from a straight drop into + * a pipe that actually returns to the wall. Derived from the host + * gutter + its roof segment — the geometry builder stays pure (takes + * this as data) so the renderer, the placement preview, and the handle + * descriptors can all feed it the same numbers. + */ +export type DownspoutRouting = { + /** + * Distance the pipe must travel toward the wall (downspout-local −Z) + * to leave the eave overhang and sit flat against the fascia. The + * outlet hangs `overhang − tuck` outboard of the wall face, plus the + * `floorMidZ` offset of the outlet within the trough — so the offset + * elbow at the top steps the pipe back by exactly this much. + */ + wallJog: number + /** Cross-section the pipe takes — round on half-round, rect on k-style / box. */ + shape: OutletShape + /** + * Inner half-extents of the gutter's drilled collar (along-length X, + * outward Z). The pipe's cross-section is clamped just under these so + * it slip-fits up inside the collar instead of sharing a coincident + * surface with it (the old pipe sat at exactly the bore and z-fought + * the collar wall). + */ + collarHalfX: number + collarHalfZ: number +} + +/** + * Pure routing from resolved nodes — used by the renderer / preview / + * tool, which already hold the effective gutter + segment. + */ +export function computeDownspoutRouting( + gutter: GutterNode, + segment: Pick, + outletId: string | undefined, +): DownspoutRouting | null { + const outlet = resolveGutterOutletById(gutter, outletId) + if (!outlet) return null + + const overhang = segment.overhang ?? 0 + const floorMidZ = profileFloorMidZ(gutter.profile ?? 'k-style', Math.max(0.04, gutter.size)) + // The gutter rim is tucked `EAVE_TUCK_INWARD` back from the very tip + // of the overhang, so the real outboard distance is `overhang − tuck` + // (never negative — a flush eave still leaves the floorMidZ offset). + const wallJog = Math.max(0, overhang - EAVE_TUCK_INWARD) + floorMidZ + + return { + wallJog, + shape: outlet.shape, + collarHalfX: outlet.innerHalfX, + collarHalfZ: outlet.innerHalfZ, + } +} + +/** + * Routing for the handle descriptors, which only get `(node, sceneApi)`. + * Walks downspout → gutter → segment through the scene snapshot. + */ +export function resolveDownspoutRouting( + node: DownspoutNode, + sceneApi: SceneApi, +): DownspoutRouting | null { + if (!node.gutterId) return null + const gutter = sceneApi.get(node.gutterId as AnyNodeId) + if (!gutter || gutter.type !== 'gutter') return null + const segment = gutter.roofSegmentId + ? sceneApi.get(gutter.roofSegmentId as AnyNodeId) + : undefined + return computeDownspoutRouting(gutter, segment ?? { overhang: 0 }, node.outletId) +} + +// ─── Pipe cross-section + effective jog ────────────────────────────── + +// Slip-fit clearance — when the pipe lands within this of the collar +// bore (the placement default, where pipe == hole), nudge it just inside +// so it doesn't share a coincident wall (the old z-fighting). +const NEAR_BORE = 0.002 +const SLIP_CLEARANCE = 0.0005 + +function nestUnder(half: number, collar: number | undefined): number { + if (collar !== undefined && Math.abs(half - collar) < NEAR_BORE) { + return Math.max(0.005, collar - SLIP_CLEARANCE) + } + return half +} + +/** + * Rendered pipe cross-section — round (halfX = halfZ = radius) or rect, + * following the host gutter's profile. Defaults to `diameter`-sized, but + * each half-extent that lands within a hair of the collar's matching + * bore is nudged just inside so the pipe slip-fits the collar instead of + * sharing a coincident wall. A deliberately larger / smaller pipe is + * left alone, so the diameter field stays honest. + */ +export function downspoutPipeDims( + node: Pick, + routing?: DownspoutRouting | null, +): OutletDims { + // 'auto' follows the gutter profile; 'round' / 'rect' override it. + const shape: OutletShape = + node.shape && node.shape !== 'auto' ? node.shape : (routing?.shape ?? 'round') + const dims = outletDims(shape, node.diameter) + if (!routing) return dims + return { + shape, + halfX: nestUnder(dims.halfX, routing.collarHalfX), + halfZ: nestUnder(dims.halfZ, routing.collarHalfZ), + } +} + +/** + * The −Z distance the wall run actually sits at. The raw `wallJog` + * reaches the wall *face*; we pull back by the pipe's outward half-depth + * (so the pipe's surface — not its centerline — meets the wall) plus the + * `standoff` (bracket gap / overshoot escape hatch), so the pipe sits + * proud of the wall instead of burying into it. + */ +export function effectiveWallJog( + node: Pick, + routing?: DownspoutRouting | null, +): number { + if (!routing) return 0 + const dims = downspoutPipeDims(node, routing) + return Math.max(0, routing.wallJog - dims.halfZ - (node.standoff ?? 0)) +} + +// ─── Centerline path ───────────────────────────────────────────────── + +// Vertical drop straight out of the collar before the offset elbow. +const TOP_DROP = 0.05 +// Bottom kickout: how far the mouth throws outward (+Z) and how tall +// the kicked section is. Skipped when the pipe is too short to fit it. +const KICK_OUT = 0.08 +const KICK_RISE = 0.1 + +export type DownspoutPath = { + /** Centerline points, top → bottom, in downspout-local space. */ + points: [number, number, number][] + /** Bottom mouth — the kicked-out pipe end. */ + bottom: [number, number, number] + /** Z of the vertical wall run (downspout-local; −jog). */ + wallRunZ: number + /** Y at the top of the straight wall run (just below the offset elbow). */ + wallRunTopY: number + /** Y at the bottom of the straight wall run (just above the kickout). */ + wallRunBottomY: number +} + +/** + * Pure centerline of the routed pipe. Shared by the geometry builder + * (sweeps a cylinder along it) and the handle descriptors (place the + * length cube at `bottom`), so the dimension chrome can never drift + * from the mesh. + * + * Local frame: Y = 0 at the gutter floor, −Y down, −Z toward the wall. + * The four legs are the vertical drop out of the collar, the offset + * elbow stepping back to the wall, the wall run, and the kickout. + */ +export function computeDownspoutPath(length: number, jog: number, allowKick = true): DownspoutPath { + const len = Math.max(0.1, length) + const j = Math.max(0, jog) + const drop = Math.min(TOP_DROP, len * 0.15) + // Offset elbow runs at ~45° (vertical travel == horizontal jog) but + // never eats more than what's left after the drop + a minimum run. + const elbowVert = Math.min(j, Math.max(0, len - drop - 0.1)) + const afterElbow = len - drop - elbowVert + // `allowKick` off (terminal 'straight') runs the pipe straight to the + // bottom — no kickout leg. + const kick = allowKick && afterElbow > KICK_RISE * 1.5 + const kickRise = kick ? Math.min(KICK_RISE, afterElbow * 0.3) : 0 + const kickOut = kick ? KICK_OUT : 0 + + const wallRunTopY = -drop - elbowVert + const wallRunBottomY = -len + kickRise + const bottom: [number, number, number] = [0, -len, -j + kickOut] + return { + points: [ + [0, 0, 0], // collar mouth + [0, -drop, 0], // bottom of the first drop + [0, wallRunTopY, -j], // offset elbow, now at the wall + [0, wallRunBottomY, -j], // bottom of the wall run + bottom, // kicked mouth + ], + bottom, + wallRunZ: -j, + wallRunTopY, + wallRunBottomY, + } +} diff --git a/packages/nodes/src/downspout/tool.tsx b/packages/nodes/src/downspout/tool.tsx index 2b7e2454c..1f92affcd 100644 --- a/packages/nodes/src/downspout/tool.tsx +++ b/packages/nodes/src/downspout/tool.tsx @@ -5,38 +5,45 @@ import { DownspoutNode, emitter, type GutterEvent, + type GutterNode, + generateId, type RoofSegmentNode, + sceneRegistry, useScene, } from '@pascal-app/core' import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useState } from 'react' +import { Vector3 } from 'three' import { computeEaveY } from '../gutter/eave-snap' -import { resolveGutterOutletPlacement } from '../gutter/outlet-lookup' +import { resolveGutterOutletById } from '../gutter/outlet-lookup' import { downspoutDefinition } from './definition' import DownspoutPreview from './preview' +import { computeDownspoutRouting, type DownspoutRouting } from './routing' + +const DEFAULT_OUTLET_DIAMETER = 0.07 type PreviewTarget = { segment: { position: [number, number, number]; rotation: number; eaveY: number } gutter: { position: [number, number, number]; rotation: number } outlet: { x: number; y: number; z: number; bore: number } + routing: DownspoutRouting | null } /** - * Downspout placement tool. Listens for `gutter:*` events and only - * highlights gutters whose `outletSide` is enabled — a downspout - * without an outlet is meaningless, so the user is gated to set the - * outlet on the gutter first. - * - * On click, the new downspout is parented (scene-graph) to the same - * roof-segment that hosts the gutter — that's the same lookup roof - * accessories already do, so the downspout naturally renders under - * the segment's `roof-elements` group alongside the gutter. + * Downspout placement tool. Hovering a gutter previews a downspout at + * the cursor's position ALONG the gutter; clicking drills a NEW outlet + * there (appended to the gutter's `outlets`) and drops a downspout + * linked to it. So multiple downspouts on one gutter land where you + * click instead of stacking on a single outlet. * - * Length defaults to the eave-Y at click time, so the pipe drops to - * Y = 0 (segment-local ground plane) without the user having to set - * it. They can tweak the length in the inspector afterward. + * The cursor's along-length offset is read by projecting the world hit + * into the gutter's registered mesh frame (worldToLocal → local X). A + * throwaway gutter with a single `preview` outlet feeds the same outlet + * lookup + routing the committed pipe uses, so the ghost matches. */ +const _hit = new Vector3() + const DownspoutTool = () => { const activeBuildingId = useViewer((s) => s.selection.buildingId) const setSelection = useViewer((s) => s.setSelection) @@ -55,14 +62,32 @@ const DownspoutTool = () => { useEffect(() => { if (!activeBuildingId) return + // Cursor's offset along the gutter length, from the world hit. + const cursorOffset = (gutter: GutterNode, world: [number, number, number]): number | null => { + const obj = sceneRegistry.nodes.get(gutter.id as AnyNodeId) + if (!obj) return null + obj.updateWorldMatrix(true, false) + return obj.worldToLocal(_hit.set(world[0], world[1], world[2])).x + } + const computeTarget = (event: GutterEvent): PreviewTarget | null => { const gutter = event.node - const outlet = resolveGutterOutletPlacement(gutter) - if (!outlet) return null const segmentId = gutter.roofSegmentId as AnyNodeId | undefined if (!segmentId) return null const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined if (!segment) return null + const offset = cursorOffset(gutter, event.position) + if (offset === null) return null + + // Throwaway single-outlet gutter at the cursor so the lookup + + // routing produce the exact pose the commit will store. + const ghost: GutterNode = { + ...gutter, + outlets: [{ id: 'preview', offset, diameter: DEFAULT_OUTLET_DIAMETER }], + } + const outlet = resolveGutterOutletById(ghost, 'preview') + if (!outlet) return null + return { segment: { position: (segment.position ?? [0, 0, 0]) as [number, number, number], @@ -74,6 +99,7 @@ const DownspoutTool = () => { rotation: gutter.rotation ?? 0, }, outlet, + routing: computeDownspoutRouting(ghost, segment, 'preview'), } } @@ -87,27 +113,37 @@ const DownspoutTool = () => { const onClick = (event: GutterEvent) => { const gutter = event.node - const outlet = resolveGutterOutletPlacement(gutter) - if (!outlet) return const segmentId = gutter.roofSegmentId as AnyNodeId | undefined if (!segmentId) return const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined if (!segment) return + const offset = cursorOffset(gutter, event.position) + if (offset === null) return + + // Drill a new outlet at the clicked offset, then drop a downspout + // linked to it. Both land in one undoable step. + const outletId = generateId('outlet') + const outlets = [ + ...(gutter.outlets ?? []), + { id: outletId, offset, diameter: DEFAULT_OUTLET_DIAMETER }, + ] + const state = useScene.getState() + state.updateNode(gutter.id as AnyNodeId, { outlets }) + state.dirtyNodes.add(gutter.id as AnyNodeId) - // Default length: drop from the gutter outlet down to the - // segment's local Y = 0 plane. The outlet sits at - // (eaveY + outlet.y) where outlet.y = −size. So the drop is - // (eaveY − size). + const outlet = resolveGutterOutletById({ ...gutter, outlets }, outletId) + if (!outlet) return + // Drop from the gutter outlet (at eaveY − size) down to segment Y = 0. const dropLength = Math.max(0.1, computeEaveY(segment) + outlet.y) const downspout = DownspoutNode.parse({ ...downspoutDefinition.defaults(), name: 'Downspout', gutterId: gutter.id, + outletId, length: dropLength, diameter: outlet.bore * 2, }) - const state = useScene.getState() state.createNode(downspout, segmentId) state.dirtyNodes.add(segmentId) setSelection({ selectedIds: [downspout.id] }) @@ -135,7 +171,10 @@ const DownspoutTool = () => { rotation-y={target.gutter.rotation} > - + @@ -155,4 +194,4 @@ function previewNodeWithDefaults( } as typeof base } -export default DownspoutTool \ No newline at end of file +export default DownspoutTool diff --git a/packages/nodes/src/gutter/corner-mitre.ts b/packages/nodes/src/gutter/corner-mitre.ts index ea10b84fc..6322c1759 100644 --- a/packages/nodes/src/gutter/corner-mitre.ts +++ b/packages/nodes/src/gutter/corner-mitre.ts @@ -1,12 +1,13 @@ -import type { GutterNode } from '@pascal-app/core' +import type { GutterNode, RoofSegmentNode } from '@pascal-app/core' /** * Auto-mitre detector for two gutters meeting at a roof corner. * * When two gutters' endpoints land within `CORNER_EPSILON` of each - * other in segment-local space, the renderer treats them as a single - * L-junction and skews each end so the back walls meet at the inner - * corner while the front rims extend outward to a clean mitre. + * other in plan (ROOF-local X/Z — see `planDistSq`), the renderer + * treats them as a single L-junction and skews each end so the back + * walls meet at the inner corner while the front rims extend outward to + * a clean mitre. * * Why "back wall stays at the corner": the gutter mounts against the * fascia (gutter-local +X is the length, +Z is outward over the eave). @@ -19,26 +20,38 @@ import type { GutterNode } from '@pascal-app/core' * `(π − interior) / 2`. Aligned gutters (interior ≈ π) → mitre 0 → no * displacement, no cap suppression — they read as a straight run. * - * Same-segment only in v1: hip roofs have all four eaves on one - * segment, so this covers the headline use-case. Cross-segment corners - * (e.g. gable + hip on adjacent sub-roofs) need parent-frame transform - * work; deferred. + * Cross-segment: endpoints + length axes are lifted into the shared + * ROOF frame (each gutter's segment position + Y-rotation applied), so + * gutters on DIFFERENT roof segments (L-shaped plans, additions) mitre + * at the corner where their segments meet — not just gutters sharing a + * segment. Same-segment corners fall out of the same path (both gutters + * carry the same segment transform). The skew assumes a convex (outer) + * corner; a concave inner corner mitres approximately. */ export type GutterMitres = { - /** Mitre angle (radians) at the gutter's −X end; 0 = no mitre. */ + /** + * SIGNED mitre angle (radians) at the gutter's −X end; 0 = no mitre. + * Positive = CONVEX (outer) corner — the front rim EXTENDS past the + * end to reach the outer eave intersection. Negative = CONCAVE (inner) + * corner — the rim RETRACTS to the inner intersection. The geometry + * builder feeds the value straight through `Math.tan`, so the sign + * flips the skew direction; `=== 0` still means "no mitre / keep cap". + */ left: number - /** Mitre angle (radians) at the gutter's +X end; 0 = no mitre. */ + /** Signed mitre angle (radians) at the gutter's +X end; see `left`. */ right: number } export const NO_MITRES: GutterMitres = { left: 0, right: 0 } -// 5 cm slack: the user is dragging endpoints by eye; eave snap is on a -// 5 cm grid, so anything closer than that reads as "they meant to -// meet" rather than "they're near each other". -const CORNER_EPSILON = 0.05 -const CORNER_EPSILON_SQ = CORNER_EPSILON * CORNER_EPSILON +// Match the length-snap's 10 cm catch radius (`length-snap.ts`): any two +// endpoints close enough for the corner snap to bind are close enough to +// read as "they meant to meet". The corner snap pulls them to the exact +// eave intersection (≈ 0 cm), so this is mostly slack for eyeballed / +// move-tool corners that never went through the length handle. +const CORNER_EPSILON = 0.1 +export const CORNER_EPSILON_SQ = CORNER_EPSILON * CORNER_EPSILON // Mitres beyond this are unphysical (an acute outer corner past 30° // interior angle isn't a building corner, it's a CSG artefact). Capping @@ -46,10 +59,22 @@ const CORNER_EPSILON_SQ = CORNER_EPSILON * CORNER_EPSILON // the rest of the trough. const MAX_MITRE = (75 * Math.PI) / 180 -type Endpoint = { +// Cross product below this magnitude counts as parallel length-axes — a +// straight collinear run, no corner. Matches `length-snap.ts`. +const AXIS_PARALLEL_EPSILON = 1e-3 + +/** A sibling gutter paired with the segment it sits on, for the frame lift. */ +export type GutterWithSegment = { + gutter: GutterNode + segment: Pick +} + +export type Endpoint = { pos: readonly [number, number, number] /** Length-axis direction in segment frame, pointing from this end toward the other end. */ awayDir: readonly [number, number] + /** Outward normal (gutter-local +Z, "away from the building") in this frame. */ + outDir: readonly [number, number] } function gutterEndpoints(g: GutterNode): { plus: Endpoint; minus: Endpoint } { @@ -59,6 +84,9 @@ function gutterEndpoints(g: GutterNode): { plus: Endpoint; minus: Endpoint } { // rotation-y convention: local (1, 0, 0) → (cos r, 0, −sin r). const dirX = Math.cos(r) const dirZ = -Math.sin(r) + // Gutter-local +Z (outward) rotated by `r`: (0, 0, 1) → (sin r, 0, cos r). + const outX = Math.sin(r) + const outZ = Math.cos(r) const half = g.length / 2 return { plus: { @@ -66,19 +94,71 @@ function gutterEndpoints(g: GutterNode): { plus: Endpoint; minus: Endpoint } { // From the +X endpoint, the rest of the gutter extends back // toward the −X end — so "away from this end" is −dir. awayDir: [-dirX, -dirZ], + outDir: [outX, outZ], }, minus: { pos: [px - dirX * half, py, pz - dirZ * half], awayDir: [dirX, dirZ], + outDir: [outX, outZ], }, } } -function distSq(a: readonly [number, number, number], b: readonly [number, number, number]): number { +/** + * Gutter endpoints lifted from segment-local into the shared ROOF frame + * by applying the host segment's position + Y-rotation. Two gutters on + * different segments can then be compared in one frame. THREE's + * rotation-y convention: a point/dir (x, z) rotates to + * (x·cos + z·sin, −x·sin + z·cos). + */ +export function gutterEndpointsInFrame( + g: GutterNode, + segment: Pick, +): { plus: Endpoint; minus: Endpoint } { + const local = gutterEndpoints(g) + const sx = segment.position?.[0] ?? 0 + const sz = segment.position?.[2] ?? 0 + const sr = segment.rotation ?? 0 + const c = Math.cos(sr) + const s = Math.sin(sr) + const liftPos = (p: readonly [number, number, number]): [number, number, number] => [ + sx + (p[0] * c + p[2] * s), + p[1], + sz + (-p[0] * s + p[2] * c), + ] + const liftDir = (d: readonly [number, number]): [number, number] => [ + d[0] * c + d[1] * s, + -d[0] * s + d[1] * c, + ] + return { + plus: { + pos: liftPos(local.plus.pos), + awayDir: liftDir(local.plus.awayDir), + outDir: liftDir(local.plus.outDir), + }, + minus: { + pos: liftPos(local.minus.pos), + awayDir: liftDir(local.minus.awayDir), + outDir: liftDir(local.minus.outDir), + }, + } +} + +// Plan-space (X/Z) distance only — deliberately ignores Y. Gutters are +// pinned to the eave line so they're coplanar in eave-Y, AND the +// renderer draws them at the LIVE `computeEaveY(segment)`, not the +// stored `position[1]` (which goes stale if the segment's wallHeight / +// pitch changed after placement). Folding Y in would reject a real +// corner whenever two gutters' stored Ys drifted apart even though they +// visibly meet. The length-snap that feeds this also works purely in +// plan, so the match must too. +export function planDistSq( + a: readonly [number, number, number], + b: readonly [number, number, number], +): number { const dx = a[0] - b[0] - const dy = a[1] - b[1] const dz = a[2] - b[2] - return dx * dx + dy * dy + dz * dz + return dx * dx + dz * dz } function mitreBetween(a: Endpoint, b: Endpoint): number { @@ -91,41 +171,91 @@ function mitreBetween(a: Endpoint, b: Endpoint): number { const mitre = (Math.PI - interior) / 2 // Aligned-or-nearly so → straight run, no mitre needed. if (mitre < 1e-3) return 0 - return Math.min(mitre, MAX_MITRE) + + // Convex vs concave. `a.outDir` is THIS gutter's outward normal; `b`'s + // body runs from the corner along `b.awayDir`. On a CONVEX (outer) + // corner the neighbour's body sits on the INWARD side of our rim, so + // `b.awayDir · a.outDir < 0` (it heads away from our outward face) — + // the rim must EXTEND (positive). On a CONCAVE (inner) corner the + // neighbour's body sits on our OUTWARD side (`· > 0`) and the rim must + // RETRACT (negative) to the inner intersection. + const concave = b.awayDir[0] * a.outDir[0] + b.awayDir[1] * a.outDir[1] > 0 + const signed = concave ? -mitre : mitre + return Math.max(-MAX_MITRE, Math.min(MAX_MITRE, signed)) +} + +// Intersection (in plan X/Z) of two infinite length-axis lines, each +// given as a point + run direction. Returns null when the runs are +// parallel (no single crossing — a straight collinear run, not a +// corner). Mirrors the `length-snap.ts` corner solve. +function axisIntersectionXZ( + aPos: readonly [number, number, number], + aDir: readonly [number, number], + bPos: readonly [number, number, number], + bDir: readonly [number, number], +): readonly [number, number, number] | null { + const cross = aDir[0] * bDir[1] - aDir[1] * bDir[0] + if (Math.abs(cross) < AXIS_PARALLEL_EPSILON) return null + const dx = bPos[0] - aPos[0] + const dz = bPos[2] - aPos[2] + const t = (dx * bDir[1] - dz * bDir[0]) / cross + return [aPos[0] + t * aDir[0], 0, aPos[2] + t * aDir[1]] } /** * Compute mitres for `subject` against every other gutter under the - * same parent. Walks each endpoint pair (subject's +X / −X × sibling's - * +X / −X), keeps the first match within `CORNER_EPSILON`. Two corners - * on the same end (rare — would require three gutters meeting at one - * point) keep the first match found; order is the caller's siblings - * order, so the result is deterministic. + * same parent. + * + * A corner is the INTERSECTION of the two gutters' length-axis lines — + * not the proximity of their endpoints. This is what makes inner + * (concave) corners work: there the two eave drip-lines meet out in the + * notch, a full overhang away from where either gutter naturally ends, + * so an endpoint-to-endpoint test never fired. Keying off the axis + * crossing treats convex and concave identically. The length-snap pulls + * both ends out to that shared point, so by the time we mitre the + * subject's end AND the sibling's end both sit on the intersection — we + * require both to be within `CORNER_EPSILON` of it, which also rejects + * runs that merely cross in the middle (a T, not an L). + * + * First match per end wins; siblings order is the caller's, so the + * result is deterministic. */ -export function computeGutterMitres(subject: GutterNode, siblings: readonly GutterNode[]): GutterMitres { +export function computeGutterMitres( + subject: GutterNode, + subjectSegment: Pick, + siblings: readonly GutterWithSegment[], +): GutterMitres { if (siblings.length === 0) return NO_MITRES - const subj = gutterEndpoints(subject) + const subj = gutterEndpointsInFrame(subject, subjectSegment) let leftMitre = 0 let rightMitre = 0 for (const sib of siblings) { - if (sib.id === subject.id) continue - const other = gutterEndpoints(sib) - - if (leftMitre === 0) { - if (distSq(subj.minus.pos, other.plus.pos) <= CORNER_EPSILON_SQ) { - leftMitre = mitreBetween(subj.minus, other.plus) - } else if (distSq(subj.minus.pos, other.minus.pos) <= CORNER_EPSILON_SQ) { - leftMitre = mitreBetween(subj.minus, other.minus) - } + if (sib.gutter.id === subject.id) continue + const other = gutterEndpointsInFrame(sib.gutter, sib.segment) + + // `minus.awayDir` runs from the −X end toward +X, i.e. along the + // length — so it's a valid direction for either gutter's axis line. + const corner = axisIntersectionXZ( + subj.minus.pos, + subj.minus.awayDir, + other.minus.pos, + other.minus.awayDir, + ) + if (!corner) continue + + // The sibling end that sits on the corner is the one we mitre against. + const otherPlusAtCorner = planDistSq(other.plus.pos, corner) <= CORNER_EPSILON_SQ + const otherMinusAtCorner = planDistSq(other.minus.pos, corner) <= CORNER_EPSILON_SQ + if (!otherPlusAtCorner && !otherMinusAtCorner) continue + const otherEnd = otherPlusAtCorner ? other.plus : other.minus + + if (leftMitre === 0 && planDistSq(subj.minus.pos, corner) <= CORNER_EPSILON_SQ) { + leftMitre = mitreBetween(subj.minus, otherEnd) } - if (rightMitre === 0) { - if (distSq(subj.plus.pos, other.plus.pos) <= CORNER_EPSILON_SQ) { - rightMitre = mitreBetween(subj.plus, other.plus) - } else if (distSq(subj.plus.pos, other.minus.pos) <= CORNER_EPSILON_SQ) { - rightMitre = mitreBetween(subj.plus, other.minus) - } + if (rightMitre === 0 && planDistSq(subj.plus.pos, corner) <= CORNER_EPSILON_SQ) { + rightMitre = mitreBetween(subj.plus, otherEnd) } if (leftMitre !== 0 && rightMitre !== 0) break } diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index 8b82d8d7b..5818f9a2e 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 { buildGutterFloorplan } from './floorplan' import { snapLengthToCorner } from './length-snap' import { gutterParametrics } from './parametrics' import { GutterNode } from './schema' @@ -39,11 +40,11 @@ function getRimZ(n: GutterNodeType): number { // yaw-aware projection as the box-vent / ridge-vent / chimney width // handles. // -// Corner snap: when the dragged endpoint enters the catch radius of a -// sibling gutter's endpoint, `snapLengthToCorner` overrides the -// raw newLength so the endpoint lands EXACTLY on the sibling — the -// corner-mitre detector's 5 cm match window then fires reliably -// without needing pixel-perfect dragging. +// Corner snap: when the dragged endpoint nears the geometric corner it +// would form with another gutter (the crossing of their length axes), +// `snapLengthToCorner` overrides the raw newLength so the endpoint lands +// EXACTLY on that corner — the corner-mitre detector then fires reliably +// without pixel-perfect dragging. Only this gutter's length changes. function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor { const sign = side === 'right' ? 1 : -1 return { @@ -69,17 +70,9 @@ function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor = { parametrics: gutterParametrics, handles: gutterHandles, + floorplan: buildGutterFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/gutter/downspouts-panel.tsx b/packages/nodes/src/gutter/downspouts-panel.tsx index 18ed0656f..06cbafd02 100644 --- a/packages/nodes/src/gutter/downspouts-panel.tsx +++ b/packages/nodes/src/gutter/downspouts-panel.tsx @@ -5,32 +5,55 @@ import { DownspoutNode, type DownspoutNode as DownspoutNodeType, type GutterNode, + generateId, type RoofSegmentNode, useScene, } from '@pascal-app/core' -import { - ActionButton, - ActionGroup, - PanelSection, - triggerSFX, -} from '@pascal-app/editor' +import { ActionButton, ActionGroup, PanelSection, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useShallow } from 'zustand/react/shallow' import { computeEaveY } from './eave-snap' -import { resolveGutterOutletPlacement } from './outlet-lookup' +import { resolveGutterOutletById } from './outlet-lookup' + +const DEFAULT_OUTLET_DIAMETER = 0.07 + +/** + * Pick an along-length offset for a new outlet that doesn't land on an + * existing one: the first goes near the right end, the rest drop into + * the midpoint of the widest gap (including the gaps to each end). So + * "Add Downspout" repeatedly spreads outlets along the run instead of + * stacking them. + */ +function nextOutletOffset(gutter: GutterNode): number { + const len = Math.max(0.05, gutter.length) + const margin = 0.12 + const lo = -len / 2 + margin + const hi = len / 2 - margin + if (hi <= lo) return 0 + + const existing = (gutter.outlets ?? []) + .map((o) => Math.max(lo, Math.min(hi, o.offset ?? 0))) + .sort((a, b) => a - b) + if (existing.length === 0) return hi + + const bounds = [lo, ...existing, hi] + let bestMid = (lo + hi) / 2 + let bestGap = -1 + for (let i = 0; i < bounds.length - 1; i++) { + const gap = bounds[i + 1]! - bounds[i]! + if (gap > bestGap) { + bestGap = gap + bestMid = (bounds[i]! + bounds[i + 1]!) / 2 + } + } + return bestMid +} /** - * Downspouts subsection rendered at the bottom of the gutter - * inspector. Same shape as the roof inspector's gutter / vent lists: - * one button per existing downspout that selects it (showing its own - * inspector), and an "Add Downspout" button below that immediately - * creates a new one parented to the same roof segment. - * - * Each Add click adds ANOTHER downspout to the list — multiple - * downspouts per gutter is allowed (real residential gutters - * sometimes split a long run between two downspouts). The Add button - * stays disabled when the gutter has no outlet, since the downspout - * has nowhere to attach. + * Downspouts subsection at the bottom of the gutter inspector. Lists the + * downspouts attached to this gutter (one per outlet); "Add Downspout" + * drills a fresh outlet at a spread-out position and drops a downspout + * on it, and each row's ✕ removes both the downspout and its outlet. */ export default function DownspoutsPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined @@ -55,69 +78,84 @@ export default function DownspoutsPanel() { if (!gutter || gutter.type !== 'gutter') return null - const outletEnabled = (gutter.outletSide ?? 'none') !== 'none' - const handleSelectDownspout = (id: AnyNodeId) => { setSelection({ selectedIds: [id] }) } const handleAddDownspout = () => { - if (!outletEnabled) return const segmentId = gutter.roofSegmentId as AnyNodeId | undefined if (!segmentId) return const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined if (!segment) return - const outlet = resolveGutterOutletPlacement(gutter) - if (!outlet) return - // Default length: drop from outlet (at eaveY − size in segment - // frame) down to segment Y = 0. Matches the placement tool's - // default so click-to-add and click-on-gutter land the same drop. - const dropLength = Math.max(0.1, computeEaveY(segment) + outlet.y) + const outletId = generateId('outlet') + const outlets = [ + ...(gutter.outlets ?? []), + { id: outletId, offset: nextOutletOffset(gutter), diameter: DEFAULT_OUTLET_DIAMETER }, + ] + const state = useScene.getState() + state.updateNode(gutter.id as AnyNodeId, { outlets }) + state.dirtyNodes.add(gutter.id as AnyNodeId) + + const outlet = resolveGutterOutletById({ ...gutter, outlets }, outletId) + const dropLength = Math.max(0.1, computeEaveY(segment) + (outlet?.y ?? -gutter.size)) const downspout = DownspoutNode.parse({ - ...{ - name: 'Downspout', - gutterId: gutter.id, - length: dropLength, - diameter: outlet.bore * 2, - }, + name: 'Downspout', + gutterId: gutter.id, + outletId, + length: dropLength, + diameter: (outlet?.bore ?? DEFAULT_OUTLET_DIAMETER / 2) * 2, }) - const state = useScene.getState() state.createNode(downspout, segmentId) state.dirtyNodes.add(segmentId) setSelection({ selectedIds: [downspout.id] }) triggerSFX('sfx:item-place') } + const handleRemove = (downspout: DownspoutNodeType) => { + const state = useScene.getState() + // Drop the outlet this downspout drained so its hole closes up. + if (downspout.outletId) { + state.updateNode(gutter.id as AnyNodeId, { + outlets: (gutter.outlets ?? []).filter((o) => o.id !== downspout.outletId), + }) + state.dirtyNodes.add(gutter.id as AnyNodeId) + } + state.deleteNode(downspout.id as AnyNodeId) + if (gutter.roofSegmentId) state.dirtyNodes.add(gutter.roofSegmentId as AnyNodeId) + setSelection({ selectedIds: [gutter.id as AnyNodeId] }) + } + return (
{downspouts.map((d, i) => ( - + + +
))} - + - {!outletEnabled && ( -

- Turn the Outlet on to add a downspout. -

- )}
) } - diff --git a/packages/nodes/src/gutter/eave-align.ts b/packages/nodes/src/gutter/eave-align.ts new file mode 100644 index 000000000..7ca20cbd7 --- /dev/null +++ b/packages/nodes/src/gutter/eave-align.ts @@ -0,0 +1,97 @@ +import type { GutterNode, RoofSegmentNode } from '@pascal-app/core' +import { CORNER_EPSILON_SQ, gutterEndpointsInFrame, planDistSq } from './corner-mitre' +import { computeEaveY } from './eave-snap' + +/** + * Shared eave-Y for a connected run of gutters. + * + * Each gutter normally derives its mount height independently from its + * own host segment via `computeEaveY` (wallHeight − overhang·tan(pitch) + * + tuck, or wallHeight on a flat deck). Two segments that read as the + * same height in the inspector can still land at different eave Ys when + * their pitch / overhang / roofType differ — so gutters that meet at a + * corner inherit two heights and the run visibly steps at the joint. + * + * This walks the connected component of gutters that meet at corners + * (same plan-space endpoint test the mitre detector uses — Y is + * deliberately ignored, so the grouping survives the very height drift + * we're correcting) and returns ONE height for the whole run: the + * HIGHEST member eave. Aligning up means no gutter ever sinks into a + * roof surface; a lower roof gets a small fascia gap, which reads + * cleaner than a gutter clipping through its slope. + * + * Deterministic + symmetric: every gutter in the run computes the same + * component and the same max, so they all converge on the identical Y + * without any shared coordinator or store write. Isolated gutters (no + * corner neighbour) get their own eave Y unchanged. + * + * Pure: no React, no scene access, no store mutation. + */ + +/** A sibling gutter paired with its FULL host segment (needs the eave-Y inputs). */ +export type GutterWithSegment = { + gutter: GutterNode + segment: RoofSegmentNode +} + +function guttersMeet( + a: GutterNode, + aSeg: RoofSegmentNode, + b: GutterNode, + bSeg: RoofSegmentNode, +): boolean { + const ea = gutterEndpointsInFrame(a, aSeg) + const eb = gutterEndpointsInFrame(b, bSeg) + return ( + planDistSq(ea.minus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ || + planDistSq(ea.minus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ || + planDistSq(ea.plus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ || + planDistSq(ea.plus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ + ) +} + +// Each gutter mounts at `segment.position[1] + computeEaveY(segment)` in +// the roof frame (the renderer adds the segment-local eave Y under the +// segment's group). Segments can sit at different Y offsets, so the run +// has to be compared — and the answer returned — in the SHARED roof +// frame, not raw segment-local eave Ys. +function worldEaveY(segment: RoofSegmentNode): number { + return (segment.position?.[1] ?? 0) + computeEaveY(segment) +} + +export function computeSharedEaveY( + subject: GutterNode, + subjectSegment: RoofSegmentNode, + siblings: readonly GutterWithSegment[], +): number { + const subjectBaseY = subjectSegment.position?.[1] ?? 0 + if (siblings.length === 0) return computeEaveY(subjectSegment) + + // Index 0 is the subject; the rest are candidates. BFS the corner + // graph from the subject and keep the tallest eave in its component. + const nodes: GutterWithSegment[] = [{ gutter: subject, segment: subjectSegment }, ...siblings] + const visited = new Array(nodes.length).fill(false) + visited[0] = true + const queue = [0] + let maxWorldEaveY = worldEaveY(subjectSegment) + + while (queue.length > 0) { + const i = queue.pop()! + const cur = nodes[i]! + for (let j = 0; j < nodes.length; j++) { + if (visited[j]) continue + const other = nodes[j]! + if (guttersMeet(cur.gutter, cur.segment, other.gutter, other.segment)) { + visited[j] = true + queue.push(j) + const eaveY = worldEaveY(other.segment) + if (eaveY > maxWorldEaveY) maxWorldEaveY = eaveY + } + } + } + + // Back to the SUBJECT's segment-local frame — the renderer applies the + // returned value under the subject segment's group, which already adds + // `subjectBaseY`. + return maxWorldEaveY - subjectBaseY +} diff --git a/packages/nodes/src/gutter/eave-snap.ts b/packages/nodes/src/gutter/eave-snap.ts index 8a275117b..a0daabbb1 100644 --- a/packages/nodes/src/gutter/eave-snap.ts +++ b/packages/nodes/src/gutter/eave-snap.ts @@ -51,11 +51,19 @@ export type EaveSnap = { * `resolveEaveSnap` uses the same formula at placement. */ export function computeEaveY( - segment: Pick, + segment: Pick, ): number { + const wallHeight = segment.wallHeight ?? 0 + // Flat roofs have no slope drop and no slope-surface-vs-deck-top + // offset — the deck top IS the eave line. EAVE_TUCK_UP is a + // correction that lifts a SLOPED gutter from the slope-surface up to + // the deck-top line; applying it to a flat deck floats the gutter + // above the roof and leaves a visible gap between the edge and the + // gutter. So mount flat gutters right at the deck top. + if ((segment.roofType ?? 'gable') === 'flat') return wallHeight const overhang = segment.overhang ?? 0 const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180 - return (segment.wallHeight ?? 0) - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP + return wallHeight - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP } /** @@ -66,17 +74,19 @@ 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`: 4-way. The slope the user is standing on is - * determined by whichever of `|lx|/halfW` or `|lz|/halfD` is + * - `hip` / `flat` / `dutch`: 4-way. 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 - * picks +/-. + * picks +/-. Dutch is a hip base with a gablet on top, so its + * lower run has all four eaves at the eave line — it gets the same + * 4-way snap as hip. * - * - `gable` / `gambrel` / `mansard` / `dutch`: 2-way `±Z`. Mansard - * and dutch have real 4-side eaves in plan, but the segment-hit - * formula approximates them as 2-slope (depth-only), so we stay - * consistent here — the user can re-place the gutter manually on - * a side eave if mansard/dutch becomes important. + * - `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. */ function pickEaveSide( roofType: RoofType, @@ -87,7 +97,7 @@ function pickEaveSide( ): EaveSide { if (roofType === 'shed') return '+Z' - if (roofType === 'hip' || roofType === 'flat') { + if (roofType === 'hip' || roofType === 'flat' || roofType === 'dutch') { 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/gutter/floorplan.ts b/packages/nodes/src/gutter/floorplan.ts new file mode 100644 index 000000000..e292a906c --- /dev/null +++ b/packages/nodes/src/gutter/floorplan.ts @@ -0,0 +1,300 @@ +import type { + AnyNodeId, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + GutterNode, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' +import { computeGutterMitres, type GutterWithSegment } from './corner-mitre' +import { EAVE_TUCK_INWARD } from './eave-snap' +import { outletDims, outletShapeForProfile, profileFloorMidZ } from './profile-geometry' + +/** + * Floor-plan builder for a gutter. A gutter is a thin rain-water channel + * hosted on a roof segment, running along an eave. In plan it reads as a + * narrow metal strip just outboard of the eave line: the trough (two long + * edges), end caps where the trough is closed, hanger straps across the + * run, and a downspout outlet symbol where one is fitted. + * + * The coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → gutter). The gutter's `position` is + * segment-local and the segment's is roof-local, so we compose + * world = roof.pos + R(roof) · (seg.pos + R(seg) · gutter.pos) + * using the floor-plan's negated-rotation convention (see + * `buildRoofSegmentFloorplan`). Gutter-local +X is the run (along the + * eave); +Z hangs outward, away from the building. + */ +export function buildGutterFloorplan( + node: GutterNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + // Compose roof → segment → gutter in plan coords. Each rotation is + // negated so SVG's y-down CW matches Three.js' top-down CCW — the same + // convention the roof-segment builder establishes. + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const gutterRot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(gutterRot) + const sin = Math.sin(gutterRot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const halfLen = Math.max(node.length, 0.1) / 2 + const width = Math.max(node.size, 0.05) // outward extent of the trough + + // The gutter's stored position is the eave drip edge — `halfD + overhang` + // out from the segment centre (resolveEaveSnap). The roof floor plan + // draws only the structural footprint (no overhang), so to seat the + // trough on the drawn roof edge we shift it inward by that overhang + // excess. Local Z then reads: `backZ` = back/fascia edge (on the roof + // edge), `rimZ` = outward lip. + // + // A plain inward shift would break mitred corners — the two meeting + // gutters lie on perpendicular eaves, so each shifts a different way and + // their ends part (they cross). We compensate in the corner math below + // by also retracting each MITRED end along the run by the same inset: + // the net move at a corner is then identical for both gutters, so they + // still meet — now at the structural corner. Exact for right-angle + // (hip / rectangular) corners. + const eaveInset = Math.max(0, (segment.overhang ?? 0) - EAVE_TUCK_INWARD) + const backZ = -eaveInset + const rimZ = width - eaveInset + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Gutters are a metal accessory — read them in a cooler grey than the + // roof's black structural ink, accent on select, light blue on hover. + const baseInk = '#475569' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#cbd5e1' + const fillOpacity = showSelectedChrome ? 0.5 : 0.4 + const lineWidth = showSelectedChrome ? 0.03 : 0.022 + + // Corner mitres — when a sibling gutter meets this one at a roof + // corner, that shared end is open: the back wall stays at the corner + // while the rim extends outward to the mitre, and the cap is + // suppressed. Mirrors the 3D builder's rule (`endCap* && mitre === 0`). + // `left` = −X end, `right` = +X end. + // + // Cross-segment: collect every other gutter on the roof paired with its + // host segment (mirrors the renderer's `mitreNodes` walk) so gutters + // meeting where two segments join still mitre, not just same-segment. + const mitreSiblings: GutterWithSegment[] = [] + for (const segId of roof.children ?? []) { + const sib = ctx.resolve(segId as AnyNodeId) as RoofSegmentNode | undefined + if (!sib || sib.type !== 'roof-segment') continue + for (const gid of sib.children ?? []) { + const g = ctx.resolve(gid as AnyNodeId) as GutterNode | undefined + if (g && g.type === 'gutter' && g.id !== node.id) { + mitreSiblings.push({ gutter: g, segment: sib }) + } + } + } + const mitres = computeGutterMitres(node, segment, mitreSiblings) + const capRight = node.endCapRight && mitres.right === 0 + const capLeft = node.endCapLeft && mitres.left === 0 + + // Footprint corners. Back edge sits on the roof edge (lz = backZ); the + // rim hangs outward (lz = rimZ). A mitred end (a) retracts along the run + // by `eaveInset` so its back corner lands on the structural corner, then + // (b) skews its rim corner by the SIGNED mitre (`Math.tan` carries the + // sign: convex extends, concave retracts) so adjacent gutters' rims meet + // at the corner. Non-mitred ends (mitre === 0) keep the full run. + const backRightX = halfLen - (mitres.right !== 0 ? eaveInset : 0) + const backLeftX = -(halfLen - (mitres.left !== 0 ? eaveInset : 0)) + const backLeft = toPlan(backLeftX, backZ) + const backRight = toPlan(backRightX, backZ) + const rimRight = toPlan(backRightX + width * Math.tan(mitres.right), rimZ) + const rimLeft = toPlan(backLeftX - width * Math.tan(mitres.left), rimZ) + + const children: FloorplanGeometry[] = [ + // Transparent hit-target across the whole channel so the thin strip + // is easy to click-select in plan. + { + kind: 'polygon', + points: [backLeft, backRight, rimRight, rimLeft], + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + // Channel fill. + { + kind: 'polygon', + points: [backLeft, backRight, rimRight, rimLeft], + fill, + fillOpacity, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'none', + }, + // Long edges — the back (fascia) line and the front lip. These two + // parallel lines are the gutter's signature read in plan. + { + kind: 'line', + x1: backLeft[0], + y1: backLeft[1], + x2: backRight[0], + y2: backRight[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }, + { + kind: 'line', + x1: rimLeft[0], + y1: rimLeft[1], + x2: rimRight[0], + y2: rimRight[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }, + ] + + // End edges. A capped end gets a square closure line; a mitred end gets + // the slanted mitre seam (so the joint shows in plan); an open, uncapped + // end gets nothing. `cap*` already excludes mitred ends, so an end never + // draws both a cap and a seam. + if (capLeft || mitres.left !== 0) { + children.push({ + kind: 'line', + x1: backLeft[0], + y1: backLeft[1], + x2: rimLeft[0], + y2: rimLeft[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + } + if (capRight || mitres.right !== 0) { + children.push({ + kind: 'line', + x1: backRight[0], + y1: backRight[1], + x2: rimRight[0], + y2: rimRight[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + } + + // Hanger straps — short ticks across the trough at the real hanger + // spacing, so the strip reads as a gutter rather than a thin wall. + if (node.hangerStyle === 'strap') { + const spacing = Math.max(node.hangerSpacing, 0.2) + const inset = width * 0.15 + // Span the (possibly retracted) run between the two end corners. + const mid = (backLeftX + backRightX) / 2 + const runLen = backRightX - backLeftX + const count = Math.max(1, Math.floor(runLen / spacing)) + const span = count * spacing + for (let i = 0; i < count; i++) { + const x = mid - span / 2 + spacing / 2 + i * spacing + if (x <= backLeftX + 0.02 || x >= backRightX - 0.02) continue + const a = toPlan(x, backZ + inset) + const b = toPlan(x, rimZ - inset) + children.push({ + kind: 'line', + x1: a[0], + y1: a[1], + x2: b[0], + y2: b[1], + stroke, + strokeWidth: lineWidth * 0.7, + strokeLinecap: 'round', + opacity: 0.6, + pointerEvents: 'none', + }) + } + } + + // Downspout outlets — a leader symbol per outlet (round for half-round + // gutters, rectangular for k-style / box, following the profile) at each + // outlet's along-run position. The strongest "this is a gutter" cue in a + // roof plan. `offset` is signed from the gutter centre along +X. + // `outlets` is a recent schema addition — gutters persisted before it + // existed deserialize without the field (the schema default only fills + // on a fresh parse), so guard against `undefined`. + const outlets = node.outlets ?? [] + if (outlets.length > 0) { + const floorZ = Math.min( + Math.max(profileFloorMidZ(node.profile, width), width * 0.25), + width * 0.85, + ) + const outletZ = backZ + floorZ + const shape = outletShapeForProfile(node.profile) + const outStroke = showSelectedChrome && palette ? palette.selectedStroke : '#1e293b' + for (const outlet of outlets) { + // Clamp inside the run so the symbol never rides out onto a cap line + // (the 3D builder clamps the drill the same way). + const outletX = Math.max(-halfLen * 0.9, Math.min(halfLen * 0.9, outlet.offset)) + const dims = outletDims(shape, outlet.diameter) + if (shape === 'round') { + const center = toPlan(outletX, outletZ) + children.push({ + kind: 'circle', + cx: center[0], + cy: center[1], + r: Math.max(dims.halfX, 0.03), + fill: 'none', + stroke: outStroke, + strokeWidth: lineWidth, + pointerEvents: 'none', + }) + } else { + children.push({ + kind: 'polygon', + points: [ + toPlan(outletX - dims.halfX, outletZ - dims.halfZ), + toPlan(outletX + dims.halfX, outletZ - dims.halfZ), + toPlan(outletX + dims.halfX, outletZ + dims.halfZ), + toPlan(outletX - dims.halfX, outletZ + dims.halfZ), + ], + fill: 'none', + stroke: outStroke, + strokeWidth: lineWidth, + pointerEvents: 'none', + }) + } + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/gutter/geometry.ts b/packages/nodes/src/gutter/geometry.ts index 92647e4e8..f030cbbcf 100644 --- a/packages/nodes/src/gutter/geometry.ts +++ b/packages/nodes/src/gutter/geometry.ts @@ -9,6 +9,15 @@ import { import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { type GutterMitres, NO_MITRES } from './corner-mitre' +import { + OUTLET_STUB_LENGTH, + OUTLET_WALL_THICKNESS, + type OutletDims, + type OutletShape, + outletDims, + outletShapeForProfile, + profileFloorMidZ, +} from './profile-geometry' /** * Pure builder for the gutter mesh. The gutter is a hollow trough that @@ -87,7 +96,7 @@ export function buildGutterGeometry( // Z_cs = depth maps to mesh--X (left end). const pieces: THREE.BufferGeometry[] = [] - const channel = new THREE.ExtrudeGeometry(channelCross, { + let channel: THREE.BufferGeometry = new THREE.ExtrudeGeometry(channelCross, { depth: channelLen, bevelEnabled: false, curveSegments: 16, @@ -98,16 +107,30 @@ export function buildGutterGeometry( // Z_cs = length (0 at right end of mesh, `channelLen` at left end). // After rotateY(-π/2): mesh-X = -Z_cs, mesh-Z = X_cs. // - // OUTER-corner mitre rule: the back wall (X_cs = 0) stays at the - // original end (mesh-X = ±len/2); the front rim (X_cs = +outward) - // extends further along the gutter's length so it can reach the - // outer eave intersection of the L. In mesh coords: + // Corner mitre rule: the back wall (X_cs = 0) stays at the original + // end (mesh-X = ±len/2); the front rim (X_cs = +outward) is SKEWED + // along the gutter's length to reach the eave intersection of the L. + // The mitre is SIGNED — positive (convex / outer corner) extends the + // rim past the end; negative (concave / inner corner) retracts it to + // the inner intersection. `Math.tan` carries the sign, so the same + // formula handles both. In mesh coords: // right end: Δmesh-X = +mesh-Z · tan(mitreRight) // left end: Δmesh-X = −mesh-Z · tan(mitreLeft) // Mapped back to source coords (Δmesh-X = −ΔZ_cs, mesh-Z = X_cs): // right end (Z_cs = 0): new Z_cs = −X_cs · tan(mitreRight) // left end (Z_cs = channelLen): new Z_cs = channelLen + X_cs · tan(mitreLeft) - if (mitres.right > 0 || mitres.left > 0) { + if (mitres.right !== 0 || mitres.left !== 0) { + // Drop the cross-section CAP at each mitred end first. Both gutters + // at a corner skew their end faces onto the SAME mitre plane, so the + // thin metal-section caps land coincident and z-fight into the stray + // seam lines visible at the joint (and the ink-edge pass outlines + // them). With the caps gone the open troughs flow into each other and + // the side walls carry the corner — a clean seam. Done before the + // skew so the end planes are still the clean z=0 / z=channelLen. + const stripped = stripEndCaps(channel, channelLen, mitres.right !== 0, mitres.left !== 0) + channel.dispose() + channel = stripped + const tanRight = Math.tan(mitres.right) const tanLeft = Math.tan(mitres.left) const eps = 1e-5 @@ -115,9 +138,9 @@ export function buildGutterGeometry( for (let i = 0; i < pos.count; i++) { const x = pos.getX(i) const z = pos.getZ(i) - if (mitres.right > 0 && Math.abs(z) < eps) { + if (mitres.right !== 0 && Math.abs(z) < eps) { pos.setZ(i, -x * tanRight) - } else if (mitres.left > 0 && Math.abs(z - channelLen) < eps) { + } else if (mitres.left !== 0 && Math.abs(z - channelLen) < eps) { pos.setZ(i, channelLen + x * tanLeft) } } @@ -164,20 +187,25 @@ export function buildGutterGeometry( // top ~3mm above the rim — so it reads as a clip resting on the // gutter rather than buried in it. if ((node.hangerStyle ?? 'strap') !== 'none') { - for (const hanger of buildHangers(node, len, size, capLeftLen, capRightLen)) { + for (const hanger of buildHangers(node, len, size, capLeftLen, capRightLen, mitres)) { pieces.push(hanger) } } - // Downspout outlet: short cylindrical drop tube hanging off the - // gutter floor at the chosen end. The stub itself is solid at the - // outer (bore + wall) radius; the bore is then drilled through both - // the floor and the stub via CSG, so the result is a real hole in + // Downspout outlets: short drop-tube collars hanging off the gutter + // floor at each outlet's position. Each collar is solid at the outer + // (bore + wall) cross-section; the bore is then drilled through both + // the floor and the collar via CSG, so the result is a real hole in // the trough floor and a hollow tube descending from it. CSG only - // runs when the outlet is enabled — `'none'` keeps the fast merge - // path. - const outletStub = buildOutletStub(node, len, size, capLeftLen, capRightLen) - if (outletStub) pieces.push(outletStub) + // runs when at least one outlet exists — an empty `outlets` keeps the + // fast merge path. + const placements = resolveOutletPlacements(node, len, size, capLeftLen, capRightLen) + for (const p of placements) { + pieces.push(buildOutletStub(p, size)) + // Flared funnel lip at the floor so the drop reads as a real drop + // outlet rather than a bare tube; the same bore drill cuts it open. + pieces.push(buildOutletFunnel(p, size)) + } const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!) // Free the intermediate pieces when merge returned a new geometry. @@ -186,27 +214,77 @@ export function buildGutterGeometry( } merged.computeVertexNormals() - // CSG drill — punches the bore through the merged geometry. Runs - // last so the floor + stub are already in one mesh; the drill cuts - // both at once. - if (outletStub) { - const drill = buildOutletDrill(node, len, size, capLeftLen, capRightLen) - if (drill) { - const mainBrush = new Brush(merged) - prepareBrushForCSG(mainBrush) + // CSG drill — punches each bore through the merged geometry. Runs + // last so the floor + collars are already in one mesh; each drill + // cuts both at once, subtracted sequentially. + if (placements.length > 0) { + let workingBrush = new Brush(merged) + prepareBrushForCSG(workingBrush) + for (const p of placements) { + const drill = buildOutletDrill(p, size) const drillBrush = new Brush(drill) prepareBrushForCSG(drillBrush) - const cut = csgEvaluator.evaluate(mainBrush, drillBrush, SUBTRACTION) - const cutGeometry = csgGeometry(cut) - merged.dispose() + const next = csgEvaluator.evaluate(workingBrush, drillBrush, SUBTRACTION) as Brush + // Free the previous step's intermediate result (but not `merged`, + // which is disposed once below). + if (workingBrush.geometry !== merged) workingBrush.geometry.dispose() + workingBrush = next drill.dispose() - return cutGeometry } + const cutGeometry = csgGeometry(workingBrush) + merged.dispose() + return cutGeometry } return merged } +// Remove the extrude's cross-section CAP triangles at a mitred end so two +// gutters meeting at a corner don't leave coincident, z-fighting end +// faces (the seam lines at the joint). A cap triangle is one whose three +// vertices ALL lie on an end plane — z≈0 (right end) or z≈channelLen +// (left end). Wall triangles always span the two planes, so this cleanly +// separates caps from walls without depending on ExtrudeGeometry's +// internal vertex order. Input is non-indexed (ExtrudeGeometry), so every +// three positions is one triangle; we copy through the survivors and let +// the caller recompute normals. +function stripEndCaps( + geo: THREE.BufferGeometry, + channelLen: number, + removeRight: boolean, + removeLeft: boolean, +): THREE.BufferGeometry { + const src = geo.index ? geo.toNonIndexed() : geo + const pos = src.attributes.position! + const uv = src.attributes.uv + const eps = 1e-4 + const keepPos: number[] = [] + const keepUv: number[] | null = uv ? [] : null + const triCount = Math.floor(pos.count / 3) + for (let t = 0; t < triCount; t++) { + const a = t * 3 + const za = pos.getZ(a) + const zb = pos.getZ(a + 1) + const zc = pos.getZ(a + 2) + const allRight = Math.abs(za) < eps && Math.abs(zb) < eps && Math.abs(zc) < eps + const allLeft = + Math.abs(za - channelLen) < eps && + Math.abs(zb - channelLen) < eps && + Math.abs(zc - channelLen) < eps + if ((removeRight && allRight) || (removeLeft && allLeft)) continue + for (let k = 0; k < 3; k++) { + const idx = a + k + keepPos.push(pos.getX(idx), pos.getY(idx), pos.getZ(idx)) + if (keepUv && uv) keepUv.push(uv.getX(idx), uv.getY(idx)) + } + } + if (src !== geo) src.dispose() + const out = new THREE.BufferGeometry() + out.setAttribute('position', new THREE.Float32BufferAttribute(keepPos, 3)) + if (keepUv) out.setAttribute('uv', new THREE.Float32BufferAttribute(keepUv, 2)) + return out +} + // Each cross-section below is authored as a single closed polygon that // traces the U-channel's MATERIAL — outer wall down → bottom → outer // wall up → rim across → inner wall down → inner bottom → inner wall @@ -379,15 +457,26 @@ function buildHangers( size: number, capLeftLen: number, capRightLen: number, + mitres: GutterMitres, ): THREE.BufferGeometry[] { const spacing = Math.max(0.2, node.hangerSpacing ?? 0.6) const profile = node.profile ?? 'k-style' const rimWidth = profileRimWidth(profile, size) const strapDepth = rimWidth + HANGER_OVERHANG * 2 - // Inset by margin AND any cap so straps don't punch into the cap slab. - const leftBound = -len / 2 + capLeftLen + HANGER_END_MARGIN - const rightBound = len / 2 - capRightLen - HANGER_END_MARGIN + // At an INNER (concave) corner the two gutters' troughs fold into the + // same notch, so a strap sitting near that end overlaps the neighbour + // gutter's end strap — the two perpendicular straps read as an X across + // the corner. Pull the run's bound back by a full strap depth at a + // concave end so the nearest strap clears the joint. OUTER (convex) + // corners diverge outward and never overlap, so they keep the tight + // margin (`mitre > 0` and non-mitred ends → no extra inset). + const concaveInset = (mitre: number) => (mitre < 0 ? strapDepth : 0) + + // Inset by margin AND any cap so straps don't punch into the cap slab, + // plus the concave-corner clearance above. + const leftBound = -len / 2 + capLeftLen + HANGER_END_MARGIN + concaveInset(mitres.left) + const rightBound = len / 2 - capRightLen - HANGER_END_MARGIN - concaveInset(mitres.right) const usable = rightBound - leftBound if (usable <= 0) return [] @@ -403,14 +492,14 @@ function buildHangers( // BoxGeometry is indexed; the channel + cap ExtrudeGeometries are // not. `mergeGeometries` rejects mixed-index sets, so flatten the // box to non-indexed before pushing. - const bar = new THREE.BoxGeometry(HANGER_BAR_LEN, HANGER_BAR_THICKNESS, strapDepth).toNonIndexed() + const bar = new THREE.BoxGeometry( + HANGER_BAR_LEN, + HANGER_BAR_THICKNESS, + strapDepth, + ).toNonIndexed() // 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, - ) + bar.translate(x, HANGER_BAR_THICKNESS / 2 + 0.001, rimWidth / 2) pieces.push(bar) } return pieces @@ -418,113 +507,128 @@ function buildHangers( // ─── Outlet ──────────────────────────────────────────────────────── -// Stub length — 6 cm reads as "drop tube" without poking conspicuously -// far below the eave; the downspout pipe (eventually a child node) -// will visually continue downward from the stub's open end. -const OUTLET_STUB_LENGTH = 0.06 // Radial subdivisions — 24 reads as smooth at typical outlet // diameters; lower and the 3″ tube starts looking faceted from below. const OUTLET_RADIAL_SEGMENTS = 24 -// Wall thickness of the drop tube — 3 mm matches typical residential -// gauge. After CSG the stub becomes a tube with outer radius = -// bore + wall and inner radius = bore. -const OUTLET_WALL_THICKNESS = 0.003 // Drill overshoot past the floor and past the stub bottom — keeps the // CSG cut planes from coinciding with merged-geometry surfaces // (coplanar cuts produce degenerate output in three-bvh-csg). const OUTLET_DRILL_OVERSHOOT = 0.01 - -/** Z (outward) coordinate of the trough floor's midpoint per profile. */ -function profileFloorMidZ(profile: GutterNode['profile'], size: number): number { - // k-style bottom is `wBot = 0.8 · size` wide; box bottom is `size` - // wide; half-round's lowest point sits at the centre of the - // semicircle at Z = r = size. - if (profile === 'half-round') return size - if (profile === 'box') return size / 2 - return size * 0.4 +// Funnel lip at the drop outlet — flares this much wider than the collar +// over this height, just below the trough floor. The bore drill cuts it +// open along with the collar. +const OUTLET_FLARE_SCALE = 1.6 +const OUTLET_FLARE_HEIGHT = 0.02 + +type OutletPlacement = { + x: number + z: number + shape: OutletShape + /** Bore cross-section (the drilled hole). */ + inner: OutletDims + /** Collar cross-section (bore + wall) — the solid stub body. */ + outer: OutletDims } /** - * Common (X, Z) placement + radius math used by both the solid stub - * and the CSG drill. Returns null when the outlet is disabled or - * doesn't fit between the caps. + * One placement per `node.outlets` entry that fits between the caps. The + * shape follows the gutter profile (`outletShapeForProfile`): round + * leader on half-round, rectangular on k-style / box. Each outlet's + * `offset` (signed from center) is clamped inside the trough-floor span + * using the OUTER along-length half-extent so the collar can't poke into + * a cap. Outlets that can't fit at all are dropped. */ -function resolveOutletPlacement( +function resolveOutletPlacements( node: GutterNode, len: number, size: number, capLeftLen: number, capRightLen: number, -): { x: number; z: number; bore: number; outer: number } | null { - const side = node.outletSide ?? 'none' - if (side === 'none') return null - - const bore = Math.max(0.01, (node.outletDiameter ?? 0.07) / 2) - const outer = bore + OUTLET_WALL_THICKNESS - const inset = Math.max(outer, node.outletInset ?? 0.15) - - // Clamp inside the trough-floor span — use the OUTER radius so the - // tube body itself can't poke into a cap. - const minX = -len / 2 + capLeftLen + outer - const maxX = len / 2 - capRightLen - outer - if (maxX <= minX) return null - let x = side === 'left' ? -len / 2 + capLeftLen + inset : len / 2 - capRightLen - inset - x = Math.max(minX, Math.min(maxX, x)) +): OutletPlacement[] { + const outlets = node.outlets ?? [] + if (outlets.length === 0) return [] + + const shape = outletShapeForProfile(node.profile) + const z = profileFloorMidZ(node.profile ?? 'k-style', size) + const placements: OutletPlacement[] = [] + for (const outlet of outlets) { + const inner = outletDims(shape, outlet.diameter ?? 0.07) + const outer: OutletDims = { + shape, + halfX: inner.halfX + OUTLET_WALL_THICKNESS, + halfZ: inner.halfZ + OUTLET_WALL_THICKNESS, + } + const minX = -len / 2 + capLeftLen + outer.halfX + const maxX = len / 2 - capRightLen - outer.halfX + if (maxX <= minX) continue + const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0)) + placements.push({ x, z, shape, inner, outer }) + } + return placements +} - const profile = node.profile ?? 'k-style' - const z = profileFloorMidZ(profile, size) - return { x, z, bore, outer } +/** 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( + dims.halfX, + dims.halfX, + h, + OUTLET_RADIAL_SEGMENTS, + ).toNonIndexed() + } + return new THREE.BoxGeometry(2 * dims.halfX, h, 2 * dims.halfZ).toNonIndexed() } -function buildOutletStub( - node: GutterNode, - len: number, - size: number, - capLeftLen: number, - capRightLen: number, -): THREE.BufferGeometry | null { - const p = resolveOutletPlacement(node, len, size, capLeftLen, capRightLen) - if (!p) return null - - // Solid cylinder at OUTER radius; the CSG drill below will hollow - // out the bore and leave a tube wall. Top of the stub sits flush - // with the gutter floor (Y = −size). CylinderGeometry is indexed; - // flatten to match the ExtrudeGeometries in `pieces`. - const tube = new THREE.CylinderGeometry( - p.outer, - p.outer, - OUTLET_STUB_LENGTH, - OUTLET_RADIAL_SEGMENTS, - ).toNonIndexed() - tube.translate(p.x, -size - OUTLET_STUB_LENGTH / 2, p.z) - return tube +/** + * Solid collar at the OUTER cross-section; the CSG drill hollows out the + * bore and leaves a tube wall. Top sits flush with the gutter floor + * (Y = −size). Flattened to match the ExtrudeGeometries. + */ +function buildOutletStub(p: OutletPlacement, size: number): THREE.BufferGeometry { + const stub = outletSolid(p.outer, OUTLET_STUB_LENGTH) + stub.translate(p.x, -size - OUTLET_STUB_LENGTH / 2, p.z) + return stub } -function buildOutletDrill( - node: GutterNode, - len: number, - size: number, - capLeftLen: number, - capRightLen: number, -): THREE.BufferGeometry | null { - const p = resolveOutletPlacement(node, len, size, capLeftLen, capRightLen) - if (!p) return null +/** + * Flared funnel lip just below the trough floor — wide at the floor, + * tapering to the collar below (round → a cone; rect → a stepped wider + * lip). Sits in the bore drill's span so it gets cut open too. + */ +function buildOutletFunnel(p: OutletPlacement, size: number): THREE.BufferGeometry { + const centerY = -size - OUTLET_FLARE_HEIGHT / 2 + let funnel: THREE.BufferGeometry + if (p.shape === 'round') { + funnel = new THREE.CylinderGeometry( + p.outer.halfX * OUTLET_FLARE_SCALE, + p.outer.halfX, + OUTLET_FLARE_HEIGHT, + OUTLET_RADIAL_SEGMENTS, + ).toNonIndexed() + } else { + funnel = new THREE.BoxGeometry( + 2 * p.outer.halfX * OUTLET_FLARE_SCALE, + OUTLET_FLARE_HEIGHT, + 2 * p.outer.halfZ * OUTLET_FLARE_SCALE, + ).toNonIndexed() + } + funnel.translate(p.x, centerY, p.z) + return funnel +} - // Drill spans from slightly above the trough floor to slightly - // below the stub's bottom — the overshoots keep CSG cut planes - // from sitting coplanar with merged-geometry faces. +/** + * Bore drill — spans from slightly above the trough floor to slightly + * below the collar's bottom; the overshoots keep CSG cut planes from + * sitting coplanar with merged-geometry faces. + */ +function buildOutletDrill(p: OutletPlacement, size: number): THREE.BufferGeometry { const top = -size + OUTLET_DRILL_OVERSHOOT const bottom = -size - OUTLET_STUB_LENGTH - OUTLET_DRILL_OVERSHOOT const height = top - bottom const centerY = (top + bottom) / 2 - const drill = new THREE.CylinderGeometry( - p.bore, - p.bore, - height, - OUTLET_RADIAL_SEGMENTS, - ) - // CSG doesn't care about indexed vs non-indexed for the input brushes. + const drill = outletSolid(p.inner, height) drill.translate(p.x, centerY, p.z) return drill } diff --git a/packages/nodes/src/gutter/length-snap.ts b/packages/nodes/src/gutter/length-snap.ts index ddb5795f9..95177a8d7 100644 --- a/packages/nodes/src/gutter/length-snap.ts +++ b/packages/nodes/src/gutter/length-snap.ts @@ -2,28 +2,31 @@ import type { AnyNodeId, GutterNode, RoofSegmentNode, SceneApi } from '@pascal-a /** * Length-handle snap. When the user drags a gutter's ±X length handle - * and the proposed endpoint lands within `SNAP_RADIUS` of a sibling - * gutter's endpoint (in segment-local space), pull BOTH gutters' - * lengths so they meet at the geometric corner — the intersection of - * their length-axis lines. The corner-mitre detector's 5 cm match - * window then fires reliably without asking the user to land a - * pixel-perfect drag. + * and the proposed endpoint lands within `SNAP_RADIUS` of the geometric + * CORNER it would form with another gutter — the intersection of their + * two length-axis lines — the dragged gutter's length is pulled so its + * end lands exactly on that corner. The corner-mitre detector's match + * window then fires reliably without a pixel-perfect drag. * - * Both-sides adjustment is the point: snapping A to wherever B - * currently sits glues the L to B's possibly-imprecise position, but - * the intersection point IS the eave corner (each eave-snapped gutter - * runs along its eave line, so the axis crossing is the eave corner - * in plan). Adjusting B too makes the L "click into" the eave - * intersection no matter which side the user dragged. + * ONLY the dragged gutter moves. The corner is the axis crossing — a + * fixed point in plan, independent of where the other gutter currently + * ends — so each gutter snaps onto the SAME shared corner on its own as + * it's dragged in, and the L meets there without ever reaching over to + * reposition a gutter the user placed deliberately. (An earlier version + * adjusted both gutters at once; that yanked an already-placed gutter + * whenever its corner-mate was dragged, which is the bug this avoids.) * - * Stable-state guard: when the snap is sustained across drag ticks the - * sibling's length / position won't visibly change from one tick to - * the next. We skip the sibling update in that case so we're not - * thrashing the store ~60×/sec for no visual change. + * Cross-segment: the search covers every gutter under the SAME ROOF + * (not just the dragged gutter's segment-mates), and all the geometry + * runs in the shared ROOF frame — each gutter's endpoints are lifted + * out of its own segment-local frame by that segment's position + + * Y-rotation. So an L-shaped plan whose two eaves live on different + * roof segments snaps + mitres at the corner where the segments meet, + * exactly like a same-segment hip corner (which is the degenerate case + * where both gutters share one segment transform). * - * Pure: no React, no THREE. Reads through SceneApi; writes are - * returned as a sibling adjustment for the caller to apply (so the - * caller decides when to commit). + * Pure: no React, no THREE. Reads through SceneApi; returns the snapped + * length for the caller to apply. */ // 10 cm catch radius — wide enough that the user doesn't need pixel- @@ -36,24 +39,20 @@ const SNAP_RADIUS_SQ = SNAP_RADIUS * SNAP_RADIUS // fall back to snapping A onto B's current endpoint without modifying B. const AXIS_PARALLEL_EPSILON = 1e-3 -// Sibling update threshold: ~1 mm of change. Below this we treat the -// snap as "already at target" and skip the store write. -const STABLE_EPSILON = 1e-3 +// How far a corner-mate's own nearer endpoint may sit from the corner +// and still bind the dragged gutter to it. The corner is the crossing +// of the two axis LINES, which can lie well beyond where a gutter ends — +// at an inner/concave corner the mate's end is a full eave overhang +// short of the crossing, so the bound has to clear a generous overhang. +// It also stops a far perpendicular gutter, whose infinite axis happens +// to cross near the dragged end, from binding by coincidence: a real +// corner-mate is within reach, an unrelated run is metres away. +const CORNER_MATE_REACH = 1.5 +const CORNER_MATE_REACH_SQ = CORNER_MATE_REACH * CORNER_MATE_REACH export type GutterLengthSnap = { /** Length to apply to the dragged gutter. */ length: number - /** - * When set, the named sibling needs to be re-lengthened so its - * matching endpoint meets the dragged gutter at the same corner. - * Caller writes via `sceneApi.update`; history is paused during the - * drag so it batches with the main commit at pointer-up. - */ - sibling?: { - id: AnyNodeId - length: number - position: [number, number, number] - } } /** @@ -63,7 +62,7 @@ export type GutterLengthSnap = { * @param anchorX,anchorZ the held-fixed endpoint (opposite of `sign`) * @param armX,armZ gutter +X direction in segment frame (cos r, −sin r) * @param minLength floor — typically the descriptor's `min` value - * @param sceneApi scene access for sibling lookup + * @param sceneApi scene access for corner-mate lookup */ export function snapLengthToCorner( initial: GutterNode, @@ -81,120 +80,138 @@ export function snapLengthToCorner( const seg = sceneApi.get(segmentId) if (!seg) return { length: proposedLength } - const proposedEndX = anchorX + sign * proposedLength * armX - const proposedEndZ = anchorZ + sign * proposedLength * armZ + // Everything runs in the ROOF frame so gutters on different segments + // can meet. The dragged gutter's anchor/arm come in segment-local + // (the caller computes them from `initial`); lift them into the roof + // frame with the dragged segment's transform. + const selfTf = segmentTransform(seg) + const anchorR = applyTf(selfTf, anchorX, anchorZ) + const armR = applyTfDir(selfTf, armX, armZ) + const aAnchorX = anchorR.x + const aAnchorZ = anchorR.z + const aArmX = armR.x + const aArmZ = armR.z + + const proposedEndX = aAnchorX + sign * proposedLength * aArmX + const proposedEndZ = aAnchorZ + sign * proposedLength * aArmZ + + // Candidate gutters: every gutter under the SAME ROOF, each carrying + // its own segment's roof-frame transform. + type Cand = { gutter: GutterNode; tf: SegmentTransform } + const candidates: Cand[] = [] + const roofId = seg.parentId as AnyNodeId | undefined + const roof = roofId ? sceneApi.get(roofId) : undefined + const roofChildren = (roof as { children?: readonly string[] } | undefined)?.children + for (const sid of roofChildren ?? []) { + const s = sceneApi.get(sid as AnyNodeId) + if (!s || s.type !== 'roof-segment') continue + const tf = segmentTransform(s) + for (const gid of s.children ?? []) { + const g = sceneApi.get(gid as AnyNodeId) + if (g?.type === 'gutter' && g.id !== initial.id) { + candidates.push({ gutter: g as GutterNode, tf }) + } + } + } - // Pass 1: pick the sibling whose endpoint is closest to the proposed - // end, and remember WHICH end (+X or −X) we matched on. - let bestSib: GutterNode | null = null - let bestSibEndIsPlus = false - let bestSibEndX = 0 - let bestSibEndZ = 0 + // Find the corner-mate whose CORNER with the dragged gutter lands + // closest to the proposed dragged endpoint. The corner is the + // intersection of the two length-axis LINES (roof frame) — NOT the + // proximity of the two endpoints. That distinction is what unlocks + // inner/concave corners: there the two eave drip-lines meet out in the + // notch, a full overhang away from where either gutter naturally ends, + // so the old endpoint-to-endpoint catch never fired. Keying off the + // axis crossing treats convex and concave identically. Parallel axes + // (a straight collinear run) have no crossing, so there we fall back to + // the mate's nearer endpoint (flush join). Only the dragged gutter's + // own length is snapped to the corner — the mate is never moved. + let bestTargetX = 0 + let bestTargetZ = 0 let bestDistSq = SNAP_RADIUS_SQ - - for (const sibId of seg.children ?? []) { - const sib = sceneApi.get(sibId as AnyNodeId) - if (!sib || sib.type !== 'gutter' || sib.id === initial.id) continue - const sibG = sib as GutterNode - const sibRot = sibG.rotation ?? 0 - const sibArmX = Math.cos(sibRot) - const sibArmZ = -Math.sin(sibRot) - const sibHalf = sibG.length / 2 - const plusX = sibG.position[0] + sibArmX * sibHalf - const plusZ = sibG.position[2] + sibArmZ * sibHalf - const minusX = sibG.position[0] - sibArmX * sibHalf - const minusZ = sibG.position[2] - sibArmZ * sibHalf - - const dPlusSq = (plusX - proposedEndX) ** 2 + (plusZ - proposedEndZ) ** 2 - if (dPlusSq < bestDistSq) { - bestDistSq = dPlusSq - bestSib = sibG - bestSibEndIsPlus = true - bestSibEndX = plusX - bestSibEndZ = plusZ + let found = false + + for (const { gutter: mateG, tf } of candidates) { + const mateRot = mateG.rotation ?? 0 + const arm = applyTfDir(tf, Math.cos(mateRot), -Math.sin(mateRot)) + const mateHalf = mateG.length / 2 + const center = applyTf(tf, mateG.position[0], mateG.position[2]) + const plusX = center.x + arm.x * mateHalf + const plusZ = center.z + arm.z * mateHalf + const minusX = center.x - arm.x * mateHalf + const minusZ = center.z - arm.z * mateHalf + + // Corner target T: axis intersection when the runs cross, else the + // mate's endpoint nearest the dragged end (collinear extension). + const crossDirs = aArmX * arm.z - aArmZ * arm.x + let targetX: number + let targetZ: number + if (Math.abs(crossDirs) < AXIS_PARALLEL_EPSILON) { + const dPlus = (plusX - proposedEndX) ** 2 + (plusZ - proposedEndZ) ** 2 + const dMinus = (minusX - proposedEndX) ** 2 + (minusZ - proposedEndZ) ** 2 + if (dPlus <= dMinus) { + targetX = plusX + targetZ = plusZ + } else { + targetX = minusX + targetZ = minusZ + } + } else { + const dx = center.x - aAnchorX + const dz = center.z - aAnchorZ + const t = (dx * arm.z - dz * arm.x) / crossDirs + targetX = aAnchorX + t * aArmX + targetZ = aAnchorZ + t * aArmZ } - const dMinusSq = (minusX - proposedEndX) ** 2 + (minusZ - proposedEndZ) ** 2 - if (dMinusSq < bestDistSq) { - bestDistSq = dMinusSq - bestSib = sibG - bestSibEndIsPlus = false - bestSibEndX = minusX - bestSibEndZ = minusZ - } - } - if (!bestSib) return { length: proposedLength } - - // Pass 2: find the geometric corner — intersection of A's axis and - // B's axis. For two eave-snapped gutters this IS the eave corner. - // Fall back to B's endpoint if the axes are parallel (rare; means - // both gutters point the same way and there's no real corner). - const sibRot = bestSib.rotation ?? 0 - const sibArmX = Math.cos(sibRot) - const sibArmZ = -Math.sin(sibRot) - const sibPosX = bestSib.position[0] - const sibPosZ = bestSib.position[2] - - const crossDirs = armX * sibArmZ - armZ * sibArmX - let targetX: number - let targetZ: number - - if (Math.abs(crossDirs) < AXIS_PARALLEL_EPSILON) { - targetX = bestSibEndX - targetZ = bestSibEndZ - } else { - // (sibPos − anchor) = t·d_A − s·d_B → t = cross(sibPos − anchor, d_B) / cross(d_A, d_B) - const dx = sibPosX - anchorX - const dz = sibPosZ - anchorZ - const t = (dx * sibArmZ - dz * sibArmX) / crossDirs - targetX = anchorX + t * armX - targetZ = anchorZ + t * armZ - - // Reject far-off intersections — if the axes cross out beyond the - // snap radius (e.g. user is mid-drag and only briefly clipped the - // sibling endpoint), don't yank the gutter across the roof. - const distSqFromProposed = - (targetX - proposedEndX) ** 2 + (targetZ - proposedEndZ) ** 2 - if (distSqFromProposed > SNAP_RADIUS_SQ) { - targetX = bestSibEndX - targetZ = bestSibEndZ + // Reject a mate whose own ends are nowhere near the crossing — its + // infinite axis lines up by coincidence, it's not a real corner-mate. + const dPlusT = (plusX - targetX) ** 2 + (plusZ - targetZ) ** 2 + const dMinusT = (minusX - targetX) ** 2 + (minusZ - targetZ) ** 2 + if (Math.min(dPlusT, dMinusT) > CORNER_MATE_REACH_SQ) continue + + const score = (targetX - proposedEndX) ** 2 + (targetZ - proposedEndZ) ** 2 + if (score < bestDistSq) { + bestDistSq = score + bestTargetX = targetX + bestTargetZ = targetZ + found = true } } - // Snap A: project (target − anchor) onto A's axis direction. `sign` - // flips so the projection produces a positive length when the target - // sits on the dragged side of the anchor. - const projectedA = sign * ((targetX - anchorX) * armX + (targetZ - anchorZ) * armZ) - const snappedLength = Math.max(minLength, projectedA) - - // Snap B: the END that matched moves to the target; the OPPOSITE end - // stays fixed (B's anchor). Same asymmetric-resize math as A's apply - // — anchor + (sign · newLen) · arm gives the moving end at the - // target; new center sits at the midpoint. - const sibHalf = bestSib.length / 2 - const sibAnchorSign = bestSibEndIsPlus ? -1 : 1 // opposite end stays put - const sibAnchorX = sibPosX + sibAnchorSign * sibArmX * sibHalf - const sibAnchorZ = sibPosZ + sibAnchorSign * sibArmZ * sibHalf - - const sibMovingSign = bestSibEndIsPlus ? 1 : -1 - const sibProjected = - sibMovingSign * ((targetX - sibAnchorX) * sibArmX + (targetZ - sibAnchorZ) * sibArmZ) - const sibNewLength = Math.max(minLength, sibProjected) - const sibNewCenterX = sibAnchorX + sibMovingSign * (sibNewLength / 2) * sibArmX - const sibNewCenterZ = sibAnchorZ + sibMovingSign * (sibNewLength / 2) * sibArmZ - - const lengthDelta = Math.abs(sibNewLength - bestSib.length) - const posDelta = Math.abs(sibNewCenterX - sibPosX) + Math.abs(sibNewCenterZ - sibPosZ) - if (lengthDelta < STABLE_EPSILON && posDelta < STABLE_EPSILON) { - return { length: snappedLength } - } + if (!found) return { length: proposedLength } + // Snap the dragged gutter's own end onto the corner: project + // (corner − anchor) onto its roof-frame axis. Length is frame-invariant + // (a scalar along the run), so the projection is the same in roof or + // segment frame — no need to map back. The mate is left untouched. + const projected = sign * ((bestTargetX - aAnchorX) * aArmX + (bestTargetZ - aAnchorZ) * aArmZ) + return { length: Math.max(minLength, projected) } +} + +// ─── Segment-frame ↔ roof-frame transform ──────────────────────────── +// +// A segment places its children at `seg.position` rotated by +// `seg.rotation` about +Y. THREE's rotation-y convention: a point +// (x, z) maps to (x·cos + z·sin, −x·sin + z·cos). These helpers lift a +// gutter's segment-local X/Z into the shared roof frame and back so two +// gutters on different segments can be compared in one frame. + +type SegmentTransform = { x: number; z: number; cos: number; sin: number } + +function segmentTransform(seg: Pick): SegmentTransform { + const r = seg.rotation ?? 0 return { - length: snappedLength, - sibling: { - id: bestSib.id as AnyNodeId, - length: sibNewLength, - position: [sibNewCenterX, bestSib.position[1], sibNewCenterZ], - }, + x: seg.position?.[0] ?? 0, + z: seg.position?.[2] ?? 0, + cos: Math.cos(r), + sin: Math.sin(r), } } + +function applyTf(tf: SegmentTransform, x: number, z: number): { x: number; z: number } { + return { x: tf.x + (x * tf.cos + z * tf.sin), z: tf.z + (-x * tf.sin + z * tf.cos) } +} + +function applyTfDir(tf: SegmentTransform, x: number, z: number): { x: number; z: number } { + return { x: x * tf.cos + z * tf.sin, z: -x * tf.sin + z * tf.cos } +} diff --git a/packages/nodes/src/gutter/outlet-lookup.ts b/packages/nodes/src/gutter/outlet-lookup.ts index 160d735c6..e2ea72437 100644 --- a/packages/nodes/src/gutter/outlet-lookup.ts +++ b/packages/nodes/src/gutter/outlet-lookup.ts @@ -1,9 +1,16 @@ -import type { GutterNode } from '@pascal-app/core' +import type { GutterNode, GutterOutlet } from '@pascal-app/core' +import { + OUTLET_WALL_THICKNESS, + type OutletShape, + outletDims, + outletShapeForProfile, + profileFloorMidZ, +} from './profile-geometry' /** - * Outlet position lookup — used by the downspout renderer to mount - * the pipe at the gutter's outlet without having to walk the gutter's - * geometry pipeline. + * Outlet position lookup — used by the downspout (renderer / tool / + * routing) to mount a pipe at one of the gutter's outlets without + * walking the gutter's geometry pipeline. * * Returns the outlet's center in GUTTER-MESH-LOCAL frame (i.e. after * the gutter's own `position` + `rotation` have already been applied @@ -11,15 +18,14 @@ import type { GutterNode } from '@pascal-app/core' * gutter's vertical extent (−size, the trough floor), Z is outward * (the profile-dependent floor midpoint). * - * Ignores mitres — when a gutter end is mitred its cap collapses, - * which shifts the outlet's clamp bound by `wallThickness` (≤ 6 mm - * at default settings). The downspout drift in that case is below - * what reads visually; the gutter's own CSG drill still cuts in the - * exact spot since it sees the full mitre context. + * The clamp mirrors the geometry's `resolveOutletPlacements` so the + * lookup and the drilled hole agree on X. Ignores mitres — when a + * gutter end is mitred its cap collapses, which shifts the clamp bound + * by ≤ 6 mm; the drift is below what reads visually, and the gutter's + * own CSG drill still cuts in the exact spot since it sees the full + * mitre context. */ -const OUTLET_WALL_THICKNESS = 0.003 - export type GutterOutletPlacement = { /** Gutter-mesh-local X — along the length axis, signed from center. */ x: number @@ -27,41 +33,75 @@ export type GutterOutletPlacement = { y: number /** Gutter-mesh-local Z — profile-dependent floor midpoint. */ z: number - /** Outlet bore radius (the open hole the downspout descends through). */ + /** Nominal bore radius (= halfX); `bore * 2` is the outlet diameter. */ bore: number + /** Outlet cross-section — round on half-round, rect on k-style / box. */ + shape: OutletShape + /** Bore half-extent along the gutter length (X) — the pipe nests just inside this. */ + innerHalfX: number + /** Bore half-extent outward (Z) — the pipe nests just inside this. */ + innerHalfZ: number } -function profileFloorMidZ(profile: GutterNode['profile'], size: number): number { - if (profile === 'half-round') return size - if (profile === 'box') return size / 2 - return size * 0.4 -} - -export function resolveGutterOutletPlacement(gutter: GutterNode): GutterOutletPlacement | null { - const side = gutter.outletSide ?? 'none' - if (side === 'none') return null - - const len = Math.max(0.05, gutter.length) - const size = Math.max(0.04, gutter.size) - const t = Math.min(Math.max(0.001, gutter.thickness), size * 0.4) - const bore = Math.max(0.01, (gutter.outletDiameter ?? 0.07) / 2) - const outer = bore + OUTLET_WALL_THICKNESS - const inset = Math.max(outer, gutter.outletInset ?? 0.15) +function placeOutlet( + gutter: GutterNode, + outlet: GutterOutlet, + len: number, + size: number, + t: number, +): GutterOutletPlacement | null { + const shape = outletShapeForProfile(gutter.profile) + const inner = outletDims(shape, outlet.diameter ?? 0.07) + const outerHalfX = inner.halfX + OUTLET_WALL_THICKNESS // Default-cap reservation — no mitre awareness here; see header note. const capLeftLen = (gutter.endCapLeft ?? true) ? t : 0 const capRightLen = (gutter.endCapRight ?? true) ? t : 0 - const minX = -len / 2 + capLeftLen + outer - const maxX = len / 2 - capRightLen - outer + const minX = -len / 2 + capLeftLen + outerHalfX + const maxX = len / 2 - capRightLen - outerHalfX if (maxX <= minX) return null - let x = side === 'left' ? -len / 2 + capLeftLen + inset : len / 2 - capRightLen - inset - x = Math.max(minX, Math.min(maxX, x)) + const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0)) return { x, y: -size, z: profileFloorMidZ(gutter.profile ?? 'k-style', size), - bore, + bore: inner.halfX, + shape, + innerHalfX: inner.halfX, + innerHalfZ: inner.halfZ, + } +} + +function gutterDims(gutter: GutterNode): { len: number; size: number; t: number } { + const len = Math.max(0.05, gutter.length) + const size = Math.max(0.04, gutter.size) + const t = Math.min(Math.max(0.001, gutter.thickness), size * 0.4) + return { len, size, t } +} + +/** Placement of the gutter's outlet with the given id, or null if absent / doesn't fit. */ +export function resolveGutterOutletById( + gutter: GutterNode, + outletId: string | undefined, +): GutterOutletPlacement | null { + if (!outletId) return null + const outlet = (gutter.outlets ?? []).find((o) => o.id === outletId) + if (!outlet) return null + const { len, size, t } = gutterDims(gutter) + return placeOutlet(gutter, outlet, len, size, t) +} + +/** Placements for every fitting outlet, tagged with its id. */ +export function resolveGutterOutlets( + gutter: GutterNode, +): Array { + const { len, size, t } = gutterDims(gutter) + const out: Array = [] + for (const outlet of gutter.outlets ?? []) { + const p = placeOutlet(gutter, outlet, len, size, t) + if (p) out.push({ ...p, id: outlet.id }) } + return out } diff --git a/packages/nodes/src/gutter/parametrics.ts b/packages/nodes/src/gutter/parametrics.ts index 6b82f8d07..6ecc6cdeb 100644 --- a/packages/nodes/src/gutter/parametrics.ts +++ b/packages/nodes/src/gutter/parametrics.ts @@ -56,38 +56,10 @@ export const gutterParametrics: ParametricDescriptor = { }, ], }, - { - label: 'Outlet', - fields: [ - { - key: 'outletSide', - kind: 'enum', - options: ['none', 'left', 'right'], - display: 'segmented', - }, - { - key: 'outletInset', - kind: 'number', - unit: 'm', - min: 0.02, - max: 1.0, - step: 0.01, - visibleIf: (n) => (n.outletSide ?? 'none') !== 'none', - }, - { - key: 'outletDiameter', - kind: 'number', - unit: 'm', - min: 0.02, - max: 0.15, - step: 0.005, - visibleIf: (n) => (n.outletSide ?? 'none') !== 'none', - }, - ], - }, ], // Lazy-loaded section that lists every downspout attached to this - // gutter and offers an Add button at the bottom — same layout as - // the roof inspector's gutter / vent lists. + // gutter and offers an Add button at the bottom. Outlets are created + // and removed through this panel (and the downspout placement tool) — + // one outlet per downspout — so there's no separate outlet field group. trailingSection: () => import('./downspouts-panel'), } diff --git a/packages/nodes/src/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx index e5bd5e7d1..60496d504 100644 --- a/packages/nodes/src/gutter/preview.tsx +++ b/packages/nodes/src/gutter/preview.tsx @@ -32,9 +32,7 @@ const GutterPreview = ({ node }: { node: GutterNode }) => { node.endCapRight, node.hangerStyle, node.hangerSpacing, - node.outletSide, - node.outletInset, - node.outletDiameter, + JSON.stringify(node.outlets), ], ) diff --git a/packages/nodes/src/gutter/profile-geometry.ts b/packages/nodes/src/gutter/profile-geometry.ts new file mode 100644 index 000000000..f2a81b567 --- /dev/null +++ b/packages/nodes/src/gutter/profile-geometry.ts @@ -0,0 +1,73 @@ +import type { GutterNode } from '@pascal-app/core' + +/** + * Shared outlet/profile geometry constants + math used by the gutter + * mesh builder, the outlet lookup the downspout mounts against, and the + * downspout's own routing. Kept in one place so the trough-floor probe + * and the collar dimensions can't drift between the three call sites + * (they did before this file — `profileFloorMidZ` was copied verbatim + * into both `geometry.ts` and `outlet-lookup.ts`). + */ + +// Wall thickness of the drop-tube collar — 3 mm matches typical +// residential gauge. After the CSG drill the stub becomes a tube with +// outer radius = bore + wall and inner radius = bore. +export const OUTLET_WALL_THICKNESS = 0.003 + +// Collar length — how far the drop-tube stub hangs below the trough +// floor. 6 cm reads as "drop outlet" without poking conspicuously far +// below the eave; the downspout slip-fits up into this collar. +export const OUTLET_STUB_LENGTH = 0.06 + +/** + * Z (outward) coordinate of the trough floor's midpoint per profile — + * where a drop outlet drills through. k-style bottom is `wBot = 0.8 · + * size` wide so its midpoint sits at `0.4 · size`; box bottom is `size` + * wide → `size / 2`; half-round's lowest point is the centre of the + * semicircle at Z = r = size. + */ +export function profileFloorMidZ(profile: GutterNode['profile'], size: number): number { + if (profile === 'half-round') return size + if (profile === 'box') return size / 2 + return size * 0.4 +} + +// ─── Outlet cross-section shape ────────────────────────────────────── + +export type OutletShape = 'round' | 'rect' + +/** + * Which cross-section a gutter's drop outlet (and the downspout that + * plugs into it) takes. Half-round gutters use a round leader; the + * flat-bottomed profiles (k-style, box) use a rectangular one — matching + * real residential hardware (round leaders on half-round, 2×3 / 3×4 + * rectangular leaders on k-style / commercial box). + */ +export function outletShapeForProfile(profile: GutterNode['profile']): OutletShape { + return (profile ?? 'k-style') === 'half-round' ? 'round' : 'rect' +} + +// Outward (Z) depth of a rectangular outlet as a fraction of its +// along-length (X) width — a 2×3 leader is ~0.66; 0.7 reads cleanly and +// still fits inside the k-style trough floor. +export const RECT_OUTLET_DEPTH_RATIO = 0.7 + +export type OutletDims = { + shape: OutletShape + /** Half-extent along the gutter length (X). Round: = radius. */ + halfX: number + /** Half-extent outward (Z). Round: = radius. */ + halfZ: number +} + +/** + * Cross-section half-extents for a `nominalDiameter`-sized outlet of the + * given shape. Round → a circle of that diameter (halfX = halfZ = + * radius); rect → that diameter wide along the run, `RECT_OUTLET_DEPTH_ + * RATIO` as deep outward. + */ +export function outletDims(shape: OutletShape, nominalDiameter: number): OutletDims { + const half = Math.max(0.01, nominalDiameter / 2) + if (shape === 'round') return { shape, halfX: half, halfZ: half } + return { shape, halfX: half, halfZ: half * RECT_OUTLET_DEPTH_RATIO } +} diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index 6aa425c41..76827e528 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -19,7 +19,8 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' -import { computeGutterMitres } from './corner-mitre' +import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner-mitre' +import { computeSharedEaveY } from './eave-align' import { computeEaveY } from './eave-snap' import { buildGutterGeometry } from './geometry' @@ -57,9 +58,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { const overrides = useLiveNodeOverrides( (s) => s.get(storeNode.id as AnyNodeId) as Partial | undefined, ) - const node: GutterNode = overrides - ? ({ ...storeNode, ...overrides } as GutterNode) - : storeNode + const node: GutterNode = overrides ? ({ ...storeNode, ...overrides } as GutterNode) : storeNode const segment = useScene((state) => node.roofSegmentId @@ -84,36 +83,80 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { : segment : undefined - // Same-segment sibling gutters drive the corner-mitre detector. Pull - // them as a fresh array each store update; `useShallow` keeps the - // reference stable when the array contents haven't changed, so the - // mitres useMemo below only re-runs when a sibling actually moves. - const siblingGutters = useScene( + // Corner-mitre inputs: every other gutter under the SAME ROOF, plus + // the roof's segments (for the frame lift). A flat array of node refs + // keeps `useShallow` stable — it only re-renders when one of those + // nodes actually changes. Cross-segment so gutters on different + // segments mitre where their segments meet (the mitres useMemo pairs + // each gutter back to its segment). + const mitreNodes = useScene( useShallow((state) => { const segmentId = node.roofSegmentId as AnyNodeId | undefined - if (!segmentId) return [] as GutterNode[] - const seg = state.nodes[segmentId] as RoofSegmentNode | undefined - if (!seg) return [] - const out: GutterNode[] = [] - for (const id of seg.children ?? []) { - const n = state.nodes[id as AnyNodeId] - if (n?.type === 'gutter' && n.id !== storeNode.id) out.push(n as GutterNode) + const seg = segmentId ? (state.nodes[segmentId] as RoofSegmentNode | undefined) : undefined + const roofId = seg?.parentId as AnyNodeId | undefined + const roof = roofId + ? (state.nodes[roofId] as { children?: readonly string[] } | undefined) + : undefined + if (!roof) return [] as (GutterNode | RoofSegmentNode)[] + const out: (GutterNode | RoofSegmentNode)[] = [] + for (const sid of roof.children ?? []) { + const s = state.nodes[sid as AnyNodeId] + if (s?.type !== 'roof-segment') continue + out.push(s as RoofSegmentNode) + for (const gid of (s as RoofSegmentNode).children ?? []) { + const g = state.nodes[gid as AnyNodeId] + if (g?.type === 'gutter' && g.id !== storeNode.id) out.push(g as GutterNode) + } } return out }), ) - const mitres = useMemo( - () => computeGutterMitres(node, siblingGutters), - [ - node.position[0], - node.position[1], - node.position[2], - node.rotation, - node.length, - siblingGutters, - ], - ) + // Mitres AND the run's shared eave height come from the same sibling + // walk: both key off which gutters meet at corners. `siblings` carries + // the FULL host segment (the alignment needs wallHeight / overhang / + // pitch / roofType to derive each eave Y), which is a superset of what + // the mitre detector reads — so one list feeds both. + const { mitres, sharedEaveY } = useMemo(() => { + if (!effectiveSegment) return { mitres: NO_MITRES, sharedEaveY: undefined } + const segById = new Map() + for (const n of mitreNodes) { + if (n.type === 'roof-segment') segById.set(n.id, n as RoofSegmentNode) + } + const siblings: GutterWithSegment[] = [] + for (const n of mitreNodes) { + if (n.type !== 'gutter') continue + const g = n as GutterNode + const seg = g.roofSegmentId ? segById.get(g.roofSegmentId) : undefined + if (seg) siblings.push({ gutter: g, segment: seg }) + } + return { + mitres: computeGutterMitres(node, effectiveSegment, siblings), + // `siblings` is typed for the mitre detector (position/rotation), + // but the segment objects are the full RoofSegmentNodes from + // `mitreNodes`, so `computeSharedEaveY` gets the eave-Y inputs it + // needs at runtime. + sharedEaveY: computeSharedEaveY( + node, + effectiveSegment, + siblings as unknown as Parameters[2], + ), + } + }, [ + node.position[0], + node.position[1], + node.position[2], + node.rotation, + node.length, + effectiveSegment?.position?.[0], + effectiveSegment?.position?.[2], + effectiveSegment?.rotation, + effectiveSegment?.wallHeight, + effectiveSegment?.overhang, + effectiveSegment?.pitch, + effectiveSegment?.roofType, + mitreNodes, + ]) const geometry = useMemo( () => buildGutterGeometry(node, mitres), @@ -126,9 +169,9 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { node.endCapRight, node.hangerStyle, node.hangerSpacing, - node.outletSide, - node.outletInset, - node.outletDiameter, + // Value-compare the outlets array so the CSG drills only rebuild + // when an outlet's offset / diameter changes or one is added. + JSON.stringify(node.outlets), mitres.left, mitres.right, ], @@ -172,7 +215,10 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { // segment geometry at draw time rather than caching it at placement. const segPos = segment.position ?? [0, 0, 0] const segRotY = segment.rotation ?? 0 - const liveEaveY = computeEaveY(effectiveSegment) + // Prefer the connected run's shared height (aligns gutters meeting at + // a corner whose segments derive different eave Ys); fall back to this + // segment's own eave Y for an isolated gutter. + const liveEaveY = sharedEaveY ?? computeEaveY(effectiveSegment) return ( From 60faccc797ff7de53aacae470df4c9ba7df44905 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 1 Jun 2026 14:40:53 +0530 Subject: [PATCH 28/35] feat(floorplan): 2D footprints for roof nodes + rotate-handle angle wedge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add floorplan builders for box-vent, chimney, dormer, ridge-vent, roof, skylight, and solar-panel, and wire each into its definition, so roof-layer nodes finally draw a 2D footprint. Roof draws the merged silhouette of its child segments, and roof-segment now renders proper architectural roof linework (ridge / hip / break + shed downslope arrow) per shape instead of a bare rectangle. Add a `pivot` to the floorplan rotate affordance so the layer can sweep a live angle wedge + degree readout during a rotate drag — the 2D twin of the 3D rotate gizmo. Column / elevator / shelf / stair pass their pivot through. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/registry/types.ts | 8 + .../renderers/floorplan-registry-layer.tsx | 208 ++++++++++++- packages/nodes/src/box-vent/definition.ts | 2 + packages/nodes/src/box-vent/floorplan.ts | 195 ++++++++++++ packages/nodes/src/chimney/definition.ts | 2 + packages/nodes/src/chimney/floorplan.ts | 215 +++++++++++++ packages/nodes/src/column/floorplan.ts | 1 + packages/nodes/src/dormer/definition.ts | 2 + packages/nodes/src/dormer/floorplan.ts | 213 +++++++++++++ packages/nodes/src/elevator/floorplan.ts | 1 + packages/nodes/src/ridge-vent/definition.ts | 2 + packages/nodes/src/ridge-vent/floorplan.ts | 152 +++++++++ packages/nodes/src/roof-segment/floorplan.ts | 223 ++++++++++--- packages/nodes/src/roof/definition.ts | 7 +- packages/nodes/src/roof/floorplan.ts | 292 ++++++++++++++++++ packages/nodes/src/shelf/floorplan.ts | 1 + packages/nodes/src/skylight/definition.ts | 2 + packages/nodes/src/skylight/floorplan.ts | 154 +++++++++ packages/nodes/src/solar-panel/definition.ts | 2 + packages/nodes/src/solar-panel/floorplan.ts | 142 +++++++++ packages/nodes/src/stair/floorplan.ts | 1 + 21 files changed, 1775 insertions(+), 50 deletions(-) create mode 100644 packages/nodes/src/box-vent/floorplan.ts create mode 100644 packages/nodes/src/chimney/floorplan.ts create mode 100644 packages/nodes/src/dormer/floorplan.ts create mode 100644 packages/nodes/src/ridge-vent/floorplan.ts create mode 100644 packages/nodes/src/roof/floorplan.ts create mode 100644 packages/nodes/src/skylight/floorplan.ts create mode 100644 packages/nodes/src/solar-panel/floorplan.ts diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index a60410373..5f710702a 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -409,6 +409,14 @@ export type FloorplanGeometry = angle: number affordance: string payload?: unknown + /** + * Rotation pivot (plan coords) this handle turns the node around. + * When present, the floor-plan layer draws a live angle wedge + degree + * readout swept from grab to the current pointer bearing during the + * drag — the 2D twin of the 3D rotate gizmo's readout. Emitters that + * already compute the pivot to place the handle should pass it through. + */ + pivot?: FloorplanPoint } /** * Centered length / distance label. Renders as a small rounded diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 73aa326f5..432cf93a0 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -7,6 +7,7 @@ import { type FloorplanAffordanceSession, type FloorplanGeometry, type FloorplanPalette, + type FloorplanPoint, type GeometryContext, kindsWithFloorplanScope, nodeRegistry, @@ -85,6 +86,26 @@ type ActiveDrag = { session: FloorplanAffordanceSession snapshots: NodeSnapshot[] historyPaused: boolean + /** + * Set only for rotate-arrow drags (handles that carry a `pivot`). Drives + * the live angle wedge + degree readout — the 2D twin of the 3D rotate + * gizmo's readout. The bearing sweep is measured the same way every + * rotate affordance measures it: `atan2(pointer − pivot)`. + */ + rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number } +} + +/** + * Transient live-rotation readout state. Rebuilt each pointer-move while a + * rotate-arrow is dragged and cleared on release. World-plan coords. + */ +type RotationOverlayState = { + pivot: FloorplanPoint + startAngle: number + endAngle: number + radius: number + /** Swept magnitude in radians, for the degree chip. */ + sweep: number } function snapshotNode(node: AnyNode): NodeSnapshot { @@ -194,6 +215,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const dragRef = useRef(null) const [hoveredHandleId, setHoveredHandleId] = useState(null) const [activeDragId, setActiveDragId] = useState(null) + const [rotationOverlay, setRotationOverlay] = useState(null) const handleSelect = useCallback( (id: AnyNodeId, event: React.PointerEvent) => { @@ -437,6 +459,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { affordance: string, payload: unknown, event: ReactPointerEvent, + // Present only for rotate-arrow handles — the pivot the node turns + // around, used to drive the live angle wedge + degree readout. + rotationPivot?: FloorplanPoint, ) => { if (event.button !== 0) return if (movingNode) return @@ -470,12 +495,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { pauseSceneHistory(useScene) + // Rotation readout setup. The wedge radius tracks the grab distance + // from the pivot (≈ the handle's orbit), nudged inward so the swept + // fill reads as the handle swinging round rather than overlapping it, + // and floored so a tight footprint still shows a legible wedge. + let rotation: ActiveDrag['rotation'] + if (rotationPivot) { + const dx = initialPlanPoint[0] - rotationPivot[0] + const dz = initialPlanPoint[1] - rotationPivot[1] + rotation = { + pivot: rotationPivot, + initialAngle: Math.atan2(dz, dx), + radius: Math.max(Math.hypot(dx, dz) * 0.72, 0.25), + } + } + dragRef.current = { pointerId: event.pointerId, handleId, session, snapshots, historyPaused: true, + rotation, } setActiveDragId(handleId) setSelection({ selectedIds: [nodeId] }) @@ -501,6 +542,30 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { metaKey: event.metaKey, }, }) + + // Live rotation readout. Sweep from the bearing at grab to the + // current pointer bearing around the pivot — the same measurement + // every rotate affordance applies — and surface it as a wedge + + // degree chip. Suppressed below ~0.5° so a fresh grab doesn't flash + // a zero-width sliver. + const rot = drag.rotation + if (rot) { + const current = Math.atan2(planPoint[1] - rot.pivot[1], planPoint[0] - rot.pivot[0]) + let delta = current - rot.initialAngle + while (delta > Math.PI) delta -= 2 * Math.PI + while (delta < -Math.PI) delta += 2 * Math.PI + if (Math.abs(delta) < 0.0087) { + setRotationOverlay(null) + } else { + setRotationOverlay({ + pivot: rot.pivot, + startAngle: rot.initialAngle, + endAngle: rot.initialAngle + delta, + radius: rot.radius, + sweep: Math.abs(delta), + }) + } + } } const onPointerUp = (event: PointerEvent) => { @@ -525,6 +590,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { sfxEmitter.emit('sfx:structure-build') dragRef.current = null setActiveDragId(null) + setRotationOverlay(null) return } @@ -575,6 +641,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { dragRef.current = null setActiveDragId(null) + setRotationOverlay(null) } const onPointerCancel = (event: PointerEvent) => { @@ -596,6 +663,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { dragRef.current = null setActiveDragId(null) + setRotationOverlay(null) } window.addEventListener('pointermove', onPointerMove) @@ -657,8 +725,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { hoveredHandleId={hoveredHandleId} nodeId={id} onHandleHoverChange={setHoveredHandleId} - onHandlePointerDown={(affordance, payload, event) => - startAffordanceDrag(id, makeHandleId(id, payload), affordance, payload, event) + onHandlePointerDown={(affordance, payload, event, rotationPivot) => + startAffordanceDrag( + id, + makeHandleId(id, payload), + affordance, + payload, + event, + rotationPivot, + ) } onMoveHandlePointerDown={(event) => { if (event.button !== 0) return @@ -716,6 +791,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { overlay ? renderEntry(id, overlay, `overlay-${id}`) : null, )} + {/* Transient live-rotation readout — drawn last so the wedge + degree + chip sit above all handle chrome while a rotate-arrow is dragged. */} + {rotationOverlay && palette ? ( + + ) : null} ) }) @@ -748,6 +833,9 @@ function InteractiveGeometry({ affordance: string, payload: unknown, event: ReactPointerEvent, + // Forwarded only by rotate-arrow handles — the pivot the drag turns + // the node around, used to drive the live angle wedge + degree chip. + rotationPivot?: FloorplanPoint, ) => void onMoveHandlePointerDown: (event: ReactPointerEvent) => void }): React.ReactElement { @@ -1011,6 +1099,7 @@ function InteractiveGeometry({ const angleDeg = (g.angle * 180) / Math.PI const affordance = g.affordance const payload = g.payload + const pivot = g.pivot return ( - onHandlePointerDown(affordance, payload, e as ReactPointerEvent) + onHandlePointerDown( + affordance, + payload, + e as ReactPointerEvent, + pivot, + ) } onPointerEnter={() => onHandleHoverChange(handleId)} onPointerLeave={() => onHandleHoverChange(null)} @@ -1048,7 +1142,12 @@ function InteractiveGeometry({ d={`${head1} ${head2}`} fill="transparent" onPointerDown={(e) => - onHandlePointerDown(affordance, payload, e as ReactPointerEvent) + onHandlePointerDown( + affordance, + payload, + e as ReactPointerEvent, + pivot, + ) } onPointerEnter={() => onHandleHoverChange(handleId)} onPointerLeave={() => onHandleHoverChange(null)} @@ -1375,9 +1474,17 @@ function InteractiveGeometry({ const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf] const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf] + // Keep the label parallel to the dimension line, but decide the + // 180° flip from the on-SCREEN angle, not the local one. The parent + // `` is rotated by `sceneRotationDeg` (default 90° in the floor + // plan), so a label kept upright in local coords still renders + // upside down for half of the wall orientations. Same fix as the + // `dimension-label` case above. let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI - if (labelDeg > 90) labelDeg -= 180 - else if (labelDeg <= -90) labelDeg += 180 + let screenDeg = labelDeg + sceneRotationDeg + screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180 + if (screenDeg > 90) labelDeg -= 180 + else if (screenDeg <= -90) labelDeg += 180 return ( @@ -1656,6 +1763,95 @@ function deepEqual(a: unknown, b: unknown): boolean { return false } +const ROTATION_WEDGE_COLOR = '#8381ed' +const ROTATION_WEDGE_SEGMENTS = 48 + +/** + * Live rotation readout for the floor plan — the 2D twin of the 3D rotate + * gizmo's wedge. Draws a filled sector + outline swept from the pointer's + * bearing at grab (`startAngle`) to its current bearing (`endAngle`) around + * the pivot, plus an upright degree chip at the wedge midpoint. All geometry + * is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads + * horizontally regardless of the building's on-screen orientation. + */ +function RotationAngleOverlay({ + overlay, + palette, + unitsPerPixel, + sceneRotationDeg, +}: { + overlay: RotationOverlayState + palette: FloorplanPalette + unitsPerPixel: number + sceneRotationDeg: number +}): React.ReactElement { + const { pivot, startAngle, endAngle, radius, sweep } = overlay + const span = endAngle - startAngle + const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS)) + let d = `M ${pivot[0]} ${pivot[1]}` + for (let i = 0; i <= count; i++) { + const a = startAngle + (span * i) / count + d += ` L ${pivot[0] + Math.cos(a) * radius} ${pivot[1] + Math.sin(a) * radius}` + } + d += ' Z' + + const midAngle = startAngle + span / 2 + const labelDist = radius + unitsPerPixel * 14 + const lx = pivot[0] + Math.cos(midAngle) * labelDist + const ly = pivot[1] + Math.sin(midAngle) * labelDist + + const text = `${Math.round((sweep * 180) / Math.PI)}°` + const padX = unitsPerPixel * 6 + const padY = unitsPerPixel * 3 + const fontSize = Math.max(unitsPerPixel * 10, 0.08) + const textWidth = text.length * unitsPerPixel * 6.2 + const plateW = textWidth + padX * 2 + const plateH = fontSize + padY * 2 + + return ( + + + + {/* Counter-rotate the scene transform so the chip stays horizontal. */} + + + + {text} + + + + ) +} + function formatGroupTransform(t?: { translate?: readonly [number, number] rotate?: number diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index 29426d42f..2b6ce03db 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -4,6 +4,7 @@ import { type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' +import { buildBoxVentFloorplan } from './floorplan' import { boxVentParametrics } from './parametrics' import { BoxVentNode } from './schema' @@ -180,6 +181,7 @@ export const boxVentDefinition: NodeDefinition = { parametrics: boxVentParametrics, handles: boxVentHandles, + floorplan: buildBoxVentFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/box-vent/floorplan.ts b/packages/nodes/src/box-vent/floorplan.ts new file mode 100644 index 000000000..5287e3f05 --- /dev/null +++ b/packages/nodes/src/box-vent/floorplan.ts @@ -0,0 +1,195 @@ +import type { + AnyNodeId, + BoxVentNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' + +/** + * Floor-plan builder for a box vent — a small attic-exhaust vent on a roof + * slope. Seen from above it reads as its footprint per style: `box` is a + * cover with an inset riser, `cap` flares to a flange past the body, and + * `dome` is a flush ellipse. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → vent), same as the chimney builder. `position` + * is segment-local (X = width, Z = depth; Y ignored — anchored to the + * slope). `rotation` is yaw. Rotations negated for the floor plan's y-down + * convention. + */ +export function buildBoxVentFloorplan( + node: BoxVentNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Painted-metal vent — cool grey, accent on select, light blue on hover. + const baseInk = '#475569' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#dbe1e8' + const fillOpacity = showSelectedChrome ? 0.55 : 0.7 + const lineWidth = showSelectedChrome ? 0.03 : 0.02 + + const hw = Math.max(node.width, 0.05) / 2 + const hd = Math.max(node.depth, 0.05) / 2 + const style = node.style ?? 'cap' + + const rect = (halfX: number, halfZ: number): FloorplanPoint[] => [ + toPlan(-halfX, -halfZ), + toPlan(halfX, -halfZ), + toPlan(halfX, halfZ), + toPlan(-halfX, halfZ), + ] + const ellipse = (halfX: number, halfZ: number): FloorplanPoint[] => { + const pts: FloorplanPoint[] = [] + const N = 28 + for (let i = 0; i < N; i++) { + const a = (i / N) * Math.PI * 2 + pts.push(toPlan(halfX * Math.cos(a), halfZ * Math.sin(a))) + } + return pts + } + + const children: FloorplanGeometry[] = [] + + if (style === 'dome') { + const outer = ellipse(hw, hd) + children.push({ + kind: 'polygon', + points: outer, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + children.push({ + kind: 'polygon', + points: outer, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + pointerEvents: 'none', + }) + // Inner ring suggests the dome bulge. + children.push({ + kind: 'polygon', + points: ellipse(hw * 0.55, hd * 0.55), + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.6, + pointerEvents: 'none', + }) + } else if (style === 'cap') { + // Flange flares past the body by `hoodOverhang` on all sides. + const ovh = Math.max(0, node.hoodOverhang ?? 0.04) + const outer = rect(hw + ovh, hd + ovh) + children.push({ + kind: 'polygon', + points: outer, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + children.push({ + kind: 'polygon', + points: outer, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + // Body footprint inside the flange. + children.push({ + kind: 'polygon', + points: rect(hw, hd), + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.7, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } else { + // box: cover footprint + inset riser. + const outer = rect(hw, hd) + children.push({ + kind: 'polygon', + points: outer, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + children.push({ + kind: 'polygon', + points: outer, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + const inset = Math.max(0, Math.min(node.baseInset ?? 0.06, Math.min(hw, hd) - 0.01)) + if (inset > 0.001) { + children.push({ + kind: 'polygon', + points: rect(hw - inset, hd - inset), + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.7, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index db3cd094e..c68c3f339 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -8,6 +8,7 @@ import { type RoofSegmentNode as RoofSegmentNodeType, type SceneApi, } from '@pascal-app/core' +import { buildChimneyFloorplan } from './floorplan' import { chimneyPaint } from './paint' import { chimneyParametrics } from './parametrics' import { ChimneyNode } from './schema' @@ -392,6 +393,7 @@ export const chimneyDefinition: NodeDefinition = { parametrics: chimneyParametrics, handles: chimneyHandles, + floorplan: buildChimneyFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/chimney/floorplan.ts b/packages/nodes/src/chimney/floorplan.ts new file mode 100644 index 000000000..989c68b8c --- /dev/null +++ b/packages/nodes/src/chimney/floorplan.ts @@ -0,0 +1,215 @@ +import type { + AnyNodeId, + ChimneyNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' +import { flueXPositions } from './geometry' + +/** + * Floor-plan builder for a chimney. A chimney is a masonry stack hosted on + * a roof segment. Seen from above it reads as its crown/cap footprint with + * the body shaft nested inside (the cap overhangs the body) and the flue + * openings poking out the top. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → chimney). The chimney's `position` is + * segment-local (X = width axis, Z = depth axis; Y is ignored — the 3D + * renderer anchors it to the slope). `rotation` is yaw. We compose with + * the floor-plan's negated-rotation convention (see + * `buildRoofSegmentFloorplan`). Unlike the gutter there's no eave/overhang + * offset — a chimney sits at its own footprint, not on the drip edge. + */ +export function buildChimneyFloorplan( + node: ChimneyNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + // Compose roof → segment → chimney in plan coords. Each rotation is + // negated so SVG's y-down CW matches Three.js' top-down CCW. + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Masonry — warm stone grey, accent on select, light blue on hover. + const baseInk = '#44403c' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#d6d3d1' + const fillOpacity = showSelectedChrome ? 0.55 : 0.6 + const lineWidth = showSelectedChrome ? 0.03 : 0.022 + + const isRound = node.bodyShape === 'round' + const halfW = Math.max(node.width, 0.05) / 2 + // Round bodies use `width` as the diameter and ignore `depth`. + const halfD = isRound ? halfW : Math.max(node.depth, 0.05) / 2 + const hasCap = node.cap && node.capShape !== 'none' + const overhang = hasCap ? Math.max(node.capOverhang, 0) : 0 + const capHalfW = halfW + overhang + const capHalfD = halfD + overhang + const showBodyInset = hasCap && overhang > 0.001 + + const children: FloorplanGeometry[] = [] + + if (isRound) { + const c = toPlan(0, 0) + // Transparent hit-target across the crown footprint. + children.push({ + kind: 'circle', + cx: c[0], + cy: c[1], + r: capHalfW, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + // Crown / outer body footprint, filled. + children.push({ + kind: 'circle', + cx: c[0], + cy: c[1], + r: capHalfW, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + pointerEvents: 'none', + }) + // Body shaft inside the cap overhang. + if (showBodyInset) { + children.push({ + kind: 'circle', + cx: c[0], + cy: c[1], + r: halfW, + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.7, + pointerEvents: 'none', + }) + } + } else { + const capCorners: FloorplanPoint[] = [ + toPlan(-capHalfW, -capHalfD), + toPlan(capHalfW, -capHalfD), + toPlan(capHalfW, capHalfD), + toPlan(-capHalfW, capHalfD), + ] + children.push({ + kind: 'polygon', + points: capCorners, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }) + children.push({ + kind: 'polygon', + points: capCorners, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + if (showBodyInset) { + children.push({ + kind: 'polygon', + points: [ + toPlan(-halfW, -halfD), + toPlan(halfW, -halfD), + toPlan(halfW, halfD), + toPlan(-halfW, halfD), + ], + fill: 'none', + stroke, + strokeWidth: lineWidth * 0.8, + strokeOpacity: 0.7, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } + } + + // Flue openings poking out the crown — drawn along the chimney's local X + // at z = 0, matching `flueXPositions` (the same layout the 3D pots use). + // Round or square per `flueShape`. Hollow so they read as openings. + const flueCount = Math.max(0, Math.min(4, node.flueCount)) + if (flueCount > 0) { + const d = Math.max(0.02, node.flueDiameter) + const r = d / 2 + const xs = flueXPositions(flueCount, node.width, d, node.flueSpacing) + const flueStroke = showSelectedChrome && palette ? palette.selectedStroke : '#292524' + for (const fx of xs) { + if (node.flueShape === 'square') { + children.push({ + kind: 'polygon', + points: [ + toPlan(fx - r, -r), + toPlan(fx + r, -r), + toPlan(fx + r, r), + toPlan(fx - r, r), + ], + fill: 'none', + stroke: flueStroke, + strokeWidth: lineWidth * 0.8, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } else { + const c = toPlan(fx, 0) + children.push({ + kind: 'circle', + cx: c[0], + cy: c[1], + r, + fill: 'none', + stroke: flueStroke, + strokeWidth: lineWidth * 0.8, + pointerEvents: 'none', + }) + } + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/column/floorplan.ts b/packages/nodes/src/column/floorplan.ts index 28fd5268d..3ed153f18 100644 --- a/packages/nodes/src/column/floorplan.ts +++ b/packages/nodes/src/column/floorplan.ts @@ -157,6 +157,7 @@ export function buildColumnFloorplan( point: [cx + cornerWorldX, cz + cornerWorldZ], angle: Math.atan2(radialZ, radialX), affordance: 'column-rotate', + pivot: [cx, cz], }) } diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 73f7d850b..ce929a785 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -9,6 +9,7 @@ import { type SceneApi, } from '@pascal-app/core' import { buildDormerRoofCut, getDormerExposedFaces } from './csg-geometry' +import { buildDormerFloorplan } from './floorplan' import { dormerPaint } from './paint' import { dormerParametrics } from './parametrics' import { DormerNode } from './schema' @@ -434,6 +435,7 @@ export const dormerDefinition: NodeDefinition = { parametrics: dormerParametrics, handles: dormerHandles, + floorplan: buildDormerFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/dormer/floorplan.ts b/packages/nodes/src/dormer/floorplan.ts new file mode 100644 index 000000000..8305c46d9 --- /dev/null +++ b/packages/nodes/src/dormer/floorplan.ts @@ -0,0 +1,213 @@ +import type { + AnyNodeId, + DormerNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' + +/** + * Floor-plan builder for a dormer — a small house-shaped structure that + * projects from a roof slope, with its own little roof and a window on the + * front face. Seen from above it reads as a `width × depth` footprint plus + * its roof's ridge/hip linework, and a line marking the window on the + * down-slope (+Z) front face. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → dormer), same as the chimney builder. The + * dormer's `position` is segment-local: X = width axis (along the eave), + * Z = depth axis (projecting down-slope; +Z is the front/window face). + * `rotation` is yaw. Rotations are negated for the floor plan's y-down + * convention (see `buildRoofSegmentFloorplan`). + * + * Per-type roof linework follows the dormer's own roof geometry + * (`buildDormerCutShape` in csg-geometry.ts): gable ridge runs along Z, + * shed slopes high-at-back (−Z) to low-at-front (+Z), hip ridges along the + * longer axis. Gambrel falls back to gable; dutch/mansard to hip — the + * same fallbacks the 3D cut uses. + */ +export function buildDormerFloorplan( + node: DormerNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + // Compose roof → segment → dormer in plan coords. Each rotation negated + // so SVG's y-down CW matches Three.js' top-down CCW. + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Reads as a small structure on the roof — neutral grey, accent on + // select, light blue on hover. + const baseInk = '#52525b' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#e4e4e7' + const fillOpacity = showSelectedChrome ? 0.55 : 0.6 + const lineWidth = showSelectedChrome ? 0.03 : 0.022 + const ridgeWidth = showSelectedChrome ? 0.04 : 0.03 + + const hw = Math.max(node.width, 0.1) / 2 + const hd = Math.max(node.depth, 0.1) / 2 + + const corners: FloorplanPoint[] = [ + toPlan(-hw, -hd), + toPlan(hw, -hd), + toPlan(hw, hd), + toPlan(-hw, hd), + ] + + const children: FloorplanGeometry[] = [ + // Transparent hit-target across the footprint. + { + kind: 'polygon', + points: corners, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + // Body footprint, filled. + { + kind: 'polygon', + points: corners, + fill, + fillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }, + ] + + const line = (a: readonly [number, number], b: readonly [number, number], w: number) => { + const pa = toPlan(a[0], a[1]) + const pb = toPlan(b[0], b[1]) + children.push({ + kind: 'line', + x1: pa[0], + y1: pa[1], + x2: pb[0], + y2: pb[1], + stroke, + strokeWidth: w, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + } + + // Roof linework per dormer roof type (skipped for flat / zero-height). + const type = node.roofType + if (node.roofHeight > 0 && type !== 'flat') { + if (type === 'shed') { + // Slopes from the high back (−Z) down to the low front (+Z); show a + // downslope arrow pointing toward the front. + const tail = toPlan(0, -hd * 0.55) + const head = toPlan(0, hd * 0.55) + const dx = head[0] - tail[0] + const dy = head[1] - tail[1] + const len = Math.hypot(dx, dy) || 1 + const ux = dx / len + const uy = dy / len + const headLen = Math.min(0.25, len * 0.4) + const wing = headLen * 0.6 + children.push({ + kind: 'line', + x1: tail[0], + y1: tail[1], + x2: head[0], + y2: head[1], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + children.push({ + kind: 'polyline', + points: [ + [head[0] - headLen * ux - wing * uy, head[1] - headLen * uy + wing * ux], + [head[0], head[1]], + [head[0] - headLen * ux + wing * uy, head[1] - headLen * uy - wing * ux], + ], + stroke, + strokeWidth: lineWidth, + strokeLinecap: 'round', + strokeLinejoin: 'round', + pointerEvents: 'none', + }) + } else if (type === 'hip' || type === 'dutch' || type === 'mansard') { + // Ridge along the longer axis + four hips from the corners (a single + // apex when square). Mirrors the dormer cut's pyramid/hip. + if (Math.abs(hw - hd) < 0.01) { + line([-hw, -hd], [0, 0], lineWidth) + line([hw, -hd], [0, 0], lineWidth) + line([hw, hd], [0, 0], lineWidth) + line([-hw, hd], [0, 0], lineWidth) + } else if (hd >= hw) { + const rl = hd - hw // ridge along Z + line([0, -rl], [0, rl], ridgeWidth) + line([-hw, hd], [0, rl], lineWidth) + line([hw, hd], [0, rl], lineWidth) + line([-hw, -hd], [0, -rl], lineWidth) + line([hw, -hd], [0, -rl], lineWidth) + } else { + const rl = hw - hd // ridge along X + line([-rl, 0], [rl, 0], ridgeWidth) + line([-hw, -hd], [-rl, 0], lineWidth) + line([-hw, hd], [-rl, 0], lineWidth) + line([hw, -hd], [rl, 0], lineWidth) + line([hw, hd], [rl, 0], lineWidth) + } + } else { + // Gable (and gambrel fallback): ridge runs front-to-back along Z. + line([0, -hd], [0, hd], ridgeWidth) + } + } + + // Window on the +Z (front) face — a line just inside the front edge, + // spanning the window width centred at its X offset. Marks the glazing + // and which way the dormer faces. + const ww = node.windowWidth ?? 0 + if (ww > 0.01) { + const halfWin = Math.min(ww, node.width) / 2 + const center = Math.max(-hw + halfWin, Math.min(hw - halfWin, node.windowOffsetX ?? 0)) + const inset = Math.min(hd * 0.2, 0.08) + line([center - halfWin, hd - inset], [center + halfWin, hd - inset], lineWidth) + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/elevator/floorplan.ts b/packages/nodes/src/elevator/floorplan.ts index 70c519f53..a0e8f7325 100644 --- a/packages/nodes/src/elevator/floorplan.ts +++ b/packages/nodes/src/elevator/floorplan.ts @@ -352,6 +352,7 @@ export function buildElevatorFloorplan( point: [cx + cornerX, cz + cornerZ], angle: Math.atan2(radialZ, radialX), affordance: 'elevator-rotate', + pivot: [cx, cz], }) } diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index de92de66f..8fbbe96e1 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -4,6 +4,7 @@ import { type RidgeVentNode as RidgeVentNodeType, RidgeVentNode as RidgeVentNodeSchema, } from '@pascal-app/core' +import { buildRidgeVentFloorplan } from './floorplan' import { ridgeVentParametrics } from './parametrics' import { RidgeVentNode } from './schema' @@ -169,6 +170,7 @@ export const ridgeVentDefinition: NodeDefinition = { parametrics: ridgeVentParametrics, handles: ridgeVentHandles, + floorplan: buildRidgeVentFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/ridge-vent/floorplan.ts b/packages/nodes/src/ridge-vent/floorplan.ts new file mode 100644 index 000000000..793db2217 --- /dev/null +++ b/packages/nodes/src/ridge-vent/floorplan.ts @@ -0,0 +1,152 @@ +import type { + AnyNodeId, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RidgeVentNode, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' + +// Tab pitch for the shingled style — matches SHINGLED_TAB_SIZE in +// geometry.ts so the plan's divider spacing reads like the 3D ridge cap. +const SHINGLED_TAB_SIZE = 0.3 + +/** + * Floor-plan builder for a ridge vent — a ventilation strip running along + * a roof ridge. Seen from above it's a long thin band straddling the + * ridge crest, with a centre crest line, end caps where closed, and tab + * dividers for the shingled style. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → vent), same as the chimney builder. `position` + * is segment-local; the run (`length`) is along local +X and the small + * cross-`width` straddles the ridge along local Z (centred at Z = 0). + * `rotation` is yaw, negated for the floor plan's y-down convention. + */ +export function buildRidgeVentFloorplan( + node: RidgeVentNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + const baseInk = '#475569' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const fill = showSelectedChrome ? '#fed7aa' : '#dbe1e8' + const fillOpacity = showSelectedChrome ? 0.55 : 0.6 + const lineWidth = showSelectedChrome ? 0.03 : 0.02 + + const halfLen = Math.max(node.length, 0.1) / 2 + const halfW = Math.max(node.width, 0.04) / 2 + + const corners: FloorplanPoint[] = [ + toPlan(-halfLen, -halfW), + toPlan(halfLen, -halfW), + toPlan(halfLen, halfW), + toPlan(-halfLen, halfW), + ] + + const children: FloorplanGeometry[] = [ + // Transparent hit-target over the whole strip. + { + kind: 'polygon', + points: corners, + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + // Strip fill. + { + kind: 'polygon', + points: corners, + fill, + fillOpacity, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'none', + }, + ] + + const seg = ( + a: readonly [number, number], + b: readonly [number, number], + w: number, + opacity?: number, + ) => { + const pa = toPlan(a[0], a[1]) + const pb = toPlan(b[0], b[1]) + children.push({ + kind: 'line', + x1: pa[0], + y1: pa[1], + x2: pb[0], + y2: pb[1], + stroke, + strokeWidth: w, + strokeLinecap: 'round', + opacity, + pointerEvents: 'none', + }) + } + + // Long edges along the run (always) + end caps (only when closed). + seg([-halfLen, -halfW], [halfLen, -halfW], lineWidth) + seg([-halfLen, halfW], [halfLen, halfW], lineWidth) + if (node.endCaps !== false) { + seg([-halfLen, -halfW], [-halfLen, halfW], lineWidth) + seg([halfLen, -halfW], [halfLen, halfW], lineWidth) + } + + // Ridge crest line down the centre. + seg([-halfLen, 0], [halfLen, 0], lineWidth * 0.8, 0.7) + + // Shingled style: tab dividers across the width at the cap pitch. + if (node.style === 'shingled') { + const total = halfLen * 2 + const count = Math.max(2, Math.round(total / SHINGLED_TAB_SIZE)) + const step = total / count + for (let i = 1; i < count; i++) { + const x = -halfLen + i * step + seg([x, -halfW], [x, halfW], lineWidth * 0.6, 0.5) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/roof-segment/floorplan.ts b/packages/nodes/src/roof-segment/floorplan.ts index fec976b14..e91bce9cb 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -7,13 +7,16 @@ import type { } from '@pascal-app/core' /** - * Stage C floor-plan builder for roof segment. Renders the segment's - * footprint as a rotated rectangle in world coords (parent roof's - * position + rotation composed with the segment's own). + * Stage C floor-plan builder for roof segment. Renders the segment as a + * proper architectural roof plan: the footprint outline plus the + * ridge / hip / break linework (and a downslope arrow for sheds) that + * makes each roof shape — hip, gable, shed, gambrel, dutch, mansard, + * flat — read distinctly, rather than as a bare rectangle. * - * Inlined from `getRoofSegmentPolygon` / `getRoofSegmentCenter` in - * `floorplan-panel.tsx`. Ridge line not yet rendered — adds a follow-up - * for full visual parity. + * All linework is derived in segment-local space, mirroring the faces the + * 3D builder (`getModuleFaces` in the roof system) generates per type, so + * the plan and the model agree. Everything is composed into world coords + * via the parent roof's position + rotation and the segment's own. */ export function buildRoofSegmentFloorplan( node: RoofSegmentNode, @@ -41,16 +44,21 @@ export function buildRoofSegmentFloorplan( const halfWidth = node.width / 2 const halfDepth = node.depth / 2 + // Map a segment-local point (lx = width axis, lz = depth axis) into + // world plan coords — the same rotation + translation the footprint + // corners use. Shared by the per-type ridge/hip linework below. + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + const corners: Array<[number, number]> = [ [-halfWidth, -halfDepth], [halfWidth, -halfDepth], [halfWidth, halfDepth], [-halfWidth, halfDepth], ] - const points: FloorplanPoint[] = corners.map(([x, y]) => [ - cx + x * cos - y * sin, - cz + x * sin + y * cos, - ]) + const points: FloorplanPoint[] = corners.map(([x, y]) => toPlan(x, y)) const view = ctx.viewState const palette = view?.palette @@ -77,46 +85,34 @@ export function buildRoofSegmentFloorplan( strokeWidth: 0, pointerEvents: 'all', }, - // Visible outline. - { - kind: 'polygon', - points, - fill: showSelectedChrome ? '#fed7aa' : 'none', - fillOpacity: showSelectedChrome ? 0.55 : 0, - stroke, - strokeWidth: showSelectedChrome ? 0.035 : 0.025, - strokeLinejoin: 'miter', - }, ] - // Ridge line — only for pitched segments, not flat roofs. Dashed - // black so it reads as the ridge (axis of the pitch) without - // competing with the perimeter outline. - if (node.roofType !== 'flat') { - const ridgeAxis = - node.roofType === 'gable' || node.roofType === 'gambrel' - ? 'x' - : node.roofType === 'dutch' - ? node.width >= node.depth - ? 'x' - : 'z' - : 'z' - const axisAngle = ridgeAxis === 'x' ? rotation : rotation + Math.PI / 2 - const halfSpan = ridgeAxis === 'x' ? node.width / 2 : node.depth / 2 + // The segment's own rectangle outline + fill render ONLY while it's + // selected / highlighted — that highlights which sub-plane is active + // (including its interior edges shared with neighbours). When unselected + // the eaves come from the parent roof's merged outline + // (`buildRoofFloorplan`), so overlapping segments read as one combined + // shape instead of stacked rectangles. Ridges/hips below always draw. + if (showSelectedChrome) { children.push({ - kind: 'line', - x1: cx - halfSpan * Math.cos(axisAngle), - y1: cz - halfSpan * Math.sin(axisAngle), - x2: cx + halfSpan * Math.cos(axisAngle), - y2: cz + halfSpan * Math.sin(axisAngle), + kind: 'polygon', + points, + fill: '#fed7aa', + fillOpacity: 0.55, stroke, - strokeWidth: 0.02, - strokeDasharray: '0.1 0.08', - strokeLinecap: 'butt', - opacity: 0.85, + strokeWidth: 0.035, + strokeLinejoin: 'miter', }) } + // 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). + // Selection chrome — orange move-handle dot at the centre, four // perpendicular side resize-arrows (width on X, depth on Z), and a // rotate-arrow at the +X/+Z corner. Sister to the 3D handles in @@ -173,8 +169,149 @@ export function buildRoofSegmentFloorplan( point: [cx + cornerX, cz + cornerZ], angle: Math.atan2(radialZ, radialX), affordance: 'roof-segment-rotate', + pivot: [cx, cz], }) } return { kind: 'group', children } } + +export type PlanPt = readonly [number, number] +export type PlanSeg = readonly [PlanPt, PlanPt] + +/** + * Ridge / hip / break linework for a roof segment in segment-local space + * (lx = width axis, lz = depth axis), mirroring the faces the 3D builder + * (`getModuleFaces`) generates for each roof type. The floor-plan builder + * maps these to world coords. `slope`, when set, is a shed roof's downhill + * fall direction (tail = high eave, head = low eave). + * + * - ridge: peak line(s) where opposite slopes meet + * - hip: diagonal from an eave corner up to a ridge end / peak + * - 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. + */ +export function getRoofSegmentPlanLinework(node: RoofSegmentNode): { + ridges: PlanSeg[] + hips: PlanSeg[] + breaks: PlanSeg[] + slope: { tail: PlanPt; head: PlanPt } | null +} { + const hw = node.width / 2 + const hd = node.depth / 2 + const ridges: PlanSeg[] = [] + const hips: PlanSeg[] = [] + const breaks: PlanSeg[] = [] + let slope: { tail: PlanPt; head: PlanPt } | null = null + + // Eave corners, matching e1..e4 in the 3D `getModuleFaces` builder. + const e1: PlanPt = [-hw, hd] + const e2: PlanPt = [hw, hd] + const e3: PlanPt = [hw, -hd] + const e4: PlanPt = [-hw, -hd] + + // Hip linework shared by `hip` and the collapsed-waist mansard/dutch + // fallbacks: ridge along the longer axis, four hips from the eave + // corners to the nearer ridge end — or a single peak when square. + const pushHip = () => { + if (Math.abs(node.width - node.depth) < 0.01) { + const peak: PlanPt = [0, 0] + hips.push([e1, peak], [e2, peak], [e3, peak], [e4, peak]) + } else if (node.width >= node.depth) { + const r1: PlanPt = [-hw + hd, 0] + const r2: PlanPt = [hw - hd, 0] + ridges.push([r1, r2]) + hips.push([e1, r1], [e4, r1], [e2, r2], [e3, r2]) + } else { + const r1: PlanPt = [0, hd - hw] + const r2: PlanPt = [0, -hd + hw] + ridges.push([r1, r2]) + hips.push([e1, r1], [e2, r1], [e3, r2], [e4, r2]) + } + } + + switch (node.roofType) { + case 'flat': + break + case 'gable': + // Single ridge down the middle along the width axis. + ridges.push([ + [-hw, 0], + [hw, 0], + ]) + break + case 'shed': + // 3D builder slopes from the high eave (lz = -hd) down to lz = +hd. + slope = { tail: [0, -hd * 0.55], head: [0, hd * 0.55] } + break + case 'hip': + pushHip() + break + case 'gambrel': { + // Ridge + two kink lines parallel to it. + const mz = hd * node.gambrelLowerWidthRatio + ridges.push([ + [-hw, 0], + [hw, 0], + ]) + breaks.push( + [ + [-hw, mz], + [hw, mz], + ], + [ + [-hw, -mz], + [hw, -mz], + ], + ) + break + } + case 'mansard': { + // Inner waist rectangle + four corner hips from the eaves to it. + const i = Math.min(node.width, node.depth) * node.mansardSteepWidthRatio + if (hw - i > 0.02 && hd - i > 0.02) { + const w1: PlanPt = [-hw + i, hd - i] + const w2: PlanPt = [hw - i, hd - i] + const w3: PlanPt = [hw - i, -hd + i] + const w4: PlanPt = [-hw + i, -hd + i] + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + } else { + pushHip() + } + break + } + case 'dutch': { + // Hipped lower skirt (eave corners → waist corners) + the gablet + // fold, then a gable-style ridge on top of the waist. + const i = Math.min(node.width, node.depth) * node.dutchHipWidthRatio + if (hw - i > 0.02 && hd - i > 0.02) { + const w1: PlanPt = [-hw + i, hd - i] + const w2: PlanPt = [hw - i, hd - i] + const w3: PlanPt = [hw - i, -hd + i] + const w4: PlanPt = [-hw + i, -hd + i] + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + if (node.width >= node.depth) { + const r1: PlanPt = [-hw + i, 0] + const r2: PlanPt = [hw - i, 0] + ridges.push([r1, r2]) + hips.push([w1, r1], [w4, r1], [w2, r2], [w3, r2]) + } else { + const r1: PlanPt = [0, hd - i] + const r2: PlanPt = [0, -hd + i] + ridges.push([r1, r2]) + hips.push([w1, r1], [w2, r1], [w3, r2], [w4, r2]) + } + } else { + pushHip() + } + break + } + } + + return { ridges, hips, breaks, slope } +} diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index d18eb13a1..0bb77211f 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -1,11 +1,15 @@ import { type NodeDefinition, RoofNode as RoofNodeSchema } from '@pascal-app/core' +import { buildRoofFloorplan } from './floorplan' import { roofParametrics } from './parametrics' import { RoofNode } from './schema' /** * Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer` * + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` + - * CSG). Inspector / move / floorplan stay legacy until Stage B-E. + * CSG). Inspector / move stay legacy until Stage B-E. `floorplan` draws + * the merged silhouette (union of the child segments' footprints), so a + * multi-segment roof reads as one combined shape rather than stacked + * rectangles. * * Roof is a "composite" node — it has `roof-segment` children that * own per-segment geometry. The parent roof handles overall framing; @@ -31,6 +35,7 @@ export const roofDefinition: NodeDefinition = { }, parametrics: roofParametrics, + floorplan: buildRoofFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/roof/floorplan.ts b/packages/nodes/src/roof/floorplan.ts new file mode 100644 index 000000000..ca04284ee --- /dev/null +++ b/packages/nodes/src/roof/floorplan.ts @@ -0,0 +1,292 @@ +import type { + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, +} 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++) { + 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[] + hips: Seg[] + breaks: Seg[] + slope: { tail: Pt; head: Pt } | null +} + +/** 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) + const sinRoof = Math.sin(-roof.rotation) + const segCx = roof.position[0] + seg.position[0] * cosRoof - seg.position[2] * sinRoof + const segCz = roof.position[2] + seg.position[0] * sinRoof + seg.position[2] * cosRoof + const rot = -(roof.rotation + seg.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const tp = (lx: number, lz: number): Pt => [segCx + lx * cos - lz * sin, segCz + lx * sin + lz * cos] + const hw = Math.max(seg.width, 0.01) / 2 + const hd = Math.max(seg.depth, 0.01) / 2 + const lw = getRoofSegmentPlanLinework(seg) + const mapSeg = (s: readonly [readonly [number, number], readonly [number, number]]): Seg => [ + tp(s[0][0], s[0][1]), + tp(s[1][0], s[1][1]), + ] + return { + footprint: [tp(-hw, -hd), tp(hw, -hd), tp(hw, hd), tp(-hw, hd)], + ridges: lw.ridges.map(mapSeg), + hips: lw.hips.map(mapSeg), + breaks: lw.breaks.map(mapSeg), + slope: lw.slope + ? { tail: tp(lw.slope.tail[0], lw.slope.tail[1]), head: tp(lw.slope.head[0], lw.slope.head[1]) } + : null, + } +} + +/** + * 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. + * + * Composition uses the floor plan's negated-rotation convention + * (segment-local → roof-local → plan). `unionPolygons` returns one ring per + * disjoint group, so non-touching segments each keep their own outline. The + * group is decorative (`pointerEvents: 'none'`) — clicks fall through to the + * segment hit-targets. + */ +export function buildRoofFloorplan( + node: RoofNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + 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 + + // 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 view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected ?? false) || (view?.highlighted ?? false) + const ink = showSelectedChrome && palette ? palette.selectedStroke : '#111111' + const eaveWidth = showSelectedChrome ? 0.04 : 0.03 + const ridgeWidth = showSelectedChrome ? 0.05 : 0.038 + const hipWidth = showSelectedChrome ? 0.04 : 0.026 + + const children: FloorplanGeometry[] = [] + const pushLine = (a: Pt, b: Pt, width: number) => { + children.push({ + kind: 'line', + x1: a[0], + y1: a[1], + x2: b[0], + y2: b[1], + stroke: ink, + strokeWidth: width, + strokeLinecap: 'round', + pointerEvents: 'none', + }) + } + + // Merged outline (eaves). + for (const ring of rings) { + if (ring.length < 3) continue + children.push({ + kind: 'polygon', + points: ring.map(([x, z]) => [x, z] as FloorplanPoint), + fill: 'none', + stroke: ink, + strokeWidth: eaveWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } + + // 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 + } + } + 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 + } + 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] + 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) + 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], + ], + stroke: ink, + strokeWidth: hipWidth, + strokeLinecap: 'round', + strokeLinejoin: 'round', + pointerEvents: 'none', + }) + } + }) + + return children.length > 0 ? { kind: 'group', children } : null +} diff --git a/packages/nodes/src/shelf/floorplan.ts b/packages/nodes/src/shelf/floorplan.ts index 6ed96ce06..1bb8fae84 100644 --- a/packages/nodes/src/shelf/floorplan.ts +++ b/packages/nodes/src/shelf/floorplan.ts @@ -137,6 +137,7 @@ export function buildShelfFloorplan( point: [px + cornerPlanX, pz + cornerPlanY], angle: Math.atan2(radialPlanY, radialPlanX), affordance: 'shelf-rotate', + pivot: [px, pz], }) return { kind: 'group', children } diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index 52557a653..ab4bcc36c 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -11,6 +11,7 @@ import { isOperableSkylightNode, toggleSkylightOpenState, } from './interaction' +import { buildSkylightFloorplan } from './floorplan' import { skylightParametrics } from './parametrics' import { buildSkylightRoofCut } from './roof-cut' import { SkylightNode } from './schema' @@ -251,6 +252,7 @@ export const skylightDefinition: NodeDefinition = { parametrics: skylightParametrics, handles: skylightHandles, + floorplan: buildSkylightFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/skylight/floorplan.ts b/packages/nodes/src/skylight/floorplan.ts new file mode 100644 index 000000000..2c4bf4cc3 --- /dev/null +++ b/packages/nodes/src/skylight/floorplan.ts @@ -0,0 +1,154 @@ +import type { + AnyNodeId, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, + SkylightNode, +} from '@pascal-app/core' + +/** + * Floor-plan builder for a skylight — a glazed opening set into the roof + * slope. Seen from above it's the classic skylight symbol: an outer frame + * rectangle, an inset glass pane, and a diagonal cross over the glass. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → skylight), same as the chimney builder. + * `position` is segment-local (X = width across slope, Z = height down + * slope; Y ignored — anchored to the slope). `rotation` is yaw. Rotations + * negated for the floor plan's y-down convention. The frame extends + * outward by `frameThickness` past the `width × height` glass opening + * (matching `frame-csg.ts`). + */ +export function buildSkylightFloorplan( + node: SkylightNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + const baseInk = '#475569' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const frameFill = showSelectedChrome ? '#fed7aa' : '#e2e8f0' + const glassFill = showSelectedChrome ? '#fed7aa' : '#dbeafe' + const frameFillOpacity = showSelectedChrome ? 0.55 : 0.7 + const glassFillOpacity = showSelectedChrome ? 0.45 : 0.55 + const lineWidth = showSelectedChrome ? 0.03 : 0.02 + + const hw = Math.max(node.width, 0.1) / 2 + const hh = Math.max(node.height, 0.1) / 2 + const ft = Math.max(0, node.frameThickness ?? 0.05) + const outerHX = hw + ft + const outerHZ = hh + ft + + const rect = (halfX: number, halfZ: number): FloorplanPoint[] => [ + toPlan(-halfX, -halfZ), + toPlan(halfX, -halfZ), + toPlan(halfX, halfZ), + toPlan(-halfX, halfZ), + ] + + const children: FloorplanGeometry[] = [ + // Transparent hit-target over the outer frame. + { + kind: 'polygon', + points: rect(outerHX, outerHZ), + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + // Outer frame / curb. + { + kind: 'polygon', + points: rect(outerHX, outerHZ), + fill: frameFill, + fillOpacity: frameFillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }, + // Inset glass pane. + { + kind: 'polygon', + points: rect(hw, hh), + fill: glassFill, + fillOpacity: glassFillOpacity, + stroke, + strokeWidth: lineWidth * 0.8, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }, + ] + + // Diagonal glazing cross over the pane — the standard skylight symbol. + const tl = toPlan(-hw, -hh) + const tr = toPlan(hw, -hh) + const bl = toPlan(-hw, hh) + const br = toPlan(hw, hh) + children.push( + { + kind: 'line', + x1: tl[0], + y1: tl[1], + x2: br[0], + y2: br[1], + stroke, + strokeWidth: lineWidth * 0.7, + strokeOpacity: 0.7, + strokeLinecap: 'round', + pointerEvents: 'none', + }, + { + kind: 'line', + x1: tr[0], + y1: tr[1], + x2: bl[0], + y2: bl[1], + stroke, + strokeWidth: lineWidth * 0.7, + strokeOpacity: 0.7, + strokeLinecap: 'round', + pointerEvents: 'none', + }, + ) + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index bb8ae1822..0c129b78a 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -4,6 +4,7 @@ import { SolarPanelNode as SolarPanelNodeSchema, type SolarPanelNode as SolarPanelNodeType, } from '@pascal-app/core' +import { buildSolarPanelFloorplan } from './floorplan' import { solarPanelParametrics } from './parametrics' import { SolarPanelNode } from './schema' @@ -252,6 +253,7 @@ export const solarPanelDefinition: NodeDefinition = { parametrics: solarPanelParametrics, handles: solarPanelHandles, + floorplan: buildSolarPanelFloorplan, renderer: { kind: 'parametric', diff --git a/packages/nodes/src/solar-panel/floorplan.ts b/packages/nodes/src/solar-panel/floorplan.ts new file mode 100644 index 000000000..dd305cf90 --- /dev/null +++ b/packages/nodes/src/solar-panel/floorplan.ts @@ -0,0 +1,142 @@ +import type { + AnyNodeId, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + RoofNode, + RoofSegmentNode, + SolarPanelNode, +} from '@pascal-app/core' + +/** + * Floor-plan builder for a solar-panel array — a grid of photovoltaic + * modules mounted on a roof segment. Seen from above it reads as its + * `rows × columns` grid of dark module rectangles with gaps between them. + * + * Coordinate frame mirrors the 3D transform stack + * (roof → roof-segment → panel), same as the chimney builder. The array's + * `position` is segment-local (X = width axis, Z = depth axis; Y is + * ignored — the 3D renderer anchors it to the slope). `rotation` is yaw. + * Rotations are negated for the floor plan's y-down convention (see + * `buildRoofSegmentFloorplan`). + * + * The per-module layout matches `buildSolarPanelGeometry` exactly: the + * array is centred at the origin, modules step by `panel + gap` along each + * axis (columns along X, rows along Z). A tilted array foreshortens + * slightly in 3D, but the plan shows its full mounting footprint — that's + * what matters for roof layout. + */ +export function buildSolarPanelFloorplan( + node: SolarPanelNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const segment = ctx.parent as RoofSegmentNode | null + if (!segment || segment.type !== 'roof-segment') return null + const roofId = segment.parentId as AnyNodeId | null + const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined + if (!roof || roof.type !== 'roof') return null + + // Compose roof → segment → panel in plan coords. Each rotation is + // negated so SVG's y-down CW matches Three.js' top-down CCW. + const cosR = Math.cos(-roof.rotation) + const sinR = Math.sin(-roof.rotation) + const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR + const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR + + const segRot = -(roof.rotation + segment.rotation) + const cosS = Math.cos(segRot) + const sinS = Math.sin(segRot) + const cx = segCx + node.position[0] * cosS - node.position[2] * sinS + const cz = segCz + node.position[0] * sinS + node.position[2] * cosS + + const rot = -(roof.rotation + segment.rotation + node.rotation) + const cos = Math.cos(rot) + const sin = Math.sin(rot) + const toPlan = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos - lz * sin, + cz + lx * sin + lz * cos, + ] + + const view = ctx.viewState + const palette = view?.palette + const isSelected = view?.selected ?? false + const isHighlighted = view?.highlighted ?? false + const isHovered = view?.hovered ?? false + const showSelectedChrome = isSelected || isHighlighted + + // Photovoltaic modules — dark glass with a thin frame. Accent on select, + // light blue on hover. + const baseInk = '#1e293b' + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : isHovered && palette + ? palette.wallHoverStroke + : baseInk + const moduleFill = showSelectedChrome ? '#fed7aa' : '#334155' + const moduleFillOpacity = showSelectedChrome ? 0.5 : 0.85 + const lineWidth = showSelectedChrome ? 0.028 : 0.016 + + // Grid params — guarded + clamped to the schema's ranges so a malformed + // or un-migrated node can't NaN the layout or blow up the module loop. + const rows = Math.max(1, Math.min(20, Math.floor(node.rows ?? 1))) + const columns = Math.max(1, Math.min(20, Math.floor(node.columns ?? 1))) + const panelW = Math.max(0.05, node.panelWidth ?? 1) + const panelH = Math.max(0.05, node.panelHeight ?? 1) + const gapX = Math.max(0, node.gapX ?? 0) + const gapY = Math.max(0, node.gapY ?? 0) + + const totalW = columns * panelW + (columns - 1) * gapX + const totalH = rows * panelH + (rows - 1) * gapY + const originX = -totalW / 2 + const originZ = -totalH / 2 + const halfW = totalW / 2 + const halfH = totalH / 2 + + const children: FloorplanGeometry[] = [ + // One transparent hit-target across the whole array so clicking a gap + // (or any module) selects the array. + { + kind: 'polygon', + points: [ + toPlan(-halfW, -halfH), + toPlan(halfW, -halfH), + toPlan(halfW, halfH), + toPlan(-halfW, halfH), + ], + fill: stroke, + fillOpacity: 0, + stroke: 'none', + strokeWidth: 0, + pointerEvents: 'all', + }, + ] + + // One filled rectangle per module — same loop as `buildSolarPanelGeometry` + // (columns along X, rows along Z), so the plan grid matches the 3D array. + const hw = panelW / 2 + const hh = panelH / 2 + for (let r = 0; r < rows; r++) { + for (let c = 0; c < columns; c++) { + const mcx = originX + c * (panelW + gapX) + panelW / 2 + const mcz = originZ + r * (panelH + gapY) + panelH / 2 + children.push({ + kind: 'polygon', + points: [ + toPlan(mcx - hw, mcz - hh), + toPlan(mcx + hw, mcz - hh), + toPlan(mcx + hw, mcz + hh), + toPlan(mcx - hw, mcz + hh), + ], + fill: moduleFill, + fillOpacity: moduleFillOpacity, + stroke, + strokeWidth: lineWidth, + strokeLinejoin: 'miter', + pointerEvents: 'none', + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/stair/floorplan.ts b/packages/nodes/src/stair/floorplan.ts index 75a751f46..3c2ece5e7 100644 --- a/packages/nodes/src/stair/floorplan.ts +++ b/packages/nodes/src/stair/floorplan.ts @@ -478,6 +478,7 @@ export function buildStairFloorplan( point: [planX, planY], angle: radialAngle, affordance: 'stair-rotate', + pivot: [cx, cz], }) } From ea2b68b9c26e6a7e91c58268f3b6d5d70b574793 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 1 Jun 2026 14:40:59 +0530 Subject: [PATCH 29/35] fix(editor): select ceiling only via its corner handles, not the grid body Add `viaHandle` to NodeEvent and set it on ceiling corner-bracket clicks. The selection manager now ignores non-handle ceiling clicks without stopping propagation, so a top-down click on the revealed ceiling grid falls through to the item hosted beneath it instead of re-selecting the ceiling and swallowing the click. The corner brackets draw with depthTest off at a high render order so they stay visible and clickable through occluding geometry. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/events/bus.ts | 5 ++++ .../components/editor/selection-manager.tsx | 10 +++++++ .../ceiling-selection-affordance-system.tsx | 29 +++++++++++++++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7922673f8..283f220a7 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -65,6 +65,11 @@ export interface NodeEvent { object: Object3D stopPropagation: () => void nativeEvent: ThreeEvent + // Set when the click originated from a dedicated selection affordance + // (e.g. a ceiling corner handle) rather than the node's own surface + // mesh. Lets selection logic accept handle clicks while ignoring clicks + // on the body so they fall through to whatever sits below. + viaHandle?: boolean } export type WallEvent = NodeEvent diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 1b1bcd0be..e73bb27ba 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1214,6 +1214,16 @@ export const SelectionManager = () => { if (boxSelectHandled) return const node = event.node + + // A ceiling is selectable only through its corner handles, never via + // the `ceiling-grid` body mesh. When the grid is revealed (ceiling + // selected, or an item placed beneath it) a top-down click hits the + // grid first; selecting the ceiling there both re-selects it as a + // no-op and stops propagation, blocking the hosted item below. By + // ignoring non-handle ceiling clicks (without stopping propagation) + // the click falls through to the item underneath. + if (node.type === 'ceiling' && !event.viaHandle) return + let currentPhase = useEditor.getState().phase let currentStructureLayer = useEditor.getState().structureLayer diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index ee0df9859..c61b86651 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -18,6 +18,10 @@ const BRACKET_THICKNESS = 0.04 const BRACKET_HEIGHT = 0.04 const BRACKET_Y_OFFSET = 0.035 const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28] +// Draw the corner handles after everything else and with depth testing +// off (see materials below) so they stay visible — and clickable — even +// when a wall, roof, or the ceiling itself would otherwise occlude them. +const CORNER_RENDER_ORDER = 1000 type CornerBracketData = { corner: [number, number] @@ -149,6 +153,7 @@ const CornerBracket = ({ localPosition: [0, 0, 0], position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]], stopPropagation: () => e.stopPropagation(), + viaHandle: true, }) } @@ -179,9 +184,16 @@ const CornerBracket = ({ e.stopPropagation() setIsHovered(false) }} + renderOrder={CORNER_RENDER_ORDER} > - +
) @@ -208,9 +220,20 @@ const BracketLeg = ({ ] return ( - + - + ) } From 9b4ad110cd825c0bc0c46d0c198235cd36a9b7a1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 1 Jun 2026 14:41:04 +0530 Subject: [PATCH 30/35] feat(editor): highlight wall openings on a selected wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WallOpeningHighlights — an indigo accent frame + translucent pane drawn around each door / window opening of the selected wall, so editable children (including frameless openings with no visible geometry) are easy to locate. The accent is deliberately distinct from the white selection outline, and draws with depthTest off so it reads on top of the wall. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/src/components/editor/index.tsx | 8 +- .../editor/wall-opening-highlights.tsx | 158 ++++++++++++++++++ 2 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 packages/editor/src/components/editor/wall-opening-highlights.tsx diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 03d36e75f..b28ce1809 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -61,14 +61,15 @@ import { FloatingActionMenu } from './floating-action-menu' import { FloatingBuildingActionMenu } from './floating-building-action-menu' import { FloorplanPanel } from './floorplan-panel' import { Grid } from './grid' -import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { NodeArrowHandles } from './node-arrow-handles' +import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { SelectionManager } from './selection-manager' import { SiteEdgeLabels } from './site-edge-labels' import { SnapshotCaptureOverlay } from './snapshot-capture-overlay' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { WallMeasurementLabel } from './wall-measurement-label' import { WallMoveSideHandles } from './wall-move-side-handles' +import { WallOpeningHighlights } from './wall-opening-highlights' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' const DELETE_CURSOR_BADGE_COLOR = '#ef4444' @@ -577,9 +578,7 @@ function PaintCursorBadge({ // grid lines when the user picks a finer snap (0.25 / 0.1 / 0.05). function SnapAwareGrid() { const gridSnapStep = useEditor((s) => s.gridSnapStep) - return ( - - ) + return } // ── Viewer scene content: memoized so doesn't re-render on mode/viewMode changes ── @@ -600,6 +599,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!isFirstPersonMode && } {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } + {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } {!(isVersionPreviewMode || isFirstPersonMode) && } diff --git a/packages/editor/src/components/editor/wall-opening-highlights.tsx b/packages/editor/src/components/editor/wall-opening-highlights.tsx new file mode 100644 index 000000000..e93aca5cf --- /dev/null +++ b/packages/editor/src/components/editor/wall-opening-highlights.tsx @@ -0,0 +1,158 @@ +'use client' + +import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createPortal, useFrame, useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef } from 'react' +import { + BoxGeometry, + type BufferGeometry, + DoubleSide, + EdgesGeometry, + type Group, + PlaneGeometry, + Vector3, +} from 'three' +import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' + +// How far the outline sits outside the opening's own extents, so it reads as +// a frame *around* the opening rather than coinciding with its edges. +const PAD = 0.05 + +const ACCENT = 0x83_81_ed + +const NO_RAYCAST = () => null +const scratchScale = new Vector3() + +// Indigo accent matching the resize-arrow handles — deliberately distinct +// from the white selection outline so the highlight reads as "editable child +// here", not "this is selected". `depthTest: false` keeps both layers drawn +// on top of the wall so frameless openings (which have no visible geometry) +// are still located. Shared across every box; never disposed. +const outlineMaterial = new LineBasicNodeMaterial({ + color: ACCENT, + depthTest: false, + depthWrite: false, +}) + +// Translucent pane that fills the opening so it reads as a highlighted +// region. Sits in the opening's plane (XY, facing the wall normal) and is +// double-sided so it shows from either side of the wall. +const fillMaterial = new MeshBasicNodeMaterial({ + color: ACCENT, + transparent: true, + opacity: 0.22, + side: DoubleSide, + depthTest: false, + depthWrite: false, +}) + +function makeOutlineGeometry(width: number, height: number, depth: number): BufferGeometry { + const box = new BoxGeometry(width + PAD, height + PAD, depth + PAD) + const edges = new EdgesGeometry(box) + box.dispose() + return edges +} + +/** + * When a wall is selected, draws a translucent indigo highlight (filled pane + * + outline) over each door / window it hosts. Openings whose `openingKind` + * is `'opening'` have no visible geometry, so without this affordance the + * user can't tell an editable cutout lives there — the fill marks it (and + * stays out of the way of clicking the opening itself, which selects it). + * + * Highlights are portalled to the scene root so they sit outside the wall's + * selection-outline subtree and keep their own accent colour. + */ +export function WallOpeningHighlights() { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const { scene } = useThree() + + if (selectedIds.length === 0) return null + + return createPortal( + <> + {selectedIds.map((id) => ( + + ))} + , + scene, + ) +} + +function WallOpenings({ wallId }: { wallId: string }) { + const wall = useScene((state) => state.nodes[wallId as AnyNodeId]) + + if (!wall || wall.type !== 'wall') return null + + const depth = wall.thickness ?? 0.1 + return ( + <> + {(wall.children ?? []).map((childId) => ( + + ))} + + ) +} + +function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) { + const node = useScene((state) => state.nodes[openingId as AnyNodeId]) + const groupRef = useRef(null) + + const isOpening = node?.type === 'door' || node?.type === 'window' + const width = isOpening ? node.width : 0 + const height = isOpening ? node.height : 0 + + const outlineGeometry = useMemo( + () => (isOpening ? makeOutlineGeometry(width, height, depth) : null), + [isOpening, width, height, depth], + ) + const fillGeometry = useMemo( + () => (isOpening ? new PlaneGeometry(width, height) : null), + [isOpening, width, height], + ) + useEffect(() => () => outlineGeometry?.dispose(), [outlineGeometry]) + useEffect(() => () => fillGeometry?.dispose(), [fillGeometry]) + + // The opening's mesh (registered by its renderer) already carries the wall + // transform + its own local pose baked into `matrixWorld`. Copy that + // straight onto the highlight each frame so it tracks moves, resizes, and + // wall rotation without any wall-local maths here. + useFrame(() => { + const group = groupRef.current + if (!group) return + const obj = sceneRegistry.nodes.get(openingId as AnyNodeId) + if (!obj) { + group.visible = false + return + } + group.visible = true + obj.matrixWorld.decompose(group.position, group.quaternion, scratchScale) + }) + + if (!isOpening || !outlineGeometry || !fillGeometry) return null + + return ( + + + + + ) +} + +export default WallOpeningHighlights From c03ce0459ddb26c2955049e4c60020ae2178e29b Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 1 Jun 2026 14:41:13 +0530 Subject: [PATCH 31/35] fix(solar-panel): derive surface frame live so panels re-seat on roof changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute the panel's Y and tilt from the parent roof-segment's finished (deck + shingle) surface every render via getRoofOuterSurfaceFrameAtPoint — the same helper skylights use — instead of reading the stored position[1]/surfaceNormal snapshot. Merging the segment's live overrides means the panel re-seats and re-tilts continuously during a wall-height / pitch drag rather than floating or burying until the value commits. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nodes/src/solar-panel/renderer.tsx | 73 ++++++++++++++------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index 28ea07e06..956995440 100644 --- a/packages/nodes/src/solar-panel/renderer.tsx +++ b/packages/nodes/src/solar-panel/renderer.tsx @@ -13,19 +13,14 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + getRoofOuterSurfaceFrameAtPoint, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { MeshStandardNodeMaterial } from 'three/webgpu' -import { - buildSolarPanelGeometry, - getAnalyticalNormal, - getDefaultPanelMaterial, - getSurfaceY, - surfaceQuatFromNormal, -} from './geometry' +import { buildSolarPanelGeometry, getDefaultPanelMaterial, surfaceQuatFromNormal } from './geometry' // Module-scope scratch vectors and quaternions for composing the panel's // local orientation each render — surfaceQuat · Y(rotation) · X(tilt). @@ -47,9 +42,17 @@ const defaultFrameMaterial = new MeshStandardNodeMaterial({ }) /** - * Solar panel renderer. Reads the parent roof-segment so the panel's - * Y can fall back to the analytical surface height when the schema's - * `surfaceNormal` is absent (legacy nodes / simplified placement). + * Solar panel renderer. The panel's Y and surface tilt are derived + * live from the parent roof-segment's finished (deck + shingle) surface + * on every render via `getRoofOuterSurfaceFrameAtPoint` — the same + * authoritative helper skylights use. Deriving rather than reading the + * stored `position[1]`/`surfaceNormal` snapshot is what lets the panel + * re-seat and re-tilt automatically when the roof's wall height or + * pitch changes; a cached snapshot would leave it floating or buried. + * + * The segment's live overrides are merged too, so the panel tracks the + * surface continuously during a wall-height/pitch drag, not just after + * the value commits to the scene store. * * The surface orientation is applied as a quaternion on an inner * group computed once per render (not per frame). This matches the @@ -80,6 +83,20 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { : undefined, ) + // Merge the segment's live overrides (written by wall-height / pitch + // slider drags before they commit) so the panel re-seats and re-tilts + // in real time as the roof changes, mirroring the gutter's eave-snap. + const segmentOverrides = useLiveNodeOverrides((s) => + node.roofSegmentId + ? (s.get(node.roofSegmentId as AnyNodeId) as Partial | undefined) + : undefined, + ) + const effectiveSegment: RoofSegmentNode | undefined = segment + ? segmentOverrides + ? ({ ...segment, ...segmentOverrides } as RoofSegmentNode) + : segment + : undefined + const geometry = useMemo( () => buildSolarPanelGeometry(node), [ @@ -115,20 +132,30 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { ) }, [shading, node.panelMaterial, node.panelMaterialPreset]) - const surfaceQuat = useMemo(() => { - if (!segment) return new THREE.Quaternion() - const normal = node.surfaceNormal - ? new THREE.Vector3(...node.surfaceNormal).normalize() - : getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment) - return surfaceQuatFromNormal(normal, new THREE.Quaternion()) - }, [segment, node.surfaceNormal, node.position[0], node.position[2]]) + // Finished-surface frame (deck + shingle top) at the panel's local + // X/Z, recomputed from the live segment each render — both the Y and + // the tilt normal flow from here, so a wall-height or pitch change + // re-seats and re-orients the panel automatically. `segmentOverrides` + // is in the deps so a live drag re-derives the frame mid-drag. + const surfaceFrame = useMemo(() => { + if (!effectiveSegment) { + return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) } + } + return getRoofOuterSurfaceFrameAtPoint( + effectiveSegment, + node.position[0] ?? 0, + node.position[2] ?? 0, + ) + }, [segment, segmentOverrides, node.position[0], node.position[2]]) + + const surfaceQuat = useMemo( + () => surfaceQuatFromNormal(surfaceFrame.normal, new THREE.Quaternion()), + [surfaceFrame.normal], + ) - if (!segment || !geometry) return null + if (!effectiveSegment || !geometry) return null - const surfaceY = - (node.position[1] ?? 0) !== 0 - ? node.position[1] - : getSurfaceY(node.position[0] ?? 0, node.position[2] ?? 0, segment) + const surfaceY = surfaceFrame.point.y const tiltRad = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0 @@ -151,7 +178,7 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { // and segment.rotation here, then the panel's segment-local offset + // composed orientation on a single registered group. return ( - + Date: Mon, 1 Jun 2026 14:41:13 +0530 Subject: [PATCH 32/35] fix(first-person): coerce imported item geometry attrs to Float32 for collider merge mergeGeometries requires every merged geometry to share the same typed-array constructor per attribute. Imported item GLBs using KHR_mesh_quantization or interleaved buffers broke the merge against Float32 wall/slab geometry, so decode each attribute into a plain non-normalized Float32 BufferAttribute before cloning into the collider world. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../first-person/build-collider-world.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index f977aab87..5661b48ad 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -56,6 +56,24 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible } +// Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a +// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every +// merged geometry to share the same typed-array constructor for matching attributes, so +// imported item GLBs using KHR_mesh_quantization or interleaved buffers must be coerced +// to Float32 to match wall/slab geometry. +function toFloat32Attribute(source: THREE.BufferAttribute | THREE.InterleavedBufferAttribute) { + const itemSize = source.itemSize + const array = new Float32Array(source.count * itemSize) + for (let i = 0; i < source.count; i++) { + const offset = i * itemSize + array[offset] = source.getX(i) + if (itemSize > 1) array[offset + 1] = source.getY(i) + if (itemSize > 2) array[offset + 2] = source.getZ(i) + if (itemSize > 3) array[offset + 3] = source.getW(i) + } + return new THREE.BufferAttribute(array, itemSize) +} + function cloneWorldGeometry(mesh: THREE.Mesh) { const sourceGeometry = mesh.geometry const position = sourceGeometry.getAttribute('position') @@ -65,11 +83,11 @@ function cloneWorldGeometry(mesh: THREE.Mesh) { ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() const cleanGeometry = new THREE.BufferGeometry() - cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) + cleanGeometry.setAttribute('position', toFloat32Attribute(workingGeometry.getAttribute('position'))) const normal = workingGeometry.getAttribute('normal') if (normal) { - cleanGeometry.setAttribute('normal', normal.clone()) + cleanGeometry.setAttribute('normal', toFloat32Attribute(normal)) } else { cleanGeometry.computeVertexNormals() } From e3266a632c18db6ee2edf0751f301eaae7c50cde Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 1 Jun 2026 14:41:13 +0530 Subject: [PATCH 33/35] fix(editor): lower camera minimum zoom distance to 6m Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/src/components/editor/custom-camera-controls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 115f9e4fb..6d785b82c 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -449,7 +449,7 @@ export const CustomCameraControls = () => { makeDefault maxDistance={100} maxPolarAngle={maxPolarAngle} - minDistance={10} + minDistance={6} minPolarAngle={0} mouseButtons={mouseButtons} onRest={onRest} From c06810961e43478586074def77c2d9f591753284 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 1 Jun 2026 14:41:13 +0530 Subject: [PATCH 34/35] chore(mcp): bump @pascal-app/mcp to 0.3.0 in lockfile Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index a3989b630..86a7bc1f1 100644 --- a/bun.lock +++ b/bun.lock @@ -211,7 +211,7 @@ }, "packages/mcp": { "name": "@pascal-app/mcp", - "version": "0.2.0", + "version": "0.3.0", "bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js", }, From dbb8a9c4fde8c4148b50ef4ec68d615c34b5e411 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 2 Jun 2026 00:40:32 +0530 Subject: [PATCH 35/35] refactor(nodes): registry-own move dispatch + hoist shared roof helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the architecture-review findings on the gutter/downspout branch: - Move dispatch: port the bespoke roof/roof-segment/stair/stair-segment movers (MoveRoofTool) and the building mover (MoveBuildingContent) into @pascal-app/nodes (shared/move-roof-tool.tsx, building/move-tool.tsx) and declare them via `affordanceTools.move`. Delete both hardcoded dispatch lists — `LEGACY_MOVABLE_KINDS` in floating-action-menu and the roof/stair/ building arms of MoveTool. `onMove` is now purely `isRegistryMovable`. Editor internals the movers need are exported from @pascal-app/editor (adds clearRoofDuplicateMetadata); sfxEmitter.emit -> triggerSFX. elevator keeps its existing capabilities.movable path (its legacy arm is the lone remaining one, now documented). - Cross-kind imports: hoist resolveRoofSegmentHit (roof/segment-hit.ts) and the roof-surface normal math (getSurfaceY/getAnalyticalNormal/ surfaceQuatFromNormal, formerly in solar-panel/geometry.ts) into packages/nodes/src/shared/, so the 8 roof accessories + skylight/box-vent stop reaching into sibling kind folders. roof/index and solar-panel/index re-export from shared so public surfaces are unchanged. - Inspector: make ParametricInspector action `enabledIf` reactive by subscribing to its boolean result (ParamActionButton), matching the existing FieldRenderer/visibleIf pattern. Type-checked (tsc) and linted (biome) across editor + nodes. Move behaviour is preserved by construction but not yet runtime-verified in the editor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/floating-action-menu.tsx | 33 +--- .../src/components/tools/item/move-tool.tsx | 34 +--- .../ui/panels/parametric-inspector.tsx | 52 +++--- packages/editor/src/index.tsx | 2 +- packages/nodes/src/box-vent/move-tool.tsx | 4 +- packages/nodes/src/box-vent/renderer.tsx | 2 +- packages/nodes/src/box-vent/tool.tsx | 4 +- packages/nodes/src/building/definition.ts | 7 + .../src/building/move-tool.tsx} | 13 +- packages/nodes/src/chimney/move-tool.tsx | 2 +- packages/nodes/src/chimney/tool.tsx | 2 +- .../nodes/src/dormer/use-dormer-placement.ts | 2 +- packages/nodes/src/gutter/move-tool.tsx | 2 +- packages/nodes/src/gutter/tool.tsx | 2 +- packages/nodes/src/ridge-vent/move-tool.tsx | 2 +- packages/nodes/src/ridge-vent/tool.tsx | 2 +- packages/nodes/src/roof-segment/definition.ts | 11 +- packages/nodes/src/roof/definition.ts | 8 + packages/nodes/src/roof/index.ts | 2 +- .../src/shared}/move-roof-tool.tsx | 22 ++- .../roof-segment-hit.ts} | 2 +- packages/nodes/src/shared/roof-surface.ts | 158 ++++++++++++++++++ packages/nodes/src/skylight/geometry.ts | 2 +- packages/nodes/src/skylight/move-tool.tsx | 11 +- packages/nodes/src/skylight/renderer.tsx | 72 ++++---- packages/nodes/src/skylight/tool.tsx | 4 +- packages/nodes/src/solar-panel/geometry.ts | 157 +---------------- packages/nodes/src/solar-panel/index.ts | 10 +- packages/nodes/src/solar-panel/move-tool.tsx | 4 +- packages/nodes/src/solar-panel/renderer.tsx | 3 +- packages/nodes/src/solar-panel/tool.tsx | 4 +- .../nodes/src/stair-segment/definition.ts | 7 + packages/nodes/src/stair/definition.ts | 18 +- 33 files changed, 335 insertions(+), 325 deletions(-) rename packages/{editor/src/components/tools/building/move-building-tool.tsx => nodes/src/building/move-tool.tsx} (95%) rename packages/{editor/src/components/tools/roof => nodes/src/shared}/move-roof-tool.tsx (96%) rename packages/nodes/src/{roof/segment-hit.ts => shared/roof-segment-hit.ts} (98%) create mode 100644 packages/nodes/src/shared/roof-surface.ts diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index dade84f1b..d1de7aaf5 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -57,20 +57,6 @@ const ALLOWED_TYPES = [ ] const DELETE_ONLY_TYPES: string[] = [] const HOLE_TYPES = ['slab', 'ceiling'] -// Kinds whose move tool is wired in the legacy tail of `MoveTool` -// (tools/item/move-tool.tsx) rather than through `affordanceTools.move`. -// `isRegistryMovable` only sees the registry-native paths, so it returns -// false for these even though `MoveTool` knows how to drive them — keep -// this list in sync with the tail of that dispatcher. Drop a kind once -// it migrates to a kind-owned affordance. -const LEGACY_MOVABLE_KINDS = new Set([ - 'roof', - 'roof-segment', - 'stair', - 'stair-segment', - 'building', - 'elevator', -]) // Menu scales with camera zoom so it feels anchored to the object, but is // clamped on both ends so it stays readable when zoomed way out and doesn't @@ -120,9 +106,7 @@ const MENU_Y_OFFSETS: Record = { function getMenuYOffset(node: AnyNode | null): number { if (!node) return MENU_Y_OFFSET_DEFAULT + EXTRA_MENU_LIFT if (node.type === 'stair-segment') { - return ( - (MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT - ) + return (MENU_Y_OFFSETS[`stair-${node.segmentType}`] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT } return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT } @@ -261,7 +245,6 @@ export function FloatingActionMenu() { // in-world chrome (height-resize arrows, measurement labels). groupRef.current.position.set(center.x, box.max.y + getMenuYOffset(node), center.z) } - } }) @@ -534,17 +517,11 @@ export function FloatingActionMenu() { : undefined } onMove={ - // Registry-driven for kinds that declare + // Fully registry-driven: any kind that declares // `capabilities.movable`, a `floorplanMoveTarget`, or a - // 3D `affordanceTools.move` mover. The legacy tail of - // `MoveTool` (see `tools/item/move-tool.tsx`) also handles - // a small set of kinds that haven't been ported to a - // kind-owned affordance yet — list them here so their - // Move buttons render too. Drop a kind from the set when - // its bespoke mover migrates onto `affordanceTools.move`. - node && (isRegistryMovable(node.type) || LEGACY_MOVABLE_KINDS.has(node.type)) - ? handleMove - : undefined + // 3D `affordanceTools.move` mover gets the Move button. + // Adding a new movable kind never touches this file. + node && isRegistryMovable(node.type) ? handleMove : undefined } onDelete={handleDelete} onDuplicate={ diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index d7c86be96..5ba49e38a 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,20 +1,9 @@ -import type { - AnyNodeId, - BuildingNode, - ElevatorNode, - RoofNode, - RoofSegmentNode, - SpawnNode, - StairNode, - StairSegmentNode, -} from '@pascal-app/core' +import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core' import { nodeRegistry } from '@pascal-app/core' import { Suspense } from 'react' import useEditor from '../../../store/use-editor' -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' import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' /** @@ -23,14 +12,13 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' * 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that * declare `capabilities.movable` (shelf, spawn, item-with-floor-attach, * …). - * 2. `def.affordanceTools.move` — kind-owned move component - * (slab / ceiling / wall / fence / column / item / door / window). - * Lazy-loaded via `getRegistryAffordanceTool`. - * 3. The narrow set of kinds that still have legacy movers because no - * registry equivalent has been written yet (building / elevator / - * roof / stair). Each of these has bespoke move semantics that - * don't fit the generic mover and are not yet ported to a - * kind-owned affordance. + * 2. `def.affordanceTools.move` — kind-owned move component, lazy-loaded + * via `getRegistryAffordanceTool`. Covers both generic movers + * (slab / ceiling / wall / fence / column / item / door / window) and + * the bespoke roof / roof-segment / stair / stair-segment / building + * movers ported into `@pascal-app/nodes`. + * 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft + * mover hasn't been ported to a kind-owned affordance yet. */ export const MoveTool: React.FC<{ onNodeMoved?: (nodeId: AnyNodeId) => void @@ -54,13 +42,7 @@ export const MoveTool: React.FC<{ ) } - if (movingNode.type === 'building') - return if (movingNode.type === 'elevator') return - if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') - return - if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') - return return null } diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index 3e13a70e6..0d273d6d3 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type IconRef, nodeRegistry, + type ParamAction, type ParamField, useScene, } from '@pascal-app/core' @@ -129,26 +130,9 @@ export function ParametricInspector() { {canMove && ( } label="Move" onClick={handleMove} /> )} - {parametrics.actions?.map((action, i) => { - const node = useScene.getState().nodes[selectedId] - const disabled = action.enabledIf && node ? !action.enabledIf(node) : false - return ( - - ) : undefined - } - key={`paramaction-${i}`} - label={action.label} - onClick={() => { - const live = useScene.getState().nodes[selectedId] - if (live) action.onClick(live) - }} - /> - ) - })} + {parametrics.actions?.map((action, i) => ( + + ))} {canDelete && ( ; nodeId: AnyNodeId }) { + const disabled = useScene((s) => { + if (!action.enabledIf) return false + const n = s.nodes[nodeId] + return n ? !action.enabledIf(n as AnyNode) : false + }) + return ( + + ) : undefined + } + label={action.label} + onClick={() => { + const live = useScene.getState().nodes[nodeId] + if (live) action.onClick(live as AnyNode) + }} + /> + ) +} + function renderIcon(ref: IconRef | undefined): React.ReactNode | undefined { if (!ref) return undefined if (ref.kind === 'url') { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5571ce581..ebc34448b 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -170,7 +170,7 @@ export { getActivePaintMaterialLabel, hasActivePaintMaterial, } from './lib/material-paint' -export { duplicateRoofSubtree } from './lib/roof-duplication' +export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { triggerSFX } from './lib/sfx-bus' diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index abc315985..312e36438 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -14,8 +14,8 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import BoxVentPreview from './preview' /** diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index 56e424451..98be50e59 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -18,7 +18,7 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { buildBoxVentGeometry } from './geometry' const defaultMaterial = new THREE.MeshStandardMaterial({ diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index cb004c14d..814434db0 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -13,8 +13,8 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { boxVentDefinition } from './definition' import BoxVentPreview from './preview' diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts index 4f050ce38..b90081f20 100644 --- a/packages/nodes/src/building/definition.ts +++ b/packages/nodes/src/building/definition.ts @@ -30,6 +30,13 @@ export const buildingDefinition: NodeDefinition = { floorplanLevelContainer: true, }, + // Building-wide drag (whole-building translate + R/T rotation). Routed + // through `MoveTool`'s registry-affordance lookup rather than a + // hardcoded dispatcher arm. + affordanceTools: { + move: () => import('./move-tool'), + }, + parametrics: buildingParametrics, renderer: { diff --git a/packages/editor/src/components/tools/building/move-building-tool.tsx b/packages/nodes/src/building/move-tool.tsx similarity index 95% rename from packages/editor/src/components/tools/building/move-building-tool.tsx rename to packages/nodes/src/building/move-tool.tsx index 0917abb28..d3cc99b35 100644 --- a/packages/editor/src/components/tools/building/move-building-tool.tsx +++ b/packages/nodes/src/building/move-tool.tsx @@ -8,13 +8,10 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' +import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { CursorSphere } from '../shared/cursor-sphere' const Y_AXIS = new THREE.Vector3(0, 1, 0) @@ -96,7 +93,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { if (rotationDelta !== 0) { event.preventDefault() - sfxEmitter.emit('sfx:item-rotate') + triggerSFX('sfx:item-rotate') pendingRotationRef.current += rotationDelta const mesh = sceneRegistry.nodes.get(nodeId) @@ -124,7 +121,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) ) { - sfxEmitter.emit('sfx:grid-snap') + triggerSFX('sfx:grid-snap') } previousGridPosRef.current = [gridX, gridZ] @@ -154,7 +151,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { }) useScene.temporal.getState().pause() - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] }) exitMoveMode() event.nativeEvent?.stopPropagation?.() @@ -207,3 +204,5 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) { ) } + +export default MoveBuildingContent diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index 113b90197..ca57e0a9c 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -15,7 +15,7 @@ import { triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import ChimneyPreview from './preview' const tmpMatrix = new THREE.Matrix4() diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index d9b582aae..522caf28a 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -14,7 +14,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { chimneyDefinition } from './definition' import ChimneyPreview from './preview' diff --git a/packages/nodes/src/dormer/use-dormer-placement.ts b/packages/nodes/src/dormer/use-dormer-placement.ts index e524342dd..97b31012a 100644 --- a/packages/nodes/src/dormer/use-dormer-placement.ts +++ b/packages/nodes/src/dormer/use-dormer-placement.ts @@ -10,7 +10,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry' const tmpMatrix = new THREE.Matrix4() diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx index 8b2a91343..a2813fc4c 100644 --- a/packages/nodes/src/gutter/move-tool.tsx +++ b/packages/nodes/src/gutter/move-tool.tsx @@ -12,7 +12,7 @@ import { } from '@pascal-app/core' import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor' import { useCallback, useEffect, useState } from 'react' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { type EaveSnap, resolveEaveSnap } from './eave-snap' import GutterPreview from './preview' diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index c72046395..f2066e090 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -11,7 +11,7 @@ import { import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { gutterDefinition } from './definition' import { type EaveSnap, resolveEaveSnap } from './eave-snap' import GutterPreview from './preview' diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index 2cc215d30..f7890d6f8 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -14,7 +14,7 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import RidgeVentPreview from './preview' /** diff --git a/packages/nodes/src/ridge-vent/tool.tsx b/packages/nodes/src/ridge-vent/tool.tsx index 878a7b876..a1bf7d383 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -13,7 +13,7 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { ridgeVentDefinition } from './definition' import RidgeVentPreview from './preview' diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index bb5a959b8..976466bf2 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -3,15 +3,15 @@ import { getPitchFromActiveRoofHeight, type HandleDescriptor, type NodeDefinition, - type RoofSegmentNode as RoofSegmentNodeType, RoofSegmentNode as RoofSegmentNodeSchema, + type RoofSegmentNode as RoofSegmentNodeType, } from '@pascal-app/core' +import { buildRoofSegmentFloorplan } from './floorplan' import { roofSegmentMoveTarget, roofSegmentResizeAffordance, roofSegmentRotateAffordance, } from './floorplan-affordances' -import { buildRoofSegmentFloorplan } from './floorplan' import { roofSegmentParametrics } from './parametrics' import { RoofSegmentNode } from './schema' @@ -281,6 +281,13 @@ export const roofSegmentDefinition: NodeDefinition = { deletable: true, }, + // Bespoke move shared with roof / stair / stair-segment via + // `shared/move-roof-tool` — routed through `MoveTool`'s registry- + // affordance lookup rather than a hardcoded dispatcher arm. + affordanceTools: { + move: () => import('../shared/move-roof-tool'), + }, + parametrics: roofSegmentParametrics, handles: roofSegmentHandles, diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index 0bb77211f..8f3663b4e 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -34,6 +34,14 @@ export const roofDefinition: NodeDefinition = { deletable: true, }, + // Bespoke free-floating move (drag-to-place with R/T rotation and + // wall/fence snapping). Routes through `MoveTool`'s registry-affordance + // lookup — no hardcoded dispatcher arm. Shared with roof-segment / stair + // / stair-segment via `shared/move-roof-tool`. + affordanceTools: { + move: () => import('../shared/move-roof-tool'), + }, + parametrics: roofParametrics, floorplan: buildRoofFloorplan, diff --git a/packages/nodes/src/roof/index.ts b/packages/nodes/src/roof/index.ts index de387ee09..79202f432 100644 --- a/packages/nodes/src/roof/index.ts +++ b/packages/nodes/src/roof/index.ts @@ -1,2 +1,2 @@ +export { type RoofSegmentHit, resolveRoofSegmentHit } from '../shared/roof-segment-hit' export { roofDefinition } from './definition' -export { type RoofSegmentHit, resolveRoofSegmentHit } from './segment-hit' diff --git a/packages/editor/src/components/tools/roof/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx similarity index 96% rename from packages/editor/src/components/tools/roof/move-roof-tool.tsx rename to packages/nodes/src/shared/move-roof-tool.tsx index be05f382a..5b4c2ccc2 100644 --- a/packages/editor/src/components/tools/roof/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -13,15 +13,17 @@ import { useScene, type WallNode, } from '@pascal-app/core' +import { + CursorSphere, + clearRoofDuplicateMetadata, + snapFenceDraftPoint, + triggerSFX, + useEditor, + type WallPlanPoint, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' -import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication' -import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' -import { snapFenceDraftPoint } from '../fence/fence-drafting' -import { CursorSphere } from '../shared/cursor-sphere' -import type { WallPlanPoint } from '../wall/wall-drafting' export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode @@ -217,7 +219,7 @@ export const MoveRoofTool: React.FC<{ previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) ) { - sfxEmitter.emit('sfx:grid-snap') + triggerSFX('sfx:grid-snap') } previousGridPosRef.current = [gridX, gridZ] @@ -274,7 +276,7 @@ export const MoveRoofTool: React.FC<{ useScene.temporal.getState().pause() - sfxEmitter.emit('sfx:item-place') + triggerSFX('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [movingNode.id] }) useLiveTransforms.getState().clear(movingNode.id) exitMoveMode() @@ -309,7 +311,7 @@ export const MoveRoofTool: React.FC<{ if (rotationDelta !== 0) { event.preventDefault() - sfxEmitter.emit('sfx:item-rotate') + triggerSFX('sfx:item-rotate') pendingRotation += rotationDelta @@ -371,3 +373,5 @@ export const MoveRoofTool: React.FC<{ ) } + +export default MoveRoofTool diff --git a/packages/nodes/src/roof/segment-hit.ts b/packages/nodes/src/shared/roof-segment-hit.ts similarity index 98% rename from packages/nodes/src/roof/segment-hit.ts rename to packages/nodes/src/shared/roof-segment-hit.ts index 1dada83c0..82b41790e 100644 --- a/packages/nodes/src/roof/segment-hit.ts +++ b/packages/nodes/src/shared/roof-segment-hit.ts @@ -19,7 +19,7 @@ export type RoofSegmentHit = { /** * Analytical surface Y for `seg` at segment-local (lx, lz). Mirrors - * the per-roof-type slope math in `solar-panel/geometry.ts` so the + * the per-roof-type slope math in `shared/roof-surface.ts` so the * disambiguator below stays free of cross-kind imports. Returns the * roof's local surface height; the value is only used to compare * candidates, never written to the scene. diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts new file mode 100644 index 000000000..7e539308d --- /dev/null +++ b/packages/nodes/src/shared/roof-surface.ts @@ -0,0 +1,158 @@ +import { + getActiveRoofHeight, + getSegmentSlopeFrame, + ROOF_SHAPE_DEFAULTS, + type RoofSegmentNode, +} from '@pascal-app/core' +import * as THREE from 'three' + +// ─── Roof-surface helpers ──────────────────────────────────────────── +// Analytical slope geometry for a roof segment, shared by every roof +// accessory that seats itself on the slope (solar-panel, skylight, +// box-vent). Lives here rather than inside any one kind's folder so the +// accessories don't reach across into a sibling kind for it. + +export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number { + const { roofType, wallHeight, depth, width } = seg + const rh = getActiveRoofHeight(seg) + const peakY = wallHeight + rh + if (rh === 0) return wallHeight + + if (roofType === 'gable') { + const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 + return peakY - t * rh + } + if (roofType === 'shed') { + const t = (lz + depth / 2) / (depth || 1) + return peakY - t * rh + } + if (roofType === 'hip') { + const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0 + const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 + return peakY - Math.max(fx, fz) * rh + } + const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 + return peakY - t * rh +} + +// Outward normal for a roof surface tilting at angle θ in the horizontal +// direction (dx, dz). Derivation: the surface tangent vectors are the +// ridge axis (perpendicular to the fall line, horizontal) and the +// down-slope direction (cos θ horizontal + −sin θ vertical). Crossing +// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz), +// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised. +function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 { + return new THREE.Vector3(dx * tan, 1, dz * tan).normalize() +} + +export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 { + const { roofType, depth, width } = seg + const slope = getSegmentSlopeFrame(seg) + if (slope.activeRh === 0 || slope.tanTheta === 0) { + return new THREE.Vector3(0, 1, 0) + } + const primaryTan = slope.tanTheta + const halfW = width / 2 + const halfD = depth / 2 + + // Ridge runs along X — slope falls in ±Z. Gambrel shares the gable + // dispatch (its kink-to-eave/lower tier is the primary slope frame). + if (roofType === 'gable' || roofType === 'gambrel') { + if (roofType === 'gambrel') { + // Tier-aware: the upper (shallower) face spans |z| < mz; the + // lower (steep) face spans mz < |z| ≤ halfD. Using primaryTan on + // the upper tier would tilt the ghost too steeply near the ridge. + const lowerWidthRatio = + seg.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio + const lowerHeightRatio = + seg.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio + const mz = halfD * lowerWidthRatio + if (Math.abs(lz) <= mz) { + const upperRise = slope.activeRh * (1 - lowerHeightRatio) + const upperRun = mz + const upperTan = upperRun > 0 ? upperRise / upperRun : 0 + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan) + } + } + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) + } + + // Single slope falling toward +Z (ridge at -Z, eave at +Z). + if (roofType === 'shed') { + return buildSlopeNormal(0, 1, primaryTan) + } + + // 4-sided slopes: the dominant axis chooses which face the point sits + // on. Hip is uniform across all four faces. Mansard has a steep outer + // band (primaryTan) and a shallow top inside the waist. Dutch has hip + // ends and gable sides — both share the same primaryTan from the + // slope frame, so directional dispatch is enough. + if (roofType === 'hip') { + const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 + const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 + if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) + } + + if (roofType === 'mansard') { + const widthRatio = seg.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio + const heightRatio = seg.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio + const inset = Math.min(width, depth) * widthRatio + const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 + const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 + const onZ = fz >= fx + const inSteepBand = onZ ? Math.abs(lz) > halfD - inset : Math.abs(lx) > halfW - inset + + let tan = primaryTan + if (!inSteepBand) { + // Top hip (shallow) above the waist — rises from the waist + // rectangle at fraction `heightRatio` of activeRh up to the peak. + const topRise = slope.activeRh * (1 - heightRatio) + const topRun = Math.max(0, Math.min(halfW, halfD) - inset) + tan = topRun > 0 ? topRise / topRun : 0 + } + if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan) + } + + if (roofType === 'dutch') { + // Hip on the short-axis ends, gable on the long-axis sides. Both + // share the primary pitch on their primary (eave-band) face, so the + // approximation collapses to "pick the dominant axis." + const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 + const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 + if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) + } + + return new THREE.Vector3(0, 1, 0) +} + +// ─── Quaternion helper ─────────────────────────────────────────────── +// Given a normal in the panel's parent frame, build a rotation that +// aligns the panel's local +Y to that normal. Lifted out so the +// renderer and the placement preview share one source of truth. + +export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaternion) { + // Build `right` by projecting world +X onto the surface plane instead of + // using `up × normal`. The cross-product version flips sign when the + // normal's Z component flips (e.g. the two slopes of a gable roof), so + // the resulting basis has its +X axis reversed on one slope — which + // makes hosted children's local +X point in opposite world directions + // depending on which slope they sit on, and registry chevrons end up + // anchored to the wrong edge. Projecting +X keeps the basis stable + // across slope-flips that share the same X axis. + const wx = new THREE.Vector3(1, 0, 0) + const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal))) + if (right.lengthSq() < 1e-6) { + // Degenerate: normal is parallel to ±X. Fall back to +Z so the basis + // is still well-defined; this is the wall-like edge case (vertical + // surface facing along X) where any in-plane convention is OK. + right.set(0, 0, 1) + } else { + right.normalize() + } + const forward = new THREE.Vector3().crossVectors(right, normal).normalize() + const m = new THREE.Matrix4().makeBasis(right, normal, forward) + return out.setFromRotationMatrix(m) +} diff --git a/packages/nodes/src/skylight/geometry.ts b/packages/nodes/src/skylight/geometry.ts index a4e3b802b..72dc04b97 100644 --- a/packages/nodes/src/skylight/geometry.ts +++ b/packages/nodes/src/skylight/geometry.ts @@ -1,5 +1,5 @@ import * as THREE from 'three' -import { getAnalyticalNormal, getSurfaceY } from '../solar-panel/geometry' +import { getAnalyticalNormal, getSurfaceY } from '../shared/roof-surface' export { getAnalyticalNormal, getSurfaceY } diff --git a/packages/nodes/src/skylight/move-tool.tsx b/packages/nodes/src/skylight/move-tool.tsx index dc73757c0..07a1e9939 100644 --- a/packages/nodes/src/skylight/move-tool.tsx +++ b/packages/nodes/src/skylight/move-tool.tsx @@ -14,8 +14,8 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import SkylightPreview from './preview' function resolveSegmentFromWorldPoint( @@ -103,7 +103,12 @@ export default function MoveSkylightTool({ node }: { node: SkylightNode }) { // same via its `if (!hit) return` guard. const updateFromHit = (event: RoofEvent) => { const roof = event.node as RoofNode - const hit = resolveRoofSegmentHit(roof, event.position[0], event.position[1], event.position[2]) + const hit = resolveRoofSegmentHit( + roof, + event.position[0], + event.position[1], + event.position[2], + ) if (!hit) { setHasHit(false) return false diff --git a/packages/nodes/src/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index 25b7f5683..47bc6dd51 100644 --- a/packages/nodes/src/skylight/renderer.tsx +++ b/packages/nodes/src/skylight/renderer.tsx @@ -22,7 +22,7 @@ import { } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' -import { surfaceQuatFromNormal } from '../solar-panel/geometry' +import { surfaceQuatFromNormal } from '../shared/roof-surface' import { buildFrameGeometry } from './frame-csg' import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry' @@ -707,41 +707,41 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { ref={ref} > - {activeType === 'lantern' && ( - - )} - {activeType === 'sliding' && ( - - )} - {activeType === 'opening' && ( - - )} + castShadow + geometry={frameGeo} + material={frameMaterial} + name="skylight-surface" + receiveShadow + /> + {activeType === 'lantern' && ( + + )} + {activeType === 'sliding' && ( + + )} + {activeType === 'opening' && ( + + )} {(activeType === 'flat' || activeType === 'walk-on') && ( 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - t * rh - } - if (roofType === 'shed') { - const t = (lz + depth / 2) / (depth || 1) - return peakY - t * rh - } - if (roofType === 'hip') { - const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0 - const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - Math.max(fx, fz) * rh - } - const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0 - return peakY - t * rh -} - -// Outward normal for a roof surface tilting at angle θ in the horizontal -// direction (dx, dz). Derivation: the surface tangent vectors are the -// ridge axis (perpendicular to the fall line, horizontal) and the -// down-slope direction (cos θ horizontal + −sin θ vertical). Crossing -// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz), -// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised. -function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 { - return new THREE.Vector3(dx * tan, 1, dz * tan).normalize() -} - -export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 { - const { roofType, depth, width } = seg - const slope = getSegmentSlopeFrame(seg) - if (slope.activeRh === 0 || slope.tanTheta === 0) { - return new THREE.Vector3(0, 1, 0) - } - const primaryTan = slope.tanTheta - const halfW = width / 2 - const halfD = depth / 2 - - // Ridge runs along X — slope falls in ±Z. Gambrel shares the gable - // dispatch (its kink-to-eave/lower tier is the primary slope frame). - if (roofType === 'gable' || roofType === 'gambrel') { - if (roofType === 'gambrel') { - // Tier-aware: the upper (shallower) face spans |z| < mz; the - // lower (steep) face spans mz < |z| ≤ halfD. Using primaryTan on - // the upper tier would tilt the ghost too steeply near the ridge. - const lowerWidthRatio = - seg.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio - const lowerHeightRatio = - seg.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio - const mz = halfD * lowerWidthRatio - if (Math.abs(lz) <= mz) { - const upperRise = slope.activeRh * (1 - lowerHeightRatio) - const upperRun = mz - const upperTan = upperRun > 0 ? upperRise / upperRun : 0 - return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan) - } - } - return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - } - - // Single slope falling toward +Z (ridge at -Z, eave at +Z). - if (roofType === 'shed') { - return buildSlopeNormal(0, 1, primaryTan) - } - - // 4-sided slopes: the dominant axis chooses which face the point sits - // on. Hip is uniform across all four faces. Mansard has a steep outer - // band (primaryTan) and a shallow top inside the waist. Dutch has hip - // ends and gable sides — both share the same primaryTan from the - // slope frame, so directional dispatch is enough. - if (roofType === 'hip') { - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) - } - - if (roofType === 'mansard') { - const widthRatio = seg.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio - const heightRatio = seg.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio - const inset = Math.min(width, depth) * widthRatio - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - const onZ = fz >= fx - const inSteepBand = onZ ? Math.abs(lz) > halfD - inset : Math.abs(lx) > halfW - inset - - let tan = primaryTan - if (!inSteepBand) { - // Top hip (shallow) above the waist — rises from the waist - // rectangle at fraction `heightRatio` of activeRh up to the peak. - const topRise = slope.activeRh * (1 - heightRatio) - const topRun = Math.max(0, Math.min(halfW, halfD) - inset) - tan = topRun > 0 ? topRise / topRun : 0 - } - if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan) - } - - if (roofType === 'dutch') { - // Hip on the short-axis ends, gable on the long-axis sides. Both - // share the primary pitch on their primary (eave-band) face, so the - // approximation collapses to "pick the dominant axis." - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) - } - - return new THREE.Vector3(0, 1, 0) -} - -// ─── Quaternion helper ─────────────────────────────────────────────── -// Given a normal in the panel's parent frame, build a rotation that -// aligns the panel's local +Y to that normal. Lifted out so the -// renderer and the placement preview share one source of truth. - -export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaternion) { - // Build `right` by projecting world +X onto the surface plane instead of - // using `up × normal`. The cross-product version flips sign when the - // normal's Z component flips (e.g. the two slopes of a gable roof), so - // the resulting basis has its +X axis reversed on one slope — which - // makes hosted children's local +X point in opposite world directions - // depending on which slope they sit on, and registry chevrons end up - // anchored to the wrong edge. Projecting +X keeps the basis stable - // across slope-flips that share the same X axis. - const wx = new THREE.Vector3(1, 0, 0) - const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal))) - if (right.lengthSq() < 1e-6) { - // Degenerate: normal is parallel to ±X. Fall back to +Z so the basis - // is still well-defined; this is the wall-like edge case (vertical - // surface facing along X) where any in-plane convention is OK. - right.set(0, 0, 1) - } else { - right.normalize() - } - const forward = new THREE.Vector3().crossVectors(right, normal).normalize() - const m = new THREE.Matrix4().makeBasis(right, normal, forward) - return out.setFromRotationMatrix(m) -} - // ─── Layout helpers (used by the inspector / placement tool) ───────── function getSlopeDepthBounds( diff --git a/packages/nodes/src/solar-panel/index.ts b/packages/nodes/src/solar-panel/index.ts index d446166c4..c0f9399f2 100644 --- a/packages/nodes/src/solar-panel/index.ts +++ b/packages/nodes/src/solar-panel/index.ts @@ -1,10 +1,4 @@ +export { getAnalyticalNormal, getSurfaceY, surfaceQuatFromNormal } from '../shared/roof-surface' export { solarPanelDefinition } from './definition' -export { - buildSolarPanelGeometry, - computeAutoFit, - flippedPanelDims, - getAnalyticalNormal, - getSurfaceY, - surfaceQuatFromNormal, -} from './geometry' +export { buildSolarPanelGeometry, computeAutoFit, flippedPanelDims } from './geometry' export { SolarPanelNode } from './schema' diff --git a/packages/nodes/src/solar-panel/move-tool.tsx b/packages/nodes/src/solar-panel/move-tool.tsx index 1394cde7b..b181c6106 100644 --- a/packages/nodes/src/solar-panel/move-tool.tsx +++ b/packages/nodes/src/solar-panel/move-tool.tsx @@ -14,8 +14,8 @@ import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pa import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' -import { getAnalyticalNormal, surfaceQuatFromNormal } from './geometry' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' // MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding // fragment stage output / writeMask not zero" error that fires when diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index 956995440..663a5608f 100644 --- a/packages/nodes/src/solar-panel/renderer.tsx +++ b/packages/nodes/src/solar-panel/renderer.tsx @@ -20,7 +20,8 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { MeshStandardNodeMaterial } from 'three/webgpu' -import { buildSolarPanelGeometry, getDefaultPanelMaterial, surfaceQuatFromNormal } from './geometry' +import { surfaceQuatFromNormal } from '../shared/roof-surface' +import { buildSolarPanelGeometry, getDefaultPanelMaterial } from './geometry' // Module-scope scratch vectors and quaternions for composing the panel's // local orientation each render — surfaceQuat · Y(rotation) · X(tilt). diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index afed1a46a..9289e230b 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -13,9 +13,9 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { resolveRoofSegmentHit } from '../roof/segment-hit' +import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' import { solarPanelDefinition } from './definition' -import { getAnalyticalNormal, surfaceQuatFromNormal } from './geometry' import SolarPanelPreview from './preview' const worldPoint = new THREE.Vector3() diff --git a/packages/nodes/src/stair-segment/definition.ts b/packages/nodes/src/stair-segment/definition.ts index 125788855..63333a619 100644 --- a/packages/nodes/src/stair-segment/definition.ts +++ b/packages/nodes/src/stair-segment/definition.ts @@ -122,6 +122,13 @@ export const stairSegmentDefinition: NodeDefinition = { deletable: true, }, + // Bespoke move shared with roof / roof-segment / stair via + // `shared/move-roof-tool` — routed through `MoveTool`'s registry- + // affordance lookup rather than a hardcoded dispatcher arm. + affordanceTools: { + move: () => import('../shared/move-roof-tool'), + }, + parametrics: stairSegmentParametrics, handles: stairSegmentHandles, diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index 6f53cbb37..d5ff25a8f 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -1,8 +1,8 @@ import { type HandleDescriptor, type NodeDefinition, - type StairNode as StairNodeType, StairNode as StairNodeSchema, + type StairNode as StairNodeType, } from '@pascal-app/core' const MIN_CURVED_RISE = 0.3 @@ -223,11 +223,7 @@ function stairRotateGizmoPosition(n: StairNodeType): [number, number, number] { } const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH) const yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2 - return [ - width / 2 + STAIR_ROTATE_CORNER_OFFSET, - yMid, - -STAIR_ROTATE_CORNER_OFFSET, - ] + return [width / 2 + STAIR_ROTATE_CORNER_OFFSET, yMid, -STAIR_ROTATE_CORNER_OFFSET] } function stairRotateHandle(): HandleDescriptor { @@ -288,6 +284,8 @@ function stairHandles(node: StairNodeType): HandleDescriptor[] { handles.push(stairRotateHandle()) return handles } + +import { buildStairFloorplan } from './floorplan' import { curvedStairInnerRadiusAffordance, curvedStairSweepAffordance, @@ -296,7 +294,6 @@ import { segmentWidthAffordance, stairRotateAffordance, } from './floorplan-affordances' -import { buildStairFloorplan } from './floorplan' import { stairFloorplanMoveTarget } from './floorplan-move' import { stairParametrics } from './parametrics' import { StairNode } from './schema' @@ -325,6 +322,13 @@ export const stairDefinition: NodeDefinition = { deletable: true, }, + // Bespoke move shared with roof / roof-segment / stair-segment via + // `shared/move-roof-tool` — routed through `MoveTool`'s registry- + // affordance lookup rather than a hardcoded dispatcher arm. + affordanceTools: { + move: () => import('../shared/move-roof-tool'), + }, + parametrics: stairParametrics, handles: stairHandles,