Skip to content

Commit a71de82

Browse files
sudhir9297claudeopen-pascal
authored
nodes: add MEP movement controls and DWV parity (#438)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * feat(duct): ceiling-snap drawing + connected-joint endpoint move Duct draw tool's ceiling mode now hangs each path point just below the ceiling actually covering it (per-room heights tracked), with a translucent surface highlight and a plumb line to the floor so the in-flight point reads clearly from any angle. Dragging a duct corner that sits on a fitting now carries the fitting's other ducts along (port-connectivity second hop), so the joint moves together instead of tearing apart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): detach + vertical modifiers for duct/pipe joint editing Alt detaches a dragged duct/pipe endpoint or fitting from its connected joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical riser movement on the fitting move. Behavioral parity across 2D and 3D. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): full DWV pipe parity for joint editing Bring pipe-segment endpoint drags and pipe-fitting moves to parity with duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow re-aim, and connectivity follow. Generalizes the shared elbow-reaim and auto-fitting helpers to dispatch by run kind so 2D and 3D share one path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): wall-style arrow handles for duct fittings + segments Add violet directional arrow affordances to duct-fitting selection (height, move cross, rotate arc) mirroring the duct-segment rig: portaled into the parent frame to stay out of the selection outline, rendered via the shared HandleArrow, and carrying mated-run connectivity through the single-undo dance. The move cross engages press-drag-release (placementDragMode) the same way the floating drag does, so the markup hit-areas go inert and the fitting move tool commits on pointer-up. Also re-export the HandleArrow primitives from @pascal-app/editor and extend the duct-segment side-move/floorplan affordances. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): click-to-latch cube handles for duct + fitting editing Replace the hover-reveal / multi-handle selection rigs with a single click-to-latch cube that opens a directional cluster, shared between duct segments and fittings via a new selection-handles module (HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube). - Duct segment: per-vertex + run-center cubes reveal axis-locked move chevrons (down arrow always shown), plus a roll arc at the run center. - Duct fitting: center cube reveals six ±XYZ move arrows and three per-axis rotation arcs (oriented in place), replacing the old height/move/rotate trio with axis-cycling. - Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth. - thin chevron profile + press-drag-release commit retained. * fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag Build a fully-determined basis for the duct roll gizmo so the curved arrow always seats at the top-outer 45° corner regardless of run direction, instead of an arbitrary apex from a single setFromUnitVectors. The selection-rig ±Y arrows now own vertical movement, so the redundant Ctrl-modifier riser drag is removed from the fitting move tool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): run-aligned duct handles, swing snapping, elbow flatten Align the duct run-center cube + horizontal arrows to the run axis (matching the per-vertex handles) while keeping whole-run translate. Endpoint side / up-down swing arrows now follow grid snap points and port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow realign + fitting schemas to flatten to a straight 0° coupling. Surface HVAC-specific hints in the select-mode helper panel for duct / fitting. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): per-segment linesets/liquid-lines with joint-follow editing Linesets and liquid lines now commit one independent two-point node per drawn segment instead of folding into a single mitered polyline, so each line selects and deletes on its own. Endpoint caps fill shared-coordinate joints so connected segments still read as continuous pipe. Dragging a shared endpoint carries mated segments along via port connectivity (Alt detaches), so a run still edits as one welded piece. Liquid-line follow mode traces the whole connected lineset run, laying a per-segment parallel line down its full assembled length. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag Lifting/lowering a connected run with the run-center cube now keeps each connected end welded to its stationary partner instead of dragging the whole network. Run-to-run ends get the classic S/Z offset (two elbows + plumb riser, partner trimmed back one leg); elbow-connected ends form a clean L — the existing elbow stays put and re-aims its collar vertical, with one new top elbow + riser reconnecting to the lifted endpoint. The offset is ghosted live and minted as a single undo step on release. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add roof accessory placement guides Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters. * Improve duct and placement routing * Fix duct vertical movement routing * Fix duct vertical offsets and roof accessory movement * Add DWV movement parity and line endpoint controls * Fix MEP handle review issues * Fix chimney placement and duct offset cleanup * Use snapped targets for roof accessory commits * fix(nodes): repair MEP movement review issues - auto-fitting: tee branch now follows the drawn lateral angle; update the stale square-tee test + doc comment that contradicted the rewrite - duct-segment: re-enable the vertical auto-offset rewind (the disabled stub left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers and stacked duplicates); remove the dead stub - duct-segment: strip the stale auto-offset tag on manual corner/roll commits so the horizontal-move path no longer trusts an out-of-date base - chimney: resume history before mutating segment children arrays so a cross-segment move reparents in one tracked transaction (undo stays consistent) - dormer: align schema test with the new windowSill=false default * fix: address mep movement review issues * fix: address follow-up mep review comments * fix: address additional mep review comments --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: pascal-open-bot <open@pascal.app>
1 parent ae90da6 commit a71de82

129 files changed

Lines changed: 15802 additions & 2052 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/editor/components/build-tab.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ export function BuildTab() {
168168
const ductContext =
169169
mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting')
170170
const pipeContext =
171-
mode === 'build' && (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting')
171+
mode === 'build' &&
172+
(activeTool === 'pipe-segment' || activeTool === 'pipe-fitting' || activeTool === 'pipe-trap')
172173
const liquidLineContext = mode === 'build' && activeTool === 'liquid-line'
173174

174175
const isMepItemActive = (item: MepItem) =>
@@ -421,6 +422,30 @@ export function BuildTab() {
421422
/>
422423
Add Fitting
423424
</button>
425+
<button
426+
className={cn(
427+
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
428+
activeTool === 'pipe-trap'
429+
? 'bg-primary/10 ring-1 ring-primary/50'
430+
: 'bg-muted/40 hover:bg-muted',
431+
)}
432+
onClick={() => {
433+
triggerSFX('sfx:menu-click')
434+
activateBuildTool(activeTool === 'pipe-trap' ? 'pipe-segment' : 'pipe-trap')
435+
}}
436+
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
437+
type="button"
438+
>
439+
<Image
440+
alt=""
441+
aria-hidden
442+
className="size-4 object-contain"
443+
height={16}
444+
src="/icons/dwv-pipes.png"
445+
width={16}
446+
/>
447+
Add Trap
448+
</button>
424449
</div>
425450
) : null}
426451

apps/editor/components/viewer-toolbar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,12 @@ const levelModeLabels: Record<string, string> = {
119119
solo: 'Solo',
120120
}
121121

122-
const wallModeOrder = ['cutaway', 'up', 'down'] as const
122+
const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const
123123
const wallModeConfig: Record<string, { icon: string; label: string }> = {
124124
up: { icon: '/icons/room.webp', label: 'Full height' },
125125
cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' },
126126
down: { icon: '/icons/walllow.webp', label: 'Low' },
127+
translucent: { icon: '/icons/wall.webp', label: 'Translucent' },
127128
}
128129

129130
const SHADING_OPTIONS = [

apps/ifc-converter/components/PreviewToolbar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Box, Grid2x2, Layers, Layers2, Maximize, ScanLine, Square } from 'lucid
1414
import { type ReactNode, useMemo } from 'react'
1515

1616
const levelModes = ['stacked', 'solo', 'exploded', 'manual'] as const
17-
const wallModes = ['up', 'cutaway', 'down'] as const
17+
const wallModes = ['up', 'cutaway', 'down', 'translucent'] as const
1818

1919
const levelLabel: Record<(typeof levelModes)[number], string> = {
2020
stacked: 'Stack',
@@ -27,6 +27,7 @@ const wallLabel: Record<(typeof wallModes)[number], string> = {
2727
up: 'Full',
2828
cutaway: 'Cutaway',
2929
down: 'Down',
30+
translucent: 'Translucent',
3031
}
3132

3233
function cycle<T>(list: readonly T[], current: T): T {

packages/core/src/material-library.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4193,7 +4193,7 @@ export function getLibraryMaterialIdFromRef(materialRef?: string | null) {
41934193
}
41944194

41954195
export function getSceneMaterialIdFromRef(materialRef?: string | null): string | null {
4196-
if (!materialRef || !materialRef.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null
4196+
if (!materialRef?.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null
41974197
return materialRef.slice(SCENE_MATERIAL_REF_PREFIX.length)
41984198
}
41994199

packages/core/src/registry/handles.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,25 @@ export type LinearResizeHandle<N> = {
182182
* the roof shell below it. Only consulted when `shape === 'tracker'`.
183183
*/
184184
trackerBaseY?: (node: N, sceneApi: SceneApi) => number
185+
/**
186+
* Stand the chevron blade up into the node's facing plane instead of
187+
* leaving it flat in the local XZ plane. For an `axis: 'x'` handle on a
188+
* wall-mounted opening (door / window), the local XZ plane is horizontal,
189+
* so the default blade is seen edge-on from the front — rotating it 90°
190+
* about its pointing axis lays it in the wall face (local XY) so it reads
191+
* face-on toward the camera. Chevron shape only; `axis: 'y'` handles are
192+
* already stood up unconditionally so this is a no-op for them.
193+
*/
194+
faceNormal?: boolean
195+
/**
196+
* Gate this arrow behind a click-to-latch cube. When set, the arrow is
197+
* hidden until the user clicks the {@link LatchHandle} cube declaring the
198+
* same `group` name; clicking the cube again hides it. Lets a node keep a
199+
* dense cluster (e.g. a dormer's window width/height arrows) collapsed
200+
* behind a single grip until the user opts in. The latch state is local to
201+
* the selection and resets when the node is deselected.
202+
*/
203+
latchGroup?: string
185204
}
186205

187206
/**
@@ -365,13 +384,33 @@ export type TranslateHandle<N = any> = {
365384
portal?: HandlePortal
366385
}
367386

387+
/**
388+
* Click-to-latch cube. Renders a small persistent cube at `placement` that
389+
* toggles the visibility of every handle tagged with the matching
390+
* {@link LinearResizeHandle.latchGroup} `group`. Clicking the cube once shows
391+
* the group's arrows; clicking again hides them. The latch state is local to
392+
* the current selection and resets on deselect.
393+
*
394+
* Mirrors the duct-fitting selection cube but driven by descriptor data so any
395+
* node can collapse a dense arrow cluster behind one grip — e.g. a dormer's
396+
* window width/height arrows latch behind a cube at the window center.
397+
*/
398+
export type LatchHandle<N = any> = {
399+
kind: 'latch'
400+
/** The `latchGroup` name whose arrows this cube reveals / hides. */
401+
group: string
402+
placement: HandlePlacement<N>
403+
portal?: HandlePortal
404+
}
405+
368406
export type HandleDescriptor<N = any> =
369407
| LinearResizeHandle<N>
370408
| RadialResizeHandle<N>
371409
| ArcResizeHandle<N>
372410
| EndpointMoveHandle<N>
373411
| TapActionHandle<N>
374412
| TranslateHandle<N>
413+
| LatchHandle<N>
375414

376415
/**
377416
* Static array, or a function for shape-dependent cases (column

packages/core/src/registry/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type {
99
HandleList,
1010
HandlePlacement,
1111
HandlePortal,
12+
LatchHandle,
1213
LinearResizeHandle,
1314
RadialResizeHandle,
1415
TapActionHandle,

packages/core/src/registry/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,6 +1584,23 @@ export type ParametricDescriptor<N> = {
15841584
* `updateNodes`.
15851585
*/
15861586
reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
1587+
/**
1588+
* Deletion companion to `reconcile`: when a node of this kind is about
1589+
* to be removed, return patches for OTHER nodes that must follow to
1590+
* undo whatever the node imposed on its neighbours — e.g. an
1591+
* auto-inserted elbow re-extends the duct runs it trimmed back onto the
1592+
* corner it replaced. Called with the node and the live scene `nodes`
1593+
* map BEFORE the deletion lands; patches targeting nodes also being
1594+
* deleted are ignored. Applied in the same `set` as the delete so it's
1595+
* one undo step. Fires only on `deleteNodes` (user-intent deletes) —
1596+
* NOT on `applyNodeChanges`, whose deletes are internal re-routes that
1597+
* rewrite neighbours explicitly in the same batch and would fight a
1598+
* restore.
1599+
*/
1600+
onDelete?: (
1601+
node: N,
1602+
nodes: Record<AnyNodeId, AnyNode>,
1603+
) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
15871604
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
15881605
/**
15891606
* Extra buttons rendered in the inspector's Actions section

packages/core/src/schema/nodes/dormer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export const DormerNode = BaseNode.extend({
9191
windowCornerRadii: z
9292
.tuple([z.number(), z.number(), z.number(), z.number()])
9393
.default(DEFAULT_CORNER_RADII),
94-
windowSill: z.boolean().default(true),
94+
windowSill: z.boolean().default(false),
9595
windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH),
9696
windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS),
9797
}).describe(

packages/core/src/schema/nodes/duct-fitting.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,21 +45,23 @@ export const DuctFittingNode = BaseNode.extend({
4545
// matching the trunk the fitting sits in. Reducers ignore the shape.
4646
// When non-round, `diameter` carries the area-equivalent round size
4747
// (drives leg lengths + advertised ports).
48-
shape: z.enum(['round', 'rect', 'oval']).default('round'),
48+
shape: z.enum(['round', 'rect', 'oval']).default('rect'),
4949
// Rect / oval run-leg profile in inches (used when shape ≠ 'round').
5050
width: z.number().min(4).max(60).default(14),
5151
height: z.number().min(3).max(40).default(8),
5252
// Tee / cross BRANCH cross-section: a round collar at `diameter2` or a
5353
// rect / oval profile matching the duct drawn off the tap. When
5454
// non-round, `diameter2` carries the branch's area-equivalent round
5555
// size. A cross's two opposed branches share this one profile.
56-
shape2: z.enum(['round', 'rect', 'oval']).default('round'),
56+
shape2: z.enum(['round', 'rect', 'oval']).default('rect'),
5757
// Rect / oval branch profile in inches (used when shape2 ≠ 'round').
5858
width2: z.number().min(4).max(60).default(14),
5959
height2: z.number().min(3).max(40).default(8),
6060
// Elbow turn angle in degrees. Residential sheet-metal elbows come in
61-
// 90° and 45°; adjustable elbows cover the range between.
62-
angle: z.number().min(15).max(90).default(90),
61+
// 90° and 45°; adjustable elbows cover the range between. 0° is a
62+
// straight coupling — what an elbow flattens to when its run is dragged
63+
// into line with the fixed collar.
64+
angle: z.number().min(0).max(90).default(90),
6365
// Tee branch angle in degrees, measured off the +X (outlet) axis: 90°
6466
// is a square straight tee, <90° a lateral whose branch sweeps
6567
// downstream toward the outlet (flow merges), >90° leans the branch
@@ -72,6 +74,7 @@ export const DuctFittingNode = BaseNode.extend({
7274
diameter2: z.number().min(2).max(48).default(6),
7375
ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'),
7476
system: z.enum(['supply', 'return']).default('supply'),
77+
slots: z.record(z.string(), z.string()).optional(),
7578
}).describe(
7679
dedent`
7780
Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs.

packages/core/src/schema/nodes/duct-segment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const DuctSegmentNode = BaseNode.extend({
5858
// Which side of the air loop this segment belongs to. Drives visual tint
5959
// and (in later slices) System graph membership.
6060
system: z.enum(['supply', 'return']).default('supply'),
61+
slots: z.record(z.string(), z.string()).optional(),
6162
}).describe(
6263
dedent`
6364
Duct segment - polyline of 3D points connected by duct sections.

0 commit comments

Comments
 (0)