Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
3731eb3
Add roof surface placement support for items
sudhir9297 May 18, 2026
ed53bc2
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
fd8e02c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
7c1e383
fixed conflict
sudhir9297 May 20, 2026
b3377da
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
f177a65
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
9af7491
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
fd27524
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 27, 2026
b516298
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 28, 2026
ebfc8ce
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 3, 2026
b7b313b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
b2ad645
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
bffdb4a
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 8, 2026
ee7b10c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 9, 2026
7d4b474
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 10, 2026
3a3318c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 13, 2026
26df69f
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 17, 2026
cee84d0
feat(duct): ceiling-snap drawing + connected-joint endpoint move
sudhir9297 Jun 17, 2026
b8e2d5f
feat(mep): detach + vertical modifiers for duct/pipe joint editing
sudhir9297 Jun 18, 2026
37d5dfa
feat(mep): full DWV pipe parity for joint editing
sudhir9297 Jun 18, 2026
810f90c
feat(mep): wall-style arrow handles for duct fittings + segments
sudhir9297 Jun 18, 2026
2cb01c4
feat(mep): click-to-latch cube handles for duct + fitting editing
sudhir9297 Jun 20, 2026
126c1f0
fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag
sudhir9297 Jun 20, 2026
682bb99
feat(mep): run-aligned duct handles, swing snapping, elbow flatten
sudhir9297 Jun 20, 2026
9a5343f
feat(mep): per-segment linesets/liquid-lines with joint-follow editing
sudhir9297 Jun 21, 2026
7892a2d
feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag
sudhir9297 Jun 21, 2026
64d8049
Add roof accessory placement guides
sudhir9297 Jun 22, 2026
7ac7c3e
Improve duct and placement routing
sudhir9297 Jun 22, 2026
4029100
Fix duct vertical movement routing
sudhir9297 Jun 22, 2026
d21ff63
Fix duct vertical offsets and roof accessory movement
sudhir9297 Jun 22, 2026
c8c8d04
Add DWV movement parity and line endpoint controls
sudhir9297 Jun 22, 2026
1cdc087
Merge branch 'main' of github.com:pascalorg/editor into fix/wed-jun-17
sudhir9297 Jun 22, 2026
b98c30a
Fix MEP handle review issues
sudhir9297 Jun 22, 2026
f70968f
Fix chimney placement and duct offset cleanup
sudhir9297 Jun 22, 2026
ba4dc2a
Use snapped targets for roof accessory commits
sudhir9297 Jun 22, 2026
a784946
fix(nodes): repair MEP movement review issues
open-pascal Jun 23, 2026
a1b0187
fix: address mep movement review issues
sudhir9297 Jun 23, 2026
dcd979a
fix: address follow-up mep review comments
sudhir9297 Jun 23, 2026
a20298f
fix: address additional mep review comments
sudhir9297 Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion apps/editor/components/build-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trap button broken icon path

Low Severity

The new Add Trap control loads /icons/dwv-pipes.png, but the app only ships dwv-pipes.webp (same path as the DWV pipe tile). The trap affordance can show a missing image while the rest of the pipe UI uses the webp asset.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a20298f. Configure here.

/>
Add Trap
</button>
</div>
) : null}

Expand Down
3 changes: 2 additions & 1 deletion apps/editor/components/viewer-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
3 changes: 2 additions & 1 deletion apps/ifc-converter/components/PreviewToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/material-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/registry/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -365,13 +384,33 @@ 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>
| ArcResizeHandle<N>
| EndpointMoveHandle<N>
| TapActionHandle<N>
| TranslateHandle<N>
| LatchHandle<N>

/**
* Static array, or a function for shape-dependent cases (column
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
HandleList,
HandlePlacement,
HandlePortal,
LatchHandle,
LinearResizeHandle,
RadialResizeHandle,
TapActionHandle,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/nodes/dormer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/schema/nodes/duct-fitting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,23 @@ 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),
// Tee / cross BRANCH cross-section: a round collar at `diameter2` or a
// 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
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/schema/nodes/duct-segment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/schema/nodes/pipe-fitting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/nodes/pipe-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export {
} from './hosting'
export {
DEFAULT_LEVEL_HEIGHT,
getCeilingAt,
getCeilingHeightAt,
getLevelHeight,
} from './level-height'
export {
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/services/level-height.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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
}
Loading
Loading