Merge remote-tracking branch 'origin/main' into feat/baked-glb-export

# Conflicts:
#	packages/editor/src/components/editor/export-manager.tsx
This commit is contained in:
Wassim SAMAD
2026-06-25 12:34:24 -04:00
141 changed files with 15999 additions and 2062 deletions
+3 -2
View File
@@ -17,12 +17,13 @@ https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b
This is a Turborepo monorepo with three main packages:
```
editor-v2/
editor/
├── apps/
│ └── editor/ # Next.js application
├── packages/
│ ├── core/ # Schema definitions, state management, systems
── viewer/ # 3D rendering components
── viewer/ # 3D rendering components
│ └── ui/ # Shared UI components
```
### Separation of Concerns
+1 -1
View File
@@ -7,7 +7,7 @@ A 3D building editor built with React Three Fiber and WebGPU.
This is a Turborepo monorepo with three main packages:
```
editor-v2/
editor/
├── apps/
│ └── editor/ # Next.js application (this package)
├── packages/
+26 -1
View File
@@ -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
</button>
<button
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
activeTool === 'pipe-trap'
? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 hover:bg-muted',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
activateBuildTool(activeTool === 'pipe-trap' ? 'pipe-segment' : 'pipe-trap')
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<Image
alt=""
aria-hidden
className="size-4 object-contain"
height={16}
src="/icons/dwv-pipes.png"
width={16}
/>
Add Trap
</button>
</div>
) : null}
+2 -1
View File
@@ -119,11 +119,12 @@ const levelModeLabels: Record<string, string> = {
solo: 'Solo',
}
const wallModeOrder = ['cutaway', 'up', 'down'] as const
const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const
const wallModeConfig: Record<string, { icon: string; label: string }> = {
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 = [
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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<T>(list: readonly T[], current: T): T {
+1 -1
View File
@@ -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)
}
+39
View File
@@ -182,6 +182,25 @@ export type LinearResizeHandle<N> = {
* 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<N = any> = {
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<N = any> = {
kind: 'latch'
/** The `latchGroup` name whose arrows this cube reveals / hides. */
group: string
placement: HandlePlacement<N>
portal?: HandlePortal
}
export type HandleDescriptor<N = any> =
| LinearResizeHandle<N>
| RadialResizeHandle<N>
@@ -372,6 +410,7 @@ export type HandleDescriptor<N = any> =
| EndpointMoveHandle<N>
| TapActionHandle<N>
| TranslateHandle<N>
| LatchHandle<N>
/**
* Static array, or a function for shape-dependent cases (column
+1
View File
@@ -9,6 +9,7 @@ export type {
HandleList,
HandlePlacement,
HandlePortal,
LatchHandle,
LinearResizeHandle,
RadialResizeHandle,
TapActionHandle,
+17
View File
@@ -1584,6 +1584,23 @@ export type ParametricDescriptor<N> = {
* `updateNodes`.
*/
reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
/**
* 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<AnyNodeId, AnyNode>,
) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
/**
* Extra buttons rendered in the inspector's Actions section
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import { MaterialSchema } from './material'
describe('MaterialSchema', () => {
describe('preset', () => {
test('valid preset passes through unchanged', () => {
const result = MaterialSchema.parse({ preset: 'brick' })
expect(result.preset).toBe('brick')
})
test('every enum preset is accepted', () => {
const presets = [
'white',
'brick',
'concrete',
'wood',
'glass',
'metal',
'plaster',
'tile',
'marble',
'custom',
] as const
for (const preset of presets) {
expect(MaterialSchema.parse({ preset }).preset).toBe(preset)
}
})
test("unknown preset coerces to 'custom' instead of throwing (Sentry MONOREPO-EDITOR-DB)", () => {
const result = MaterialSchema.parse({ preset: 'stone' })
expect(result.preset).toBe('custom')
})
test("non-string preset coerces to 'custom'", () => {
const result = MaterialSchema.parse({ preset: 42 })
expect(result.preset).toBe('custom')
})
test('missing preset stays undefined', () => {
const result = MaterialSchema.parse({})
expect(result.preset).toBeUndefined()
})
test('explicit undefined preset stays undefined', () => {
const result = MaterialSchema.parse({ preset: undefined })
expect(result.preset).toBeUndefined()
})
})
})
+2 -1
View File
@@ -27,7 +27,8 @@ export type MaterialProperties = z.infer<typeof MaterialProperties>
export const MaterialSchema = z.object({
id: z.string().optional(),
preset: MaterialPreset.optional(),
// Coerce unknown presets (legacy/AI-generated data) to 'custom' instead of throwing.
preset: MaterialPreset.catch('custom').optional(),
properties: MaterialProperties.optional(),
texture: z
.object({
+1 -1
View File
@@ -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(
@@ -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.
@@ -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.
@@ -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).
+1 -1
View File
@@ -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
+2
View File
@@ -43,6 +43,8 @@ export {
} from './hosting'
export {
DEFAULT_LEVEL_HEIGHT,
getCeilingAt,
getCeilingHeightAt,
getLevelHeight,
} from './level-height'
export {
@@ -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<AnyNodeId, AnyNode>,
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<AnyNodeId, AnyNode>,
x: number,
z: number,
): number | null {
const ceiling = getCeilingAt(levelId, nodes, x, z)
return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null
}
@@ -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<string, unknown>): AnyNode {
nextId += 1
return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode
}
function sceneOf(...nodes: AnyNode[]): Record<AnyNodeId, AnyNode> {
return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record<AnyNodeId, AnyNode>
}
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<string, unknown>), 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<string, unknown>),
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<string, unknown>),
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<string, unknown>),
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<string, unknown>),
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()
})
})
+349 -117
View File
@@ -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'
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[]
}
| {
/** Partner is another fitting mated collar-to-collar: it translates
* rigidly so its collar stays on the moved collar. */
/** A fitting mated collar-to-collar: it translates rigidly. */
kind: 'rigid-node'
nodeId: AnyNodeId
movedPortId: string
/** Partner node's `position` at edit-start. */
/** Node's `position` at edit-start. */
startPosition: Point
}
| {
/** A run whose endpoint(s) ride the edit: it stretches and/or
* translates, never skews. */
kind: 'run'
nodeId: AnyNodeId
/** 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<string, Record<string, Array<{ nodeId: AnyNodeId; portId: string }>>>
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<string, Point>
/** Reachable run/fitting nodes (excludes the moved node), keyed by id. */
graph: Record<string, GraphNode>
/** 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<string, AnyNode>,
): PortConnectivity {
const movedPorts = portsOf(movedNode) ?? []
const startMovedPorts: Record<string, Point> = {}
const movedPortSystem: Record<string, string | undefined> = {}
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
const movedPorts = portsOf(movedNode) ?? []
const startMovedPorts: Record<string, Point> = {}
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
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 })
}
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
// 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)
}
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 graph: Record<string, GraphNode> = {}
const visited = new Set<string>([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<Point>((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<string, Record<string, Point>>,
): Record<string, Point> {
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<string, Point> {
return {
start: sub(path[0]!, startPath[0]!),
end: sub(path[path.length - 1]!, startPath[startPath.length - 1]!),
}
}
function runPathFromPortDeltas(startPath: Point[], portDeltas: Record<string, Point>): 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<AnyNode> }[] {
const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity
if (Object.keys(graph).length === 0) return []
const newPorts = portsOf(previewNode) ?? []
const newById: Record<string, Point> = {}
for (const p of newPorts) newById[p.id] = p.position
const newMovedPos: Record<string, Point> = {}
for (const p of newPorts) newMovedPos[p.id] = p.position
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
for (const conn of connectivity.connections) {
const start = connectivity.startMovedPorts[conn.movedPortId]
const now = newById[conn.movedPortId]
if (!start || !now) continue
// 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<string, { id: AnyNodeId; data: Partial<AnyNode> }> = {}
const constrainedPorts: Record<string, Record<string, Record<string, Point>>> = {}
const propagatedPorts: Record<string, Record<string, Point>> = {}
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<AnyNode> })
} 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<AnyNode>,
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<AnyNode>,
}
// 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<AnyNode> }
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)
}
@@ -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)) {
+61 -8
View File
@@ -547,9 +547,18 @@ function migrateNodes(nodes: Record<string, any>): {
// any per-type migration runs, so already-saved scenes load cleanly.
const { nodes: healed } = healSceneNodes(nodes)
const patchedNodes = { ...healed } as Record<string, any>
// Scene materials minted while moving legacy wall fields onto `node.slots`;
// merged into the scene material map by the caller (`setScene`).
const mintedMaterials: Record<SceneMaterialId, SceneMaterial> = {}
// Pass 1: all node types except elevator.
// Elevator migration (migrateElevatorParent) mutates level.children to remove
// the elevator ID. If the elevator is processed before its parent level in
// Object.entries order, the level migration in this same pass would then see
// a children array that still contains the elevator ID and filter it out as
// "missing" — corrupting the level. Running elevators in a second pass after
// all levels are stable avoids the race entirely.
for (const [id, node] of Object.entries(patchedNodes)) {
// 1. Item scale migration
if (node.type === 'item' && !('scale' in node)) {
@@ -682,14 +691,6 @@ function migrateNodes(nodes: Record<string, any>): {
)
}
if (node.type === 'elevator') {
const parentMigrated = migrateElevatorParent(id, node, patchedNodes)
const normalized = normalizeElevatorNode(parentMigrated)
if (normalized) {
patchedNodes[id] = normalized
}
}
// Roof-segment hosting was added in this migration cycle (the same
// pattern as shelf above). Older segments saved before the schema
// gained `children` need the field initialised so
@@ -778,7 +779,59 @@ function migrateNodes(nodes: Record<string, any>): {
patchedNodes[id] = { ...node, children: flattened }
}
}
// Level children normalization.
// Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node
// map (e.g. elevator IDs that lived under a level before the elevator
// parent migration moved them up to building). If those dangling IDs are
// left in place, collectReachableNodeIds marks the level as having
// reachable children that don't exist, which corrupts the scene graph
// traversal and leaves the LevelNode in a broken state — making floors
// impossible to drag or delete after import.
// We intentionally do NOT filter by type prefix here; being permissive
// about which types are allowed as children prevents data loss when new
// child types are added to the schema in the future.
if (node.type === 'level') {
const rawChildren = getStringArray(node.children)
const validChildren = rawChildren.filter((childId) => {
const exists = Boolean(patchedNodes[childId])
if (!exists) {
console.warn(
'[migrateNodes] level',
id,
'references missing child',
childId,
'— dropping',
)
}
return exists
})
const levelNumber = getFiniteNumber(node.level, 0)
patchedNodes[id] = {
...node,
level: levelNumber,
children: validChildren,
}
}
}
// Pass 2: elevator migration.
// migrateElevatorParent mutates the parent level's children array (removes
// the elevator ID from it). Running this after Pass 1 guarantees that the
// level normalization above has already seen a clean children list — if we
// ran elevator migration inside Pass 1, the order of Object.entries
// iteration would be non-deterministic: processing an elevator before its
// parent level would mutate the level's children mid-iteration, potentially
// causing the level branch above to see a stale node reference.
for (const [id, node] of Object.entries(patchedNodes)) {
if (node.type !== 'elevator') continue
const parentMigrated = migrateElevatorParent(id, node, patchedNodes)
const normalized = normalizeElevatorNode(parentMigrated)
if (normalized) {
patchedNodes[id] = normalized
}
}
return { nodes: patchedNodes as Record<string, AnyNode>, mintedMaterials }
}
@@ -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()
@@ -1147,6 +1147,7 @@ export default function Editor({
<CeilingSystem />
<RoofEditSystem />
<StairEditSystem />
{isFirstPersonMode && <FirstPersonControls />}
<CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
<InteractiveSystem />
@@ -1205,8 +1206,14 @@ export default function Editor({
{!isLoading && isPreviewMode ? (
<div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
<div className="h-full w-full">{previewViewerContent}</div>
{isFirstPersonMode ? (
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
) : (
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
)}
<div className="h-full w-full" data-pascal-viewer-3d>
{previewViewerContent}
</div>
</div>
) : (
<>
@@ -1270,8 +1277,14 @@ export default function Editor({
{!isLoading && isPreviewMode ? (
<>
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
<div className="h-full w-full">{previewViewerContent}</div>
{isFirstPersonMode ? (
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
) : (
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
)}
<div className="h-full w-full" data-pascal-viewer-3d>
{previewViewerContent}
</div>
</>
) : (
<>
@@ -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<number | null>(null)
const [preDragNode, setPreDragNode] = useState<AnyNode | null>(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<ReadonlySet<string>>(() => 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<HandleDragControls>(
() => ({
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) => (
<ArrowHandle
activeIndex={activeIndex}
descriptor={descriptor}
dragControls={dragControls}
handleIndex={index}
// Descriptors come from a per-node-kind static list, so index is a
// stable identity within this node's selection cycle.
key={index}
liveNode={node}
preDragNode={preDragNode}
rideObject={arrowFrame}
suppressFreeze={activeIsTranslate}
/>
))
const arrows = descriptors.map((descriptor, index) => {
// A `latch` cube toggles its group's visibility; render it always.
if (descriptor.kind === 'latch') {
return (
<LatchCube
descriptor={descriptor}
key={index}
node={node}
onToggle={toggleLatchGroup}
open={openLatchGroups.has(descriptor.group)}
/>
)
}
// 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 (
<ArrowHandle
activeIndex={activeIndex}
descriptor={descriptor}
dragControls={dragControls}
handleIndex={index}
key={index}
liveNode={node}
preDragNode={preDragNode}
rideObject={arrowFrame}
suppressFreeze={activeIsTranslate}
/>
)
})
return createPortal(
<group ref={outerRef}>
@@ -668,6 +704,11 @@ function LinearArrow({
? 1
: -1
// Last value an emitted resize tick fired at — a new tick fires only
// when the (snapped + clamped) value actually changes, so the cue
// tracks real size steps instead of every sub-pixel pointer jitter.
let lastTickValue = initialValue
return {
overrideId,
onBegin: () => {
@@ -695,6 +736,10 @@ function LinearArrow({
? snapScalar(rawNext, gridSnapStep)
: rawNext
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
if (next !== lastTickValue) {
lastTickValue = next
sfxEmitter.emit('sfx:resize')
}
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
// Let the kind publish live guides for the edge being resized.
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
@@ -708,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
@@ -797,6 +851,7 @@ function LinearArrow({
onPointerDown={activate}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="chevron"
thin
>
{showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
</HandleArrow>
@@ -1192,6 +1247,7 @@ function ArcArrow({
baseScale,
}}
shape={isRotateShape ? 'curved-arrow' : 'chevron'}
thin
/>
</>
)
@@ -1314,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<AnyNode>
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 (
<HandleArrow
cursor="grab"
hover={isHovered || open}
hoverScale={1.15}
onHoverChange={setIsHovered}
onPointerDown={onPointerDown}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="tracker"
/>
)
}
@@ -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
@@ -12,12 +12,28 @@ interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
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<Group, CursorSphereProps>(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<Group, CursorSphereProps>(function Cursor
return (
<group ref={ref} {...props} visible={isVisible}>
{/* 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. */}
<group rotation={[-Math.PI / 2, 0, 0]}>
{/* Center dot */}
<mesh layers={EDITOR_LAYER} renderOrder={2}>
<circleGeometry args={[0.06, 32]} />
<meshBasicMaterial
color={color}
depthTest={false}
depthWrite={false}
opacity={0.9}
transparent
/>
</mesh>
{/* Center dot — at the ground unless the placement point is elevated */}
{!dotAtTip && (
<mesh layers={EDITOR_LAYER} renderOrder={2}>
<circleGeometry args={[0.06, 32]} />
<meshBasicMaterial
color={color}
depthTest={false}
depthWrite={false}
opacity={0.9}
transparent
/>
</mesh>
)}
{/* Outer ring / glow */}
<mesh layers={EDITOR_LAYER} renderOrder={2}>
@@ -60,7 +80,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
color={color}
depthTest={false}
depthWrite={false}
opacity={0.25}
opacity={dotAtTip ? 0.2 : 0.25}
transparent
/>
</mesh>
@@ -80,6 +100,15 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
</mesh>
)}
{/* 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 && (
<mesh layers={EDITOR_LAYER} position={[0, height, 0]} renderOrder={2}>
<sphereGeometry args={[0.08, 20, 14]} />
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} />
</mesh>
)}
{/* Tool Icon Tooltip at the top of the line */}
{isVisible && showTooltip && (activeToolConfig || tooltipContent) && (
<Html
@@ -245,10 +245,10 @@ export function EditorCommands() {
label: 'Wall Mode',
group: 'Viewer Controls',
icon: <Layers className="h-4 w-4" />,
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'),
@@ -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' && (
<Command.Group heading="Wall Mode">
{(['cutaway', 'up', 'down'] as const).map((mode) => (
{(['cutaway', 'up', 'down', 'translucent'] as const).map((mode) => (
<OptionItem
isActive={wallMode === mode}
key={mode}
@@ -72,17 +72,23 @@ export function HelperManager() {
.filter((node): node is AnyNode => 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
@@ -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<HTMLCanvasElement>('[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) => (
<img alt="Translucent" height={28} src="/icons/wall.png" width={28} {...props} />
),
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"
/>
</ActionButton>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* First-person walkthrough */}
<ActionButton
className="hover:bg-white/5 hover:text-emerald-400"
label="Walkthrough"
onClick={() => {
flushSync(() => useEditor.getState().setFirstPersonMode(true))
requestWalkthroughPointerLock()
}}
size="icon"
tooltipSide="top"
variant="ghost"
>
<Footprints className="h-6 w-6" />
</ActionButton>
</div>
</TooltipProvider>
</div>
+21 -1
View File
@@ -40,7 +40,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,
@@ -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({
+2
View File
@@ -10,6 +10,7 @@ type SFXEvents = {
'sfx:item-pick': undefined
'sfx:item-place': undefined
'sfx:item-rotate': undefined
'sfx:resize': undefined
'sfx:structure-build-start': undefined
'sfx:structure-build': undefined
'sfx:structure-delete': undefined
@@ -40,6 +41,7 @@ export function initSFXBus() {
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
sfxEmitter.on('sfx:resize', () => playSFX('resize'))
sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
+10
View File
@@ -59,6 +59,16 @@ export const SFX: Record<string, SFXConfig> = {
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
// Ticks as a resize handle is dragged across snap steps. Fires in rapid
// succession, so it mirrors gridSnap: three variations cycled round-robin
// with pitch/pan jitter and a gap so the run reads as texture, not a tone.
resize: {
src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
rateRange: [0.98, 1.02],
volumeRange: [0.26, 0.34],
panJitter: 0.15,
minIntervalMs: 80,
},
// Fired when a structure draft begins (first click of a wall/slab/etc).
structureBuildStart: {
src: '/audios/sfx/structure_build_start.mp3',
+30 -2
View File
@@ -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])
+18 -1
View File
@@ -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 && (
+153 -21
View File
@@ -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<string, unknown>)
: {}
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<string, unknown>)
: {}
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])
+15 -1
View File
@@ -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 && (
+1 -12
View File
@@ -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)
+30 -2
View File
@@ -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])
+15 -1
View File
@@ -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 && (
+1 -1
View File
@@ -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.
@@ -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]', () => {
+67 -10
View File
@@ -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<DormerNodeT
}
}
// Depth arrow on the +Z side. Symmetric (anchor 'center') to match
// chimney's known-working handle count — splitting depth into asymmetric
// front + back chevrons puts the dormer over the per-node MRT/TSL
// budget that chimney already documents (see `chimneyHandles` factory).
// Re-evaluate the split once that pipeline issue is pinned down.
function dormerDepthHandle(): HandleDescriptor<DormerNodeType> {
// 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<DormerNodeType> {
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<Dorme
return {
kind: 'linear-resize',
axis: 'x',
// Stand the blade up into the gable face so it reads flat-on like the
// top/bottom window-height arrows instead of edge-on.
faceNormal: true,
// Hidden until the user clicks the window-center latch cube.
latchGroup: WINDOW_LATCH_GROUP,
anchor: side === 'right' ? 'min' : 'max',
min: MIN_WINDOW_DIM,
// Cap at the dormer's window field — keep a 0.1m gap on each side
@@ -317,6 +347,8 @@ function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor<Dorm
return {
kind: 'linear-resize',
axis: 'y',
// Hidden until the user clicks the window-center latch cube.
latchGroup: WINDOW_LATCH_GROUP,
// 'min' = bottom edge anchored (top arrow grows the top edge up).
// 'max' = top edge anchored (bottom arrow drops the bottom edge).
anchor: side === 'top' ? 'min' : 'max',
@@ -350,12 +382,37 @@ function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor<Dorm
}
}
// Window-center latch cube. Sits at the window center on the exposed
// gable face; clicking it reveals / hides the four window edge arrows
// (width L/R + height top/bottom) tagged with `WINDOW_LATCH_GROUP`.
// Mirrors the duct-fitting selection cube but driven by the shared
// latch descriptor so the dense window cluster stays collapsed behind
// one grip until the user opts in.
function dormerWindowLatchHandle(): HandleDescriptor<DormerNodeType> {
return {
kind: 'latch',
group: WINDOW_LATCH_GROUP,
placement: {
position: (n, sceneApi) => {
const faceSign = getExposedFaceZSign(n, sceneApi)
return [
n.windowOffsetX,
getWindowCenterY(n),
faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET),
]
},
},
}
}
const dormerHandles: HandleDescriptor<DormerNodeType>[] = [
dormerWidthHandle('right'),
dormerWidthHandle('left'),
dormerDepthHandle(),
dormerDepthHandle('front'),
dormerDepthHandle('back'),
dormerWallHeightHandle(),
dormerRotateHandle(),
dormerWindowLatchHandle(),
dormerWindowWidthHandle('right'),
dormerWindowWidthHandle('left'),
dormerWindowHeightHandle('top'),
+81 -69
View File
@@ -11,6 +11,7 @@ import {
import { useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo } from 'react'
import { DormerPlacementGuides } from './placement-guides'
import DormerPreview from './preview'
import { useDormerPlacement } from './use-dormer-placement'
@@ -74,84 +75,95 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
}
}, [node.id, isNew])
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
initialRotation: originalRotation,
relativeStart: {
position: [...node.position] as [number, number, number],
roofSegmentId: node.roofSegmentId,
},
onCommit: (hit, rotation) => {
const state = useScene.getState()
const { activeBuildingId, segmentXform, hitSegment, hitLocal, ghostRotation } =
useDormerPlacement({
initialRotation: originalRotation,
relativeStart: {
position: [...node.position] as [number, number, number],
roofSegmentId: node.roofSegmentId,
},
onCommit: (hit, rotation) => {
const state = useScene.getState()
// Strip the `isNew` / `isTransient` flags — only used to mark a
// clone or in-flight move that hasn't been committed yet.
const cleanedMeta = (() => {
const m =
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
: {}
const {
isNew: _isNew,
isTransient: _isTransient,
...rest
} = m as {
isNew?: boolean
isTransient?: boolean
}
return Object.keys(rest).length > 0 ? rest : undefined
})()
// Strip the `isNew` / `isTransient` flags — only used to mark a
// clone or in-flight move that hasn't been committed yet.
const cleanedMeta = (() => {
const m =
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
: {}
const {
isNew: _isNew,
isTransient: _isTransient,
...rest
} = m as {
isNew?: boolean
isTransient?: boolean
}
return Object.keys(rest).length > 0 ? rest : undefined
})()
if (isNew || !node.id) {
const { id: _id, ...rest } = node
const committed = DormerNodeSchema.parse({
...rest,
roofSegmentId: hit.segment.id,
parentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ],
rotation,
metadata: cleanedMeta,
})
state.createNode(committed, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [committed.id] })
} else {
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
state.updateNode(node.id as AnyNodeId, {
roofSegmentId: hit.segment.id,
parentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ],
rotation,
metadata: cleanedMeta,
})
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
// Unlist from previous segment's children and add to the new one.
if (prevSegmentId && prevSegmentId !== (hit.segment.id as AnyNodeId)) {
const prevSeg = state.nodes[prevSegmentId] as RoofSegmentNode | undefined
if (prevSeg) {
state.updateNode(prevSegmentId, {
children: (prevSeg.children ?? []).filter((id) => id !== node.id),
})
}
const newSeg = state.nodes[hit.segment.id as AnyNodeId] as RoofSegmentNode | undefined
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
state.updateNode(hit.segment.id as AnyNodeId, {
children: [...(newSeg.children ?? []), node.id],
})
if (isNew || !node.id) {
const { id: _id, ...rest } = node
const committed = DormerNodeSchema.parse({
...rest,
roofSegmentId: hit.segment.id,
parentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ],
rotation,
metadata: cleanedMeta,
})
state.createNode(committed, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [committed.id] })
} else {
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
state.updateNode(node.id as AnyNodeId, {
roofSegmentId: hit.segment.id,
parentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ],
rotation,
metadata: cleanedMeta,
})
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
// Unlist from previous segment's children and add to the new one.
if (prevSegmentId && prevSegmentId !== (hit.segment.id as AnyNodeId)) {
const prevSeg = state.nodes[prevSegmentId] as RoofSegmentNode | undefined
if (prevSeg) {
state.updateNode(prevSegmentId, {
children: (prevSeg.children ?? []).filter((id) => id !== node.id),
})
}
const newSeg = state.nodes[hit.segment.id as AnyNodeId] as RoofSegmentNode | undefined
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
state.updateNode(hit.segment.id as AnyNodeId, {
children: [...(newSeg.children ?? []), node.id],
})
}
}
setSelection({ selectedIds: [node.id] })
}
setSelection({ selectedIds: [node.id] })
}
const dormerObj = sceneRegistry.nodes.get(node.id)
if (dormerObj) dormerObj.visible = true
setMovingNode(null)
},
})
const dormerObj = sceneRegistry.nodes.get(node.id)
if (dormerObj) dormerObj.visible = true
setMovingNode(null)
},
})
if (!activeBuildingId || !segmentXform || !hitLocal) return null
return (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
{hitSegment && (
<DormerPlacementGuides
center={hitLocal}
depth={previewNode.depth}
movingId={node.id}
rotation={ghostRotation}
segment={hitSegment}
width={previewNode.width}
/>
)}
<group position={hitLocal}>
<group rotation-y={ghostRotation}>
<DormerPreview node={previewNode} />
@@ -0,0 +1,273 @@
'use client'
import type { RoofSegmentNode } from '@pascal-app/core'
import { EDITOR_LAYER, formatMeasurement } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useMemo } from 'react'
import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { getRoofSurfaceFaceBoundsAt } from '../shared/roof-surface'
import {
roofFaceKey,
roofGuideBounds,
roofSiblingSpacing,
} from '../shared/roof-surface-placement-guides'
// Indigo — matches the wall/window 3D proximity guide accent so every
// "distance to edge" readout reads the same across the app.
const GUIDE_COLOR = 0x81_8c_f8
const ALIGN_COLOR = 0xef_44_44
const PILL_BG = '#6366f1'
const BADGE_BG = '#ec4899'
// Lift the lines a hair off the sloped surface so they don't z-fight the
// roof + dormer ghost.
const SURFACE_LIFT = 0.02
// Hide a gap that has collapsed (dormer edge flush to / past the roof edge)
// so we don't draw a degenerate "0m" pill.
const MIN_GAP_M = 0.02
const guideMaterial = new LineBasicNodeMaterial({
color: GUIDE_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
})
const alignMaterial = new LineBasicNodeMaterial({
color: ALIGN_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
})
type Vec3 = [number, number, number]
type DormerGuide =
| {
id: string
from: Vec3
to: Vec3
kind: 'align-line' | 'dimension'
value?: number
}
| {
id: string
at: Vec3
kind: 'badge'
value: number
}
/**
* Live "distance to roof edge" guides shown while a dormer ghost is being
* placed or dragged — the roof-plane analog of the window's sill/head +
* edge-proximity pills. Renders measured lines from each side-center of
* the dormer's occupied roof area out to the active roof face edges, each
* with a distance pill at its midpoint.
*
* Mounted as a sibling of `<DormerPreview>` INSIDE the segment-local frame
* (the `segmentXform` group) but OUTSIDE the dormer's `hitLocal` + rotation
* groups, so its coordinates are segment-local. The roof-face boundary is
* resolved from the actual visible top face under `center`, not from the
* wall footprint dimensions.
*
* Normal roof accessories use side-center readouts. Linear accessories
* like ridge vents and gutters use their own two-end guide mode.
*/
export function DormerPlacementGuides({
segment,
center,
width,
depth,
rotation,
movingId,
}: {
segment: RoofSegmentNode
center: Vec3
width: number
depth: number
rotation: number
movingId?: string
}) {
const unit = useViewer((s) => s.unit)
const [cx, , cz] = center
const faceBounds = getRoofSurfaceFaceBoundsAt(segment, cx, cz)
const halfW = Math.max(0, width) / 2
const halfD = Math.max(0, depth) / 2
const cos = Math.cos(rotation)
const sin = Math.sin(rotation)
const halfX = Math.abs(cos) * halfW + Math.abs(sin) * halfD
const halfZ = Math.abs(sin) * halfW + Math.abs(cos) * halfD
const movingBounds = roofGuideBounds(center, { width, depth, rotation })
const surfaceY = (x: number, z: number): number => faceBounds.surfaceYAt(x, z) + SURFACE_LIFT
const xInterval = faceBounds.xIntervalAtZ(cz)
const zInterval = faceBounds.zIntervalAtX(cx)
const guides: DormerGuide[] = []
const push = (id: string, ax: number, az: number, bx: number, bz: number) => {
const from: Vec3 = [ax, surfaceY(ax, az), az]
const to: Vec3 = [bx, surfaceY(bx, bz), bz]
const value = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2])
if (value < MIN_GAP_M) return
guides.push({
id,
from,
to,
kind: 'dimension',
value,
})
}
const siblingSpacing = roofSiblingSpacing<DormerGuide>({
segment,
movingId,
movingBounds,
faceKey: roofFaceKey(faceBounds.polygon),
dimension: (id, [ax, az], [bx, bz]) => {
const from: Vec3 = [ax, surfaceY(ax, az), az]
const to: Vec3 = [bx, surfaceY(bx, bz), bz]
const value = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2])
if (value < MIN_GAP_M) return null
return { id, from, to, kind: 'dimension', value }
},
alignLine: (id, [ax, az], [bx, bz]) => {
const from: Vec3 = [ax, surfaceY(ax, az), az]
const to: Vec3 = [bx, surfaceY(bx, bz), bz]
const value = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2])
if (value < MIN_GAP_M) return null
return { id, from, to, kind: 'align-line' }
},
badge: (id, [x, z], value) => {
if (value < MIN_GAP_M) return null
return {
id,
at: [x, surfaceY(x, z), z],
kind: 'badge',
value,
}
},
measure: ([ax, az], [bx, bz]) => {
const ay = surfaceY(ax, az)
const by = surfaceY(bx, bz)
return Math.hypot(bx - ax, by - ay, bz - az)
},
})
if (xInterval) {
const [faceMinX, faceMaxX] = xInterval
const itemMinX = Math.max(faceMinX, Math.min(faceMaxX, cx - halfX))
const itemMaxX = Math.max(faceMinX, Math.min(faceMaxX, cx + halfX))
if (!siblingSpacing.blockedSides.left && itemMinX > faceMinX + MIN_GAP_M) {
push('left', faceMinX, cz, itemMinX, cz)
}
if (!siblingSpacing.blockedSides.right && itemMaxX < faceMaxX - MIN_GAP_M) {
push('right', itemMaxX, cz, faceMaxX, cz)
}
}
if (zInterval) {
const [faceMinZ, faceMaxZ] = zInterval
const itemMinZ = Math.max(faceMinZ, Math.min(faceMaxZ, cz - halfZ))
const itemMaxZ = Math.max(faceMinZ, Math.min(faceMaxZ, cz + halfZ))
if (!siblingSpacing.blockedSides.bottom && itemMinZ > faceMinZ + MIN_GAP_M) {
push('back', cx, faceMinZ, cx, itemMinZ)
}
if (!siblingSpacing.blockedSides.top && itemMaxZ < faceMaxZ - MIN_GAP_M) {
push('front', cx, itemMaxZ, cx, faceMaxZ)
}
}
guides.push(...siblingSpacing.guides)
return (
<>
{guides.map((g) => (
<Guide key={g.id} guide={g} unit={unit} />
))}
</>
)
}
function Guide({ guide, unit }: { guide: DormerGuide; unit: 'metric' | 'imperial' }) {
if (guide.kind === 'badge') {
return <GuideBadge at={guide.at} pill={`= ${formatMeasurement(guide.value, unit)}`} />
}
return (
<GuideLine
from={guide.from}
kind={guide.kind}
pill={guide.value === undefined ? undefined : formatMeasurement(guide.value, unit)}
to={guide.to}
/>
)
}
function GuideBadge({ at, pill }: { at: Vec3; pill: string }) {
return (
<Html
center
position={at}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[20, 0]}
>
<div
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-semibold font-sans text-[11px] text-white"
style={{ backgroundColor: BADGE_BG }}
>
{pill}
</div>
</Html>
)
}
function GuideLine({
from,
to,
pill,
kind,
}: {
from: Vec3
to: Vec3
pill?: string
kind: DormerGuide['kind']
}) {
const { line, position } = useMemo(() => {
const position = new Float32BufferAttribute(new Float32Array(6), 3)
const geometry = new BufferGeometry()
geometry.setAttribute('position', position)
const line = new ThreeLine(geometry, kind === 'align-line' ? alignMaterial : guideMaterial)
line.frustumCulled = false
line.layers.set(EDITOR_LAYER)
line.renderOrder = 1000
return { line, position }
}, [kind])
position.setXYZ(0, from[0], from[1], from[2])
position.setXYZ(1, to[0], to[1], to[2])
position.needsUpdate = true
useEffect(() => () => line.geometry.dispose(), [line])
const mid: Vec3 = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2, (from[2] + to[2]) / 2]
return (
<>
<primitive object={line} />
{pill ? (
<Html
center
position={mid}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[20, 0]}
>
<div
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
style={{ backgroundColor: PILL_BG }}
>
{pill}
</div>
</Html>
) : null}
</>
)
}
+11 -1
View File
@@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { dormerDefinition } from './definition'
import { DormerPlacementGuides } from './placement-guides'
import DormerPreview from './preview'
import { useDormerPlacement } from './use-dormer-placement'
@@ -48,7 +49,7 @@ const DormerTool = () => {
[],
)
const { activeBuildingId, clearPreview, segmentXform, hitLocal, ghostRotation } =
const { activeBuildingId, clearPreview, segmentXform, hitSegment, hitLocal, ghostRotation } =
useDormerPlacement({
onCommit: (hit, rotation) => {
const state = useScene.getState()
@@ -78,6 +79,15 @@ const DormerTool = () => {
/>
{activeBuildingId && segmentXform && hitLocal && (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
{hitSegment && (
<DormerPlacementGuides
center={hitLocal}
depth={previewNode.depth}
rotation={ghostRotation}
segment={hitSegment}
width={previewNode.width}
/>
)}
<group position={hitLocal}>
<group rotation-y={ghostRotation}>
<DormerPreview node={previewNode} />
@@ -60,12 +60,14 @@ export function useDormerPlacement(opts: {
activeBuildingId: string | undefined
clearPreview: () => void
segmentXform: DormerSegmentTransform | null
hitSegment: RoofSegmentNode | null
hitLocal: [number, number, number] | null
ghostRotation: number
} {
const activeBuildingId = useViewer((s) => s.selection.buildingId)
const [segmentXform, setSegmentXform] = useState<DormerSegmentTransform | null>(null)
const [hitSegment, setHitSegment] = useState<RoofSegmentNode | null>(null)
const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null)
const [ghostRotation, setGhostRotation] = useState(opts.initialRotation ?? 0)
const lastSnapRef = useRef<[number, number] | null>(null)
@@ -81,6 +83,7 @@ export function useDormerPlacement(opts: {
const clearPreview = () => {
setSegmentXform(null)
setHitSegment(null)
setHitLocal(null)
}
@@ -136,6 +139,7 @@ export function useDormerPlacement(opts: {
const xform = computeSegmentXform(hit.segment.id)
if (!xform) return
setSegmentXform(xform)
setHitSegment(hit.segment)
// Lift the ghost to the actual roof-surface Y at the cursor so
// it tracks the mouse along the slope. The CSG inside
// `generateDormerGeometry` carves the dormer against the host
@@ -200,6 +204,7 @@ export function useDormerPlacement(opts: {
activeBuildingId: activeBuildingId ?? undefined,
clearPreview,
segmentXform,
hitSegment,
hitLocal,
ghostRotation,
}
@@ -1,4 +1,5 @@
import type { NodeDefinition } from '@pascal-app/core'
import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint'
import { rotateFittingNode } from '../shared/fitting-rotation'
import { buildDuctFittingFloorplan } from './floorplan'
import { buildDuctFittingGeometry } from './geometry'
@@ -29,16 +30,16 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
position: [0, 0, 0],
rotation: [0, 0, 0],
fittingType: 'elbow',
shape: 'round',
shape: 'rect',
width: 14,
height: 8,
shape2: 'round',
shape2: 'rect',
width2: 14,
height2: 8,
angle: 90,
branchAngle: 90,
diameter: 6,
diameter2: 6,
diameter: 12,
diameter2: 12,
ductMaterial: 'sheet-metal',
system: 'supply',
}),
@@ -51,6 +52,8 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
duplicable: true,
deletable: true,
slots: () => ductBodySlots(),
paint: ductBodyPaint,
},
parametrics: ductFittingParametrics,
@@ -75,6 +78,7 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
n.diameter2,
n.ductMaterial,
n.system,
n.slots,
]),
ports: getDuctFittingPorts,
+27 -5
View File
@@ -1,3 +1,5 @@
import type { GeometryContext } from '@pascal-app/core'
import type { ColorPreset, RenderShading } from '@pascal-app/viewer'
import {
BufferGeometry,
CylinderGeometry,
@@ -5,8 +7,8 @@ import {
Euler,
Float32BufferAttribute,
Group,
type Material,
Mesh,
type MeshStandardMaterial,
SphereGeometry,
TorusGeometry,
Vector3,
@@ -18,6 +20,7 @@ import {
createDuctMaterial,
INCHES_TO_METERS,
} from '../duct-segment/geometry'
import { DUCT_BODY_SLOT_ID } from '../shared/duct-body-paint'
import { localFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
@@ -76,7 +79,7 @@ function buildMiteredElbow(
sweepM: number,
cheekM: number,
profileShape: 'rect' | 'oval',
material: MeshStandardMaterial,
material: Material,
): Mesh {
const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction
const travelOut = outletPos.clone().normalize() // junction → outlet
@@ -155,7 +158,7 @@ function buildRectToRoundLoft(
widthM: number,
heightM: number,
radius: number,
material: MeshStandardMaterial,
material: Material,
): Mesh {
const hw = widthM / 2
const hh = heightM / 2
@@ -212,9 +215,23 @@ function buildRectToRoundLoft(
* height rides local +Y — for the horizontal-plane orientations trunks
* are drawn in, that's world-vertical.
*/
export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
export function buildDuctFittingGeometry(
node: DuctFittingNode,
ctx?: GeometryContext,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): Group {
const group = new Group()
const material = createDuctMaterial(node)
const material = createDuctMaterial(
node,
ctx?.materials,
shading,
textures,
colorPreset,
sceneTheme,
)
const radiusMain = (node.diameter * INCHES_TO_METERS) / 2
const ports = localFittingPorts(node)
const widthM = node.width * INCHES_TO_METERS
@@ -459,5 +476,10 @@ export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
group.add(collar)
}
group.traverse((object) => {
const mesh = object as Mesh
if (mesh.isMesh) mesh.userData.slotId = DUCT_BODY_SLOT_ID
})
return group
}
@@ -0,0 +1,38 @@
'use client'
import { ActionButton } from '@pascal-app/editor'
import { ArrowLeftRight } from 'lucide-react'
import type { DuctFittingNode } from './schema'
const WIDTH_MIN = 4
const WIDTH_MAX = 60
const HEIGHT_MIN = 3
const HEIGHT_MAX = 40
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
export function DuctFittingSizeSwapEditor({
node,
onUpdate,
}: {
node: DuctFittingNode
onUpdate: (patch: Partial<DuctFittingNode>) => void
}) {
const nextWidth = clamp(node.height, WIDTH_MIN, WIDTH_MAX)
const nextHeight = clamp(node.width, HEIGHT_MIN, HEIGHT_MAX)
return (
<div className="px-2">
<ActionButton
className="h-8 w-full flex-none"
icon={<ArrowLeftRight className="h-3.5 w-3.5" />}
label="Swap W/H"
onClick={() => onUpdate({ width: nextWidth, height: nextHeight })}
title="Swap width and height"
type="button"
/>
</div>
)
}
+75 -6
View File
@@ -11,6 +11,7 @@ import {
useScene,
} from '@pascal-app/core'
import {
consumePlacementDragRelease,
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
@@ -22,11 +23,13 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react'
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
import { buildDuctFittingGeometry } from './geometry'
type Vec3 = [number, number, number]
@@ -174,11 +177,27 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
if (existedAtStart) setMeshHidden(true)
// Carry connected ducts as the fitting slides: the part of the move along
// a run's axis stretches it, the part across translates the whole run (and
// propagates to its far joint). Snapshot once at drag start; only existing
// fittings are mated to anything.
const connectivity: RunMoveConnectivity | null = existedAtStart
? startRunMoveConnectivity(node)
: null
let lastPos: Vec3 = originalPosition
// Tracks whether the last frame held Alt: the fitting is detached from its
// connected ducts for the drag, so they stay put (no follow) and the
// commit omits their updates. Mirrors the duct endpoint's Alt-detach.
let lastDetached = false
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
// Alt = detach: drop the connected-duct follow so the fitting moves on
// its own, leaving every mated run where it sits.
const detached = event.nativeEvent?.altKey === true
const snap = bypass ? (v: number) => v : snapToGridStep
let x = snap(event.localPosition[0])
let z = snap(event.localPosition[2])
@@ -198,17 +217,27 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
} else {
useAlignmentGuides.getState().clear()
}
const next: Vec3 = [x, lastPos[1], z]
const next: Vec3 = [x, originalPosition[1], z]
if (next[0] !== lastPos[0] || next[2] !== lastPos[2]) triggerSFX('sfx:grid-snap')
if (next[0] !== lastPos[0] || next[1] !== lastPos[1] || next[2] !== lastPos[2]) {
triggerSFX('sfx:grid-snap')
}
lastPos = next
lastDetached = detached
hasMoved = true
setCursorPos(next)
// Detached: keep the followers at their origin (drop any live overrides
// from a prior non-detached frame). Otherwise preview the follow.
if (detached) connectivity?.clear()
else connectivity?.preview({ position: next })
}
const commit = (event: GridEvent) => {
const commit = (event: GridEvent, fromDragRelease = false) => {
if (committed) return
if (Date.now() - activatedAt < 150) {
// The 150ms debounce only guards click-to-place against the arming click
// double-firing; a press-drag release is a distinct pointerup gesture, so
// it skips the guard (a quick drag-flick still commits).
if (!fromDragRelease && Date.now() - activatedAt < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
@@ -230,10 +259,24 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { position: lastPos } as Partial<AnyNode>)
useScene.getState().markDirty(nodeId)
// Fold connected-duct / sibling-run follow-updates into the SAME batch
// as the moved fitting so the whole joint is one undo step. Detached
// (Alt on the final frame): the joint is broken, so nothing follows.
const followUpdates = lastDetached
? []
: (connectivity?.commitUpdates({ position: lastPos }) ?? [])
const scene = useScene.getState()
scene.updateNodes([
{ id: nodeId, data: { position: lastPos } as Partial<AnyNode> },
...followUpdates,
...autoOffsetInvalidationUpdates(scene.nodes, nodeId),
])
scene.markDirty(nodeId)
}
useScene.temporal.getState().pause()
// Followers are committed to the store — drop their live overrides so
// renderers read the canonical path/position.
connectivity?.clear()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
@@ -245,6 +288,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
const onCancel = () => {
connectivity?.clear()
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -256,14 +300,39 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
useEditor.getState().setMovingNode(null)
}
// Press-drag-release: when the move was engaged by the drag gesture (the
// selection rig's move cross or a future floating drag), `placementDragMode`
// is set, so commit on pointer-up at the last previewed position instead of
// waiting for a second click — same contract as every other move tool.
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
// A press-release that never moved isn't a placement — back out cleanly
// (drop the ghost, re-select the fitting) instead of leaving the tool
// armed waiting for a click.
if (!hasMoved) {
onCancel()
return
}
commit(
{
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as GridEvent,
true,
)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
connectivity?.clear()
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
@@ -0,0 +1,271 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
DuctFittingNode,
DuctSegmentNode,
useScene,
} from '@pascal-app/core'
import { readAutoOffsetTag, withAutoOffsetTag } from '../shared/auto-offset-tag'
import { getDuctFittingPorts } from './ports'
let ductFittingParametrics: typeof import('./parametrics')['ductFittingParametrics']
type Point = [number, number, number]
function equivalentDiameterIn(widthIn: number, heightIn: number): number {
return 2 * Math.sqrt((widthIn * heightIn) / Math.PI)
}
function rectElbow() {
return DuctFittingNode.parse({
id: 'duct-fitting_resize' as AnyNodeId,
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Resize elbow',
fittingType: 'elbow',
shape: 'rect',
width: 14,
height: 8,
diameter: equivalentDiameterIn(14, 8),
diameter2: equivalentDiameterIn(14, 8),
ductMaterial: 'sheet-metal',
system: 'supply',
position: [0, 0, 0],
rotation: [0, 0, 0],
angle: 90,
})
}
function verticalRectRunFrom(point: Point, roll: number) {
return DuctSegmentNode.parse({
id: 'duct-segment_vertical' as AnyNodeId,
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Drawn vertical run',
path: [point, [point[0], point[1] + 3, point[2]]],
shape: 'rect',
width: 14,
height: 8,
diameter: equivalentDiameterIn(14, 8),
roll,
ductMaterial: 'sheet-metal',
insulationR: 0,
system: 'supply',
})
}
describe('ductFittingParametrics', () => {
beforeAll(async () => {
mock.module('@pascal-app/editor', () => ({
ActionButton: () => null,
}))
;({ ductFittingParametrics } = await import('./parametrics'))
})
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
useScene.temporal.getState().clear()
})
test('resizing a fitting retrims connected ducts without changing their roll', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const originalRoll = 0.37
const duct = verticalRectRunFrom([...outlet.position] as Point, originalRoll)
useScene.setState({
nodes: {
[fitting.id]: fitting as AnyNode,
[duct.id]: duct as AnyNode,
},
rootNodeIds: [fitting.id, duct.id],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
const patch = { width: 20 }
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
const ductUpdate = updates.find((u) => u.id === duct.id)
expect(ductUpdate).toBeDefined()
expect((ductUpdate?.data as Partial<DuctSegmentNode>).path).toBeDefined()
expect((ductUpdate?.data as Partial<DuctSegmentNode>).roll).toBeUndefined()
})
test('resizing a fitting refreshes a connected duct auto-offset base path', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
const taggedDuct = DuctSegmentNode.parse({
...duct,
metadata: withAutoOffsetTag(duct.metadata, {
group: 'aoff_resize',
dy: 1,
minted: ['duct-fitting_minted' as AnyNodeId],
base: [{ id: duct.id, data: { path: duct.path } }],
}),
})
useScene.setState({
nodes: {
[fitting.id]: fitting as AnyNode,
[taggedDuct.id]: taggedDuct as AnyNode,
},
rootNodeIds: [fitting.id, taggedDuct.id],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
const patch = { width: 20 }
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
const ductUpdate = updates.find((u) => u.id === taggedDuct.id)
const nextOutlet = getDuctFittingPorts(next).find((p) => p.id === 'outlet')!
const nextTag = readAutoOffsetTag({ metadata: ductUpdate?.data.metadata })
const basePath = nextTag?.base.find((b) => b.id === taggedDuct.id)?.data.path as
| Point[]
| undefined
expect(basePath?.[0]).toEqual([...nextOutlet.position])
})
test('deleting an elbow re-extends mated runs back onto the junction', () => {
const fitting = rectElbow()
const ports = getDuctFittingPorts(fitting)
const outlet = ports.find((p) => p.id === 'outlet')!
const inlet = ports.find((p) => p.id === 'inlet')!
// Two runs meeting the elbow's collars — the L-shape the elbow trimmed.
const outletRun = verticalRectRunFrom([...outlet.position] as Point, 0)
const inletRun = DuctSegmentNode.parse({
...verticalRectRunFrom([...inlet.position] as Point, 0),
id: 'duct-segment_inlet' as AnyNodeId,
path: [
[...inlet.position] as Point,
[inlet.position[0] - 3, inlet.position[1], inlet.position[2]],
],
})
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[outletRun.id]: outletRun as AnyNode,
[inletRun.id]: inletRun as AnyNode,
}
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
const outletUpdate = updates.find((u) => u.id === outletRun.id)
const inletUpdate = updates.find((u) => u.id === inletRun.id)
// Both mated endpoints snap back to the junction (the original corner).
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
...fitting.position,
])
expect((inletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([...fitting.position])
})
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const nearOutlet: Point = [outlet.position[0], outlet.position[1], outlet.position[2] + 0.04]
const outletRun = DuctSegmentNode.parse({
...verticalRectRunFrom(nearOutlet, 0),
id: 'duct-segment_outlet_gap' as AnyNodeId,
})
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[outletRun.id]: outletRun as AnyNode,
}
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
const outletUpdate = updates.find((u) => u.id === outletRun.id)
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
...fitting.position,
])
})
test('deleting a generated elbow clears the owner duct auto-offset tag', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
const taggedDuct = DuctSegmentNode.parse({
...duct,
metadata: withAutoOffsetTag(duct.metadata, {
group: 'aoff_deleted_elbow',
dy: 1,
minted: [fitting.id],
base: [{ id: duct.id, data: { path: duct.path } }],
}),
})
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[taggedDuct.id]: taggedDuct as AnyNode,
}
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
})
test('deleting a tee leaves mated runs untouched', () => {
const tee = DuctFittingNode.parse({ ...rectElbow(), fittingType: 'tee' })
const outlet = getDuctFittingPorts(tee).find((p) => p.id === 'outlet')!
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
const nodes: Record<AnyNodeId, AnyNode> = {
[tee.id]: tee as AnyNode,
[duct.id]: duct as AnyNode,
}
expect(ductFittingParametrics.onDelete?.(tee, nodes) ?? []).toEqual([])
})
test('resizing a generated fitting clears the owner duct auto-offset tag', () => {
const fitting = rectElbow()
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
const taggedDuct = DuctSegmentNode.parse({
...duct,
metadata: withAutoOffsetTag(duct.metadata, {
group: 'aoff_generated_fit',
dy: 1,
minted: [fitting.id],
base: [{ id: duct.id, data: { path: duct.path } }],
}),
})
useScene.setState({
nodes: {
[fitting.id]: fitting as AnyNode,
[taggedDuct.id]: taggedDuct as AnyNode,
},
rootNodeIds: [fitting.id, taggedDuct.id],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
const patch = { width: 20 }
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
})
})
+100 -35
View File
@@ -7,19 +7,41 @@ import {
} from '@pascal-app/core'
import { Vector3 } from 'three'
import {
ductPortDiameterIn,
equivalentDiameterIn,
ovalEquivalentDiameterIn,
rollToContinueAcrossElbow,
} from '../duct-segment/geometry'
autoOffsetInvalidationUpdates,
readAutoOffsetTag,
withAutoOffsetTag,
} from '../shared/auto-offset-tag'
import { DuctFittingSizeSwapEditor } from './inspector-editors'
import { getDuctFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
/** Schema bounds for `diameter` / `diameter2`. */
const clampDiameter = (d: number) => Math.min(48, Math.max(2, d))
const equivalentDiameterIn = (widthIn: number, heightIn: number): number =>
2 * Math.sqrt((widthIn * heightIn) / Math.PI)
const ovalEquivalentDiameterIn = (widthIn: number, heightIn: number): number => {
const minor = Math.min(widthIn, heightIn)
const major = Math.max(widthIn, heightIn)
const area = (major - minor) * minor + Math.PI * (minor / 2) ** 2
return 2 * Math.sqrt(area / Math.PI)
}
const ductPortDiameterIn = (node: DuctSegmentNode): number => {
if (node.shape === 'rect' && node.width && node.height) {
return equivalentDiameterIn(node.width, node.height)
}
if (node.shape === 'oval' && node.width && node.height) {
return ovalEquivalentDiameterIn(node.width, node.height)
}
return node.diameter
}
/** A duct endpoint sitting this close to a collar counts as mated. */
const MATE_TOL_M = 0.03
const MATE_TOL_M = 0.05
type Point = [number, number, number]
type DuctMate = { duct: DuctSegmentNode; endIndex: number }
@@ -28,10 +50,13 @@ type DuctMate = { duct: DuctSegmentNode; endIndex: number }
* port id. Auto-minted joints place duct ends exactly on the collar, so
* a tight distance check is enough — no connectivity graph yet.
*/
function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
function matedDucts(
fitting: DuctFittingNode,
nodes: Record<AnyNodeId, AnyNode> = useScene.getState().nodes,
): Map<string, DuctMate> {
const mates = new Map<string, DuctMate>()
const ports = getDuctFittingPorts(fitting)
for (const node of Object.values(useScene.getState().nodes)) {
for (const node of Object.values(nodes)) {
if (node.type !== 'duct-segment') continue
const duct = node as DuctSegmentNode
for (const endIndex of [0, duct.path.length - 1]) {
@@ -51,6 +76,25 @@ function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
return mates
}
function refreshedAutoOffsetMetadata(
duct: DuctSegmentNode,
endIndex: number,
target: Point,
): Record<string, unknown> | null {
const tag = readAutoOffsetTag(duct)
if (!tag) return null
let changed = false
const base = tag.base.map((patch) => {
if (patch.id !== duct.id || !Array.isArray(patch.data.path)) return patch
const path = patch.data.path.map((p) => (Array.isArray(p) ? [...p] : p))
if (!Array.isArray(path[endIndex])) return patch
path[endIndex] = [...target]
changed = true
return { ...patch, data: { ...patch.data, path } }
})
return changed ? withAutoOffsetTag(duct.metadata, { ...tag, base }) : null
}
export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
// Switching the run legs round↔rect flips the whole fitting and sizes
// the new profile off the ducts actually mated to its collars, so the
@@ -127,34 +171,48 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
path[mate.endIndex] = [...target.position]
data.path = path
}
// Steep rect / oval runs also re-derive their cross-section roll
// so a riser's profile stays continuous through the fitting (same
// continuity the draw tool computes; runs flipped to rect after
// drawing never got it). Horizontal runs are left alone — their
// roll-0 orientation is canonical and re-deriving it from a
// possibly-stale riser roll would corrupt it.
if (next.shape !== 'round' && mate.duct.shape !== 'round') {
const away = mate.duct.path[mate.endIndex === 0 ? 1 : mate.duct.path.length - 2]
const source = getDuctFittingPorts(next).find(
(p) => p.id !== portId && p.id !== 'branch' && p.id !== 'branch2',
)
if (away && source) {
const newDir = new Vector3(away[0] - end[0], away[1] - end[1], away[2] - end[2])
if (newDir.lengthSq() >= 1e-10) {
newDir.normalize()
if (Math.abs(newDir.y) >= Math.SQRT1_2) {
const srcMate = mates.get(source.id)
const srcRoll = srcMate && srcMate.duct.shape !== 'round' ? srcMate.duct.roll : 0
const srcDir = new Vector3(...source.direction)
const roll = rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
if (Math.abs(roll - mate.duct.roll) > 1e-6) data.roll = roll
}
}
}
}
const metadata = refreshedAutoOffsetMetadata(
mate.duct,
mate.endIndex,
target.position as Point,
)
if (metadata) data.metadata = metadata as DuctSegmentNode['metadata']
if (Object.keys(data).length > 0) updates.push({ id: mate.duct.id, data })
}
return updates
return [...updates, ...autoOffsetInvalidationUpdates(useScene.getState().nodes, next.id)]
},
// Deleting an auto-inserted elbow restores the corner it replaced: both
// mated runs were pulled back one leg onto its collars, with the
// junction (the fitting's position) sitting exactly on the corner they
// originally met at. Re-extend each mated endpoint back to that junction
// so the L-shape returns to its pre-fitting length. Scoped to elbows —
// tees / crosses split a trunk into two separate nodes, which can't be
// re-joined by moving an endpoint.
onDelete: (fitting, nodes) => {
const invalidations = autoOffsetInvalidationUpdates(nodes, fitting.id)
if (fitting.fittingType !== 'elbow') return invalidations
const junction = new Vector3(...fitting.position)
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
for (const mate of matedDucts(fitting, nodes).values()) {
const end = mate.duct.path[mate.endIndex]
if (!end) continue
const dx = end[0] - junction.x
const dy = end[1] - junction.y
const dz = end[2] - junction.z
if (dx * dx + dy * dy + dz * dz < 1e-12) continue
const path = mate.duct.path.map((p) => [...p] as Point)
path[mate.endIndex] = [junction.x, junction.y, junction.z]
const data: Partial<DuctSegmentNode> = { path }
const metadata = refreshedAutoOffsetMetadata(mate.duct, mate.endIndex, [
junction.x,
junction.y,
junction.z,
])
if (metadata) data.metadata = metadata as DuctSegmentNode['metadata']
updates.push({ id: mate.duct.id, data })
}
return [...updates, ...invalidations]
},
groups: [
{
@@ -170,7 +228,7 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
key: 'angle',
kind: 'number',
unit: '°',
min: 15,
min: 0,
max: 90,
step: 15,
visibleIf: (n) => n.fittingType === 'elbow',
@@ -236,6 +294,13 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
visibleIf: (n) =>
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
},
{
key: 'swapWidthHeight',
kind: 'custom',
component: DuctFittingSizeSwapEditor,
visibleIf: (n) =>
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
},
{
key: 'shape2',
kind: 'enum',
+2 -1
View File
@@ -1,8 +1,9 @@
import type { NodePort } from '@pascal-app/core'
import { Euler, Vector3 } from 'three'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { DuctFittingNode } from './schema'
const INCHES_TO_METERS = 0.0254
/**
* Collar stub length in meters — how far each port sticks out from the
* fitting's junction center. Scales with the duct so big trunks get
+862 -17
View File
@@ -1,27 +1,299 @@
'use client'
import { type AnyNodeId, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
type Cursor,
type DuctFittingNode,
type PortConnectivity,
pauseSceneHistory,
resolveConnectivityUpdates,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
ARROW_COLOR,
EDITOR_LAYER,
swallowNextClick,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { cycleRotationAxis } from '../shared/fitting-rotation'
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import {
BufferGeometry,
Euler,
Float32BufferAttribute,
type Group,
LineSegments,
type Object3D,
OrthographicCamera,
Plane,
Quaternion,
Raycaster,
SphereGeometry,
Vector2,
Vector3,
} from 'three'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
import {
AXIS_VECTORS,
cycleRotationAxis,
ROTATE_STEP_RAD,
type RotationAxis,
} from '../shared/fitting-rotation'
import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles'
import { fittingLegLength } from './ports'
type Point = [number, number, number]
/** Stand-off (meters) from the fitting body to each arrow. */
const ARROW_GAP = 0.14
const RESIZE_HANDLE_GAP = 0.18
const RESIZE_STEP_IN = 1
const RESIZE_GUIDE_DASH = 0.07
const RESIZE_GUIDE_GAP = 0.045
const RESIZE_SPHERE_RADIUS = 0.065
const RESIZE_HIT_RADIUS = 0.13
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
/** Rough body radius (meters) — the larger of the fitting's two collar reaches,
* used to stand the handles clear of the geometry. */
function fittingExtentM(node: DuctFittingNode): number {
const d2 = (node as { diameter2?: number }).diameter2 ?? node.diameter
return Math.max(fittingLegLength(node.diameter), fittingLegLength(d2))
}
/** The transform a drag frame writes onto the fitting. */
type FittingTransform = { position?: Point; rotation?: Point }
type FittingDimension = 'width' | 'height'
function fittingParameterPatch(node: DuctFittingNode): Partial<DuctFittingNode> {
return {
fittingType: node.fittingType,
shape: node.shape,
width: node.width,
height: node.height,
shape2: node.shape2,
width2: node.width2,
height2: node.height2,
angle: node.angle,
branchAngle: node.branchAngle,
diameter: node.diameter,
diameter2: node.diameter2,
ductMaterial: node.ductMaterial,
system: node.system,
}
}
function preserveFittingParameters(
node: DuctFittingNode,
data: Partial<DuctFittingNode>,
): Partial<AnyNode> {
return { ...fittingParameterPatch(node), ...data } as Partial<AnyNode>
}
function canResizeRunProfile(node: DuctFittingNode): boolean {
return (
node.fittingType === 'transition' || (node.fittingType !== 'reducer' && node.shape !== 'round')
)
}
function dimensionBounds(dimension: FittingDimension): { min: number; max: number } {
return dimension === 'width' ? { min: 4, max: 60 } : { min: 3, max: 40 }
}
function closestAxisParameterToRay(
axisOrigin: Vector3,
axisDirection: Vector3,
ray: Raycaster['ray'],
) {
const originToRay = axisOrigin.clone().sub(ray.origin)
const b = axisDirection.dot(ray.direction)
const d = axisDirection.dot(originToRay)
const e = ray.direction.dot(originToRay)
const denominator = 1 - b * b
if (Math.abs(denominator) < 1e-6) return -d
const axisParameter = (b * e - d) / denominator
const rayParameter = e + b * axisParameter
return rayParameter < 0 ? -d : axisParameter
}
function DashedResizeGuide({ from, to }: { from: Point; to: Point }) {
const line = useMemo(() => {
const a = new Vector3(from[0], from[1], from[2])
const b = new Vector3(to[0], to[1], to[2])
const span = b.clone().sub(a)
const length = span.length()
const points: number[] = []
if (length > 1e-4) {
const dir = span.clone().normalize()
let t = 0
while (t < length) {
const start = a.clone().addScaledVector(dir, t)
const end = a.clone().addScaledVector(dir, Math.min(t + RESIZE_GUIDE_DASH, length))
points.push(start.x, start.y, start.z, end.x, end.y, end.z)
t += RESIZE_GUIDE_DASH + RESIZE_GUIDE_GAP
}
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(points), 3))
const material = new LineBasicNodeMaterial({
color: ARROW_COLOR,
transparent: true,
opacity: 0.8,
depthWrite: false,
})
const next = new LineSegments(geometry, material)
next.frustumCulled = false
next.layers.set(EDITOR_LAYER)
next.renderOrder = 1002
next.raycast = () => {}
return next
}, [from, to])
useEffect(
() => () => {
line.geometry.dispose()
;(line.material as LineBasicNodeMaterial).dispose()
},
[line],
)
return <primitive object={line} />
}
function ResizeSphereHandle({
cursor,
onPointerDown,
position,
}: {
cursor: Cursor
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
position: Point
}) {
const { camera } = useThree()
const [hovered, setHovered] = useState(false)
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const sphereGeometry = useMemo(() => new SphereGeometry(RESIZE_SPHERE_RADIUS, 18, 12), [])
const hitGeometry = useMemo(() => new SphereGeometry(RESIZE_HIT_RADIUS, 12, 8), [])
const sphereMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: ARROW_COLOR,
transparent: true,
opacity: 0.92,
depthTest: false,
depthWrite: false,
}),
[],
)
const hitMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: ARROW_COLOR,
transparent: true,
opacity: 0,
depthTest: false,
depthWrite: false,
}),
[],
)
useEffect(() => {
sphereMaterial.opacity = hovered ? 1 : 0.92
}, [sphereMaterial, hovered])
useEffect(
() => () => {
hitGeometry.dispose()
sphereGeometry.dispose()
sphereMaterial.dispose()
hitMaterial.dispose()
},
[hitGeometry, hitMaterial, sphereGeometry, sphereMaterial],
)
const consumePress = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
event.nativeEvent.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
swallowNextClick()
onPointerDown(event)
}
return (
<group position={position} scale={zoom}>
<mesh
geometry={hitGeometry}
material={hitMaterial}
onPointerDown={consumePress}
onPointerEnter={(event) => {
event.stopPropagation()
setHovered(true)
document.body.style.cursor = cursor
}}
onPointerLeave={(event) => {
event.stopPropagation()
setHovered(false)
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
}}
/>
<mesh geometry={sphereGeometry} material={sphereMaterial} renderOrder={1004} />
</group>
)
}
/**
* Selection-time rotation support for placed fittings, mounted by the
* editor's SelectionAffordanceManager (`def.affordanceTools.selection`).
* The R/T rotation itself lives in `def.keyboardActions` (the editor's
* keyboard hook dispatches it); this contributes the piece that hook
* can't: **Alt cycles the active rotation axis** while a single fitting
* is selected. The axis lives on `useEditor.rotationAxis`, which the
* floating action menu reads to show the axis pill above the selected
* fitting — so this component renders nothing.
* Selection-time affordances for a placed duct fitting — the 3D twin of the
* duct-segment selection rig. A CLICK-to-latch cube sits at the fitting center;
* clicking it opens (click again to close) a cluster of:
*
* - **Six move arrows** (±X / ±Y / ±Z): translate the whole fitting along one
* world axis. Connected runs follow via port connectivity.
* - **Three rotation arcs** (X / Y / Z): spin the fitting about each world
* axis. Connected runs re-aim via port follow.
* - **Two profile cubes** on the fitting's visible side/top faces: resize
* non-round fitting width and height without occupying the inside corner.
*
* The handle rig is PORTALED into the fitting group's PARENT — never the
* fitting group itself — because the selection outliner (`MergedOutlineNode`)
* traces every descendant mesh of the SELECTED node, so a hit-area cylinder
* parented under the fitting would be swept into its selection outline. Walls /
* doors / windows dodge it the same way. The fitting's local `position` is
* expressed in the parent's frame, so an identity group under the parent lets
* us place handles at absolute level-local coords with world-aligned axes.
*
* History does the single-undo dance: paused during the drag (live ticks are
* untracked), reverted on release, resumed, then the final transform re-applied
* as one tracked change so the whole joint is one undo step.
*/
const DuctFittingSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const hasSelectedFitting = useScene((s) => {
if (selectedIds.length !== 1) return false
return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'duct-fitting'
const fitting = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'duct-fitting' ? (node as DuctFittingNode) : null
})
// Alt cycles the active rotation axis for the R / T keyboard rotate while a
// single fitting is selected (the gizmo's three arcs cover every axis on
// their own; this only keeps the keyboard action meaningful).
const hasSelectedFitting = !!fitting
useEffect(() => {
if (!hasSelectedFitting) return
const onKeyDown = (e: KeyboardEvent) => {
@@ -31,13 +303,586 @@ const DuctFittingSelectionAffordance = () => {
e.preventDefault()
cycleRotationAxis()
}
// Bubble phase — when the placement tool is active its capture-phase
// handler stops propagation, so the two never double-cycle.
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [hasSelectedFitting])
return null
// Portal target: the fitting's registered group. Resolved with a rAF retry
// because registration lands on the renderer's mount, a frame after select.
const fittingId = fitting?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!fittingId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(fittingId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [fittingId])
if (!fitting || !target) return null
const mount = target.parent ?? target
return createPortal(<FittingHandles fitting={fitting} target={target} />, mount, undefined)
}
const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: Object3D }) => {
const { camera, gl } = useThree()
const [frame, setFrame] = useState<Group | null>(null)
// True while the cluster is latched open. Click the center cube to toggle.
const [open, setOpen] = useState(false)
// True while a move / rotate drag is live — the arrows hide (the window
// pointer handlers own the gesture), exactly like the duct-segment rig.
const [dragging, setDragging] = useState(false)
const [sideSign, setSideSign] = useState(1)
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
const sampleAxisParameter = (
clientX: number,
clientY: number,
axisOrigin: Vector3,
axisDirection: Vector3,
): number => closestAxisParameterToRay(axisOrigin, axisDirection, makeRay(clientX, clientY))
/** World hit on a vertical, camera-facing plane through `anchorWorld`,
* returned as a level-local Y (the frame is axis-aligned to the parent). */
const intersectVerticalY = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
): number | null => {
if (!frame) return null
const forward = camera.getWorldDirection(new Vector3())
forward.y = 0
if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1)
forward.normalize()
const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld)
const hit = intersect(clientX, clientY, plane)
return hit ? frame.worldToLocal(hit.clone()).y : null
}
const toWorld = (p: Point): Vector3 =>
frame ? frame.localToWorld(new Vector3(p[0], p[1], p[2])) : new Vector3(p[0], p[1], p[2])
const axisToWorld = (origin: Point, axis: Vector3): Vector3 => {
const originWorld = toWorld(origin)
const tipWorld = frame
? frame.localToWorld(new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z))
: new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z)
return tipWorld.sub(originWorld).normalize()
}
/** Cursor's coordinate on one world axis, in the frame's local space. For Y
* it rides a camera-facing vertical plane; for X / Z it projects onto the
* horizontal plane through the fitting and reads back the local component. */
const sampleAxis = (
axis: RotationAxis,
clientX: number,
clientY: number,
anchorWorld: Vector3,
): number | null => {
if (axis === 'y') return intersectVerticalY(clientX, clientY, anchorWorld)
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, anchorWorld)
const hit = intersect(clientX, clientY, plane)
if (!hit || !frame) return null
const local = frame.worldToLocal(hit.clone())
return axis === 'x' ? local.x : local.z
}
// Follow-updates for runs / fittings mated to this fitting, given a preview
// transform. Endpoints whose ports didn't move resolve to a zero delta.
const connectivityUpdates = (
connectivity: PortConnectivity | null,
transform: FittingTransform,
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
if (!connectivity) return []
const preview = { ...(fitting as Record<string, unknown>), ...transform } as AnyNode
const nodes = useScene.getState().nodes
return resolveConnectivityUpdates(connectivity, preview)
.filter((u) => nodes[u.id])
.map((u) => {
const node = nodes[u.id]
if (node?.type !== 'duct-fitting') return u
return {
id: u.id,
data: preserveFittingParameters(
node as DuctFittingNode,
u.data as Partial<DuctFittingNode>,
),
}
})
}
/**
* Shared lifecycle for the move / rotate drags. `makeCompute` is built at
* pointer-down so it can capture the grab anchor (cursor's start coord /
* bearing) and avoid a teleport. Each frame `compute` turns the cursor into
* the fitting's next transform; the fitting writes it and any mated runs
* follow via port connectivity. Lands as one undo step.
*/
const beginDrag =
(
cursor: Cursor,
makeCompute: (
e: ThreeEvent<PointerEvent>,
) => (event: PointerEvent) => FittingTransform | null,
) =>
(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPosition = [...fitting.position] as Point
const initialRotation = [...fitting.rotation] as Point
const connectivity = analyzePortConnectivity(fitting as AnyNode, useScene.getState().nodes)
const compute = makeCompute(e)
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
setDragging(true)
document.body.style.cursor = cursor
let current: FittingTransform | null = null
const buildBatch = (t: FittingTransform): { id: AnyNodeId; data: Partial<AnyNode> }[] => [
{
id: fitting.id as AnyNodeId,
data: preserveFittingParameters(fitting, t as Partial<DuctFittingNode>),
},
...connectivityUpdates(connectivity, t),
]
const onMove = (event: PointerEvent) => {
const next = compute(event)
if (!next) return
current = next
useScene.getState().updateNodes(buildBatch(next))
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
setDragging(false)
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
}
const onUp = () => {
// Swallow the trailing synthetic click so it doesn't reach the
// background-click deselect handler (cleanup drops `inputDragging`
// synchronously here).
swallowNextClick()
cleanup()
// Single-undo dance: revert the fitting AND its followers to the
// pre-drag state while history is still paused, resume, then re-apply
// the final transform as one tracked change.
const reverts: { id: AnyNodeId; data: Partial<AnyNode> }[] = (
connectivity?.connections ?? []
).map((conn) => {
if (conn.kind !== 'rigid-node') {
return { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }
}
const node = useScene.getState().nodes[conn.nodeId]
return {
id: conn.nodeId,
data:
node?.type === 'duct-fitting'
? preserveFittingParameters(node as DuctFittingNode, {
position: conn.startPosition as Point,
})
: ({ position: conn.startPosition } as Partial<AnyNode>),
}
})
useScene.getState().updateNodes([
{
id: fitting.id as AnyNodeId,
data: preserveFittingParameters(fitting, {
position: initialPosition,
rotation: initialRotation,
}),
},
...reverts.filter((u) => useScene.getState().nodes[u.id]),
])
resumeSceneHistory(useScene)
if (current) {
const scene = useScene.getState()
scene.updateNodes([
...buildBatch(current),
...autoOffsetInvalidationUpdates(scene.nodes, fitting.id as AnyNodeId),
])
}
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
// Move: translate the fitting along one world axis, anchored to the cursor's
// start coord so it doesn't jump on grab. Y is clamped at the floor; Shift
// bypasses grid snapping.
const moveCompute =
(axis: RotationAxis) =>
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
const anchorWorld = toWorld(fitting.position as Point)
const start = sampleAxis(axis, e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld)
const base = [...fitting.position] as Point
const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
let lastDelta = Number.NaN
return (event: PointerEvent): FittingTransform | null => {
if (start === null) return null
const s = sampleAxis(axis, event.clientX, event.clientY, anchorWorld)
if (s === null) return null
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
const delta = snap(s - start, step)
if (delta === lastDelta) return null
lastDelta = delta
if (step > 0) triggerSFX('sfx:grid-snap')
const next = [...base] as Point
next[axisIndex] = (
axis === 'y' ? Math.max(0, base[axisIndex] + delta) : base[axisIndex] + delta
) as number
return { position: next }
}
}
// Rotate: spin the fitting about one world axis. The cursor's bearing in the
// plane perpendicular to that axis (through the body center) drives the
// angle; world-frame premultiply so the axis means the screen X/Y/Z the user
// expects regardless of how the fitting is already turned.
const rotateCompute =
(axis: RotationAxis) =>
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
const normal = AXIS_VECTORS[axis].clone()
const center = toWorld(fitting.position as Point)
const ref = axis === 'y' ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0)
const u = ref
.clone()
.sub(normal.clone().multiplyScalar(ref.dot(normal)))
.normalize()
const v = new Vector3().crossVectors(normal, u)
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center)
const bearing = (clientX: number, clientY: number): number | null => {
const hit = intersect(clientX, clientY, plane)
if (!hit) return null
const d = hit.sub(center)
return Math.atan2(d.dot(v), d.dot(u))
}
const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY)
const startQuat = new Quaternion().setFromEuler(
new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
)
let lastStep = Number.NaN
return (event: PointerEvent): FittingTransform | null => {
if (startBearing === null) return null
const b = bearing(event.clientX, event.clientY)
if (b === null) return null
// Snap the turn to 45° steps; Shift = smooth (no snap).
const raw = b - startBearing
const delta = event.shiftKey ? raw : Math.round(raw / ROTATE_STEP_RAD) * ROTATE_STEP_RAD
// Tick the rotate SFX each time a fresh snap step is crossed (snapped
// turns only — a smooth Shift-drag has no discrete steps to mark).
if (!event.shiftKey) {
const step = Math.round(raw / ROTATE_STEP_RAD)
if (step !== lastStep) {
lastStep = step
triggerSFX('sfx:item-rotate')
}
}
const turn = new Quaternion().setFromAxisAngle(normal, delta)
const euler = new Euler().setFromQuaternion(turn.multiply(startQuat))
return { rotation: [euler.x, euler.y, euler.z] }
}
}
const beginDimensionDrag =
(dimension: FittingDimension, axisLocal: Vector3, cursor: Cursor) =>
(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const baseValue = fitting[dimension]
const initialPatch = { [dimension]: baseValue } as Partial<DuctFittingNode>
const centerWorld = toWorld(fitting.position as Point)
const axisWorld = axisToWorld(fitting.position as Point, axisLocal)
const start = sampleAxisParameter(
e.nativeEvent.clientX,
e.nativeEvent.clientY,
centerWorld,
axisWorld,
)
const { min, max } = dimensionBounds(dimension)
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
setDragging(true)
document.body.style.cursor = cursor
let current: Partial<DuctFittingNode> | null = null
let lastValue = Number.NaN
const apply = (patch: Partial<DuctFittingNode>) => {
useScene.getState().updateNodes([
{
id: fitting.id as AnyNodeId,
data: preserveFittingParameters(fitting, patch),
},
])
}
const onMove = (event: PointerEvent) => {
const rawDeltaM =
sampleAxisParameter(event.clientX, event.clientY, centerWorld, axisWorld) - start
const deltaIn = (rawDeltaM / INCHES_TO_METERS) * 2
const nextRaw = baseValue + deltaIn
const nextValue = clamp(event.shiftKey ? nextRaw : snap(nextRaw, RESIZE_STEP_IN), min, max)
if (nextValue === lastValue) return
lastValue = nextValue
current = { [dimension]: nextValue } as Partial<DuctFittingNode>
if (!event.shiftKey) triggerSFX('sfx:grid-snap')
apply(current)
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
setDragging(false)
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
}
const onUp = () => {
swallowNextClick()
cleanup()
apply(initialPatch)
resumeSceneHistory(useScene)
if (current) apply(current)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
const extent = useMemo(() => fittingExtentM(fitting), [fitting])
const p = fitting.position as Point
const base = extent + ARROW_GAP
const fittingRotation = useMemo(
() => new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
[fitting.rotation],
)
const profileAxes = useMemo(() => {
const hingeAxis = new Vector3(0, 1, 0).applyEuler(fittingRotation).normalize()
const sideAxis = new Vector3(0, 0, 1).applyEuler(fittingRotation).normalize()
const hingeIsVertical = Math.abs(hingeAxis.y) >= Math.SQRT1_2
const hingeDimension: FittingDimension = hingeIsVertical ? 'height' : 'width'
const sideDimension: FittingDimension = hingeIsVertical ? 'width' : 'height'
const hingeEntry = { key: hingeDimension, axis: hingeAxis }
const sideEntry = { key: sideDimension, axis: sideAxis }
return Math.abs(hingeAxis.dot(UP)) >= Math.abs(sideAxis.dot(UP))
? { top: hingeEntry, side: sideEntry }
: { top: sideEntry, side: hingeEntry }
}, [fittingRotation])
const topAxis = useMemo(() => {
const axis = profileAxes.top.axis.clone()
return axis.dot(UP) >= 0 ? axis : axis.multiplyScalar(-1)
}, [profileAxes])
const baseSideAxis = profileAxes.side.axis
const sideAxis = useMemo(
() => baseSideAxis.clone().multiplyScalar(sideSign),
[baseSideAxis, sideSign],
)
useFrame(() => {
if (!frame) return
const cameraPosition = camera.getWorldPosition(new Vector3())
const cameraLocal = frame.worldToLocal(cameraPosition)
const toCamera = cameraLocal.sub(new Vector3(p[0], p[1], p[2]))
const nextSign = baseSideAxis.dot(toCamera) >= 0 ? 1 : -1
setSideSign((current) => (current === nextSign ? current : nextSign))
})
const resizeHandleBase = extent + RESIZE_HANDLE_GAP
const resizeHandles: {
key: FittingDimension
axis: Vector3
cursor: Cursor
guideFrom: Point
guideTo: Point
position: Point
}[] = canResizeRunProfile(fitting)
? [
{
key: profileAxes.top.key,
axis: topAxis,
cursor: 'ns-resize',
guideFrom: [
p[0] + topAxis.x * resizeHandleBase,
p[1] + topAxis.y * resizeHandleBase,
p[2] + topAxis.z * resizeHandleBase,
],
guideTo: [
p[0] + topAxis.x * Math.max(extent * 0.18, 0.04),
p[1] + topAxis.y * Math.max(extent * 0.18, 0.04),
p[2] + topAxis.z * Math.max(extent * 0.18, 0.04),
],
position: [
p[0] + topAxis.x * resizeHandleBase,
p[1] + topAxis.y * resizeHandleBase,
p[2] + topAxis.z * resizeHandleBase,
],
},
{
key: profileAxes.side.key,
axis: sideAxis,
cursor: 'ew-resize',
guideFrom: [
p[0] + sideAxis.x * resizeHandleBase,
p[1] + sideAxis.y * resizeHandleBase,
p[2] + sideAxis.z * resizeHandleBase,
],
guideTo: [
p[0] + sideAxis.x * Math.max(extent * 0.18, 0.04),
p[1] + sideAxis.y * Math.max(extent * 0.18, 0.04),
p[2] + sideAxis.z * Math.max(extent * 0.18, 0.04),
],
position: [
p[0] + sideAxis.x * resizeHandleBase,
p[1] + sideAxis.y * resizeHandleBase,
p[2] + sideAxis.z * resizeHandleBase,
],
},
]
: []
// Six whole-fitting move arrows, one per ± world axis.
const moveArrows: {
key: string
axis: RotationAxis
position: Point
rotationY: number
vertical?: 'up' | 'down'
cursor: Cursor
}[] = [
{ key: '+x', axis: 'x', position: [p[0] + base, p[1], p[2]], rotationY: 0, cursor: 'grab' },
{
key: '-x',
axis: 'x',
position: [p[0] - base, p[1], p[2]],
rotationY: Math.PI,
cursor: 'grab',
},
{
key: '+z',
axis: 'z',
position: [p[0], p[1], p[2] + base],
rotationY: -Math.PI / 2,
cursor: 'grab',
},
{
key: '-z',
axis: 'z',
position: [p[0], p[1], p[2] - base],
rotationY: Math.PI / 2,
cursor: 'grab',
},
{
key: '+y',
axis: 'y',
position: [p[0], p[1] + base, p[2]],
rotationY: 0,
vertical: 'up',
cursor: 'ns-resize',
},
{
key: '-y',
axis: 'y',
position: [p[0], p[1] - base, p[2]],
rotationY: 0,
vertical: 'down',
cursor: 'ns-resize',
},
]
// Three rotation arcs, one per world axis. Each arc wraps its axis (the
// shared `curved-arrow` wraps world +Y by default; `setFromUnitVectors`
// re-aims it) and sits at a diagonal offset in the plane it spins, so the
// three don't pile onto the move arrows.
const d = base * Math.SQRT1_2
const rotateArcs: { key: string; axis: RotationAxis; position: Point; rotation: Point }[] = (
['x', 'y', 'z'] as RotationAxis[]
).map((axis) => {
const q = new Quaternion().setFromUnitVectors(UP, AXIS_VECTORS[axis])
// Spin the arc in place about its own axis so the grip sits where we want
// it without moving its position.
if (axis === 'z') {
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.z, Math.PI / 4))
} else if (axis === 'x') {
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.x, (-145 * Math.PI) / 180))
} else if (axis === 'y') {
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.y, (-45 * Math.PI) / 180))
}
const e = new Euler().setFromQuaternion(q)
const position: Point =
axis === 'x'
? [p[0], p[1] + d, p[2] + d]
: axis === 'y'
? [p[0] + d, p[1], p[2] + d]
: [p[0] + d, p[1] + d, p[2]]
return { key: `r${axis}`, axis, position, rotation: [e.x, e.y, e.z] }
})
if (dragging) {
return <group ref={setFrame} />
}
return (
<group ref={setFrame}>
<HandleCube active={open} onClick={() => setOpen((o) => !o)} position={p} />
{!open &&
resizeHandles.map((handle) => (
<group key={handle.key}>
<DashedResizeGuide from={handle.guideFrom} to={handle.guideTo} />
<ResizeSphereHandle
cursor={handle.cursor}
onPointerDown={beginDimensionDrag(handle.key, handle.axis, handle.cursor)}
position={handle.position}
/>
</group>
))}
{open && (
<>
{moveArrows.map((a) => (
<MoveChevron
cursor={a.cursor}
key={a.key}
onPointerDown={beginDrag(
a.axis === 'y' ? 'ns-resize' : 'grabbing',
moveCompute(a.axis),
)}
position={a.position}
rotationY={a.rotationY}
vertical={a.vertical}
/>
))}
{rotateArcs.map((arc) => (
<RotateArc
key={arc.key}
onPointerDown={beginDrag('grabbing', rotateCompute(arc.axis))}
position={arc.position}
rotation={arc.rotation}
/>
))}
</>
)}
</group>
)
}
export default DuctFittingSelectionAffordance
@@ -1,5 +1,7 @@
import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core'
import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint'
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
import { createSegmentMoveAffordance } from '../shared/path-segment-affordance'
import { buildDuctSegmentFloorplan } from './floorplan'
import { buildDuctSegmentGeometry, ductPortDiameterIn } from './geometry'
import { ductSegmentParametrics } from './parametrics'
@@ -71,6 +73,8 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
slots: () => ductBodySlots(),
paint: ductBodyPaint,
},
parametrics: ductSegmentParametrics,
@@ -103,6 +107,7 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
n.insulated,
n.insulationR,
n.system,
n.slots,
]),
// Open run ends as typed ports — directions point outward along the
@@ -147,6 +152,9 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
// `endpoint-handle` per path vertex; this drags the matching point.
floorplanAffordances: {
'move-path-point': createPathPointMoveAffordance('duct-segment'),
// 2D twin of the 3D side-move arrows: slide a segment perpendicular to
// itself. (Length editing stays on the per-vertex hex handles.)
'move-segment': createSegmentMoveAffordance('duct-segment'),
},
// Selection-time path-point handles (drag to edit a committed run).
@@ -164,7 +172,7 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Start segment' },
{ key: 'Click again', label: 'Place it (locked to 45°)' },
{ key: 'Click again', label: 'Place and continue' },
{ key: 'Shift', label: 'Free angle' },
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
{ key: '[ / ]', label: 'Duct diameter down / up' },
@@ -5,6 +5,10 @@ import type { DuctSegmentNode } from './schema'
const SUPPLY_CENTERLINE = '#d4825a'
const RETURN_CENTERLINE = '#5a8ad4'
const BODY_COLOR = '#9ca3af'
/** Move-arrow stand-off past the duct body, in plan meters. */
const SIDE_ARROW_GAP = 0.27
/** Below this plan length a segment / end has no usable direction. */
const MIN_SEGMENT_LEN = 0.05
/**
* Floor-plan representation of a duct run: the path drawn at the duct's
@@ -96,6 +100,32 @@ export function buildDuctSegmentFloorplan(
payload: { pointIndex: indexMap[k]! },
})
}
// Side-move arrows: a front / back pair at each segment midpoint, sliding
// that segment perpendicular to itself. 2D twin of the 3D side-move
// arrows. The arrows stand one duct-radius + gap off the body; `angle`
// points each chevron outward along the segment normal.
const offset = diameterM / 2 + SIDE_ARROW_GAP
for (let k = 0; k < points.length - 1; k++) {
const a = points[k]!
const b = points[k + 1]!
const dx = b[0] - a[0]
const dz = b[1] - a[1]
const len = Math.hypot(dx, dz)
if (len < MIN_SEGMENT_LEN) continue
const normal: [number, number] = [-dz / len, dx / len]
const mid: FloorplanPoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
for (const side of [1, -1] as const) {
const n: [number, number] = [normal[0] * side, normal[1] * side]
children.push({
kind: 'move-arrow',
point: [mid[0] + n[0] * offset, mid[1] + n[1] * offset],
angle: Math.atan2(n[1], n[0]),
affordance: 'move-segment',
payload: { segmentIndex: indexMap[k]!, normal: n },
})
}
}
}
return { kind: 'group', children }
+57 -14
View File
@@ -1,9 +1,18 @@
import type { GeometryContext } from '@pascal-app/core'
import {
type ColorPreset,
createSurfaceRoleMaterial,
type RenderShading,
resolveMaterialRef,
resolveSlotDefaultMaterial,
} from '@pascal-app/viewer'
import {
BoxGeometry,
CatmullRomCurve3,
CylinderGeometry,
ExtrudeGeometry,
Group,
type Material,
Matrix4,
Mesh,
MeshStandardMaterial,
@@ -13,6 +22,7 @@ import {
TubeGeometry,
Vector3,
} from 'three'
import { DUCT_BODY_SLOT_DEFAULT, DUCT_BODY_SLOT_ID } from '../shared/duct-body-paint'
import type { DuctSegmentNode } from './schema'
export const INCHES_TO_METERS = 0.0254
@@ -137,7 +147,7 @@ export function buildRectSection(
end: Vector3,
widthM: number,
heightM: number,
material: MeshStandardMaterial,
material: Material,
name: string,
roll = 0,
): Mesh | null {
@@ -200,7 +210,7 @@ export function buildOvalSection(
end: Vector3,
widthM: number,
heightM: number,
material: MeshStandardMaterial,
material: Material,
name: string,
roll = 0,
): Mesh | null {
@@ -226,7 +236,7 @@ export function buildSection(
start: Vector3,
end: Vector3,
radius: number,
material: MeshStandardMaterial,
material: Material,
name: string,
): Mesh | null {
const dir = new Vector3().subVectors(end, start)
@@ -318,6 +328,7 @@ function helixRidgeFor(
type DuctAppearance = {
ductMaterial: 'sheet-metal' | 'spiral' | 'flex' | 'duct-board'
system: 'supply' | 'return'
slots?: Record<string, string>
}
function getSystemTint(node: DuctAppearance): string {
@@ -330,12 +341,25 @@ function getSystemTint(node: DuctAppearance): string {
* metal. Shared with the fitting builder so connected runs and junctions
* look like one piece.
*/
export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial {
return new MeshStandardMaterial({
color: '#ffffff',
metalness: 0,
roughness: 0.7,
})
export function createDuctMaterial(
node: DuctAppearance,
sceneMaterials?: GeometryContext['materials'],
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): Material {
if (!textures) {
return createSurfaceRoleMaterial('furnishing', colorPreset, undefined, sceneTheme)
}
const slotRef = node.slots?.[DUCT_BODY_SLOT_ID]
if (slotRef) {
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
if (resolved) return resolved
}
return resolveSlotDefaultMaterial(DUCT_BODY_SLOT_DEFAULT, shading, 0.7)
}
/**
@@ -354,7 +378,14 @@ export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial
* identity since the schema has no position field — the path itself is
* absolute within the level).
*/
export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
export function buildDuctSegmentGeometry(
node: DuctSegmentNode,
ctx?: GeometryContext,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): Group {
const group = new Group()
if (node.path.length < 2) return group
@@ -363,7 +394,14 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
const radius = (node.diameter * INCHES_TO_METERS) / 2
const widthM = node.width * INCHES_TO_METERS
const heightM = node.height * INCHES_TO_METERS
const ductMaterial = createDuctMaterial(node)
const ductMaterial = createDuctMaterial(
node,
ctx?.materials,
shading,
textures,
colorPreset,
sceneTheme,
)
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
@@ -371,9 +409,10 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
half: number,
rectW: number,
rectH: number,
material: MeshStandardMaterial,
material: Material,
namePrefix: string,
endInsetM = 0,
paintableBody = false,
) => {
for (let i = 0; i < points.length - 1; i++) {
// Loop bounds + min(2) on the schema guarantee both points exist.
@@ -396,7 +435,10 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
: isOval
? buildOvalSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
: buildSection(a, b, half, material, `${namePrefix}-section-${i}`)
if (mesh) group.add(mesh)
if (mesh) {
if (paintableBody) mesh.userData.slotId = DUCT_BODY_SLOT_ID
group.add(mesh)
}
}
// Joint caps at interior points only (skip first and last — they're
// open ends; equipment / terminal / fitting collars cap them). Rect
@@ -410,11 +452,12 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
: new Mesh(new SphereGeometry(half, RADIAL_SEGMENTS, 12), material)
joint.name = `${namePrefix}-joint-${i}`
joint.position.copy(points[i] as Vector3)
if (paintableBody) joint.userData.slotId = DUCT_BODY_SLOT_ID
group.add(joint)
}
}
addRun(radius, widthM, heightM, ductMaterial, 'duct')
addRun(radius, widthM, heightM, ductMaterial, 'duct', 0, true)
// Construction body detail: spiral winds its lock seam, flex its wire
// helix (tight pitch — reads as corrugation) over each round section.
+121 -4
View File
@@ -4,6 +4,7 @@ import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
DuctSegmentNode,
emitter,
type GridEvent,
@@ -11,6 +12,7 @@ import {
useScene,
} from '@pascal-app/core'
import {
consumePlacementDragRelease,
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
@@ -27,6 +29,13 @@ import {
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost'
import { collectScenePorts, DUCT_PORT_SYSTEMS } from '../shared/ports'
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
import {
planRunTranslationOffsets,
type RunTranslationOffsetPlan,
} from '../shared/run-translation-offset'
import { rectSectionAxes } from './geometry'
type Vec3 = [number, number, number]
@@ -106,6 +115,7 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
(node.metadata as Record<string, unknown>).isNew === true
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
const [translationGhost, setTranslationGhost] = useState<RunTranslationOffsetPlan | null>(null)
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
const hasMovedRef = useRef(false)
const activatedAtRef = useRef<number>(Date.now())
@@ -138,6 +148,26 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
if (existedAtStart) setMeshHidden(true)
// Carry connected fittings (+ their other runs) as the whole run slides.
// Snapshot once at drag start; only existing runs are mated to anything.
const connectivity: RunMoveConnectivity | null = existedAtStart
? startRunMoveConnectivity(node)
: null
const portConnectivity = existedAtStart
? analyzePortConnectivity(node, useScene.getState().nodes)
: null
const scenePorts = existedAtStart
? collectScenePorts({ excludeNodeId: nodeId, systems: DUCT_PORT_SYSTEMS })
: []
const nodesById = useScene.getState().nodes
const profile = {
shape: duct.shape,
diameter: duct.diameter,
width: duct.width,
height: duct.height,
}
let lastTranslationPlan: RunTranslationOffsetPlan | null = null
const setPreview = (path: Vec3[]) => {
previewPathRef.current = path
setPreviewPath(path)
@@ -177,12 +207,30 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
prevSnapRef.current = cur
hasMovedRef.current = true
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)
setPreview(nextPath)
lastTranslationPlan =
existedAtStart && portConnectivity
? planRunTranslationOffsets({
duct,
translatedPath: nextPath,
profile,
connections: portConnectivity.connections,
scenePorts,
nodesById,
})
: null
if (lastTranslationPlan) connectivity?.clear()
else connectivity?.preview({ path: nextPath })
setTranslationGhost(lastTranslationPlan)
}
const commit = (event: GridEvent) => {
const commit = (event: GridEvent, fromDragRelease = false) => {
if (committed) return
if (Date.now() - activatedAtRef.current < 150) {
// The 150ms debounce only guards click-to-place against the arming click
// double-firing; a press-drag release is a distinct pointerup gesture, so
// it skips the guard (a quick drag-flick still commits).
if (!fromDragRelease && Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
@@ -205,10 +253,44 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
const translationPlan =
portConnectivity &&
planRunTranslationOffsets({
duct,
translatedPath: finalPath,
profile,
connections: portConnectivity.connections,
scenePorts,
nodesById,
})
if (translationPlan) {
useScene.getState().applyNodeChanges({
create: [...translationPlan.fittings, ...translationPlan.connectors].map((created) => ({
node: created as AnyNode,
parentId: node.parentId as AnyNodeId,
})),
update: [
{ id: nodeId, data: { path: translationPlan.ductPath } as Partial<AnyNode> },
...translationPlan.updates,
],
})
} else {
// Fold connected-fitting / sibling-run follow-updates into the SAME
// batch as the moved run so the whole joint is one undo step.
const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? []
useScene
.getState()
.updateNodes([
{ id: nodeId, data: { path: finalPath } as Partial<AnyNode> },
...followUpdates,
])
}
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
// Followers are committed to the store — drop their live overrides so
// renderers read the canonical path/position.
connectivity?.clear()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
@@ -220,6 +302,8 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
const onCancel = () => {
connectivity?.clear()
setTranslationGhost(null)
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -231,14 +315,37 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
useEditor.getState().setMovingNode(null)
}
// Press-drag-release: when the move was engaged by the drag gesture (the
// selection rig's move cross), `placementDragMode` is set, so commit on
// pointer-up at the last previewed path instead of waiting for a second
// click — same contract as the fitting move tool.
const onPlacementDragPointerUp = (event: PointerEvent) => {
if (!consumePlacementDragRelease(event)) return
if (!hasMovedRef.current) {
onCancel()
return
}
commit(
{
nativeEvent: event,
stopPropagation: () => event.stopPropagation(),
} as unknown as GridEvent,
true,
)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
window.addEventListener('pointerup', onPlacementDragPointerUp)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('pointerup', onPlacementDragPointerUp)
connectivity?.clear()
setTranslationGhost(null)
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
@@ -261,6 +368,16 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
{segments.map((seg, i) => (
<GhostSegment a={seg.a} b={seg.b} duct={duct} key={`ghost-${i}`} />
))}
{translationGhost?.fittings.map((fitting) => (
<FittingGhost fitting={fitting} key={`translation-fitting-${fitting.id}`} tint="valid" />
))}
{translationGhost?.connectors.map((connector) => (
<DuctSegmentGhost
duct={connector}
key={`translation-connector-${connector.id}`}
tint="valid"
/>
))}
<DragBoundingBox
centerY={0}
nodeId={node.id}
File diff suppressed because it is too large Load Diff
+473 -259
View File
@@ -2,11 +2,13 @@
import {
type AnyNode,
type CeilingNode,
type DuctFittingNode,
DuctSegmentNode,
emitter,
type GridEvent,
getLevelHeight,
sceneRegistry,
getCeilingAt,
getCeilingHeightAt,
useScene,
} from '@pascal-app/core'
import {
@@ -19,8 +21,17 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useEffect, useRef, useState } from 'react'
import { type Group, Matrix4, Vector3 } from 'three'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
type BufferGeometry,
DoubleSide,
type Group,
Matrix4,
Path,
Shape,
ShapeGeometry,
Vector3,
} from 'three'
import { getDuctFittingPorts } from '../duct-fitting/ports'
import {
planCrossAtRunBody,
@@ -30,6 +41,7 @@ import {
} from '../shared/auto-fitting'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { FittingGhost } from '../shared/mep-ghost'
import {
collectScenePorts,
DUCT_PORT_SYSTEMS,
@@ -40,17 +52,17 @@ import {
type ScenePort,
} from '../shared/ports'
import { ductSegmentDefinition } from './definition'
import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
import { ductPortDiameterIn, rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
/**
* One-segment-at-a-time placement tool for round duct segments.
* Continuous placement tool for duct segments.
*
* Mouse-driven model:
* - **First click** anchors the segment start (port snap joins onto an
* existing run / fitting collar).
* - **Second click** commits a two-point duct immediately and re-arms
* the tool — no polyline accumulation, no finish gesture. Chain runs
* by clicking again near the end you just placed (port snap).
* - **Second click** commits a two-point duct immediately and keeps the
* segment end anchored, so the next click continues the run like wall
* drafting. No polyline accumulation, no finish gesture.
* - **Auto-elbow**: when either end snapped onto another RUN's open
* port at an angle (1590°, vertical turns included), an elbow
* fitting is minted at the joint and the duct pulls back to its
@@ -69,9 +81,10 @@ import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
* vertical mouse motion drives Y. Click commits the riser segment.
* - **[ / ]** step the duct diameter through nominal US sizes; the
* ghost preview and the committed node both use it.
* - **C** toggles ceiling-level placement: the start point lands at
* the level's ceiling height (duct top hugging the ceiling) instead
* of the floor. Subsequent points inherit the start's Y as usual.
* - **C** toggles ceiling-level placement: each point lands just below
* the ceiling actually covering it (duct top hugging that ceiling)
* instead of the floor, so a run tracks per-room ceiling heights.
* Points not under any ceiling fall back to the floor.
* - Esc clears an anchored start point.
*/
const PREVIEW_OPACITY = 0.55
@@ -94,6 +107,11 @@ const ALT_PIXELS_PER_METER = 100
const ALT_Y_MIN_M = -3
const ALT_Y_MAX_M = 10
/** green-500 — the project's bounding-box / placeable accent. The cursor
* ring + vertical line recolour to this while the point is snapped onto an
* existing run, so the coincidence reads with the familiar snap green. */
const SNAP_CURSOR_COLOR = '#22c55e'
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
@@ -163,6 +181,14 @@ function continuityRollFrom(port: ScenePort | null, newDir: Vector3): number | n
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
}
function continuityRollForRun(
startPort: ScenePort | null,
endPort: ScenePort | null,
dir: Vector3,
): number {
return continuityRollFrom(startPort, dir) ?? continuityRollFrom(endPort, dir) ?? 0
}
/**
* Nearest typed port — duct run ends, fitting collars, anything whose
* kind registers `def.ports` — within snap range of `point` on the XZ
@@ -259,6 +285,209 @@ function projectToAngleLock(
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
}
/** The full set of nodes a drawn segment produces. The drawn `ducts`
* (and any trunk `tails` from a tee / cross split) are previewed by the
* duct ghost already; `fittings` are the auto-inserted elbow / tee /
* cross nodes the ghost preview draws so the user sees them before the
* commit. Shared by `commitSegment` and the live preview so what you see
* is exactly what lands. */
type DuctDrawPlan = {
fittings: DuctFittingNode[]
ducts: DuctSegmentNode[]
tails: DuctSegmentNode[]
updates: { id: AnyNode['id']; data: Partial<AnyNode> }[]
}
const elbowPlanFor = (
port: ScenePort | null,
awayDir: [number, number, number],
profile: DraftProfile,
) => {
if (!port) return null
const owner = useScene.getState().nodes[port.nodeId]
if (owner?.type !== 'duct-segment') return null
const plan = planElbowAtPort(port, awayDir, profile)
if (!plan) return null
// Trim the run's snapped endpoint back to the elbow's inlet collar.
const path = owner.path.map((p) => [...p] as [number, number, number])
const index = port.id === 'start' ? 0 : path.length - 1
const neighbor = path[index === 0 ? 1 : index - 1]!
const remaining = Math.hypot(
plan.trimmedPortPoint[0] - neighbor[0],
plan.trimmedPortPoint[1] - neighbor[1],
plan.trimmedPortPoint[2] - neighbor[2],
)
// The trim must leave a real piece of the existing run AND not flip it.
const original = path[index]!
const originalLen = Math.hypot(
original[0] - neighbor[0],
original[1] - neighbor[1],
original[2] - neighbor[2],
)
if (remaining < 0.08 || remaining >= originalLen) return null
path[index] = plan.trimmedPortPoint
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
}
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
if (!port) return null
const owner = useScene.getState().nodes[port.nodeId]
if (owner?.type !== 'duct-fitting') return null
return planElbowRealign(owner, port.id, awayDir)
}
/**
* Pure planner for a drawn duct segment: given its endpoints and what
* each end snapped onto (an open port, or a run body for a tee / cross
* tap), decide every node the commit creates / updates — auto-inserted
* elbows / tees / crosses, the drawn run (split in two when it crosses a
* trunk), trunk tails, and trim / realign updates. Reads the live scene
* graph but mutates nothing, so the live preview can call it each frame
* to ghost the fittings before the commit applies the identical plan.
*/
function planDuctDraw(
start: [number, number, number],
end: [number, number, number],
startPort: ScenePort | null,
startBody: RunBodyHit | null,
endPort: ScenePort | null,
endBody: RunBodyHit | null,
profile: DraftProfile,
): DuctDrawPlan | null {
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
if (length < 1e-4) return null
const dir: [number, number, number] = [
(end[0] - start[0]) / length,
(end[1] - start[1]) / length,
(end[2] - start[2]) / length,
]
const startPlan = elbowPlanFor(startPort, dir, profile)
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]], profile)
const startRealign = startPlan ? null : realignPlanFor(startPort, dir)
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
const trunkBody = startPlan ? null : startBody
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
const teePlan =
trunkBody && trunkOwner?.type === 'duct-segment'
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profile)
: null
const endTrunkBody = endPlan || endRealign ? null : endBody
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
const endTeePlan =
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
? planTeeAtRunBody(endTrunkOwner, endTrunkBody, [-dir[0], -dir[1], -dir[2]], profile)
: null
let ductStart =
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
let ductEnd = endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
const remaining = Math.hypot(
ductEnd[0] - ductStart[0],
ductEnd[1] - ductStart[1],
ductEnd[2] - ductStart[2],
)
let plans = [startPlan, endPlan].filter((p) => p !== null)
let tee = teePlan
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
let realigns = [startRealign, endRealign].filter((p) => p !== null)
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
const crossTappedElsewhere =
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
let cross =
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
? planCrossAtRunBody(crossOwner, crossHit, dir, profile)
: null
if (remaining <= 0.08) {
plans = []
tee = null
endTee = null
realigns = []
cross = null
ductStart = start
ductEnd = end
}
// Rect / oval continuity: roll the new run's cross-section so its
// profile stays continuous with whatever either end joined.
let roll = 0
if (profile.shape !== 'round') {
const newDir = new Vector3(...dir)
roll = continuityRollForRun(startPort, endPort, newDir)
}
const defaults = ductSegmentDefinition.defaults()
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
DuctSegmentNode.parse({
...defaults,
...toolDefaults,
name: profile.shape === 'rect' ? 'Trunk' : 'Duct run',
path: [from, to],
shape: profile.shape,
diameter: profile.diameter,
width: profile.width,
height: profile.height,
roll,
})
const ducts = cross
? [
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
? makeDuct(ductStart, cross.branchCollarNear)
: null,
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
? makeDuct(cross.branchCollarFar, ductEnd)
: null,
].filter((d) => d !== null)
: [makeDuct(ductStart, ductEnd)]
const fittings: DuctFittingNode[] = [
...plans.map((p) => p.fitting),
...(tee ? [tee.fitting] : []),
...(endTee ? [endTee.fitting] : []),
...(cross ? [cross.fitting] : []),
]
const tails: DuctSegmentNode[] = [
...(tee ? [tee.trunkTail] : []),
...(endTee ? [endTee.trunkTail] : []),
...(cross ? [cross.trunkTail] : []),
]
const updates: { id: AnyNode['id']; data: Partial<AnyNode> }[] = [
...plans.map((p) => p.trim),
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
...realigns.map((p) => p.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
]
return { fittings, ducts, tails, updates }
}
function ductEndPort(duct: DuctSegmentNode, id: 'start' | 'end'): ScenePort | null {
if (duct.path.length < 2) return null
const index = id === 'start' ? 0 : duct.path.length - 1
const neighborIndex = id === 'start' ? 1 : duct.path.length - 2
const position = duct.path[index]!
const neighbor = duct.path[neighborIndex]!
const dx = position[0] - neighbor[0]
const dy = position[1] - neighbor[1]
const dz = position[2] - neighbor[2]
const len = Math.hypot(dx, dy, dz)
const direction: [number, number, number] =
len < 1e-9 ? [1, 0, 0] : [dx / len, dy / len, dz / len]
return {
id,
nodeId: duct.id,
position,
direction,
diameter: ductPortDiameterIn(duct),
system: duct.system,
}
}
const DuctSegmentTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const unit = useViewer((s) => s.unit)
@@ -285,12 +514,24 @@ const DuctSegmentTool = () => {
// Ceiling mode (toggle with C): the first point lands at the level's
// ceiling height (duct top hugging the ceiling) instead of the floor.
const [ceilingMode, setCeilingMode] = useState(false)
// When the cursor is within snap range of an existing duct's endpoint we
// surface a brighter indicator and commit at the endpoint's exact coords.
// The shared coordinate when the cursor is within snap range of an existing
// duct (null = free placement). Drives the green cursor highlight so the
// user sees the next click will join an existing run, not freeform-place.
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
// In ceiling mode, the ceiling the cursor is currently under — rendered as
// a translucent overlay so the duct reads as hung against a real surface
// rather than a dot floating in space. Null when off-ceiling.
const [hoverCeiling, setHoverCeiling] = useState<CeilingNode | null>(null)
// True while Alt is held with a last point on the draft — drives the
// vertical-cylinder ghost and the cursor HUD label.
const [altActive, setAltActive] = useState(false)
// What the in-flight cursor end currently snaps onto (port end, or a
// run body for a tee / cross tap). Drives the auto-fitting GHOST so the
// user sees the elbow / tee / cross the next click will mint.
const [endSnap, setEndSnap] = useState<{ port: ScenePort | null; body: RunBodyHit | null }>({
port: null,
body: null,
})
// Mirror into refs so emitter callbacks (closing over the first render's
// setState) read the latest values without re-subscribing.
const draftRef = useRef(draftPoints)
@@ -317,246 +558,63 @@ const DuctSegmentTool = () => {
useEffect(() => {
if (!activeLevelId) return
/**
* Auto-elbow gate: only joints onto another RUN's open end get a
* fitting minted. Ports on fittings / equipment / terminals are
* already proper connections — a duct mates straight onto those.
*
* The elbow's junction sits ON the drawn corner, so the existing run
* must trim back one leg to make room (`trim` update). Plans that
* would trim the run to (or past) nothing are dropped — that corner
* stays a plain butt joint. Guards against the snapped node having
* been deleted between clicks.
*/
const elbowPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
if (!port) return null
const owner = useScene.getState().nodes[port.nodeId]
if (owner?.type !== 'duct-segment') return null
const plan = planElbowAtPort(port, awayDir, profileRef.current)
if (!plan) return null
// Trim the run's snapped endpoint back to the elbow's inlet collar.
const path = owner.path.map((p) => [...p] as [number, number, number])
const index = port.id === 'start' ? 0 : path.length - 1
const neighbor = path[index === 0 ? 1 : index - 1]!
const remaining = Math.hypot(
plan.trimmedPortPoint[0] - neighbor[0],
plan.trimmedPortPoint[1] - neighbor[1],
plan.trimmedPortPoint[2] - neighbor[2],
)
// The trim must leave a real piece of the existing run AND not flip
// it (trimmed point past the neighbor) — otherwise skip the fitting.
const original = path[index]!
const originalLen = Math.hypot(
original[0] - neighbor[0],
original[1] - neighbor[1],
original[2] - neighbor[2],
)
if (remaining < 0.08 || remaining >= originalLen) return null
path[index] = plan.trimmedPortPoint
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
}
/**
* Realign gate: the snapped port belongs to an existing ELBOW's open
* collar — re-aim that elbow (junction + mated collar fixed, free
* collar swings to the drawn direction). Null when the owner isn't
* an elbow or the required turn leaves the 1590° range.
*/
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
if (!port) return null
const owner = useScene.getState().nodes[port.nodeId]
if (owner?.type !== 'duct-fitting') return null
return planElbowRealign(owner, port.id, awayDir)
}
// One segment per gesture: first click anchors the start, second
// click commits a two-point duct immediately. No selection switch —
// the tool stays armed so the next click starts the next segment
// (port snap joins it onto the end just committed).
// Continuous chain: first click anchors the start, each following
// click commits one two-point duct and uses that duct's far end as
// the next anchor. No selection switch or finish gesture.
//
// When an end of the segment snapped onto another run's open port at
// an angle, an elbow fitting is minted at that joint and the duct is
// pulled back to the elbow's outlet collar — corners get real
// fittings instead of butt joints.
// All the auto-fitting decisions (elbow / tee / cross) live in the
// shared `planDuctDraw` so the live ghost previews exactly what this
// commit applies.
const commitSegment = (
start: [number, number, number],
end: [number, number, number],
endPort: ScenePort | null = null,
endBody: RunBodyHit | null = null,
) => {
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
if (length < 1e-4) return
const dir: [number, number, number] = [
(end[0] - start[0]) / length,
(end[1] - start[1]) / length,
(end[2] - start[2]) / length,
]
const startPlan = elbowPlanFor(startPortRef.current, dir)
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
// Existing-fitting joints: re-aim the elbow whose collar was hit so
// it faces the drawn run instead of leaving a mismatched butt joint.
const startRealign = startPlan ? null : realignPlanFor(startPortRef.current, dir)
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
// Tee tap: the start snapped onto a run's BODY (not an end port) —
// split the trunk and branch from the tee's collar.
const trunkBody = startPlan ? null : startBodyRef.current
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
const teePlan =
trunkBody && trunkOwner?.type === 'duct-segment'
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profileRef.current)
: null
// End tee tap: the END landed on a run's BODY — split that trunk and
// the new duct ends at the tee's branch collar. The branch leaves
// toward the drawn run (back along -dir, since dir points start→end).
const endTrunkBody = endPlan || endRealign ? null : endBody
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
const endTeePlan =
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
? planTeeAtRunBody(
endTrunkOwner,
endTrunkBody,
[-dir[0], -dir[1], -dir[2]],
profileRef.current,
)
: null
let ductStart =
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
let ductEnd =
endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
// The collar pull-back must leave a real piece of duct between the
// fittings; if not, fall back to the plain joint.
const remaining = Math.hypot(
ductEnd[0] - ductStart[0],
ductEnd[1] - ductStart[1],
ductEnd[2] - ductStart[2],
const plan = planDuctDraw(
start,
end,
startPortRef.current,
startBodyRef.current,
endPort,
endBody,
profileRef.current,
)
let plans = [startPlan, endPlan].filter((p) => p !== null)
let tee = teePlan
// Both ends tapping the SAME trunk would split one polyline twice in
// a single change (conflicting updates + double tail) — drop the end
// tee in that rare case and let the end butt-join instead.
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
let realigns = [startRealign, endRealign].filter((p) => p !== null)
// Cross tap: the drawn run passes straight THROUGH a trunk's body
// (interior crossing, not an end touch). Split that trunk and the
// drawn duct into two halves meeting the cross's opposed branch
// collars. Skip a run already tapped by a start / end tee so one
// polyline isn't split twice in a single change.
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
const crossTappedElsewhere =
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
let cross =
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
? planCrossAtRunBody(crossOwner, crossHit, dir, profileRef.current)
: null
if (remaining <= 0.08) {
plans = []
tee = null
endTee = null
realigns = []
cross = null
ductStart = start
ductEnd = end
}
// Rect / oval continuity: roll the new run's cross-section so its
// profile stays continuous with whatever either end joined — run
// end or fitting collar, turn or straight continuation (see
// `continuityRollFrom`). The start joint wins if both ends join.
let roll = 0
if (profileRef.current.shape !== 'round') {
const newDir = new Vector3(...dir)
roll =
continuityRollFrom(startPortRef.current, newDir) ??
continuityRollFrom(endPort, newDir) ??
0
}
const defaults = ductSegmentDefinition.defaults()
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
DuctSegmentNode.parse({
...defaults,
...toolDefaults,
name: profileRef.current.shape === 'rect' ? 'Trunk' : 'Duct run',
path: [from, to],
shape: profileRef.current.shape,
diameter: profileRef.current.diameter,
width: profileRef.current.width,
height: profileRef.current.height,
roll,
})
// A cross splits the drawn run into two halves that meet its opposed
// branch collars; otherwise it's one duct end-to-end. Degenerate
// halves (the crossing too near an end) are dropped.
const ducts = cross
? [
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
? makeDuct(ductStart, cross.branchCollarNear)
: null,
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
? makeDuct(cross.branchCollarFar, ductEnd)
: null,
].filter((d) => d !== null)
: [makeDuct(ductStart, ductEnd)]
if (!plan) return
// One atomic change: trim / split the joined runs, create the
// fittings + the new duct. Single undo step.
useScene.getState().applyNodeChanges({
create: [
...plans.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })),
...(tee
? [
{ node: tee.fitting, parentId: activeLevelId },
{ node: tee.trunkTail, parentId: activeLevelId },
]
: []),
...(endTee
? [
{ node: endTee.fitting, parentId: activeLevelId },
{ node: endTee.trunkTail, parentId: activeLevelId },
]
: []),
...(cross
? [
{ node: cross.fitting, parentId: activeLevelId },
{ node: cross.trunkTail, parentId: activeLevelId },
]
: []),
...ducts.map((node) => ({ node, parentId: activeLevelId })),
],
update: [
...plans.map((plan) => plan.trim),
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
...realigns.map((plan) => plan.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
...plan.fittings.map((node) => ({ node, parentId: activeLevelId })),
...plan.tails.map((node) => ({ node, parentId: activeLevelId })),
...plan.ducts.map((node) => ({ node, parentId: activeLevelId })),
],
update: plan.updates,
})
const nextDuct = plan.ducts.at(-1)
const nextStart = nextDuct ? nextDuct.path[nextDuct.path.length - 1]! : end
const nextPort = nextDuct ? ductEndPort(nextDuct, 'end') : endPort
triggerSFX('sfx:item-place')
setDraftPoints([])
setDraftPoints([nextStart])
setSnapTarget(null)
startPortRef.current = null
startBodyRef.current = null
setEndSnap({ port: null, body: null })
startPortRef.current = nextPort
startBodyRef.current = nextPort ? null : endBody
altAnchorRef.current = null
setAltActive(false)
}
// Base Y for a fresh run's first point: floor (0) by default, or just
// below the level's ceiling in ceiling mode so the duct's top hugs the
// ceiling (centerline = ceiling height radius).
const resolveBaseY = (): number => {
// Y for a point at level-local `[x, z]`. Floor (0) when ceiling mode is
// off. In ceiling mode, query the ceiling actually covering that point
// and hang the duct just below it (centerline = ceiling underside
// half the duct's vertical dimension) so its top hugs the ceiling. Each
// point follows its own ceiling, so a run stepping into a room with a
// different ceiling height tracks that change. Points not under any
// ceiling fall back to the floor.
const resolveCeilingY = (x: number, z: number): number => {
if (!ceilingModeRef.current) return 0
const ceiling = getLevelHeight(
activeLevelId,
useScene.getState().nodes,
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
)
const ceiling = getCeilingHeightAt(activeLevelId, useScene.getState().nodes, x, z)
if (ceiling === null) return 0
const p = profileRef.current
const verticalIn = p.shape === 'round' ? p.diameter : p.height
return Math.max(0, ceiling - (verticalIn * 0.0254) / 2)
@@ -571,11 +629,11 @@ const DuctSegmentTool = () => {
body: RunBodyHit | null
} => {
const last = draftRef.current.at(-1)
// First point of the run: grid-snapped placement at the base Y (floor,
// or ceiling height in ceiling mode). Endpoint snap can still join an
// existing run.
// First point of the run: grid-snapped placement. Y follows the
// ceiling under the cursor in ceiling mode (floor otherwise).
// Endpoint snap can still join an existing run.
if (!last) {
const baseY = resolveBaseY()
const baseY = resolveCeilingY(event.localPosition[0], event.localPosition[2])
const raw: [number, number, number] = [
event.localPosition[0],
baseY,
@@ -601,15 +659,20 @@ const DuctSegmentTool = () => {
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
if (body) return { point: body.point, snapped: body.point, port: null, body }
}
const sx = snap(raw[0], step)
const sz = snap(raw[2], step)
return {
point: [snap(raw[0], step), baseY, snap(raw[2], step)],
point: [sx, resolveCeilingY(sx, sz), sz],
snapped: null,
port: null,
body: null,
}
}
// Subsequent points: angle-locked to 45° from `last` (Shift releases).
// Y stays at `last[1]` — depth changes come from Shift+click risers.
// Y inherits `last[1]` for the angle/probe math; the free placement
// below re-resolves it from the ceiling under the point in ceiling
// mode, so a run stepping into a room with a different ceiling height
// tracks that change. Depth changes otherwise come from Alt risers.
const rawXZ: [number, number, number] = [
event.localPosition[0],
last[1],
@@ -638,8 +701,11 @@ const DuctSegmentTool = () => {
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
if (body) return { point: body.point, snapped: body.point, port: null, body }
}
const fx = snap(angled[0], step)
const fz = snap(angled[2], step)
const fy = ceilingModeRef.current ? resolveCeilingY(fx, fz) : angled[1]
return {
point: [snap(angled[0], step), angled[1], snap(angled[2], step)],
point: [fx, fy, fz],
snapped: null,
port: null,
body: null,
@@ -681,6 +747,17 @@ const DuctSegmentTool = () => {
return { ...r, point }
}
// The ceiling the cursor is under (ceiling mode only) — drives the
// translucent surface overlay so the in-flight point reads as hung
// against a real ceiling. Cleared when off-ceiling or out of mode.
const updateHoverCeiling = (x: number, z: number) => {
if (!ceilingModeRef.current) {
setHoverCeiling(null)
return
}
setHoverCeiling(getCeilingAt(activeLevelId, useScene.getState().nodes, x, z))
}
const onMove = (event: GridEvent) => {
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
if (typeof clientY === 'number') lastClientYRef.current = clientY
@@ -691,12 +768,16 @@ const DuctSegmentTool = () => {
clearDrawAlignment()
setCursorPos(point)
setSnapTarget(null)
setEndSnap({ port: null, body: null })
updateHoverCeiling(point[0], point[2])
return
}
}
const { point, snapped } = resolveAlignedPoint(event)
const { point, snapped, port, body } = resolveAlignedPoint(event)
setCursorPos(point)
setSnapTarget(snapped)
setEndSnap({ port, body: port ? null : body })
updateHoverCeiling(point[0], point[2])
}
const onClick = (event: GridEvent) => {
@@ -783,12 +864,14 @@ const DuctSegmentTool = () => {
setProfile((p) => ({ ...p, shape: p.shape === 'round' ? 'rect' : 'round' }))
triggerSFX('sfx:grid-snap')
} else if (e.key === 'c' || e.key === 'C') {
// Toggle ceiling mode. Only the first point reads the base Y, so
// toggling mid-run is a no-op until the next fresh segment — flip
// it only while unanchored to keep the behaviour predictable.
// Toggle ceiling mode: points hang from the ceiling above them
// (duct top hugging the ceiling) instead of sitting on the floor.
// Only flip while unanchored — already-placed points keep their Y,
// so a mid-run toggle would split a run across two height regimes.
if (draftRef.current.length > 0) return
e.preventDefault()
setCeilingMode((m) => !m)
setHoverCeiling(null)
triggerSFX('sfx:grid-snap')
}
}
@@ -807,6 +890,8 @@ const DuctSegmentTool = () => {
setDraftPoints([])
setCursorPos(null)
setSnapTarget(null)
setEndSnap({ port: null, body: null })
setHoverCeiling(null)
startPortRef.current = null
startBodyRef.current = null
}
@@ -838,6 +923,22 @@ const DuctSegmentTool = () => {
previewSegments.push({ a: last, b: cursorPos })
}
// Ghost the auto-inserted fittings (elbow / tee / cross) the next click
// will mint, by running the SAME planner the commit uses against the
// in-flight endpoints. Skipped in Alt-vertical mode (no XZ tap there).
const ghostFittings =
last && cursorPos && !altActive
? (planDuctDraw(
last,
cursorPos,
startPortRef.current,
startBodyRef.current,
endSnap.port,
endSnap.body,
profile,
)?.fittings ?? [])
: []
// Wall-style dimension pill above the cursor: absolute world coords before
// the first point, signed per-axis deltas from the last placed point while
// a segment is in flight. The actively-driven axis is emphasised — Y in
@@ -868,45 +969,71 @@ const DuctSegmentTool = () => {
: 'z'
: undefined
// When the in-flight point hangs above the floor (ceiling mode, or an
// Alt riser), the cursor marker itself rides AT the point (where the
// mouse is aiming and the next click commits), and a plumb line drops
// straight down to a faint ground ring on the floor below — so the plan
// position stays legible from any angle. A floor-level point keeps the
// standard fixed-height cursor look.
const cursorElevation = cursorPos ? cursorPos[1] : 0
const isElevated = cursorElevation > 0.001
const cursorGround: [number, number, number] | null = cursorPos
? [cursorPos[0], 0, cursorPos[2]]
: null
return (
<LevelOffsetGroup>
{/* Ceiling-mode surface highlight — the ceiling the cursor is under,
tinted at its own elevation so the duct reads as hung against a
real surface instead of a point floating in space. */}
{ceilingMode && hoverCeiling && <CeilingHighlight ceiling={hoverCeiling} />}
{/* Cursor marker — the same ground ring + vertical line + tool-icon
badge walls and items show while drawing (icon resolved from the
active `duct-segment` structure-tools entry). The dimension pill
rides just above the cursor. */}
{cursorPos && (
{cursorPos && cursorGround && (
<>
<CursorSphere position={cursorPos} ref={cursorRef} />
{/* In ceiling mode (or any elevated point) the ground ring sits on
the floor below the cursor and the line rises to the placement
point, with the bright dot + tool badge at its tip — exactly
where the next click commits. At floor level it's the standard
fixed-height cursor. */}
{isElevated ? (
<CursorSphere
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
dotAtTip
height={cursorElevation}
position={cursorGround}
ref={cursorRef}
/>
) : (
<CursorSphere
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
position={cursorPos}
ref={cursorRef}
/>
)}
{pillParts && (
<group position={cursorPos}>
<Html
center
position={[0, 0.35, 0]}
position={[0, 1.45, 0]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<div className="flex flex-col items-center gap-1">
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
<div className="flex flex-col items-center gap-2">
{ceilingMode && !last && (
<div className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-3 py-0.5 text-[10px] text-muted-foreground shadow-sm backdrop-blur">
Ceiling · C to toggle
</div>
)}
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
</div>
</Html>
</group>
)}
</>
)}
{/* Endpoint-snap halo — brighter ring around the target endpoint
while the cursor is within snap range, so the user sees that the
next click will join an existing duct rather than freeform-place. */}
{snapTarget && (
<mesh layers={EDITOR_LAYER} position={snapTarget}>
<sphereGeometry args={[0.12, 24, 16]} />
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
</mesh>
)}
{/* Committed point pips */}
{draftPoints.map((p, i) => (
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
@@ -919,25 +1046,112 @@ const DuctSegmentTool = () => {
<PreviewSegment
a={seg.a}
b={seg.b}
endPort={endSnap.port}
key={`seg-${i}`}
profile={profile}
startPort={startPortRef.current}
/>
))}
{/* Auto-fitting ghosts — the elbow / tee / cross the next click mints. */}
{ghostFittings.map((fitting) => (
<FittingGhost fitting={fitting} key={fitting.id} />
))}
</LevelOffsetGroup>
)
}
/**
* Build a horizontal `ShapeGeometry` for a ceiling polygon (with holes) in
* level-local XZ, laid flat in the XZ plane. Mirrors the ceiling renderer /
* move-tool convention (Z negated, then rotated onto the floor plane).
*/
function buildCeilingShape(
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
): BufferGeometry | null {
if (polygon.length < 3) return null
const shape = new Shape()
const first = polygon[0]!
shape.moveTo(first[0], -first[1])
for (let i = 1; i < polygon.length; i++) {
const pt = polygon[i]!
shape.lineTo(pt[0], -pt[1])
}
shape.closePath()
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
const hole = new Path()
const hf = holePolygon[0]!
hole.moveTo(hf[0], -hf[1])
for (let i = 1; i < holePolygon.length; i++) {
const pt = holePolygon[i]!
hole.lineTo(pt[0], -pt[1])
}
hole.closePath()
shape.holes.push(hole)
}
const geometry = new ShapeGeometry(shape)
geometry.rotateX(-Math.PI / 2)
return geometry
}
/**
* Translucent overlay of the ceiling the cursor is under, drawn at the
* ceiling's own height. Gives the in-flight duct point a real surface to
* read against, so "hung against the ceiling" is visible from any angle
* instead of being a dot floating in space.
*/
function CeilingHighlight({ ceiling }: { ceiling: CeilingNode }) {
const geometry = useMemo(
() => buildCeilingShape(ceiling.polygon, ceiling.holes),
[ceiling.polygon, ceiling.holes],
)
const outline = useMemo(() => {
if (ceiling.polygon.length < 2) return null
const pts = ceiling.polygon.map(([x, z]) => new Vector3(x, 0, z))
const f = ceiling.polygon[0]!
pts.push(new Vector3(f[0], 0, f[1]))
return pts
}, [ceiling.polygon])
if (!geometry) return null
const y = ceiling.height ?? 2.5
return (
<group position={[0, y, 0]}>
<mesh geometry={geometry} layers={EDITOR_LAYER} renderOrder={1}>
<meshBasicMaterial
color="#818cf8"
depthWrite={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
{outline && (
<line>
<bufferGeometry
ref={(g) => {
if (g) g.setFromPoints(outline)
}}
/>
<lineBasicMaterial color="#818cf8" opacity={0.6} transparent />
</line>
)}
</group>
)
}
function PreviewSegment({
a,
b,
profile,
startPort,
endPort,
}: {
a: [number, number, number]
b: [number, number, number]
profile: DraftProfile
startPort: ScenePort | null
endPort: ScenePort | null
}) {
const start = new Vector3(...a)
const end = new Vector3(...b)
@@ -959,7 +1173,7 @@ function PreviewSegment({
if (!m) return
// Same basis AND roll as the commit will use, so the ghost
// shows the orientation that actually lands.
const roll = continuityRollFrom(startPort, dir) ?? 0
const roll = continuityRollForRun(startPort, endPort, dir)
const { width: x, height: z } = rectSectionAxes(dir, roll)
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
}}
+30 -2
View File
@@ -5,6 +5,7 @@ import {
type EyebrowVentNode,
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 EyebrowVentPreview from './preview'
/**
@@ -71,10 +78,21 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
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
@@ -101,12 +119,18 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
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
@@ -147,6 +171,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
if (obj) obj.visible = true
triggerSFX('sfx:item-place')
clearRoofSurfacePlacementGuides()
exitMoveMode()
event.stopPropagation()
}
@@ -165,6 +190,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
useScene.getState().deleteNode(node.id as AnyNodeId)
useScene.temporal.getState().resume()
markToolCancelConsumed()
clearRoofSurfacePlacementGuides()
exitMoveMode()
return
}
@@ -184,6 +210,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
useScene.temporal.getState().resume()
markToolCancelConsumed()
clearRoofSurfacePlacementGuides()
exitMoveMode()
}
@@ -213,6 +240,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
clearRoofSurfacePlacementGuides()
useScene.temporal.getState().resume()
}
}, [exitMoveMode, node])
+18 -1
View File
@@ -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 { eyebrowVentDefinition } from './definition'
import EyebrowVentPreview from './preview'
@@ -80,6 +85,15 @@ const EyebrowVentTool = () => {
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()
}
@@ -104,6 +118,7 @@ const EyebrowVentTool = () => {
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [vent.id] })
triggerSFX('sfx:item-place')
clearRoofSurfacePlacementGuides()
event.stopPropagation()
}
@@ -115,8 +130,9 @@ const EyebrowVentTool = () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
clearRoofSurfacePlacementGuides()
}
}, [activeBuildingId, setSelection])
}, [activeBuildingId, setSelection, previewNode])
return (
<>
@@ -126,6 +142,7 @@ const EyebrowVentTool = () => {
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
clearRoofSurfacePlacementGuides()
}}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
+1
View File
@@ -552,6 +552,7 @@ export const FenceTool: React.FC = () => {
}
const onGridClick = (event: GridEvent) => {
if (!previewRef.current) return
if (buildingState.current === 1 && event.nativeEvent.detail >= 2) {
stopDrafting()
return
+20 -3
View File
@@ -17,7 +17,11 @@ import {
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
import { createRelativeRoofDrag, snapRelativeRoofDragTarget } from '../shared/relative-roof-drag'
import {
clearRoofSurfacePlacementGuides,
publishRoofSurfaceNodePlacementGuides,
} from '../shared/roof-surface-placement-guides'
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
import GutterPreview from './preview'
@@ -83,11 +87,13 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
lastTarget = null
lastSnap = null
setTarget(null)
clearRoofSurfacePlacementGuides()
}
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
const target = roofDrag.resolve(event)
if (!target) return null
const rawTarget = roofDrag.resolve(event)
if (!rawTarget) return null
const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true)
return {
segment: target.segment,
snap: resolveEaveSnap(target.segment, target.localX, target.localZ),
@@ -131,6 +137,13 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
},
snap,
})
publishRoofSurfaceNodePlacementGuides({
roof,
segment: target.segment,
center: [snap.eaveX, snap.eaveY, snap.eaveZ],
node: { ...node, rotation: snap.rotation },
mode: 'linear-edge',
})
event.stopPropagation()
}
@@ -178,6 +191,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
if (obj) obj.visible = true
triggerSFX('sfx:item-place')
clearRoofSurfacePlacementGuides()
exitMoveMode()
event.stopPropagation()
}
@@ -196,6 +210,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
useScene.getState().deleteNode(node.id as AnyNodeId)
useScene.temporal.getState().resume()
markToolCancelConsumed()
clearRoofSurfacePlacementGuides()
exitMoveMode()
return
}
@@ -215,6 +230,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
useScene.temporal.getState().resume()
markToolCancelConsumed()
clearRoofSurfacePlacementGuides()
exitMoveMode()
}
@@ -244,6 +260,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
clearRoofSurfacePlacementGuides()
useScene.temporal.getState().resume()
}
}, [exitMoveMode, node])
+19 -2
View File
@@ -13,6 +13,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
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 { gutterDefinition } from './definition'
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
import GutterPreview from './preview'
@@ -100,6 +105,13 @@ const GutterTool = () => {
},
snap,
})
publishRoofSurfacePlacementGuides({
roof,
segment: hit.segment,
center: [snap.eaveX, snap.eaveY, snap.eaveZ],
footprint: roofSurfaceFootprintFromNode({ ...previewNode, rotation: snap.rotation }),
mode: 'linear-edge',
})
event.stopPropagation()
}
@@ -129,6 +141,7 @@ const GutterTool = () => {
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [gutter.id] })
triggerSFX('sfx:item-place')
clearRoofSurfacePlacementGuides()
event.stopPropagation()
}
@@ -140,15 +153,19 @@ const GutterTool = () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
clearRoofSurfacePlacementGuides()
}
}, [activeBuildingId, setSelection])
}, [activeBuildingId, setSelection, previewNode])
return (
<>
<RoofAttachmentFallbackPreview
activeBuildingId={activeBuildingId}
ghost={<GutterPreview node={previewNode} invalid />}
onInvalidTarget={() => setTarget(null)}
onInvalidTarget={() => {
setTarget(null)
clearRoofSurfacePlacementGuides()
}}
/>
{activeBuildingId && target && (
<group position={target.roof.position} rotation-y={target.roof.rotation}>
+1 -1
View File
@@ -380,7 +380,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
mesh.castShadow = !hasGlass
mesh.receiveShadow = !hasGlass
}
}, [ref, scene, shading, textures, colorPreset, node.slots, sceneMaterials])
}, [shading, textures, colorPreset, node.slots, sceneMaterials])
const interactive = interactiveRef.current
const animEffect =
-124
View File
@@ -1,124 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { planLinesetConnect } from './connect'
import type { LinesetNode } from './schema'
type Point = [number, number, number]
/** Minimal stand-in — the planner only reads `id` and `path`. */
function line(id: string, path: Point[]): LinesetNode {
return { id, path } as unknown as LinesetNode
}
describe('planLinesetConnect', () => {
test('no shared endpoint → create', () => {
const plan = planLinesetConnect(
[
line('a', [
[0, 0, 0],
[1, 0, 0],
]),
],
[5, 0, 0],
[6, 0, 0],
)
expect(plan).toEqual({
kind: 'create',
path: [
[5, 0, 0],
[6, 0, 0],
],
})
})
test('new start meets run end → extend, old end becomes interior', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [1, 0, 0], [1, 0, 2])
expect(plan).toEqual({
kind: 'extend',
id: 'a',
path: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 2],
],
})
})
test('new start meets run start → extend, run reversed so join is interior', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [0, 0, 0], [0, 0, 2])
expect(plan).toEqual({
kind: 'extend',
id: 'a',
path: [
[1, 0, 0],
[0, 0, 0],
[0, 0, 2],
],
})
})
test('new end meets a run → extend, new segment leads', () => {
const a = line('a', [
[1, 0, 0],
[2, 0, 0],
])
const plan = planLinesetConnect([a], [1, 0, 3], [1, 0, 0])
expect(plan).toEqual({
kind: 'extend',
id: 'a',
path: [
[1, 0, 3],
[1, 0, 0],
[2, 0, 0],
],
})
})
test('both ends meet distinct runs → bridge, second run absorbed', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const b = line('b', [
[1, 0, 5],
[2, 0, 5],
])
const plan = planLinesetConnect([a, b], [1, 0, 0], [1, 0, 5])
expect(plan).toEqual({
kind: 'bridge',
id: 'a',
deleteId: 'b',
path: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 5],
[2, 0, 5],
],
})
})
test('both ends meet the SAME run → not a bridge (extends at start)', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [0, 0, 0], [1, 0, 0])
expect(plan.kind).toBe('extend')
})
test('float drift within tolerance still coincides', () => {
const a = line('a', [
[0, 0, 0],
[1, 0, 0],
])
const plan = planLinesetConnect([a], [1.0000001, 0, 0], [1, 0, 2])
expect(plan.kind).toBe('extend')
})
})
-98
View File
@@ -1,98 +0,0 @@
import type { LinesetNode } from './schema'
type Point = [number, number, number]
type LinesetId = LinesetNode['id']
/** Coincidence tolerance (meters) for folding endpoints into one run. The
* draw tool snaps onto an existing run's endpoint exactly, so this only
* needs to absorb float drift, not user aim. */
const COINCIDENT_EPS_M = 1e-3
function samePoint(a: Point, b: Point): boolean {
return (
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
)
}
/** Which terminal of `line` coincides with `p`, if either. */
function matchEnd(line: LinesetNode, p: Point): 'start' | 'end' | null {
const path = line.path as Point[]
if (samePoint(path[0]!, p)) return 'start'
if (samePoint(path[path.length - 1]!, p)) return 'end'
return null
}
/** First lineset whose start or end coincides with `p`. */
function findConnection(
existing: LinesetNode[],
p: Point,
): { line: LinesetNode; side: 'start' | 'end' } | null {
for (const line of existing) {
if (line.path.length < 2) continue
const side = matchEnd(line, p)
if (side) return { line, side }
}
return null
}
/** Path re-ordered so the connecting terminal is its LAST point. */
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'end' ? path : [...path].reverse()
}
/** Path re-ordered so the connecting terminal is its FIRST point. */
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'start' ? path : [...path].reverse()
}
/**
* Outcome of committing a new `start`→`end` segment against the existing
* lineset runs on the same level:
* - `create` — no shared endpoint; place a fresh standalone run.
* - `extend` — one end lands on run `id`; grow that run's path so the old
* terminal becomes an interior point (the geometry miters it).
* - `bridge` — both ends land on two *different* runs; weld them plus the
* new segment into one path on `id` and delete the absorbed `deleteId`.
*/
export type LinesetConnectPlan =
| { kind: 'create'; path: Point[] }
| { kind: 'extend'; id: LinesetId; path: Point[] }
| { kind: 'bridge'; id: LinesetId; path: Point[]; deleteId: LinesetId }
/**
* Decide how a freshly drawn `start`→`end` segment folds into existing
* lineset runs that share an endpoint coordinate. Pure: returns a plan, the
* caller mutates the scene. Coords are level-local, so `existing` must be
* pre-filtered to the segment's level.
*/
export function planLinesetConnect(
existing: LinesetNode[],
start: Point,
end: Point,
): LinesetConnectPlan {
const atStart = findConnection(existing, start)
const atEnd = findConnection(existing, end)
// Both ends meet distinct runs → weld the three into one path.
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return {
kind: 'bridge',
id: atStart.line.id,
path: [...left, ...right],
deleteId: atEnd.line.id,
}
}
if (atStart) {
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
}
if (atEnd) {
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
}
return { kind: 'create', path: [start, end] }
}
+10 -4
View File
@@ -46,8 +46,12 @@ function buildRun(
*
* One line per node — what the ghost previews is exactly what commits. To run
* the suction line beside the liquid line, draw them as two separate linesets
* rather than rendering both together off one path. Joint spheres cap interior
* corners so turns read as continuous pipe.
* rather than rendering both together off one path.
*
* Each line is a standalone two-point node (no fitting system, unlike ducts),
* so a sphere caps BOTH endpoints. On a free end it just rounds the cap; where
* two segments share a coordinate the coincident spheres fill the miter gap, so
* the turn reads as continuous pipe.
*
* Children are level-local meters; `<ParametricNodeRenderer>` owns the
* node transform (identity today — the path is absolute within the level).
@@ -87,8 +91,10 @@ export function buildLinesetGeometry(node: LinesetNode): Group {
}
}
// Joint caps at interior corners so turns read as continuous pipe.
for (let i = 1; i < points.length - 1; i++) {
// Spherical caps at every point. Interior corners read as continuous pipe;
// endpoint caps round the open ends and, where two separate segments share a
// coordinate, the coincident spheres fill the miter so the turn looks welded.
for (let i = 0; i < points.length; i++) {
const joint = new Mesh(new SphereGeometry(copperR, RADIAL_SEGMENTS, 10), copperMat)
joint.name = `lineset-copper-joint-${i}`
joint.position.copy(points[i] as Vector3)
-1
View File
@@ -1,4 +1,3 @@
export { type LinesetConnectPlan, planLinesetConnect } from './connect'
export { linesetDefinition } from './definition'
export { buildLinesetGeometry } from './geometry'
export { LinesetNode } from './schema'
+24 -2
View File
@@ -27,6 +27,7 @@ import {
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
type Vec3 = [number, number, number]
@@ -137,6 +138,12 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
if (existedAtStart) setMeshHidden(true)
// Carry connected fittings (+ their other runs) as the whole run slides.
// Snapshot once at drag start; only existing runs are mated to anything.
const connectivity: RunMoveConnectivity | null = existedAtStart
? startRunMoveConnectivity(node)
: null
const setPreview = (path: Vec3[]) => {
previewPathRef.current = path
setPreviewPath(path)
@@ -176,7 +183,9 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
prevSnapRef.current = cur
hasMovedRef.current = true
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)
setPreview(nextPath)
connectivity?.preview({ path: nextPath })
}
const commit = (event: GridEvent) => {
@@ -204,10 +213,21 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
// Fold connected-fitting / sibling-run follow-updates into the SAME
// batch as the moved run so the whole joint is one undo step.
const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? []
useScene
.getState()
.updateNodes([
{ id: nodeId, data: { path: finalPath } as Partial<AnyNode> },
...followUpdates,
])
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
// Followers are committed to the store — drop their live overrides so
// renderers read the canonical path/position.
connectivity?.clear()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
@@ -219,6 +239,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
const onCancel = () => {
connectivity?.clear()
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -238,6 +259,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
connectivity?.clear()
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
+2 -279
View File
@@ -1,282 +1,5 @@
'use client'
import {
type AnyNodeId,
type LinesetNode,
pauseSceneHistory,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
import { createRefrigerantLineSelectionAffordance } from '../shared/refrigerant-line-selection'
const HANDLE_RADIUS = 0.08
const PORT_SNAP_RADIUS_M = 0.4
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
type Point = [number, number, number]
/**
* Selection-time editing for committed lineset runs: one draggable handle
* per path point. Mirrors the duct-segment path-handle system, but dragged
* run endpoints snap onto refrigerant ports only.
*
* Handles are PORTALED into the lineset's registered scene group so they
* share its exact frame. Drag raycasts run in world space and convert hits
* back into the group's local frame before writing the path.
*/
const LinesetSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const lineset = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'lineset' ? (node as LinesetNode) : null
})
const linesetId = lineset?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!linesetId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(linesetId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [linesetId])
if (!lineset || !target) return null
return createPortal(<LinesetPointHandles lineset={lineset} target={target} />, target, undefined)
}
const LinesetPointHandles = ({ lineset, target }: { lineset: LinesetNode; target: Object3D }) => {
const { camera, gl } = useThree()
const unit = useViewer((s) => s.unit)
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const dragRef = useRef<{
index: number
initialPath: Point[]
current: Point
cleanup: () => void
} | null>(null)
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
const projectOntoAxis = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
axisWorld: Vector3,
): number | null => {
const ray = makeRay(clientX, clientY)
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
const b = ray.direction.dot(axisWorld)
const denom = 1 - b * b
if (Math.abs(denom) < 1e-6) return null
const d0 = ray.direction.dot(w0)
const e0 = axisWorld.dot(w0)
return (e0 - b * d0) / denom
}
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
const toLocal = (world: Vector3): Point => {
const local = target.worldToLocal(world.clone())
return [local.x, local.y, local.z]
}
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPath = lineset.path.map((p) => [...p] as Point)
const startPoint = initialPath[index]!
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
document.body.style.cursor = 'grabbing'
setDraggingIndex(index)
const isEndpoint = index === 0 || index === initialPath.length - 1
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
const axisLocal = new Vector3(
startPoint[0] - neighbor[0],
startPoint[1] - neighbor[1],
startPoint[2] - neighbor[2],
)
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
axisLocal.normalize()
const anchorWorldStart = toWorld(startPoint)
const axisWorld = toWorld([
startPoint[0] + axisLocal.x,
startPoint[1] + axisLocal.y,
startPoint[2] + axisLocal.z,
])
.sub(anchorWorldStart)
.normalize()
const onMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag) return
const current = drag.current
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
let next: Point | null = null
if (event.altKey) {
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
const hit = intersect(event.clientX, event.clientY, plane)
if (hit) {
const local = toLocal(hit)
next = [snap(local[0], step), current[1], snap(local[2], step)]
if (isEndpoint) {
const port = findNearestPortXZ(
[local[0], current[1], local[2]],
collectScenePorts({ excludeNodeId: lineset.id, systems: REFRIGERANT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) next = [port.position[0], port.position[1], port.position[2]]
}
}
} else {
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
if (t !== null) {
const dist = snap(t, step)
next = [
startPoint[0] + axisLocal.x * dist,
Math.max(0, startPoint[1] + axisLocal.y * dist),
startPoint[2] + axisLocal.z * dist,
]
}
}
if (!next) return
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
drag.current = next
const path = lineset.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
useScene.getState().updateNode(lineset.id, { path })
}
const onUp = () => {
const drag = dragRef.current
if (!drag) return
drag.cleanup()
dragRef.current = null
setDraggingIndex(null)
const finalPath = drag.initialPath.map((p, i) =>
i === drag.index ? drag.current : p,
) as Point[]
useScene.getState().updateNode(lineset.id, { path: drag.initialPath })
resumeSceneHistory(useScene)
const moved = finalPath[drag.index]!.some(
(v, axis) => v !== drag.initialPath[drag.index]![axis],
)
if (moved) useScene.getState().updateNode(lineset.id, { path: finalPath })
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
document.body.style.cursor = ''
}
dragRef.current = { index, initialPath, current: startPoint, cleanup }
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
return (
<group>
{lineset.path.map((p, i) => {
const active = draggingIndex === i
const hovered = hoverIndex === i
return (
<mesh
key={`lineset-handle-${i}`}
layers={EDITOR_LAYER}
onPointerDown={onHandleDown(i)}
onPointerEnter={(e) => {
e.stopPropagation()
setHoverIndex(i)
if (draggingIndex === null) document.body.style.cursor = 'grab'
}}
onPointerLeave={() => {
setHoverIndex((prev) => (prev === i ? null : prev))
if (draggingIndex === null) document.body.style.cursor = ''
}}
position={p as Point}
>
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
<meshBasicMaterial
color={active || hovered ? '#a5b4fc' : '#818cf8'}
depthTest={false}
opacity={active ? 1 : 0.85}
transparent
/>
</mesh>
)
})}
{draggingIndex !== null &&
lineset.path[draggingIndex] &&
(() => {
const point = lineset.path[draggingIndex]!
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
const axes = ['x', 'y', 'z'] as const
const primary = axes.reduce((best, axis, i) =>
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
)
return (
<Html
center
position={[point[0], point[1] + 0.35, point[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill
parts={axes.map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: deltas[i]!,
signed: true,
}))}
primary={primary}
unit={unit}
/>
</Html>
)
})()}
</group>
)
}
export default LinesetSelectionAffordance
export default createRefrigerantLineSelectionAffordance('lineset')
+16 -30
View File
@@ -1,6 +1,6 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core'
import { emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core'
import {
CursorSphere,
DimensionPill,
@@ -16,18 +16,18 @@ import { type Group, Vector3 } from 'three'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
import { planLinesetConnect } from './connect'
import { linesetDefinition } from './definition'
/**
* One-segment-at-a-time placement tool for refrigerant linesets — the
* refrigerant-loop sibling of the duct-segment tool.
* Continuous placement tool for refrigerant linesets — the refrigerant-loop
* sibling of the duct-segment tool.
*
* Mouse-driven model:
* - **First click** anchors the run start. Within range of a refrigerant
* service port (a condenser / coil valve, or another lineset's end) it
* snaps onto the port so a run mates flush.
* - **Second click** commits a two-point lineset and re-arms the tool.
* - **Second click** commits a two-point lineset and keeps its far end
* anchored, so the next click continues the run like wall / duct drafting.
* - The in-flight end is angle-locked to the nearest 45° step in XZ from
* the start; Y stays at the start's height. Hold **Shift** to release.
* - Hold **Alt** → vertical mode. XZ locks to the start; vertical mouse
@@ -103,32 +103,18 @@ const LinesetTool = () => {
Math.abs(start[2] - end[2]) < 1e-4
if (sameSpot) return
// Fold into any existing run that shares this segment's endpoint, so
// two runs meeting at a coordinate become one mitered path instead of
// overlapping nodes. Only same-level runs are candidates — lineset
// paths are level-local.
const scene = useScene.getState()
const existing = Object.values(scene.nodes).filter(
(n): n is LinesetNode =>
n?.type === 'lineset' && (n.parentId as AnyNodeId | null) === activeLevelId,
)
const plan = planLinesetConnect(existing, start, end)
if (plan.kind === 'create') {
const lineset = LinesetNode.parse({
...linesetDefinition.defaults(),
name: 'Lineset',
path: plan.path,
})
scene.createNode(lineset, activeLevelId)
} else if (plan.kind === 'extend') {
scene.updateNode(plan.id, { path: plan.path })
} else {
scene.updateNode(plan.id, { path: plan.path })
scene.deleteNode(plan.deleteId)
}
// Each drawn segment is its own standalone two-point lineset node — the
// refrigerant-loop sibling of duct-segment. Independent nodes mean each
// segment selects and deletes on its own, rather than folding into one
// mitered polyline run.
const lineset = LinesetNode.parse({
...linesetDefinition.defaults(),
name: 'Lineset',
path: [start, end],
})
useScene.getState().createNode(lineset, activeLevelId)
triggerSFX('sfx:item-place')
setDraftPoints([])
setDraftPoints([end])
setSnapTarget(null)
altAnchorRef.current = null
setAltActive(false)
-98
View File
@@ -1,98 +0,0 @@
import type { LiquidLineNode } from './schema'
type Point = [number, number, number]
type LiquidLineId = LiquidLineNode['id']
/** Coincidence tolerance (meters) for folding endpoints into one run. The
* draw tool snaps onto an existing run's endpoint exactly, so this only
* needs to absorb float drift, not user aim. */
const COINCIDENT_EPS_M = 1e-3
function samePoint(a: Point, b: Point): boolean {
return (
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
)
}
/** Which terminal of `line` coincides with `p`, if either. */
function matchEnd(line: LiquidLineNode, p: Point): 'start' | 'end' | null {
const path = line.path as Point[]
if (samePoint(path[0]!, p)) return 'start'
if (samePoint(path[path.length - 1]!, p)) return 'end'
return null
}
/** First liquid line whose start or end coincides with `p`. */
function findConnection(
existing: LiquidLineNode[],
p: Point,
): { line: LiquidLineNode; side: 'start' | 'end' } | null {
for (const line of existing) {
if (line.path.length < 2) continue
const side = matchEnd(line, p)
if (side) return { line, side }
}
return null
}
/** Path re-ordered so the connecting terminal is its LAST point. */
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'end' ? path : [...path].reverse()
}
/** Path re-ordered so the connecting terminal is its FIRST point. */
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
return side === 'start' ? path : [...path].reverse()
}
/**
* Outcome of committing a new `start`→`end` segment against the existing
* liquid-line runs on the same level:
* - `create` — no shared endpoint; place a fresh standalone run.
* - `extend` — one end lands on run `id`; grow that run's path so the old
* terminal becomes an interior point (the geometry miters it).
* - `bridge` — both ends land on two *different* runs; weld them plus the
* new segment into one path on `id` and delete the absorbed `deleteId`.
*/
export type LiquidLineConnectPlan =
| { kind: 'create'; path: Point[] }
| { kind: 'extend'; id: LiquidLineId; path: Point[] }
| { kind: 'bridge'; id: LiquidLineId; path: Point[]; deleteId: LiquidLineId }
/**
* Decide how a freshly drawn `start`→`end` segment folds into existing
* liquid-line runs that share an endpoint coordinate. Pure: returns a plan,
* the caller mutates the scene. Coords are level-local, so `existing` must be
* pre-filtered to the segment's level.
*/
export function planLiquidLineConnect(
existing: LiquidLineNode[],
start: Point,
end: Point,
): LiquidLineConnectPlan {
const atStart = findConnection(existing, start)
const atEnd = findConnection(existing, end)
// Both ends meet distinct runs → weld the three into one path.
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return {
kind: 'bridge',
id: atStart.line.id,
path: [...left, ...right],
deleteId: atEnd.line.id,
}
}
if (atStart) {
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
}
if (atEnd) {
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
}
return { kind: 'create', path: [start, end] }
}
+10 -3
View File
@@ -31,8 +31,12 @@ function buildRun(
/**
* Pure geometry builder for a standalone liquid line: a single thin bare-copper
* cylinder following the node path centerline, with joint spheres capping
* interior corners so turns read as continuous pipe.
* cylinder following the node path centerline.
*
* Each line is a standalone two-point node (no fitting system), so a sphere caps
* BOTH endpoints. On a free end it rounds the cap; where two segments share a
* coordinate the coincident spheres fill the miter gap, so the turn reads as
* continuous pipe.
*
* Children are level-local meters; `<ParametricNodeRenderer>` owns the node
* transform (identity today — the path is absolute within the level).
@@ -55,7 +59,10 @@ export function buildLiquidLineGeometry(node: LiquidLineNode): Group {
if (run) group.add(run)
}
for (let i = 1; i < points.length - 1; i++) {
// Spherical caps at every point: interior corners read as continuous pipe,
// and endpoint caps round the open ends so two separate segments sharing a
// coordinate fill the miter and look welded.
for (let i = 0; i < points.length; i++) {
const joint = new Mesh(new SphereGeometry(radius, RADIAL_SEGMENTS, 10), copperMat)
joint.name = `liquid-line-joint-${i}`
joint.position.copy(points[i] as Vector3)
-1
View File
@@ -1,4 +1,3 @@
export { type LiquidLineConnectPlan, planLiquidLineConnect } from './connect'
export { liquidLineDefinition } from './definition'
export { buildLiquidLineGeometry } from './geometry'
export { useLiquidLineToolOptions } from './options'
+2 -279
View File
@@ -1,282 +1,5 @@
'use client'
import {
type AnyNodeId,
type LiquidLineNode,
pauseSceneHistory,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
import { createRefrigerantLineSelectionAffordance } from '../shared/refrigerant-line-selection'
const HANDLE_RADIUS = 0.07
const PORT_SNAP_RADIUS_M = 0.4
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
type Point = [number, number, number]
/**
* Selection-time editing for committed liquid-line runs: one draggable handle
* per path point. Mirrors the lineset path-handle system; dragged run
* endpoints snap onto refrigerant ports only.
*
* Handles are PORTALED into the line's registered scene group so they share
* its exact frame. Drag raycasts run in world space and convert hits back into
* the group's local frame before writing the path.
*/
const LiquidLineSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const line = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'liquid-line' ? (node as LiquidLineNode) : null
})
const lineId = line?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!lineId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(lineId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [lineId])
if (!line || !target) return null
return createPortal(<LiquidLinePointHandles line={line} target={target} />, target, undefined)
}
const LiquidLinePointHandles = ({ line, target }: { line: LiquidLineNode; target: Object3D }) => {
const { camera, gl } = useThree()
const unit = useViewer((s) => s.unit)
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const dragRef = useRef<{
index: number
initialPath: Point[]
current: Point
cleanup: () => void
} | null>(null)
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
const projectOntoAxis = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
axisWorld: Vector3,
): number | null => {
const ray = makeRay(clientX, clientY)
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
const b = ray.direction.dot(axisWorld)
const denom = 1 - b * b
if (Math.abs(denom) < 1e-6) return null
const d0 = ray.direction.dot(w0)
const e0 = axisWorld.dot(w0)
return (e0 - b * d0) / denom
}
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
const toLocal = (world: Vector3): Point => {
const local = target.worldToLocal(world.clone())
return [local.x, local.y, local.z]
}
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPath = line.path.map((p) => [...p] as Point)
const startPoint = initialPath[index]!
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
document.body.style.cursor = 'grabbing'
setDraggingIndex(index)
const isEndpoint = index === 0 || index === initialPath.length - 1
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
const axisLocal = new Vector3(
startPoint[0] - neighbor[0],
startPoint[1] - neighbor[1],
startPoint[2] - neighbor[2],
)
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
axisLocal.normalize()
const anchorWorldStart = toWorld(startPoint)
const axisWorld = toWorld([
startPoint[0] + axisLocal.x,
startPoint[1] + axisLocal.y,
startPoint[2] + axisLocal.z,
])
.sub(anchorWorldStart)
.normalize()
const onMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag) return
const current = drag.current
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
let next: Point | null = null
if (event.altKey) {
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
const hit = intersect(event.clientX, event.clientY, plane)
if (hit) {
const local = toLocal(hit)
next = [snap(local[0], step), current[1], snap(local[2], step)]
if (isEndpoint) {
const port = findNearestPortXZ(
[local[0], current[1], local[2]],
collectScenePorts({ excludeNodeId: line.id, systems: REFRIGERANT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) next = [port.position[0], port.position[1], port.position[2]]
}
}
} else {
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
if (t !== null) {
const dist = snap(t, step)
next = [
startPoint[0] + axisLocal.x * dist,
Math.max(0, startPoint[1] + axisLocal.y * dist),
startPoint[2] + axisLocal.z * dist,
]
}
}
if (!next) return
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
drag.current = next
const path = line.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
useScene.getState().updateNode(line.id, { path })
}
const onUp = () => {
const drag = dragRef.current
if (!drag) return
drag.cleanup()
dragRef.current = null
setDraggingIndex(null)
const finalPath = drag.initialPath.map((p, i) =>
i === drag.index ? drag.current : p,
) as Point[]
useScene.getState().updateNode(line.id, { path: drag.initialPath })
resumeSceneHistory(useScene)
const moved = finalPath[drag.index]!.some(
(v, axis) => v !== drag.initialPath[drag.index]![axis],
)
if (moved) useScene.getState().updateNode(line.id, { path: finalPath })
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
document.body.style.cursor = ''
}
dragRef.current = { index, initialPath, current: startPoint, cleanup }
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
return (
<group>
{line.path.map((p, i) => {
const active = draggingIndex === i
const hovered = hoverIndex === i
return (
<mesh
key={`liquid-line-handle-${i}`}
layers={EDITOR_LAYER}
onPointerDown={onHandleDown(i)}
onPointerEnter={(e) => {
e.stopPropagation()
setHoverIndex(i)
if (draggingIndex === null) document.body.style.cursor = 'grab'
}}
onPointerLeave={() => {
setHoverIndex((prev) => (prev === i ? null : prev))
if (draggingIndex === null) document.body.style.cursor = ''
}}
position={p as Point}
>
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
<meshBasicMaterial
color={active || hovered ? '#a5b4fc' : '#818cf8'}
depthTest={false}
opacity={active ? 1 : 0.85}
transparent
/>
</mesh>
)
})}
{draggingIndex !== null &&
line.path[draggingIndex] &&
(() => {
const point = line.path[draggingIndex]!
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
const axes = ['x', 'y', 'z'] as const
const primary = axes.reduce((best, axis, i) =>
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
)
return (
<Html
center
position={[point[0], point[1] + 0.35, point[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill
parts={axes.map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: deltas[i]!,
signed: true,
}))}
primary={primary}
unit={unit}
/>
</Html>
)
})()}
</group>
)
}
export default LiquidLineSelectionAffordance
export default createRefrigerantLineSelectionAffordance('liquid-line')
+144 -60
View File
@@ -24,17 +24,17 @@ import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { offsetPathHorizontal } from '../shared/path-offset'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
import { planLiquidLineConnect } from './connect'
import { liquidLineDefinition } from './definition'
import { useLiquidLineToolOptions } from './options'
/**
* One-segment-at-a-time placement tool for standalone liquid lines — the same
* draw model as the lineset tool (the line it used to be a rail of):
* Continuous placement tool for standalone liquid lines — the same draw model
* as the lineset tool (the line it used to be a rail of):
* - **First click** anchors the run start; within range of a refrigerant
* service port it snaps onto it so a run mates flush.
* - **Second click** commits a two-point line and re-arms; the in-flight end
* is angle-locked to 45° (Shift frees it), Alt drags it vertical.
* - **Second click** commits a two-point line and keeps its far end anchored;
* the in-flight end is angle-locked to 45° (Shift frees it), Alt drags it
* vertical.
*
* **Follow mode** (toggled by the MEP panel's Follow button or the `F` key):
* instead of free-drawing, hover an existing lineset and click — a liquid line
@@ -117,46 +117,138 @@ function traceOffsetMeters(lineset: LinesetNode): number {
return suctionR + jacket + FOLLOW_GAP_M + GHOST_RADIUS_M
}
type FollowTarget = { lineset: LinesetNode; sign: number }
/** Coincidence tolerance (meters) for treating two endpoints as the same joint
* when chaining linesets — the draw tool snaps endpoints exactly, so this only
* needs to absorb float drift. */
const JOINT_EPS_M = 1e-3
function samePt(a: Vec3, b: Vec3): boolean {
return (
Math.abs(a[0] - b[0]) < JOINT_EPS_M &&
Math.abs(a[1] - b[1]) < JOINT_EPS_M &&
Math.abs(a[2] - b[2]) < JOINT_EPS_M
)
}
/** Quantized coordinate key so endpoints sharing a joint hash together. */
function jointKey(p: Vec3): string {
return `${Math.round(p[0] / JOINT_EPS_M)},${Math.round(p[1] / JOINT_EPS_M)},${Math.round(
p[2] / JOINT_EPS_M,
)}`
}
/**
* Nearest lineset whose path passes within `FOLLOW_PICK_RADIUS_M` of the
* cursor, plus which side of it the cursor is on (`sign`, matching
* `offsetPathHorizontal`'s side convention). Restricted to the active level.
* Whole-run trace target: the assembled centerline of every lineset chained to
* the hovered one (each lineset is its own two-point node now), which side the
* cursor is on (`sign`, matching `offsetPathHorizontal`'s convention), and a
* representative lineset for the offset distance.
*/
type FollowTarget = { path: Vec3[]; sign: number; lineset: LinesetNode }
/**
* Walk the chain of linesets joined end-to-end at shared joint coordinates,
* starting from `start`, into one continuous centerline. Follows a joint only
* when it has a single unvisited continuation (degree-2) — a branch / junction
* (degree ≥ 3) ends the run so the trace stays a simple path.
*/
function assembleRun(start: LinesetNode, linesets: LinesetNode[]): Vec3[] {
const byJoint = new Map<string, LinesetNode[]>()
for (const ls of linesets) {
const a = ls.path[0] as Vec3
const b = ls.path[ls.path.length - 1] as Vec3
for (const key of [jointKey(a), jointKey(b)]) {
const arr = byJoint.get(key)
if (arr) arr.push(ls)
else byJoint.set(key, [ls])
}
}
const visited = new Set<string>([start.id])
let points: Vec3[] = (start.path as Vec3[]).map((p) => [...p] as Vec3)
// Grow the run one lineset at a time off the chosen terminal, until a joint
// has no unique continuation. `atEnd` extends after the last point; otherwise
// before the first.
const grow = (atEnd: boolean) => {
for (;;) {
const terminal = atEnd ? points[points.length - 1]! : points[0]!
const next = (byJoint.get(jointKey(terminal)) ?? []).filter((ls) => !visited.has(ls.id))
if (next.length !== 1) break
const node = next[0]!
visited.add(node.id)
const np = (node.path as Vec3[]).map((p) => [...p] as Vec3)
if (atEnd) {
if (samePt(np[np.length - 1]!, terminal)) np.reverse() // np must start at terminal
points = [...points, ...np.slice(1)]
} else {
if (samePt(np[0]!, terminal)) np.reverse() // np must end at terminal
points = [...np.slice(0, np.length - 1), ...points]
}
}
}
grow(true)
grow(false)
return points
}
/** Cursor side relative to the assembled run's nearest segment, as the offset
* sign for `offsetPathHorizontal`. */
function sideSign(path: Vec3[], point: Vec3): number {
let bestD = Number.POSITIVE_INFINITY
let bi = 0
for (let i = 0; i < path.length - 1; i++) {
const d = distToSegmentXZ(point, path[i]!, path[i + 1]!)
if (d < bestD) {
bestD = d
bi = i
}
}
const a = path[bi]!
const b = path[bi + 1]!
// Side vector = normalize(heading_xz) × UP = (-hz, 0, hx).
const hx = b[0] - a[0]
const hz = b[2] - a[2]
const hlen = Math.hypot(hx, hz)
const sx = hlen > 1e-9 ? -hz / hlen : 0
const sz = hlen > 1e-9 ? hx / hlen : 0
return (point[0] - a[0]) * sx + (point[2] - a[2]) * sz >= 0 ? 1 : -1
}
/**
* Nearest lineset within `FOLLOW_PICK_RADIUS_M` of the cursor, expanded into
* the whole connected run it belongs to. Restricted to the active level.
*/
function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null {
const scene = useScene.getState()
let best: FollowTarget | null = null
let bestD = FOLLOW_PICK_RADIUS_M
const linesets: LinesetNode[] = []
for (const n of Object.values(scene.nodes)) {
if (!n || n.type !== 'lineset') continue
if ((n.parentId as AnyNodeId | null) !== levelId) continue
const ls = n as LinesetNode
if (ls.path.length < 2) continue
if (ls.path.length >= 2) linesets.push(ls)
}
let hovered: LinesetNode | null = null
let bestD = FOLLOW_PICK_RADIUS_M
for (const ls of linesets) {
for (let i = 0; i < ls.path.length - 1; i++) {
const a = ls.path[i] as Vec3
const b = ls.path[i + 1] as Vec3
const d = distToSegmentXZ(point, a, b)
const d = distToSegmentXZ(point, ls.path[i] as Vec3, ls.path[i + 1] as Vec3)
if (d >= bestD) continue
bestD = d
// Side vector = normalize(heading_xz) × UP = (-hz, 0, hx); sign is which
// side of the segment the cursor sits on.
const hx = b[0] - a[0]
const hz = b[2] - a[2]
const hlen = Math.hypot(hx, hz)
const sx = hlen > 1e-9 ? -hz / hlen : 0
const sz = hlen > 1e-9 ? hx / hlen : 0
const dot = (point[0] - a[0]) * sx + (point[2] - a[2]) * sz
best = { lineset: ls, sign: dot >= 0 ? 1 : -1 }
hovered = ls
}
}
return best
if (!hovered) return null
const path = assembleRun(hovered, linesets)
if (path.length < 2) return null
return { path, sign: sideSign(path, point), lineset: hovered }
}
/** The offset path a follow-target would trace, or null if degenerate. */
/** The offset centerline a follow-target would trace, or null if degenerate. */
function tracePath(target: FollowTarget): Vec3[] | null {
const offset = target.sign * traceOffsetMeters(target.lineset)
const traced = offsetPathHorizontal(target.lineset.path as Vec3[], offset)
const traced = offsetPathHorizontal(target.path, offset)
return traced.length >= 2 ? traced : null
}
@@ -199,47 +291,39 @@ const LiquidLineTool = () => {
Math.abs(start[2] - end[2]) < 1e-4
if (sameSpot) return
// Fold into any existing run that shares this segment's endpoint, so two
// runs meeting at a coordinate become one mitered path instead of
// overlapping nodes. Only same-level runs are candidates.
const scene = useScene.getState()
const existing = Object.values(scene.nodes).filter(
(n): n is LiquidLineNode =>
n?.type === 'liquid-line' && (n.parentId as AnyNodeId | null) === activeLevelId,
)
const plan = planLiquidLineConnect(existing, start, end)
if (plan.kind === 'create') {
const line = LiquidLineNode.parse({
...liquidLineDefinition.defaults(),
name: 'Liquid Line',
path: plan.path,
})
scene.createNode(line, activeLevelId)
} else if (plan.kind === 'extend') {
scene.updateNode(plan.id, { path: plan.path })
} else {
scene.updateNode(plan.id, { path: plan.path })
scene.deleteNode(plan.deleteId)
}
// Each drawn segment is its own standalone two-point liquid-line node.
// Independent nodes mean each segment selects and deletes on its own,
// rather than folding into one mitered polyline run.
const line = LiquidLineNode.parse({
...liquidLineDefinition.defaults(),
name: 'Liquid Line',
path: [start, end],
})
useScene.getState().createNode(line, activeLevelId)
triggerSFX('sfx:item-place')
setDraftPoints([])
setDraftPoints([end])
setSnapTarget(null)
altAnchorRef.current = null
setAltActive(false)
}
// Lay a liquid line beside a lineset, tracing its whole path at the offset.
// Lay liquid lines beside the whole connected lineset run, tracing its
// assembled centerline at the offset. One two-point node per segment so the
// result stays per-segment selectable, matching free-drawn liquid lines.
const commitTrace = (target: FollowTarget) => {
const traced = tracePath(target)
if (!traced) return
const scene = useScene.getState()
const line = LiquidLineNode.parse({
...liquidLineDefinition.defaults(),
name: 'Liquid Line',
path: traced,
})
scene.createNode(line, activeLevelId)
const defaults = liquidLineDefinition.defaults()
const create = []
for (let i = 0; i < traced.length - 1; i++) {
const a = traced[i]!
const b = traced[i + 1]!
if (samePt(a, b)) continue
const node = LiquidLineNode.parse({ ...defaults, name: 'Liquid Line', path: [a, b] })
create.push({ node, parentId: activeLevelId })
}
if (create.length === 0) return
useScene.getState().applyNodeChanges({ create })
triggerSFX('sfx:item-place')
setTraceGhost(null)
followTargetRef.current = null
@@ -471,7 +555,7 @@ const LiquidLineTool = () => {
}}
>
{followTargetRef.current
? 'Click to trace this lineset'
? 'Click to trace this lineset run'
: 'Follow: hover a lineset'}
</div>
</Html>
@@ -78,6 +78,7 @@ export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
// editor's SelectionAffordanceManager rather than `def.system`.
affordanceTools: {
selection: () => import('./selection'),
move: () => import('./move-tool'),
},
tool: () => import('./tool'),
@@ -0,0 +1,361 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
emitter,
type GridEvent,
PipeFittingNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react'
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
import { buildPipeFittingGeometry } from './geometry'
type Vec3 = [number, number, number]
const GHOST_COLOR = '#818cf8'
const GHOST_OPACITY = 0.5
/** Screen pixels → meters for the Ctrl-vertical (riser) drag — matches the
* pipe draw tool's Alt-vertical feel. 100 px ≈ 1 m. */
const VERTICAL_PIXELS_PER_METER = 100
const VERTICAL_Y_MIN_M = -3
const VERTICAL_Y_MAX_M = 10
/** Snap a coordinate to the editor's live grid step. */
function snapToGridStep(value: number): number {
const step = useEditor.getState().gridSnapStep
if (step <= 0) return value
return Math.round(value / step) * step
}
/** World-space size + centre offset of `box` after the fitting's euler
* rotation — the footprint box that wraps the oriented geometry. */
function rotatedBounds(box: Box3, rotation: Vec3): { size: Vec3; offset: Vec3 } {
const euler = new Euler(rotation[0], rotation[1], rotation[2])
const min = box.min
const max = box.max
const corners: Vec3[] = [
[min.x, min.y, min.z],
[max.x, min.y, min.z],
[min.x, max.y, min.z],
[min.x, min.y, max.z],
[max.x, max.y, min.z],
[max.x, min.y, max.z],
[min.x, max.y, max.z],
[max.x, max.y, max.z],
]
const lo: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]
const hi: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]
const v = new Vector3()
for (const c of corners) {
v.set(c[0], c[1], c[2]).applyEuler(euler)
lo[0] = Math.min(lo[0], v.x)
lo[1] = Math.min(lo[1], v.y)
lo[2] = Math.min(lo[2], v.z)
hi[0] = Math.max(hi[0], v.x)
hi[1] = Math.max(hi[1], v.y)
hi[2] = Math.max(hi[2], v.z)
}
return {
size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]],
offset: [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2],
}
}
/**
* Ghost-preview duplicate / move tool for DWV pipe fittings (elbow / wye /
* sanitary tee) — the plumbing sibling of the duct-fitting move tool.
*
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
* inserted into the scene until the commit click. A translucent copy of the
* fitting (built from its real geometry, at its own `rotation`, so an elbow
* / riser stays properly aligned) rides the cursor inside a footprint
* bounding box — the same affordance other items get — and Figma-style
* alignment guides snap the box edges to nearby geometry. The commit click
* calls `createNode`; Esc discards.
*
* **Move** (existing fitting): the real node is hidden while the ghost + box
* track the cursor; commit writes the new `position` and reveals it.
*
* Modifiers (mirroring the duct-fitting move):
* - **Alt** detaches: the connected-pipe follow drops so the fitting moves
* on its own, leaving every mated run where it sits.
* - **Ctrl / Cmd** switches to vertical movement (stack / riser editing): XZ
* holds and the cursor's screen-Y drives the riser height.
* - **Shift** bypasses grid snapping / alignment.
*
* Wired via `def.affordanceTools.move`.
*/
export const MovePipeFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const fitting = node as PipeFittingNode
const originalPosition = (fitting.position ?? [0, 0, 0]) as Vec3
const rotation = (fitting.rotation ?? [0, 0, 0]) as Vec3
const isNew =
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
(node.metadata as Record<string, unknown>).isNew === true
const [cursorPos, setCursorPos] = useState<Vec3>(originalPosition)
// Translucent stand-in built from the fitting's real geometry. Rotation is
// a geometry input (it decides the elbow's profile roles), so the ghost
// matches what lands. Rebuilt only if the source changes.
const ghost = useMemo(() => {
const group = buildPipeFittingGeometry(fitting)
group.traverse((obj) => {
const mesh = obj as Mesh
if ((mesh as { isMesh?: boolean }).isMesh) {
mesh.material = new MeshBasicMaterial({
color: GHOST_COLOR,
transparent: true,
opacity: GHOST_OPACITY,
depthTest: false,
})
mesh.renderOrder = 999
}
obj.layers.set(EDITOR_LAYER)
})
return group
}, [fitting])
// Footprint box that wraps the oriented geometry (size + centre offset),
// measured once from the ghost.
const bounds = useMemo(() => {
const box = new Box3().setFromObject(ghost)
if (box.isEmpty()) return { size: [0.3, 0.3, 0.3] as Vec3, offset: [0, 0, 0] as Vec3 }
return rotatedBounds(box, rotation)
}, [ghost, rotation])
useEffect(() => {
return () => {
ghost.traverse((obj) => {
const mesh = obj as Mesh
if ((mesh as { isMesh?: boolean }).isMesh) {
mesh.geometry?.dispose?.()
const mat = mesh.material as Material | Material[]
if (Array.isArray(mat)) for (const m of mat) m.dispose?.()
else mat?.dispose?.()
}
})
}
}, [ghost])
useEffect(() => {
const nodeId = node.id as AnyNodeId
const [hx, , hz] = [bounds.size[0] / 2, 0, bounds.size[2] / 2]
const [ox, , oz] = bounds.offset
useScene.temporal.getState().pause()
let committed = false
let hasMoved = false
const activatedAt = Date.now()
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
useScene.getState().nodes,
nodeId,
useViewer.getState().selection.levelId ?? node.parentId,
)
// Moving an existing fitting: hide its 3D MESH imperatively (NOT the
// store `visible` flag — the 2D floor plan skips `visible:false` nodes,
// so a store hide makes it vanish in 2D / split view). The ghost stands
// in until commit; the real mesh is restored on cancel / unmount.
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
const setMeshHidden = (hidden: boolean) => {
const obj = sceneRegistry.nodes.get(nodeId)
if (obj) obj.visible = !hidden
}
if (existedAtStart) setMeshHidden(true)
// Carry connected pipes as the fitting slides: the part of the move along
// a run's axis stretches it, the part across translates the whole run (and
// propagates to its far joint). Snapshot once at drag start; only existing
// fittings are mated to anything.
const connectivity: RunMoveConnectivity | null = existedAtStart
? startRunMoveConnectivity(node)
: null
let lastPos: Vec3 = originalPosition
// Tracks whether the last frame held Alt: the fitting is detached from its
// connected pipes for the drag, so they stay put (no follow) and the
// commit omits their updates. Mirrors the pipe endpoint's Alt-detach.
let lastDetached = false
// Anchor for the Ctrl-vertical (riser) drag: clientY + base Y captured the
// frame Ctrl is first held, so vertical mouse motion maps to Y. Cleared
// when Ctrl is released. Mirrors the draw tool's Alt-vertical anchor.
let verticalAnchor: { clientY: number; baseY: number } | null = null
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
// Alt = detach: drop the connected-pipe follow so the fitting moves on
// its own, leaving every mated run where it sits.
const detached = event.nativeEvent?.altKey === true
// Ctrl/Cmd = vertical: XZ locks to where the fitting sits and the cursor's
// screen-Y drives the riser height (connected pipes still follow).
const vertical = event.nativeEvent?.ctrlKey === true || event.nativeEvent?.metaKey === true
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
const snap = bypass ? (v: number) => v : snapToGridStep
let next: Vec3
if (vertical && typeof clientY === 'number') {
if (!verticalAnchor) verticalAnchor = { clientY, baseY: lastPos[1] }
// Screen +Y points down, so subtract to map "drag up = raise".
const dy = (verticalAnchor.clientY - clientY) / VERTICAL_PIXELS_PER_METER
const y = Math.min(
VERTICAL_Y_MAX_M,
Math.max(VERTICAL_Y_MIN_M, verticalAnchor.baseY + snap(dy)),
)
next = [lastPos[0], y, lastPos[2]]
useAlignmentGuides.getState().clear()
} else {
verticalAnchor = null
let x = snap(event.localPosition[0])
let z = snap(event.localPosition[2])
// Alignment: snap the footprint box edges onto nearby geometry and
// publish guides (Alt / Shift bypass).
if (!bypass) {
const proposed: Aabb2D = {
minX: x + ox - hx,
maxX: x + ox + hx,
minZ: z + oz - hz,
maxZ: z + oz + hz,
}
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
x += dx
z += dz
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
}
next = [x, lastPos[1], z]
}
if (next[0] !== lastPos[0] || next[1] !== lastPos[1] || next[2] !== lastPos[2]) {
triggerSFX('sfx:grid-snap')
}
lastPos = next
lastDetached = detached
hasMoved = true
setCursorPos(next)
// Detached: keep the followers at their origin (drop any live overrides
// from a prior non-detached frame). Otherwise preview the follow.
if (detached) connectivity?.clear()
else connectivity?.preview({ position: next })
}
const commit = (event: GridEvent) => {
if (committed) return
if (Date.now() - activatedAt < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
if (!hasMoved) {
event.nativeEvent?.stopPropagation?.()
return
}
committed = true
useScene.temporal.getState().resume()
let selectId = nodeId
if (isNew && !useScene.getState().nodes[nodeId]) {
const created = PipeFittingNode.parse({
...(node as Record<string, unknown>),
position: lastPos,
metadata: stripPlacementMetadataFlags(node.metadata),
visible: true,
})
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
// Fold connected-pipe / sibling-run follow-updates into the SAME batch
// as the moved fitting so the whole joint is one undo step. Detached
// (Alt on the final frame): the joint is broken, so nothing follows.
const followUpdates = lastDetached
? []
: (connectivity?.commitUpdates({ position: lastPos }) ?? [])
useScene
.getState()
.updateNodes([
{ id: nodeId, data: { position: lastPos } as Partial<AnyNode> },
...followUpdates,
])
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
// Followers are committed to the store — drop their live overrides so
// renderers read the canonical path/position.
connectivity?.clear()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [selectId] })
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
connectivity?.clear()
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
markToolCancelConsumed()
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
connectivity?.clear()
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
}
}, [bounds, isNew, node, originalPosition])
return (
<group>
<primitive object={ghost} position={cursorPos} rotation={rotation} />
<DragBoundingBox
centerY={bounds.offset[1]}
nodeId={node.id}
position={[cursorPos[0] + bounds.offset[0], cursorPos[1], cursorPos[2] + bounds.offset[2]]}
size={bounds.size}
/>
</group>
)
}
export default MovePipeFittingTool
@@ -0,0 +1,106 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeId, PipeFittingNode, PipeSegmentNode } from '@pascal-app/core'
import { pipeFittingParametrics } from './parametrics'
import { getPipeFittingPorts } from './ports'
type Point = [number, number, number]
function pipeElbow() {
return PipeFittingNode.parse({
id: 'pipe-fitting_elbow' as AnyNodeId,
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'DWV bend',
fittingType: 'elbow',
angle: 90,
diameter: 3,
diameter2: 3,
pipeMaterial: 'pvc',
system: 'waste',
position: [0, 0, 0],
rotation: [0, 0, 0],
})
}
function pipe(id: string, path: Point[]) {
return PipeSegmentNode.parse({
id: id as AnyNodeId,
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'DWV pipe',
path,
diameter: 3,
pipeMaterial: 'pvc',
system: 'waste',
})
}
function add(point: readonly number[], dir: readonly number[], length: number): Point {
return [point[0]! + dir[0]! * length, point[1]! + dir[1]! * length, point[2]! + dir[2]! * length]
}
describe('pipeFittingParametrics', () => {
test('deleting an elbow re-extends mated pipe ends back onto the junction', () => {
const fitting = pipeElbow()
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
const outlet = getPipeFittingPorts(fitting).find((p) => p.id === 'outlet')!
const inletRun = pipe('pipe-segment_inlet', [
add(inlet.position, inlet.direction, 3),
[...inlet.position] as Point,
])
const outletRun = pipe('pipe-segment_outlet', [
[...outlet.position] as Point,
add(outlet.position, outlet.direction, 3),
])
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[inletRun.id]: inletRun as AnyNode,
[outletRun.id]: outletRun as AnyNode,
}
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
const inletUpdate = updates.find((u) => u.id === inletRun.id)
const outletUpdate = updates.find((u) => u.id === outletRun.id)
expect((inletUpdate?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
expect((outletUpdate?.data as Partial<PipeSegmentNode>).path?.[0]).toEqual([
...fitting.position,
])
})
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
const fitting = pipeElbow()
const inlet = getPipeFittingPorts(fitting).find((p) => p.id === 'inlet')!
const inletRun = pipe('pipe-segment_inlet', [
add(inlet.position, inlet.direction, 3),
add(inlet.position, [0, 0, 1], 0.04),
])
const nodes: Record<AnyNodeId, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[inletRun.id]: inletRun as AnyNode,
}
const updates = pipeFittingParametrics.onDelete?.(fitting, nodes) ?? []
expect((updates[0]?.data as Partial<PipeSegmentNode>).path?.[1]).toEqual([...fitting.position])
})
test('deleting a branch fitting leaves mated pipe ends untouched', () => {
const wye = PipeFittingNode.parse({ ...pipeElbow(), fittingType: 'wye' })
const inlet = getPipeFittingPorts(wye).find((p) => p.id === 'inlet')!
const inletRun = pipe('pipe-segment_inlet', [
add(inlet.position, inlet.direction, 3),
[...inlet.position] as Point,
])
const nodes: Record<AnyNodeId, AnyNode> = {
[wye.id]: wye as AnyNode,
[inletRun.id]: inletRun as AnyNode,
}
expect(pipeFittingParametrics.onDelete?.(wye, nodes) ?? []).toEqual([])
})
})
+57 -2
View File
@@ -1,7 +1,62 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { AnyNode, AnyNodeId, ParametricDescriptor, PipeSegmentNode } from '@pascal-app/core'
import { getPipeFittingPorts } from './ports'
import type { PipeFittingNode } from './schema'
/** A pipe endpoint sitting this close to a fitting hub counts as mated. */
const MATE_TOL_M = 0.05
type Point = [number, number, number]
type PipeMate = { pipe: PipeSegmentNode; endIndex: number }
function matedPipes(
fitting: PipeFittingNode,
nodes: Record<AnyNodeId, AnyNode>,
): Map<string, PipeMate> {
const mates = new Map<string, PipeMate>()
const ports = getPipeFittingPorts(fitting)
for (const node of Object.values(nodes)) {
if (node.type !== 'pipe-segment') continue
const pipe = node as PipeSegmentNode
for (const endIndex of [0, pipe.path.length - 1]) {
const p = pipe.path[endIndex]
if (!p) continue
for (const port of ports) {
if (mates.has(port.id)) continue
const dx = p[0] - port.position[0]
const dy = p[1] - port.position[1]
const dz = p[2] - port.position[2]
if (dx * dx + dy * dy + dz * dz <= MATE_TOL_M * MATE_TOL_M) {
mates.set(port.id, { pipe, endIndex })
}
}
}
}
return mates
}
export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
// Deleting an auto-inserted DWV bend restores the corner it replaced.
// The connected pipe endpoints were pulled back onto the bend collars;
// send those endpoints back to the junction so the L-shape regains its
// original length.
onDelete: (fitting, nodes) => {
if (fitting.fittingType !== 'elbow') return []
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
for (const mate of matedPipes(fitting, nodes).values()) {
const end = mate.pipe.path[mate.endIndex]
if (!end) continue
const target = fitting.position
const dx = end[0] - target[0]
const dy = end[1] - target[1]
const dz = end[2] - target[2]
if (dx * dx + dy * dy + dz * dz < 1e-12) continue
const path = mate.pipe.path.map((p) => [...p] as Point)
path[mate.endIndex] = [...target]
updates.push({ id: mate.pipe.id, data: { path } as Partial<PipeSegmentNode> })
}
return updates
},
groups: [
{
label: 'Fitting',
@@ -16,7 +71,7 @@ export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
key: 'angle',
kind: 'number',
unit: '°',
min: 15,
min: 0,
max: 90,
step: 7.5,
visibleIf: (n) => n.fittingType === 'elbow',
+765 -19
View File
@@ -1,27 +1,259 @@
'use client'
import { type AnyNodeId, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
type Cursor,
type PipeFittingNode,
type PortConnectivity,
pauseSceneHistory,
resolveConnectivityUpdates,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
ARROW_COLOR,
EDITOR_LAYER,
swallowNextClick,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { cycleRotationAxis } from '../shared/fitting-rotation'
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import {
BufferGeometry,
Euler,
Float32BufferAttribute,
type Group,
LineSegments,
type Object3D,
OrthographicCamera,
Plane,
Quaternion,
Raycaster,
SphereGeometry,
Vector2,
Vector3,
} from 'three'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import {
AXIS_VECTORS,
cycleRotationAxis,
ROTATE_STEP_RAD,
type RotationAxis,
} from '../shared/fitting-rotation'
import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles'
import { pipeFittingLegLength } from './ports'
type Point = [number, number, number]
type FittingTransform = { position?: Point; rotation?: Point }
type PipeDimension = 'diameter' | 'diameter2'
const ARROW_GAP = 0.34
const RESIZE_HANDLE_GAP = 0.3
const RESIZE_STEP_IN = 0.25
const RESIZE_GUIDE_DASH = 0.07
const RESIZE_GUIDE_GAP = 0.045
const RESIZE_SPHERE_RADIUS = 0.065
const RESIZE_HIT_RADIUS = 0.13
const INCHES_TO_METERS = 0.0254
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function fittingExtentM(node: PipeFittingNode): number {
return Math.max(pipeFittingLegLength(node.diameter), pipeFittingLegLength(node.diameter2))
}
function fittingParameterPatch(node: PipeFittingNode): Partial<PipeFittingNode> {
return {
fittingType: node.fittingType,
angle: node.angle,
diameter: node.diameter,
diameter2: node.diameter2,
pipeMaterial: node.pipeMaterial,
system: node.system,
}
}
function preserveFittingParameters(
node: PipeFittingNode,
data: Partial<PipeFittingNode>,
): Partial<AnyNode> {
return { ...fittingParameterPatch(node), ...data } as Partial<AnyNode>
}
function dimensionPatch(
fitting: PipeFittingNode,
dimension: PipeDimension,
value: number,
): Partial<PipeFittingNode> {
if (dimension === 'diameter' && fitting.fittingType === 'elbow') {
return { diameter: value, diameter2: value }
}
return { [dimension]: value } as Partial<PipeFittingNode>
}
function closestAxisParameterToRay(
axisOrigin: Vector3,
axisDirection: Vector3,
ray: Raycaster['ray'],
) {
const originToRay = axisOrigin.clone().sub(ray.origin)
const b = axisDirection.dot(ray.direction)
const d = axisDirection.dot(originToRay)
const e = ray.direction.dot(originToRay)
const denominator = 1 - b * b
if (Math.abs(denominator) < 1e-6) return -d
const axisParameter = (b * e - d) / denominator
const rayParameter = e + b * axisParameter
return rayParameter < 0 ? -d : axisParameter
}
function DashedResizeGuide({ from, to }: { from: Point; to: Point }) {
const line = useMemo(() => {
const a = new Vector3(from[0], from[1], from[2])
const b = new Vector3(to[0], to[1], to[2])
const span = b.clone().sub(a)
const length = span.length()
const points: number[] = []
if (length > 1e-4) {
const dir = span.clone().normalize()
let t = 0
while (t < length) {
const start = a.clone().addScaledVector(dir, t)
const end = a.clone().addScaledVector(dir, Math.min(t + RESIZE_GUIDE_DASH, length))
points.push(start.x, start.y, start.z, end.x, end.y, end.z)
t += RESIZE_GUIDE_DASH + RESIZE_GUIDE_GAP
}
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(points), 3))
const material = new LineBasicNodeMaterial({
color: ARROW_COLOR,
transparent: true,
opacity: 0.8,
depthWrite: false,
})
const next = new LineSegments(geometry, material)
next.frustumCulled = false
next.layers.set(EDITOR_LAYER)
next.renderOrder = 1002
next.raycast = () => {}
return next
}, [from, to])
useEffect(
() => () => {
line.geometry.dispose()
;(line.material as LineBasicNodeMaterial).dispose()
},
[line],
)
return <primitive object={line} />
}
function ResizeSphereHandle({
cursor,
onPointerDown,
position,
}: {
cursor: Cursor
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
position: Point
}) {
const { camera } = useThree()
const [hovered, setHovered] = useState(false)
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const sphereGeometry = useMemo(() => new SphereGeometry(RESIZE_SPHERE_RADIUS, 18, 12), [])
const hitGeometry = useMemo(() => new SphereGeometry(RESIZE_HIT_RADIUS, 12, 8), [])
const sphereMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: ARROW_COLOR,
transparent: true,
opacity: 0.92,
depthTest: false,
depthWrite: false,
}),
[],
)
const hitMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: ARROW_COLOR,
transparent: true,
opacity: 0,
depthTest: false,
depthWrite: false,
}),
[],
)
useEffect(() => {
sphereMaterial.opacity = hovered ? 1 : 0.92
}, [sphereMaterial, hovered])
useEffect(
() => () => {
hitGeometry.dispose()
sphereGeometry.dispose()
sphereMaterial.dispose()
hitMaterial.dispose()
},
[hitGeometry, hitMaterial, sphereGeometry, sphereMaterial],
)
const consumePress = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
event.nativeEvent.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
swallowNextClick()
onPointerDown(event)
}
return (
<group position={position} scale={zoom}>
<mesh
geometry={hitGeometry}
material={hitMaterial}
onPointerDown={consumePress}
onPointerEnter={(event) => {
event.stopPropagation()
setHovered(true)
document.body.style.cursor = cursor
}}
onPointerLeave={(event) => {
event.stopPropagation()
setHovered(false)
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
}}
/>
<mesh geometry={sphereGeometry} material={sphereMaterial} renderOrder={1004} />
</group>
)
}
/**
* Selection-time rotation support for placed pipe fittings — mirrors
* the duct-fitting affordance, mounted by the editor's
* SelectionAffordanceManager (`def.affordanceTools.selection`). R/T
* rotation lives in `def.keyboardActions`; this contributes the piece
* that hook can't: **Alt cycles the active rotation axis** while a
* single fitting is selected. The axis lives on `useEditor.rotationAxis`,
* which the floating action menu reads to show the axis pill — so this
* component renders nothing.
*/
const PipeFittingSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const hasSelectedFitting = useScene((s) => {
if (selectedIds.length !== 1) return false
return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'pipe-fitting'
const fitting = useScene((s) => {
if (selectedIds.length !== 1) return null
const node = s.nodes[selectedIds[0] as AnyNodeId]
return node?.type === 'pipe-fitting' ? (node as PipeFittingNode) : null
})
const hasSelectedFitting = !!fitting
useEffect(() => {
if (!hasSelectedFitting) return
const onKeyDown = (e: KeyboardEvent) => {
@@ -31,13 +263,527 @@ const PipeFittingSelectionAffordance = () => {
e.preventDefault()
cycleRotationAxis()
}
// Bubble phase — when the placement tool is active its capture-phase
// handler stops propagation, so the two never double-cycle.
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [hasSelectedFitting])
return null
const fittingId = fitting?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!fittingId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(fittingId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [fittingId])
if (!fitting || !target) return null
const mount = target.parent ?? target
return createPortal(<FittingHandles fitting={fitting} />, mount, undefined)
}
const FittingHandles = ({ fitting }: { fitting: PipeFittingNode }) => {
const { camera, gl } = useThree()
const [frame, setFrame] = useState<Group | null>(null)
const [open, setOpen] = useState(false)
const [dragging, setDragging] = useState(false)
const [sideSign, setSideSign] = useState(1)
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
const sampleAxisParameter = (
clientX: number,
clientY: number,
axisOrigin: Vector3,
axisDirection: Vector3,
): number => closestAxisParameterToRay(axisOrigin, axisDirection, makeRay(clientX, clientY))
const intersectVerticalY = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
): number | null => {
if (!frame) return null
const forward = camera.getWorldDirection(new Vector3())
forward.y = 0
if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1)
forward.normalize()
const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld)
const hit = intersect(clientX, clientY, plane)
return hit ? frame.worldToLocal(hit.clone()).y : null
}
const toWorld = (p: Point): Vector3 =>
frame ? frame.localToWorld(new Vector3(p[0], p[1], p[2])) : new Vector3(p[0], p[1], p[2])
const axisToWorld = (origin: Point, axis: Vector3): Vector3 => {
const originWorld = toWorld(origin)
const tipWorld = frame
? frame.localToWorld(new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z))
: new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z)
return tipWorld.sub(originWorld).normalize()
}
const sampleAxis = (
axis: RotationAxis,
clientX: number,
clientY: number,
anchorWorld: Vector3,
): number | null => {
if (axis === 'y') return intersectVerticalY(clientX, clientY, anchorWorld)
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, anchorWorld)
const hit = intersect(clientX, clientY, plane)
if (!hit || !frame) return null
const local = frame.worldToLocal(hit.clone())
return axis === 'x' ? local.x : local.z
}
const connectivityUpdates = (
connectivity: PortConnectivity | null,
transform: FittingTransform,
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
if (!connectivity) return []
const preview = { ...(fitting as Record<string, unknown>), ...transform } as AnyNode
const nodes = useScene.getState().nodes
return resolveConnectivityUpdates(connectivity, preview)
.filter((u) => nodes[u.id])
.map((u) => {
const node = nodes[u.id]
if (node?.type !== 'pipe-fitting') return u
return {
id: u.id,
data: preserveFittingParameters(
node as PipeFittingNode,
u.data as Partial<PipeFittingNode>,
),
}
})
}
const beginDrag =
(
cursor: Cursor,
makeCompute: (
e: ThreeEvent<PointerEvent>,
) => (event: PointerEvent) => FittingTransform | null,
) =>
(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPosition = [...fitting.position] as Point
const initialRotation = [...fitting.rotation] as Point
const connectivity = analyzePortConnectivity(fitting as AnyNode, useScene.getState().nodes)
const compute = makeCompute(e)
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
setDragging(true)
document.body.style.cursor = cursor
let current: FittingTransform | null = null
const buildBatch = (t: FittingTransform): { id: AnyNodeId; data: Partial<AnyNode> }[] => [
{
id: fitting.id as AnyNodeId,
data: preserveFittingParameters(fitting, t as Partial<PipeFittingNode>),
},
...connectivityUpdates(connectivity, t),
]
const onMove = (event: PointerEvent) => {
const next = compute(event)
if (!next) return
current = next
useScene.getState().updateNodes(buildBatch(next))
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
setDragging(false)
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
}
const onUp = () => {
swallowNextClick()
cleanup()
const reverts: { id: AnyNodeId; data: Partial<AnyNode> }[] = (
connectivity?.connections ?? []
).map((conn) => {
if (conn.kind !== 'rigid-node') {
return { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }
}
const node = useScene.getState().nodes[conn.nodeId]
return {
id: conn.nodeId,
data:
node?.type === 'pipe-fitting'
? preserveFittingParameters(node as PipeFittingNode, {
position: conn.startPosition as Point,
})
: ({ position: conn.startPosition } as Partial<AnyNode>),
}
})
useScene.getState().updateNodes([
{
id: fitting.id as AnyNodeId,
data: preserveFittingParameters(fitting, {
position: initialPosition,
rotation: initialRotation,
}),
},
...reverts.filter((u) => useScene.getState().nodes[u.id]),
])
resumeSceneHistory(useScene)
if (current) useScene.getState().updateNodes(buildBatch(current))
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
const moveCompute =
(axis: RotationAxis) =>
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
const anchorWorld = toWorld(fitting.position as Point)
const start = sampleAxis(axis, e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld)
const base = [...fitting.position] as Point
const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
let lastDelta = Number.NaN
return (event: PointerEvent): FittingTransform | null => {
if (start === null) return null
const s = sampleAxis(axis, event.clientX, event.clientY, anchorWorld)
if (s === null) return null
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
const delta = snap(s - start, step)
if (delta === lastDelta) return null
lastDelta = delta
if (step > 0) triggerSFX('sfx:grid-snap')
const next = [...base] as Point
next[axisIndex] = base[axisIndex] + delta
return { position: next }
}
}
const rotateCompute =
(axis: RotationAxis) =>
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
const normal = AXIS_VECTORS[axis].clone()
const center = toWorld(fitting.position as Point)
const ref = axis === 'y' ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0)
const u = ref
.clone()
.sub(normal.clone().multiplyScalar(ref.dot(normal)))
.normalize()
const v = new Vector3().crossVectors(normal, u)
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center)
const bearing = (clientX: number, clientY: number): number | null => {
const hit = intersect(clientX, clientY, plane)
if (!hit) return null
const d = hit.sub(center)
return Math.atan2(d.dot(v), d.dot(u))
}
const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY)
const startQuat = new Quaternion().setFromEuler(
new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
)
let lastStep = Number.NaN
return (event: PointerEvent): FittingTransform | null => {
if (startBearing === null) return null
const b = bearing(event.clientX, event.clientY)
if (b === null) return null
const raw = b - startBearing
const delta = event.shiftKey ? raw : Math.round(raw / ROTATE_STEP_RAD) * ROTATE_STEP_RAD
if (!event.shiftKey) {
const step = Math.round(raw / ROTATE_STEP_RAD)
if (step !== lastStep) {
lastStep = step
triggerSFX('sfx:item-rotate')
}
}
const turn = new Quaternion().setFromAxisAngle(normal, delta)
const euler = new Euler().setFromQuaternion(turn.multiply(startQuat))
return { rotation: [euler.x, euler.y, euler.z] }
}
}
const beginDimensionDrag =
(dimension: PipeDimension, axisLocal: Vector3, cursor: Cursor) =>
(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const baseValue = fitting[dimension]
const initialPatch = dimensionPatch(fitting, dimension, baseValue)
const centerWorld = toWorld(fitting.position as Point)
const axisWorld = axisToWorld(fitting.position as Point, axisLocal)
const start = sampleAxisParameter(
e.nativeEvent.clientX,
e.nativeEvent.clientY,
centerWorld,
axisWorld,
)
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
setDragging(true)
document.body.style.cursor = cursor
let current: Partial<PipeFittingNode> | null = null
let lastValue = Number.NaN
const apply = (patch: Partial<PipeFittingNode>) => {
useScene.getState().updateNodes([
{
id: fitting.id as AnyNodeId,
data: preserveFittingParameters(fitting, patch),
},
])
}
const onMove = (event: PointerEvent) => {
const rawDeltaM =
sampleAxisParameter(event.clientX, event.clientY, centerWorld, axisWorld) - start
const deltaIn = (rawDeltaM / INCHES_TO_METERS) * 2
const nextRaw = baseValue + deltaIn
const nextValue = clamp(event.shiftKey ? nextRaw : snap(nextRaw, RESIZE_STEP_IN), 1.25, 8)
if (nextValue === lastValue) return
lastValue = nextValue
current = dimensionPatch(fitting, dimension, nextValue)
if (!event.shiftKey) triggerSFX('sfx:grid-snap')
apply(current)
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
setDragging(false)
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
}
const onUp = () => {
swallowNextClick()
cleanup()
apply(initialPatch)
resumeSceneHistory(useScene)
if (current) apply(current)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
const extent = useMemo(() => fittingExtentM(fitting), [fitting])
const p = fitting.position as Point
const base = extent + ARROW_GAP
const fittingRotation = useMemo(
() => new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
[fitting.rotation],
)
const runDiameterAxis = useMemo(() => {
const axis = new Vector3(0, 1, 0).applyEuler(fittingRotation).normalize()
return axis.dot(UP) >= 0 ? axis : axis.multiplyScalar(-1)
}, [fittingRotation])
const baseBranchAxis = useMemo(
() => new Vector3(0, 0, 1).applyEuler(fittingRotation).normalize(),
[fittingRotation],
)
const branchAxis = useMemo(
() => baseBranchAxis.clone().multiplyScalar(sideSign),
[baseBranchAxis, sideSign],
)
useFrame(() => {
if (!frame || fitting.fittingType === 'elbow') return
const cameraPosition = camera.getWorldPosition(new Vector3())
const cameraLocal = frame.worldToLocal(cameraPosition)
const toCamera = cameraLocal.sub(new Vector3(p[0], p[1], p[2]))
const nextSign = baseBranchAxis.dot(toCamera) >= 0 ? 1 : -1
setSideSign((current) => (current === nextSign ? current : nextSign))
})
const resizeHandleBase = extent + RESIZE_HANDLE_GAP
const resizeHandles: {
key: PipeDimension
axis: Vector3
cursor: Cursor
guideFrom: Point
guideTo: Point
position: Point
}[] = [
{
key: 'diameter',
axis: runDiameterAxis,
cursor: 'ns-resize',
guideFrom: [
p[0] + runDiameterAxis.x * resizeHandleBase,
p[1] + runDiameterAxis.y * resizeHandleBase,
p[2] + runDiameterAxis.z * resizeHandleBase,
],
guideTo: [
p[0] + runDiameterAxis.x * Math.max(extent * 0.18, 0.04),
p[1] + runDiameterAxis.y * Math.max(extent * 0.18, 0.04),
p[2] + runDiameterAxis.z * Math.max(extent * 0.18, 0.04),
],
position: [
p[0] + runDiameterAxis.x * resizeHandleBase,
p[1] + runDiameterAxis.y * resizeHandleBase,
p[2] + runDiameterAxis.z * resizeHandleBase,
],
},
...(fitting.fittingType === 'elbow'
? []
: [
{
key: 'diameter2' as const,
axis: branchAxis,
cursor: 'ew-resize' as Cursor,
guideFrom: [
p[0] + branchAxis.x * resizeHandleBase,
p[1] + branchAxis.y * resizeHandleBase,
p[2] + branchAxis.z * resizeHandleBase,
] as Point,
guideTo: [
p[0] + branchAxis.x * Math.max(extent * 0.18, 0.04),
p[1] + branchAxis.y * Math.max(extent * 0.18, 0.04),
p[2] + branchAxis.z * Math.max(extent * 0.18, 0.04),
] as Point,
position: [
p[0] + branchAxis.x * resizeHandleBase,
p[1] + branchAxis.y * resizeHandleBase,
p[2] + branchAxis.z * resizeHandleBase,
] as Point,
},
]),
]
const moveArrows: {
key: string
axis: RotationAxis
position: Point
rotationY: number
vertical?: 'up' | 'down'
cursor: Cursor
}[] = [
{ key: '+x', axis: 'x', position: [p[0] + base, p[1], p[2]], rotationY: 0, cursor: 'grab' },
{
key: '-x',
axis: 'x',
position: [p[0] - base, p[1], p[2]],
rotationY: Math.PI,
cursor: 'grab',
},
{
key: '+z',
axis: 'z',
position: [p[0], p[1], p[2] + base],
rotationY: -Math.PI / 2,
cursor: 'grab',
},
{
key: '-z',
axis: 'z',
position: [p[0], p[1], p[2] - base],
rotationY: Math.PI / 2,
cursor: 'grab',
},
{
key: '+y',
axis: 'y',
position: [p[0], p[1] + base, p[2]],
rotationY: 0,
vertical: 'up',
cursor: 'ns-resize',
},
{
key: '-y',
axis: 'y',
position: [p[0], p[1] - base, p[2]],
rotationY: 0,
vertical: 'down',
cursor: 'ns-resize',
},
]
const d = base * Math.SQRT1_2
const rotateArcs: { key: string; axis: RotationAxis; position: Point; rotation: Point }[] = (
['x', 'y', 'z'] as RotationAxis[]
).map((axis) => {
const q = new Quaternion().setFromUnitVectors(UP, AXIS_VECTORS[axis])
if (axis === 'z') {
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.z, Math.PI / 4))
} else if (axis === 'x') {
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.x, (-145 * Math.PI) / 180))
} else if (axis === 'y') {
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.y, (-45 * Math.PI) / 180))
}
const e = new Euler().setFromQuaternion(q)
const position: Point =
axis === 'x'
? [p[0], p[1] + d, p[2] + d]
: axis === 'y'
? [p[0] + d, p[1], p[2] + d]
: [p[0] + d, p[1] + d, p[2]]
return { key: `r${axis}`, axis, position, rotation: [e.x, e.y, e.z] }
})
if (dragging) return <group ref={setFrame} />
return (
<group ref={setFrame}>
<HandleCube active={open} onClick={() => setOpen((o) => !o)} position={p} />
{!open &&
resizeHandles.map((handle) => (
<group key={handle.key}>
<DashedResizeGuide from={handle.guideFrom} to={handle.guideTo} />
<ResizeSphereHandle
cursor={handle.cursor}
onPointerDown={beginDimensionDrag(handle.key, handle.axis, handle.cursor)}
position={handle.position}
/>
</group>
))}
{open && (
<>
{moveArrows.map((a) => (
<MoveChevron
cursor={a.cursor}
key={a.key}
onPointerDown={beginDrag(
a.axis === 'y' ? 'ns-resize' : 'grabbing',
moveCompute(a.axis),
)}
position={a.position}
rotationY={a.rotationY}
vertical={a.vertical}
/>
))}
{rotateArcs.map((arc) => (
<RotateArc
key={arc.key}
onPointerDown={beginDrag('grabbing', rotateCompute(arc.axis))}
position={arc.position}
rotation={arc.rotation}
/>
))}
</>
)}
</group>
)
}
export default PipeFittingSelectionAffordance
+24 -2
View File
@@ -27,6 +27,7 @@ import {
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
type Vec3 = [number, number, number]
@@ -135,6 +136,12 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
if (existedAtStart) setMeshHidden(true)
// Carry connected fittings (+ their other runs) as the whole run slides.
// Snapshot once at drag start; only existing runs are mated to anything.
const connectivity: RunMoveConnectivity | null = existedAtStart
? startRunMoveConnectivity(node)
: null
const setPreview = (path: Vec3[]) => {
previewPathRef.current = path
setPreviewPath(path)
@@ -174,7 +181,9 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
prevSnapRef.current = cur
hasMovedRef.current = true
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)
setPreview(nextPath)
connectivity?.preview({ path: nextPath })
}
const commit = (event: GridEvent) => {
@@ -202,10 +211,21 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
// Fold connected-fitting / sibling-run follow-updates into the SAME
// batch as the moved run so the whole joint is one undo step.
const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? []
useScene
.getState()
.updateNodes([
{ id: nodeId, data: { path: finalPath } as Partial<AnyNode> },
...followUpdates,
])
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
// Followers are committed to the store — drop their live overrides so
// renderers read the canonical path/position.
connectivity?.clear()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
@@ -217,6 +237,7 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
const onCancel = () => {
connectivity?.clear()
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -236,6 +257,7 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
connectivity?.clear()
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
File diff suppressed because it is too large Load Diff
+42 -4
View File
@@ -55,6 +55,11 @@ import { pipeSegmentDefinition } from './definition'
* - Esc clears an anchored start point.
*/
const PREVIEW_OPACITY = 0.55
/** green-500 — the project's snap accent. The cursor ring + vertical line
* recolour to this while the point is snapped onto an existing run / port,
* so the coincidence reads with the familiar snap green (matches the duct
* tool). */
const SNAP_CURSOR_COLOR = '#22c55e'
/** Nominal residential DWV sizes (inches). */
const PIPE_DIAMETERS_IN = [1.25, 1.5, 2, 3, 4, 6] as const
/** IPC default drain slope — ¼" per foot (1:48). */
@@ -88,6 +93,28 @@ function findNearbyPort(point: [number, number, number]): ScenePort | null {
)
}
function pipeEndPort(pipe: PipeSegmentNode, id: 'start' | 'end'): ScenePort | null {
if (pipe.path.length < 2) return null
const index = id === 'start' ? 0 : pipe.path.length - 1
const neighborIndex = id === 'start' ? 1 : pipe.path.length - 2
const position = pipe.path[index]!
const neighbor = pipe.path[neighborIndex]!
const dx = position[0] - neighbor[0]
const dy = position[1] - neighbor[1]
const dz = position[2] - neighbor[2]
const len = Math.hypot(dx, dy, dz)
const direction: [number, number, number] =
len < 1e-9 ? [1, 0, 0] : [dx / len, dy / len, dz / len]
return {
id,
nodeId: pipe.id,
position,
direction,
diameter: pipe.diameter,
system: pipe.system,
}
}
function projectToAngleLock(
from: [number, number, number],
raw: [number, number, number],
@@ -305,11 +332,14 @@ const PipeSegmentTool = () => {
...(cross ? [cross.runUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
],
})
const nextPipe = pipes.at(-1)
const nextStart = nextPipe ? nextPipe.path[nextPipe.path.length - 1]! : end
const nextPort = nextPipe ? pipeEndPort(nextPipe, 'end') : endPort
triggerSFX('sfx:item-place')
setDraftStart(null)
setDraftStart(nextStart)
setSnapTarget(null)
startPortRef.current = null
startBodyRef.current = null
startPortRef.current = nextPort
startBodyRef.current = nextPort ? null : endBody
altAnchorRef.current = null
setAltActive(false)
}
@@ -478,6 +508,14 @@ const PipeSegmentTool = () => {
triggerSFX('sfx:grid-snap')
startPortRef.current = port
startBodyRef.current = port ? null : body
// Continue an existing run at its true size: adopt the snapped
// pipe's diameter so the new segment carries on at the same gauge
// instead of whatever size the tool last drew.
const ownerId = port?.nodeId ?? (port ? null : body?.nodeId)
const owner = ownerId ? useScene.getState().nodes[ownerId] : null
if (owner?.type === 'pipe-segment' && owner.diameter !== diameterRef.current) {
setDiameter(owner.diameter)
}
setDraftStart(point)
return
}
@@ -612,7 +650,7 @@ const PipeSegmentTool = () => {
dimension pill rides just above the cursor. */}
{cursorPos && (
<>
<CursorSphere position={cursorPos} />
<CursorSphere color={snapTarget ? SNAP_CURSOR_COLOR : undefined} position={cursorPos} />
{pillParts && (
<group position={cursorPos}>
<Html
+1 -1
View File
@@ -26,7 +26,7 @@ export const pipeTrapDefinition: NodeDefinition<typeof PipeTrapNode> = {
metadata: {},
position: [0, 0, 0],
rotation: 0,
diameter: 1.5,
diameter: 2,
pipeMaterial: 'pvc',
armLengthM: 0,
}),
+23 -1
View File
@@ -1,9 +1,14 @@
import { Group, Mesh, TorusGeometry, Vector3 } from 'three'
import { DoubleSide, Group, Mesh, SphereGeometry, TorusGeometry, Vector3 } from 'three'
import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry'
import { createPipeMaterial } from '../pipe-segment/geometry'
import type { PipeTrapNode } from './schema'
const BEND_SEGMENTS = 24
const RADIAL_SEGMENTS = 20
/** Sphere hubs filling the U-bend → stub joints read as a coupling and,
* more importantly, hide the wedge gap left where the horizontal arm's
* flat end cap meets the bend's upward-facing opening at 90°. */
const HUB_RADIUS_FACTOR = 1.12
/** Inlet drop and arm reach in pipe radii — keeps the trap proportional
* to its size without per-size tuning. */
@@ -19,8 +24,12 @@ const ARM_REACH_RADII = 3.2
export function buildPipeTrapGeometry(node: PipeTrapNode): Group {
const group = new Group()
const material = createPipeMaterial({ pipeMaterial: node.pipeMaterial, system: 'waste' })
// Double-sided so the thin pipe walls don't drop out at grazing angles,
// which read as cuts/holes on the bend and stub ends.
material.side = DoubleSide
const radius = (node.diameter * INCHES_TO_METERS) / 2
const bendR = radius * 1.6
const hubRadius = radius * HUB_RADIUS_FACTOR
// U-bend: half torus in the XY plane, opening upward. Sits so its two
// tops are at y = bendR (the inlet riser and the arm rise).
@@ -49,6 +58,19 @@ export function buildPipeTrapGeometry(node: PipeTrapNode): Group {
const arm = buildSection(armStart, armEnd, radius, material, 'pipe-trap-arm')
if (arm) group.add(arm)
// Coupling hubs at the two U-bend tops where the straight stubs meet the
// torus. They fill the 90° miter wedge (the visible "cut") and read as
// the trap's slip-joint nuts.
for (const [i, center] of [
new Vector3(0, bendR, 0),
new Vector3(bendR * 2, bendR, 0),
].entries()) {
const hub = new Mesh(new SphereGeometry(hubRadius, RADIAL_SEGMENTS, 12), material)
hub.name = `pipe-trap-hub-${i}`
hub.position.copy(center)
group.add(hub)
}
return group
}
+1 -1
View File
@@ -26,7 +26,7 @@ const PipeTrapTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId)
const [cursor, setCursor] = useState<[number, number, number] | null>(null)
const [yaw, setYaw] = useState(0)
const [diameter] = useState(1.5)
const [diameter] = useState(pipeTrapDefinition.defaults().diameter)
const yawRef = useRef(0)
const diameterRef = useRef(diameter)
diameterRef.current = diameter
+21 -2
View File
@@ -5,6 +5,7 @@ import {
emitter,
type RidgeVentNode,
type RoofEvent,
type RoofNode,
type RoofSegmentNode,
sceneRegistry,
useScene,
@@ -20,8 +21,13 @@ import {
createRelativeRoofDrag,
type RelativeRoofDragTarget,
roofSegmentLocalToBuildingLocal,
snapRelativeRoofDragTarget,
} from '../shared/relative-roof-drag'
import { getSurfaceY } from '../shared/roof-surface'
import {
clearRoofSurfacePlacementGuides,
publishRoofSurfaceNodePlacementGuides,
} from '../shared/roof-surface-placement-guides'
import RidgeVentPreview from './preview'
type RidgeVentDragTarget = Pick<RelativeRoofDragTarget, 'segment' | 'localX'> & {
@@ -72,11 +78,13 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
lastTarget = null
lastSnap = null
setPreviewPos(null)
clearRoofSurfacePlacementGuides()
}
const resolveTarget = (event: RoofEvent): RidgeVentDragTarget | null => {
const target = roofDrag.resolve(event)
if (!target) return null
const rawTarget = roofDrag.resolve(event)
if (!rawTarget) return null
const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true)
return {
segment: target.segment,
localX: target.localX,
@@ -111,6 +119,13 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
target.localZ,
]),
)
publishRoofSurfaceNodePlacementGuides({
roof: event.node as RoofNode,
segment: target.segment,
center: [target.localX, target.localY, target.localZ],
node,
mode: 'linear-edge',
})
event.stopPropagation()
}
@@ -157,6 +172,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
if (obj) obj.visible = true
triggerSFX('sfx:item-place')
clearRoofSurfacePlacementGuides()
exitMoveMode()
event.stopPropagation()
}
@@ -175,6 +191,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
useScene.getState().deleteNode(node.id as AnyNodeId)
useScene.temporal.getState().resume()
markToolCancelConsumed()
clearRoofSurfacePlacementGuides()
exitMoveMode()
return
}
@@ -194,6 +211,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
useScene.temporal.getState().resume()
markToolCancelConsumed()
clearRoofSurfacePlacementGuides()
exitMoveMode()
}
@@ -223,6 +241,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) {
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
clearRoofSurfacePlacementGuides()
useScene.temporal.getState().resume()
}
}, [exitMoveMode, node])
+20 -2
View File
@@ -16,6 +16,11 @@ import * as THREE from 'three'
import { resolveRidgeSnap } from '../shared/ridge-snap'
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 { ridgeVentDefinition } from './definition'
import RidgeVentPreview from './preview'
@@ -73,6 +78,7 @@ const RidgeVentTool = () => {
const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
if (!snap) {
setPreviewPos(null)
clearRoofSurfacePlacementGuides()
return
}
const segObj = sceneRegistry.nodes.get(hit.segment.id)
@@ -96,6 +102,13 @@ const RidgeVentTool = () => {
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
setPreviewPos(worldToBuildingLocal(ridgeWorld[0], ridgeWorld[1], ridgeWorld[2]))
publishRoofSurfacePlacementGuides({
roof: event.node as RoofNode,
segment: hit.segment,
center: [snap.localX, hit.localY, snap.localZ],
footprint: roofSurfaceFootprintFromNode(previewNode),
mode: 'linear-edge',
})
event.stopPropagation()
}
@@ -122,6 +135,7 @@ const RidgeVentTool = () => {
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [vent.id] })
triggerSFX('sfx:item-place')
clearRoofSurfacePlacementGuides()
event.stopPropagation()
}
@@ -133,8 +147,9 @@ const RidgeVentTool = () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
clearRoofSurfacePlacementGuides()
}
}, [activeBuildingId, setSelection])
}, [activeBuildingId, setSelection, previewNode])
return (
<>
@@ -150,7 +165,10 @@ const RidgeVentTool = () => {
)
return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ)
}}
onInvalidTarget={() => setPreviewPos(null)}
onInvalidTarget={() => {
setPreviewPos(null)
clearRoofSurfacePlacementGuides()
}}
/>
{activeBuildingId && previewPos && (
<group position={previewPos}>
+28 -5
View File
@@ -179,16 +179,20 @@ describe('planTeeAtRunBody', () => {
expect(plan!.fitting.diameter2).toBe(6)
})
test('45° drawn branch leaves square (projected perpendicular)', () => {
test('45° drawn branch builds a 45° lateral that follows the drawn run', () => {
const run = trunk([
[0, 0, 0],
[6, 0, 0],
])
const d = Math.SQRT1_2
// Drawn 45° downstream off the +X trunk. The tee becomes a lateral whose
// branch points along the drawn direction, so the new duct continues
// straight out of the collar instead of kinking square.
const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [d, 0, d], ROUND_6)
expect(plan).not.toBeNull()
expect(plan!.fitting.branchAngle).toBeCloseTo(45, 6)
const branch = getDuctFittingPorts(plan!.fitting).find((p) => p.id === 'branch')!
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
expect(dot(branch.direction, [d, 0, d])).toBeCloseTo(1, 6)
})
test('tap too close to a run end → null (use the end port instead)', () => {
@@ -502,10 +506,29 @@ describe('planElbowRealign', () => {
expect(dot(outlet.direction, [0, 0, 1])).toBeCloseTo(1, 6)
})
test('arrival needing a turn outside 1590° → null', () => {
test('shallow arrival flattens the elbow toward a straight coupling', () => {
const elbow = existingElbow()
// Away nearly opposite the fixed inlet direction → turn < 15°. Unlike
// fresh-fitting creation, an existing elbow flattens to this small angle
// instead of bailing, so the run can be dragged dead straight.
const plan = planElbowRealign(elbow, 'outlet', [0.99, 0, 0.14])
expect(plan).not.toBeNull()
expect(plan!.update.data.angle).toBeLessThan(15)
expect(plan!.update.data.angle).toBeGreaterThanOrEqual(0)
})
test('run dragged into line flattens the elbow to a straight 0° coupling', () => {
const elbow = existingElbow()
// The free outlet pulled exactly opposite the mated inlet → no turn left.
const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')!
const away: Point = [-inlet.direction[0], -inlet.direction[1], -inlet.direction[2]]
const plan = planElbowRealign(elbow, 'outlet', away)
expect(plan).not.toBeNull()
expect(plan!.update.data.angle).toBeCloseTo(0, 5)
})
test('a back-turn sharper than 90° still bails', () => {
const elbow = existingElbow()
// Away nearly opposite the fixed inlet direction → turn < 15°.
expect(planElbowRealign(elbow, 'outlet', [0.99, 0, 0.14])).toBeNull()
// Away aligned WITH the fixed collar direction → turn > 90°.
expect(planElbowRealign(elbow, 'outlet', [-0.99, 0, 0.14])).toBeNull()
})
+166 -36
View File
@@ -202,9 +202,10 @@ export type TeeTapPlan = {
* upstream half (trimmed one leg short), a new duct-segment node carries
* the downstream half (starting one leg after), and the tee's run legs
* bridge the gap with its junction exactly on the centerline hit. The
* branch collar points along `awayDir` projected perpendicular to the
* trunk axis — a tee's branch is square to its run, so a 4drawn
* branch leaves square and the drawn duct continues from the collar.
* branch collar follows `awayDir`: the tee becomes a lateral whose
* `branchAngle` (clamped to the buildable 45135° range) matches the turn
* the drawn run makes off the trunk, so the new duct continues straight
* out of the collar instead of kinking square.
*
* Returns null when the tap can't be built: too close to the segment's
* ends (no room for the run legs — join the end port instead), or the
@@ -223,13 +224,38 @@ export function planTeeAtRunBody(
if (axis.lengthSq() < 1e-10) return null
axis.normalize()
// Branch leaves square to the run: project the drawn direction onto
// the plane perpendicular to the trunk axis.
const away = new Vector3(...awayDir)
// The branch FOLLOWS the drawn run's angle: the tee becomes a lateral
// whose `branchAngle` matches the actual turn the new run makes off the
// trunk, instead of forcing a square tap and kinking the drawn duct.
// `branchDir` is the drawn direction's component square to the trunk —
// it sets the PLANE the branch leans in; the lean amount comes from how
// much of `away` runs along the trunk vs. across it.
const away = new Vector3(...awayDir).normalize()
if (away.lengthSq() < 1e-10) return null
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
if (branchDir.lengthSq() < 1e-6) return null
branchDir.normalize()
// `branchAngle` is measured off the +X (outlet / downstream) axis in the
// tee's local XZ plane, where +Z is the branch's square direction. So
// the angle is atan2(across-trunk component, along-trunk component) of
// the drawn run — 90° when square, <90° leaning downstream, >90° leaning
// upstream. Clamped to the schema's buildable 45135° lateral range.
const acrossLen = Math.sqrt(Math.max(0, 1 - away.dot(axis) ** 2))
const branchAngleDeg = Math.min(
135,
Math.max(45, (Math.atan2(acrossLen, away.dot(axis)) * 180) / Math.PI),
)
const phi = (branchAngleDeg * Math.PI) / 180
// Actual branch outward direction at the (possibly clamped) angle — the
// new run starts at its collar. When unclamped this equals `away`, so
// the drawn duct continues straight out of the tee.
const branchOutDir = axis
.clone()
.multiplyScalar(Math.cos(phi))
.addScaledVector(branchDir, Math.sin(phi))
.normalize()
// Room check: both run legs must fit inside the hit segment with a
// margin of real duct on each side.
// Rect trunks present their area-equivalent round size at joints
@@ -244,8 +270,9 @@ export function planTeeAtRunBody(
const MIN_STUB = 0.08
if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null
// Local +X (the run) → axis, local +Z (the branch) → branchDir. Both
// pairs are perpendicular, so the basis transfer is exact.
// Local +X (the run) → axis, local +Z (the branch plane) → branchDir.
// Both pairs are perpendicular, so the basis transfer is exact and the
// local branch leg (cos φ, sin φ) lands on `branchOutDir` in world.
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
const worldFrame = frame(axis, branchDir)
if (!localFrame || !worldFrame) return null
@@ -256,7 +283,7 @@ export function planTeeAtRunBody(
const inletTrim = P.clone().addScaledVector(axis, -legRun)
const outletTrim = P.clone().addScaledVector(axis, legRun)
const collar = P.clone().addScaledVector(branchDir, legBranch)
const collar = P.clone().addScaledVector(branchOutDir, legBranch)
const fitting = DuctFittingNode.parse({
object: 'node',
@@ -273,6 +300,7 @@ export function planTeeAtRunBody(
width2: branch.width,
height2: branch.height,
diameter2: branchDiameterIn,
branchAngle: branchAngleDeg,
ductMaterial: 'sheet-metal',
system: trunk.system,
position: [P.x, P.y, P.z],
@@ -462,24 +490,30 @@ export type ElbowRealignPlan = {
collarPoint: Point
}
export type PipeElbowRealignPlan = {
update: { id: PipeFittingNode['id']; data: { angle: number; rotation: Point } }
collarPoint: Point
}
/**
* Re-aim an existing elbow whose open collar a new run just snapped
* onto. The junction stays put and the OTHER collar keeps its exact
* position + direction (it's mated to something), while the snapped
* collar swings to face the incoming run — the elbow's `angle` adjusts
* to whatever turn that requires.
* Shared elbow re-aim geometry for duct AND pipe elbows — both share the
* exact same local convention (inlet -X, outlet turned `angle`° in XZ,
* 1590° buildable range), so only the collar leg length differs.
*
* Geometry: with the fixed collar's outward direction f and the desired
* free direction `awayDir`, the elbow's local inlet/outlet pair subtends
* 180° angle, so the new turn is θ = 180° ∠(f, away). Buildable only
* while θ stays in the elbow's 1590° range — otherwise null and the
* caller leaves the joint as a plain butt joint.
* The junction stays put and the OTHER collar keeps its exact position +
* direction (it's mated to something), while the snapped collar swings to
* face `awayDir` — the elbow's `angle` adjusts to whatever turn that
* requires. Geometry: with the fixed collar's outward direction f and the
* desired free direction `awayDir`, the elbow's local inlet/outlet pair
* subtends 180° angle, so the new turn is θ = 180° ∠(f, away).
* Buildable only while θ stays in 1590° — otherwise null.
*/
export function planElbowRealign(
elbow: DuctFittingNode,
function planElbowRealignCore(
elbow: { fittingType: string; rotation: Point; angle: number; position: Point },
snappedPortId: string,
awayDir: Point,
): ElbowRealignPlan | null {
leg: number,
): { angle: number; rotation: Point; collarPoint: Point } | null {
if (elbow.fittingType !== 'elbow') return null
if (snappedPortId !== 'inlet' && snappedPortId !== 'outlet') return null
@@ -498,10 +532,14 @@ export function planElbowRealign(
)
const fixedWorld = snappedPortId === 'inlet' ? outletWorld : inletWorld
// New turn from the fixed collar / free collar pair.
// New turn from the fixed collar / free collar pair. Unlike fresh-fitting
// creation (which butt-joins near-straight runs rather than minting a flat
// elbow), an EXISTING elbow may flatten all the way to 0° — a straight
// coupling — when its run is dragged into line, so only the upper bound
// guards here.
const spread = fixedWorld.angleTo(away)
const turnNew = Math.PI - spread
if (turnNew < MIN_TURN_RAD || turnNew > MAX_TURN_RAD) return null
if (turnNew > MAX_TURN_RAD) return null
// Local outward pair at the new angle, ordered (fixed, free) to match
// the world pair.
@@ -512,23 +550,115 @@ export function planElbowRealign(
const localFrame = frame(fixedLocal, freeLocal)
const worldFrame = frame(fixedWorld, away)
if (!localFrame || !worldFrame) return null
const rotation = new Quaternion().setFromRotationMatrix(
worldFrame.multiply(localFrame.transpose()),
)
// At (near-)straight the two collars are collinear, so the bend plane is
// undefined and `frame()` returns null. Map the fixed collar's local axis
// onto its world direction instead; the free collar (antiparallel) lands
// on `away` for free, and a straight coupling's roll is arbitrary.
const rotation =
localFrame && worldFrame
? new Quaternion().setFromRotationMatrix(worldFrame.multiply(localFrame.transpose()))
: new Quaternion().setFromUnitVectors(fixedLocal, fixedWorld)
const euler = new Euler().setFromQuaternion(rotation)
const leg = fittingLegLength(elbow.diameter)
const collar = new Vector3(...elbow.position).addScaledVector(away, leg)
return {
update: {
id: elbow.id,
data: {
angle: Math.min(90, (turnNew * 180) / Math.PI),
rotation: [euler.x, euler.y, euler.z],
},
},
angle: Math.max(0, Math.min(90, (turnNew * 180) / Math.PI)),
rotation: [euler.x, euler.y, euler.z],
collarPoint: [collar.x, collar.y, collar.z],
}
}
/** Re-aim a DUCT elbow whose open collar a new run just snapped onto. */
export function planElbowRealign(
elbow: DuctFittingNode,
snappedPortId: string,
awayDir: Point,
): ElbowRealignPlan | null {
const core = planElbowRealignCore(elbow, snappedPortId, awayDir, fittingLegLength(elbow.diameter))
if (!core) return null
return {
update: { id: elbow.id, data: { angle: core.angle, rotation: core.rotation } },
collarPoint: core.collarPoint,
}
}
/** Re-aim a DWV PIPE elbow — same geometry, pipe collar leg length. */
export function planPipeElbowRealign(
elbow: PipeFittingNode,
snappedPortId: string,
awayDir: Point,
): PipeElbowRealignPlan | null {
const core = planElbowRealignCore(
elbow,
snappedPortId,
awayDir,
pipeFittingLegLength(elbow.diameter),
)
if (!core) return null
return {
update: { id: elbow.id, data: { angle: core.angle, rotation: core.rotation } },
collarPoint: core.collarPoint,
}
}
// ─── Tee branch re-aim (run dragged off an existing tee's branch) ────
export type TeeBranchRealignPlan = {
/** Patch for the existing tee: new branch lean angle. The run axis and
* the tee's orientation stay fixed (inlet / outlet stay mated to the
* trunk) — only `branchAngle` changes. */
update: { id: DuctFittingNode['id']; data: { branchAngle: number } }
/** Where the branch collar lands at the new angle — the dragged run's
* mated end rides here. */
collarPoint: Point
}
/**
* Re-aim a duct TEE's branch to follow a run dragged off its branch collar.
*
* Unlike the elbow (which re-orients its whole body), a tee's run legs stay
* mated to the trunk, so the body orientation is FIXED: the branch can only
* swing within the tee's local XZ plane (local +X = run axis, +Z = the
* square branch direction). `awayDir` (junction → dragged end) is projected
* onto that plane and read as the lean angle off +X — 90° square, <90°
* leaning downstream toward the outlet, >90° upstream toward the inlet —
* clamped to the schema's buildable 45135° lateral range.
*/
export function planTeeBranchRealign(
tee: DuctFittingNode,
awayDir: Point,
): TeeBranchRealignPlan | null {
if (tee.fittingType !== 'tee') return null
const away = new Vector3(...awayDir)
if (away.lengthSq() < 1e-10) return null
away.normalize()
const rot = new Quaternion().setFromEuler(
new Euler(tee.rotation[0], tee.rotation[1], tee.rotation[2]),
)
const runAxis = new Vector3(1, 0, 0).applyQuaternion(rot)
const squareDir = new Vector3(0, 0, 1).applyQuaternion(rot)
const ax = away.dot(runAxis)
const az = away.dot(squareDir)
// Drag straight along the run axis (no square component) leaves the lean
// undefined — hold the frame.
if (Math.abs(ax) < 1e-9 && Math.abs(az) < 1e-9) return null
const branchAngleDeg = Math.min(135, Math.max(45, (Math.atan2(az, ax) * 180) / Math.PI))
const phi = (branchAngleDeg * Math.PI) / 180
const branchDir = runAxis
.clone()
.multiplyScalar(Math.cos(phi))
.addScaledVector(squareDir, Math.sin(phi))
.normalize()
const collar = new Vector3(...tee.position).addScaledVector(
branchDir,
fittingLegLength(tee.diameter2),
)
return {
update: { id: tee.id, data: { branchAngle: branchAngleDeg } },
collarPoint: [collar.x, collar.y, collar.z],
}
}
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import {
AUTO_OFFSET_KEY,
type AutoOffsetTag,
autoOffsetInvalidationUpdates,
newAutoOffsetGroupId,
readAutoOffsetTag,
translateAutoOffsetBase,
withAutoOffsetTag,
withoutAutoOffsetTag,
} from './auto-offset-tag'
const sampleTag = (): AutoOffsetTag => ({
group: 'aoff_test',
dy: 0.6,
minted: ['duct-fitting_a' as AnyNodeId, 'duct-segment_r' as AnyNodeId],
base: [{ id: 'duct-segment_run' as AnyNodeId, data: { path: [[0, 2, 0]] } }],
})
describe('auto-offset tag round-trip', () => {
it('writes then reads back an identical tag', () => {
const tag = sampleTag()
const meta = withAutoOffsetTag({ existing: 1 }, tag)
expect(meta.existing).toBe(1)
expect(readAutoOffsetTag({ metadata: meta })).toEqual(tag)
})
it('replaces a prior tag rather than nesting it', () => {
const first = sampleTag()
const second: AutoOffsetTag = { ...first, dy: 1.2, group: 'aoff_two' }
const meta = withAutoOffsetTag(withAutoOffsetTag({}, first), second)
expect(readAutoOffsetTag({ metadata: meta })).toEqual(second)
})
it('removes the tag while preserving other metadata keys', () => {
const meta = withAutoOffsetTag({ keep: 'me' }, sampleTag())
const stripped = withoutAutoOffsetTag(meta)
expect(stripped).toEqual({ keep: 'me' })
expect(stripped[AUTO_OFFSET_KEY]).toBeUndefined()
expect(readAutoOffsetTag({ metadata: stripped })).toBeNull()
})
})
describe('translateAutoOffsetBase', () => {
it('moves path and position patches with a rigid offset translation', () => {
const tag: AutoOffsetTag = {
...sampleTag(),
base: [
{
id: 'duct-segment_run' as AnyNodeId,
data: {
path: [
[0, 0, 0],
[2, 0, 0],
],
},
},
{
id: 'duct-fitting_elbow' as AnyNodeId,
data: { position: [4, 1, 5], angle: 90 },
},
],
}
const moved = translateAutoOffsetBase(tag, [1, 0, -2])
expect(moved.base[0]?.data.path).toEqual([
[1, 0, -2],
[3, 0, -2],
])
expect(moved.base[1]?.data.position).toEqual([5, 1, 3])
expect(moved.base[1]?.data.angle).toBe(90)
})
})
describe('autoOffsetInvalidationUpdates', () => {
it('clears owner tags when a generated offset part is edited manually', () => {
const owner = {
id: 'duct-segment_owner' as AnyNodeId,
metadata: withAutoOffsetTag({}, sampleTag()),
} as AnyNode
const other = {
id: 'duct-segment_other' as AnyNodeId,
metadata: withAutoOffsetTag({}, { ...sampleTag(), minted: ['duct-fitting_other'] }),
} as AnyNode
const updates = autoOffsetInvalidationUpdates(
{
[owner.id]: owner,
[other.id]: other,
},
'duct-fitting_a' as AnyNodeId,
)
expect(updates).toHaveLength(1)
expect(updates[0]?.id).toBe(owner.id)
expect(readAutoOffsetTag({ metadata: updates[0]?.data.metadata })).toBeNull()
})
it('clears owner tags when a stored base participant is edited manually', () => {
const owner = {
id: 'duct-segment_owner' as AnyNodeId,
metadata: withAutoOffsetTag(
{},
{
...sampleTag(),
base: [
{ id: 'duct-segment_owner' as AnyNodeId, data: { path: [[0, 0, 0]] } },
{ id: 'duct-fitting_corner' as AnyNodeId, data: { position: [1, 0, 0] } },
],
},
),
} as AnyNode
const updates = autoOffsetInvalidationUpdates(
{ [owner.id]: owner },
'duct-fitting_corner' as AnyNodeId,
)
expect(updates).toHaveLength(1)
expect(updates[0]?.id).toBe(owner.id)
expect(readAutoOffsetTag({ metadata: updates[0]?.data.metadata })).toBeNull()
})
})
describe('readAutoOffsetTag guards', () => {
it('returns null for missing / empty metadata', () => {
expect(readAutoOffsetTag(null)).toBeNull()
expect(readAutoOffsetTag(undefined)).toBeNull()
expect(readAutoOffsetTag({})).toBeNull()
expect(readAutoOffsetTag({ metadata: {} })).toBeNull()
})
it('returns null for a malformed tag (wrong field shapes)', () => {
const bad = [
{ group: 1, dy: 0, minted: [], base: [] },
{ group: 'g', dy: 'x', minted: [], base: [] },
{ group: 'g', dy: 0, minted: 'nope', base: [] },
{ group: 'g', dy: 0, minted: [], base: {} },
]
for (const tag of bad) {
expect(readAutoOffsetTag({ metadata: { [AUTO_OFFSET_KEY]: tag } })).toBeNull()
}
})
})
describe('newAutoOffsetGroupId', () => {
it('produces a prefixed, unique-ish id', () => {
const a = newAutoOffsetGroupId()
const b = newAutoOffsetGroupId()
expect(a.startsWith('aoff_')).toBe(true)
expect(a).not.toBe(b)
})
})

Some files were not shown because too many files have changed in this diff Show More