diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 99fb55d98..f80d65a8e 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -168,7 +168,8 @@ export function BuildTab() { const ductContext = mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting') const pipeContext = - mode === 'build' && (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting') + mode === 'build' && + (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting' || activeTool === 'pipe-trap') const liquidLineContext = mode === 'build' && activeTool === 'liquid-line' const isMepItemActive = (item: MepItem) => @@ -421,6 +422,30 @@ export function BuildTab() { /> Add Fitting + ) : null} diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 225f7237b..ddd2c8217 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -119,11 +119,12 @@ const levelModeLabels: Record = { solo: 'Solo', } -const wallModeOrder = ['cutaway', 'up', 'down'] as const +const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const const wallModeConfig: Record = { up: { icon: '/icons/room.webp', label: 'Full height' }, cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' }, down: { icon: '/icons/walllow.webp', label: 'Low' }, + translucent: { icon: '/icons/wall.webp', label: 'Translucent' }, } const SHADING_OPTIONS = [ diff --git a/apps/ifc-converter/components/PreviewToolbar.tsx b/apps/ifc-converter/components/PreviewToolbar.tsx index 192119dd7..6ae43f521 100644 --- a/apps/ifc-converter/components/PreviewToolbar.tsx +++ b/apps/ifc-converter/components/PreviewToolbar.tsx @@ -14,7 +14,7 @@ import { Box, Grid2x2, Layers, Layers2, Maximize, ScanLine, Square } from 'lucid import { type ReactNode, useMemo } from 'react' const levelModes = ['stacked', 'solo', 'exploded', 'manual'] as const -const wallModes = ['up', 'cutaway', 'down'] as const +const wallModes = ['up', 'cutaway', 'down', 'translucent'] as const const levelLabel: Record<(typeof levelModes)[number], string> = { stacked: 'Stack', @@ -27,6 +27,7 @@ const wallLabel: Record<(typeof wallModes)[number], string> = { up: 'Full', cutaway: 'Cutaway', down: 'Down', + translucent: 'Translucent', } function cycle(list: readonly T[], current: T): T { diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index 161b25b6d..ca1ab1b93 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -4193,7 +4193,7 @@ export function getLibraryMaterialIdFromRef(materialRef?: string | null) { } export function getSceneMaterialIdFromRef(materialRef?: string | null): string | null { - if (!materialRef || !materialRef.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null + if (!materialRef?.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null return materialRef.slice(SCENE_MATERIAL_REF_PREFIX.length) } diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 5824255c9..a248b54d9 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -182,6 +182,25 @@ export type LinearResizeHandle = { * the roof shell below it. Only consulted when `shape === 'tracker'`. */ trackerBaseY?: (node: N, sceneApi: SceneApi) => number + /** + * Stand the chevron blade up into the node's facing plane instead of + * leaving it flat in the local XZ plane. For an `axis: 'x'` handle on a + * wall-mounted opening (door / window), the local XZ plane is horizontal, + * so the default blade is seen edge-on from the front — rotating it 90° + * about its pointing axis lays it in the wall face (local XY) so it reads + * face-on toward the camera. Chevron shape only; `axis: 'y'` handles are + * already stood up unconditionally so this is a no-op for them. + */ + faceNormal?: boolean + /** + * Gate this arrow behind a click-to-latch cube. When set, the arrow is + * hidden until the user clicks the {@link LatchHandle} cube declaring the + * same `group` name; clicking the cube again hides it. Lets a node keep a + * dense cluster (e.g. a dormer's window width/height arrows) collapsed + * behind a single grip until the user opts in. The latch state is local to + * the selection and resets when the node is deselected. + */ + latchGroup?: string } /** @@ -365,6 +384,25 @@ export type TranslateHandle = { portal?: HandlePortal } +/** + * Click-to-latch cube. Renders a small persistent cube at `placement` that + * toggles the visibility of every handle tagged with the matching + * {@link LinearResizeHandle.latchGroup} `group`. Clicking the cube once shows + * the group's arrows; clicking again hides them. The latch state is local to + * the current selection and resets on deselect. + * + * Mirrors the duct-fitting selection cube but driven by descriptor data so any + * node can collapse a dense arrow cluster behind one grip — e.g. a dormer's + * window width/height arrows latch behind a cube at the window center. + */ +export type LatchHandle = { + kind: 'latch' + /** The `latchGroup` name whose arrows this cube reveals / hides. */ + group: string + placement: HandlePlacement + portal?: HandlePortal +} + export type HandleDescriptor = | LinearResizeHandle | RadialResizeHandle @@ -372,6 +410,7 @@ export type HandleDescriptor = | EndpointMoveHandle | TapActionHandle | TranslateHandle + | LatchHandle /** * 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 37d4ac5e5..df489b6f4 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -9,6 +9,7 @@ export type { HandleList, HandlePlacement, HandlePortal, + LatchHandle, LinearResizeHandle, RadialResizeHandle, TapActionHandle, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index c12bbdfae..3c20cae52 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1584,6 +1584,23 @@ export type ParametricDescriptor = { * `updateNodes`. */ reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial }> + /** + * Deletion companion to `reconcile`: when a node of this kind is about + * to be removed, return patches for OTHER nodes that must follow to + * undo whatever the node imposed on its neighbours — e.g. an + * auto-inserted elbow re-extends the duct runs it trimmed back onto the + * corner it replaced. Called with the node and the live scene `nodes` + * map BEFORE the deletion lands; patches targeting nodes also being + * deleted are ignored. Applied in the same `set` as the delete so it's + * one undo step. Fires only on `deleteNodes` (user-intent deletes) — + * NOT on `applyNodeChanges`, whose deletes are internal re-routes that + * rewrite neighbours explicitly in the same batch and would fight a + * restore. + */ + onDelete?: ( + node: N, + nodes: Record, + ) => Array<{ id: AnyNodeId; data: Partial }> customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> /** * Extra buttons rendered in the inspector's Actions section diff --git a/packages/core/src/schema/nodes/dormer.ts b/packages/core/src/schema/nodes/dormer.ts index d4b772c0c..a5702caa7 100644 --- a/packages/core/src/schema/nodes/dormer.ts +++ b/packages/core/src/schema/nodes/dormer.ts @@ -91,7 +91,7 @@ export const DormerNode = BaseNode.extend({ windowCornerRadii: z .tuple([z.number(), z.number(), z.number(), z.number()]) .default(DEFAULT_CORNER_RADII), - windowSill: z.boolean().default(true), + windowSill: z.boolean().default(false), windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH), windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS), }).describe( diff --git a/packages/core/src/schema/nodes/duct-fitting.ts b/packages/core/src/schema/nodes/duct-fitting.ts index 277b02f77..1c22a6e09 100644 --- a/packages/core/src/schema/nodes/duct-fitting.ts +++ b/packages/core/src/schema/nodes/duct-fitting.ts @@ -45,7 +45,7 @@ export const DuctFittingNode = BaseNode.extend({ // matching the trunk the fitting sits in. Reducers ignore the shape. // When non-round, `diameter` carries the area-equivalent round size // (drives leg lengths + advertised ports). - shape: z.enum(['round', 'rect', 'oval']).default('round'), + shape: z.enum(['round', 'rect', 'oval']).default('rect'), // Rect / oval run-leg profile in inches (used when shape ≠ 'round'). width: z.number().min(4).max(60).default(14), height: z.number().min(3).max(40).default(8), @@ -53,13 +53,15 @@ export const DuctFittingNode = BaseNode.extend({ // rect / oval profile matching the duct drawn off the tap. When // non-round, `diameter2` carries the branch's area-equivalent round // size. A cross's two opposed branches share this one profile. - shape2: z.enum(['round', 'rect', 'oval']).default('round'), + shape2: z.enum(['round', 'rect', 'oval']).default('rect'), // Rect / oval branch profile in inches (used when shape2 ≠ 'round'). width2: z.number().min(4).max(60).default(14), height2: z.number().min(3).max(40).default(8), // Elbow turn angle in degrees. Residential sheet-metal elbows come in - // 90° and 45°; adjustable elbows cover the range between. - angle: z.number().min(15).max(90).default(90), + // 90° and 45°; adjustable elbows cover the range between. 0° is a + // straight coupling — what an elbow flattens to when its run is dragged + // into line with the fixed collar. + angle: z.number().min(0).max(90).default(90), // Tee branch angle in degrees, measured off the +X (outlet) axis: 90° // is a square straight tee, <90° a lateral whose branch sweeps // downstream toward the outlet (flow merges), >90° leans the branch @@ -72,6 +74,7 @@ export const DuctFittingNode = BaseNode.extend({ diameter2: z.number().min(2).max(48).default(6), ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'), system: z.enum(['supply', 'return']).default('supply'), + slots: z.record(z.string(), z.string()).optional(), }).describe( dedent` Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs. diff --git a/packages/core/src/schema/nodes/duct-segment.ts b/packages/core/src/schema/nodes/duct-segment.ts index 21af751f3..348a909d9 100644 --- a/packages/core/src/schema/nodes/duct-segment.ts +++ b/packages/core/src/schema/nodes/duct-segment.ts @@ -58,6 +58,7 @@ export const DuctSegmentNode = BaseNode.extend({ // Which side of the air loop this segment belongs to. Drives visual tint // and (in later slices) System graph membership. system: z.enum(['supply', 'return']).default('supply'), + slots: z.record(z.string(), z.string()).optional(), }).describe( dedent` Duct segment - polyline of 3D points connected by duct sections. diff --git a/packages/core/src/schema/nodes/pipe-fitting.ts b/packages/core/src/schema/nodes/pipe-fitting.ts index 88e38e9a3..81707ae0a 100644 --- a/packages/core/src/schema/nodes/pipe-fitting.ts +++ b/packages/core/src/schema/nodes/pipe-fitting.ts @@ -24,8 +24,10 @@ export const PipeFittingNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), fittingType: z.enum(['elbow', 'wye', 'sanitary-tee', 'cross']).default('elbow'), // Elbow turn in degrees — DWV bends ship as 22.5 / 45 / 90 ("long - // sweep" for drains); adjustable range matches the duct elbow. - angle: z.number().min(15).max(90).default(90), + // sweep" for drains); adjustable range matches the duct elbow. 0° is a + // straight coupling — what an elbow flattens to when its run is dragged + // into line with the fixed collar. + angle: z.number().min(0).max(90).default(90), // Run nominal size in inches. diameter: z.number().min(1.25).max(8).default(2), // Branch collar size (wye / sanitary-tee). diff --git a/packages/core/src/schema/nodes/pipe-trap.ts b/packages/core/src/schema/nodes/pipe-trap.ts index ca63f242f..0eba603fd 100644 --- a/packages/core/src/schema/nodes/pipe-trap.ts +++ b/packages/core/src/schema/nodes/pipe-trap.ts @@ -21,7 +21,7 @@ export const PipeTrapNode = BaseNode.extend({ // Yaw in radians (the arm direction in plan). rotation: z.number().default(0), // Trap size in inches — matches the fixture drain it serves. - diameter: z.number().min(1.25).max(4).default(1.5), + diameter: z.number().min(1.25).max(4).default(2), pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), // Developed length of the trap arm (trap weir → vent) in meters. The // draw tool measures it when the arm is drawn; editable in the diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index f52f93944..4d8bac971 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -43,6 +43,8 @@ export { } from './hosting' export { DEFAULT_LEVEL_HEIGHT, + getCeilingAt, + getCeilingHeightAt, getLevelHeight, } from './level-height' export { diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 46f4da0a4..016711ac4 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,3 +1,4 @@ +import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager' import type { CeilingNode, LevelNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' @@ -40,3 +41,46 @@ export function getLevelHeight( return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT } + +/** + * The ceiling covering level-local point `[x, z]`, or `null` when none + * sits over it. Points inside a ceiling's hole are treated as uncovered. + * When ceilings overlap, the lowest one wins — that's the surface a duct + * would actually hang from. + */ +export function getCeilingAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): CeilingNode | null { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return null + + let best: CeilingNode | null = null + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'ceiling') continue + const ceiling = child as CeilingNode + if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue + if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue + const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT + if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling + } + return best +} + +/** + * Underside elevation (meters above the level floor) of the ceiling + * covering level-local point `[x, z]`, or `null` when no ceiling sits + * over that point. See {@link getCeilingAt}. + */ +export function getCeilingHeightAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const ceiling = getCeilingAt(levelId, nodes, x, z) + return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null +} diff --git a/packages/core/src/services/port-connectivity.test.ts b/packages/core/src/services/port-connectivity.test.ts new file mode 100644 index 000000000..7d9cc1ea5 --- /dev/null +++ b/packages/core/src/services/port-connectivity.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeDefinition, DistributionRole, NodePort } from '../registry' +import { registerNode } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' +import { analyzePortConnectivity, resolveConnectivityUpdates } from './port-connectivity' + +type Point = [number, number, number] + +// Stub registrations mirroring the real kinds' port + role conventions +// without importing the nodes package (which pulls in CSG and can't load +// under the test runner). A run exposes start/end at its path tips; the +// fitting here is a simple two-collar elbow at ±X around its position. +function stubDef( + kind: string, + distributionRole: DistributionRole, + ports: (node: AnyNode) => NodePort[], +): void { + registerNode({ + kind, + schemaVersion: 1, + schema: {}, + category: 'utility', + distributionRole, + defaults: () => ({}), + capabilities: {}, + ports, + } as unknown as AnyNodeDefinition) +} + +stubDef('duct-segment', 'run', (node) => { + const path = (node as unknown as { path: Point[] }).path + const system = (node as unknown as { system: string }).system + return [ + { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system }, + { id: 'end', position: path[path.length - 1]!, direction: [1, 0, 0], diameter: 6, system }, + ] +}) +stubDef('duct-fitting', 'fitting', (node) => { + const position = (node as unknown as { position: Point }).position + const system = (node as unknown as { system: string }).system + return [ + { + id: 'inlet', + position: [position[0] - 0.2, position[1], position[2]], + direction: [-1, 0, 0], + diameter: 6, + system, + }, + { + id: 'outlet', + position: [position[0] + 0.2, position[1], position[2]], + direction: [1, 0, 0], + diameter: 6, + system, + }, + ] +}) +stubDef('duct-tee', 'fitting', (node) => { + const position = (node as unknown as { position: Point }).position + const system = (node as unknown as { system: string }).system + return [ + { + id: 'inlet', + position: [position[0] - 0.2, position[1], position[2]], + direction: [-1, 0, 0], + diameter: 6, + system, + }, + { + id: 'outlet', + position: [position[0] + 0.2, position[1], position[2]], + direction: [1, 0, 0], + diameter: 6, + system, + }, + { + id: 'branch', + position: [position[0], position[1], position[2] + 0.2], + direction: [0, 0, 1], + diameter: 6, + system, + }, + ] +}) + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} + +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +function expectPointClose(actual: Point, expected: Point) { + expect(actual[0]).toBeCloseTo(expected[0], 6) + expect(actual[1]).toBeCloseTo(expected[1], 6) + expect(actual[2]).toBeCloseTo(expected[2], 6) +} + +describe('port connectivity — joint follow (stretch vs translate)', () => { + // Layout: duct A ends at the fitting's inlet (−0.2,0,0); duct B starts at the + // fitting's outlet (+0.2,0,0). Both runs lie on the X axis. Dragging A's + // mated endpoint carries the fitting and duct B; how B reacts depends on + // whether the drag is along its axis (stretch) or across it (translate). + function joint() { + const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) + const ductA = makeNode('duct-segment', { + path: [ + [-3, 0, 0], + [-0.2, 0, 0], + ], + system: 'supply', + }) + const ductB = makeNode('duct-segment', { + path: [ + [0.2, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + return { fitting, ductA, ductB } + } + + function movedA(end: Point): AnyNode { + const { ductA } = joint() + return { ...(ductA as Record), path: [[-3, 0, 0], end] } as AnyNode + } + + test('the fitting and sibling run are picked up as carried connections', () => { + const { fitting, ductA, ductB } = joint() + const connectivity = analyzePortConnectivity(ductA, sceneOf(fitting, ductA, ductB)) + expect( + connectivity.connections.find((c) => c.kind === 'rigid-node' && c.nodeId === fitting.id), + ).toBeDefined() + expect( + connectivity.connections.find((c) => c.kind === 'run' && c.nodeId === ductB.id), + ).toBeDefined() + }) + + test('perpendicular drag translates the WHOLE sibling run (no skew)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) + + // Move A's mated end +1 in Z — perpendicular to B's X axis. + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) + + expect( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + ).toEqual([0, 0, 1]) + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Both ends ride +1 in Z: the run keeps its length and direction. + expect(bPath[0]).toEqual([0.2, 0, 1]) + expect(bPath[1]).toEqual([3, 0, 1]) + }) + + test('parallel drag stretches the sibling run (only the near end slides)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) + + // Move A's mated end +0.5 in X — along B's axis (the fitting slides toward B). + const updates = resolveConnectivityUpdates(connectivity, movedA([0.3, 0, 0])) + + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Near end slid +0.5 in X; far end stayed put → the run shortened. + expect(bPath[0]).toEqual([0.7, 0, 0]) + expect(bPath[1]).toEqual([3, 0, 0]) + }) + + test('perpendicular slide propagates through the sibling run to its far joint', () => { + // Extend the chain: duct B's far end (3,0,0) meets a second elbow, and duct + // C hangs off that elbow. A perpendicular drag should carry the whole chain. + const { fitting, ductA, ductB } = joint() + const elbow2 = makeNode('duct-fitting', { position: [3.2, 0, 0], system: 'supply' }) + // elbow ports are ±0.2 on X around its position → inlet at (3,0,0) meets B. + const ductC = makeNode('duct-segment', { + path: [ + [3.4, 0, 0], + [6, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, elbow2, ductC) + const connectivity = analyzePortConnectivity(ductA, nodes) + + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) + + // Whole chain rode +1 in Z. + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + expect(bPath[1]).toEqual([3, 0, 1]) + expect((updates.find((u) => u.id === elbow2.id)!.data as { position: Point }).position).toEqual( + [3.2, 0, 1], + ) + const cPath = (updates.find((u) => u.id === ductC.id)!.data as { path: Point[] }).path + expect(cPath[0]).toEqual([3.4, 0, 1]) + expect(cPath[1]).toEqual([6, 0, 1]) + }) + + test('a run reached from both ends applies both endpoint deltas', () => { + const moved = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + const follower = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, follower) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [0, 0, 1], + [3, 0, 2], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path + expect(path[0]).toEqual([0, 0, 1]) + expect(path[1]).toEqual([3, 0, 2]) + }) + + test('a polyline run reached from both ends preserves interior bend shape', () => { + const moved = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 3], + ], + system: 'supply', + }) + const follower = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [1, 0, 0], + [1, 0, 3], + [3, 0, 3], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, follower) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.5, 0, 0], + [3.5, 0, 3], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path + expect(path).toEqual([ + [-0.5, 0, 0], + [1, 0, 0], + [1, 0, 3], + [3.5, 0, 3], + ]) + }) + + test('a fitting reached from both collars rebroadcasts its final compatible rigid delta', () => { + const moved = makeNode('duct-segment', { + path: [ + [-0.2, 0, 0], + [0.2, 0, 0], + ], + system: 'supply', + }) + const fitting = makeNode('duct-tee', { position: [0, 0, 0], system: 'supply' }) + const downstream = makeNode('duct-segment', { + path: [ + [0, 0, 0.2], + [3, 0, 0.2], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, fitting, downstream) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.2, 0, 1], + [0.2, 0, 1.00005], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + expectPointClose( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + [0, 0, 1.000025], + ) + const path = (updates.find((u) => u.id === downstream.id)!.data as { path: Point[] }).path + expectPointClose(path[0]!, [0, 0, 1.200025]) + expectPointClose(path[1]!, [3, 0, 1.200025]) + }) + + test('a fitting reached from incompatible collars merges constraints deterministically', () => { + const moved = makeNode('duct-segment', { + path: [ + [-0.2, 0, 0], + [0.2, 0, 0], + ], + system: 'supply', + }) + const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) + const nodes = sceneOf(moved, fitting) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.2, 0, 1], + [0.2, 0, -1], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + expectPointClose( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + [0, 0, 0], + ) + }) + + test('an unrelated run not on the fitting is left alone', () => { + const { fitting, ductA, ductB } = joint() + const distant = makeNode('duct-segment', { + path: [ + [10, 0, 0], + [13, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, distant) + const connectivity = analyzePortConnectivity(ductA, nodes) + expect(connectivity.connections.find((c) => c.nodeId === distant.id)).toBeUndefined() + }) +}) diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts index 3dbbbf352..57c0b0d45 100644 --- a/packages/core/src/services/port-connectivity.ts +++ b/packages/core/src/services/port-connectivity.ts @@ -13,14 +13,29 @@ import type { AnyNode, AnyNodeId } from '../schema' * * Pure logic: it asks each node for its ports via `def.ports` (level-local * meters) and does arithmetic. No Three.js, no rendering — it lives in - * core and is consumed by the editor's move tool and the duct-segment - * system alike. + * core and is consumed by the editor's move tool and the duct/pipe + * selection affordances alike. * - * Propagation is intentionally **one hop**: a moved fitting stretches the - * ducts touching it (their near endpoint follows) and rigidly drags any - * fitting mated collar-to-collar, but it does NOT chase the far end of - * those ducts or anything beyond. Bounded and predictable — no runaway - * network rearrangement. + * ## Propagation model + * + * The joint graph is snapshotted once at drag start (`analyzePortConnectivity`) + * and walked every frame (`resolveConnectivityUpdates`) given the moved node's + * live transform. Deltas flow outward from the moved node through coincident + * ports: + * + * - **Fitting** (rigid): a collar pushed by delta `d` translates the whole + * fitting by `d`; every other collar carries that same `d` onward. + * - **Run** (stretch + slide, never skew): an endpoint pushed by delta `d` is + * split against the run's own axis. The *parallel* part slides only that + * endpoint (the run lengthens / shortens); the *perpendicular* part + * translates the entire run (so its direction is preserved). The far + * endpoint therefore moves by just the perpendicular part, and that part + * propagates onward to whatever is mated to the far endpoint. + * + * Propagation walks the whole connected component so a joint stays welded all + * the way down the chain, with a visited guard so cycles (looped runs) and + * shared joints terminate. First-reached (shortest path) wins on a node + * reachable two ways. */ type Point = readonly [number, number, number] @@ -30,36 +45,55 @@ type Point = readonly [number, number, number] * generous slack for grid-snapped hand placement without false matches. */ const COINCIDENT_EPS_M = 0.05 -/** A node attached to one of the moved node's ports, plus how it follows. */ +/** Below this (meters) a propagated delta is treated as zero — stops the + * walk from chasing sub-millimeter perpendicular residue. */ +const DELTA_EPS_M = 1e-4 +const PROPAGATION_EPS_M = 1e-9 + +/** A node carried by the edit, plus the snapshot needed to revert it. Kept + * deliberately small: the move tools read only `kind` + `nodeId` and the + * matching start snapshot to revert before the single tracked commit. */ export type PortConnection = | { - /** Partner is a duct run: the endpoint touching the moved port slides - * to track it (one hop — the far endpoint stays put, stretching the - * run). */ - kind: 'duct-endpoint' + /** A fitting mated collar-to-collar: it translates rigidly. */ + kind: 'rigid-node' nodeId: AnyNodeId - /** Index in the duct's `path` that tracks the moved port. */ - pathIndex: number - /** The moved node's port id this endpoint follows. */ - movedPortId: string - /** The duct's full path at edit-start (other points are preserved). */ - startPath: Point[] + /** Node's `position` at edit-start. */ + startPosition: Point } | { - /** Partner is another fitting mated collar-to-collar: it translates - * rigidly so its collar stays on the moved collar. */ - kind: 'rigid-node' + /** A run whose endpoint(s) ride the edit: it stretches and/or + * translates, never skews. */ + kind: 'run' nodeId: AnyNodeId - movedPortId: string - /** Partner node's `position` at edit-start. */ - startPosition: Point + /** The run's full `path` at edit-start. */ + startPath: Point[] } +/** One node in the snapshotted joint graph (everything reachable from the + * moved node, excluding the moved node itself). */ +type GraphNode = { + id: AnyNodeId + role: 'run' | 'fitting' + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + startPath?: Point[] + startPosition?: Point +} + +/** Who else sits on a given node's port, keyed `nodeId` → `portId` → mates. */ +type Adjacency = Record>> + export type PortConnectivity = { movedNodeId: AnyNodeId - /** The moved node's port world positions at edit-start, keyed by port id. - * Used as the reference each connection's delta is measured from. */ + /** The moved node's port world positions at edit-start, keyed by port id — + * the reference each frame's delta is measured from. */ startMovedPorts: Record + /** Reachable run/fitting nodes (excludes the moved node), keyed by id. */ + graph: Record + /** Port coincidence edges across the moved node + every graph node. */ + adjacency: Adjacency + /** Flat list of carried nodes for the move tools' revert + "anything to + * follow?" check. Derived from `graph`. */ connections: PortConnection[] } @@ -83,85 +117,225 @@ function distSq(a: Point, b: Point): number { return dx * dx + dy * dy + dz * dz } +/** Two ports mate when they coincide AND don't cross incompatible systems + * (a supply duct and a waste pipe that merely touch must not fuse). */ +function portsMate( + a: { position: Point; system?: string }, + b: { position: Point; system?: string }, + epsSq: number, +): boolean { + if (distSq(a.position, b.position) > epsSq) return false + if (a.system && b.system && a.system !== b.system) return false + return true +} + /** - * Snapshot which nodes are connected to `movedNode`'s ports, taken at the + * Snapshot the joint graph reachable from `movedNode`'s ports, taken at the * start of a move/resize. Call once before the drag; feed the result to * `resolveConnectivityUpdates` on every frame. * - * Only `run`-role partners (segments — endpoint stretch) and `fitting`-role - * partners (rigid follow) are tracked — terminals and equipment usually mount - * to a surface and shouldn't be yanked off it when an adjacent fitting nudges. + * Only `run`-role partners (segments) and `fitting`-role partners are walked — + * terminals and equipment usually mount to a surface and shouldn't be yanked + * off it when an adjacent fitting nudges. Fittings that declare + * `portConnectivityFollow: false` are anchored fixtures (e.g. pipe-trap) and + * are skipped, so a connected run stretches against them instead. */ export function analyzePortConnectivity( movedNode: AnyNode, nodes: Record, ): PortConnectivity { + const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + const movedPorts = portsOf(movedNode) ?? [] const startMovedPorts: Record = {} - const movedPortSystem: Record = {} - for (const p of movedPorts) { - startMovedPorts[p.id] = p.position - movedPortSystem[p.id] = p.system - } - - const connections: PortConnection[] = [] - const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + for (const p of movedPorts) startMovedPorts[p.id] = p.position + // Candidate partners: every run + every following fitting in the scene. + const candidates: GraphNode[] = [] for (const other of Object.values(nodes)) { if (!other || other.id === movedNode.id) continue - // Generalised across every distribution family (HVAC duct + DWV pipe): - // `run` partners stretch an endpoint, `fitting` partners follow rigidly. - // Terminals/equipment mount to surfaces and are intentionally NOT dragged. - // Fittings that declare `portConnectivityFollow: false` are anchored - // fixtures (e.g. pipe-trap) — moving a connected run stretches the arm. - const otherRole = roleOf(other) - if (otherRole !== 'run' && otherRole !== 'fitting') continue - const otherDef = nodeRegistry.get(other.type) - if (otherRole === 'fitting' && otherDef?.portConnectivityFollow === false) continue - const otherPorts = portsOf(other) - if (!otherPorts) continue - - for (const op of otherPorts) { - // Find which of the moved node's ports this partner port sits on. - let matchedId: string | null = null - for (const mp of movedPorts) { - if (distSq(op.position, mp.position) > epsSq) continue - // Don't fuse ports from incompatible systems (e.g. a supply duct - // and a waste pipe that happen to cross): only mate when both - // ports declare the same system, or at least one is unscoped. - const ms = movedPortSystem[mp.id] - if (ms && op.system && ms !== op.system) continue - matchedId = mp.id - break - } - if (!matchedId) continue - - if (otherRole === 'run') { - const path = (other as unknown as { path?: Point[] }).path - if (!Array.isArray(path) || path.length < 2) continue - // Port id 'start' → first point, 'end' → last point. - const pathIndex = op.id === 'start' ? 0 : path.length - 1 - connections.push({ - kind: 'duct-endpoint', - nodeId: other.id, - pathIndex, - movedPortId: matchedId, - startPath: path.map((p) => [...p] as Point), - }) - } else { - const position = (other as unknown as { position?: Point }).position - if (!position) continue - connections.push({ - kind: 'rigid-node', - nodeId: other.id, - movedPortId: matchedId, - startPosition: [position[0], position[1], position[2]], - }) + const role = roleOf(other) + if (role !== 'run' && role !== 'fitting') continue + if (role === 'fitting' && nodeRegistry.get(other.type)?.portConnectivityFollow === false) { + continue + } + const ports = portsOf(other) + if (!ports) continue + const startPath = + role === 'run' + ? (other as unknown as { path?: Point[] }).path?.map((p) => [...p] as Point) + : undefined + if (role === 'run' && (!startPath || startPath.length < 2)) continue + const startPosition = + role === 'fitting' + ? (() => { + const pos = (other as unknown as { position?: Point }).position + return pos ? ([pos[0], pos[1], pos[2]] as Point) : undefined + })() + : undefined + if (role === 'fitting' && !startPosition) continue + candidates.push({ id: other.id as AnyNodeId, role, ports, startPath, startPosition }) + } + + // Walk outward from the moved node, collecting every node reachable through + // coincident ports. The adjacency records each port's mates so the resolver + // can replay the same edges with live deltas. + const adjacency: Adjacency = {} + const addEdge = (nodeId: string, portId: string, mate: { nodeId: AnyNodeId; portId: string }) => { + const byPort = adjacency[nodeId] ?? {} + adjacency[nodeId] = byPort + const mates = byPort[portId] ?? [] + byPort[portId] = mates + mates.push(mate) + } + + const graph: Record = {} + const visited = new Set([movedNode.id]) + + // Seed: the moved node's own ports. + const queue: Array<{ + id: string + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + }> = [{ id: movedNode.id, ports: movedPorts }] + + while (queue.length > 0) { + const { id, ports } = queue.shift()! + for (const port of ports) { + for (const cand of candidates) { + if (cand.id === id) continue + for (const cp of cand.ports) { + if (!portsMate(port, cp, epsSq)) continue + addEdge(id, port.id, { nodeId: cand.id, portId: cp.id }) + addEdge(cand.id, cp.id, { nodeId: id as AnyNodeId, portId: port.id }) + if (!visited.has(cand.id)) { + visited.add(cand.id) + graph[cand.id] = cand + queue.push({ id: cand.id, ports: cand.ports }) + } + } } } } - return { movedNodeId: movedNode.id as AnyNodeId, connections, startMovedPorts } + const connections: PortConnection[] = Object.values(graph).map((g) => + g.role === 'fitting' + ? { kind: 'rigid-node', nodeId: g.id, startPosition: g.startPosition! } + : { kind: 'run', nodeId: g.id, startPath: g.startPath! }, + ) + + return { + movedNodeId: movedNode.id as AnyNodeId, + startMovedPorts, + graph, + adjacency, + connections, + } +} + +function add(a: Point, b: Point): Point { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +function sub(a: Point, b: Point): Point { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +function lenSq(v: Point): number { + return v[0] * v[0] + v[1] * v[1] + v[2] * v[2] +} + +/** Split `delta` into the component along unit `axis` and the remainder. */ +function decompose(delta: Point, axis: Point): { parallel: Point; perp: Point } { + const dot = delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2] + const parallel: Point = [axis[0] * dot, axis[1] * dot, axis[2] * dot] + return { parallel, perp: sub(delta, parallel) } +} + +function scale(v: Point, scalar: number): Point { + return [v[0] * scalar, v[1] * scalar, v[2] * scalar] +} + +function average(deltas: Point[]): Point { + const sum = deltas.reduce((acc, delta) => add(acc, delta), [0, 0, 0]) + return scale(sum, 1 / deltas.length) +} + +function nearlyEqual(a: Point, b: Point): boolean { + return lenSq(sub(a, b)) <= DELTA_EPS_M * DELTA_EPS_M +} + +function propagationEqual(a: Point, b: Point): boolean { + return lenSq(sub(a, b)) <= PROPAGATION_EPS_M * PROPAGATION_EPS_M +} + +function effectivePortDeltas( + constraints: Record>, +): Record { + return Object.fromEntries( + Object.entries(constraints).map(([portId, bySource]) => [ + portId, + average(Object.values(bySource)), + ]), + ) +} + +/** Unit direction of the run's segment adjacent to its `start` / `end` tip. */ +function endpointAxis(path: Point[], portId: string): Point { + const n = path.length + const [a, b] = portId === 'start' ? [path[1]!, path[0]!] : [path[n - 2]!, path[n - 1]!] + const dir = sub(b, a) + const l2 = lenSq(dir) + if (l2 < 1e-12) return [0, 0, 0] + const l = Math.sqrt(l2) + return [dir[0] / l, dir[1] / l, dir[2] / l] +} + +function runPathFromSinglePortDelta( + startPath: Point[], + portId: 'start' | 'end', + delta: Point, +): Point[] { + const nearIdx = portId === 'start' ? 0 : startPath.length - 1 + const axis = endpointAxis(startPath, portId) + const { parallel, perp } = decompose(delta, axis) + const path = startPath.map((p) => add(p, perp)) + path[nearIdx] = add(path[nearIdx]!, parallel) + return path +} + +function runEndpointDeltas(startPath: Point[], path: Point[]): Record { + return { + start: sub(path[0]!, startPath[0]!), + end: sub(path[path.length - 1]!, startPath[startPath.length - 1]!), + } +} + +function runPathFromPortDeltas(startPath: Point[], portDeltas: Record): Point[] { + const startDelta = portDeltas.start + const endDelta = portDeltas.end + if (startDelta && endDelta) { + if (startPath.length === 2) { + return [add(startPath[0]!, startDelta), add(startPath[1]!, endDelta)] + } + + if (nearlyEqual(startDelta, endDelta)) { + return startPath.map((p) => add(p, startDelta)) + } + + const startParts = decompose(startDelta, endpointAxis(startPath, 'start')) + const endParts = decompose(endDelta, endpointAxis(startPath, 'end')) + const commonPerp = average([startParts.perp, endParts.perp]) + const path = startPath.map((p) => add(p, commonPerp)) + path[0] = add(path[0]!, startParts.parallel) + path[path.length - 1] = add(path[path.length - 1]!, endParts.parallel) + return path + } + + return runPathFromSinglePortDelta( + startPath, + startDelta ? 'start' : 'end', + (startDelta ?? endDelta)!, + ) } /** @@ -169,45 +343,103 @@ export function analyzePortConnectivity( * that keep every connected node attached. `previewNode` is the moved node * with its current drag position/rotation applied so its ports recompute. * - * - Duct endpoint: set the tracked path point to the moved port's new - * position (the joint stays welded; the run stretches). - * - Rigid fitting: translate by the moved port's delta so its mated collar - * rides along. + * Walks the snapshotted graph, propagating each port delta outward: fittings + * translate rigidly, runs stretch along their axis and translate across it + * (never skew when driven from one end), and effective port movement carries on + * to neighbouring joints. Port-level output guards bound cycles while still + * allowing a looped/shared run to accept constraints at both endpoints. */ export function resolveConnectivityUpdates( connectivity: PortConnectivity, previewNode: AnyNode, ): { id: AnyNodeId; data: Partial }[] { + const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity + if (Object.keys(graph).length === 0) return [] + const newPorts = portsOf(previewNode) ?? [] - const newById: Record = {} - for (const p of newPorts) newById[p.id] = p.position - - const updates: { id: AnyNodeId; data: Partial }[] = [] - for (const conn of connectivity.connections) { - const start = connectivity.startMovedPorts[conn.movedPortId] - const now = newById[conn.movedPortId] - if (!start || !now) continue - - if (conn.kind === 'duct-endpoint') { - const path = conn.startPath.map((p, i) => - i === conn.pathIndex ? ([now[0], now[1], now[2]] as Point) : ([...p] as Point), - ) - updates.push({ id: conn.nodeId, data: { path } as Partial }) - } else { - const dx = now[0] - start[0] - const dy = now[1] - start[1] - const dz = now[2] - start[2] - updates.push({ - id: conn.nodeId, - data: { - position: [ - conn.startPosition[0] + dx, - conn.startPosition[1] + dy, - conn.startPosition[2] + dz, - ], - } as Partial, + const newMovedPos: Record = {} + for (const p of newPorts) newMovedPos[p.id] = p.position + + // Each queue item drives a node's port by a delta ("this collar / endpoint + // must move by this much"). + const queue: Array<{ nodeId: AnyNodeId; portId: string; delta: Point; sourceKey: string }> = [] + const results: Record }> = {} + const constrainedPorts: Record>> = {} + const propagatedPorts: Record> = {} + + const enqueueMates = (nodeId: string, portId: string, delta: Point) => { + const byPort = propagatedPorts[nodeId] ?? {} + propagatedPorts[nodeId] = byPort + const previous = byPort[portId] + if (previous && propagationEqual(previous, delta)) return + byPort[portId] = delta + + for (const mate of adjacency[nodeId]?.[portId] ?? []) { + if (mate.nodeId === movedNodeId) continue + queue.push({ + nodeId: mate.nodeId, + portId: mate.portId, + delta, + sourceKey: `${nodeId}:${portId}`, }) } } - return updates + + const acceptPortDelta = ( + nodeId: AnyNodeId, + portId: string, + sourceKey: string, + delta: Point, + ): boolean => { + const byPort = constrainedPorts[nodeId] ?? {} + constrainedPorts[nodeId] = byPort + const bySource = byPort[portId] ?? {} + byPort[portId] = bySource + const existing = bySource[sourceKey] + if (existing && propagationEqual(existing, delta)) { + return false + } + bySource[sourceKey] = delta + return true + } + + // Seed from the moved node's live port deltas. + for (const [portId, start] of Object.entries(startMovedPorts)) { + const now = newMovedPos[portId] + if (!now) continue + const delta = sub(now, start) + if (lenSq(delta) <= DELTA_EPS_M * DELTA_EPS_M) continue + enqueueMates(movedNodeId, portId, delta) + } + + while (queue.length > 0) { + const { nodeId, portId, delta, sourceKey } = queue.shift()! + const node = graph[nodeId] + if (!node) continue + if (!acceptPortDelta(nodeId, portId, sourceKey, delta)) continue + const portDeltas = effectivePortDeltas(constrainedPorts[nodeId]!) + + if (node.role === 'fitting') { + const start = node.startPosition! + const effectiveDelta = average(Object.values(portDeltas)) + results[nodeId] = { + id: nodeId, + data: { position: add(start, effectiveDelta) } as Partial, + } + // Rigid: every collar carries the effective body translation onward. + for (const p of node.ports) { + enqueueMates(nodeId, p.id, effectiveDelta) + } + } else { + const startPath = node.startPath! + const path = runPathFromPortDeltas(startPath, portDeltas) + results[nodeId] = { id: nodeId, data: { path } as Partial } + for (const [nextPortId, nextDelta] of Object.entries(runEndpointDeltas(startPath, path))) { + if (lenSq(nextDelta) <= DELTA_EPS_M * DELTA_EPS_M) continue + enqueueMates(nodeId, nextPortId, nextDelta) + } + } + } + + return Object.values(results) } diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 5de80de90..94997dba1 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1,3 +1,4 @@ +import { nodeRegistry } from '../../registry/registry' import { type AnyNode, type AnyNodeId, @@ -1010,6 +1011,24 @@ export const deleteNodesAction = ( } for (const id of allIds) deletedIds.add(id) + // Let each deleted kind undo what it imposed on its neighbours (e.g. an + // auto-inserted elbow re-extends the duct runs it trimmed back onto the + // corner it replaced). Read against pre-deletion `nextNodes`; skip + // patches that target a node also being deleted. + for (const id of allIds) { + const node = nextNodes[id] + if (!node) continue + const onDelete = nodeRegistry.get(node.type)?.parametrics?.onDelete + if (!onDelete) continue + for (const { id: targetId, data } of onDelete(node, nextNodes)) { + if (allIds.has(targetId)) continue + const target = nextNodes[targetId] + if (!target) continue + nextNodes[targetId] = { ...target, ...data } as AnyNode + nodesToMarkDirty.add(targetId) + } + } + for (const plan of mergePlans) { const primaryWall = nextNodes[plan.primaryWallId] if (!(primaryWall && primaryWall.type === 'wall') || allIds.has(plan.primaryWallId)) { diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index caf71ffe2..8c8f0589e 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -51,6 +51,13 @@ const CHEVRON_DEPTH = 0.08 const CHEVRON_BEVEL_THICKNESS = 0.035 const CHEVRON_BEVEL_SIZE = 0.03 const CHEVRON_BEVEL_SEGMENTS = 10 +// Slimmer extrude profile matching the legacy wall side handles +// (`wall-move-side-handles.tsx`) — opt-in via the `thin` prop so the chunkier +// default is preserved for every other handle that uses the shared chevron. +const CHEVRON_THIN_DEPTH = 0.045 +const CHEVRON_THIN_BEVEL_THICKNESS = 0.018 +const CHEVRON_THIN_BEVEL_SIZE = 0.02 +const CHEVRON_THIN_BEVEL_SEGMENTS = 8 const MOVE_CROSS_HALF_LENGTH = 0.36 const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03 const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12 @@ -64,7 +71,7 @@ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3 const ROTATE_RIBBON_HALF_WIDTH = 0.02 const ROTATE_HEAD_HALF_WIDTH = 0.045 const TRACKER_CUBE_SIZE = 0.16 -export const CORNER_HEX_RADIUS = 0.16 +export const CORNER_HEX_RADIUS = 0.11 export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker' export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross' @@ -90,6 +97,8 @@ export type HandleArrowProps = { indicatorRotation?: readonly [number, number, number] onPointerEnter?: PointerHandler onPointerLeave?: PointerHandler + // Extrude the slimmer wall-handle chevron profile (chevron shape only). + thin?: boolean } function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape { @@ -179,8 +188,9 @@ export function createRotateArrowHandleGeometry() { // Reused chevron+shaft silhouette. The chevron points along +X by default; // callers rotate it around Y for Z-axis handles and into a vertical frame for -// Y-axis handles. -export function createArrowHandleGeometry() { +// Y-axis handles. `thin` extrudes the slimmer wall-handle profile. +export function createArrowHandleGeometry(thin = false) { + const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH const shape = new Shape() shape.moveTo(CHEVRON_MAX_X, 0) shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH) @@ -191,16 +201,16 @@ export function createArrowHandleGeometry() { shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH) shape.lineTo(CHEVRON_MAX_X, 0) const geometry = new ExtrudeGeometry(shape, { - depth: CHEVRON_DEPTH, + depth, bevelEnabled: true, - bevelThickness: CHEVRON_BEVEL_THICKNESS, - bevelSize: CHEVRON_BEVEL_SIZE, + bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS, + bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE, bevelOffset: 0, - bevelSegments: CHEVRON_BEVEL_SEGMENTS, + bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS, curveSegments: 16, steps: 1, }) - geometry.translate(0, 0, -CHEVRON_DEPTH / 2) + geometry.translate(0, 0, -depth / 2) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() geometry.computeBoundingSphere() @@ -314,8 +324,8 @@ export function createEndpointHitAreaGeometry(radius: number) { return geometry } -function createHandleArrowGeometry(shape: HandleArrowShape) { - if (shape === 'chevron') return createArrowHandleGeometry() +function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) { + if (shape === 'chevron') return createArrowHandleGeometry(thin) if (shape === 'cross') return createMoveCrossHandleGeometry() if (shape === 'curved-arrow') return createRotateArrowHandleGeometry() if (shape === 'tracker') { @@ -459,9 +469,10 @@ export function HandleArrow({ onPointerDown, onPointerEnter, onPointerLeave, + thin = false, }: HandleArrowProps) { const visualShape = normalizeHandleArrowShape(shape, cursor) - const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape]) + const geometry = useMemo(() => createHandleArrowGeometry(visualShape, thin), [visualShape, thin]) const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape]) const indicatorMaterial = useHandleArrowMaterial(visualShape) const hitMaterial = useInvisibleHitAreaMaterial() diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d8b27dec4..4ff55fd2b 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1147,6 +1147,7 @@ export default function Editor({ + {isFirstPersonMode && } @@ -1205,8 +1206,14 @@ export default function Editor({ {!isLoading && isPreviewMode ? (
- useEditor.getState().setPreviewMode(false)} /> -
{previewViewerContent}
+ {isFirstPersonMode ? ( + useEditor.getState().setFirstPersonMode(false)} /> + ) : ( + useEditor.getState().setPreviewMode(false)} /> + )} +
+ {previewViewerContent} +
) : ( <> @@ -1270,8 +1277,14 @@ export default function Editor({ {!isLoading && isPreviewMode ? ( <> - useEditor.getState().setPreviewMode(false)} /> -
{previewViewerContent}
+ {isFirstPersonMode ? ( + useEditor.getState().setFirstPersonMode(false)} /> + ) : ( + useEditor.getState().setPreviewMode(false)} /> + )} +
+ {previewViewerContent} +
) : ( <> diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 622034cdc..052065b1f 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -9,6 +9,7 @@ import { DEFAULT_ANGLE_STEP, type HandleDescriptor, type HandlePortal, + type LatchHandle, type LinearResizeHandle, nodeRegistry, type RadialResizeHandle, @@ -109,11 +110,16 @@ export { ARROW_COLOR, ARROW_HOVER_COLOR, ARROW_SCALE, + createArrowHandleGeometry, createArrowHitAreaGeometry, createEndpointHitAreaGeometry, createMoveCrossHandleGeometry, createRotateArrowHandleGeometry, createRotateArrowHitAreaGeometry, + HandleArrow, + type HandleArrowInputShape, + type HandleArrowPlacement, + type HandleArrowProps, HIT_AREA_MARGIN, InvisibleHandleHitArea, NO_RAYCAST, @@ -369,6 +375,21 @@ function NodeArrowHandlesForNode({ // hook count between renders and trip React's rules-of-hooks check. const [activeIndex, setActiveIndex] = useState(null) const [preDragNode, setPreDragNode] = useState(null) + // Latch groups currently toggled open. A `latch` cube descriptor flips its + // group here on click; arrows tagged with a `latchGroup` only render while + // their group is in this set. Local to this mount, so it resets on deselect + // (the rig remounts per selection — see the `key` on NodeArrowHandlesForNode). + const [openLatchGroups, setOpenLatchGroups] = useState>(() => new Set()) + const toggleLatchGroup = useMemo( + () => (group: string) => + setOpenLatchGroups((prev) => { + const next = new Set(prev) + if (next.has(group)) next.delete(group) + else next.add(group) + return next + }), + [], + ) const dragControls = useMemo( () => ({ onStart: (index: number, snapshot: AnyNode) => { @@ -399,21 +420,36 @@ function NodeArrowHandlesForNode({ // here, or they'd lag behind the moving item. const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate' - const arrows = descriptors.map((descriptor, index) => ( - - )) + const arrows = descriptors.map((descriptor, index) => { + // A `latch` cube toggles its group's visibility; render it always. + if (descriptor.kind === 'latch') { + return ( + + ) + } + // Arrows tagged with a latch group stay hidden until that group is open. + const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined + if (latchGroup && !openLatchGroups.has(latchGroup)) return null + return ( + + ) + }) return createPortal( @@ -717,10 +753,19 @@ function LinearArrow({ // X+Z rotation chain matching DoorHeightArrowHandle. When the handle // sits below the node (placement Y < 0, e.g. window bottom arrow), // flip the Z rotation so the chevron points outward (downward). + // + // For axis === 'x' with `faceNormal` (wall-mounted opening width arrows), + // roll the blade 90° about its own pointing (X) axis so it stands up from + // the horizontal XZ plane into the node's facing plane (XY = the wall + // face) — otherwise the blade is seen edge-on from the front. + const faceNormalX = + descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true const innerRotation: [number, number, number] = descriptor.axis === 'y' ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2] - : [0, 0, 0] + : faceNormalX + ? [Math.PI / 2, 0, 0] + : [0, 0, 0] // Optional guide decoration — linear handles use it for curved-stair // width / inner-radius rings; radial handles use it for the column's @@ -806,6 +851,7 @@ function LinearArrow({ onPointerDown={activate} placement={{ position, rotation: [0, rotationY, 0], baseScale }} shape="chevron" + thin > {showLabel ? : null} @@ -1201,6 +1247,7 @@ function ArcArrow({ baseScale, }} shape={isRotateShape ? 'curved-arrow' : 'chevron'} + thin /> ) @@ -1323,6 +1370,56 @@ function TapActionArrow({ onPointerDown={onActivate} placement={{ position, rotation, baseScale }} shape={shape === 'move-cross' ? 'move-cross' : 'chevron'} + thin + /> + ) +} + +// Click-to-latch cube. A persistent grip (the `tracker` cube) that toggles +// the visibility of every arrow tagged with its `latchGroup` on click. Sized +// to match the duct selection cube (`baseScale = zoom`, full TRACKER_CUBE_SIZE) +// so every latch grip reads the same across the app. Stays highlighted while +// its group is open so the user can tell it's engaged. +function LatchCube({ + descriptor, + node, + open, + onToggle, +}: { + descriptor: LatchHandle + node: AnyNode + open: boolean + onToggle: (group: string) => void +}) { + const [isHovered, setIsHovered] = useState(false) + const { camera } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom + + const placementSceneApi = useMemo(() => createSceneApi(useScene), []) + const position = descriptor.placement.position(node, placementSceneApi) + const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 + + // Route through the shared tap path so the cube click is swallowed before it + // reaches the select tool — stops R3F propagation, suppresses box-select, and + // eats the trailing DOM click that would otherwise select the host node. + const onPointerDown = useHandleDrag({ + kind: 'tap', + onTap: () => { + setIsHovered(false) + onToggle(descriptor.group) + }, + }) + + return ( + ) } 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 1840e4597..97d7d6e79 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -54,7 +54,7 @@ const ARROW_HOVER_COLOR = '#a5b4fc' // Match the door arrows: scale the rendered chevron down to ~two-thirds // so the in-world handles read as a single UI family. const ARROW_SCALE = 0.65 -const CORNER_HEX_RADIUS = 0.16 +const CORNER_HEX_RADIUS = 0.11 const CORNER_DASH_SIZE = 0.1 const CORNER_GAP_SIZE = 0.07 const CORNER_DASH_THICKNESS = 0.006 diff --git a/packages/editor/src/components/tools/shared/cursor-sphere.tsx b/packages/editor/src/components/tools/shared/cursor-sphere.tsx index 10af0c084..d403842aa 100644 --- a/packages/editor/src/components/tools/shared/cursor-sphere.tsx +++ b/packages/editor/src/components/tools/shared/cursor-sphere.tsx @@ -12,12 +12,28 @@ interface CursorSphereProps extends Omit { depthWrite?: boolean showTooltip?: boolean height?: number + /** + * Put the bright marker dot at the TIP of the vertical line (y = height) + * instead of on the ground ring. Used when the point being placed hangs + * above the floor (e.g. duct drawn against the ceiling): the dot rides at + * the cursor / placement point while the line drops to a floor ring that + * keeps the plan position readable. + */ + dotAtTip?: boolean /** Custom tooltip content — overrides the auto-detected build tool icon */ tooltipContent?: React.ReactNode } export const CursorSphere = forwardRef(function CursorSphere( - { color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props }, + { + color = '#818cf8', + showTooltip = true, + height = 2.5, + dotAtTip = false, + visible = true, + tooltipContent, + ...props + }, ref, ) { const tool = useEditor((s) => s.tool) @@ -39,19 +55,23 @@ export const CursorSphere = forwardRef(function Cursor return ( - {/* Flat marker on the ground */} + {/* Flat marker on the ground. The bright center dot moves to the tip + of the line in `dotAtTip` mode (the placement point hangs above the + floor), leaving a faint ring here so the plan position stays read. */} - {/* Center dot */} - - - - + {/* Center dot — at the ground unless the placement point is elevated */} + {!dotAtTip && ( + + + + + )} {/* Outer ring / glow */} @@ -60,7 +80,7 @@ export const CursorSphere = forwardRef(function Cursor color={color} depthTest={false} depthWrite={false} - opacity={0.25} + opacity={dotAtTip ? 0.2 : 0.25} transparent /> @@ -80,6 +100,15 @@ export const CursorSphere = forwardRef(function Cursor )} + {/* Bright marker dot at the tip of the line — the actual placement + point, riding at the cursor while the line drops to the floor. */} + {dotAtTip && height > 0 && ( + + + + + )} + {/* Tool Icon Tooltip at the top of the line */} {isVisible && showTooltip && (activeToolConfig || tooltipContent) && ( , - keywords: ['wall', 'cutaway', 'up', 'down', 'view'], + keywords: ['wall', 'cutaway', 'up', 'down', 'translucent', 'view'], badge: () => { const mode = useViewer.getState().wallMode - return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode] + return { cutaway: 'Cutaway', up: 'Up', down: 'Down', translucent: 'Translucent' }[mode] }, navigate: true, execute: () => navigateTo('wall-mode'), diff --git a/packages/editor/src/components/ui/command-palette/index.tsx b/packages/editor/src/components/ui/command-palette/index.tsx index 4f9d2964e..2979d80f4 100644 --- a/packages/editor/src/components/ui/command-palette/index.tsx +++ b/packages/editor/src/components/ui/command-palette/index.tsx @@ -244,10 +244,11 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm setOpen(false) } - const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = { + const wallModeLabel: Record<'cutaway' | 'up' | 'down' | 'translucent', string> = { cutaway: 'Cutaway', up: 'Up', down: 'Down', + translucent: 'Translucent', } const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { manual: 'Manual', @@ -373,7 +374,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm {/* ── Wall Mode sub-page ────────────────────────────────────── */} {page === 'wall-mode' && ( - {(['cutaway', 'up', 'down'] as const).map((mode) => ( + {(['cutaway', 'up', 'down', 'translucent'] as const).map((mode) => ( node !== undefined), ), ) - const selectModeHints = useMemo( - () => - resolveSelectModeHelpHints({ - selectedCount: selectedNodes.length, - hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)), - hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)), - commandPressed: modifiers.command, - shiftPressed: modifiers.shift, - }), - [modifiers.command, modifiers.shift, selectedNodes], - ) + const selectModeHints = useMemo(() => { + const single = selectedNodes.length === 1 ? selectedNodes[0] : null + const mepSelection = + single?.type === 'duct-segment' || single?.type === 'pipe-segment' + ? 'run' + : single?.type === 'duct-fitting' || single?.type === 'pipe-fitting' + ? 'fitting' + : null + return resolveSelectModeHelpHints({ + selectedCount: selectedNodes.length, + hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)), + hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)), + commandPressed: modifiers.command, + shiftPressed: modifiers.shift, + mepSelection, + }) + }, [modifiers.command, modifiers.shift, selectedNodes]) // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. if (isMobile) return null diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index 9e1cc3122..e60c5d662 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -25,6 +25,7 @@ import { Check, ChevronRight, Diamond, + Footprints, Layers, Palette, PenLine, @@ -32,8 +33,10 @@ import { Square, } from 'lucide-react' import Link from 'next/link' +import { flushSync } from 'react-dom' import { useShallow } from 'zustand/react/shallow' import { cn } from '../lib/utils' +import useEditor from '../store/use-editor' import { ActionButton } from './ui/action-menu/action-button' import { DropdownMenu, @@ -51,6 +54,24 @@ type ProjectOwner = { image: string | null } +function requestWalkthroughPointerLock() { + const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') + if (!canvas) return + + if (!canvas.hasAttribute('tabindex')) { + canvas.tabIndex = -1 + } + canvas.focus({ preventScroll: true }) + + if (document.pointerLockElement === canvas) return + + try { + canvas.requestPointerLock?.() + } catch { + return + } +} + const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { stacked: 'Stacked', exploded: 'Exploded', @@ -83,6 +104,12 @@ const wallModeConfig = { ), label: 'Low', }, + translucent: { + icon: (props: any) => ( + Translucent + ), + label: 'Translucent', + }, } const SHADING_OPTIONS = [ @@ -580,7 +607,12 @@ export const ViewerOverlay = ({ } label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`} onClick={() => { - const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] + const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [ + 'cutaway', + 'up', + 'down', + 'translucent', + ] const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') }} @@ -641,6 +673,23 @@ export const ViewerOverlay = ({ src="/icons/topview.webp" /> + +
+ + {/* First-person walkthrough */} + { + flushSync(() => useEditor.getState().setFirstPersonMode(true)) + requestWalkthroughPointerLock() + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + +
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 1ef26d771..0fd27078e 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -39,7 +39,27 @@ export { formatMeasurement, MeasurementPill, } from './components/editor/measurement-pill' -export { NodeArrowHandles } from './components/editor/node-arrow-handles' +// In-world arrow handle primitives (chevron geometry, invisible hit area, +// shared material, palette + scale constants). Re-exported so kind-owned +// 3D selection affordances in `@pascal-app/nodes` (duct side-move / height / +// extend arrows) reuse the same UI family as the wall / fence side handles. +export { + ARROW_COLOR, + ARROW_HOVER_COLOR, + ARROW_SCALE, + createArrowHandleGeometry, + createArrowHitAreaGeometry, + HandleArrow, + type HandleArrowInputShape, + type HandleArrowPlacement, + type HandleArrowProps, + InvisibleHandleHitArea, + NO_RAYCAST, + NodeArrowHandles, + swallowNextClick, + useArrowMaterial, + useInvisibleHitAreaMaterial, +} from './components/editor/node-arrow-handles' export { type SnapshotCameraData, ThumbnailGenerator, diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index 3648a8c94..221fe580e 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -10,12 +10,19 @@ export type SelectModeHelpContext = { hasRotatableSelection: boolean commandPressed: boolean shiftPressed: boolean + // When a single MEP node is selected its in-world handle rig (click a dot to + // reveal move arrows) is the real editing path, so the panel leads with the + // handle-specific hints instead of just the generic Cmd-drag tips. + mepSelection?: 'run' | 'fitting' | null } const COMMAND_KEY = 'Cmd/Ctrl' const LEFT_CLICK = 'Left click' const RIGHT_CLICK = 'Right click' const SHIFT_KEY = 'Shift' +const CLICK = 'Click' +const ALT_KEY = 'Alt' +const ROTATE_KEYS = 'R / T' export function resolveSelectModeHelpHints({ selectedCount, @@ -23,6 +30,7 @@ export function resolveSelectModeHelpHints({ hasRotatableSelection, commandPressed, shiftPressed, + mepSelection = null, }: SelectModeHelpContext): ContextualShortcutHint[] { const hints: ContextualShortcutHint[] = [] @@ -37,6 +45,20 @@ export function resolveSelectModeHelpHints({ return hints } + // MEP handle workflow — duct/pipe runs and fittings are edited through the + // in-world arrow rig that a click on the handle dot reveals, so surface those + // hints first. A run endpoint's side / up-down arrows swing the run and Alt + // detaches the joint mid-drag; a fitting's cluster adds rotate arcs, with + // R / T (and Alt to switch axis) for keyboard rotation. + if (mepSelection === 'run') { + hints.push({ keys: [CLICK], label: 'Click a handle dot to show move arrows' }) + hints.push({ keys: [ALT_KEY], label: 'Detach the joint while dragging an arrow' }) + } else if (mepSelection === 'fitting') { + hints.push({ keys: [CLICK], label: 'Click the handle dot to show move + rotate handles' }) + hints.push({ keys: [ROTATE_KEYS], label: 'Rotate ±45°' }) + hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' }) + } + if (commandPressed) { if (hasMovableSelection) { hints.push({ diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index 6553f7a37..1c9ec3020 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -5,6 +5,7 @@ import { type BoxVentNode, emitter, type RoofEvent, + type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, @@ -21,8 +22,14 @@ import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, + snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' import BoxVentPreview from './preview' /** @@ -72,10 +79,21 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { lastSnap = null setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() + } + + const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { + const rawTarget = roofDrag.resolve(event) + if (!rawTarget) return null + return snapRoofSurfaceNodeTarget({ + target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + node, + bypass: event.nativeEvent?.shiftKey === true, + }) } const updatePreview = (event: RoofEvent) => { - const target = roofDrag.resolve(event) + const target = resolveSnappedTarget(event) if (!target) { clearTarget() return @@ -102,12 +120,18 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { target.localZ, ]), ) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node as RoofNode, + segment: target.segment, + center: [target.localX, target.localY, target.localZ], + node, + }) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? roofDrag.resolve(event) + const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return committed = true const targetSegmentId = target.segment.id as AnyNodeId @@ -152,6 +176,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { if (obj) obj.visible = true triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() exitMoveMode() event.stopPropagation() } @@ -172,6 +197,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { useScene.getState().deleteNode(node.id as AnyNodeId) useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() return } @@ -191,6 +217,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() } @@ -223,6 +250,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { // the original mesh visible rather than stranded invisible. const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true + clearRoofSurfacePlacementGuides() useScene.temporal.getState().resume() } }, [exitMoveMode, node]) diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index 23c8af63f..27dd2da61 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { boxVentDefinition } from './definition' import BoxVentPreview from './preview' @@ -85,6 +90,15 @@ const BoxVentTool = () => { setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode({ + ...previewNode, + rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), + }), + }) event.stopPropagation() } @@ -109,6 +123,7 @@ const BoxVentTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [vent.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -120,8 +135,9 @@ const BoxVentTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -131,6 +147,7 @@ const BoxVentTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index 5e4690f91..bf0562667 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -10,11 +10,25 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { consumePlacementDragRelease, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + 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 { createRelativeRoofDrag, type RelativeRoofDragTarget } from '../shared/relative-roof-drag' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + snapRelativeRoofDragTarget, +} from '../shared/relative-roof-drag' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' import ChimneyPreview from './preview' const tmpMatrix = new THREE.Matrix4() @@ -67,6 +81,25 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { useEffect(() => { if (!activeBuildingId) return + 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 = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + const isNew = !!meta.isNew + + if (node.id) { + const chimneyObj = sceneRegistry.nodes.get(node.id) + if (chimneyObj) chimneyObj.visible = false + } const computeSegmentXform = (segmentId: string): SegmentTransform | null => { const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) @@ -84,25 +117,33 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { } let lastTarget: RelativeRoofDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag({ - position: [...node.position] as [number, number, number], - roofSegmentId: node.roofSegmentId, + position: original.position, + roofSegmentId: original.roofSegmentId, }) + const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { + const rawTarget = roofDrag.resolve(event) + if (!rawTarget) return null + return snapRoofSurfaceNodeTarget({ + target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + node, + bypass: event.nativeEvent?.shiftKey === true, + }) + } + const clearTarget = () => { lastTarget = null setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) + clearRoofSurfacePlacementGuides() } const updatePreview = (event: RoofEvent) => { - const target = roofDrag.resolve(event) - if (!target) { - clearTarget() - return - } - lastTarget = target + const target = resolveSnappedTarget(event) + if (!target) return clearTarget() const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 @@ -113,26 +154,32 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { } const xform = computeSegmentXform(target.segment.id) - if (!xform) return + if (!xform) return clearTarget() + lastTarget = target setSegmentXform(xform) setHitLocal([target.localX, target.localY, target.localZ]) setPreviewSegment(target.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, + segment: target.segment, + center: [target.localX, target.localY, target.localZ], + node, + }) event.stopPropagation() } const onClick = (event: RoofEvent) => { - const target = lastTarget ?? roofDrag.resolve(event) + if (committed) return + const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return + committed = true const state = useScene.getState() // Strip the `isNew` flag — only used to mark a duplicate clone // that hasn't been committed yet. - const meta = - node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) - ? (node.metadata as Record) - : {} const { isNew, ...restMeta } = meta as { isNew?: boolean } const cleanedMeta = Object.keys(restMeta).length > 0 ? restMeta : undefined + const targetSegmentId = target.segment.id as AnyNodeId // Duplicate (clone with no committed id yet) → create a fresh // chimney parented to the hit segment. Plain move (existing id, @@ -143,29 +190,105 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { ...node, id: undefined as never, roofSegmentId: target.segment.id, + parentId: target.segment.id, position: [target.localX, target.localY, target.localZ], + visible: true, metadata: cleanedMeta, }) - state.createNode(committed, target.segment.id as AnyNodeId) - state.dirtyNodes.add(target.segment.id as AnyNodeId) + useScene.temporal.getState().resume() + state.applyNodeChanges({ + delete: node.id ? [node.id as AnyNodeId] : [], + create: [{ node: committed, parentId: targetSegmentId }], + }) + state.dirtyNodes.add(targetSegmentId) setSelection({ selectedIds: [committed.id] }) + useScene.temporal.getState().pause() } else { - const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined + const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined + const reparenting = Boolean(prevSegmentId && prevSegmentId !== targetSegmentId) + // Resume BEFORE any scene edits so the reparent (both segments' + // children arrays + the chimney's own host/position update) lands as + // one tracked transaction. Otherwise undo reverts the chimney but + // leaves the children arrays inconsistent with its parentId. + useScene.temporal.getState().resume() + if (reparenting) { + const oldSeg = state.nodes[prevSegmentId!] as RoofSegmentNode | undefined + if (oldSeg) { + state.updateNode(prevSegmentId!, { + children: (oldSeg.children ?? []).filter((id) => id !== node.id), + }) + } + const newSeg = state.nodes[targetSegmentId] as RoofSegmentNode | undefined + if (newSeg && !(newSeg.children ?? []).includes(node.id)) { + state.updateNode(targetSegmentId, { + children: [...(newSeg.children ?? []), node.id], + }) + } + state.dirtyNodes.add(prevSegmentId!) + } state.updateNode(node.id as AnyNodeId, { roofSegmentId: target.segment.id, parentId: target.segment.id, position: [target.localX, target.localY, target.localZ], + rotation: original.rotation, + visible: true, metadata: cleanedMeta, }) - if (prevSegmentId) state.dirtyNodes.add(prevSegmentId) - state.dirtyNodes.add(target.segment.id as AnyNodeId) + useScene.temporal.getState().pause() + state.dirtyNodes.add(targetSegmentId) + state.dirtyNodes.add(node.id as AnyNodeId) setSelection({ selectedIds: [node.id] }) } + const obj = node.id && !isNew ? sceneRegistry.nodes.get(node.id) : null + if (obj) obj.visible = true + clearRoofSurfacePlacementGuides() setMovingNode(null) triggerSFX('sfx:item-place') event.stopPropagation() } + const onCancel = () => { + if (isNew) { + if (node.id) { + 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() + clearRoofSurfacePlacementGuides() + setMovingNode(null) + return + } + + if (node.id) { + 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() + clearRoofSurfacePlacementGuides() + setMovingNode(null) + } + const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return if (!lastTarget) return @@ -179,6 +302,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onClick) emitter.on('roof:leave', clearTarget) + emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { @@ -186,7 +310,15 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) emitter.off('roof:leave', clearTarget) + emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + + if (node.id) { + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + } + clearRoofSurfacePlacementGuides() + useScene.temporal.getState().resume() } }, [activeBuildingId, node, setMovingNode, setSelection]) diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index 19a7ada01..e8455b021 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -16,6 +16,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { chimneyDefinition } from './definition' import ChimneyPreview from './preview' @@ -102,6 +107,12 @@ const ChimneyTool = () => { setSegmentXform(xform) setHitLocal([hit.localX, hit.localY, hit.localZ]) setPreviewSegment(hit.segment) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode, { segment: hit.segment }), + }) event.stopPropagation() } @@ -126,6 +137,7 @@ const ChimneyTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [chimney.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -137,8 +149,9 @@ const ChimneyTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -149,6 +162,7 @@ const ChimneyTool = () => { setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && segmentXform && hitLocal && previewSegment && ( diff --git a/packages/nodes/src/column/renderer.tsx b/packages/nodes/src/column/renderer.tsx index fb070206a..53a6d5bd1 100644 --- a/packages/nodes/src/column/renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -2404,18 +2404,7 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { textures, colorPreset, }), - [ - shading, - textures, - colorPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - node.materialPreset, - node.slots, - sceneMaterials, - ], + [shading, textures, colorPreset, node, sceneMaterials], ) useRegistry(node.id, node.type, ref) diff --git a/packages/nodes/src/cupola/move-tool.tsx b/packages/nodes/src/cupola/move-tool.tsx index 1d8882179..0e02936bb 100644 --- a/packages/nodes/src/cupola/move-tool.tsx +++ b/packages/nodes/src/cupola/move-tool.tsx @@ -5,6 +5,7 @@ import { type CupolaNode, emitter, type RoofEvent, + type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, @@ -21,8 +22,14 @@ import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, + snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' import CupolaPreview from './preview' /** @@ -70,10 +77,21 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { lastSnap = null setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() + } + + const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { + const rawTarget = roofDrag.resolve(event) + if (!rawTarget) return null + return snapRoofSurfaceNodeTarget({ + target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + node, + bypass: event.nativeEvent?.shiftKey === true, + }) } const updatePreview = (event: RoofEvent) => { - const target = roofDrag.resolve(event) + const target = resolveSnappedTarget(event) if (!target) { clearTarget() return @@ -100,12 +118,18 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { target.localZ, ]), ) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node as RoofNode, + segment: target.segment, + center: [target.localX, target.localY, target.localZ], + node, + }) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? roofDrag.resolve(event) + const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return committed = true const targetSegmentId = target.segment.id as AnyNodeId @@ -146,6 +170,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { if (obj) obj.visible = true triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() exitMoveMode() event.stopPropagation() } @@ -164,6 +189,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { useScene.getState().deleteNode(node.id as AnyNodeId) useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() return } @@ -183,6 +209,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() } @@ -212,6 +239,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true + clearRoofSurfacePlacementGuides() useScene.temporal.getState().resume() } }, [exitMoveMode, node]) diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index 2e1373944..fbf004deb 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { cupolaDefinition } from './definition' import CupolaPreview from './preview' @@ -77,6 +82,12 @@ const CupolaTool = () => { setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode), + }) event.stopPropagation() } @@ -101,6 +112,7 @@ const CupolaTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [cupola.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -112,8 +124,9 @@ const CupolaTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -123,6 +136,7 @@ const CupolaTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/door/preview.tsx b/packages/nodes/src/door/preview.tsx index 8cd9b6375..f2441cafb 100644 --- a/packages/nodes/src/door/preview.tsx +++ b/packages/nodes/src/door/preview.tsx @@ -29,7 +29,7 @@ const DoorPreview = ({ const m = buildDoorPreviewMesh(node) m.layers.set(EDITOR_LAYER) return m - }, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount]) + }, [node]) // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips; // its cleanup only disposes the clones it made. diff --git a/packages/nodes/src/dormer/__tests__/schema.test.ts b/packages/nodes/src/dormer/__tests__/schema.test.ts index 0a16e44cf..eb833c34e 100644 --- a/packages/nodes/src/dormer/__tests__/schema.test.ts +++ b/packages/nodes/src/dormer/__tests__/schema.test.ts @@ -12,7 +12,7 @@ describe('DormerNode schema', () => { expect(parsed.height).toBe(0) expect(parsed.roofType).toBe('gable') expect(parsed.windowShape).toBe('rectangle') - expect(parsed.windowSill).toBe(true) + expect(parsed.windowSill).toBe(false) }) test('windowColumns / windowRows clamped to [1, 8]', () => { diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 17e9581d1..ab7a15185 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -33,6 +33,9 @@ const MAX_SKIRT = 6 const WINDOW_SIDE_HANDLE_OFFSET = 0.15 const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15 const WINDOW_FACE_Z_OFFSET = 0.05 +// The four window-edge arrows latch behind a cube at the window center; +// they stay hidden until the user clicks that cube to open the group. +const WINDOW_LATCH_GROUP = 'dormer-window' // Lower clamp for window dims matches the geometry's internal clamp // in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the // dormer dimensions and are resolved per-handle via the function form @@ -109,21 +112,43 @@ function dormerWidthHandle(side: 'left' | 'right'): HandleDescriptor { +// Depth arrow on the +Z (front) or -Z (back) side. Asymmetric resize: +// dragging one arrow grows the dormer outward from its own edge while +// the opposite edge stays world-fixed in segment frame — same pattern +// as `dormerWidthHandle`, just on the Z axis. `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 +Z onto +// segment frame via (sin r, cos r), find the anchored edge's segment- +// local XZ from the pre-drag node, then place the new center half a new- +// depth away from that anchor in the same direction. +function dormerDepthHandle(side: 'front' | 'back'): HandleDescriptor { + const sign = side === 'front' ? 1 : -1 return { kind: 'linear-resize', axis: 'z', - anchor: 'center', + // 'min' = -Z edge anchored (front arrow grows the +Z edge outward). + // 'max' = +Z edge anchored (back arrow grows the -Z edge outward). + anchor: side === 'front' ? 'min' : 'max', min: MIN_DIM, currentValue: (n) => n.depth, - apply: (_n, newValue) => ({ depth: newValue }), + apply: (initial, newDepth) => { + 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 + return { + depth: newDepth, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, placement: { - position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET], + position: (n) => [0, getBodyMidY(n), sign * (n.depth / 2 + SIDE_HANDLE_OFFSET)], + // The renderer auto-yaws axis-'z' chevrons by -π/2 so the default + // points +Z (front). Flip the back chevron 180° to point -Z. + rotationY: () => (side === 'front' ? 0 : Math.PI), }, } } @@ -273,6 +298,11 @@ function dormerWindowWidthHandle(side: 'left' | 'right'): HandleDescriptor