Files
editor/packages/nodes/src/lineset/definition.ts
T
Wassim SAMADandClaude Opus 4.8 2fdbcf03c3 feat(editor): MEP placement migration — Shift=cycle / mode-driven snapping
Migrate all 9 MEP kinds' placement tools onto the unified snapping model:
declare snapProfile ('item' for point-placed hvac-equipment / duct-terminal /
duct-fitting / pipe-fitting / pipe-trap; 'structural' for directional runs
duct-segment / pipe-segment / liquid-line / lineset), and replace the legacy
shiftKey-bypass reads with mode-driven isGridSnapActive / isMagneticSnapActive /
isAngleSnapActive. For runs the 45° lock becomes the cyclable 'angles' mode;
Alt stays the vertical-riser modifier (run drafting has no validity gate to
force). Port mating gated on "mode != off". Dropped stale "⇧ smooth/free" hints.

The bespoke MEP move-tool/selection (endpoint) tools stay on the legacy model —
they use setMovingNode(null) so no moving-scope context resolves yet; migrating
them needs scope-wiring first (follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:56:55 -04:00

136 lines
4.7 KiB
TypeScript

import type { NodeDefinition } from '@pascal-app/core'
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
import { buildLinesetFloorplan } from './floorplan'
import { buildLinesetGeometry } from './geometry'
import { linesetParametrics } from './parametrics'
import { LinesetNode } from './schema'
/**
* Refrigerant lineset — the copper suction + liquid pair joining a split
* system's outdoor condenser to its indoor coil. The refrigerant-side
* sibling of `duct-segment`: same polyline model and draw tool, but it
* snaps onto refrigerant service ports instead of duct collars.
*
* Composition: `def.geometry` only, plus a selection-time path-handle
* system shared in spirit with the duct segment. The framework's
* `<ParametricNodeRenderer>` mounts an empty group; `<GeometrySystem>`
* fills it via `buildLinesetGeometry` on dirty.
*/
export const linesetDefinition: NodeDefinition<typeof LinesetNode> = {
kind: 'lineset',
schemaVersion: 1,
schema: LinesetNode,
category: 'utility',
distributionRole: 'run',
// Directional run: like a wall, drafting sets a direction, so it takes the
// structural snapping context (grid / lines / angles / off) with a 45° angle
// lock available as a cyclable mode.
snapProfile: 'structural',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
path: [
[0, 0, 0],
[2, 0, 0],
],
suctionDiameter: 0.875,
liquidDiameter: 0.375,
insulated: true,
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
parametrics: linesetParametrics,
geometry: buildLinesetGeometry,
geometryKey: (n) => JSON.stringify([n.path, n.suctionDiameter, n.liquidDiameter, n.insulated]),
// Open run ends as typed refrigerant ports — directions point outward
// along the path tangent so they mate flush onto a service valve. Path
// coords are already level-local, so no transform is needed.
ports: (n) => {
if (n.path.length < 2) return []
const diameter = n.suctionDiameter
const unit = (
a: readonly [number, number, number],
b: readonly [number, number, number],
): [number, number, number] => {
const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
const len = Math.hypot(d[0], d[1], d[2])
return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len]
}
const first = n.path[0]!
const second = n.path[1]!
const last = n.path[n.path.length - 1]!
const prev = n.path[n.path.length - 2]!
return [
{
id: 'start',
position: first,
direction: unit(first, second),
diameter,
system: 'refrigerant',
},
{
id: 'end',
position: last,
direction: unit(last, prev),
diameter,
system: 'refrigerant',
},
]
},
floorplan: buildLinesetFloorplan,
// 2D selection-time path-point handles — the floor-plan twin of the 3D
// `affordanceTools.selection` handles. The builder emits an
// `endpoint-handle` per path vertex; this drags the matching point.
floorplanAffordances: {
'move-path-point': createPathPointMoveAffordance('lineset'),
},
// Selection-time path-point handles (drag to edit a committed run).
// Editor-only UI (reads gridSnapStep, renders DimensionPill), so it
// mounts via the editor's SelectionAffordanceManager — not `def.system`,
// which the viewer package mounts for the read-only route.
affordanceTools: {
selection: () => import('./selection'),
// Ghost-preview duplicate / move (the refrigerant-loop sibling of
// duct-segment's mover). Duplicate is pure drag-to-place: a translucent
// copy of the run, wrapped in a footprint bounding box, follows the
// cursor and only lands on the commit click — nothing is inserted into
// the scene before that.
move: () => import('./move-tool'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Start lineset' },
{ key: 'Click again', label: 'Place it (locked to 45°)' },
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
{ key: 'Esc', label: 'Cancel start point' },
],
presentation: {
label: 'Lineset',
description:
'Refrigerant lineset — copper suction + liquid pair joining a condenser to the indoor coil.',
icon: { kind: 'url', src: '/icons/lineset.webp' },
paletteSection: 'structure',
paletteOrder: 93,
},
mcp: {
description:
'A refrigerant lineset defined as a polyline: an insulated suction line plus a bare liquid line, joining an HVAC condenser to its indoor coil. Snaps onto refrigerant service ports.',
},
}