feat: HVAC ductwork + DWV plumbing systems (#402)
Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { nodeRegistry } from '@pascal-app/core'
|
import { nodeRegistry } from '@pascal-app/core'
|
||||||
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
|
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -30,17 +31,40 @@ type BuildToolKind =
|
|||||||
| 'shelf'
|
| 'shelf'
|
||||||
| 'spawn'
|
| 'spawn'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP"
|
||||||
|
* group tile — its own sub-grid, like Roof's "Features".
|
||||||
|
*/
|
||||||
|
type MepToolKind =
|
||||||
|
| 'duct-segment'
|
||||||
|
| 'duct-fitting'
|
||||||
|
| 'duct-terminal'
|
||||||
|
| 'hvac-equipment'
|
||||||
|
| 'lineset'
|
||||||
|
| 'liquid-line'
|
||||||
|
| 'pipe-segment'
|
||||||
|
| 'pipe-fitting'
|
||||||
|
|
||||||
type BuildType = {
|
type BuildType = {
|
||||||
/** Selection id — equals `kind` for tool types, `'painting'` for paint mode. */
|
/** Selection id — equals `kind` for tool types, `'painting'` for paint mode, `'mep'` for the MEP group. */
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
/** Raster asset tile (legacy Build sidebar artwork). */
|
||||||
iconSrc: string
|
iconSrc: string
|
||||||
/** Present for structure-tool types (absent for the paint mode). */
|
/** Present for structure-tool types (absent for paint mode and the MEP group). */
|
||||||
kind?: BuildToolKind
|
kind?: BuildToolKind
|
||||||
/** Non-placement special mode. */
|
/** Non-placement special mode. */
|
||||||
mode?: 'material-paint'
|
mode?: 'material-paint'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MepItem = {
|
||||||
|
/** Selection id — equals `kind`. */
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
iconSrc: string
|
||||||
|
kind: MepToolKind
|
||||||
|
}
|
||||||
|
|
||||||
// Same icons + ordering as the community Build sidebar, minus presets.
|
// Same icons + ordering as the community Build sidebar, minus presets.
|
||||||
const BUILD_TYPES: BuildType[] = [
|
const BUILD_TYPES: BuildType[] = [
|
||||||
{ id: 'wall', label: 'Wall', iconSrc: '/icons/wall.png', kind: 'wall' },
|
{ id: 'wall', label: 'Wall', iconSrc: '/icons/wall.png', kind: 'wall' },
|
||||||
@@ -55,14 +79,32 @@ const BUILD_TYPES: BuildType[] = [
|
|||||||
{ id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' },
|
{ id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' },
|
||||||
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.png', kind: 'shelf' },
|
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.png', kind: 'shelf' },
|
||||||
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.png', kind: 'spawn' },
|
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.png', kind: 'spawn' },
|
||||||
|
// Group tile — no tool of its own; opens the MEP sub-grid below (like Roof).
|
||||||
|
{ id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.png' },
|
||||||
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' },
|
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP
|
||||||
|
// tools had in the community Build sidebar.
|
||||||
|
const MEP_ITEMS: MepItem[] = [
|
||||||
|
{ id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.png', kind: 'duct-segment' },
|
||||||
|
{
|
||||||
|
id: 'duct-terminal',
|
||||||
|
label: 'Register',
|
||||||
|
iconSrc: '/icons/registers.png',
|
||||||
|
kind: 'duct-terminal',
|
||||||
|
},
|
||||||
|
{ id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.png', kind: 'hvac-equipment' },
|
||||||
|
{ id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.png', kind: 'lineset' },
|
||||||
|
{ id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.png', kind: 'liquid-line' },
|
||||||
|
{ id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.png', kind: 'pipe-segment' },
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
|
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
|
||||||
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
|
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
|
||||||
*/
|
*/
|
||||||
function activateBuildTool(kind: BuildToolKind): void {
|
function activateBuildTool(kind: BuildToolKind | MepToolKind): void {
|
||||||
const ed = useEditor.getState()
|
const ed = useEditor.getState()
|
||||||
ed.setPhase('structure')
|
ed.setPhase('structure')
|
||||||
ed.setStructureLayer('elements')
|
ed.setStructureLayer('elements')
|
||||||
@@ -111,10 +153,31 @@ function activateRoofFeatureTool(kind: string): void {
|
|||||||
export function BuildTab() {
|
export function BuildTab() {
|
||||||
const activeTool = useEditor((s) => s.tool)
|
const activeTool = useEditor((s) => s.tool)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
// Which build tile's panel is showing. Roof is the only tile with a panel
|
const follow = useLiquidLineToolOptions((s) => s.follow)
|
||||||
// (its Features group); others arm a tool and show nothing below.
|
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
|
||||||
|
// Which build tile's panel is showing. Roof (Features) and MEP (its tool
|
||||||
|
// sub-grid) are the tiles with a panel; others arm a tool and show nothing
|
||||||
|
// below.
|
||||||
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null)
|
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// The fitting / follow tools are armed from a segment's panel, not a grid
|
||||||
|
// tile — keep the segment tile lit so the panel (and the way back) stays
|
||||||
|
// visible.
|
||||||
|
const ductContext =
|
||||||
|
mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting')
|
||||||
|
const pipeContext =
|
||||||
|
mode === 'build' && (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting')
|
||||||
|
const liquidLineContext = mode === 'build' && activeTool === 'liquid-line'
|
||||||
|
|
||||||
|
const isMepItemActive = (item: MepItem) =>
|
||||||
|
item.kind === 'duct-segment'
|
||||||
|
? ductContext
|
||||||
|
: item.kind === 'pipe-segment'
|
||||||
|
? pipeContext
|
||||||
|
: item.kind === 'liquid-line'
|
||||||
|
? liquidLineContext
|
||||||
|
: mode === 'build' && activeTool === item.kind
|
||||||
|
|
||||||
// Read at render time (not module scope): the registry is populated by the
|
// Read at render time (not module scope): the registry is populated by the
|
||||||
// app bootstrap, so enumerating earlier would race it and see no kinds.
|
// app bootstrap, so enumerating earlier would race it and see no kinds.
|
||||||
const roofFeatures = useMemo<RoofFeature[]>(() => {
|
const roofFeatures = useMemo<RoofFeature[]>(() => {
|
||||||
@@ -141,6 +204,10 @@ export function BuildTab() {
|
|||||||
const handleTypeClick = useCallback((type: BuildType) => {
|
const handleTypeClick = useCallback((type: BuildType) => {
|
||||||
if (type.mode === 'material-paint') {
|
if (type.mode === 'material-paint') {
|
||||||
activatePaintMode()
|
activatePaintMode()
|
||||||
|
} else if (type.id === 'mep') {
|
||||||
|
// MEP is a group tile: arm its first tool so a usable tool is active
|
||||||
|
// (and we leave any prior paint mode), then reveal the MEP sub-grid.
|
||||||
|
activateBuildTool('duct-segment')
|
||||||
} else if (type.kind) {
|
} else if (type.kind) {
|
||||||
activateBuildTool(type.kind)
|
activateBuildTool(type.kind)
|
||||||
}
|
}
|
||||||
@@ -250,6 +317,137 @@ export function BuildTab() {
|
|||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
) : selectedTypeId === 'mep' ? (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
|
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">MEP</div>
|
||||||
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||||
|
<div
|
||||||
|
className="grid gap-1.5 px-0.5"
|
||||||
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
|
||||||
|
>
|
||||||
|
{MEP_ITEMS.map((item) => {
|
||||||
|
const active = isMepItemActive(item)
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.id}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'group relative flex aspect-square items-center justify-center rounded-xl transition-all duration-200',
|
||||||
|
active
|
||||||
|
? 'bg-primary/10 ring-1 ring-primary/50'
|
||||||
|
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
triggerSFX('sfx:menu-click')
|
||||||
|
activateBuildTool(item.kind)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt={item.label}
|
||||||
|
className="size-full object-contain transition-transform duration-200 group-hover:scale-110"
|
||||||
|
height={48}
|
||||||
|
src={item.iconSrc}
|
||||||
|
width={48}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="pointer-events-none" side="top">
|
||||||
|
{item.label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{ductContext ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-muted-foreground text-xs">Duct</span>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
|
||||||
|
activeTool === 'duct-fitting'
|
||||||
|
? 'bg-primary/10 ring-1 ring-primary/50'
|
||||||
|
: 'bg-muted/40 hover:bg-muted',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
triggerSFX('sfx:menu-click')
|
||||||
|
activateBuildTool(activeTool === 'duct-fitting' ? 'duct-segment' : 'duct-fitting')
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="size-4 object-contain"
|
||||||
|
height={16}
|
||||||
|
src="/icons/duct-fitting.png"
|
||||||
|
width={16}
|
||||||
|
/>
|
||||||
|
Add Fitting
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{pipeContext ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-muted-foreground text-xs">DWV Pipe</span>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
|
||||||
|
activeTool === 'pipe-fitting'
|
||||||
|
? 'bg-primary/10 ring-1 ring-primary/50'
|
||||||
|
: 'bg-muted/40 hover:bg-muted',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
triggerSFX('sfx:menu-click')
|
||||||
|
activateBuildTool(activeTool === 'pipe-fitting' ? 'pipe-segment' : 'pipe-fitting')
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="size-4 object-contain"
|
||||||
|
height={16}
|
||||||
|
src="/icons/duct-fitting.png"
|
||||||
|
width={16}
|
||||||
|
/>
|
||||||
|
Add Fitting
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{liquidLineContext ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-muted-foreground text-xs">Liquid Line</span>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
|
||||||
|
follow ? 'bg-primary/10 ring-1 ring-primary/50' : 'bg-muted/40 hover:bg-muted',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
triggerSFX('sfx:menu-click')
|
||||||
|
toggleFollow()
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>Follow lineset</span>
|
||||||
|
<span className="text-muted-foreground text-xs">{follow ? 'On' : 'Off'}</span>
|
||||||
|
</button>
|
||||||
|
<span className="px-1 text-[11px] text-muted-foreground">
|
||||||
|
{follow
|
||||||
|
? 'Click a lineset to lay the line beside it.'
|
||||||
|
: 'Trace a line alongside an existing lineset (F).'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 721 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 465 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 554 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 737 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 457 KiB |
@@ -115,6 +115,7 @@ export default function IfcConverter() {
|
|||||||
return results
|
return results
|
||||||
}, [pascalData, searchQuery])
|
}, [pascalData, searchQuery])
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: runs once on mount to load the initial file from the URL.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const requested = params.get('file')
|
const requested = params.get('file')
|
||||||
@@ -201,6 +202,7 @@ export default function IfcConverter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: stable drop handler; handleFile only calls setState setters, so a mount-time capture stays correct.
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
@@ -234,7 +236,7 @@ export default function IfcConverter() {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = fileName.replace('.ifc', '') + '_pascal.json'
|
a.download = `${fileName.replace('.ifc', '')}_pascal.json`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -11,13 +11,22 @@ import type {
|
|||||||
DoorNode,
|
DoorNode,
|
||||||
DormerNode,
|
DormerNode,
|
||||||
DownspoutNode,
|
DownspoutNode,
|
||||||
|
DuctFittingNode,
|
||||||
|
DuctSegmentNode,
|
||||||
|
DuctTerminalNode,
|
||||||
ElevatorNode,
|
ElevatorNode,
|
||||||
EyebrowVentNode,
|
EyebrowVentNode,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
GuideNode,
|
GuideNode,
|
||||||
GutterNode,
|
GutterNode,
|
||||||
|
HvacEquipmentNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
|
LinesetNode,
|
||||||
|
LiquidLineNode,
|
||||||
|
PipeFittingNode,
|
||||||
|
PipeSegmentNode,
|
||||||
|
PipeTrapNode,
|
||||||
RidgeVentNode,
|
RidgeVentNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
@@ -107,6 +116,15 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
|||||||
export type SkylightEvent = NodeEvent<SkylightNode>
|
export type SkylightEvent = NodeEvent<SkylightNode>
|
||||||
export type DormerEvent = NodeEvent<DormerNode>
|
export type DormerEvent = NodeEvent<DormerNode>
|
||||||
export type DownspoutEvent = NodeEvent<DownspoutNode>
|
export type DownspoutEvent = NodeEvent<DownspoutNode>
|
||||||
|
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
|
||||||
|
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
|
||||||
|
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
|
||||||
|
export type HvacEquipmentEvent = NodeEvent<HvacEquipmentNode>
|
||||||
|
export type PipeSegmentEvent = NodeEvent<PipeSegmentNode>
|
||||||
|
export type PipeFittingEvent = NodeEvent<PipeFittingNode>
|
||||||
|
export type PipeTrapEvent = NodeEvent<PipeTrapNode>
|
||||||
|
export type LinesetEvent = NodeEvent<LinesetNode>
|
||||||
|
export type LiquidLineEvent = NodeEvent<LiquidLineNode>
|
||||||
|
|
||||||
// Event suffixes - exported for use in hooks
|
// Event suffixes - exported for use in hooks
|
||||||
export const eventSuffixes = [
|
export const eventSuffixes = [
|
||||||
@@ -261,6 +279,15 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'skylight', SkylightEvent> &
|
NodeEvents<'skylight', SkylightEvent> &
|
||||||
NodeEvents<'dormer', DormerEvent> &
|
NodeEvents<'dormer', DormerEvent> &
|
||||||
NodeEvents<'downspout', DownspoutEvent> &
|
NodeEvents<'downspout', DownspoutEvent> &
|
||||||
|
NodeEvents<'duct-segment', DuctSegmentEvent> &
|
||||||
|
NodeEvents<'duct-fitting', DuctFittingEvent> &
|
||||||
|
NodeEvents<'duct-terminal', DuctTerminalEvent> &
|
||||||
|
NodeEvents<'hvac-equipment', HvacEquipmentEvent> &
|
||||||
|
NodeEvents<'pipe-segment', PipeSegmentEvent> &
|
||||||
|
NodeEvents<'pipe-fitting', PipeFittingEvent> &
|
||||||
|
NodeEvents<'pipe-trap', PipeTrapEvent> &
|
||||||
|
NodeEvents<'lineset', LinesetEvent> &
|
||||||
|
NodeEvents<'liquid-line', LiquidLineEvent> &
|
||||||
CameraControlEvents &
|
CameraControlEvents &
|
||||||
ToolEvents &
|
ToolEvents &
|
||||||
GuideEvents &
|
GuideEvents &
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export type {
|
|||||||
Capabilities,
|
Capabilities,
|
||||||
CapabilityCtx,
|
CapabilityCtx,
|
||||||
CuttableConfig,
|
CuttableConfig,
|
||||||
|
DistributionRole,
|
||||||
DragAction,
|
DragAction,
|
||||||
EditorCtx,
|
EditorCtx,
|
||||||
FloorPlacedConfig,
|
FloorPlacedConfig,
|
||||||
@@ -85,6 +86,7 @@ export type {
|
|||||||
MovableConfig,
|
MovableConfig,
|
||||||
NodeCategory,
|
NodeCategory,
|
||||||
NodeDefinition,
|
NodeDefinition,
|
||||||
|
NodePort,
|
||||||
NodeRegistry,
|
NodeRegistry,
|
||||||
PaintCapability,
|
PaintCapability,
|
||||||
PaintEffectiveMaterialArgs,
|
PaintEffectiveMaterialArgs,
|
||||||
|
|||||||
@@ -168,6 +168,40 @@ export type FloorplanStyle = {
|
|||||||
cursor?: string
|
cursor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── NodePort ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A typed connection point exposed by a node — the open end of a duct
|
||||||
|
// run, the collar of a fitting, the supply plenum of an air handler.
|
||||||
|
// Ports are what placement tools snap to and what a future system graph
|
||||||
|
// walks to decide connectivity.
|
||||||
|
//
|
||||||
|
// Coordinates are LEVEL-LOCAL meters — the same space duct paths and
|
||||||
|
// grid events use. Kinds whose schema stores a node transform
|
||||||
|
// (`position` / `rotation`) apply it themselves inside `def.ports` so
|
||||||
|
// consumers never need to know how a kind stores its placement.
|
||||||
|
|
||||||
|
export type NodePort = {
|
||||||
|
/** Stable identifier within the node, e.g. 'start', 'end', 'branch'. */
|
||||||
|
id: string
|
||||||
|
/** Level-local meters. */
|
||||||
|
position: readonly [number, number, number]
|
||||||
|
/** Unit vector pointing OUT of the port (away from the node body). */
|
||||||
|
direction: readonly [number, number, number]
|
||||||
|
/** Nominal connection diameter in inches. For a rect / oval port this is
|
||||||
|
* the area-equivalent round size, so a round run still mates sensibly. */
|
||||||
|
diameter: number
|
||||||
|
/** Which distribution loop the port belongs to, e.g. 'supply' | 'return'. */
|
||||||
|
system?: string
|
||||||
|
/** Cross-section of the connection. Omitted = round at `diameter`. A duct
|
||||||
|
* run joining a rect / oval port adopts this shape and rolls its
|
||||||
|
* cross-section to line up with the collar. */
|
||||||
|
shape?: 'round' | 'rect' | 'oval'
|
||||||
|
/** Rect / oval cross-section in inches: width is the collar's horizontal
|
||||||
|
* face at roll 0, height the vertical one. */
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ToolHint ────────────────────────────────────────────────────────
|
// ─── ToolHint ────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// A single key + label entry in the contextual shortcut hint panel.
|
// A single key + label entry in the contextual shortcut hint panel.
|
||||||
@@ -666,12 +700,26 @@ export type SurfaceRole =
|
|||||||
| 'glazing'
|
| 'glazing'
|
||||||
| 'furnishing'
|
| 'furnishing'
|
||||||
|
|
||||||
|
/** Role a kind plays in a duct / pipe / lineset distribution system. */
|
||||||
|
export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment'
|
||||||
|
|
||||||
export type NodeDefinition<S extends ZodObject<any>> = {
|
export type NodeDefinition<S extends ZodObject<any>> = {
|
||||||
kind: string
|
kind: string
|
||||||
schemaVersion: number
|
schemaVersion: number
|
||||||
schema: S
|
schema: S
|
||||||
category: NodeCategory
|
category: NodeCategory
|
||||||
surfaceRole?: SurfaceRole
|
surfaceRole?: SurfaceRole
|
||||||
|
/**
|
||||||
|
* Role this kind plays in a distribution system (HVAC duct / DWV pipe /
|
||||||
|
* refrigerant lineset). Lets the system-graph summary classify a
|
||||||
|
* component without branching on `node.type`:
|
||||||
|
* - `'run'` — a duct / pipe / lineset segment (carries `path`).
|
||||||
|
* - `'fitting'` — an inline fitting (elbow / tee / reducer / trap).
|
||||||
|
* - `'terminal'` — a grille / register / diffuser endpoint.
|
||||||
|
* - `'equipment'` — a furnace / air handler / condenser source.
|
||||||
|
* Kinds outside any distribution system leave this unset.
|
||||||
|
*/
|
||||||
|
distributionRole?: DistributionRole
|
||||||
|
|
||||||
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
|
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
|
||||||
migrate?: Record<number, (old: unknown) => unknown>
|
migrate?: Record<number, (old: unknown) => unknown>
|
||||||
@@ -829,6 +877,15 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
nodes: Record<AnyNodeId, AnyNode>
|
nodes: Record<AnyNodeId, AnyNode>
|
||||||
liveOverrides: Map<string, Record<string, unknown>>
|
liveOverrides: Map<string, Record<string, unknown>>
|
||||||
}) => Record<AnyNodeId, AnyNode>
|
}) => Record<AnyNodeId, AnyNode>
|
||||||
|
/**
|
||||||
|
* Typed connection points this kind exposes (duct/pipe open ends,
|
||||||
|
* fitting collars, equipment plenums). Pure function of the node —
|
||||||
|
* returns LEVEL-LOCAL positions/directions (the kind applies its own
|
||||||
|
* transform). Consumed by placement tools for port-snapping and, in a
|
||||||
|
* later slice, by the system graph for connectivity. Kinds with no
|
||||||
|
* connectable geometry omit this.
|
||||||
|
*/
|
||||||
|
ports?: (node: z.infer<S>) => NodePort[]
|
||||||
system?: SystemContribution
|
system?: SystemContribution
|
||||||
tool?: LazyComponent
|
tool?: LazyComponent
|
||||||
/**
|
/**
|
||||||
@@ -915,6 +972,14 @@ export type KeyboardActions = {
|
|||||||
r?: KeyboardAction
|
r?: KeyboardAction
|
||||||
/** T / Shift+T secondary action. */
|
/** T / Shift+T secondary action. */
|
||||||
t?: KeyboardAction
|
t?: KeyboardAction
|
||||||
|
/**
|
||||||
|
* Set for kinds whose R/T rotation turns around a user-cyclable world
|
||||||
|
* axis (Alt cycles Y → X → Z) — duct / pipe fittings with full 3D
|
||||||
|
* orientation. The floating action menu reads this to surface the
|
||||||
|
* active-axis pill above the selected node; kinds with plain Y-only
|
||||||
|
* rotation omit it.
|
||||||
|
*/
|
||||||
|
axisCycling?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KeyboardAction = {
|
export type KeyboardAction = {
|
||||||
@@ -1279,6 +1344,31 @@ export type CapabilityCtx = { node: AnyNode }
|
|||||||
export type MovableConfig = {
|
export type MovableConfig = {
|
||||||
axes: ReadonlyArray<'x' | 'y' | 'z'>
|
axes: ReadonlyArray<'x' | 'y' | 'z'>
|
||||||
gridSnap?: boolean
|
gridSnap?: boolean
|
||||||
|
/**
|
||||||
|
* Pin the dragged node to the cursor (absolute placement) instead of the
|
||||||
|
* default offset-preserving drag, where the node moves by the cursor's
|
||||||
|
* delta from where the drag started. Offset preservation suits large
|
||||||
|
* furniture you grab by an edge; small connector-like kinds (duct
|
||||||
|
* fittings) read as "lagging behind the mouse" — they want the cursor.
|
||||||
|
*/
|
||||||
|
cursorAttached?: boolean
|
||||||
|
/**
|
||||||
|
* Magnetically snap one of this kind's own ports onto a nearby scene
|
||||||
|
* port while dragging — e.g. a register's collar onto a duct run end.
|
||||||
|
* The dragged node shifts in XZ so its closest matching port lands on
|
||||||
|
* the target port. Alt bypasses the snap. Kinds without `def.ports`
|
||||||
|
* can't use this. Snap takes precedence over grid / alignment snap.
|
||||||
|
*/
|
||||||
|
portSnap?: {
|
||||||
|
/**
|
||||||
|
* Distribution loops a target port must belong to (e.g.
|
||||||
|
* `['supply', 'return']`). A target port with no `system` always
|
||||||
|
* matches. Omit to match every port.
|
||||||
|
*/
|
||||||
|
systems?: readonly string[]
|
||||||
|
/** Snap radius in meters (XZ). Defaults to 0.5. */
|
||||||
|
radius?: number
|
||||||
|
}
|
||||||
override?: (ctx: CapabilityCtx) => MovableConfig | null
|
override?: (ctx: CapabilityCtx) => MovableConfig | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1411,7 +1501,24 @@ export type Relations = {
|
|||||||
export type ParametricDescriptor<N> = {
|
export type ParametricDescriptor<N> = {
|
||||||
groups: ParamGroup<N>[]
|
groups: ParamGroup<N>[]
|
||||||
invariants?: ReadonlyArray<(n: N) => Issue[]>
|
invariants?: ReadonlyArray<(n: N) => Issue[]>
|
||||||
derive?: (n: N) => Partial<N>
|
/**
|
||||||
|
* Co-update hook for fields that must stay consistent when edited
|
||||||
|
* from the inspector. Called with the node AFTER `patch` is merged
|
||||||
|
* plus the patch itself (so the hook can tell which field the user
|
||||||
|
* touched); whatever it returns is folded into the same update.
|
||||||
|
* Direct store/MCP writes bypass it — keep real invariants in
|
||||||
|
* `invariants`.
|
||||||
|
*/
|
||||||
|
derive?: (next: N, patch: Partial<N>) => Partial<N>
|
||||||
|
/**
|
||||||
|
* Cross-node companion to `derive`: after an inspector edit lands on
|
||||||
|
* this node, return patches for OTHER nodes that must follow to keep
|
||||||
|
* the scene consistent — e.g. duct runs re-trimmed onto a resized
|
||||||
|
* fitting's collars. `prev` is the node before the edit, `next` after
|
||||||
|
* (with `derive` already folded in). Applied in the same gesture via
|
||||||
|
* `updateNodes`.
|
||||||
|
*/
|
||||||
|
reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
|
||||||
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
|
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
|
||||||
/**
|
/**
|
||||||
* Extra buttons rendered in the inspector's Actions section
|
* Extra buttons rendered in the inspector's Actions section
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export {
|
|||||||
getEffectiveDormerSurfaceMaterial,
|
getEffectiveDormerSurfaceMaterial,
|
||||||
} from './nodes/dormer'
|
} from './nodes/dormer'
|
||||||
export { DownspoutNode } from './nodes/downspout'
|
export { DownspoutNode } from './nodes/downspout'
|
||||||
|
export { DuctFittingNode } from './nodes/duct-fitting'
|
||||||
|
export { DuctSegmentNode } from './nodes/duct-segment'
|
||||||
|
export { DuctTerminalNode } from './nodes/duct-terminal'
|
||||||
export {
|
export {
|
||||||
ElevatorDoorPanelStyle,
|
ElevatorDoorPanelStyle,
|
||||||
ElevatorDoorStyle,
|
ElevatorDoorStyle,
|
||||||
@@ -69,6 +72,7 @@ export { EyebrowVentNode } from './nodes/eyebrow-vent'
|
|||||||
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||||
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
||||||
export { GutterNode, GutterOutlet } from './nodes/gutter'
|
export { GutterNode, GutterOutlet } from './nodes/gutter'
|
||||||
|
export { HvacEquipmentNode } from './nodes/hvac-equipment'
|
||||||
export type {
|
export type {
|
||||||
AnimationEffect,
|
AnimationEffect,
|
||||||
Asset,
|
Asset,
|
||||||
@@ -88,6 +92,11 @@ export {
|
|||||||
LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT,
|
LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT,
|
||||||
} from './nodes/item'
|
} from './nodes/item'
|
||||||
export { LevelNode } from './nodes/level'
|
export { LevelNode } from './nodes/level'
|
||||||
|
export { LinesetNode } from './nodes/lineset'
|
||||||
|
export { LiquidLineNode } from './nodes/liquid-line'
|
||||||
|
export { PipeFittingNode } from './nodes/pipe-fitting'
|
||||||
|
export { PipeSegmentNode } from './nodes/pipe-segment'
|
||||||
|
export { PipeTrapNode } from './nodes/pipe-trap'
|
||||||
// Nodes
|
// Nodes
|
||||||
export { RidgeVentNode } from './nodes/ridge-vent'
|
export { RidgeVentNode } from './nodes/ridge-vent'
|
||||||
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
|
export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof'
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duct fitting — the junction pieces that connect round duct segments:
|
||||||
|
* elbows (direction change), tees (branch takeoff), reducers (diameter
|
||||||
|
* transition).
|
||||||
|
*
|
||||||
|
* Phase 2 of the HVAC node system. Fittings are the first kind to expose
|
||||||
|
* typed ports (`def.ports`) — placement tools snap duct endpoints onto a
|
||||||
|
* fitting's collars, and the future system graph walks ports to decide
|
||||||
|
* connectivity.
|
||||||
|
*
|
||||||
|
* `position` is level-local meters; `rotation` is an XYZ euler in radians
|
||||||
|
* so a fitting can turn a horizontal run vertical (riser elbows).
|
||||||
|
*
|
||||||
|
* Local-frame conventions (before `rotation` is applied):
|
||||||
|
* - elbow: inlet faces -X, outlet turned by `angle` degrees in the
|
||||||
|
* XZ plane (90° → +Z).
|
||||||
|
* - tee: run along the X axis (ports face -X and +X), branch
|
||||||
|
* collar at `branchAngle`° from the +X (outlet) axis in the
|
||||||
|
* XZ plane — 90° a square straight tee, <90° a lateral
|
||||||
|
* leaning downstream toward the outlet, >90° leaning upstream
|
||||||
|
* toward the inlet — sized at `diameter2`.
|
||||||
|
* - cross: four-way junction — run along the X axis (ports face -X
|
||||||
|
* and +X) at the run profile, two opposed branches square to
|
||||||
|
* the run along ±Z (branch faces +Z, branch2 faces -Z) at the
|
||||||
|
* branch profile (`shape2` / `diameter2`).
|
||||||
|
* - reducer: inlet at `diameter` faces -X, outlet at `diameter2`
|
||||||
|
* faces +X.
|
||||||
|
* - transition: square-to-round — rect end at `width` × `height` faces
|
||||||
|
* -X, round end at `diameter2` faces +X. `diameter` carries
|
||||||
|
* the rect end's area-equivalent round size.
|
||||||
|
*/
|
||||||
|
export const DuctFittingNode = BaseNode.extend({
|
||||||
|
id: objectId('duct-fitting'),
|
||||||
|
type: nodeType('duct-fitting'),
|
||||||
|
// Level-local meters.
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
// XYZ euler radians.
|
||||||
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
fittingType: z.enum(['elbow', 'tee', 'cross', 'reducer', 'transition']).default('elbow'),
|
||||||
|
// Run-leg cross-section: round collars, or a rect / flat-oval profile
|
||||||
|
// 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'),
|
||||||
|
// 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'),
|
||||||
|
// 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),
|
||||||
|
// 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
|
||||||
|
// upstream toward the inlet. Ignored by every other fitting type.
|
||||||
|
branchAngle: z.number().min(45).max(135).default(90),
|
||||||
|
// Main (run/inlet) nominal diameter in inches.
|
||||||
|
diameter: z.number().min(2).max(48).default(6),
|
||||||
|
// Secondary diameter in inches — tee branch collar, reducer outlet.
|
||||||
|
// Ignored by elbows.
|
||||||
|
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'),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs.
|
||||||
|
- position: [x, y, z] level-local meters
|
||||||
|
- rotation: [x, y, z] euler radians
|
||||||
|
- fittingType: elbow | tee | cross | reducer | transition (rect end -X, round end +X)
|
||||||
|
- shape: round | rect | oval run legs (matches the trunk; ignored by reducer / transition)
|
||||||
|
- width / height: rect / oval run-leg profile in inches (transition: the rect end)
|
||||||
|
- shape2: round | rect | oval tee / cross branch (matches the duct drawn off the tap)
|
||||||
|
- width2 / height2: rect / oval branch profile in inches
|
||||||
|
- angle: elbow turn in degrees (45 or 90 typical)
|
||||||
|
- branchAngle: tee branch angle off the outlet axis (90 straight tee, 45 downstream lateral, 135 upstream); cross branches are always square
|
||||||
|
- diameter: main nominal diameter in inches
|
||||||
|
- diameter2: tee / cross branch / reducer outlet / transition round-end diameter in inches
|
||||||
|
- ductMaterial: sheet-metal | flex | duct-board
|
||||||
|
- system: supply | return
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
export type DuctFittingNode = z.infer<typeof DuctFittingNode>
|
||||||
|
export type DuctFittingNodeId = DuctFittingNode['id']
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round duct segment — a polyline of 3D points connected by cylindrical
|
||||||
|
* duct sections. Forced-air HVAC supply/return runs in US residential.
|
||||||
|
*
|
||||||
|
* Phase 1 of the HVAC node system: just the geometry primitive. Fittings,
|
||||||
|
* terminals, equipment, and typed ports come in later slices.
|
||||||
|
*
|
||||||
|
* Path coordinates are level-local meters: [x, y, z] tuples. y is height
|
||||||
|
* above the level floor. A duct hung at ceiling height through three points
|
||||||
|
* is e.g. `[[0, 2.6, 0], [3, 2.6, 0], [3, 2.6, 4]]`.
|
||||||
|
*
|
||||||
|
* Diameters are nominal US round-duct sizes in inches; the geometry
|
||||||
|
* builder converts to meters for the cylinder radius.
|
||||||
|
*/
|
||||||
|
export const DuctSegmentNode = BaseNode.extend({
|
||||||
|
id: objectId('duct-segment'),
|
||||||
|
type: nodeType('duct-segment'),
|
||||||
|
// Polyline path in level-local meters. Minimum two points (start, end).
|
||||||
|
path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2),
|
||||||
|
// Cross-section. Round is the branch default; rect is the trunk /
|
||||||
|
// plenum profile (real US systems: rect trunk, round branches); oval
|
||||||
|
// is the flat-oval profile (two semicircles of the duct height joined
|
||||||
|
// by flat sides) used where round won't fit a joist bay.
|
||||||
|
shape: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||||
|
// Nominal inner diameter in inches (round shape). Common residential
|
||||||
|
// sizes 4"–14"; we accept any positive number so the inspector slider
|
||||||
|
// stays ergonomic and larger commercial sizes load without a schema bump.
|
||||||
|
diameter: z.number().min(2).max(48).default(6),
|
||||||
|
// Rect / oval cross-section in inches: width is the horizontal face,
|
||||||
|
// height the vertical. Typical residential trunks 12×8 – 24×10. For
|
||||||
|
// oval, height is also the end-cap semicircle diameter (width ≥ height).
|
||||||
|
width: z.number().min(4).max(60).default(14),
|
||||||
|
height: z.number().min(3).max(40).default(8),
|
||||||
|
// Cross-section roll (radians) about the run direction. 0 = width
|
||||||
|
// horizontal / height vertical (the natural orientation the geometry
|
||||||
|
// derives from direction). Non-zero only on a rect riser turned out of
|
||||||
|
// the horizontal plane, so its profile stays continuous through the
|
||||||
|
// elbow it left instead of snapping to the world-axis fallback.
|
||||||
|
roll: z.number().default(0),
|
||||||
|
// Construction material. Spiral is round rigid sheet metal with the
|
||||||
|
// helical lock seam drawn on the body (round shape only — rect / oval
|
||||||
|
// runs render it as plain sheet metal).
|
||||||
|
ductMaterial: z.enum(['sheet-metal', 'spiral', 'flex', 'duct-board']).default('flex'),
|
||||||
|
// Whether to draw the construction body detail (spiral lock seam /
|
||||||
|
// flex wire corrugation) on round runs. Off renders a smooth body —
|
||||||
|
// lighter on the eyes and the GPU in dense scenes.
|
||||||
|
seamDetail: z.boolean().default(false),
|
||||||
|
// Whether the run wears its external insulation wrap (drawn as a
|
||||||
|
// translucent shell). Off by default — bare duct.
|
||||||
|
insulated: z.boolean().default(false),
|
||||||
|
// External insulation R-value (used when insulated). Common flex-duct
|
||||||
|
// values are R-4.2, R-6, R-8.
|
||||||
|
insulationR: z.number().min(0).max(12).default(0.5),
|
||||||
|
// 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'),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Duct segment - polyline of 3D points connected by duct sections.
|
||||||
|
- path: list of [x, y, z] points in level-local meters (min 2)
|
||||||
|
- shape: round (branches) | rect (trunks / plenums) | oval (flat-oval, tight joist bays)
|
||||||
|
- diameter: nominal inner diameter in inches for round (typ. 4-14 residential)
|
||||||
|
- width / height: rect / oval cross-section in inches (typ. 12x8 - 24x10 trunks)
|
||||||
|
- roll: cross-section roll in radians (0 = upright; set on risers to stay continuous through their elbow)
|
||||||
|
- ductMaterial: sheet-metal | spiral (round rigid, helical seam) | flex | duct-board
|
||||||
|
- seamDetail: draw the spiral seam / flex corrugation on round runs (default off)
|
||||||
|
- insulated: whether the run wears its external insulation wrap (default off)
|
||||||
|
- insulationR: external insulation R-value when insulated (4, 6, 8 typical)
|
||||||
|
- system: supply | return (drives visual tint)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
export type DuctSegmentNode = z.infer<typeof DuctSegmentNode>
|
||||||
|
export type DuctSegmentNodeId = DuctSegmentNode['id']
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duct terminal — where the air loop meets the room: supply registers,
|
||||||
|
* ceiling diffusers, return grilles.
|
||||||
|
*
|
||||||
|
* Phase 3 of the HVAC node system. Each terminal exposes a single typed
|
||||||
|
* port at its collar (behind/above/below the face depending on mount),
|
||||||
|
* so duct runs end onto it like any other port.
|
||||||
|
*
|
||||||
|
* `position` is the center of the visible face in level-local meters —
|
||||||
|
* floor registers at y≈0, ceiling diffusers at ceiling height, wall
|
||||||
|
* registers at their height on the wall. `rotation` is yaw radians.
|
||||||
|
*/
|
||||||
|
export const DuctTerminalNode = BaseNode.extend({
|
||||||
|
id: objectId('duct-terminal'),
|
||||||
|
type: nodeType('duct-terminal'),
|
||||||
|
// Level-local meters — center of the face.
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
// Yaw in radians.
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'),
|
||||||
|
// Which surface the terminal mounts on. Drives face orientation and
|
||||||
|
// which way the collar (and its port) points.
|
||||||
|
mount: z.enum(['floor', 'ceiling', 'wall']).default('floor'),
|
||||||
|
// Face dimensions in meters. Typical floor register ~0.30 × 0.15;
|
||||||
|
// ceiling diffusers are square (0.6 × 0.6); return grilles run large.
|
||||||
|
width: z.number().min(0.1).max(1.5).default(0.3),
|
||||||
|
depth: z.number().min(0.05).max(1.5).default(0.15),
|
||||||
|
// Collar cross-section on the duct side. Round is the default; rect and
|
||||||
|
// oval (flat-oval) match the duct shapes a run might end with.
|
||||||
|
collarShape: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||||
|
// Round collar diameter in inches on the duct side.
|
||||||
|
collarDiameter: z.number().min(4).max(20).default(6),
|
||||||
|
// Rect / oval collar cross-section in inches: width is the horizontal
|
||||||
|
// face, height the vertical. For oval, height is also the end-cap
|
||||||
|
// semicircle diameter (width ≥ height).
|
||||||
|
collarWidth: z.number().min(4).max(20).default(10),
|
||||||
|
collarHeight: z.number().min(3).max(20).default(6),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Duct terminal - supply register, ceiling diffuser, or return grille.
|
||||||
|
- position: [x, y, z] level-local meters, center of the face
|
||||||
|
- rotation: yaw radians
|
||||||
|
- terminalType: supply-register | diffuser | return-grille (grille = return side)
|
||||||
|
- mount: floor | ceiling | wall - face orientation + collar direction
|
||||||
|
- width / depth: face size in meters
|
||||||
|
- collarShape: round | rect | oval - duct-side collar cross-section
|
||||||
|
- collarDiameter: round collar diameter in inches
|
||||||
|
- collarWidth / collarHeight: rect / oval collar cross-section in inches
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
export type DuctTerminalNode = z.infer<typeof DuctTerminalNode>
|
||||||
|
export type DuctTerminalNodeId = DuctTerminalNode['id']
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HVAC equipment — the boxes duct systems start and end at: furnace,
|
||||||
|
* air handler, outdoor condenser.
|
||||||
|
*
|
||||||
|
* Phase 3 of the HVAC node system. Furnaces and air handlers expose
|
||||||
|
* typed duct ports (supply plenum on top, return drop on the side) so
|
||||||
|
* duct runs and fittings snap onto them. Every unit also exposes a
|
||||||
|
* refrigerant service port on its valve face — a condenser, the outdoor
|
||||||
|
* half of a split system, carries no duct ports but pipes to the indoor
|
||||||
|
* coil through a `lineset` run mating onto that port.
|
||||||
|
*
|
||||||
|
* Floor-placed: `position` is level-local meters with y at the base,
|
||||||
|
* `rotation` is yaw radians (the editor's default R-rotate applies).
|
||||||
|
*/
|
||||||
|
export const HvacEquipmentNode = BaseNode.extend({
|
||||||
|
id: objectId('hvac-equipment'),
|
||||||
|
type: nodeType('hvac-equipment'),
|
||||||
|
// Level-local meters, y at the unit's base.
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
// Yaw in radians.
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'),
|
||||||
|
// Cabinet dimensions in meters. Defaults match a typical upflow
|
||||||
|
// furnace cabinet (~22" × 28" footprint, ~43" tall).
|
||||||
|
width: z.number().min(0.3).max(2).default(0.56),
|
||||||
|
depth: z.number().min(0.3).max(2).default(0.71),
|
||||||
|
height: z.number().min(0.4).max(2.5).default(1.1),
|
||||||
|
// Duct collar cross-section on the supply / return connections. Round is
|
||||||
|
// the default; rect and oval (flat-oval) match the duct shapes a run
|
||||||
|
// might mate with. Condensers carry no duct collars (ignored).
|
||||||
|
supplyShape: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||||
|
returnShape: z.enum(['round', 'rect', 'oval']).default('round'),
|
||||||
|
// Round collar diameters in inches.
|
||||||
|
supplyDiameter: z.number().min(6).max(30).default(8),
|
||||||
|
returnDiameter: z.number().min(6).max(30).default(8),
|
||||||
|
// Rect / oval collar cross-section in inches: width is the horizontal
|
||||||
|
// face, height the vertical. For oval, height is also the end-cap
|
||||||
|
// semicircle diameter (width ≥ height).
|
||||||
|
supplyWidth: z.number().min(6).max(30).default(12),
|
||||||
|
supplyHeight: z.number().min(6).max(30).default(8),
|
||||||
|
returnWidth: z.number().min(6).max(30).default(14),
|
||||||
|
returnHeight: z.number().min(6).max(30).default(8),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
HVAC equipment cabinet - furnace, air handler, or outdoor condenser.
|
||||||
|
- position: [x, y, z] level-local meters (y = base)
|
||||||
|
- rotation: yaw radians
|
||||||
|
- equipmentType: furnace | air-handler | condenser
|
||||||
|
- width / depth / height: cabinet size in meters
|
||||||
|
- supplyShape / returnShape: round | rect | oval duct collar cross-section (ignored by condenser)
|
||||||
|
- supplyDiameter / returnDiameter: round collar sizes in inches
|
||||||
|
- supplyWidth / supplyHeight / returnWidth / returnHeight: rect / oval collar cross-section in inches
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
export type HvacEquipmentNode = z.infer<typeof HvacEquipmentNode>
|
||||||
|
export type HvacEquipmentNodeId = HvacEquipmentNode['id']
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refrigerant lineset — the copper pipe pair that links the outdoor
|
||||||
|
* condenser to the indoor coil (furnace / air handler) of a split system.
|
||||||
|
* It is the refrigerant-side analogue of a duct run: a polyline of points,
|
||||||
|
* but carrying two lines instead of one airway.
|
||||||
|
*
|
||||||
|
* Real linesets run a fat insulated SUCTION line (cool vapour back to the
|
||||||
|
* compressor) beside a thin bare LIQUID line (warm liquid out to the coil).
|
||||||
|
* The geometry builder draws a single copper line on the path centerline
|
||||||
|
* (sized to `suctionDiameter`, wrapped in a foam jacket when `insulated`);
|
||||||
|
* draw the liquid line as a second lineset rather than both off one path.
|
||||||
|
*
|
||||||
|
* Path coordinates are level-local meters: [x, y, z] tuples, same space as
|
||||||
|
* duct paths and grid events. Diameters are nominal copper OD in inches.
|
||||||
|
*/
|
||||||
|
export const LinesetNode = BaseNode.extend({
|
||||||
|
id: objectId('lineset'),
|
||||||
|
type: nodeType('lineset'),
|
||||||
|
// Polyline path in level-local meters. Minimum two points (start, end).
|
||||||
|
path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2),
|
||||||
|
// Nominal suction-line copper OD in inches (the large insulated line).
|
||||||
|
// Common residential sizes are 3/4"–1-1/8".
|
||||||
|
suctionDiameter: z.number().min(0.25).max(2).default(0.875),
|
||||||
|
// Nominal liquid-line copper OD in inches (the small bare line).
|
||||||
|
// Common residential sizes are 1/4"–3/8".
|
||||||
|
liquidDiameter: z.number().min(0.125).max(1).default(0.375),
|
||||||
|
// Whether the suction line carries its foam insulation jacket. Bare = false.
|
||||||
|
insulated: z.boolean().default(true),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Refrigerant lineset - copper suction + liquid pair linking a condenser to an indoor coil.
|
||||||
|
- path: list of [x, y, z] points in level-local meters (min 2)
|
||||||
|
- suctionDiameter: nominal copper OD in inches of the large insulated line (typ. 3/4"-1-1/8")
|
||||||
|
- liquidDiameter: nominal copper OD in inches of the small bare line (typ. 1/4"-3/8")
|
||||||
|
- insulated: whether the suction line wears its foam jacket
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
export type LinesetNode = z.infer<typeof LinesetNode>
|
||||||
|
export type LinesetNodeId = LinesetNode['id']
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone refrigerant liquid line — the thin bare-copper line that carries
|
||||||
|
* warm liquid out to the indoor coil. It is the line that used to be drawn as
|
||||||
|
* the lineset's second rail; broken out here as its own polyline run so it can
|
||||||
|
* be drawn on its own, including traced alongside an existing lineset.
|
||||||
|
*
|
||||||
|
* Path coordinates are level-local meters: [x, y, z] tuples, the same space as
|
||||||
|
* lineset and duct paths. Diameter is nominal copper OD in inches.
|
||||||
|
*/
|
||||||
|
export const LiquidLineNode = BaseNode.extend({
|
||||||
|
id: objectId('liquid-line'),
|
||||||
|
type: nodeType('liquid-line'),
|
||||||
|
// Polyline path in level-local meters. Minimum two points (start, end).
|
||||||
|
path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2),
|
||||||
|
// Nominal copper OD in inches. Common residential sizes are 1/4"–3/8".
|
||||||
|
diameter: z.number().min(0.125).max(1).default(0.375),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Standalone refrigerant liquid line - a thin bare-copper polyline run.
|
||||||
|
- path: list of [x, y, z] points in level-local meters (min 2)
|
||||||
|
- diameter: nominal copper OD in inches (typ. 1/4"-3/8")
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
export type LiquidLineNode = z.infer<typeof LiquidLineNode>
|
||||||
|
export type LiquidLineNodeId = LiquidLineNode['id']
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DWV pipe fitting — the joints drain systems are actually built from:
|
||||||
|
* elbows (bends), wyes (45° branch entries, the code-preferred way to
|
||||||
|
* join horizontal drains), sanitary tees (square branch entries), and
|
||||||
|
* crosses (two opposed branches where a run passes straight through).
|
||||||
|
*
|
||||||
|
* Local-frame conventions (before `rotation`):
|
||||||
|
* - elbow: inlet faces -X, outlet turned `angle`° in XZ.
|
||||||
|
* - wye: run along X (inlet -X, outlet +X), branch collar at
|
||||||
|
* 45° between +X and +Z.
|
||||||
|
* - sanitary-tee: run along X, branch collar faces +Z.
|
||||||
|
* - cross: run along X, two opposed branch collars on ±Z.
|
||||||
|
*/
|
||||||
|
export const PipeFittingNode = BaseNode.extend({
|
||||||
|
id: objectId('pipe-fitting'),
|
||||||
|
type: nodeType('pipe-fitting'),
|
||||||
|
// Level-local meters.
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
// XYZ euler radians.
|
||||||
|
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),
|
||||||
|
// Run nominal size in inches.
|
||||||
|
diameter: z.number().min(1.25).max(8).default(2),
|
||||||
|
// Branch collar size (wye / sanitary-tee).
|
||||||
|
diameter2: z.number().min(1.25).max(8).default(2),
|
||||||
|
pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'),
|
||||||
|
system: z.enum(['waste', 'vent']).default('waste'),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
DWV pipe fitting - elbow (bend), wye (45° branch), sanitary tee (square branch), or cross (two opposed branches).
|
||||||
|
- position: [x, y, z] level-local meters
|
||||||
|
- rotation: [x, y, z] euler radians
|
||||||
|
- fittingType: elbow | wye | sanitary-tee | cross
|
||||||
|
- angle: elbow turn in degrees (22.5 / 45 / 90 typical)
|
||||||
|
- diameter: run size in inches; diameter2: branch collar size (both branches for a cross)
|
||||||
|
- pipeMaterial: pvc | abs | cast-iron
|
||||||
|
- system: waste | vent
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
export type PipeFittingNode = z.infer<typeof PipeFittingNode>
|
||||||
|
export type PipeFittingNodeId = PipeFittingNode['id']
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DWV pipe segment — drain / waste / vent runs in US residential
|
||||||
|
* plumbing. Phase 2 of the distribution-system effort: the plumbing
|
||||||
|
* sibling of `duct-segment`, sharing the polyline model and the typed
|
||||||
|
* port machinery.
|
||||||
|
*
|
||||||
|
* The defining difference from ducts is SLOPE: drains must fall
|
||||||
|
* (IPC: ¼" per foot for pipes under 3", ⅛" allowed at 3"+). Slope is
|
||||||
|
* stored implicitly in the path's Y coordinates — the draw tool drops
|
||||||
|
* Y as you draw a waste run; vents run level or vertical.
|
||||||
|
*
|
||||||
|
* Path coordinates are level-local meters. Y may be negative (drains
|
||||||
|
* drop below the floor into the joist / crawl space).
|
||||||
|
*/
|
||||||
|
export const PipeSegmentNode = BaseNode.extend({
|
||||||
|
id: objectId('pipe-segment'),
|
||||||
|
type: nodeType('pipe-segment'),
|
||||||
|
// Polyline path in level-local meters. Minimum two points.
|
||||||
|
path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2),
|
||||||
|
// Nominal pipe size in inches. Residential DWV: 1¼ (lav tailpiece) to
|
||||||
|
// 4 (building drain); 6 covers oversized mains.
|
||||||
|
diameter: z.number().min(1.25).max(8).default(2),
|
||||||
|
pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'),
|
||||||
|
// Which DWV role the run plays. Waste carries water (sloped); vent
|
||||||
|
// carries air (level or vertical, dashed in plan).
|
||||||
|
system: z.enum(['waste', 'vent']).default('waste'),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
DWV pipe segment - drain / waste / vent run as a polyline of 3D points.
|
||||||
|
- path: list of [x, y, z] points in level-local meters (min 2; y may go below the floor)
|
||||||
|
- diameter: nominal size in inches (1.5 / 2 / 3 / 4 typical residential)
|
||||||
|
- pipeMaterial: pvc | abs | cast-iron
|
||||||
|
- system: waste (sloped drains) | vent (level / vertical air pipes)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
export type PipeSegmentNode = z.infer<typeof PipeSegmentNode>
|
||||||
|
export type PipeSegmentNodeId = PipeSegmentNode['id']
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DWV trap — the P-trap between a fixture and the waste system. Holds a
|
||||||
|
* water seal that blocks sewer gas; every drained fixture has exactly
|
||||||
|
* one. Modeled as an explicit fitting (not folded into the fixture) so
|
||||||
|
* the trap-arm rule (IPC 909.1 max developed length to the vent) has a
|
||||||
|
* node to attach to and the inspector can edit size + arm length.
|
||||||
|
*
|
||||||
|
* Local-frame convention (before `rotation`): inlet faces +Y (up, to
|
||||||
|
* the fixture tailpiece), outlet faces +X (the horizontal trap arm
|
||||||
|
* toward the vented waste line).
|
||||||
|
*/
|
||||||
|
export const PipeTrapNode = BaseNode.extend({
|
||||||
|
id: objectId('pipe-trap'),
|
||||||
|
type: nodeType('pipe-trap'),
|
||||||
|
// Level-local meters.
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
// 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),
|
||||||
|
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
|
||||||
|
// inspector. Drives the IPC 909.1 max-trap-arm check.
|
||||||
|
armLengthM: z.number().min(0).default(0),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
DWV trap (P-trap) - the water-seal fitting between a fixture and the waste line.
|
||||||
|
- position: [x, y, z] level-local meters
|
||||||
|
- rotation: yaw radians (trap-arm direction in plan)
|
||||||
|
- diameter: trap size in inches (matches the fixture drain)
|
||||||
|
- pipeMaterial: pvc | abs | cast-iron
|
||||||
|
- armLengthM: developed length from trap to vent in meters (IPC 909.1 limited by size)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
export type PipeTrapNode = z.infer<typeof PipeTrapNode>
|
||||||
|
export type PipeTrapNodeId = PipeTrapNode['id']
|
||||||
@@ -8,13 +8,22 @@ import { CupolaNode } from './nodes/cupola'
|
|||||||
import { DoorNode } from './nodes/door'
|
import { DoorNode } from './nodes/door'
|
||||||
import { DormerNode } from './nodes/dormer'
|
import { DormerNode } from './nodes/dormer'
|
||||||
import { DownspoutNode } from './nodes/downspout'
|
import { DownspoutNode } from './nodes/downspout'
|
||||||
|
import { DuctFittingNode } from './nodes/duct-fitting'
|
||||||
|
import { DuctSegmentNode } from './nodes/duct-segment'
|
||||||
|
import { DuctTerminalNode } from './nodes/duct-terminal'
|
||||||
import { ElevatorNode } from './nodes/elevator'
|
import { ElevatorNode } from './nodes/elevator'
|
||||||
import { EyebrowVentNode } from './nodes/eyebrow-vent'
|
import { EyebrowVentNode } from './nodes/eyebrow-vent'
|
||||||
import { FenceNode } from './nodes/fence'
|
import { FenceNode } from './nodes/fence'
|
||||||
import { GuideNode } from './nodes/guide'
|
import { GuideNode } from './nodes/guide'
|
||||||
import { GutterNode } from './nodes/gutter'
|
import { GutterNode } from './nodes/gutter'
|
||||||
|
import { HvacEquipmentNode } from './nodes/hvac-equipment'
|
||||||
import { ItemNode } from './nodes/item'
|
import { ItemNode } from './nodes/item'
|
||||||
import { LevelNode } from './nodes/level'
|
import { LevelNode } from './nodes/level'
|
||||||
|
import { LinesetNode } from './nodes/lineset'
|
||||||
|
import { LiquidLineNode } from './nodes/liquid-line'
|
||||||
|
import { PipeFittingNode } from './nodes/pipe-fitting'
|
||||||
|
import { PipeSegmentNode } from './nodes/pipe-segment'
|
||||||
|
import { PipeTrapNode } from './nodes/pipe-trap'
|
||||||
import { RidgeVentNode } from './nodes/ridge-vent'
|
import { RidgeVentNode } from './nodes/ridge-vent'
|
||||||
import { RoofNode } from './nodes/roof'
|
import { RoofNode } from './nodes/roof'
|
||||||
import { RoofSegmentNode } from './nodes/roof-segment'
|
import { RoofSegmentNode } from './nodes/roof-segment'
|
||||||
@@ -65,6 +74,15 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
SkylightNode,
|
SkylightNode,
|
||||||
DormerNode,
|
DormerNode,
|
||||||
DownspoutNode,
|
DownspoutNode,
|
||||||
|
DuctSegmentNode,
|
||||||
|
DuctFittingNode,
|
||||||
|
DuctTerminalNode,
|
||||||
|
HvacEquipmentNode,
|
||||||
|
LinesetNode,
|
||||||
|
LiquidLineNode,
|
||||||
|
PipeSegmentNode,
|
||||||
|
PipeFittingNode,
|
||||||
|
PipeTrapNode,
|
||||||
])
|
])
|
||||||
|
|
||||||
export type AnyNode = z.infer<typeof AnyNode>
|
export type AnyNode = z.infer<typeof AnyNode>
|
||||||
|
|||||||
@@ -295,8 +295,43 @@ export function nodeAlignmentAnchors(
|
|||||||
const poly = (node as { polygon?: [number, number][] }).polygon
|
const poly = (node as { polygon?: [number, number][] }).polygon
|
||||||
return poly ? polygonAnchors(node.id, poly) : []
|
return poly ? polygonAnchors(node.id, poly) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const anchors: AlignmentAnchor[] = []
|
||||||
|
|
||||||
|
// Box footprint (items, columns, shelves, stairs, …).
|
||||||
const aabb = alignmentAABB(node, nodes)
|
const aabb = alignmentAABB(node, nodes)
|
||||||
return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : []
|
if (aabb) {
|
||||||
|
anchors.push(...bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polyline kinds (duct / pipe / lineset): every path vertex is an anchor,
|
||||||
|
// so anything dragged snaps to a run's ends and bends.
|
||||||
|
const path = (node as { path?: unknown }).path
|
||||||
|
if (Array.isArray(path)) {
|
||||||
|
for (const p of path as Array<[number, number, number]>) {
|
||||||
|
anchors.push({ nodeId: node.id, kind: 'corner', x: p[0], z: p[2] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typed ports (fittings, equipment, terminals, run ends): connection points
|
||||||
|
// are natural alignment targets — line a new run up with an existing collar.
|
||||||
|
const ports = nodeRegistry.get(node.type)?.ports?.(node)
|
||||||
|
if (ports) {
|
||||||
|
for (const port of ports) {
|
||||||
|
anchors.push({ nodeId: node.id, kind: 'corner', x: port.position[0], z: port.position[2] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position-based kinds with no footprint (e.g. duct fittings): the origin
|
||||||
|
// itself is a useful centre anchor.
|
||||||
|
if (!aabb) {
|
||||||
|
const position = (node as { position?: [number, number, number] }).position
|
||||||
|
if (Array.isArray(position)) {
|
||||||
|
anchors.push({ nodeId: node.id, kind: 'center', x: position[0], z: position[2] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return anchors
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export {
|
|||||||
pickHost,
|
pickHost,
|
||||||
type Vec3,
|
type Vec3,
|
||||||
} from './hosting'
|
} from './hosting'
|
||||||
|
export {
|
||||||
|
DEFAULT_LEVEL_HEIGHT,
|
||||||
|
getLevelHeight,
|
||||||
|
} from './level-height'
|
||||||
export {
|
export {
|
||||||
type AxisLock,
|
type AxisLock,
|
||||||
applyAxisLock,
|
applyAxisLock,
|
||||||
@@ -69,6 +73,19 @@ export {
|
|||||||
type VerticalFeature,
|
type VerticalFeature,
|
||||||
type WallExtent,
|
type WallExtent,
|
||||||
} from './opening-guides'
|
} from './opening-guides'
|
||||||
|
export {
|
||||||
|
analyzePortConnectivity,
|
||||||
|
type PortConnection,
|
||||||
|
type PortConnectivity,
|
||||||
|
resolveConnectivityUpdates,
|
||||||
|
} from './port-connectivity'
|
||||||
|
export {
|
||||||
|
buildRiserDiagram,
|
||||||
|
projectIso,
|
||||||
|
type RiserDiagram,
|
||||||
|
type RiserLine,
|
||||||
|
type RiserMarker,
|
||||||
|
} from './riser-diagram'
|
||||||
export {
|
export {
|
||||||
DEFAULT_ANGLE_STEP,
|
DEFAULT_ANGLE_STEP,
|
||||||
DEFAULT_GRID_STEP,
|
DEFAULT_GRID_STEP,
|
||||||
@@ -82,3 +99,13 @@ export {
|
|||||||
snapVec3ToGrid,
|
snapVec3ToGrid,
|
||||||
snapWorldXZToBuildingLocal,
|
snapWorldXZToBuildingLocal,
|
||||||
} from './snap'
|
} from './snap'
|
||||||
|
export {
|
||||||
|
buildPortComponents,
|
||||||
|
type SystemSummary,
|
||||||
|
summarizeSystemFor,
|
||||||
|
} from './system-graph'
|
||||||
|
export {
|
||||||
|
type DwvFinding,
|
||||||
|
type DwvSeverity,
|
||||||
|
validateDwv,
|
||||||
|
} from './validate-dwv'
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { sceneRegistry } from '../hooks/scene-registry/scene-registry'
|
||||||
|
import type { CeilingNode, LevelNode, WallNode } from '../schema'
|
||||||
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
|
|
||||||
|
export const DEFAULT_LEVEL_HEIGHT = 2.5
|
||||||
|
|
||||||
|
// Cache: levelId → computed height. Invalidated when the nodes reference changes.
|
||||||
|
// Zustand produces a new `nodes` object on every mutation, so reference equality
|
||||||
|
// is a zero-cost way to detect stale data without any subscription overhead.
|
||||||
|
const heightCache = new Map<string, number>()
|
||||||
|
let lastNodesRef: object | null = null
|
||||||
|
|
||||||
|
export function getLevelHeight(levelId: string, nodes: Record<AnyNodeId, AnyNode>): number {
|
||||||
|
if (nodes !== lastNodesRef) {
|
||||||
|
heightCache.clear()
|
||||||
|
lastNodesRef = nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heightCache.has(levelId)) return heightCache.get(levelId)!
|
||||||
|
|
||||||
|
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
||||||
|
if (!level) return DEFAULT_LEVEL_HEIGHT
|
||||||
|
|
||||||
|
let maxTop = 0
|
||||||
|
|
||||||
|
for (const childId of level.children) {
|
||||||
|
const child = nodes[childId as keyof typeof nodes]
|
||||||
|
if (!child) continue
|
||||||
|
if (child.type === 'ceiling') {
|
||||||
|
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
|
||||||
|
if (ch > maxTop) maxTop = ch
|
||||||
|
} else if (child.type === 'wall') {
|
||||||
|
let meshY = sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0
|
||||||
|
if (meshY < 0) meshY = 0
|
||||||
|
const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
||||||
|
if (top > maxTop) maxTop = top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
|
||||||
|
heightCache.set(levelId, height)
|
||||||
|
return height
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { nodeRegistry } from '../registry'
|
||||||
|
import type { AnyNode, AnyNodeId } from '../schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connectivity-aware editing for port-bearing kinds (HVAC ductwork).
|
||||||
|
*
|
||||||
|
* Two nodes are "connected" when a port of one coincides in space with a
|
||||||
|
* port of the other — exactly how the placement tools mate a fitting onto
|
||||||
|
* a duct end (they snap the fitting's collar onto the run's open port).
|
||||||
|
* This service reads that relationship back out so an edit to one node can
|
||||||
|
* carry its neighbours along.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Point = readonly [number, number, number]
|
||||||
|
|
||||||
|
/** Distance (meters) under which two ports count as the same joint. Joints
|
||||||
|
* formed by placement snapping coincide to sub-millimeter; 5 cm leaves
|
||||||
|
* 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. */
|
||||||
|
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. */
|
||||||
|
kind: 'rigid-node'
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
movedPortId: string
|
||||||
|
/** Partner node's `position` at edit-start. */
|
||||||
|
startPosition: Point
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
||||||
|
startMovedPorts: Record<string, Point>
|
||||||
|
connections: PortConnection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function portsOf(node: AnyNode): ReadonlyArray<{ id: string; position: Point }> | undefined {
|
||||||
|
return nodeRegistry.get(node.type)?.ports?.(node) as
|
||||||
|
| ReadonlyArray<{ id: string; position: Point }>
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function distSq(a: Point, b: Point): number {
|
||||||
|
const dx = a[0] - b[0]
|
||||||
|
const dy = a[1] - b[1]
|
||||||
|
const dz = a[2] - b[2]
|
||||||
|
return dx * dx + dy * dy + dz * dz
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot which nodes are connected to `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 duct-segment (endpoint stretch) and duct-fitting (rigid follow)
|
||||||
|
* partners are tracked — terminals and equipment usually mount to a
|
||||||
|
* surface and shouldn't be yanked off it when an adjacent fitting nudges.
|
||||||
|
*/
|
||||||
|
export function analyzePortConnectivity(
|
||||||
|
movedNode: AnyNode,
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
): PortConnectivity {
|
||||||
|
const movedPorts = portsOf(movedNode) ?? []
|
||||||
|
const startMovedPorts: Record<string, Point> = {}
|
||||||
|
for (const p of movedPorts) startMovedPorts[p.id] = p.position
|
||||||
|
|
||||||
|
const connections: PortConnection[] = []
|
||||||
|
const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M
|
||||||
|
|
||||||
|
for (const other of Object.values(nodes)) {
|
||||||
|
if (!other || other.id === movedNode.id) continue
|
||||||
|
if (other.type !== 'duct-segment' && other.type !== 'duct-fitting') continue
|
||||||
|
const otherPorts = portsOf(other)
|
||||||
|
if (!otherPorts) continue
|
||||||
|
|
||||||
|
for (const op of otherPorts) {
|
||||||
|
// Find which of the moved node's ports this partner port sits on.
|
||||||
|
let matchedId: string | null = null
|
||||||
|
for (const mp of movedPorts) {
|
||||||
|
if (distSq(op.position, mp.position) <= epsSq) {
|
||||||
|
matchedId = mp.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matchedId) continue
|
||||||
|
|
||||||
|
if (other.type === 'duct-segment') {
|
||||||
|
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]],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { movedNodeId: movedNode.id as AnyNodeId, connections, startMovedPorts }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the moved node in its live (in-drag) transform, produce the patches
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function resolveConnectivityUpdates(
|
||||||
|
connectivity: PortConnectivity,
|
||||||
|
previewNode: AnyNode,
|
||||||
|
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
|
||||||
|
const newPorts = portsOf(previewNode) ?? []
|
||||||
|
const newById: Record<string, Point> = {}
|
||||||
|
for (const p of newPorts) newById[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
|
||||||
|
|
||||||
|
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>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updates
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { AnyNode, AnyNodeId } from '../schema'
|
||||||
|
import { buildRiserDiagram, projectIso } from './riser-diagram'
|
||||||
|
|
||||||
|
type Point = [number, number, number]
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('projectIso', () => {
|
||||||
|
test('higher elevation maps to smaller screen Y', () => {
|
||||||
|
const [, lowY] = projectIso(0, 0, 0)
|
||||||
|
const [, highY] = projectIso(0, 2, 0)
|
||||||
|
expect(highY).toBeLessThan(lowY)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildRiserDiagram', () => {
|
||||||
|
test('null when no DWV nodes', () => {
|
||||||
|
const wall = makeNode('wall', {})
|
||||||
|
expect(buildRiserDiagram(sceneOf(wall))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifies a vertical stack vs a sloped horizontal drain', () => {
|
||||||
|
const stack = makeNode('pipe-segment', {
|
||||||
|
path: [
|
||||||
|
[0, 0, 0],
|
||||||
|
[0, 3, 0],
|
||||||
|
] as Point[],
|
||||||
|
diameter: 3,
|
||||||
|
system: 'vent',
|
||||||
|
})
|
||||||
|
const drain = makeNode('pipe-segment', {
|
||||||
|
path: [
|
||||||
|
[0, 0, 0],
|
||||||
|
[3, -0.06, 0],
|
||||||
|
] as Point[],
|
||||||
|
diameter: 2,
|
||||||
|
system: 'waste',
|
||||||
|
})
|
||||||
|
const diagram = buildRiserDiagram(sceneOf(stack, drain))!
|
||||||
|
const stackLine = diagram.lines.find((l) => l.nodeId === stack.id)!
|
||||||
|
const drainLine = diagram.lines.find((l) => l.nodeId === drain.id)!
|
||||||
|
expect(stackLine.vertical).toBe(true)
|
||||||
|
expect(drainLine.vertical).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('emits a vent-termination marker for a vent run', () => {
|
||||||
|
const vent = makeNode('pipe-segment', {
|
||||||
|
path: [
|
||||||
|
[0, 0, 0],
|
||||||
|
[0, 3, 0],
|
||||||
|
] as Point[],
|
||||||
|
diameter: 2,
|
||||||
|
system: 'vent',
|
||||||
|
})
|
||||||
|
const diagram = buildRiserDiagram(sceneOf(vent))!
|
||||||
|
expect(diagram.markers.some((m) => m.kind === 'vent-termination')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('labels traps', () => {
|
||||||
|
const trap = makeNode('pipe-trap', {
|
||||||
|
position: [1, 0, 0] as Point,
|
||||||
|
diameter: 1.5,
|
||||||
|
})
|
||||||
|
const diagram = buildRiserDiagram(sceneOf(trap))!
|
||||||
|
expect(diagram.markers.some((m) => m.kind === 'trap')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { AnyNode, AnyNodeId } from '../schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Riser diagram (plumbing isometric) — the conventional way DWV systems
|
||||||
|
* are drawn for permit: the drain/vent tree projected to a 30° iso so
|
||||||
|
* vertical stacks read as vertical and horizontal runs lean off at 30°,
|
||||||
|
* annotated with size + slope and vent terminations.
|
||||||
|
*
|
||||||
|
* This is a pure projector: it turns the scene's DWV nodes into 2D
|
||||||
|
* drawables (level-independent, no rendering). The editor draws the
|
||||||
|
* result as SVG. Air/refrigerant nodes are ignored — riser diagrams are
|
||||||
|
* a plumbing convention.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COS30 = Math.cos(Math.PI / 6)
|
||||||
|
const SIN30 = Math.sin(Math.PI / 6)
|
||||||
|
|
||||||
|
/** A 3D level-local point (meters) projected to 2D iso screen space.
|
||||||
|
* Screen Y grows DOWNWARD (SVG convention), so higher elevation → lower
|
||||||
|
* screen Y. */
|
||||||
|
export function projectIso(x: number, y: number, z: number): [number, number] {
|
||||||
|
const sx = (x - z) * COS30
|
||||||
|
const sy = (x + z) * SIN30 - y
|
||||||
|
return [sx, sy]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RiserLine = {
|
||||||
|
/** Projected endpoints in iso screen space. */
|
||||||
|
from: [number, number]
|
||||||
|
to: [number, number]
|
||||||
|
system: 'waste' | 'vent'
|
||||||
|
/** Nominal size in inches. */
|
||||||
|
diameter: number
|
||||||
|
/** True for a (near-)vertical run — drawn solid/bold as a stack. */
|
||||||
|
vertical: boolean
|
||||||
|
/** Source node, so the editor can link selection. */
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RiserMarker = {
|
||||||
|
point: [number, number]
|
||||||
|
kind: 'trap' | 'vent-termination' | 'fitting'
|
||||||
|
label: string
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RiserDiagram = {
|
||||||
|
lines: RiserLine[]
|
||||||
|
markers: RiserMarker[]
|
||||||
|
/** Bounding box of all projected geometry, screen space. */
|
||||||
|
bounds: { minX: number; minY: number; maxX: number; maxY: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elevation gain per horizontal meter under which a leg is "vertical". */
|
||||||
|
const VERTICAL_EPS = 4 // dy/dxz ratio: steeper than this reads as a stack
|
||||||
|
|
||||||
|
type Vec3 = readonly [number, number, number]
|
||||||
|
|
||||||
|
function legIsVertical(a: Vec3, b: Vec3): boolean {
|
||||||
|
const horizontal = Math.hypot(b[0] - a[0], b[2] - a[2])
|
||||||
|
const vertical = Math.abs(b[1] - a[1])
|
||||||
|
if (horizontal < 1e-4) return true
|
||||||
|
return vertical / horizontal > VERTICAL_EPS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the riser diagram for the whole scene. Returns null when there's
|
||||||
|
* no DWV geometry to draw.
|
||||||
|
*/
|
||||||
|
export function buildRiserDiagram(
|
||||||
|
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||||
|
): RiserDiagram | null {
|
||||||
|
const lines: RiserLine[] = []
|
||||||
|
const markers: RiserMarker[] = []
|
||||||
|
|
||||||
|
let minX = Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
let maxX = -Infinity
|
||||||
|
let maxY = -Infinity
|
||||||
|
const grow = (p: [number, number]) => {
|
||||||
|
if (p[0] < minX) minX = p[0]
|
||||||
|
if (p[1] < minY) minY = p[1]
|
||||||
|
if (p[0] > maxX) maxX = p[0]
|
||||||
|
if (p[1] > maxY) maxY = p[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if (!node) continue
|
||||||
|
if (node.type === 'pipe-segment') {
|
||||||
|
const path = node.path as Vec3[]
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const a = path[i]!
|
||||||
|
const b = path[i + 1]!
|
||||||
|
const from = projectIso(a[0], a[1], a[2])
|
||||||
|
const to = projectIso(b[0], b[1], b[2])
|
||||||
|
grow(from)
|
||||||
|
grow(to)
|
||||||
|
lines.push({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
system: node.system,
|
||||||
|
diameter: node.diameter,
|
||||||
|
vertical: legIsVertical(a, b),
|
||||||
|
nodeId: node.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Vent runs that end above everything are vent terminations
|
||||||
|
// (through-roof). Tag the highest endpoint of a vent run.
|
||||||
|
if (node.system === 'vent') {
|
||||||
|
const top = path.reduce((hi, p) => (p[1] > hi[1] ? p : hi), path[0]!)
|
||||||
|
const pt = projectIso(top[0], top[1], top[2])
|
||||||
|
markers.push({
|
||||||
|
point: pt,
|
||||||
|
kind: 'vent-termination',
|
||||||
|
label: `${node.diameter}" VTR`,
|
||||||
|
nodeId: node.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (node.type === 'pipe-trap') {
|
||||||
|
const pt = projectIso(node.position[0], node.position[1], node.position[2])
|
||||||
|
grow(pt)
|
||||||
|
markers.push({
|
||||||
|
point: pt,
|
||||||
|
kind: 'trap',
|
||||||
|
label: `${node.diameter}" P-trap`,
|
||||||
|
nodeId: node.id,
|
||||||
|
})
|
||||||
|
} else if (node.type === 'pipe-fitting') {
|
||||||
|
const pt = projectIso(node.position[0], node.position[1], node.position[2])
|
||||||
|
grow(pt)
|
||||||
|
markers.push({
|
||||||
|
point: pt,
|
||||||
|
kind: 'fitting',
|
||||||
|
label: node.fittingType,
|
||||||
|
nodeId: node.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0 && markers.length === 0) return null
|
||||||
|
|
||||||
|
return { lines, markers, bounds: { minX, minY, maxX, maxY } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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 { buildPortComponents, summarizeSystemFor } from './system-graph'
|
||||||
|
|
||||||
|
type Point = [number, number, number]
|
||||||
|
|
||||||
|
// Stub registrations: the graph consults `def.ports` for the connectivity
|
||||||
|
// graph and `def.distributionRole` to classify each node. Mirrors the real
|
||||||
|
// kinds' port + role conventions (duct runs expose start/end, equipment a
|
||||||
|
// supply collar, terminals one collar) without importing the nodes package.
|
||||||
|
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('hvac-equipment', 'equipment', (node) => {
|
||||||
|
const position = (node as unknown as { position: Point }).position
|
||||||
|
return [{ id: 'supply', position, direction: [0, 1, 0], diameter: 12, system: 'supply' }]
|
||||||
|
})
|
||||||
|
stubDef('duct-terminal', 'terminal', (node) => {
|
||||||
|
const position = (node as unknown as { position: Point }).position
|
||||||
|
return [{ id: 'collar', position, direction: [0, -1, 0], diameter: 6, system: 'supply' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
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 run(path: Point[], system = 'supply'): AnyNode {
|
||||||
|
return makeNode('duct-segment', { path, system, diameter: 6 })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildPortComponents', () => {
|
||||||
|
test('chained runs land in one component; a distant run is separate', () => {
|
||||||
|
const a = run([
|
||||||
|
[0, 0, 0],
|
||||||
|
[3, 0, 0],
|
||||||
|
])
|
||||||
|
const b = run([
|
||||||
|
[3, 0, 0],
|
||||||
|
[3, 0, 4],
|
||||||
|
]) // shares a's end
|
||||||
|
const c = run([
|
||||||
|
[20, 0, 0],
|
||||||
|
[24, 0, 0],
|
||||||
|
]) // far away
|
||||||
|
const components = buildPortComponents(sceneOf(a, b, c))
|
||||||
|
expect(components.length).toBe(2)
|
||||||
|
const joined = components.find((g) => g.length === 2)!
|
||||||
|
expect(new Set(joined)).toEqual(new Set([a.id, b.id]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('joints within tolerance still join; outside do not', () => {
|
||||||
|
const a = run([
|
||||||
|
[0, 0, 0],
|
||||||
|
[3, 0, 0],
|
||||||
|
])
|
||||||
|
const near = run([
|
||||||
|
[3.03, 0, 0],
|
||||||
|
[6, 0, 0],
|
||||||
|
]) // 3 cm — joined
|
||||||
|
const far = run([
|
||||||
|
[3.2, 0, 4],
|
||||||
|
[6, 0, 4],
|
||||||
|
]) // 20 cm in another row — separate
|
||||||
|
const components = buildPortComponents(sceneOf(a, near, far))
|
||||||
|
expect(components.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nodes without ports do not participate', () => {
|
||||||
|
const wall = makeNode('wall', {})
|
||||||
|
const a = run([
|
||||||
|
[0, 0, 0],
|
||||||
|
[3, 0, 0],
|
||||||
|
])
|
||||||
|
const components = buildPortComponents(sceneOf(wall, a))
|
||||||
|
expect(components.length).toBe(1)
|
||||||
|
expect(components[0]).toEqual([a.id])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('summarizeSystemFor', () => {
|
||||||
|
test('full tree: equipment → run → terminal, stats add up', () => {
|
||||||
|
const furnace = makeNode('hvac-equipment', { position: [0, 0, 0] as Point })
|
||||||
|
const trunk = run([
|
||||||
|
[0, 0, 0],
|
||||||
|
[4, 0, 0],
|
||||||
|
])
|
||||||
|
const branch = run([
|
||||||
|
[4, 0, 0],
|
||||||
|
[4, 0, 3],
|
||||||
|
])
|
||||||
|
const register = makeNode('duct-terminal', {
|
||||||
|
position: [4, 0, 3] as Point,
|
||||||
|
terminalType: 'supply-register',
|
||||||
|
})
|
||||||
|
const scene = sceneOf(furnace, trunk, branch, register)
|
||||||
|
|
||||||
|
const summary = summarizeSystemFor(register.id, scene)!
|
||||||
|
expect(summary.nodeIds.length).toBe(4)
|
||||||
|
expect(summary.connectedToEquipment).toBe(true)
|
||||||
|
expect(summary.runCount).toBe(2)
|
||||||
|
expect(summary.runLengthM).toBeCloseTo(7, 6)
|
||||||
|
expect(summary.terminalCount).toBe(1)
|
||||||
|
expect(summary.equipmentCount).toBe(1)
|
||||||
|
expect(summary.systems).toEqual(['supply'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('orphaned run reports no equipment', () => {
|
||||||
|
const lonely = run([
|
||||||
|
[10, 0, 10],
|
||||||
|
[14, 0, 10],
|
||||||
|
])
|
||||||
|
const summary = summarizeSystemFor(lonely.id, sceneOf(lonely))!
|
||||||
|
expect(summary.connectedToEquipment).toBe(false)
|
||||||
|
expect(summary.runCount).toBe(1)
|
||||||
|
expect(summary.runLengthM).toBeCloseTo(4, 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('port-less node → null', () => {
|
||||||
|
const wall = makeNode('wall', {})
|
||||||
|
expect(summarizeSystemFor(wall.id, sceneOf(wall))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { nodeRegistry } from '../registry'
|
||||||
|
import type { AnyNode, AnyNodeId } from '../schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "System" primitive: connected components over the port graph.
|
||||||
|
*
|
||||||
|
* Two nodes are joined when a port of one coincides in space with a port
|
||||||
|
* of the other — the same mated-joint relationship `port-connectivity`
|
||||||
|
* uses for drag propagation, read here at whole-scene scope. A component
|
||||||
|
* is one distribution system: a furnace, its trunk, the tees, branches,
|
||||||
|
* and registers hanging off it.
|
||||||
|
*
|
||||||
|
* Pure logic (def.ports + arithmetic), no rendering — lives in core so
|
||||||
|
* the editor (badges, schedules) and analyses (sizing, code checks) can
|
||||||
|
* share it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Distance (meters) under which two ports count as the same joint —
|
||||||
|
* matches port-connectivity's tolerance for hand-placed joints. */
|
||||||
|
const COINCIDENT_EPS_M = 0.05
|
||||||
|
|
||||||
|
export type SystemSummary = {
|
||||||
|
/** Every node in this connected component. */
|
||||||
|
nodeIds: AnyNodeId[]
|
||||||
|
/** Distribution loops present, e.g. ['supply'], ['supply','return']. */
|
||||||
|
systems: string[]
|
||||||
|
/** Duct / lineset run statistics. */
|
||||||
|
runCount: number
|
||||||
|
runLengthM: number
|
||||||
|
fittingCount: number
|
||||||
|
terminalCount: number
|
||||||
|
equipmentCount: number
|
||||||
|
/** False = orphaned subtree: air goes nowhere (no furnace / air
|
||||||
|
* handler / condenser anywhere in the component). */
|
||||||
|
connectedToEquipment: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortRecord = {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
system: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPorts(nodes: Readonly<Record<AnyNodeId, AnyNode>>): PortRecord[] {
|
||||||
|
const result: PortRecord[] = []
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if (!node) continue
|
||||||
|
const ports = nodeRegistry.get(node.type)?.ports?.(node)
|
||||||
|
if (!ports) continue
|
||||||
|
for (const port of ports) {
|
||||||
|
result.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
x: port.position[0],
|
||||||
|
y: port.position[1],
|
||||||
|
z: port.position[2],
|
||||||
|
system: port.system,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union-find over node ids. */
|
||||||
|
class Components {
|
||||||
|
private parent = new Map<AnyNodeId, AnyNodeId>()
|
||||||
|
|
||||||
|
find(id: AnyNodeId): AnyNodeId {
|
||||||
|
let root = this.parent.get(id) ?? id
|
||||||
|
if (root !== id) {
|
||||||
|
root = this.find(root)
|
||||||
|
this.parent.set(id, root)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
union(a: AnyNodeId, b: AnyNodeId): void {
|
||||||
|
const ra = this.find(a)
|
||||||
|
const rb = this.find(b)
|
||||||
|
if (ra !== rb) this.parent.set(rb, ra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathLength(path: ReadonlyArray<readonly [number, number, number]>): number {
|
||||||
|
let total = 0
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const a = path[i]!
|
||||||
|
const b = path[i + 1]!
|
||||||
|
total += Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group every port-bearing node into connected components via coinciding
|
||||||
|
* ports. Nodes with ports but no joints form singleton components; nodes
|
||||||
|
* without `def.ports` don't participate at all.
|
||||||
|
*/
|
||||||
|
export function buildPortComponents(nodes: Readonly<Record<AnyNodeId, AnyNode>>): AnyNodeId[][] {
|
||||||
|
const ports = collectPorts(nodes)
|
||||||
|
const components = new Components()
|
||||||
|
const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M
|
||||||
|
|
||||||
|
for (let i = 0; i < ports.length; i++) {
|
||||||
|
const a = ports[i]!
|
||||||
|
for (let j = i + 1; j < ports.length; j++) {
|
||||||
|
const b = ports[j]!
|
||||||
|
if (a.nodeId === b.nodeId) continue
|
||||||
|
const dx = a.x - b.x
|
||||||
|
const dy = a.y - b.y
|
||||||
|
const dz = a.z - b.z
|
||||||
|
if (dx * dx + dy * dy + dz * dz <= epsSq) components.union(a.nodeId, b.nodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = new Map<AnyNodeId, AnyNodeId[]>()
|
||||||
|
const seen = new Set<AnyNodeId>()
|
||||||
|
for (const port of ports) {
|
||||||
|
if (seen.has(port.nodeId)) continue
|
||||||
|
seen.add(port.nodeId)
|
||||||
|
const root = components.find(port.nodeId)
|
||||||
|
const group = grouped.get(root)
|
||||||
|
if (group) group.push(port.nodeId)
|
||||||
|
else grouped.set(root, [port.nodeId])
|
||||||
|
}
|
||||||
|
return [...grouped.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(
|
||||||
|
nodeIds: AnyNodeId[],
|
||||||
|
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||||
|
): SystemSummary {
|
||||||
|
const systems = new Set<string>()
|
||||||
|
let runCount = 0
|
||||||
|
let runLengthM = 0
|
||||||
|
let fittingCount = 0
|
||||||
|
let terminalCount = 0
|
||||||
|
let equipmentCount = 0
|
||||||
|
|
||||||
|
for (const id of nodeIds) {
|
||||||
|
const node = nodes[id]
|
||||||
|
if (!node) continue
|
||||||
|
const role = nodeRegistry.get(node.type)?.distributionRole
|
||||||
|
const fields = node as {
|
||||||
|
path?: ReadonlyArray<readonly [number, number, number]>
|
||||||
|
system?: string
|
||||||
|
terminalType?: string
|
||||||
|
}
|
||||||
|
if (role === 'run') {
|
||||||
|
runCount += 1
|
||||||
|
if (fields.path) runLengthM += pathLength(fields.path)
|
||||||
|
// Linesets carry refrigerant; duct / pipe runs name their own loop.
|
||||||
|
systems.add(fields.system ?? 'refrigerant')
|
||||||
|
} else if (role === 'fitting') {
|
||||||
|
fittingCount += 1
|
||||||
|
if (fields.system) systems.add(fields.system)
|
||||||
|
} else if (role === 'terminal') {
|
||||||
|
terminalCount += 1
|
||||||
|
systems.add(fields.terminalType === 'return-grille' ? 'return' : 'supply')
|
||||||
|
} else if (role === 'equipment') {
|
||||||
|
equipmentCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeIds,
|
||||||
|
systems: [...systems].sort(),
|
||||||
|
runCount,
|
||||||
|
runLengthM,
|
||||||
|
fittingCount,
|
||||||
|
terminalCount,
|
||||||
|
equipmentCount,
|
||||||
|
connectedToEquipment: equipmentCount > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of the system the given node belongs to, or null when the node
|
||||||
|
* has no ports (not a distribution kind). A node with ports but no
|
||||||
|
* joints yet still gets a (singleton) summary — `connectedToEquipment:
|
||||||
|
* false` is the interesting signal there.
|
||||||
|
*/
|
||||||
|
export function summarizeSystemFor(
|
||||||
|
nodeId: AnyNodeId,
|
||||||
|
nodes: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||||
|
): SystemSummary | null {
|
||||||
|
const node = nodes[nodeId]
|
||||||
|
if (!node) return null
|
||||||
|
const ports = nodeRegistry.get(node.type)?.ports?.(node)
|
||||||
|
if (!ports || ports.length === 0) return null
|
||||||
|
for (const component of buildPortComponents(nodes)) {
|
||||||
|
if (component.includes(nodeId)) return summarize(component, nodes)
|
||||||
|
}
|
||||||
|
return summarize([nodeId], nodes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type { AnyNodeDefinition, NodePort } from '../registry'
|
||||||
|
import { registerNode } from '../registry'
|
||||||
|
import type { AnyNode, AnyNodeId } from '../schema'
|
||||||
|
import { validateDwv } from './validate-dwv'
|
||||||
|
|
||||||
|
type Point = [number, number, number]
|
||||||
|
|
||||||
|
// The validator reads node fields directly + buildPortComponents (which
|
||||||
|
// consults def.ports), so register stub port-providers for the DWV kinds
|
||||||
|
// it groups by. Mirrors the system-graph test's approach.
|
||||||
|
function stubDef(kind: string, ports: (node: AnyNode) => NodePort[]): void {
|
||||||
|
registerNode({
|
||||||
|
kind,
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: {},
|
||||||
|
category: 'utility',
|
||||||
|
defaults: () => ({}),
|
||||||
|
capabilities: {},
|
||||||
|
ports,
|
||||||
|
} as unknown as AnyNodeDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
stubDef('pipe-segment', (node) => {
|
||||||
|
const path = (node as unknown as { path: Point[] }).path
|
||||||
|
const diameter = (node as unknown as { diameter: number }).diameter
|
||||||
|
const system = (node as unknown as { system: string }).system
|
||||||
|
return [
|
||||||
|
{ id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter, system },
|
||||||
|
{
|
||||||
|
id: 'end',
|
||||||
|
position: path[path.length - 1]!,
|
||||||
|
direction: [1, 0, 0],
|
||||||
|
diameter,
|
||||||
|
system,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
stubDef('pipe-trap', (node) => {
|
||||||
|
const position = (node as unknown as { position: Point }).position
|
||||||
|
return [{ id: 'inlet', position, direction: [0, 1, 0], diameter: 1.5, system: 'waste' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A waste run from a→b. Drop the end Y to slope it. */
|
||||||
|
function waste(path: Point[], diameter = 2): AnyNode {
|
||||||
|
return makeNode('pipe-segment', { path, diameter, system: 'waste' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUARTER_PER_FOOT = 1 / 48
|
||||||
|
|
||||||
|
describe('validateDwv — slope', () => {
|
||||||
|
test('flags a flat waste run', () => {
|
||||||
|
const run = waste([
|
||||||
|
[0, 0, 0],
|
||||||
|
[3, 0, 0], // dead level
|
||||||
|
])
|
||||||
|
const findings = validateDwv(sceneOf(run))
|
||||||
|
expect(findings.some((f) => f.code === 'slope-too-flat')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes a run sloped at quarter-inch per foot', () => {
|
||||||
|
const drop = 3 * QUARTER_PER_FOOT
|
||||||
|
const run = waste([
|
||||||
|
[0, 0, 0],
|
||||||
|
[3, -drop, 0],
|
||||||
|
])
|
||||||
|
const findings = validateDwv(sceneOf(run))
|
||||||
|
expect(findings.some((f) => f.code === 'slope-too-flat')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flags an over-steep run (siphoning risk)', () => {
|
||||||
|
// 2" pipe, max slope = 2/12 ≈ 0.167; drop 2m over 1m horizontal.
|
||||||
|
const run = waste([
|
||||||
|
[0, 0, 0],
|
||||||
|
[1, -2, 0],
|
||||||
|
])
|
||||||
|
const findings = validateDwv(sceneOf(run))
|
||||||
|
expect(findings.some((f) => f.code === 'slope-too-steep')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores vents (level is fine)', () => {
|
||||||
|
const vent = makeNode('pipe-segment', {
|
||||||
|
path: [
|
||||||
|
[0, 0, 0],
|
||||||
|
[0, 3, 0],
|
||||||
|
] as Point[],
|
||||||
|
diameter: 2,
|
||||||
|
system: 'vent',
|
||||||
|
})
|
||||||
|
const findings = validateDwv(sceneOf(vent))
|
||||||
|
expect(findings.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateDwv — trap arm', () => {
|
||||||
|
test('flags an over-long trap arm', () => {
|
||||||
|
const trap = makeNode('pipe-trap', {
|
||||||
|
position: [0, 0, 0] as Point,
|
||||||
|
diameter: 1.5, // max arm 42in = 1.067m
|
||||||
|
armLengthM: 2, // way over
|
||||||
|
})
|
||||||
|
const findings = validateDwv(sceneOf(trap))
|
||||||
|
expect(findings.some((f) => f.code === 'trap-arm-too-long')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes a trap arm within the limit', () => {
|
||||||
|
const trap = makeNode('pipe-trap', {
|
||||||
|
position: [0, 0, 0] as Point,
|
||||||
|
diameter: 2, // max arm 60in = 1.524m
|
||||||
|
armLengthM: 1,
|
||||||
|
})
|
||||||
|
const findings = validateDwv(sceneOf(trap))
|
||||||
|
expect(findings.some((f) => f.code === 'trap-arm-too-long')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import type { AnyNode, AnyNodeId } from '../schema'
|
||||||
|
import { buildPortComponents } from './system-graph'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPC validators for the DWV (drain-waste-vent) system — the "CodeRule"
|
||||||
|
* primitive from the domain brief. The slope, minimum-size, and
|
||||||
|
* trap-arm rules are all geometric and read straight off the node
|
||||||
|
* fields, so they live here in core (pure logic) where the editor can
|
||||||
|
* surface them and analyses can reuse them.
|
||||||
|
*
|
||||||
|
* Scope is residential IPC, simplified:
|
||||||
|
* - 704.1 drainage slope by pipe size.
|
||||||
|
* - 909 trap-arm maximum developed length by trap size.
|
||||||
|
*
|
||||||
|
* These are intentionally conservative approximations, not a certified
|
||||||
|
* plan-check — enough to flag the mistakes a drawing tool invites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Drainage findings, worst-first per consumer's sort. */
|
||||||
|
export type DwvSeverity = 'error' | 'warning'
|
||||||
|
|
||||||
|
export type DwvFinding = {
|
||||||
|
severity: DwvSeverity
|
||||||
|
/** Stable rule id, e.g. 'slope-too-flat'. */
|
||||||
|
code: string
|
||||||
|
/** Human-readable, already-formatted message. */
|
||||||
|
message: string
|
||||||
|
/** Nodes the finding implicates (usually one). */
|
||||||
|
nodeIds: AnyNodeId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IPC 704.1 minimum drainage slope (rise/run, dimensionless) by
|
||||||
|
* nominal pipe size: ¼"/ft (1:48) under 3", ⅛"/ft (1:96) for 3–6",
|
||||||
|
* 1/16"/ft (1:192) at 8"+. */
|
||||||
|
function minSlopeFor(diameterIn: number): number {
|
||||||
|
if (diameterIn < 3) return 1 / 48
|
||||||
|
if (diameterIn < 8) return 1 / 96
|
||||||
|
return 1 / 192
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IPC Table 909.1 maximum trap-arm developed length (meters) by trap
|
||||||
|
* size: 30" @ 1¼", 42" @ 1½", 60" @ 2", 72" @ 3", 120" @ 4". */
|
||||||
|
const TRAP_ARM_MAX_M: ReadonlyArray<readonly [number, number]> = [
|
||||||
|
[1.25, 30 * 0.0254],
|
||||||
|
[1.5, 42 * 0.0254],
|
||||||
|
[2, 60 * 0.0254],
|
||||||
|
[3, 72 * 0.0254],
|
||||||
|
[4, 120 * 0.0254],
|
||||||
|
]
|
||||||
|
|
||||||
|
function trapArmMaxFor(diameterIn: number): number {
|
||||||
|
let max = Infinity
|
||||||
|
for (const [size, lengthM] of TRAP_ARM_MAX_M) {
|
||||||
|
if (diameterIn <= size) return lengthM
|
||||||
|
max = lengthM
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slopes shallower than this fraction of the minimum are flagged
|
||||||
|
* "too flat" — a small tolerance keeps round-off off the list. */
|
||||||
|
const SLOPE_TOLERANCE = 0.9
|
||||||
|
/** Horizontal legs shorter than this (meters) are treated as vertical
|
||||||
|
* stacks and skipped from the slope check. */
|
||||||
|
const VERTICAL_LEG_EPS_M = 0.02
|
||||||
|
|
||||||
|
type Vec3 = readonly [number, number, number]
|
||||||
|
|
||||||
|
function legSlope(a: Vec3, b: Vec3): { horizontalM: number; slope: number } {
|
||||||
|
const horizontalM = Math.hypot(b[0] - a[0], b[2] - a[2])
|
||||||
|
if (horizontalM < VERTICAL_LEG_EPS_M) return { horizontalM, slope: Infinity }
|
||||||
|
return { horizontalM, slope: Math.abs(a[1] - b[1]) / horizontalM }
|
||||||
|
}
|
||||||
|
|
||||||
|
function inchLabel(value: number): string {
|
||||||
|
return `${value}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-foot slope as a readable fraction, e.g. 0.0208 → '¼"/ft'. */
|
||||||
|
function slopePerFootLabel(slope: number): string {
|
||||||
|
const inchesPerFoot = slope * 12
|
||||||
|
return `${inchesPerFoot.toFixed(2)}"/ft`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run every DWV rule over the scene and return the findings. Empty
|
||||||
|
* array = nothing to flag. Pure: no scene/store access, no rendering.
|
||||||
|
*/
|
||||||
|
export function validateDwv(nodes: Readonly<Record<AnyNodeId, AnyNode>>): DwvFinding[] {
|
||||||
|
const findings: DwvFinding[] = []
|
||||||
|
|
||||||
|
// ── Per-segment slope (waste only) ──────────────────────────────
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if (!node || node.type !== 'pipe-segment' || node.system !== 'waste') continue
|
||||||
|
const path = node.path as Vec3[]
|
||||||
|
const minSlope = minSlopeFor(node.diameter)
|
||||||
|
const maxSlope = node.diameter / 12 // 1 pipe-diameter per foot → siphoning
|
||||||
|
let flaggedFlat = false
|
||||||
|
let flaggedSteep = false
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const { slope } = legSlope(path[i]!, path[i + 1]!)
|
||||||
|
if (slope === Infinity) continue // vertical stack leg
|
||||||
|
if (!flaggedFlat && slope < minSlope * SLOPE_TOLERANCE) {
|
||||||
|
findings.push({
|
||||||
|
severity: 'error',
|
||||||
|
code: 'slope-too-flat',
|
||||||
|
message: `${inchLabel(node.diameter)} drain slopes ${slopePerFootLabel(
|
||||||
|
slope,
|
||||||
|
)} — IPC 704.1 requires at least ${slopePerFootLabel(minSlope)}.`,
|
||||||
|
nodeIds: [node.id],
|
||||||
|
})
|
||||||
|
flaggedFlat = true
|
||||||
|
}
|
||||||
|
if (!flaggedSteep && slope > maxSlope) {
|
||||||
|
findings.push({
|
||||||
|
severity: 'warning',
|
||||||
|
code: 'slope-too-steep',
|
||||||
|
message: `${inchLabel(node.diameter)} drain slopes ${slopePerFootLabel(
|
||||||
|
slope,
|
||||||
|
)} — over one pipe-diameter per foot risks siphoning the traps.`,
|
||||||
|
nodeIds: [node.id],
|
||||||
|
})
|
||||||
|
flaggedSteep = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component-scoped trap rules ──────────────────────────────────
|
||||||
|
for (const component of buildPortComponents(nodes)) {
|
||||||
|
const traps: AnyNode[] = []
|
||||||
|
|
||||||
|
for (const id of component) {
|
||||||
|
const node = nodes[id]
|
||||||
|
if (!node) continue
|
||||||
|
if (node.type === 'pipe-trap') {
|
||||||
|
traps.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trap-arm developed length: trap outlet → its vent, capped by size.
|
||||||
|
// Independent of waste segments — a trap on its own can already be
|
||||||
|
// over-armed.
|
||||||
|
for (const trap of traps) {
|
||||||
|
const t = trap as { id: AnyNodeId; diameter: number; armLengthM?: number }
|
||||||
|
const armLengthM = t.armLengthM ?? 0
|
||||||
|
const maxArm = trapArmMaxFor(t.diameter)
|
||||||
|
if (armLengthM > maxArm + 1e-6) {
|
||||||
|
findings.push({
|
||||||
|
severity: 'error',
|
||||||
|
code: 'trap-arm-too-long',
|
||||||
|
message: `${inchLabel(t.diameter)} trap arm runs ${(armLengthM / 0.0254).toFixed(
|
||||||
|
0,
|
||||||
|
)}" to its vent — IPC 909.1 caps it at ${(maxArm / 0.0254).toFixed(0)}".`,
|
||||||
|
nodeIds: [t.id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings
|
||||||
|
}
|
||||||
@@ -48,6 +48,13 @@ export function FloorplanRegistryActionMenu() {
|
|||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
||||||
const movingNode = useEditor((s) => s.movingNode)
|
const movingNode = useEditor((s) => s.movingNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
// Gate on floorplan hover so this 2D menu never coexists with the 3D
|
||||||
|
// FloatingActionMenu in split view — that menu hides while the floorplan
|
||||||
|
// is hovered, so this one must only show then. Mirrors the legacy
|
||||||
|
// FloorplanActionMenuLayer guard. Without it a registry kind (e.g. a
|
||||||
|
// duct) shows two Duplicate buttons whenever the pointer is outside the
|
||||||
|
// 2D panel.
|
||||||
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||||
|
|
||||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ export function FloorplanRegistryActionMenu() {
|
|||||||
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
||||||
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
||||||
const isRegistryKind = !!def
|
const isRegistryKind = !!def
|
||||||
const isVisible = isRegistryKind && !movingNode
|
const isVisible = isRegistryKind && !movingNode && isFloorplanHovered
|
||||||
const isWall = selectedKind === 'wall'
|
const isWall = selectedKind === 'wall'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -191,6 +198,11 @@ export function FloorplanRegistryActionMenu() {
|
|||||||
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
||||||
? (cloned.metadata as Record<string, unknown>)
|
? (cloned.metadata as Record<string, unknown>)
|
||||||
: {}
|
: {}
|
||||||
|
// Mark fresh + hand to the placement cursor so the copy follows the
|
||||||
|
// pointer and only lands on the next click — same gesture for every
|
||||||
|
// kind. Polyline runs (duct / pipe / lineset) ride the same path:
|
||||||
|
// `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
|
||||||
|
// no longer need the old "offset + drop already-placed" special case.
|
||||||
cloned.metadata = { ...prevMeta, isNew: true }
|
cloned.metadata = { ...prevMeta, isNew: true }
|
||||||
const parsed = def.schema.parse(cloned) as AnyNode
|
const parsed = def.schema.parse(cloned) as AnyNode
|
||||||
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
||||||
|
|||||||
@@ -429,11 +429,32 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
|
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
|
|
||||||
const originalPosition = ((
|
// Polyline kinds (duct / pipe / lineset) carry a `path`, not a
|
||||||
movingNode as unknown as {
|
// `position` — translating a `position` here would write a field their
|
||||||
position?: [number, number, number]
|
// schema ignores and snap the run back. For those we move every path
|
||||||
}
|
// point by the cursor delta and commit the translated `path` instead.
|
||||||
).position ?? [0, 0, 0]) as [number, number, number]
|
// The reference origin is the path centre so the SVG `translate` delta
|
||||||
|
// matches the geometry's actual location (which isn't at [0,0,0]).
|
||||||
|
const originalPath =
|
||||||
|
'path' in movingNode && Array.isArray((movingNode as { path?: unknown }).path)
|
||||||
|
? (movingNode as { path: [number, number, number][] }).path.map(
|
||||||
|
(p) => [...p] as [number, number, number],
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
const originalPosition: [number, number, number] = originalPath
|
||||||
|
? (() => {
|
||||||
|
let cx = 0
|
||||||
|
let cz = 0
|
||||||
|
for (const p of originalPath) {
|
||||||
|
cx += p[0]
|
||||||
|
cz += p[2]
|
||||||
|
}
|
||||||
|
const n = originalPath.length || 1
|
||||||
|
return [cx / n, originalPath[0]?.[1] ?? 0, cz / n]
|
||||||
|
})()
|
||||||
|
: (((movingNode as unknown as { position?: [number, number, number] }).position ?? [
|
||||||
|
0, 0, 0,
|
||||||
|
]) as [number, number, number])
|
||||||
const isFreshPlacement = isFreshPlacementMetadata(
|
const isFreshPlacement = isFreshPlacementMetadata(
|
||||||
(movingNode as { metadata?: unknown }).metadata,
|
(movingNode as { metadata?: unknown }).metadata,
|
||||||
)
|
)
|
||||||
@@ -450,13 +471,34 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
const otherId = el.getAttribute('data-node-id')
|
const otherId = el.getAttribute('data-node-id')
|
||||||
if (!otherId || otherId === movingNode.id) continue
|
if (!otherId || otherId === movingNode.id) continue
|
||||||
const b = (el as SVGGraphicsElement).getBBox()
|
const b = (el as SVGGraphicsElement).getBBox()
|
||||||
if (b.width <= 0 || b.height <= 0) continue
|
// Skip only fully-degenerate (point) entries. A thin run (duct / pipe /
|
||||||
|
// lineset drawn as a line) has one zero dimension but is still a valid
|
||||||
|
// alignment target — its endpoints become line anchors.
|
||||||
|
if (b.width <= 0 && b.height <= 0) continue
|
||||||
candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
|
candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastSnapped: [number, number] | null = null
|
let lastSnapped: [number, number] | null = null
|
||||||
let dragAnchor: [number, number] | null = null
|
let dragAnchor: [number, number] | null = null
|
||||||
|
|
||||||
|
// Footprint bounding box drawn around the dragged entry — the 2D
|
||||||
|
// counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
|
||||||
|
// reads the same in both views. Green wireframe rect over the entry's
|
||||||
|
// own bbox, translated in lockstep with it. The entry stays visible the
|
||||||
|
// whole drag (no hide-until-move) so it never appears to vanish.
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg'
|
||||||
|
const boxEl = document.createElementNS(SVG_NS, 'rect')
|
||||||
|
boxEl.setAttribute('x', String(movingLocalBBox.x))
|
||||||
|
boxEl.setAttribute('y', String(movingLocalBBox.y))
|
||||||
|
boxEl.setAttribute('width', String(movingLocalBBox.width))
|
||||||
|
boxEl.setAttribute('height', String(movingLocalBBox.height))
|
||||||
|
boxEl.setAttribute('fill', 'none')
|
||||||
|
boxEl.setAttribute('stroke', '#22c55e')
|
||||||
|
boxEl.setAttribute('stroke-width', '1.5')
|
||||||
|
boxEl.setAttribute('vector-effect', 'non-scaling-stroke')
|
||||||
|
boxEl.setAttribute('pointer-events', 'none')
|
||||||
|
scene.appendChild(boxEl)
|
||||||
|
|
||||||
const onMove = (event: PointerEvent) => {
|
const onMove = (event: PointerEvent) => {
|
||||||
// Same target guard as Path 1 — pointer must be over the floor
|
// Same target guard as Path 1 — pointer must be over the floor
|
||||||
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
|
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
|
||||||
@@ -527,6 +569,7 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
const dx = finalX - originalPosition[0]
|
const dx = finalX - originalPosition[0]
|
||||||
const dz = finalZ - originalPosition[2]
|
const dz = finalZ - originalPosition[2]
|
||||||
entry.setAttribute('transform', `translate(${dx} ${dz})`)
|
entry.setAttribute('transform', `translate(${dx} ${dz})`)
|
||||||
|
boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
|
||||||
lastSnapped = [finalX, finalZ]
|
lastSnapped = [finalX, finalZ]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +583,33 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
const [, oldY] = originalPosition
|
const [, oldY] = originalPosition
|
||||||
setMovingNodeOrigin('2d')
|
setMovingNodeOrigin('2d')
|
||||||
let selectedId = movingNode.id as AnyNodeId
|
let selectedId = movingNode.id as AnyNodeId
|
||||||
|
if (originalPath) {
|
||||||
|
// Polyline kinds: shift every point by the committed delta and
|
||||||
|
// write `path`. Strip the fresh-placement flags on first drop.
|
||||||
|
const dx = sx - originalPosition[0]
|
||||||
|
const dz = sz - originalPosition[2]
|
||||||
|
const nextPath = originalPath.map(
|
||||||
|
([x, y, z]) => [x + dx, y, z + dz] as [number, number, number],
|
||||||
|
)
|
||||||
|
useScene.getState().updateNode(
|
||||||
|
movingNode.id as AnyNodeId,
|
||||||
|
(isFreshPlacement
|
||||||
|
? {
|
||||||
|
path: nextPath,
|
||||||
|
metadata: stripPlacementMetadataFlags(
|
||||||
|
(movingNode as { metadata?: unknown }).metadata,
|
||||||
|
),
|
||||||
|
visible: true,
|
||||||
|
}
|
||||||
|
: { path: nextPath }) as Partial<AnyNode>,
|
||||||
|
)
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] })
|
||||||
|
entry.removeAttribute('transform')
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
setMovingNode(null)
|
||||||
|
swallowNextClick()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isFreshPlacement) {
|
if (isFreshPlacement) {
|
||||||
selectedId =
|
selectedId =
|
||||||
commitFreshPlacementSubtree(
|
commitFreshPlacementSubtree(
|
||||||
@@ -592,6 +662,10 @@ export function FloorplanRegistryMoveOverlay() {
|
|||||||
window.removeEventListener('pointerup', onPointerUp)
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
window.removeEventListener('keydown', onKey)
|
window.removeEventListener('keydown', onKey)
|
||||||
entry.removeAttribute('transform')
|
entry.removeAttribute('transform')
|
||||||
|
// Always un-hide on teardown so a committed copy shows and a
|
||||||
|
// never-revealed entry doesn't leak a hidden style onto a reused node.
|
||||||
|
entry.style.visibility = ''
|
||||||
|
boxEl.remove()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
}
|
}
|
||||||
}, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
|
}, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
StairNode,
|
StairNode,
|
||||||
StairSegmentNode,
|
StairSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
|
summarizeSystemFor,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
useScene,
|
useScene,
|
||||||
WallNode,
|
WallNode,
|
||||||
@@ -32,7 +33,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
import { Html } from '@react-three/drei'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useCallback, useRef } from 'react'
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||||
@@ -41,6 +42,21 @@ import useEditor from '../../store/use-editor'
|
|||||||
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
||||||
import { NodeActionMenu } from './node-action-menu'
|
import { NodeActionMenu } from './node-action-menu'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A kind shows the system pill when it exposes typed ports — `def.ports`
|
||||||
|
* is exactly what makes a node participate in the supply/return graph the
|
||||||
|
* pill summarizes. Keeps the menu off a hand-maintained kind list.
|
||||||
|
*/
|
||||||
|
const hasPorts = (type: string) => nodeRegistry.get(type)?.ports != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A kind shows the rotation-axis pill when its R/T keyboard rotation
|
||||||
|
* turns around a user-cyclable axis (`keyboardActions.axisCycling`) —
|
||||||
|
* duct / pipe fittings with full 3D orientation.
|
||||||
|
*/
|
||||||
|
const hasAxisCycling = (type: string) =>
|
||||||
|
nodeRegistry.get(type)?.keyboardActions?.axisCycling === true
|
||||||
|
|
||||||
const ALLOWED_TYPES = [
|
const ALLOWED_TYPES = [
|
||||||
'item',
|
'item',
|
||||||
'door',
|
'door',
|
||||||
@@ -200,6 +216,8 @@ export function FloatingActionMenu() {
|
|||||||
// flips only at drag start / end, so subscribing here is cheap — the live
|
// flips only at drag start / end, so subscribing here is cheap — the live
|
||||||
// height value is written imperatively in the useFrame below.
|
// height value is written imperatively in the useFrame below.
|
||||||
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
||||||
|
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
||||||
|
const rotationAxis = useEditor((s) => s.rotationAxis)
|
||||||
|
|
||||||
const groupRef = useRef<THREE.Group>(null)
|
const groupRef = useRef<THREE.Group>(null)
|
||||||
const menuScaleRef = useRef<HTMLDivElement>(null)
|
const menuScaleRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -490,10 +508,26 @@ export function FloatingActionMenu() {
|
|||||||
// item without clicking" bug. (Item has its own
|
// item without clicking" bug. (Item has its own
|
||||||
// draft-committing move tool, so it must skip the generic
|
// draft-committing move tool, so it must skip the generic
|
||||||
// registry auto-create branch below.)
|
// registry auto-create branch below.)
|
||||||
|
} else if (
|
||||||
|
duplicate.type === 'duct-segment' ||
|
||||||
|
duplicate.type === 'duct-fitting' ||
|
||||||
|
duplicate.type === 'pipe-segment' ||
|
||||||
|
duplicate.type === 'lineset' ||
|
||||||
|
duplicate.type === 'liquid-line'
|
||||||
|
) {
|
||||||
|
// Duct runs & fittings, DWV pipe runs, and refrigerant linesets use
|
||||||
|
// pure drag-to-place: NO node is inserted into the scene until the
|
||||||
|
// commit click. `setMovingNode` below hands the clone (with
|
||||||
|
// `metadata.isNew`) to its ghost tool (`MoveDuctSegmentTool` /
|
||||||
|
// `MoveDuctFittingTool` / `MovePipeSegmentTool` / `MoveLinesetTool`),
|
||||||
|
// which previews a translucent copy inside a footprint bounding box
|
||||||
|
// on the cursor and calls `createNode` on the drop click.
|
||||||
|
// Pre-creating here would drop a copy before any click — the
|
||||||
|
// "auto-places it" bug.
|
||||||
} else if (nodeRegistry.has(duplicate.type)) {
|
} else if (nodeRegistry.has(duplicate.type)) {
|
||||||
// Registry-driven kinds: offset the position slightly so the
|
// Registry-driven kinds: offset slightly so the duplicate doesn't
|
||||||
// duplicate doesn't overlap exactly, then create + hand to the
|
// overlap exactly, then create + hand to the move tool. Mirrors the
|
||||||
// move tool. Mirrors the roof-segment / stair-segment behavior.
|
// roof-segment / stair-segment behavior.
|
||||||
if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
|
if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
|
||||||
const pos = (duplicate as { position: [number, number, number] }).position
|
const pos = (duplicate as { position: [number, number, number] }).position
|
||||||
;(duplicate as { position: [number, number, number] }).position = [
|
;(duplicate as { position: [number, number, number] }).position = [
|
||||||
@@ -501,6 +535,12 @@ export function FloatingActionMenu() {
|
|||||||
pos[1],
|
pos[1],
|
||||||
pos[2] + 1,
|
pos[2] + 1,
|
||||||
]
|
]
|
||||||
|
} else if ('path' in duplicate && Array.isArray((duplicate as any).path)) {
|
||||||
|
// Other polyline kinds (pipe / lineset) carry a `path`, not a
|
||||||
|
// `position`. Create the copy HIDDEN so nothing is auto-placed:
|
||||||
|
// their shared path mover reveals it as a cursor-following
|
||||||
|
// preview on the first mouse move and commits on the next click.
|
||||||
|
;(duplicate as { visible?: boolean }).visible = false
|
||||||
}
|
}
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
}
|
}
|
||||||
@@ -643,9 +683,86 @@ export function FloatingActionMenu() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* HVAC chrome above the menu — same slot as the wall height
|
||||||
|
pill. System pill (which tree, run length, equipment reach)
|
||||||
|
for every distribution kind; the rotation-axis pill stacks
|
||||||
|
under it for duct fittings. */}
|
||||||
|
{node && hasPorts(node.type) ? (
|
||||||
|
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2 flex flex-col items-center gap-1">
|
||||||
|
<SystemSummaryPill nodeId={node.id} unit={unit} />
|
||||||
|
{hasAxisCycling(node.type) ? (
|
||||||
|
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
Axis {rotationAxis.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">R/T rotate</span>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">⌥ axis</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System summary pill for a selected distribution kind (HVAC duct / DWV
|
||||||
|
* pipe / refrigerant lineset): which supply/return tree it belongs to, its
|
||||||
|
* run length, and whether it actually reaches a piece of equipment.
|
||||||
|
*
|
||||||
|
* Mounted only while an HVAC node is selected, so the full-`nodes`
|
||||||
|
* subscription it needs (connectivity changes when ANY joint moves) doesn't
|
||||||
|
* re-render the always-mounted parent menu on every unrelated scene tick.
|
||||||
|
*/
|
||||||
|
function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' | 'imperial' }) {
|
||||||
|
const allNodes = useScene((s) => s.nodes)
|
||||||
|
const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
|
||||||
|
if (!summary) return null
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{summary.systems.length > 0
|
||||||
|
? summary.systems.map((sys) => sys[0]!.toUpperCase() + sys.slice(1)).join(' + ')
|
||||||
|
: 'System'}
|
||||||
|
</span>
|
||||||
|
{summary.runCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '}
|
||||||
|
{summary.runCount === 1 ? 'run' : 'runs'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{summary.terminalCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{summary.terminalCount} {summary.terminalCount === 1 ? 'register' : 'registers'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{summary.connectedToEquipment ? null : (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-amber-500">⚠ no equipment</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5551,15 +5551,7 @@ export function FloorplanPanel({
|
|||||||
} as AnyNode
|
} as AnyNode
|
||||||
usePlacementPreview.getState().set(ghost, wall)
|
usePlacementPreview.getState().set(ghost, wall)
|
||||||
},
|
},
|
||||||
[
|
[floorplanOpeningLocalY, isDoorBuildActive, movingNode, movingOpeningType],
|
||||||
DoorNodeSchema,
|
|
||||||
WallNodeSchema,
|
|
||||||
WindowNodeSchema,
|
|
||||||
floorplanOpeningLocalY,
|
|
||||||
isDoorBuildActive,
|
|
||||||
movingNode,
|
|
||||||
movingOpeningType,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
// Drop the floating opening ghost whenever opening placement ends (commit,
|
// Drop the floating opening ghost whenever opening placement ends (commit,
|
||||||
// tool change, mode switch, cancel) or the active level changes, so a stale
|
// tool change, mode switch, cancel) or the active level changes, so a stale
|
||||||
@@ -5567,6 +5559,7 @@ export function FloorplanPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpeningPlacementActive) usePlacementPreview.getState().clear()
|
if (!isOpeningPlacementActive) usePlacementPreview.getState().clear()
|
||||||
}, [isOpeningPlacementActive])
|
}, [isOpeningPlacementActive])
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: `levelId` is an intentional re-run trigger; the effect drops the placement ghost when the active level changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
usePlacementPreview.getState().clear()
|
usePlacementPreview.getState().clear()
|
||||||
}, [levelId])
|
}, [levelId])
|
||||||
@@ -8813,7 +8806,14 @@ export function FloorplanPanel({
|
|||||||
isFenceBuildActive,
|
isFenceBuildActive,
|
||||||
isFloorplanGridInteractionActive,
|
isFloorplanGridInteractionActive,
|
||||||
isMarqueeSelectionToolActive,
|
isMarqueeSelectionToolActive,
|
||||||
isOpeningPlacementActive,
|
isOpeningBuildActive,
|
||||||
|
isOpeningMoveActive,
|
||||||
|
// The off-wall opening ghost is published through this memoised
|
||||||
|
// callback, whose glyph (door swing-arc vs window panes) is bound to
|
||||||
|
// `isDoorBuildActive`. It must be a dependency or a door→window tool
|
||||||
|
// switch (which changes none of the other listed deps) would keep the
|
||||||
|
// stale closure and float a door symbol while the window tool is armed.
|
||||||
|
showOpeningGhost,
|
||||||
isPolygonBuildActive,
|
isPolygonBuildActive,
|
||||||
isRoofBuildActive,
|
isRoofBuildActive,
|
||||||
isSlabBuildActive,
|
isSlabBuildActive,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import useEditor from '../../store/use-editor'
|
|||||||
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
||||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||||
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||||
|
import { SelectionAffordanceManager } from '../systems/selection-affordance-manager'
|
||||||
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
||||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||||
@@ -55,6 +56,7 @@ import { Grid } from './grid'
|
|||||||
import { GroupMoveHandle } from './group-move-handle'
|
import { GroupMoveHandle } from './group-move-handle'
|
||||||
import { GroupRotateHandle } from './group-rotate-handle'
|
import { GroupRotateHandle } from './group-rotate-handle'
|
||||||
import { NodeArrowHandles } from './node-arrow-handles'
|
import { NodeArrowHandles } from './node-arrow-handles'
|
||||||
|
import { RiserDiagramPanel } from './riser-diagram-panel'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { SiteEdgeLabels } from './site-edge-labels'
|
import { SiteEdgeLabels } from './site-edge-labels'
|
||||||
import { SlabHoleHighlights } from './slab-hole-highlights'
|
import { SlabHoleHighlights } from './slab-hole-highlights'
|
||||||
@@ -617,6 +619,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
|||||||
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
<CeilingSelectionAffordanceSystem />
|
<CeilingSelectionAffordanceSystem />
|
||||||
|
{!noEditing && <SelectionAffordanceManager />}
|
||||||
<RoofEditSystem />
|
<RoofEditSystem />
|
||||||
<StairEditSystem />
|
<StairEditSystem />
|
||||||
{!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
|
{!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
|
||||||
@@ -1287,6 +1290,7 @@ export default function Editor({
|
|||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<HelperManager />
|
<HelperManager />
|
||||||
</div>
|
</div>
|
||||||
|
<RiserDiagramPanel />
|
||||||
{isFirstPersonMode && (
|
{isFirstPersonMode && (
|
||||||
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,11 +23,64 @@ const PART_ORDER: { key: MeasurePart; prefix: string }[] = [
|
|||||||
{ key: 'thickness', prefix: 'T' },
|
{ key: 'thickness', prefix: 'T' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export interface DimensionPillPart {
|
||||||
|
key: string
|
||||||
|
prefix: string
|
||||||
|
value: number
|
||||||
|
/** Render an explicit +/- sign — for deltas rather than absolute sizes. */
|
||||||
|
signed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic floating dimension pill: a row of `prefix value` readouts with the
|
||||||
|
* active one emphasised. Styled to match the top-center floating info bar
|
||||||
|
* (rounded-full, design-token colours) so it tracks the app theme.
|
||||||
|
*
|
||||||
|
* `primaryRef` points at the primary value's `<span>` so a caller driving a
|
||||||
|
* per-frame drag can rewrite its text imperatively without a React re-render.
|
||||||
|
*/
|
||||||
|
export function DimensionPill({
|
||||||
|
parts,
|
||||||
|
unit,
|
||||||
|
primary,
|
||||||
|
primaryRef,
|
||||||
|
}: {
|
||||||
|
parts: DimensionPillPart[]
|
||||||
|
unit: 'metric' | 'imperial'
|
||||||
|
primary?: string
|
||||||
|
primaryRef?: ForwardedRef<HTMLSpanElement>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
const text = part.signed
|
||||||
|
? `${part.value < 0 ? '-' : '+'}${formatMeasurement(Math.abs(part.value), unit)}`
|
||||||
|
: formatMeasurement(part.value, unit)
|
||||||
|
return (
|
||||||
|
<Fragment key={part.key}>
|
||||||
|
{index > 0 ? (
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
part.key === primary ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
ref={part.key === primary ? primaryRef : undefined}
|
||||||
|
>
|
||||||
|
{`${part.prefix} ${text}`}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating dimension pill shown during wall / fence drags: `H · L · T` with
|
* Floating dimension pill shown during wall / fence drags: `H · L · T` with
|
||||||
* the actively-dragged dimension emphasised. Styled to match the top-center
|
* the actively-dragged dimension emphasised.
|
||||||
* floating info bar (rounded-full, design-token colours) so it tracks the
|
|
||||||
* app theme.
|
|
||||||
*
|
*
|
||||||
* The forwarded ref points at the `primary` value's `<span>` so a caller
|
* The forwarded ref points at the `primary` value's `<span>` so a caller
|
||||||
* driving a per-frame drag (the height arrow) can rewrite its text
|
* driving a per-frame drag (the height arrow) can rewrite its text
|
||||||
@@ -52,24 +105,11 @@ export const MeasurementPill = forwardRef(function MeasurementPill(
|
|||||||
) {
|
) {
|
||||||
const values: Record<MeasurePart, number> = { height, length, thickness }
|
const values: Record<MeasurePart, number> = { height, length, thickness }
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
<DimensionPill
|
||||||
{PART_ORDER.map((part, index) => (
|
parts={PART_ORDER.map((part) => ({ ...part, value: values[part.key] }))}
|
||||||
<Fragment key={part.key}>
|
primary={primary}
|
||||||
{index > 0 ? (
|
primaryRef={primaryRef}
|
||||||
<span aria-hidden className="text-muted-foreground">
|
unit={unit}
|
||||||
·
|
/>
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
part.key === primary ? 'font-medium text-foreground' : 'text-muted-foreground'
|
|
||||||
}
|
|
||||||
ref={part.key === primary ? primaryRef : undefined}
|
|
||||||
>
|
|
||||||
{`${part.prefix} ${formatMeasurement(values[part.key], unit)}`}
|
|
||||||
</span>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNodeId, buildRiserDiagram, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import useEditor from '../../store/use-editor'
|
||||||
|
|
||||||
|
const WASTE_COLOR = '#0ea5e9'
|
||||||
|
const VENT_COLOR = '#a855f7'
|
||||||
|
const MARKER_COLOR = '#1e293b'
|
||||||
|
const PADDING = 32
|
||||||
|
/** Meters → SVG units. The iso projection is in meters; scale up so a
|
||||||
|
* typical house drain (a few meters) fills the panel. */
|
||||||
|
const SCALE = 90
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DWV riser diagram — the plumbing isometric drawn from the scene's
|
||||||
|
* drain/waste/vent nodes. Read-only; toggled from the view controls.
|
||||||
|
* Vertical stacks read vertical, sloped drains lean at 30°, with size +
|
||||||
|
* vent-termination annotations, matching the permit-drawing convention.
|
||||||
|
* Clicking a line/marker selects its node in 3D.
|
||||||
|
*/
|
||||||
|
export function RiserDiagramPanel() {
|
||||||
|
const isOpen = useEditor((s) => s.isRiserOpen)
|
||||||
|
// Only the open flag lives here. The whole-scene subscription that drives
|
||||||
|
// the diagram lives in the child, mounted only while the panel is open —
|
||||||
|
// so a closed panel doesn't re-render on every scene mutation.
|
||||||
|
if (!isOpen) return null
|
||||||
|
return <RiserDiagramContent />
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiserDiagramContent() {
|
||||||
|
const setRiserOpen = useEditor((s) => s.setRiserOpen)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
|
||||||
|
const diagram = useMemo(() => buildRiserDiagram(nodes), [nodes])
|
||||||
|
|
||||||
|
const select = (nodeId: AnyNodeId) => useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
|
||||||
|
const width = diagram ? (diagram.bounds.maxX - diagram.bounds.minX) * SCALE + PADDING * 2 : 320
|
||||||
|
const height = diagram ? (diagram.bounds.maxY - diagram.bounds.minY) * SCALE + PADDING * 2 : 200
|
||||||
|
const tx = diagram ? -diagram.bounds.minX * SCALE + PADDING : 0
|
||||||
|
const ty = diagram ? -diagram.bounds.minY * SCALE + PADDING : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dark pointer-events-auto absolute top-4 right-4 z-30 flex max-h-[80vh] w-[26rem] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 text-foreground shadow-lg backdrop-blur-xl">
|
||||||
|
<div className="flex items-center justify-between border-border/40 border-b px-4 py-2.5">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-sm">Riser Diagram</span>
|
||||||
|
<span className="text-muted-foreground text-xs">DWV plumbing isometric</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-white/10"
|
||||||
|
onClick={() => setRiserOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 border-border/40 border-b px-4 py-2 text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="h-0.5 w-4" style={{ background: WASTE_COLOR }} /> Waste
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="h-0 w-4 border-t-2 border-dashed" style={{ borderColor: VENT_COLOR }} />{' '}
|
||||||
|
Vent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto p-2">
|
||||||
|
{diagram ? (
|
||||||
|
<svg
|
||||||
|
height={Math.max(height, 120)}
|
||||||
|
role="img"
|
||||||
|
aria-label="DWV riser diagram"
|
||||||
|
viewBox={`0 0 ${Math.max(width, 200)} ${Math.max(height, 120)}`}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<g transform={`translate(${tx}, ${ty})`}>
|
||||||
|
{diagram.lines.map((line, i) => {
|
||||||
|
const isSel = selectedIds.includes(line.nodeId)
|
||||||
|
const color = line.system === 'waste' ? WASTE_COLOR : VENT_COLOR
|
||||||
|
return (
|
||||||
|
<g key={`${line.nodeId}-${i}`}>
|
||||||
|
<line
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => select(line.nodeId)}
|
||||||
|
stroke={color}
|
||||||
|
strokeDasharray={line.system === 'vent' ? '5 4' : undefined}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={(line.vertical ? 3.5 : 2.5) + (isSel ? 2 : 0)}
|
||||||
|
x1={line.from[0] * SCALE}
|
||||||
|
x2={line.to[0] * SCALE}
|
||||||
|
y1={line.from[1] * SCALE}
|
||||||
|
y2={line.to[1] * SCALE}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
fill={color}
|
||||||
|
fontSize={9}
|
||||||
|
x={((line.from[0] + line.to[0]) / 2) * SCALE + 4}
|
||||||
|
y={((line.from[1] + line.to[1]) / 2) * SCALE - 3}
|
||||||
|
>
|
||||||
|
{line.diameter}"
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{diagram.markers.map((marker, i) => (
|
||||||
|
<g
|
||||||
|
className="cursor-pointer"
|
||||||
|
key={`${marker.nodeId}-${i}`}
|
||||||
|
onClick={() => select(marker.nodeId)}
|
||||||
|
transform={`translate(${marker.point[0] * SCALE}, ${marker.point[1] * SCALE})`}
|
||||||
|
>
|
||||||
|
{marker.kind === 'vent-termination' ? (
|
||||||
|
<path d="M -5 0 L 0 -7 L 5 0" fill="none" stroke={VENT_COLOR} strokeWidth={2} />
|
||||||
|
) : (
|
||||||
|
<circle fill={MARKER_COLOR} r={3} stroke={MARKER_COLOR} strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
<text fill={MARKER_COLOR} fontSize={9} x={8} y={3}>
|
||||||
|
{marker.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-32 items-center justify-center px-6 text-center text-muted-foreground text-sm">
|
||||||
|
No drain, waste, or vent pipes yet. Draw plumbing to see the riser diagram.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { type ComponentType, Suspense, useMemo } from 'react'
|
||||||
|
import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor-mounted dispatcher for a kind's selection-time editing UI.
|
||||||
|
*
|
||||||
|
* Some kinds expose drag-to-edit affordances that should appear only
|
||||||
|
* while a single node of that kind is selected — duct / pipe / lineset
|
||||||
|
* path-point handles, fitting Alt-axis-cycling listeners. These read
|
||||||
|
* `useEditor` (grid snap step, rotation axis) and render the editor's
|
||||||
|
* `DimensionPill`, so they must NOT ride in `def.system` (which the
|
||||||
|
* viewer package mounts for the read-only route). The kind declares the
|
||||||
|
* component under `def.affordanceTools.selection` and this manager —
|
||||||
|
* mounted inside the editor only — loads it for the selected kind.
|
||||||
|
*/
|
||||||
|
export function SelectionAffordanceManager() {
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
const selectedKind = useScene((s) => {
|
||||||
|
if (selectedIds.length !== 1) return null
|
||||||
|
return s.nodes[selectedIds[0] as AnyNodeId]?.type ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const Component = useMemo<ComponentType | null>(() => {
|
||||||
|
if (!selectedKind) return null
|
||||||
|
return getRegistryAffordanceTool(selectedKind, 'selection')
|
||||||
|
}, [selectedKind])
|
||||||
|
|
||||||
|
if (!Component) return null
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Component />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,14 +9,17 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
|
|||||||
/**
|
/**
|
||||||
* MoveTool dispatcher. Routes to (in order):
|
* MoveTool dispatcher. Routes to (in order):
|
||||||
*
|
*
|
||||||
* 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
|
* 1. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
|
||||||
* declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
|
* via `getRegistryAffordanceTool`. Covers generic movers
|
||||||
* …).
|
* (slab / ceiling / wall / fence / column / item / door / window), the
|
||||||
* 2. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
|
* bespoke roof / roof-segment / stair / stair-segment / building
|
||||||
* via `getRegistryAffordanceTool`. Covers both generic movers
|
* movers, and the polyline / fitting ghost-placement movers
|
||||||
* (slab / ceiling / wall / fence / column / item / door / window) and
|
* (duct-segment / duct-fitting). A kind that ships its own mover wins
|
||||||
* the bespoke roof / roof-segment / stair / stair-segment / building
|
* even if it also declares `capabilities.movable` (duct-fitting keeps
|
||||||
* movers ported into `@pascal-app/nodes`.
|
* `movable` for the inspector / hint readers but places via its ghost).
|
||||||
|
* 2. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that only
|
||||||
|
* declare `capabilities.movable` (shelf, spawn, duct-terminal,
|
||||||
|
* hvac-equipment, …).
|
||||||
* 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft
|
* 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft
|
||||||
* mover hasn't been ported to a kind-owned affordance yet.
|
* mover hasn't been ported to a kind-owned affordance yet.
|
||||||
*/
|
*/
|
||||||
@@ -29,9 +32,6 @@ export const MoveTool: React.FC<{
|
|||||||
if (!movingNode) return null
|
if (!movingNode) return null
|
||||||
|
|
||||||
const def = nodeRegistry.get(movingNode.type)
|
const def = nodeRegistry.get(movingNode.type)
|
||||||
if (def?.capabilities?.movable) {
|
|
||||||
return <MoveRegistryNodeTool node={movingNode} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
|
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
|
||||||
if (RegistryMove) {
|
if (RegistryMove) {
|
||||||
@@ -42,6 +42,10 @@ export const MoveTool: React.FC<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (def?.capabilities?.movable) {
|
||||||
|
return <MoveRegistryNodeTool node={movingNode} />
|
||||||
|
}
|
||||||
|
|
||||||
if (movingNode.type === 'elevator')
|
if (movingNode.type === 'elevator')
|
||||||
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
|
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../../three-types'
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
analyzePortConnectivity,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
type EventSuffix,
|
type EventSuffix,
|
||||||
emitter,
|
emitter,
|
||||||
@@ -12,9 +13,12 @@ import {
|
|||||||
movingFootprintAnchors,
|
movingFootprintAnchors,
|
||||||
type NodeEvent,
|
type NodeEvent,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
|
type PortConnectivity,
|
||||||
resolveAlignment,
|
resolveAlignment,
|
||||||
|
resolveConnectivityUpdates,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
|
useLiveNodeOverrides,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -44,6 +48,65 @@ const snapToGridStep = (value: number) => {
|
|||||||
/** 45° steps, matching the GLB item placement rotation. */
|
/** 45° steps, matching the GLB item placement rotation. */
|
||||||
const ROTATION_STEP = Math.PI / 4
|
const ROTATION_STEP = Math.PI / 4
|
||||||
|
|
||||||
|
/** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
|
||||||
|
const PORT_SNAP_RADIUS_M = 0.5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magnetic port snap for a dragged node: if one of the node's own ports
|
||||||
|
* (read live from `def.ports`) lands within `radius` of a matching scene
|
||||||
|
* port at the candidate XZ, return the node XZ that mates them exactly.
|
||||||
|
*
|
||||||
|
* Pure core: ports come through `nodeRegistry` so this stays layer-clean.
|
||||||
|
* Ports are level-local meters — the same frame as the cursor's
|
||||||
|
* `localPosition`, so no extra transform is needed. The dragged node's
|
||||||
|
* ports move rigidly with its position, so a port at candidate `(x,z)`
|
||||||
|
* sits at `portStored + (candidate - nodeStored)`. We pick the closest
|
||||||
|
* (own-port, target-port) pair and shift the node so they coincide in XZ.
|
||||||
|
*/
|
||||||
|
function resolvePortSnap(
|
||||||
|
node: AnyNode,
|
||||||
|
candidate: [number, number],
|
||||||
|
config: { systems?: readonly string[]; radius?: number },
|
||||||
|
): [number, number] | null {
|
||||||
|
const nodePos = (node as { position?: [number, number, number] }).position
|
||||||
|
if (!nodePos) return null
|
||||||
|
const ownPorts = nodeRegistry.get(node.type)?.ports?.(node)
|
||||||
|
if (!ownPorts || ownPorts.length === 0) return null
|
||||||
|
|
||||||
|
const radius = config.radius ?? PORT_SNAP_RADIUS_M
|
||||||
|
const radiusSq = radius * radius
|
||||||
|
const { systems } = config
|
||||||
|
const dragDx = candidate[0] - nodePos[0]
|
||||||
|
const dragDz = candidate[1] - nodePos[2]
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
let bestDistSq = radiusSq
|
||||||
|
let snap: [number, number] | null = null
|
||||||
|
|
||||||
|
for (const node2 of Object.values(nodes)) {
|
||||||
|
if (!node2 || node2.id === node.id) continue
|
||||||
|
const targets = nodeRegistry.get(node2.type)?.ports?.(node2)
|
||||||
|
if (!targets) continue
|
||||||
|
for (const target of targets) {
|
||||||
|
if (systems && target.system !== undefined && !systems.includes(target.system)) continue
|
||||||
|
for (const own of ownPorts) {
|
||||||
|
// Own port at the candidate position = stored port + drag delta.
|
||||||
|
const ownX = own.position[0] + dragDx
|
||||||
|
const ownZ = own.position[2] + dragDz
|
||||||
|
const dx = target.position[0] - ownX
|
||||||
|
const dz = target.position[2] - ownZ
|
||||||
|
const distSq = dx * dx + dz * dz
|
||||||
|
if (distSq <= bestDistSq) {
|
||||||
|
bestDistSq = distSq
|
||||||
|
// Shift the node so this own port lands on the target (XZ only).
|
||||||
|
snap = [candidate[0] + dx, candidate[1] + dz]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
/** Figma-style alignment-snap threshold (meters), matching the 2D
|
/** Figma-style alignment-snap threshold (meters), matching the 2D
|
||||||
* floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
|
* floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
|
||||||
* without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */
|
* without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */
|
||||||
@@ -145,6 +208,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
// and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
|
// and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
|
||||||
// and committed to the scene on drop.
|
// and committed to the scene on drop.
|
||||||
const rotationRef = useRef(originalRotationY)
|
const rotationRef = useRef(originalRotationY)
|
||||||
|
// Snapshot of which ducts / fittings are mated to this node's ports at
|
||||||
|
// drag-start (duct fittings only). Drives the "connected ductwork follows"
|
||||||
|
// behaviour: connected nodes preview through `useLiveNodeOverrides` during
|
||||||
|
// the drag and commit alongside the moved node on drop. Null for kinds with
|
||||||
|
// no ports, so every other movable kind is unaffected.
|
||||||
|
const connectivityRef = useRef<PortConnectivity | null>(null)
|
||||||
|
// Node ids this drag has pushed live overrides onto — cleared on
|
||||||
|
// commit / cancel / unmount so a follow-on drag starts clean.
|
||||||
|
const overriddenIdsRef = useRef<AnyNodeId[]>([])
|
||||||
|
|
||||||
// Shelf placement shows the same green/red footprint box GLB items use
|
// Shelf placement shows the same green/red footprint box GLB items use
|
||||||
// (instead of the vertical-arrow cursor) and refuses an invalid drop unless
|
// (instead of the vertical-arrow cursor) and refuses an invalid drop unless
|
||||||
@@ -163,6 +235,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
|
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
|
||||||
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
|
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
|
||||||
useFreshPlacementVisibility({ node })
|
useFreshPlacementVisibility({ node })
|
||||||
|
// Kinds that declare `movable.cursorAttached` (duct fittings) pin to the
|
||||||
|
// cursor instead of preserving the grab offset — small connector-like
|
||||||
|
// nodes read an offset drag as "lagging behind the mouse".
|
||||||
|
const cursorAttached = nodeRegistry.get(node.type)?.capabilities?.movable?.cursorAttached === true
|
||||||
|
// Kinds that declare `movable.portSnap` (duct terminals) magnetically
|
||||||
|
// mate one of their own ports onto a nearby scene port while dragging —
|
||||||
|
// a register collar drops onto a duct run end. Reads `def.ports` through
|
||||||
|
// the core registry, so it stays layer-clean (no @pascal-app/nodes import).
|
||||||
|
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
|
||||||
// Mirrors of `valid` / Shift for the event handlers inside the effect, which
|
// Mirrors of `valid` / Shift for the event handlers inside the effect, which
|
||||||
// can't read React state without stale closures.
|
// can't read React state without stale closures.
|
||||||
const validRef = useRef(true)
|
const validRef = useRef(true)
|
||||||
@@ -212,6 +293,45 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connectivity follow (duct fittings): the moved node with its live drag
|
||||||
|
// transform, so `def.ports` recomputes for `resolveConnectivityUpdates`.
|
||||||
|
// Uses the logical (un-stacked) position + Y rotation that commit writes,
|
||||||
|
// not the floor-lifted visual position.
|
||||||
|
const buildPreviewNode = (position: [number, number, number], rotationY: number): AnyNode =>
|
||||||
|
({
|
||||||
|
...(node as Record<string, unknown>),
|
||||||
|
position,
|
||||||
|
rotation: toCommitRotation(rotationY),
|
||||||
|
}) as AnyNode
|
||||||
|
|
||||||
|
// Resolve the patches that keep connected ductwork attached and preview
|
||||||
|
// them through `useLiveNodeOverrides` (transient — no history churn;
|
||||||
|
// GeometrySystem merges overrides via getEffectiveNode). Each connected
|
||||||
|
// node is re-dirtied so its geometry rebuilds against the new override.
|
||||||
|
const previewConnectivity = (position: [number, number, number], rotationY: number) => {
|
||||||
|
const connectivity = connectivityRef.current
|
||||||
|
if (!connectivity) return
|
||||||
|
const updates = resolveConnectivityUpdates(
|
||||||
|
connectivity,
|
||||||
|
buildPreviewNode(position, rotationY),
|
||||||
|
)
|
||||||
|
if (updates.length === 0) return
|
||||||
|
useLiveNodeOverrides
|
||||||
|
.getState()
|
||||||
|
.setMany(updates.map((u) => [u.id, u.data as Record<string, unknown>] as const))
|
||||||
|
overriddenIdsRef.current = updates.map((u) => u.id)
|
||||||
|
for (const u of updates) {
|
||||||
|
if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearConnectivityOverrides = () => {
|
||||||
|
for (const id of overriddenIdsRef.current) {
|
||||||
|
useLiveNodeOverrides.getState().clear(id)
|
||||||
|
if (useScene.getState().nodes[id]) useScene.getState().markDirty(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
||||||
|
|
||||||
// Re-run the floor-collision check at the live cursor + rotation and push
|
// Re-run the floor-collision check at the live cursor + rotation and push
|
||||||
@@ -277,6 +397,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
useViewer.getState().selection.levelId ?? node.parentId,
|
useViewer.getState().selection.levelId ?? node.parentId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Connectivity snapshot (existing port-bearing nodes only — fresh
|
||||||
|
// placements aren't connected to anything yet). Records which ducts /
|
||||||
|
// fittings are mated to this node's ports so they can follow the drag.
|
||||||
|
connectivityRef.current = null
|
||||||
|
overriddenIdsRef.current = []
|
||||||
|
if (!isNew && nodeRegistry.get(node.type)?.ports) {
|
||||||
|
const snapshot = analyzePortConnectivity(node, useScene.getState().nodes)
|
||||||
|
if (snapshot.connections.length > 0) connectivityRef.current = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
const rawX = event.localPosition[0]
|
const rawX = event.localPosition[0]
|
||||||
const rawZ = event.localPosition[2]
|
const rawZ = event.localPosition[2]
|
||||||
@@ -286,7 +416,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
cursor: [rawX, rawZ],
|
cursor: [rawX, rawZ],
|
||||||
original: [originalPosition[0], originalPosition[2]],
|
original: [originalPosition[0], originalPosition[2]],
|
||||||
anchor: dragAnchorRef.current,
|
anchor: dragAnchorRef.current,
|
||||||
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
|
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
|
||||||
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
|
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
|
||||||
})
|
})
|
||||||
dragAnchorRef.current = resolved.anchor
|
dragAnchorRef.current = resolved.anchor
|
||||||
@@ -313,6 +443,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Magnetic port snap (duct terminals): mate a collar onto a nearby
|
||||||
|
// duct run end. Takes precedence over grid / alignment snap; Alt
|
||||||
|
// bypasses. Only kinds that opted in via `movable.portSnap`.
|
||||||
|
if (!bypass && portSnapConfig) {
|
||||||
|
const mated = resolvePortSnap(node, [x, z], portSnapConfig)
|
||||||
|
if (mated) {
|
||||||
|
x = mated[0]
|
||||||
|
z = mated[1]
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const position: [number, number, number] = [x, originalPosition[1], z]
|
const position: [number, number, number] = [x, originalPosition[1], z]
|
||||||
const visualPosition = getVisualPosition(position)
|
const visualPosition = getVisualPosition(position)
|
||||||
hasMovedRef.current = true
|
hasMovedRef.current = true
|
||||||
@@ -337,6 +479,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
rotation: rotationRef.current,
|
rotation: rotationRef.current,
|
||||||
})
|
})
|
||||||
markMovedNodeDirty()
|
markMovedNodeDirty()
|
||||||
|
// Carry connected ductwork along (preview only — committed on drop).
|
||||||
|
previewConnectivity(position, rotationRef.current)
|
||||||
|
|
||||||
const prev = previousSnapRef.current
|
const prev = previousSnapRef.current
|
||||||
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) {
|
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) {
|
||||||
@@ -403,8 +547,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
committedId = finalId
|
committedId = finalId
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Fold the connected-ductwork follow-updates into the SAME
|
||||||
|
// batch as the moved node so the whole thing is one undo step.
|
||||||
|
const connectivityUpdates = connectivityRef.current
|
||||||
|
? resolveConnectivityUpdates(
|
||||||
|
connectivityRef.current,
|
||||||
|
buildPreviewNode(position, rotationRef.current),
|
||||||
|
).filter((u) => useScene.getState().nodes[u.id])
|
||||||
|
: []
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useScene.getState().updateNode(node.id, data)
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
committed = true
|
committed = true
|
||||||
}
|
}
|
||||||
@@ -430,6 +584,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
// canonical position, then restamp the lifted presentation Y for the
|
// canonical position, then restamp the lifted presentation Y for the
|
||||||
// current frame.
|
// current frame.
|
||||||
useLiveTransforms.getState().clear(node.id)
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
// Connected ductwork is now committed to the store — drop its live
|
||||||
|
// overrides so the renderers read the canonical path/position.
|
||||||
|
clearConnectivityOverrides()
|
||||||
const mesh = sceneRegistry.nodes.get(node.id)
|
const mesh = sceneRegistry.nodes.get(node.id)
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
mesh.position.set(...visualPosition)
|
mesh.position.set(...visualPosition)
|
||||||
@@ -491,6 +648,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
rotation: rotationRef.current,
|
rotation: rotationRef.current,
|
||||||
})
|
})
|
||||||
markMovedNodeDirty()
|
markMovedNodeDirty()
|
||||||
|
// Rotating the fitting swings its collars — connected ducts follow.
|
||||||
|
previewConnectivity(position, rotationRef.current)
|
||||||
// Rotation changes the footprint's collision span — re-check validity.
|
// Rotation changes the footprint's collision span — re-check validity.
|
||||||
recomputeValidity()
|
recomputeValidity()
|
||||||
}
|
}
|
||||||
@@ -533,6 +692,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
useLiveTransforms.getState().clear(node.id)
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
clearConnectivityOverrides()
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||||
} else {
|
} else {
|
||||||
@@ -570,6 +730,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
||||||
if (!(committed || isNew || finalisedBy2D)) {
|
if (!(committed || isNew || finalisedBy2D)) {
|
||||||
useLiveTransforms.getState().clear(node.id)
|
useLiveTransforms.getState().clear(node.id)
|
||||||
|
clearConnectivityOverrides()
|
||||||
sceneRegistry.nodes
|
sceneRegistry.nodes
|
||||||
.get(node.id)
|
.get(node.id)
|
||||||
?.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
?.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
||||||
@@ -579,6 +740,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
boxDimensions,
|
boxDimensions,
|
||||||
|
cursorAttached,
|
||||||
|
portSnapConfig,
|
||||||
exitMoveMode,
|
exitMoveMode,
|
||||||
isFreshPlacement,
|
isFreshPlacement,
|
||||||
node,
|
node,
|
||||||
|
|||||||
@@ -25,4 +25,12 @@ export const tools: ToolConfig[] = [
|
|||||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||||
{ id: 'spawn', iconSrc: '/icons/spawn-point.png', label: 'Spawn Point' },
|
{ id: 'spawn', iconSrc: '/icons/spawn-point.png', label: 'Spawn Point' },
|
||||||
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
||||||
|
{ id: 'duct-segment', iconSrc: '/icons/duct.png', label: 'Duct' },
|
||||||
|
{ id: 'duct-fitting', iconSrc: '/icons/duct-fitting.png', label: 'Duct Fitting' },
|
||||||
|
{ id: 'duct-terminal', iconSrc: '/icons/registers.png', label: 'Register' },
|
||||||
|
{ id: 'hvac-equipment', iconSrc: '/icons/HVAC.png', label: 'HVAC Unit' },
|
||||||
|
{ id: 'pipe-segment', iconSrc: '/icons/dwv-pipes.png', label: 'DWV Pipe' },
|
||||||
|
{ id: 'pipe-fitting', iconSrc: '/icons/duct-fitting.png', label: 'Pipe Fitting' },
|
||||||
|
{ id: 'lineset', iconSrc: '/icons/lineset.png', label: 'Lineset' },
|
||||||
|
{ id: 'liquid-line', iconSrc: '/icons/lineset.png', label: 'Liquid Line' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
|
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2, Waypoints } from 'lucide-react'
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { getLevelDisplayName } from '@pascal-app/core'
|
import { getLevelDisplayName } from '@pascal-app/core'
|
||||||
@@ -989,6 +989,29 @@ function ReferenceFloorControl() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Riser diagram control ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RiserControl() {
|
||||||
|
const isRiserOpen = useEditor((state) => state.isRiserOpen)
|
||||||
|
const toggleRiserOpen = useEditor((state) => state.toggleRiserOpen)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionButton
|
||||||
|
className={cn(
|
||||||
|
isRiserOpen
|
||||||
|
? 'bg-white/15'
|
||||||
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||||
|
)}
|
||||||
|
label="Riser diagram"
|
||||||
|
onClick={toggleRiserOpen}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Waypoints className="h-4 w-4" />
|
||||||
|
</ActionButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Exports ─────────────────────────────────────────────────────────────────
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export { GridSnapControl }
|
export { GridSnapControl }
|
||||||
@@ -1008,6 +1031,7 @@ export function ViewToggles() {
|
|||||||
<ScansControl />
|
<ScansControl />
|
||||||
<GuidesControl />
|
<GuidesControl />
|
||||||
<ReferenceFloorControl />
|
<ReferenceFloorControl />
|
||||||
|
<RiserControl />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,9 +62,22 @@ export function ParametricInspector({
|
|||||||
const handleUpdate = useCallback(
|
const handleUpdate = useCallback(
|
||||||
(patch: Partial<AnyNode>) => {
|
(patch: Partial<AnyNode>) => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
useScene.getState().updateNode(selectedId, patch)
|
const scene = useScene.getState()
|
||||||
|
const node = scene.nodes[selectedId]
|
||||||
|
if (parametrics?.derive && node) {
|
||||||
|
const next = { ...node, ...patch } as AnyNode
|
||||||
|
patch = { ...patch, ...parametrics.derive(next, patch) }
|
||||||
|
}
|
||||||
|
// Bundle the edited node + any reconcile follow-ups into ONE
|
||||||
|
// updateNodes call so a single inspector edit is a single undo step.
|
||||||
|
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [{ id: selectedId, data: patch }]
|
||||||
|
if (parametrics?.reconcile && node) {
|
||||||
|
const next = { ...node, ...patch } as AnyNode
|
||||||
|
updates.push(...parametrics.reconcile(node as AnyNode, next))
|
||||||
|
}
|
||||||
|
scene.updateNodes(updates)
|
||||||
},
|
},
|
||||||
[selectedId],
|
[selectedId, parametrics],
|
||||||
)
|
)
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ export { default as Editor } from './components/editor'
|
|||||||
// surface uses the shorter, shell-friendly names from the unified
|
// surface uses the shorter, shell-friendly names from the unified
|
||||||
// preset-system spec.
|
// preset-system spec.
|
||||||
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
||||||
export { formatMeasurement, MeasurementPill } from './components/editor/measurement-pill'
|
export {
|
||||||
|
DimensionPill,
|
||||||
|
type DimensionPillPart,
|
||||||
|
formatMeasurement,
|
||||||
|
MeasurementPill,
|
||||||
|
} from './components/editor/measurement-pill'
|
||||||
export {
|
export {
|
||||||
type SnapshotCameraData,
|
type SnapshotCameraData,
|
||||||
ThumbnailGenerator,
|
ThumbnailGenerator,
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ export type StructureTool =
|
|||||||
| 'dormer'
|
| 'dormer'
|
||||||
| 'gutter'
|
| 'gutter'
|
||||||
| 'downspout'
|
| 'downspout'
|
||||||
|
| 'duct-segment'
|
||||||
|
| 'duct-fitting'
|
||||||
|
| 'duct-terminal'
|
||||||
|
| 'hvac-equipment'
|
||||||
|
| 'lineset'
|
||||||
|
| 'liquid-line'
|
||||||
|
| 'pipe-segment'
|
||||||
|
| 'pipe-fitting'
|
||||||
|
|
||||||
// Furnish mode tools (items and decoration)
|
// Furnish mode tools (items and decoration)
|
||||||
export type FurnishTool = 'item'
|
export type FurnishTool = 'item'
|
||||||
@@ -291,6 +299,14 @@ type EditorState = {
|
|||||||
*/
|
*/
|
||||||
activeHandleDrag: { nodeId: AnyNodeId; label: string } | null
|
activeHandleDrag: { nodeId: AnyNodeId; label: string } | null
|
||||||
setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | null) => void
|
setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | null) => void
|
||||||
|
/**
|
||||||
|
* World axis the R/T keyboard rotation turns around, for kinds with
|
||||||
|
* full 3D orientation (duct fittings). Alt cycles it Y → X → Z; the
|
||||||
|
* kind's tool / keyboard actions read it, and the floating action
|
||||||
|
* menu surfaces it in a pill above the selected node.
|
||||||
|
*/
|
||||||
|
rotationAxis: 'x' | 'y' | 'z'
|
||||||
|
cycleRotationAxis: () => 'x' | 'y' | 'z'
|
||||||
curvingWall: WallNode | null
|
curvingWall: WallNode | null
|
||||||
setCurvingWall: (wall: WallNode | null) => void
|
setCurvingWall: (wall: WallNode | null) => void
|
||||||
curvingFence: FenceNode | null
|
curvingFence: FenceNode | null
|
||||||
@@ -348,6 +364,10 @@ type EditorState = {
|
|||||||
toggleFloorplanOpen: () => void
|
toggleFloorplanOpen: () => void
|
||||||
isFloorplanHovered: boolean
|
isFloorplanHovered: boolean
|
||||||
setFloorplanHovered: (hovered: boolean) => void
|
setFloorplanHovered: (hovered: boolean) => void
|
||||||
|
// Toggleable DWV riser-diagram (plumbing isometric) overlay.
|
||||||
|
isRiserOpen: boolean
|
||||||
|
setRiserOpen: (open: boolean) => void
|
||||||
|
toggleRiserOpen: () => void
|
||||||
navigationSyncPose: NavigationSyncPose | null
|
navigationSyncPose: NavigationSyncPose | null
|
||||||
publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
|
publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
|
||||||
floorplanSelectionTool: FloorplanSelectionTool
|
floorplanSelectionTool: FloorplanSelectionTool
|
||||||
@@ -808,6 +828,13 @@ const useEditor = create<EditorState>()(
|
|||||||
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
||||||
activeHandleDrag: null,
|
activeHandleDrag: null,
|
||||||
setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
|
setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
|
||||||
|
rotationAxis: 'y',
|
||||||
|
cycleRotationAxis: () => {
|
||||||
|
const order = ['y', 'x', 'z'] as const
|
||||||
|
const next = order[(order.indexOf(get().rotationAxis as 'y' | 'x' | 'z') + 1) % 3]!
|
||||||
|
set({ rotationAxis: next })
|
||||||
|
return next
|
||||||
|
},
|
||||||
curvingWall: null,
|
curvingWall: null,
|
||||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||||
curvingFence: null,
|
curvingFence: null,
|
||||||
@@ -934,6 +961,9 @@ const useEditor = create<EditorState>()(
|
|||||||
}),
|
}),
|
||||||
isFloorplanHovered: false,
|
isFloorplanHovered: false,
|
||||||
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
||||||
|
isRiserOpen: false,
|
||||||
|
setRiserOpen: (open) => set({ isRiserOpen: open }),
|
||||||
|
toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })),
|
||||||
navigationSyncPose: null,
|
navigationSyncPose: null,
|
||||||
publishNavigationSyncPose: (pose) =>
|
publishNavigationSyncPose: (pose) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { BoxVentNode } from './schema'
|
|||||||
* the cursor ray and starve the placement tool of `roof:move` events.
|
* the cursor ray and starve the placement tool of `roof:move` events.
|
||||||
*/
|
*/
|
||||||
const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolean }) => {
|
const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolean }) => {
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildBoxVentGeometry(node),
|
() => buildBoxVentGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.hoodOverhang, node.style],
|
[node.width, node.depth, node.height, node.hoodOverhang, node.style],
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
|||||||
// every parametric field, including the per-style ones. Listing them
|
// every parametric field, including the per-style ones. Listing them
|
||||||
// explicitly keeps the dep array tight (vs. `[node]` which would
|
// explicitly keeps the dep array tight (vs. `[node]` which would
|
||||||
// also fire on `name` / `visible` flips).
|
// also fire on `name` / `visible` flips).
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildBoxVentGeometry(node),
|
() => buildBoxVentGeometry(node),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const ChimneyPreview = ({
|
|||||||
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||||
const effectiveSegment = segment ?? RoofSegmentSchema.parse({})
|
const effectiveSegment = segment ?? RoofSegmentSchema.parse({})
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geo = useMemo(
|
const geo = useMemo(
|
||||||
() => buildChimneyGeometry(node, effectiveSegment),
|
() => buildChimneyGeometry(node, effectiveSegment),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -77,24 +77,16 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
|
|||||||
}, [node, segment])
|
}, [node, segment])
|
||||||
|
|
||||||
// Segment brushes for the body trim. Building these is non-trivial
|
// Segment brushes for the body trim. Building these is non-trivial
|
||||||
// (4 CSG-ready Brush instances per segment), so memoise by the shape
|
// (4 CSG-ready Brush instances per segment). `segment` comes from a
|
||||||
// fields that drive their geometry. A chimney slider drag changes
|
// `useScene` selector, so it only re-identifies when the segment's own
|
||||||
// `node.*` but not these, so the cached brushes survive the drag —
|
// data changes — depend on it directly (as the `geo` memo above does)
|
||||||
// previously each frame rebuilt all four.
|
// and the brushes rebuild exactly when the host roof reshapes, incl.
|
||||||
const segmentBrushes = useMemo(
|
// the gambrel / mansard / dutch-hip width-ratio fields that
|
||||||
() => (segment ? getRoofSegmentBrushes(segment) : null),
|
// `getRoofSegmentBrushes` reads. A chimney slider drag changes `node`,
|
||||||
[
|
// not `segment`, so the cache still survives the drag. Enumerating
|
||||||
segment?.roofType,
|
// individual fields here previously omitted those ratios and left the
|
||||||
segment?.width,
|
// trim CSG-ing against a stale roof outline.
|
||||||
segment?.depth,
|
const segmentBrushes = useMemo(() => (segment ? getRoofSegmentBrushes(segment) : null), [segment])
|
||||||
segment?.wallHeight,
|
|
||||||
segment?.pitch,
|
|
||||||
segment?.wallThickness,
|
|
||||||
segment?.deckThickness,
|
|
||||||
segment?.overhang,
|
|
||||||
segment?.shingleThickness,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (segmentBrushes) {
|
if (segmentBrushes) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { CupolaNode } from './schema'
|
|||||||
* so the preview doesn't intercept the cursor ray feeding the tool.
|
* so the preview doesn't intercept the cursor ray feeding the tool.
|
||||||
*/
|
*/
|
||||||
const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean }) => {
|
const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean }) => {
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildCupolaGeometry(node),
|
() => buildCupolaGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.roofStyle, node.finial],
|
[node.width, node.depth, node.height, node.roofStyle, node.finial],
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildCupolaGeometry(node),
|
() => buildCupolaGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.roofStyle, node.finial],
|
[node.width, node.depth, node.height, node.roofStyle, node.finial],
|
||||||
|
|||||||
@@ -325,7 +325,6 @@ export function generateDormerGeometry(
|
|||||||
|
|
||||||
const dormerBrushes = getRoofSegmentBrushes(virtualSegment)
|
const dormerBrushes = getRoofSegmentBrushes(virtualSegment)
|
||||||
if (!dormerBrushes) {
|
if (!dormerBrushes) {
|
||||||
// biome-ignore lint/suspicious/noConsole: keep diagnostic — fallback path.
|
|
||||||
console.warn('[dormer] getRoofSegmentBrushes returned null; using fallback silhouette.')
|
console.warn('[dormer] getRoofSegmentBrushes returned null; using fallback silhouette.')
|
||||||
return buildDormerFallbackGeometry(dormer)
|
return buildDormerFallbackGeometry(dormer)
|
||||||
}
|
}
|
||||||
@@ -472,7 +471,6 @@ export function generateDormerGeometry(
|
|||||||
remapRoofShellFaces(resultGeo, virtualSegment)
|
remapRoofShellFaces(resultGeo, virtualSegment)
|
||||||
splitDormerGableMaterial(resultGeo, dormer.height, DORMER_GABLE_MATERIAL_INDEX)
|
splitDormerGableMaterial(resultGeo, dormer.height, DORMER_GABLE_MATERIAL_INDEX)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// biome-ignore lint/suspicious/noConsole: dormer CSG can throw; keep diagnostic.
|
|
||||||
console.error('[dormer] CSG failed, falling back to silhouette:', e)
|
console.error('[dormer] CSG failed, falling back to silhouette:', e)
|
||||||
if (dormerSolid) {
|
if (dormerSolid) {
|
||||||
try {
|
try {
|
||||||
@@ -492,7 +490,6 @@ export function generateDormerGeometry(
|
|||||||
// dormer is at least visible.
|
// dormer is at least visible.
|
||||||
const triCount = resultGeo.getIndex()?.count ?? resultGeo.getAttribute('position')?.count ?? 0
|
const triCount = resultGeo.getIndex()?.count ?? resultGeo.getAttribute('position')?.count ?? 0
|
||||||
if (triCount === 0) {
|
if (triCount === 0) {
|
||||||
// biome-ignore lint/suspicious/noConsole: keep diagnostic — empty CSG.
|
|
||||||
console.warn('[dormer] CSG produced empty geometry; using fallback silhouette.')
|
console.warn('[dormer] CSG produced empty geometry; using fallback silhouette.')
|
||||||
return buildDormerFallbackGeometry(dormer)
|
return buildDormerFallbackGeometry(dormer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function DormerPositionSection({
|
|||||||
const segmentId = segment?.id
|
const segmentId = segment?.id
|
||||||
const roofChildrenKey = (roof?.children ?? []).join(',')
|
const roofChildrenKey = (roof?.children ?? []).join(',')
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: roofChildrenKey is the stable signature of `roof.children`; intentionally omitting `roof` (object identity) in favor of the joined ids.
|
||||||
const worldXform = useMemo(() => {
|
const worldXform = useMemo(() => {
|
||||||
const dormerObj = sceneRegistry.nodes.get(selectedId)
|
const dormerObj = sceneRegistry.nodes.get(selectedId)
|
||||||
let worldX = 0
|
let worldX = 0
|
||||||
@@ -79,7 +80,6 @@ export function DormerPositionSection({
|
|||||||
if (Number.isFinite(lo_x)) bounds = { minX: lo_x, maxX: hi_x, minZ: lo_z, maxZ: hi_z }
|
if (Number.isFinite(lo_x)) bounds = { minX: lo_x, maxX: hi_x, minZ: lo_z, maxZ: hi_z }
|
||||||
}
|
}
|
||||||
return { worldX, worldZ, worldRotation, bounds }
|
return { worldX, worldZ, worldRotation, bounds }
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: roofChildrenKey is the stable signature of `roof.children`; intentionally omitting `roof` (object identity) in favor of the joined ids.
|
|
||||||
}, [selectedId, px, py, pz, nodeRotation, segmentId, roofChildrenKey])
|
}, [selectedId, px, py, pz, nodeRotation, segmentId, roofChildrenKey])
|
||||||
|
|
||||||
const worldX_now = worldXform.worldX
|
const worldX_now = worldXform.worldX
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function DormerPanel() {
|
|||||||
}, [node, selectedId, setMovingNode, setSelection])
|
}, [node, selectedId, setMovingNode, setSelection])
|
||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!(node && node.roofSegmentId)) return
|
if (!node?.roofSegmentId) return
|
||||||
triggerSFX('sfx:item-pick')
|
triggerSFX('sfx:item-pick')
|
||||||
// Deep clone and strip the id so the move tool's onClick branch
|
// Deep clone and strip the id so the move tool's onClick branch
|
||||||
// (`isNew || !node.id`) takes the "create fresh" path. Setting
|
// (`isNew || !node.id`) takes the "create fresh" path. Setting
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const invalidGhostMaterial = new THREE.MeshStandardMaterial({
|
|||||||
const DormerPreview = ({ node, invalid }: { node: DormerNode; invalid?: boolean }) => {
|
const DormerPreview = ({ node, invalid }: { node: DormerNode; invalid?: boolean }) => {
|
||||||
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
const material = invalid ? invalidGhostMaterial : ghostMaterial
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geo = useMemo(
|
const geo = useMemo(
|
||||||
() => buildDormerGhostGeometry(node),
|
() => buildDormerGhostGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight],
|
[node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight],
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
|||||||
// shingle, 4=Gable wall. Walls take the 'wall' role, the deck side and
|
// shingle, 4=Gable wall. Walls take the 'wall' role, the deck side and
|
||||||
// shingle take 'roof'. When textures are off, every slot snaps to its
|
// shingle take 'roof'. When textures are off, every slot snaps to its
|
||||||
// role colour regardless of explicit paint (the render-modes invariant).
|
// role colour regardless of explicit paint (the render-modes invariant).
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const wallRole = () => createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
|
const wallRole = () => createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
|
||||||
const roofRole = () => createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme)
|
const roofRole = () => createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme)
|
||||||
@@ -111,6 +112,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
|||||||
[colorPreset, sceneTheme],
|
[colorPreset, sceneTheme],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(() => {
|
const geometry = useMemo(() => {
|
||||||
if (!segment) return null
|
if (!segment) return null
|
||||||
if (isLiveDrag) return buildDormerFallbackGeometry(node)
|
if (isLiveDrag) return buildDormerFallbackGeometry(node)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const DormerWindowAssembly = ({
|
|||||||
frameMaterial: THREE.Material
|
frameMaterial: THREE.Material
|
||||||
glassMaterial: THREE.Material
|
glassMaterial: THREE.Material
|
||||||
}) => {
|
}) => {
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const skirtWin = useMemo(
|
const skirtWin = useMemo(
|
||||||
() => getDormerSkirtWindowDims(node),
|
() => getDormerSkirtWindowDims(node),
|
||||||
[
|
[
|
||||||
@@ -45,6 +46,7 @@ const DormerWindowAssembly = ({
|
|||||||
const winShape: DormerWindowShape = node.windowShape
|
const winShape: DormerWindowShape = node.windowShape
|
||||||
const resolvedRadii: [number, number, number, number] = [...node.windowCornerRadii]
|
const resolvedRadii: [number, number, number, number] = [...node.windowCornerRadii]
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const winGeo = useMemo(
|
const winGeo = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildDormerWindowGeometries(
|
buildDormerWindowGeometries(
|
||||||
@@ -101,6 +103,7 @@ const DormerWindowAssembly = ({
|
|||||||
)
|
)
|
||||||
useEffect(() => () => sillGeo?.dispose(), [sillGeo])
|
useEffect(() => () => sillGeo?.dispose(), [sillGeo])
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const exposed = useMemo(
|
const exposed = useMemo(
|
||||||
() => getDormerExposedFaces(node, segment),
|
() => getDormerExposedFaces(node, segment),
|
||||||
[
|
[
|
||||||
@@ -142,7 +145,6 @@ const DormerWindowAssembly = ({
|
|||||||
{winGeo.glassPanes.map((pane, i) => (
|
{winGeo.glassPanes.map((pane, i) => (
|
||||||
<mesh
|
<mesh
|
||||||
geometry={pane.geo}
|
geometry={pane.geo}
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: glass panes are derived from grid indices, no stable id.
|
|
||||||
key={`${keyPrefix}-glass-${i}`}
|
key={`${keyPrefix}-glass-${i}`}
|
||||||
material={glassMaterial}
|
material={glassMaterial}
|
||||||
name={`dormer-glass-${keyPrefix}-${i}`}
|
name={`dormer-glass-${keyPrefix}-${i}`}
|
||||||
@@ -153,7 +155,6 @@ const DormerWindowAssembly = ({
|
|||||||
<mesh
|
<mesh
|
||||||
castShadow
|
castShadow
|
||||||
geometry={bar.geo}
|
geometry={bar.geo}
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: frame bars are derived from grid indices, no stable id.
|
|
||||||
key={`${keyPrefix}-bar-${i}`}
|
key={`${keyPrefix}-bar-${i}`}
|
||||||
material={frameMaterial}
|
material={frameMaterial}
|
||||||
name={`dormer-frame-${keyPrefix}-${i}`}
|
name={`dormer-frame-${keyPrefix}-${i}`}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const DownspoutPreview = ({
|
|||||||
routing?: DownspoutRouting | null
|
routing?: DownspoutRouting | null
|
||||||
invalid?: boolean
|
invalid?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildDownspoutGeometry(node, routing),
|
() => buildDownspoutGeometry(node, routing),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => {
|
|||||||
// that actually move the jog or the collar bore, so the pipe geometry
|
// that actually move the jog or the collar bore, so the pipe geometry
|
||||||
// only rebuilds when one of those changes (not on every override-merge
|
// only rebuilds when one of those changes (not on every override-merge
|
||||||
// render). Resolves to null when the gutter has no outlet.
|
// render). Resolves to null when the gutter has no outlet.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const routing = useMemo(
|
const routing = useMemo(
|
||||||
() =>
|
() =>
|
||||||
effectiveGutter && effectiveSegment
|
effectiveGutter && effectiveSegment
|
||||||
@@ -117,6 +118,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildDownspoutGeometry(node, routing),
|
() => buildDownspoutGeometry(node, routing),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { NodeDefinition } from '@pascal-app/core'
|
||||||
|
import { rotateFittingNode } from '../shared/fitting-rotation'
|
||||||
|
import { buildDuctFittingFloorplan } from './floorplan'
|
||||||
|
import { buildDuctFittingGeometry } from './geometry'
|
||||||
|
import { ductFittingParametrics } from './parametrics'
|
||||||
|
import { getDuctFittingPorts } from './ports'
|
||||||
|
import { DuctFittingNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2 of the HVAC node system — duct fittings (elbow / tee / reducer)
|
||||||
|
* and the first kind to expose typed ports (`def.ports`).
|
||||||
|
*
|
||||||
|
* Composition: `def.geometry` only, same as duct-segment. Ports are the
|
||||||
|
* architectural payload: placement tools snap onto them, and a later
|
||||||
|
* slice walks them to build the supply/return system graph.
|
||||||
|
*/
|
||||||
|
export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
||||||
|
kind: 'duct-fitting',
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: DuctFittingNode,
|
||||||
|
category: 'utility',
|
||||||
|
distributionRole: 'fitting',
|
||||||
|
|
||||||
|
defaults: () => ({
|
||||||
|
object: 'node',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
fittingType: 'elbow',
|
||||||
|
shape: 'round',
|
||||||
|
width: 14,
|
||||||
|
height: 8,
|
||||||
|
shape2: 'round',
|
||||||
|
width2: 14,
|
||||||
|
height2: 8,
|
||||||
|
angle: 90,
|
||||||
|
branchAngle: 90,
|
||||||
|
diameter: 6,
|
||||||
|
diameter2: 6,
|
||||||
|
ductMaterial: 'sheet-metal',
|
||||||
|
system: 'supply',
|
||||||
|
}),
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
selectable: { hitVolume: 'bbox' },
|
||||||
|
// `cursorAttached`: a fitting is a small connector — an offset-
|
||||||
|
// preserving drag reads as the mesh trailing the mouse, so pin its
|
||||||
|
// origin to the cursor instead.
|
||||||
|
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
|
||||||
|
duplicable: true,
|
||||||
|
deletable: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
parametrics: ductFittingParametrics,
|
||||||
|
|
||||||
|
geometry: buildDuctFittingGeometry,
|
||||||
|
geometryKey: (n) =>
|
||||||
|
JSON.stringify([
|
||||||
|
n.fittingType,
|
||||||
|
// The mitered elbow + flange profiles swap width/height roles based
|
||||||
|
// on where world-up sits in the local frame, so orientation is a
|
||||||
|
// geometry input.
|
||||||
|
n.rotation,
|
||||||
|
n.shape,
|
||||||
|
n.width,
|
||||||
|
n.height,
|
||||||
|
n.shape2,
|
||||||
|
n.width2,
|
||||||
|
n.height2,
|
||||||
|
n.angle,
|
||||||
|
n.branchAngle,
|
||||||
|
n.diameter,
|
||||||
|
n.diameter2,
|
||||||
|
n.ductMaterial,
|
||||||
|
n.system,
|
||||||
|
]),
|
||||||
|
|
||||||
|
ports: getDuctFittingPorts,
|
||||||
|
|
||||||
|
floorplan: buildDuctFittingFloorplan,
|
||||||
|
|
||||||
|
// R/T rotate a selected fitting ±45° around the shared active axis.
|
||||||
|
// The default editor rotate only knows Y; fittings need X/Z for
|
||||||
|
// risers, so this overrides it. Alt-cycling of the axis + the axis
|
||||||
|
// badge live in `./selection.tsx`.
|
||||||
|
keyboardActions: {
|
||||||
|
r: {
|
||||||
|
appliesTo: (node) => node.type === 'duct-fitting',
|
||||||
|
run: (node) => rotateFittingNode(node, 1),
|
||||||
|
},
|
||||||
|
t: {
|
||||||
|
appliesTo: (node) => node.type === 'duct-fitting',
|
||||||
|
run: (node) => rotateFittingNode(node, -1),
|
||||||
|
},
|
||||||
|
axisCycling: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Alt-cycles the active rotation axis while a fitting is selected.
|
||||||
|
// Editor-only (drives `useEditor.rotationAxis`), so it mounts via the
|
||||||
|
// editor's SelectionAffordanceManager rather than `def.system`.
|
||||||
|
affordanceTools: {
|
||||||
|
selection: () => import('./selection'),
|
||||||
|
// Ghost-preview duplicate / move. Duplicate is pure drag-to-place: a
|
||||||
|
// translucent copy of the fitting (built from its real geometry, at its
|
||||||
|
// own rotation, so an elbow / riser stays properly aligned) follows the
|
||||||
|
// cursor and only lands on the commit click. Takes priority over
|
||||||
|
// `capabilities.movable` in the MoveTool dispatcher.
|
||||||
|
move: () => import('./move-tool'),
|
||||||
|
},
|
||||||
|
|
||||||
|
tool: () => import('./tool'),
|
||||||
|
toolHints: [
|
||||||
|
{ key: 'Click', label: 'Place fitting' },
|
||||||
|
{ key: 'Hover a duct end', label: 'Snap onto the run' },
|
||||||
|
{ key: 'R / T', label: 'Rotate ±45°' },
|
||||||
|
{ key: 'Alt', label: 'Switch rotation axis (Y → X → Z)' },
|
||||||
|
{ key: 'Esc', label: 'Exit' },
|
||||||
|
],
|
||||||
|
|
||||||
|
presentation: {
|
||||||
|
label: 'Duct Fitting',
|
||||||
|
description: 'Elbow, tee, reducer, or square-to-round transition connecting duct runs.',
|
||||||
|
icon: { kind: 'url', src: '/icons/duct-fitting.png' },
|
||||||
|
paletteSection: 'structure',
|
||||||
|
paletteOrder: 91,
|
||||||
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
description:
|
||||||
|
'A duct fitting (elbow, tee, reducer, or square-to-round transition) with typed connection ports. Position is level-local meters; rotation is an XYZ euler in radians.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
|
||||||
|
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||||
|
import { getDuctFittingPorts } from './ports'
|
||||||
|
import type { DuctFittingNode } from './schema'
|
||||||
|
|
||||||
|
const SUPPLY_COLOR = '#d4825a'
|
||||||
|
const RETURN_COLOR = '#5a8ad4'
|
||||||
|
const BODY_COLOR = '#9ca3af'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor-plan symbol for a duct fitting: one stub line per port from the
|
||||||
|
* junction center out to the collar (drawn at each collar's real
|
||||||
|
* diameter), plus a junction circle. Ports are computed in level-local
|
||||||
|
* 3D and projected to plan, so a rotated or riser-turned fitting shows
|
||||||
|
* its true plan footprint; a vertical port collapses onto the junction
|
||||||
|
* circle, which is exactly how it should read from above.
|
||||||
|
*/
|
||||||
|
export function buildDuctFittingFloorplan(
|
||||||
|
node: DuctFittingNode,
|
||||||
|
ctx: GeometryContext,
|
||||||
|
): FloorplanGeometry | null {
|
||||||
|
const [cx, , cz] = node.position
|
||||||
|
const ports = getDuctFittingPorts(node)
|
||||||
|
const view = ctx.viewState
|
||||||
|
const palette = view?.palette
|
||||||
|
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
|
||||||
|
const accent = node.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR
|
||||||
|
const bodyStroke = showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR
|
||||||
|
|
||||||
|
const children: FloorplanGeometry[] = []
|
||||||
|
for (const port of ports) {
|
||||||
|
const px = port.position[0]
|
||||||
|
const pz = port.position[2]
|
||||||
|
// Vertical port — projects onto the junction itself; skip the stub.
|
||||||
|
if (Math.hypot(px - cx, pz - cz) < 1e-4) continue
|
||||||
|
children.push({
|
||||||
|
kind: 'line',
|
||||||
|
x1: cx,
|
||||||
|
y1: cz,
|
||||||
|
x2: px,
|
||||||
|
y2: pz,
|
||||||
|
stroke: bodyStroke,
|
||||||
|
strokeWidth: port.diameter * INCHES_TO_METERS,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
opacity: showSelectedChrome ? 0.95 : 0.8,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push({
|
||||||
|
kind: 'circle',
|
||||||
|
cx,
|
||||||
|
cy: cz,
|
||||||
|
r: (node.diameter * INCHES_TO_METERS) / 2 + 0.015,
|
||||||
|
fill: bodyStroke,
|
||||||
|
stroke: accent,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
vectorEffect: 'non-scaling-stroke',
|
||||||
|
opacity: 0.95,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (showSelectedChrome) {
|
||||||
|
children.push({
|
||||||
|
kind: 'move-handle',
|
||||||
|
point: [cx, cz],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'group', children }
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import {
|
||||||
|
BufferGeometry,
|
||||||
|
CylinderGeometry,
|
||||||
|
DoubleSide,
|
||||||
|
Euler,
|
||||||
|
Float32BufferAttribute,
|
||||||
|
Group,
|
||||||
|
Mesh,
|
||||||
|
type MeshStandardMaterial,
|
||||||
|
SphereGeometry,
|
||||||
|
TorusGeometry,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
|
import {
|
||||||
|
buildOvalSection,
|
||||||
|
buildRectSection,
|
||||||
|
buildSection,
|
||||||
|
createDuctMaterial,
|
||||||
|
INCHES_TO_METERS,
|
||||||
|
} from '../duct-segment/geometry'
|
||||||
|
import { localFittingPorts } from './ports'
|
||||||
|
import type { DuctFittingNode } from './schema'
|
||||||
|
|
||||||
|
const RADIAL_SEGMENTS = 24
|
||||||
|
const UP = new Vector3(0, 1, 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mitered rectangular elbow as ONE closed solid — the way sheet-metal
|
||||||
|
* square elbows are actually folded. The rect profile sweeps from the
|
||||||
|
* inlet face to the outlet face through a single miter ring lying on
|
||||||
|
* the corner's bisector plane (the classic 2D miter-join offset:
|
||||||
|
* join(u) = (wA + wB) · u / (1 + wA·wB)), so the two legs meet in a
|
||||||
|
* crisp seam instead of interpenetrating boxes.
|
||||||
|
*
|
||||||
|
* Local frame: legs in the XZ plane (ports convention) so the fold hinge
|
||||||
|
* is always local Y. `sweepM` is the profile dimension carried through the
|
||||||
|
* bend (in the XZ bend plane); `cheekM` is the dimension that stays
|
||||||
|
* constant along the hinge. Which physical dimension (width vs height)
|
||||||
|
* plays each role depends on the elbow's world orientation and is decided
|
||||||
|
* by the caller — a floor turn folds about vertical (cheek = height),
|
||||||
|
* a wall riser folds about horizontal (cheek = width).
|
||||||
|
*
|
||||||
|
* Non-indexed triangles → flat face normals for the folded-metal look;
|
||||||
|
* the closed solid renders double-sided so winding never makes a face
|
||||||
|
* vanish.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Stadium (flat-oval) outline in profile (u, v) coordinates: u-extent
|
||||||
|
* `uM`, v-extent `vM`, semicircular caps of the smaller dimension. The
|
||||||
|
* caps land on whichever axis is longer, so a riser-rotated profile
|
||||||
|
* (swapped roles) stays a valid stadium.
|
||||||
|
*/
|
||||||
|
function stadiumOutline(uM: number, vM: number, samplesPerCap = 10): Array<[number, number]> {
|
||||||
|
const pts: Array<[number, number]> = []
|
||||||
|
const r = Math.min(uM, vM) / 2
|
||||||
|
const s = (Math.max(uM, vM) - Math.min(uM, vM)) / 2
|
||||||
|
const cap = (cu: number, cv: number, startA: number) => {
|
||||||
|
for (let i = 0; i <= samplesPerCap; i++) {
|
||||||
|
const a = startA + (Math.PI * i) / samplesPerCap
|
||||||
|
pts.push([cu + r * Math.cos(a), cv + r * Math.sin(a)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uM >= vM) {
|
||||||
|
cap(s, 0, -Math.PI / 2)
|
||||||
|
cap(-s, 0, Math.PI / 2)
|
||||||
|
} else {
|
||||||
|
cap(0, s, 0)
|
||||||
|
cap(0, -s, Math.PI)
|
||||||
|
}
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMiteredElbow(
|
||||||
|
inletPos: Vector3,
|
||||||
|
outletPos: Vector3,
|
||||||
|
sweepM: number,
|
||||||
|
cheekM: number,
|
||||||
|
profileShape: 'rect' | 'oval',
|
||||||
|
material: MeshStandardMaterial,
|
||||||
|
): Mesh {
|
||||||
|
const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction
|
||||||
|
const travelOut = outletPos.clone().normalize() // junction → outlet
|
||||||
|
const wA = new Vector3().crossVectors(UP, travelIn).normalize()
|
||||||
|
const wB = new Vector3().crossVectors(UP, travelOut).normalize()
|
||||||
|
// Elbow turns are ≤ 90°, so wA·wB ≥ 0 and the join never degenerates.
|
||||||
|
const miterScale = 1 / (1 + wA.dot(wB))
|
||||||
|
const wJoin = new Vector3().addVectors(wA, wB)
|
||||||
|
|
||||||
|
const hw = sweepM / 2
|
||||||
|
const hh = cheekM / 2
|
||||||
|
const corners: Array<[number, number]> =
|
||||||
|
profileShape === 'oval'
|
||||||
|
? stadiumOutline(sweepM, cheekM)
|
||||||
|
: [
|
||||||
|
[hw, hh],
|
||||||
|
[-hw, hh],
|
||||||
|
[-hw, -hh],
|
||||||
|
[hw, -hh],
|
||||||
|
]
|
||||||
|
const n = corners.length
|
||||||
|
const ring = (center: Vector3, uAxis: Vector3, scale = 1): Vector3[] =>
|
||||||
|
corners.map(([u, v]) =>
|
||||||
|
center
|
||||||
|
.clone()
|
||||||
|
.addScaledVector(uAxis, u * scale)
|
||||||
|
.addScaledVector(UP, v),
|
||||||
|
)
|
||||||
|
|
||||||
|
const inletRing = ring(inletPos, wA)
|
||||||
|
const miterRing = ring(new Vector3(0, 0, 0), wJoin, miterScale)
|
||||||
|
const outletRing = ring(outletPos, wB)
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const tri = (a: Vector3, b: Vector3, c: Vector3) =>
|
||||||
|
positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
|
||||||
|
const quad = (a: Vector3, b: Vector3, c: Vector3, d: Vector3) => {
|
||||||
|
tri(a, b, c)
|
||||||
|
tri(a, c, d)
|
||||||
|
}
|
||||||
|
const skin = (from: Vector3[], to: Vector3[]) => {
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const k2 = (k + 1) % n
|
||||||
|
quad(from[k]!, to[k]!, to[k2]!, from[k2]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skin(inletRing, miterRing)
|
||||||
|
skin(miterRing, outletRing)
|
||||||
|
// End caps — triangle fans so any convex profile closes.
|
||||||
|
for (let k = 1; k < n - 1; k++) {
|
||||||
|
tri(inletRing[0]!, inletRing[k]!, inletRing[k + 1]!)
|
||||||
|
tri(outletRing[k + 1]!, outletRing[k]!, outletRing[0]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new BufferGeometry()
|
||||||
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||||
|
geometry.computeVertexNormals()
|
||||||
|
const solidMaterial = material.clone()
|
||||||
|
solidMaterial.side = DoubleSide
|
||||||
|
const mesh = new Mesh(geometry, solidMaterial)
|
||||||
|
mesh.name = `fitting-elbow-${profileShape}`
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Square-to-round loft between a rect ring at `xRect` and a round ring
|
||||||
|
* at `xRound`, both centered on the local X axis (the straight-through
|
||||||
|
* run). Profiles are sampled at matching polar angles — the rect point
|
||||||
|
* is the ray's intersection with the rectangle boundary — so the skin
|
||||||
|
* twists nowhere. Non-indexed triangles + computed normals give the
|
||||||
|
* faceted gore look of a real shop-made square-to-round.
|
||||||
|
*/
|
||||||
|
function buildRectToRoundLoft(
|
||||||
|
xRect: number,
|
||||||
|
xRound: number,
|
||||||
|
widthM: number,
|
||||||
|
heightM: number,
|
||||||
|
radius: number,
|
||||||
|
material: MeshStandardMaterial,
|
||||||
|
): Mesh {
|
||||||
|
const hw = widthM / 2
|
||||||
|
const hh = heightM / 2
|
||||||
|
const rectRing: Vector3[] = []
|
||||||
|
const roundRing: Vector3[] = []
|
||||||
|
for (let i = 0; i < RADIAL_SEGMENTS; i++) {
|
||||||
|
const theta = (2 * Math.PI * i) / RADIAL_SEGMENTS
|
||||||
|
const cz = Math.cos(theta)
|
||||||
|
const sy = Math.sin(theta)
|
||||||
|
// Scale the unit ray until it hits the rectangle boundary. Width
|
||||||
|
// spans local Z and height local Y — the same axes buildRectSection
|
||||||
|
// gives a +X run.
|
||||||
|
const t = 1 / Math.max(Math.abs(cz) / hw, Math.abs(sy) / hh)
|
||||||
|
rectRing.push(new Vector3(xRect, t * sy, t * cz))
|
||||||
|
roundRing.push(new Vector3(xRound, radius * sy, radius * cz))
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const tri = (a: Vector3, b: Vector3, c: Vector3) =>
|
||||||
|
positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
|
||||||
|
for (let i = 0; i < RADIAL_SEGMENTS; i++) {
|
||||||
|
const j = (i + 1) % RADIAL_SEGMENTS
|
||||||
|
tri(rectRing[i]!, roundRing[i]!, roundRing[j]!)
|
||||||
|
tri(rectRing[i]!, roundRing[j]!, rectRing[j]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new BufferGeometry()
|
||||||
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||||
|
geometry.computeVertexNormals()
|
||||||
|
const solidMaterial = material.clone()
|
||||||
|
solidMaterial.side = DoubleSide
|
||||||
|
const mesh = new Mesh(geometry, solidMaterial)
|
||||||
|
mesh.name = 'fitting-transition-loft'
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure geometry builder for a duct fitting, in the fitting's LOCAL frame —
|
||||||
|
* `<ParametricNodeRenderer>` applies `node.position` / `node.rotation`.
|
||||||
|
*
|
||||||
|
* Strategy: one cylinder stub per port from the junction center outward
|
||||||
|
* (reusing the segment builder's `buildSection`), a sphere at the
|
||||||
|
* junction, and a slightly-oversized crimp collar ring at each port
|
||||||
|
* opening so fittings read as sheet-metal junctions rather than bare
|
||||||
|
* tube ends.
|
||||||
|
*
|
||||||
|
* The reducer is special-cased: instead of equal stubs + sphere it draws
|
||||||
|
* a short inlet stub, a tapered cone, and a short outlet stub inline.
|
||||||
|
*
|
||||||
|
* Non-round shapes (elbow / tee): run legs carry the fitting's
|
||||||
|
* width × height profile — rect prisms or flat-oval stadiums — matching
|
||||||
|
* the trunk they join; a tee's branch leg carries its own `shape2`
|
||||||
|
* profile (width2 × height2, or round at `diameter2`). The profile's
|
||||||
|
* height rides local +Y — for the horizontal-plane orientations trunks
|
||||||
|
* are drawn in, that's world-vertical.
|
||||||
|
*/
|
||||||
|
export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
|
||||||
|
const group = new Group()
|
||||||
|
const material = createDuctMaterial(node)
|
||||||
|
const radiusMain = (node.diameter * INCHES_TO_METERS) / 2
|
||||||
|
const ports = localFittingPorts(node)
|
||||||
|
const widthM = node.width * INCHES_TO_METERS
|
||||||
|
const heightM = node.height * INCHES_TO_METERS
|
||||||
|
// The elbow folds about its local Y. Width spans the XZ bend plane and
|
||||||
|
// height rides the hinge ONLY when local Y is world-vertical (a floor
|
||||||
|
// turn). For a riser the node is rotated so local Y lands horizontal —
|
||||||
|
// then it's width that runs along the hinge, so the roles swap. Pick by
|
||||||
|
// where world-up sits in the fitting's local frame.
|
||||||
|
const hingeWorld = UP.clone().applyEuler(
|
||||||
|
new Euler(node.rotation[0], node.rotation[1], node.rotation[2]),
|
||||||
|
)
|
||||||
|
const hingeIsVertical = Math.abs(hingeWorld.y) >= Math.SQRT1_2
|
||||||
|
|
||||||
|
if (node.fittingType === 'reducer') {
|
||||||
|
const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2
|
||||||
|
const inlet = ports[0]!
|
||||||
|
const outlet = ports[1]!
|
||||||
|
const taperHalf = Math.abs(inlet.position.x) / 3
|
||||||
|
const stubA = buildSection(
|
||||||
|
inlet.position,
|
||||||
|
new Vector3(-taperHalf, 0, 0),
|
||||||
|
radiusMain,
|
||||||
|
material,
|
||||||
|
'fitting-stub-inlet',
|
||||||
|
)
|
||||||
|
if (stubA) group.add(stubA)
|
||||||
|
const cone = new Mesh(
|
||||||
|
new CylinderGeometry(radiusOut, radiusMain, taperHalf * 2, RADIAL_SEGMENTS, 1, false),
|
||||||
|
material,
|
||||||
|
)
|
||||||
|
cone.name = 'fitting-taper'
|
||||||
|
cone.quaternion.setFromUnitVectors(UP, new Vector3(1, 0, 0))
|
||||||
|
group.add(cone)
|
||||||
|
const stubB = buildSection(
|
||||||
|
new Vector3(taperHalf, 0, 0),
|
||||||
|
outlet.position,
|
||||||
|
radiusOut,
|
||||||
|
material,
|
||||||
|
'fitting-stub-outlet',
|
||||||
|
)
|
||||||
|
if (stubB) group.add(stubB)
|
||||||
|
} else if (node.fittingType === 'transition') {
|
||||||
|
// Square-to-round: rect stub on the inlet, lofted gore body through
|
||||||
|
// the junction, round stub on the outlet. Same inline layout as the
|
||||||
|
// reducer, with the taper replaced by the loft.
|
||||||
|
const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2
|
||||||
|
const inlet = ports[0]!
|
||||||
|
const outlet = ports[1]!
|
||||||
|
const taperHalf = Math.abs(inlet.position.x) / 3
|
||||||
|
const stubA = buildRectSection(
|
||||||
|
inlet.position,
|
||||||
|
new Vector3(-taperHalf, 0, 0),
|
||||||
|
widthM,
|
||||||
|
heightM,
|
||||||
|
material,
|
||||||
|
'fitting-stub-inlet',
|
||||||
|
)
|
||||||
|
if (stubA) group.add(stubA)
|
||||||
|
group.add(buildRectToRoundLoft(-taperHalf, taperHalf, widthM, heightM, radiusOut, material))
|
||||||
|
const stubB = buildSection(
|
||||||
|
new Vector3(taperHalf, 0, 0),
|
||||||
|
outlet.position,
|
||||||
|
radiusOut,
|
||||||
|
material,
|
||||||
|
'fitting-stub-outlet',
|
||||||
|
)
|
||||||
|
if (stubB) group.add(stubB)
|
||||||
|
} else if (node.shape !== 'round' && node.fittingType === 'elbow') {
|
||||||
|
// One mitered solid — no stubs, no junction blob. Oval profiles
|
||||||
|
// sweep the same way; the ring is a stadium instead of 4 corners.
|
||||||
|
const inlet = ports.find((p) => p.id === 'inlet')!
|
||||||
|
const outlet = ports.find((p) => p.id === 'outlet')!
|
||||||
|
group.add(
|
||||||
|
buildMiteredElbow(
|
||||||
|
inlet.position,
|
||||||
|
outlet.position,
|
||||||
|
hingeIsVertical ? widthM : heightM,
|
||||||
|
hingeIsVertical ? heightM : widthM,
|
||||||
|
node.shape,
|
||||||
|
material,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (node.shape !== 'round' && node.fittingType === 'tee') {
|
||||||
|
// Straight rect / oval run inlet→outlet (one prism — nothing to
|
||||||
|
// miter) plus a branch leg tapping its side. The branch carries its
|
||||||
|
// own profile: rect or oval at width2 × height2, round at diameter2.
|
||||||
|
//
|
||||||
|
// Same orientation swap as the elbow: the run prism and branch stub
|
||||||
|
// are built on the `rectSectionAxes` basis, whose height rides local
|
||||||
|
// +Y. That's world-vertical only when the tee's local Y stays vertical
|
||||||
|
// (a flat tap off a horizontal trunk). When the tee is rotated so
|
||||||
|
// local Y lands horizontal, width and height roles swap so the
|
||||||
|
// physical height keeps reading as the vertical face — without this a
|
||||||
|
// tee drawn along the perpendicular axis looks squished.
|
||||||
|
const inlet = ports.find((p) => p.id === 'inlet')!
|
||||||
|
const outlet = ports.find((p) => p.id === 'outlet')!
|
||||||
|
const branch = ports.find((p) => p.id === 'branch')!
|
||||||
|
const width2M = node.width2 * INCHES_TO_METERS
|
||||||
|
const height2M = node.height2 * INCHES_TO_METERS
|
||||||
|
const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection
|
||||||
|
const run = buildRunSection(
|
||||||
|
inlet.position,
|
||||||
|
outlet.position,
|
||||||
|
hingeIsVertical ? widthM : heightM,
|
||||||
|
hingeIsVertical ? heightM : widthM,
|
||||||
|
material,
|
||||||
|
'fitting-run',
|
||||||
|
)
|
||||||
|
if (run) group.add(run)
|
||||||
|
const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection
|
||||||
|
const stub =
|
||||||
|
node.shape2 !== 'round'
|
||||||
|
? buildBranchSection(
|
||||||
|
new Vector3(0, 0, 0),
|
||||||
|
branch.position,
|
||||||
|
hingeIsVertical ? width2M : height2M,
|
||||||
|
hingeIsVertical ? height2M : width2M,
|
||||||
|
material,
|
||||||
|
'fitting-stub-branch',
|
||||||
|
)
|
||||||
|
: buildSection(
|
||||||
|
new Vector3(0, 0, 0),
|
||||||
|
branch.position,
|
||||||
|
(branch.diameter * INCHES_TO_METERS) / 2,
|
||||||
|
material,
|
||||||
|
'fitting-stub-branch',
|
||||||
|
)
|
||||||
|
if (stub) group.add(stub)
|
||||||
|
} else if (node.shape !== 'round' && node.fittingType === 'cross') {
|
||||||
|
// Straight rect / oval run inlet→outlet plus two opposed branch legs
|
||||||
|
// (±Z) carrying the branch profile — both halves of the run that
|
||||||
|
// passed through, same size at `width2 × height2` / `diameter2`. Same
|
||||||
|
// orientation swap as the tee / elbow so the cross stays upright when
|
||||||
|
// rotated so its local Y lands horizontal.
|
||||||
|
const inlet = ports.find((p) => p.id === 'inlet')!
|
||||||
|
const outlet = ports.find((p) => p.id === 'outlet')!
|
||||||
|
const width2M = node.width2 * INCHES_TO_METERS
|
||||||
|
const height2M = node.height2 * INCHES_TO_METERS
|
||||||
|
const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection
|
||||||
|
const run = buildRunSection(
|
||||||
|
inlet.position,
|
||||||
|
outlet.position,
|
||||||
|
hingeIsVertical ? widthM : heightM,
|
||||||
|
hingeIsVertical ? heightM : widthM,
|
||||||
|
material,
|
||||||
|
'fitting-run',
|
||||||
|
)
|
||||||
|
if (run) group.add(run)
|
||||||
|
const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection
|
||||||
|
for (const id of ['branch', 'branch2'] as const) {
|
||||||
|
const branch = ports.find((p) => p.id === id)!
|
||||||
|
const stub =
|
||||||
|
node.shape2 !== 'round'
|
||||||
|
? buildBranchSection(
|
||||||
|
new Vector3(0, 0, 0),
|
||||||
|
branch.position,
|
||||||
|
hingeIsVertical ? width2M : height2M,
|
||||||
|
hingeIsVertical ? height2M : width2M,
|
||||||
|
material,
|
||||||
|
`fitting-stub-${id}`,
|
||||||
|
)
|
||||||
|
: buildSection(
|
||||||
|
new Vector3(0, 0, 0),
|
||||||
|
branch.position,
|
||||||
|
(branch.diameter * INCHES_TO_METERS) / 2,
|
||||||
|
material,
|
||||||
|
`fitting-stub-${id}`,
|
||||||
|
)
|
||||||
|
if (stub) group.add(stub)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const port of ports) {
|
||||||
|
const stub = buildSection(
|
||||||
|
new Vector3(0, 0, 0),
|
||||||
|
port.position,
|
||||||
|
(port.diameter * INCHES_TO_METERS) / 2,
|
||||||
|
material,
|
||||||
|
`fitting-stub-${port.id}`,
|
||||||
|
)
|
||||||
|
if (stub) group.add(stub)
|
||||||
|
}
|
||||||
|
const junction = new Mesh(new SphereGeometry(radiusMain * 1.02, RADIAL_SEGMENTS, 12), material)
|
||||||
|
junction.name = 'fitting-junction'
|
||||||
|
group.add(junction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Joint trim at each opening. Round legs get a crimp-collar torus just
|
||||||
|
// proud of the stub; rect legs get a drive-cleat flange — the thin
|
||||||
|
// raised rim (TDC/S-cleat) real sheet-metal trunk joints wear where a
|
||||||
|
// section meets a fitting. The plate is centered on the collar plane so
|
||||||
|
// the rim reads as the seam between fitting and duct. Run legs
|
||||||
|
// (inlet/outlet) are rect when `shape` is rect; a rect tee's branch is
|
||||||
|
// rect when `shape2` is rect. Reducers ignore shape.
|
||||||
|
// Which profile a leg's opening carries: a transition's inlet is its
|
||||||
|
// rect end regardless of `shape`; reducers are always round; otherwise
|
||||||
|
// the run legs follow `shape` and a tee's branch follows `shape2`
|
||||||
|
// (only meaningful when the run itself is non-round).
|
||||||
|
const legShape = (portId: string): 'round' | 'rect' | 'oval' => {
|
||||||
|
if (node.fittingType === 'transition') return portId === 'inlet' ? 'rect' : 'round'
|
||||||
|
if (node.fittingType === 'reducer' || node.shape === 'round') return 'round'
|
||||||
|
return portId === 'branch' || portId === 'branch2' ? node.shape2 : node.shape
|
||||||
|
}
|
||||||
|
// The flange's profile must match the leg it caps: the branch carries
|
||||||
|
// its own width2 × height2; elbow legs swap width/height roles when the
|
||||||
|
// fold hinge lies horizontal (riser elbows) — same choice as the
|
||||||
|
// mitered solid above.
|
||||||
|
const rectLegProfile = (portId: string): [number, number] => {
|
||||||
|
if (portId === 'branch' || portId === 'branch2') {
|
||||||
|
const width2M = node.width2 * INCHES_TO_METERS
|
||||||
|
const height2M = node.height2 * INCHES_TO_METERS
|
||||||
|
return hingeIsVertical ? [width2M, height2M] : [height2M, width2M]
|
||||||
|
}
|
||||||
|
if (!hingeIsVertical) return [heightM, widthM]
|
||||||
|
return [widthM, heightM]
|
||||||
|
}
|
||||||
|
const FLANGE_LIP_M = 0.02
|
||||||
|
const FLANGE_THICK_M = 0.012
|
||||||
|
for (const port of ports) {
|
||||||
|
const profile = legShape(port.id)
|
||||||
|
if (profile !== 'round') {
|
||||||
|
const [w, h] = rectLegProfile(port.id)
|
||||||
|
const start = port.position.clone().addScaledVector(port.direction, -FLANGE_THICK_M / 2)
|
||||||
|
const end = port.position.clone().addScaledVector(port.direction, FLANGE_THICK_M / 2)
|
||||||
|
const buildFlange = profile === 'oval' ? buildOvalSection : buildRectSection
|
||||||
|
const flange = buildFlange(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
w + FLANGE_LIP_M * 2,
|
||||||
|
h + FLANGE_LIP_M * 2,
|
||||||
|
material,
|
||||||
|
`fitting-flange-${port.id}`,
|
||||||
|
)
|
||||||
|
if (flange) group.add(flange)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const radius = (port.diameter * INCHES_TO_METERS) / 2
|
||||||
|
const collar = new Mesh(new TorusGeometry(radius, radius * 0.12, 8, RADIAL_SEGMENTS), material)
|
||||||
|
collar.name = `fitting-collar-${port.id}`
|
||||||
|
collar.position.copy(port.position)
|
||||||
|
collar.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), port.direction)
|
||||||
|
group.add(collar)
|
||||||
|
}
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { ductFittingDefinition } from './definition'
|
||||||
|
export { buildDuctFittingGeometry } from './geometry'
|
||||||
|
export { getDuctFittingPorts } from './ports'
|
||||||
|
export { DuctFittingNode } from './schema'
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AlignmentAnchor,
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
DuctFittingNode,
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
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 { buildDuctFittingGeometry } from './geometry'
|
||||||
|
|
||||||
|
type Vec3 = [number, number, number]
|
||||||
|
|
||||||
|
const GHOST_COLOR = '#818cf8'
|
||||||
|
const GHOST_OPACITY = 0.5
|
||||||
|
|
||||||
|
/** 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 duct fittings (elbow / tee /
|
||||||
|
* reducer / transition).
|
||||||
|
*
|
||||||
|
* **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.
|
||||||
|
*
|
||||||
|
* Wired via `def.affordanceTools.move`.
|
||||||
|
*/
|
||||||
|
export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||||
|
const fitting = node as DuctFittingNode
|
||||||
|
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 = buildDuctFittingGeometry(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)
|
||||||
|
|
||||||
|
let lastPos: Vec3 = originalPosition
|
||||||
|
|
||||||
|
const onMove = (event: GridEvent) => {
|
||||||
|
const bypass = event.nativeEvent?.shiftKey === true
|
||||||
|
const snap = bypass ? (v: number) => v : snapToGridStep
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
const next: Vec3 = [x, originalPosition[1], z]
|
||||||
|
if (next[0] !== lastPos[0] || next[2] !== lastPos[2]) triggerSFX('sfx:grid-snap')
|
||||||
|
lastPos = next
|
||||||
|
hasMoved = true
|
||||||
|
setCursorPos(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 = DuctFittingNode.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 {
|
||||||
|
useScene.getState().updateNode(nodeId, { position: lastPos } as Partial<AnyNode>)
|
||||||
|
useScene.getState().markDirty(nodeId)
|
||||||
|
}
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
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 = () => {
|
||||||
|
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)
|
||||||
|
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 MoveDuctFittingTool
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type DuctSegmentNode,
|
||||||
|
type ParametricDescriptor,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
|
import {
|
||||||
|
ductPortDiameterIn,
|
||||||
|
equivalentDiameterIn,
|
||||||
|
ovalEquivalentDiameterIn,
|
||||||
|
rollToContinueAcrossElbow,
|
||||||
|
} from '../duct-segment/geometry'
|
||||||
|
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))
|
||||||
|
|
||||||
|
/** A duct endpoint sitting this close to a collar counts as mated. */
|
||||||
|
const MATE_TOL_M = 0.03
|
||||||
|
|
||||||
|
type DuctMate = { duct: DuctSegmentNode; endIndex: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ducts whose endpoint sits ON one of the fitting's collars, keyed by
|
||||||
|
* 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> {
|
||||||
|
const mates = new Map<string, DuctMate>()
|
||||||
|
const ports = getDuctFittingPorts(fitting)
|
||||||
|
for (const node of Object.values(useScene.getState().nodes)) {
|
||||||
|
if (node.type !== 'duct-segment') continue
|
||||||
|
const duct = node as DuctSegmentNode
|
||||||
|
for (const endIndex of [0, duct.path.length - 1]) {
|
||||||
|
const p = duct.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, { duct, endIndex })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mates
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// fitting lands flush instead of at schema defaults. The tee branch
|
||||||
|
// follows its own mated duct (or the run shape when nothing is mated);
|
||||||
|
// `shape2` stays editable afterwards for mixed taps. Rect profiles
|
||||||
|
// also write their area-equivalent round size back into `diameter` /
|
||||||
|
// `diameter2`, which drive leg lengths + advertised ports — without
|
||||||
|
// this the legs keep the stale round size.
|
||||||
|
derive: (next, patch) => {
|
||||||
|
const out: Partial<DuctFittingNode> = {}
|
||||||
|
if ('shape' in patch && next.fittingType !== 'reducer') {
|
||||||
|
// `next` still carries the pre-edit diameters, so its ports sit
|
||||||
|
// where the mated ducts end — size off the actual neighbours.
|
||||||
|
const mates = matedDucts(next)
|
||||||
|
const run = (mates.get('inlet') ?? mates.get('outlet'))?.duct
|
||||||
|
if (next.shape !== 'round' && run?.shape === next.shape) {
|
||||||
|
out.width = run.width
|
||||||
|
out.height = run.height
|
||||||
|
} else if (next.shape === 'round' && run && run.shape !== 'rect') {
|
||||||
|
// Oval runs present their area-equivalent round size.
|
||||||
|
out.diameter = clampDiameter(ductPortDiameterIn(run))
|
||||||
|
}
|
||||||
|
if (next.fittingType === 'tee' || next.fittingType === 'cross') {
|
||||||
|
// A cross's two branches share one profile — size off whichever
|
||||||
|
// branch leg has a duct mated (both halves are the same run).
|
||||||
|
const branchDuct = (mates.get('branch') ?? mates.get('branch2'))?.duct
|
||||||
|
out.shape2 = branchDuct?.shape ?? next.shape
|
||||||
|
if (branchDuct && branchDuct.shape !== 'round') {
|
||||||
|
out.width2 = branchDuct.width
|
||||||
|
out.height2 = branchDuct.height
|
||||||
|
} else if (branchDuct) {
|
||||||
|
out.diameter2 = clampDiameter(ductPortDiameterIn(branchDuct))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-round legs write their area-equivalent round size back into the
|
||||||
|
// diameters (leg lengths + advertised ports). A transition's inlet is
|
||||||
|
// always the rect end regardless of `shape`.
|
||||||
|
const runShape = next.fittingType === 'transition' ? 'rect' : next.shape
|
||||||
|
if (runShape !== 'round' && next.fittingType !== 'reducer') {
|
||||||
|
const equivalent = runShape === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn
|
||||||
|
out.diameter = clampDiameter(equivalent(out.width ?? next.width, out.height ?? next.height))
|
||||||
|
}
|
||||||
|
const shape2 = out.shape2 ?? next.shape2
|
||||||
|
if ((next.fittingType === 'tee' || next.fittingType === 'cross') && shape2 !== 'round') {
|
||||||
|
const equivalent2 = shape2 === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn
|
||||||
|
out.diameter2 = clampDiameter(
|
||||||
|
equivalent2(out.width2 ?? next.width2, out.height2 ?? next.height2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resizing a fitting moves its collars (leg lengths follow the
|
||||||
|
// diameters) — re-trim each mated duct's endpoint onto the collar's
|
||||||
|
// new position so metal keeps meeting metal instead of overlapping
|
||||||
|
// one neighbour and gapping off another.
|
||||||
|
reconcile: (prev, next) => {
|
||||||
|
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
||||||
|
const newPorts = new Map(getDuctFittingPorts(next).map((p) => [p.id, p]))
|
||||||
|
const mates = matedDucts(prev)
|
||||||
|
for (const [portId, mate] of mates) {
|
||||||
|
const target = newPorts.get(portId)
|
||||||
|
if (!target) continue
|
||||||
|
const end = mate.duct.path[mate.endIndex]
|
||||||
|
if (!end) continue
|
||||||
|
const data: Partial<DuctSegmentNode> = {}
|
||||||
|
const dx = end[0] - target.position[0]
|
||||||
|
const dy = end[1] - target.position[1]
|
||||||
|
const dz = end[2] - target.position[2]
|
||||||
|
if (dx * dx + dy * dy + dz * dz >= 1e-12) {
|
||||||
|
const path = mate.duct.path.map((p) => [...p] as [number, number, number])
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(data).length > 0) updates.push({ id: mate.duct.id, data })
|
||||||
|
}
|
||||||
|
return updates
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: 'Fitting',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'fittingType',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['elbow', 'tee', 'cross', 'reducer', 'transition'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'angle',
|
||||||
|
kind: 'number',
|
||||||
|
unit: '°',
|
||||||
|
min: 15,
|
||||||
|
max: 90,
|
||||||
|
step: 15,
|
||||||
|
visibleIf: (n) => n.fittingType === 'elbow',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'branchAngle',
|
||||||
|
kind: 'number',
|
||||||
|
unit: '°',
|
||||||
|
min: 45,
|
||||||
|
max: 135,
|
||||||
|
step: 15,
|
||||||
|
visibleIf: (n) => n.fittingType === 'tee',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['supply', 'return'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Connections',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'shape',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['round', 'rect', 'oval'],
|
||||||
|
display: 'segmented',
|
||||||
|
// Reducers are always round; a transition's ends are fixed
|
||||||
|
// (rect inlet, round outlet) so there's nothing to pick.
|
||||||
|
visibleIf: (n) => n.fittingType !== 'reducer' && n.fittingType !== 'transition',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'diameter',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 4,
|
||||||
|
max: 24,
|
||||||
|
step: 1,
|
||||||
|
// Hidden when the run legs are rect / oval (transition's inlet
|
||||||
|
// always is) — `diameter` is then derived as the area equivalent.
|
||||||
|
visibleIf: (n) =>
|
||||||
|
n.fittingType === 'reducer' || (n.fittingType !== 'transition' && n.shape === 'round'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'width',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 4,
|
||||||
|
max: 60,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) =>
|
||||||
|
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'height',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 3,
|
||||||
|
max: 40,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) =>
|
||||||
|
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shape2',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['round', 'rect', 'oval'],
|
||||||
|
display: 'segmented',
|
||||||
|
visibleIf: (n) => n.fittingType === 'tee' || n.fittingType === 'cross',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'diameter2',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 4,
|
||||||
|
max: 24,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) =>
|
||||||
|
n.fittingType !== 'elbow' &&
|
||||||
|
(n.fittingType !== 'tee' || n.shape2 === 'round') &&
|
||||||
|
(n.fittingType !== 'cross' || n.shape2 === 'round'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'width2',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 4,
|
||||||
|
max: 60,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) =>
|
||||||
|
(n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'height2',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 3,
|
||||||
|
max: 40,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) =>
|
||||||
|
(n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ductMaterial',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['sheet-metal', 'flex', 'duct-board'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Placement',
|
||||||
|
fields: [
|
||||||
|
{ key: 'position', kind: 'vec3' },
|
||||||
|
{ key: 'rotation', kind: 'vec3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* proportionally longer collars, with a floor so 4" fittings stay
|
||||||
|
* grabbable.
|
||||||
|
*/
|
||||||
|
export function fittingLegLength(diameterInches: number): number {
|
||||||
|
const radius = (diameterInches * INCHES_TO_METERS) / 2
|
||||||
|
return Math.max(0.14, radius * 2.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalPort = { id: string; position: Vector3; direction: Vector3; diameter: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ports in the fitting's LOCAL frame (origin at the junction center,
|
||||||
|
* before `position`/`rotation`). Shared by `def.ports` (which transforms
|
||||||
|
* them to level-local) and the geometry builder (which draws a stub per
|
||||||
|
* port).
|
||||||
|
*
|
||||||
|
* Conventions documented on the schema: elbow inlet -X / outlet turned
|
||||||
|
* `angle`° in XZ; tee run along X with the branch at `branchAngle`° off
|
||||||
|
* the +X outlet axis (90° → +Z square tee, 45° → downstream lateral,
|
||||||
|
* 135° → upstream lateral); reducer -X → +X.
|
||||||
|
*/
|
||||||
|
export function localFittingPorts(node: DuctFittingNode): LocalPort[] {
|
||||||
|
const main = fittingLegLength(node.diameter)
|
||||||
|
if (node.fittingType === 'elbow') {
|
||||||
|
const theta = (node.angle * Math.PI) / 180
|
||||||
|
const outDir = new Vector3(Math.cos(theta), 0, Math.sin(theta))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'inlet',
|
||||||
|
position: new Vector3(-main, 0, 0),
|
||||||
|
direction: new Vector3(-1, 0, 0),
|
||||||
|
diameter: node.diameter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outlet',
|
||||||
|
position: outDir.clone().multiplyScalar(main),
|
||||||
|
direction: outDir,
|
||||||
|
diameter: node.diameter,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (node.fittingType === 'tee') {
|
||||||
|
const branch = fittingLegLength(node.diameter2)
|
||||||
|
// Branch leans `branchAngle`° off the +X outlet axis in XZ: 90° is a
|
||||||
|
// square tap (+Z), shallower angles sweep the branch downstream
|
||||||
|
// toward the outlet so the lateral merges with the run's flow, and
|
||||||
|
// angles past 90° lean it upstream toward the inlet (cos goes
|
||||||
|
// negative, swinging the collar to -X).
|
||||||
|
const phi = (node.branchAngle * Math.PI) / 180
|
||||||
|
const branchDir = new Vector3(Math.cos(phi), 0, Math.sin(phi))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'inlet',
|
||||||
|
position: new Vector3(-main, 0, 0),
|
||||||
|
direction: new Vector3(-1, 0, 0),
|
||||||
|
diameter: node.diameter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outlet',
|
||||||
|
position: new Vector3(main, 0, 0),
|
||||||
|
direction: new Vector3(1, 0, 0),
|
||||||
|
diameter: node.diameter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'branch',
|
||||||
|
position: branchDir.clone().multiplyScalar(branch),
|
||||||
|
direction: branchDir,
|
||||||
|
diameter: node.diameter2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (node.fittingType === 'cross') {
|
||||||
|
// Four-way junction: run inlet -X / outlet +X at the run profile,
|
||||||
|
// two opposed branches square to the run along ±Z at the branch
|
||||||
|
// profile. Both branches share `diameter2` (one drawn run passes
|
||||||
|
// straight through, so its two halves are the same size).
|
||||||
|
const branch = fittingLegLength(node.diameter2)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'inlet',
|
||||||
|
position: new Vector3(-main, 0, 0),
|
||||||
|
direction: new Vector3(-1, 0, 0),
|
||||||
|
diameter: node.diameter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outlet',
|
||||||
|
position: new Vector3(main, 0, 0),
|
||||||
|
direction: new Vector3(1, 0, 0),
|
||||||
|
diameter: node.diameter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'branch',
|
||||||
|
position: new Vector3(0, 0, branch),
|
||||||
|
direction: new Vector3(0, 0, 1),
|
||||||
|
diameter: node.diameter2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'branch2',
|
||||||
|
position: new Vector3(0, 0, -branch),
|
||||||
|
direction: new Vector3(0, 0, -1),
|
||||||
|
diameter: node.diameter2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// reducer / transition: straight-through, inlet at `diameter` (the
|
||||||
|
// transition's rect end advertises its area-equivalent round size),
|
||||||
|
// outlet at `diameter2`.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'inlet',
|
||||||
|
position: new Vector3(-main, 0, 0),
|
||||||
|
direction: new Vector3(-1, 0, 0),
|
||||||
|
diameter: node.diameter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outlet',
|
||||||
|
position: new Vector3(main, 0, 0),
|
||||||
|
direction: new Vector3(1, 0, 0),
|
||||||
|
diameter: node.diameter2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `def.ports` — local ports transformed into level-local space. */
|
||||||
|
export function getDuctFittingPorts(node: DuctFittingNode): NodePort[] {
|
||||||
|
const euler = new Euler(node.rotation[0], node.rotation[1], node.rotation[2])
|
||||||
|
const offset = new Vector3(node.position[0], node.position[1], node.position[2])
|
||||||
|
return localFittingPorts(node).map((port) => {
|
||||||
|
const position = port.position.clone().applyEuler(euler).add(offset)
|
||||||
|
const direction = port.direction.clone().applyEuler(euler).normalize()
|
||||||
|
return {
|
||||||
|
id: port.id,
|
||||||
|
position: [position.x, position.y, position.z] as const,
|
||||||
|
direction: [direction.x, direction.y, direction.z] as const,
|
||||||
|
diameter: port.diameter,
|
||||||
|
system: node.system,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { DuctFittingNode } from '@pascal-app/core'
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { cycleRotationAxis } from '../shared/fitting-rotation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasSelectedFitting) return
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Alt' || e.repeat) return
|
||||||
|
const tag = (e.target as HTMLElement | null)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DuctFittingSelectionAffordance
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { DuctFittingNode, emitter, type GridEvent, useScene } from '@pascal-app/core'
|
||||||
|
import { CursorSphere, EDITOR_LAYER, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Euler, Quaternion, Vector3 } from 'three'
|
||||||
|
import {
|
||||||
|
AXIS_VECTORS,
|
||||||
|
cycleRotationAxis,
|
||||||
|
getRotationAxis,
|
||||||
|
ROTATE_STEP_RAD,
|
||||||
|
} from '../shared/fitting-rotation'
|
||||||
|
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||||
|
import {
|
||||||
|
collectScenePorts,
|
||||||
|
DUCT_PORT_SYSTEMS,
|
||||||
|
findNearestPortXZ,
|
||||||
|
type ScenePort,
|
||||||
|
} from '../shared/ports'
|
||||||
|
import { ductFittingDefinition } from './definition'
|
||||||
|
import { buildDuctFittingGeometry } from './geometry'
|
||||||
|
import { localFittingPorts } from './ports'
|
||||||
|
|
||||||
|
/** Snap radius (meters, XZ) for mating onto an existing port. */
|
||||||
|
const PORT_SNAP_RADIUS_M = 0.5
|
||||||
|
const PREVIEW_OPACITY = 0.55
|
||||||
|
|
||||||
|
function snap(value: number, step: number): number {
|
||||||
|
if (step <= 0) return value
|
||||||
|
return Math.round(value / step) * step
|
||||||
|
}
|
||||||
|
|
||||||
|
type Placement = {
|
||||||
|
position: [number, number, number]
|
||||||
|
rotation: [number, number, number]
|
||||||
|
snapPort: ScenePort | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve where the fitting would land for a cursor at `raw`:
|
||||||
|
* - Near an existing port → mate: orientation aligns the inlet onto
|
||||||
|
* the port (plus the user's manual R/T rotation, pivoting around
|
||||||
|
* the inlet collar so it stays on the port while the body sweeps).
|
||||||
|
* - Otherwise → grid-snapped free placement on the floor, manual
|
||||||
|
* rotation only.
|
||||||
|
*/
|
||||||
|
function resolvePlacement(
|
||||||
|
raw: [number, number, number],
|
||||||
|
previewNode: DuctFittingNode,
|
||||||
|
gridStep: number,
|
||||||
|
manualQuat: Quaternion,
|
||||||
|
): Placement {
|
||||||
|
const port = findNearestPortXZ(
|
||||||
|
raw,
|
||||||
|
collectScenePorts({ systems: DUCT_PORT_SYSTEMS }),
|
||||||
|
PORT_SNAP_RADIUS_M,
|
||||||
|
)
|
||||||
|
if (port) {
|
||||||
|
const direction = new Vector3(...port.direction).normalize()
|
||||||
|
// Local +X must map onto the port's outward direction so the inlet
|
||||||
|
// (local -X) faces back into the run it's joining. Manual rotation
|
||||||
|
// composes in the world frame on top of the mate orientation.
|
||||||
|
const mate = new Quaternion().setFromUnitVectors(new Vector3(1, 0, 0), direction)
|
||||||
|
const final = manualQuat.clone().multiply(mate)
|
||||||
|
const inlet = localFittingPorts(previewNode)[0]!
|
||||||
|
const inletWorldOffset = inlet.position.clone().applyQuaternion(final)
|
||||||
|
const position = new Vector3(...port.position).sub(inletWorldOffset)
|
||||||
|
const euler = new Euler().setFromQuaternion(final)
|
||||||
|
return {
|
||||||
|
position: [position.x, position.y, position.z],
|
||||||
|
rotation: [euler.x, euler.y, euler.z],
|
||||||
|
snapPort: port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const euler = new Euler().setFromQuaternion(manualQuat)
|
||||||
|
return {
|
||||||
|
position: [snap(raw[0], gridStep), 0, snap(raw[2], gridStep)],
|
||||||
|
rotation: [euler.x, euler.y, euler.z],
|
||||||
|
snapPort: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-place tool for duct fittings (elbow / tee / reducer).
|
||||||
|
*
|
||||||
|
* A translucent ghost of the fitting follows the cursor. Within snap
|
||||||
|
* range of any scene port (duct run ends, other fittings' collars) the
|
||||||
|
* ghost jumps onto the port — position AND orientation — so one click
|
||||||
|
* mates the fitting onto the run.
|
||||||
|
*
|
||||||
|
* Rotation while placing: **R / T** turn the ghost ±45° around the
|
||||||
|
* active world axis; **Alt** cycles the axis (Y → X → Z). The HUD badge
|
||||||
|
* above the ghost shows the current axis. When snapped to a port the
|
||||||
|
* rotation pivots around the inlet collar so the joint stays mated.
|
||||||
|
* Handlers run in the capture phase so R doesn't also spin whatever
|
||||||
|
* node happens to be selected.
|
||||||
|
*/
|
||||||
|
const DuctFittingTool = () => {
|
||||||
|
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||||
|
const [placement, setPlacement] = useState<Placement | null>(null)
|
||||||
|
const axis = useEditor((s) => s.rotationAxis)
|
||||||
|
// Accumulated manual rotation from R/T presses. Ref (not state) so the
|
||||||
|
// emitter callbacks always read the latest without re-subscribing; a
|
||||||
|
// placement recompute is triggered explicitly after each change.
|
||||||
|
const manualQuatRef = useRef(new Quaternion())
|
||||||
|
// Last raw cursor position so a key press can recompute the placement
|
||||||
|
// without waiting for the next mouse move.
|
||||||
|
const lastRawRef = useRef<[number, number, number] | null>(null)
|
||||||
|
|
||||||
|
// Ghost matches exactly what a click creates (the kind's defaults).
|
||||||
|
const previewNode = useMemo(
|
||||||
|
() => DuctFittingNode.parse({ ...ductFittingDefinition.defaults(), name: 'Duct fitting' }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const ghost = useMemo(() => {
|
||||||
|
const group = buildDuctFittingGeometry(previewNode)
|
||||||
|
group.traverse((child) => {
|
||||||
|
// Overlay layer keeps the placement ghost out of the ink / SSGI
|
||||||
|
// buffers and the thumbnail export, like every other tool preview.
|
||||||
|
child.layers.set(EDITOR_LAYER)
|
||||||
|
const mesh = child as { material?: { transparent: boolean; opacity: number } }
|
||||||
|
if (mesh.material) {
|
||||||
|
mesh.material.transparent = true
|
||||||
|
mesh.material.opacity = PREVIEW_OPACITY
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return group
|
||||||
|
}, [previewNode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeLevelId) return
|
||||||
|
|
||||||
|
const recompute = () => {
|
||||||
|
const raw = lastRawRef.current
|
||||||
|
if (!raw) return
|
||||||
|
setPlacement(
|
||||||
|
resolvePlacement(
|
||||||
|
raw,
|
||||||
|
previewNode,
|
||||||
|
useEditor.getState().gridSnapStep,
|
||||||
|
manualQuatRef.current,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (event: GridEvent) => {
|
||||||
|
lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]]
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = (event: GridEvent) => {
|
||||||
|
lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]]
|
||||||
|
const { position, rotation } = resolvePlacement(
|
||||||
|
lastRawRef.current,
|
||||||
|
previewNode,
|
||||||
|
useEditor.getState().gridSnapStep,
|
||||||
|
manualQuatRef.current,
|
||||||
|
)
|
||||||
|
const fitting = DuctFittingNode.parse({
|
||||||
|
...ductFittingDefinition.defaults(),
|
||||||
|
name: 'Duct fitting',
|
||||||
|
position,
|
||||||
|
rotation,
|
||||||
|
})
|
||||||
|
useScene.getState().createNode(fitting, activeLevelId)
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [fitting.id] })
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const tag = (e.target as HTMLElement | null)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||||
|
const key = e.key
|
||||||
|
if (key === 'r' || key === 'R' || key === 't' || key === 'T') {
|
||||||
|
// Capture-phase + stopPropagation so the editor's selection-rotate
|
||||||
|
// R handler doesn't also fire while the placement tool owns R.
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
|
||||||
|
const turn = new Quaternion().setFromAxisAngle(
|
||||||
|
AXIS_VECTORS[getRotationAxis()],
|
||||||
|
steps * ROTATE_STEP_RAD,
|
||||||
|
)
|
||||||
|
manualQuatRef.current = turn.multiply(manualQuatRef.current)
|
||||||
|
triggerSFX('sfx:item-rotate')
|
||||||
|
recompute()
|
||||||
|
} else if (key === 'Alt' && !e.repeat) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
cycleRotationAxis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onMove)
|
||||||
|
emitter.on('grid:click', onClick)
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
return () => {
|
||||||
|
emitter.off('grid:move', onMove)
|
||||||
|
emitter.off('grid:click', onClick)
|
||||||
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
}
|
||||||
|
}, [activeLevelId, previewNode])
|
||||||
|
|
||||||
|
if (!activeLevelId || !placement) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LevelOffsetGroup>
|
||||||
|
{/* Same ground ring + vertical line + tool-icon badge the duct draw
|
||||||
|
tool shows in 3D (icon resolved from the active `duct-fitting`
|
||||||
|
structure-tools entry). In 2D the floorplan overlay draws this for
|
||||||
|
every tool; in 3D each tool renders its own. */}
|
||||||
|
<CursorSphere position={placement.position} />
|
||||||
|
<group position={placement.position} rotation={placement.rotation}>
|
||||||
|
<primitive object={ghost} />
|
||||||
|
</group>
|
||||||
|
{/* Rotation HUD — active axis + key hints, pinned above the ghost. */}
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
position={[placement.position[0], placement.position[1] + 0.5, placement.position[2]]}
|
||||||
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
|
zIndexRange={[100, 0]}
|
||||||
|
>
|
||||||
|
{/* Same pill shell as DimensionPill so the placement HUD matches
|
||||||
|
the drawing / dragging readouts. */}
|
||||||
|
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||||
|
<span className="font-medium text-foreground">Axis {axis.toUpperCase()}</span>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">R/T rotate</span>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">⌥ axis</span>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
{/* Port-snap halo so the user sees the click will mate, not free-place. */}
|
||||||
|
{placement.snapPort && (
|
||||||
|
<mesh
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
|
position={placement.snapPort.position as [number, number, number]}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.18, 24, 16]} />
|
||||||
|
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</LevelOffsetGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DuctFittingTool
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core'
|
||||||
|
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
|
||||||
|
import { buildDuctSegmentFloorplan } from './floorplan'
|
||||||
|
import { buildDuctSegmentGeometry, ductPortDiameterIn } from './geometry'
|
||||||
|
import { ductSegmentParametrics } from './parametrics'
|
||||||
|
import { DuctSegmentNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1 of the HVAC node system — round duct segment as a polyline.
|
||||||
|
*
|
||||||
|
* Composition: `def.geometry` only. No custom renderer, no per-frame
|
||||||
|
* system. The framework's `<ParametricNodeRenderer>` mounts an empty
|
||||||
|
* group; `<GeometrySystem>` calls `buildDuctSegmentGeometry` whenever
|
||||||
|
* the node is dirty and swaps in the cylinder+sphere meshes.
|
||||||
|
*
|
||||||
|
* Deferred to later slices:
|
||||||
|
* - Placement tool (polyline draw UX).
|
||||||
|
* - Fittings (elbow / tee / reducer) — needs typed ports first.
|
||||||
|
* - Terminals (registers / diffusers) — needs surface-snapping.
|
||||||
|
* - Equipment (furnace / air-handler / condenser).
|
||||||
|
* - Floor-plan rendering.
|
||||||
|
* - Move / endpoint handles.
|
||||||
|
*
|
||||||
|
* The node can be created programmatically today via
|
||||||
|
* `DuctSegmentNode.parse({ path: [...] })` + `useScene.createNode(...)`.
|
||||||
|
*/
|
||||||
|
/** R / T roll step (radians) — 45°, matching the fitting rotate. */
|
||||||
|
const ROLL_STEP_RAD = Math.PI / 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R / T roll a selected rect / oval run's cross-section ±45° around its
|
||||||
|
* drawn line, so a rectangular trunk can be turned on its side after
|
||||||
|
* placement. Round runs look identical at any roll, so the action gates
|
||||||
|
* itself off for them (`appliesTo`) and the editor's default rotation —
|
||||||
|
* a no-op for a node with no `rotation` field — takes over harmlessly.
|
||||||
|
*/
|
||||||
|
function rollDuctSegment(node: AnyNode, steps: 1 | -1): void {
|
||||||
|
const duct = node as DuctSegmentNode
|
||||||
|
useScene.getState().updateNode(duct.id, { roll: duct.roll + steps * ROLL_STEP_RAD })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||||
|
kind: 'duct-segment',
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: DuctSegmentNode,
|
||||||
|
category: 'utility',
|
||||||
|
distributionRole: 'run',
|
||||||
|
|
||||||
|
defaults: () => ({
|
||||||
|
object: 'node',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
path: [
|
||||||
|
[0, 0, 0],
|
||||||
|
[3, 0, 0],
|
||||||
|
],
|
||||||
|
shape: 'rect',
|
||||||
|
diameter: 6,
|
||||||
|
width: 14,
|
||||||
|
height: 8,
|
||||||
|
ductMaterial: 'flex',
|
||||||
|
seamDetail: false,
|
||||||
|
insulated: false,
|
||||||
|
insulationR: 0.5,
|
||||||
|
system: 'supply',
|
||||||
|
roll: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
selectable: { hitVolume: 'bbox' },
|
||||||
|
duplicable: true,
|
||||||
|
deletable: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
parametrics: ductSegmentParametrics,
|
||||||
|
|
||||||
|
// R / T roll a selected rect / oval run ±45° around its drawn line.
|
||||||
|
// `appliesTo` lets round runs fall through to the editor's default
|
||||||
|
// (harmless — duct-segment has no `rotation` field).
|
||||||
|
keyboardActions: {
|
||||||
|
r: {
|
||||||
|
appliesTo: (node) => node.type === 'duct-segment' && node.shape !== 'round',
|
||||||
|
run: (node) => rollDuctSegment(node, 1),
|
||||||
|
},
|
||||||
|
t: {
|
||||||
|
appliesTo: (node) => node.type === 'duct-segment' && node.shape !== 'round',
|
||||||
|
run: (node) => rollDuctSegment(node, -1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
geometry: buildDuctSegmentGeometry,
|
||||||
|
geometryKey: (n) =>
|
||||||
|
JSON.stringify([
|
||||||
|
n.path,
|
||||||
|
n.shape,
|
||||||
|
n.diameter,
|
||||||
|
n.width,
|
||||||
|
n.height,
|
||||||
|
n.roll,
|
||||||
|
n.ductMaterial,
|
||||||
|
n.seamDetail,
|
||||||
|
n.insulated,
|
||||||
|
n.insulationR,
|
||||||
|
n.system,
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Open run ends as typed ports — directions point outward along the
|
||||||
|
// path tangent so fittings mate flush. Path coords are already
|
||||||
|
// level-local, so no transform is needed.
|
||||||
|
ports: (n) => {
|
||||||
|
if (n.path.length < 2) return []
|
||||||
|
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: ductPortDiameterIn(n),
|
||||||
|
system: n.system,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'end',
|
||||||
|
position: last,
|
||||||
|
direction: unit(last, prev),
|
||||||
|
diameter: ductPortDiameterIn(n),
|
||||||
|
system: n.system,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
floorplan: buildDuctSegmentFloorplan,
|
||||||
|
|
||||||
|
// 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('duct-segment'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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. Duplicate is pure drag-to-place: a
|
||||||
|
// translucent copy of the run 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 segment' },
|
||||||
|
{ key: 'Click again', label: 'Place it (locked to 45°)' },
|
||||||
|
{ key: 'Shift', label: 'Free angle' },
|
||||||
|
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
|
||||||
|
{ key: '[ / ]', label: 'Duct diameter down / up' },
|
||||||
|
{ key: 'Q', label: 'Round / rect trunk' },
|
||||||
|
{ key: 'C', label: 'Ceiling / floor height' },
|
||||||
|
{ key: 'Esc', label: 'Cancel start point' },
|
||||||
|
],
|
||||||
|
|
||||||
|
presentation: {
|
||||||
|
label: 'Duct',
|
||||||
|
description: 'HVAC duct run — polyline of round, rect, or flat-oval sections.',
|
||||||
|
icon: { kind: 'url', src: '/icons/duct.png' },
|
||||||
|
paletteSection: 'structure',
|
||||||
|
paletteOrder: 90,
|
||||||
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
description:
|
||||||
|
'An HVAC duct run defined as a polyline — round (branches), rect (trunks/plenums), or flat-oval (tight joist bays). Supply or return, with configurable size, material (incl. spiral seam), and external insulation.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
|
||||||
|
import { INCHES_TO_METERS } from './geometry'
|
||||||
|
import type { DuctSegmentNode } from './schema'
|
||||||
|
|
||||||
|
const SUPPLY_CENTERLINE = '#d4825a'
|
||||||
|
const RETURN_CENTERLINE = '#5a8ad4'
|
||||||
|
const BODY_COLOR = '#9ca3af'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor-plan representation of a duct run: the path drawn at the duct's
|
||||||
|
* real width (plan-unit stroke so it scales with zoom), with a dashed
|
||||||
|
* centerline tinted by system — orange for supply, blue for return, the
|
||||||
|
* same hues the 3D tint uses. Vertical risers collapse to a point in
|
||||||
|
* plan; consecutive duplicate plan points are dropped so they don't
|
||||||
|
* render zero-length artifacts.
|
||||||
|
*/
|
||||||
|
export function buildDuctSegmentFloorplan(
|
||||||
|
node: DuctSegmentNode,
|
||||||
|
ctx: GeometryContext,
|
||||||
|
): FloorplanGeometry | null {
|
||||||
|
if (node.path.length < 2) return null
|
||||||
|
|
||||||
|
// Project to plan, dropping consecutive duplicates (risers). `indexMap[k]`
|
||||||
|
// is the original path index plan point k came from, so the drag handle
|
||||||
|
// edits the right vertex.
|
||||||
|
const points: FloorplanPoint[] = []
|
||||||
|
const indexMap: number[] = []
|
||||||
|
for (let i = 0; i < node.path.length; i++) {
|
||||||
|
const [x, , z] = node.path[i]!
|
||||||
|
const prev = points[points.length - 1]
|
||||||
|
if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue
|
||||||
|
points.push([x, z])
|
||||||
|
indexMap.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan width: rect / oval runs draw at their actual width; round at diameter.
|
||||||
|
const diameterM = (node.shape === 'round' ? node.diameter : node.width) * INCHES_TO_METERS
|
||||||
|
const view = ctx.viewState
|
||||||
|
const palette = view?.palette
|
||||||
|
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
|
||||||
|
const centerline = node.system === 'supply' ? SUPPLY_CENTERLINE : RETURN_CENTERLINE
|
||||||
|
|
||||||
|
// A pure riser (single plan point) still gets a marker: a circle at
|
||||||
|
// the duct's diameter so the vertical run is visible in plan.
|
||||||
|
if (points.length < 2) {
|
||||||
|
const p = points[0] ?? [node.path[0]![0], node.path[0]![2]]
|
||||||
|
return {
|
||||||
|
kind: 'group',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
kind: 'circle',
|
||||||
|
cx: p[0],
|
||||||
|
cy: p[1],
|
||||||
|
r: diameterM / 2,
|
||||||
|
fill: BODY_COLOR,
|
||||||
|
stroke: showSelectedChrome && palette ? palette.selectedStroke : centerline,
|
||||||
|
strokeWidth: 0.02,
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const children: FloorplanGeometry[] = [
|
||||||
|
{
|
||||||
|
kind: 'polyline',
|
||||||
|
points,
|
||||||
|
stroke: showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR,
|
||||||
|
strokeWidth: diameterM,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
opacity: showSelectedChrome ? 0.95 : 0.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'polyline',
|
||||||
|
points,
|
||||||
|
stroke: centerline,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
vectorEffect: 'non-scaling-stroke',
|
||||||
|
strokeDasharray: '5 4',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Selection chrome: one draggable handle per path vertex (2D twin of the
|
||||||
|
// 3D selection handles). Routes to the shared `move-path-point` affordance.
|
||||||
|
if (view?.selected) {
|
||||||
|
for (let k = 0; k < points.length; k++) {
|
||||||
|
children.push({
|
||||||
|
kind: 'endpoint-handle',
|
||||||
|
point: points[k]!,
|
||||||
|
state: 'idle',
|
||||||
|
affordance: 'move-path-point',
|
||||||
|
payload: { pointIndex: indexMap[k]! },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'group', children }
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import {
|
||||||
|
BoxGeometry,
|
||||||
|
CatmullRomCurve3,
|
||||||
|
CylinderGeometry,
|
||||||
|
ExtrudeGeometry,
|
||||||
|
Group,
|
||||||
|
Matrix4,
|
||||||
|
Mesh,
|
||||||
|
MeshStandardMaterial,
|
||||||
|
Quaternion,
|
||||||
|
Shape,
|
||||||
|
SphereGeometry,
|
||||||
|
TubeGeometry,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
|
import type { DuctSegmentNode } from './schema'
|
||||||
|
|
||||||
|
export const INCHES_TO_METERS = 0.0254
|
||||||
|
// Insulation wraps the duct in a roughly uniform shell. A strictly physical
|
||||||
|
// mapping (fiberglass ≈ R-3.2 per inch) makes low R-values nearly invisible
|
||||||
|
// at screen scale — R-1 would add only ~8 mm over a 15 cm duct. So the shell
|
||||||
|
// uses a perceptual mapping: a visible base jacket as soon as insulation is
|
||||||
|
// non-zero, plus a clear per-R increment. Anchored so R-8 still lands near
|
||||||
|
// the real-world ~3" jacket.
|
||||||
|
const INSULATION_BASE_IN = 0.5
|
||||||
|
const INSULATION_INCHES_PER_R = 0.3125
|
||||||
|
function pickInsulationThickness(r: number): number {
|
||||||
|
if (r <= 0) return 0
|
||||||
|
return (INSULATION_BASE_IN + r * INSULATION_INCHES_PER_R) * INCHES_TO_METERS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supply/return tint — kept only for the spiral seam ridge accent; the duct
|
||||||
|
// body itself is plain white (see createDuctMaterial).
|
||||||
|
const SUPPLY_COLOR = '#d4825a'
|
||||||
|
const RETURN_COLOR = '#5a8ad4'
|
||||||
|
|
||||||
|
const RADIAL_SEGMENTS = 24
|
||||||
|
|
||||||
|
const UP = new Vector3(0, 1, 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Area-equivalent round diameter (inches) for a rect cross-section —
|
||||||
|
* what a rect trunk advertises on its ports so round fittings / branches
|
||||||
|
* mate at a sensible size.
|
||||||
|
*/
|
||||||
|
export function equivalentDiameterIn(widthIn: number, heightIn: number): number {
|
||||||
|
return 2 * Math.sqrt((widthIn * heightIn) / Math.PI)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Area-equivalent round diameter (inches) for a flat-oval cross-section:
|
||||||
|
* a rectangle of (width − height) × height plus the two semicircular caps.
|
||||||
|
*/
|
||||||
|
export function 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The diameter (inches) a duct segment presents at its ports. */
|
||||||
|
export function ductPortDiameterIn(node: {
|
||||||
|
shape?: 'round' | 'rect' | 'oval'
|
||||||
|
diameter: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}): 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-section axes for a rect run along `dir`, rolled `roll` radians
|
||||||
|
* about the run direction. At roll 0: width is the horizontal axis
|
||||||
|
* (UP × dir) and height the vertical one — vertical runs, where that
|
||||||
|
* cross product degenerates, fall back to world X/Z. `roll` rotates the
|
||||||
|
* pair in the plane perpendicular to `dir`, letting a riser carry the
|
||||||
|
* orientation of the run it turned off instead of the bare fallback.
|
||||||
|
*/
|
||||||
|
export function rectSectionAxes(dir: Vector3, roll = 0): { width: Vector3; height: Vector3 } {
|
||||||
|
const d = dir.clone().normalize()
|
||||||
|
const xBase = new Vector3().crossVectors(UP, d)
|
||||||
|
if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0)
|
||||||
|
xBase.normalize()
|
||||||
|
const zBase = new Vector3().crossVectors(xBase, d)
|
||||||
|
const c = Math.cos(roll)
|
||||||
|
const s = Math.sin(roll)
|
||||||
|
const width = xBase.clone().multiplyScalar(c).addScaledVector(zBase, s)
|
||||||
|
const height = xBase.clone().multiplyScalar(-s).addScaledVector(zBase, c)
|
||||||
|
return { width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll (radians) that keeps a rect cross-section continuous across an
|
||||||
|
* elbow: the dimension lying along the joint's hinge — the bend-plane
|
||||||
|
* normal `portDir × newDir`, perpendicular to both legs — must stay on
|
||||||
|
* the same physical face on the new run as on the source run. Returns 0
|
||||||
|
* for an in-plane (degenerate-normal) joint, so horizontal turns keep
|
||||||
|
* the natural width-horizontal orientation.
|
||||||
|
*/
|
||||||
|
export function rollToContinueAcrossElbow(
|
||||||
|
sourceDir: Vector3,
|
||||||
|
sourceRoll: number,
|
||||||
|
portDir: Vector3,
|
||||||
|
newDir: Vector3,
|
||||||
|
): number {
|
||||||
|
const n = new Vector3().crossVectors(portDir, newDir)
|
||||||
|
if (n.lengthSq() < 1e-8) return 0
|
||||||
|
n.normalize()
|
||||||
|
const src = rectSectionAxes(sourceDir, sourceRoll)
|
||||||
|
const carriesWidth = Math.abs(src.width.dot(n)) >= Math.abs(src.height.dot(n))
|
||||||
|
const d = newDir.clone().normalize()
|
||||||
|
const xBase = new Vector3().crossVectors(UP, d)
|
||||||
|
if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0)
|
||||||
|
xBase.normalize()
|
||||||
|
const zBase = new Vector3().crossVectors(xBase, d)
|
||||||
|
// Place the hinge-aligned face on the same axis the source carries it.
|
||||||
|
return carriesWidth
|
||||||
|
? Math.atan2(n.dot(zBase), n.dot(xBase))
|
||||||
|
: Math.atan2(-n.dot(xBase), n.dot(zBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rect box spanning `start`→`end`. Orientation comes from `rectSectionAxes`
|
||||||
|
* (width horizontal, height vertical by default; `roll` reorients a riser
|
||||||
|
* to stay continuous through its elbow). Quaternion from an explicit basis
|
||||||
|
* — the minimal-rotation `setFromUnitVectors` used for cylinders would roll
|
||||||
|
* the cross-section on axis-aligned runs.
|
||||||
|
*/
|
||||||
|
export function buildRectSection(
|
||||||
|
start: Vector3,
|
||||||
|
end: Vector3,
|
||||||
|
widthM: number,
|
||||||
|
heightM: number,
|
||||||
|
material: MeshStandardMaterial,
|
||||||
|
name: string,
|
||||||
|
roll = 0,
|
||||||
|
): Mesh | null {
|
||||||
|
const dir = new Vector3().subVectors(end, start)
|
||||||
|
const length = dir.length()
|
||||||
|
if (length < 1e-6) return null
|
||||||
|
dir.normalize()
|
||||||
|
|
||||||
|
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
||||||
|
|
||||||
|
const geom = new BoxGeometry(widthM, length, heightM)
|
||||||
|
const mesh = new Mesh(geom, material)
|
||||||
|
mesh.name = name
|
||||||
|
mesh.position.copy(start).addScaledVector(dir, length / 2)
|
||||||
|
mesh.quaternion.copy(new Quaternion().setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z)))
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat-oval (stadium) profile in the XY plane: width along X, height
|
||||||
|
* along Y, flat top/bottom joined by semicircular end caps of the height.
|
||||||
|
* Degenerates to a circle when width ≤ height.
|
||||||
|
*/
|
||||||
|
function stadiumShape(widthM: number, heightM: number): Shape {
|
||||||
|
const r = Math.min(widthM, heightM) / 2
|
||||||
|
const straight = Math.max(0, widthM - heightM) / 2
|
||||||
|
const shape = new Shape()
|
||||||
|
shape.absarc(straight, 0, r, -Math.PI / 2, Math.PI / 2, false)
|
||||||
|
shape.absarc(-straight, 0, r, Math.PI / 2, (3 * Math.PI) / 2, false)
|
||||||
|
shape.closePath()
|
||||||
|
return shape
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered flat-oval prism with the same local axes as the rect box
|
||||||
|
* (X = width, Y = run length, Z = height), so sections and previews
|
||||||
|
* orient it with the `rectSectionAxes` basis.
|
||||||
|
*/
|
||||||
|
export function createOvalSectionGeometry(
|
||||||
|
widthM: number,
|
||||||
|
heightM: number,
|
||||||
|
lengthM: number,
|
||||||
|
): ExtrudeGeometry {
|
||||||
|
const geom = new ExtrudeGeometry(stadiumShape(widthM, heightM), {
|
||||||
|
depth: lengthM,
|
||||||
|
bevelEnabled: false,
|
||||||
|
curveSegments: RADIAL_SEGMENTS / 2,
|
||||||
|
})
|
||||||
|
geom.translate(0, 0, -lengthM / 2)
|
||||||
|
geom.rotateX(-Math.PI / 2)
|
||||||
|
return geom
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat-oval section spanning `start`→`end` — the oval counterpart of
|
||||||
|
* `buildRectSection`, sharing its orientation basis and roll semantics.
|
||||||
|
*/
|
||||||
|
export function buildOvalSection(
|
||||||
|
start: Vector3,
|
||||||
|
end: Vector3,
|
||||||
|
widthM: number,
|
||||||
|
heightM: number,
|
||||||
|
material: MeshStandardMaterial,
|
||||||
|
name: string,
|
||||||
|
roll = 0,
|
||||||
|
): Mesh | null {
|
||||||
|
const dir = new Vector3().subVectors(end, start)
|
||||||
|
const length = dir.length()
|
||||||
|
if (length < 1e-6) return null
|
||||||
|
dir.normalize()
|
||||||
|
|
||||||
|
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
||||||
|
|
||||||
|
const mesh = new Mesh(createOvalSectionGeometry(widthM, heightM, length), material)
|
||||||
|
mesh.name = name
|
||||||
|
mesh.position.copy(start).addScaledVector(dir, length / 2)
|
||||||
|
mesh.quaternion.copy(new Quaternion().setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z)))
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cylinder spanning `start`→`end` at `radius`. Shared by the segment and
|
||||||
|
* fitting builders — fittings are just short sections + a junction.
|
||||||
|
*/
|
||||||
|
export function buildSection(
|
||||||
|
start: Vector3,
|
||||||
|
end: Vector3,
|
||||||
|
radius: number,
|
||||||
|
material: MeshStandardMaterial,
|
||||||
|
name: string,
|
||||||
|
): Mesh | null {
|
||||||
|
const dir = new Vector3().subVectors(end, start)
|
||||||
|
const length = dir.length()
|
||||||
|
if (length < 1e-6) return null
|
||||||
|
dir.normalize()
|
||||||
|
|
||||||
|
// Capped, front-side-only — ducts should read as solid metal tubes,
|
||||||
|
// not hollow open-ended shells.
|
||||||
|
const geom = new CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS, 1, false)
|
||||||
|
const mesh = new Mesh(geom, material)
|
||||||
|
mesh.name = name
|
||||||
|
mesh.position.copy(start).addScaledVector(dir, length / 2)
|
||||||
|
mesh.quaternion.setFromUnitVectors(UP, dir)
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helical ridge wound around the cylinder spanning `start`→`end` at the
|
||||||
|
* given `pitch` (meters of run per turn) and `ridge` tube radius. The
|
||||||
|
* ridge sits centered on the body surface, so half its thickness reads
|
||||||
|
* as raised. Two construction details share this: the spiral duct's
|
||||||
|
* lock seam (long pitch, thin ridge) and the flex duct's wire helix
|
||||||
|
* (tight pitch, fat ridge → corrugated look).
|
||||||
|
*/
|
||||||
|
function buildHelixRidge(
|
||||||
|
start: Vector3,
|
||||||
|
end: Vector3,
|
||||||
|
radius: number,
|
||||||
|
pitch: number,
|
||||||
|
ridge: number,
|
||||||
|
material: MeshStandardMaterial,
|
||||||
|
name: string,
|
||||||
|
): Mesh | null {
|
||||||
|
const dir = new Vector3().subVectors(end, start)
|
||||||
|
const length = dir.length()
|
||||||
|
if (length < 1e-6) return null
|
||||||
|
dir.normalize()
|
||||||
|
|
||||||
|
const turns = length / pitch
|
||||||
|
const { width: u, height: v } = rectSectionAxes(dir)
|
||||||
|
const samples = Math.min(4096, Math.max(8, Math.ceil(turns * 12)))
|
||||||
|
const pts: Vector3[] = []
|
||||||
|
for (let i = 0; i <= samples; i++) {
|
||||||
|
const t = i / samples
|
||||||
|
const theta = 2 * Math.PI * turns * t
|
||||||
|
pts.push(
|
||||||
|
start
|
||||||
|
.clone()
|
||||||
|
.addScaledVector(dir, t * length)
|
||||||
|
.addScaledVector(u, radius * Math.cos(theta))
|
||||||
|
.addScaledVector(v, radius * Math.sin(theta)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const geom = new TubeGeometry(new CatmullRomCurve3(pts), samples, ridge, 6, false)
|
||||||
|
const mesh = new Mesh(geom, material)
|
||||||
|
mesh.name = name
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helix parameters for a construction material's body detail, or null
|
||||||
|
* for materials with a smooth body. Spiral: the machine seam keeps a
|
||||||
|
* roughly constant helix angle, so pitch scales with the diameter.
|
||||||
|
* Flex: the wire helix is tight and reads as corrugation; its pitch
|
||||||
|
* also follows the diameter but is clamped much lower.
|
||||||
|
*/
|
||||||
|
function helixRidgeFor(
|
||||||
|
ductMaterial: DuctAppearance['ductMaterial'],
|
||||||
|
radius: number,
|
||||||
|
): { pitch: number; ridge: number; color: string } | null {
|
||||||
|
if (ductMaterial === 'spiral') {
|
||||||
|
return {
|
||||||
|
pitch: Math.min(0.3, Math.max(0.08, radius * 1.2)),
|
||||||
|
ridge: Math.min(0.006, Math.max(0.002, radius * 0.06)),
|
||||||
|
color: '#9b9b9b',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ductMaterial === 'flex') {
|
||||||
|
return {
|
||||||
|
pitch: Math.min(0.06, Math.max(0.025, radius * 0.5)),
|
||||||
|
ridge: Math.min(0.009, Math.max(0.004, radius * 0.12)),
|
||||||
|
color: '#737373',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuctAppearance = {
|
||||||
|
ductMaterial: 'sheet-metal' | 'spiral' | 'flex' | 'duct-board'
|
||||||
|
system: 'supply' | 'return'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemTint(node: DuctAppearance): string {
|
||||||
|
return node.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard duct body material — a plain white matte finish so runs and
|
||||||
|
* fittings read like walls / other building elements rather than tinted
|
||||||
|
* 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure geometry builder for a round duct segment polyline.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - For every consecutive pair of path points, build a cylinder of the
|
||||||
|
* duct's inner diameter.
|
||||||
|
* - Drop a sphere of the same radius at every interior joint to cap the
|
||||||
|
* corner smoothly (no mitering yet — fittings come in a later slice).
|
||||||
|
* - When insulation is non-zero, repeat the same pattern at a larger
|
||||||
|
* radius using a translucent shell material.
|
||||||
|
*
|
||||||
|
* All children are returned in level-local meters; the framework's
|
||||||
|
* `<ParametricNodeRenderer>` handles the node-level transform (currently
|
||||||
|
* identity since the schema has no position field — the path itself is
|
||||||
|
* absolute within the level).
|
||||||
|
*/
|
||||||
|
export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||||
|
const group = new Group()
|
||||||
|
if (node.path.length < 2) return group
|
||||||
|
|
||||||
|
const isRect = node.shape === 'rect'
|
||||||
|
const isOval = node.shape === 'oval'
|
||||||
|
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 points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
|
||||||
|
|
||||||
|
const addRun = (
|
||||||
|
half: number,
|
||||||
|
rectW: number,
|
||||||
|
rectH: number,
|
||||||
|
material: MeshStandardMaterial,
|
||||||
|
namePrefix: string,
|
||||||
|
endInsetM = 0,
|
||||||
|
) => {
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
// Loop bounds + min(2) on the schema guarantee both points exist.
|
||||||
|
let a = points[i] as Vector3
|
||||||
|
let b = points[i + 1] as Vector3
|
||||||
|
// Pull the run's open ends in so this shell's end faces never sit
|
||||||
|
// coplanar with the duct's own end caps (z-fighting). Clamped so
|
||||||
|
// a short section can't invert.
|
||||||
|
if (endInsetM > 0) {
|
||||||
|
const dir = new Vector3().subVectors(b, a)
|
||||||
|
const length = dir.length()
|
||||||
|
if (length < 1e-6) continue
|
||||||
|
dir.divideScalar(length)
|
||||||
|
const inset = Math.min(endInsetM, length * 0.25)
|
||||||
|
if (i === 0) a = a.clone().addScaledVector(dir, inset)
|
||||||
|
if (i === points.length - 2) b = b.clone().addScaledVector(dir, -inset)
|
||||||
|
}
|
||||||
|
const mesh = isRect
|
||||||
|
? buildRectSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
|
||||||
|
: 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)
|
||||||
|
}
|
||||||
|
// Joint caps at interior points only (skip first and last — they're
|
||||||
|
// open ends; equipment / terminal / fitting collars cap them). Rect
|
||||||
|
// joints are cubes spanning the cross-section (oval joints the same
|
||||||
|
// prism in stadium profile); round joints spheres.
|
||||||
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
|
const joint = isRect
|
||||||
|
? new Mesh(new BoxGeometry(rectW, rectH, rectW), material)
|
||||||
|
: isOval
|
||||||
|
? new Mesh(createOvalSectionGeometry(rectW, rectH, rectW), material)
|
||||||
|
: new Mesh(new SphereGeometry(half, RADIAL_SEGMENTS, 12), material)
|
||||||
|
joint.name = `${namePrefix}-joint-${i}`
|
||||||
|
joint.position.copy(points[i] as Vector3)
|
||||||
|
group.add(joint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addRun(radius, widthM, heightM, ductMaterial, 'duct')
|
||||||
|
|
||||||
|
// Construction body detail: spiral winds its lock seam, flex its wire
|
||||||
|
// helix (tight pitch — reads as corrugation) over each round section.
|
||||||
|
// These are round-body details, so rect / oval runs render smooth.
|
||||||
|
const helix =
|
||||||
|
node.shape === 'round' && node.seamDetail ? helixRidgeFor(node.ductMaterial, radius) : null
|
||||||
|
if (helix) {
|
||||||
|
const ridgeMaterial = new MeshStandardMaterial({
|
||||||
|
color: helix.color,
|
||||||
|
metalness: node.ductMaterial === 'flex' ? 0.1 : 0.7,
|
||||||
|
roughness: node.ductMaterial === 'flex' ? 0.85 : 0.35,
|
||||||
|
emissive: getSystemTint(node),
|
||||||
|
emissiveIntensity: 0.08,
|
||||||
|
})
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const seam = buildHelixRidge(
|
||||||
|
points[i] as Vector3,
|
||||||
|
points[i + 1] as Vector3,
|
||||||
|
radius,
|
||||||
|
helix.pitch,
|
||||||
|
helix.ridge,
|
||||||
|
ridgeMaterial,
|
||||||
|
`duct-seam-${i}`,
|
||||||
|
)
|
||||||
|
if (seam) group.add(seam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insulationThickness = node.insulated ? pickInsulationThickness(node.insulationR) : 0
|
||||||
|
if (insulationThickness > 0) {
|
||||||
|
const insulationMaterial = new MeshStandardMaterial({
|
||||||
|
color: '#f0e4c8',
|
||||||
|
roughness: 1,
|
||||||
|
metalness: 0,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.25,
|
||||||
|
})
|
||||||
|
addRun(
|
||||||
|
radius + insulationThickness,
|
||||||
|
widthM + insulationThickness * 2,
|
||||||
|
heightM + insulationThickness * 2,
|
||||||
|
insulationMaterial,
|
||||||
|
'duct-insulation',
|
||||||
|
0.01,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { ductSegmentDefinition } from './definition'
|
||||||
|
export { buildDuctSegmentGeometry } from './geometry'
|
||||||
|
export { DuctSegmentNode } from './schema'
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AlignmentAnchor,
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
DuctSegmentNode,
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
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, useRef, useState } from 'react'
|
||||||
|
import { Matrix4, Vector3 } from 'three'
|
||||||
|
import {
|
||||||
|
type Aabb2D,
|
||||||
|
collectGhostAlignmentCandidates,
|
||||||
|
resolveGhostAlignment,
|
||||||
|
} from '../shared/ghost-alignment'
|
||||||
|
import { rectSectionAxes } from './geometry'
|
||||||
|
|
||||||
|
type Vec3 = [number, number, number]
|
||||||
|
|
||||||
|
const GHOST_COLOR = '#818cf8'
|
||||||
|
const GHOST_OPACITY = 0.5
|
||||||
|
const IN_TO_M = 0.0254
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathCenterXZ(path: readonly Vec3[]): [number, number] {
|
||||||
|
let x = 0
|
||||||
|
let z = 0
|
||||||
|
for (const p of path) {
|
||||||
|
x += p[0]
|
||||||
|
z += p[2]
|
||||||
|
}
|
||||||
|
const n = path.length || 1
|
||||||
|
return [x / n, z / n]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half the run's cross-section (meters) — the box / footprint padding. */
|
||||||
|
function runRadiusM(duct: DuctSegmentNode): number {
|
||||||
|
if (duct.shape === 'round') return (duct.diameter * IN_TO_M) / 2
|
||||||
|
return (Math.max(duct.width, duct.height) * IN_TO_M) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The run's vertical box extent (meters). */
|
||||||
|
function runHeightM(duct: DuctSegmentNode): number {
|
||||||
|
return (duct.shape === 'round' ? duct.diameter : duct.height) * IN_TO_M
|
||||||
|
}
|
||||||
|
|
||||||
|
/** XZ bounds of a path padded by the run's radius. */
|
||||||
|
function pathAabb(path: readonly Vec3[], r: number): Aabb2D {
|
||||||
|
let minX = Number.POSITIVE_INFINITY
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY
|
||||||
|
let minZ = Number.POSITIVE_INFINITY
|
||||||
|
let maxZ = Number.NEGATIVE_INFINITY
|
||||||
|
for (const p of path) {
|
||||||
|
if (p[0] < minX) minX = p[0]
|
||||||
|
if (p[0] > maxX) maxX = p[0]
|
||||||
|
if (p[2] < minZ) minZ = p[2]
|
||||||
|
if (p[2] > maxZ) maxZ = p[2]
|
||||||
|
}
|
||||||
|
return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ghost-preview duplicate / move tool for duct runs.
|
||||||
|
*
|
||||||
|
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
|
||||||
|
* inserted into the scene until the commit click. A translucent ghost of
|
||||||
|
* the run (cylinders / boxes matching its profile) rides the cursor inside
|
||||||
|
* a footprint bounding box — the same affordance other items get — and
|
||||||
|
* Figma-style alignment guides snap the box's edges to nearby geometry. The
|
||||||
|
* next grid click calls `createNode`; Esc discards.
|
||||||
|
*
|
||||||
|
* **Move** (existing run): the real node is hidden while the same ghost +
|
||||||
|
* box tracks the cursor; the commit click writes the translated `path` and
|
||||||
|
* reveals it, Esc reveals it unchanged.
|
||||||
|
*
|
||||||
|
* Wired via `def.affordanceTools.move`.
|
||||||
|
*/
|
||||||
|
export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||||
|
const duct = node as DuctSegmentNode
|
||||||
|
const originalPathRef = useRef<Vec3[]>(duct.path.map((p) => [...p] as Vec3))
|
||||||
|
|
||||||
|
const isNew =
|
||||||
|
typeof node.metadata === 'object' &&
|
||||||
|
node.metadata !== null &&
|
||||||
|
!Array.isArray(node.metadata) &&
|
||||||
|
(node.metadata as Record<string, unknown>).isNew === true
|
||||||
|
|
||||||
|
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
|
||||||
|
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
|
||||||
|
const hasMovedRef = useRef(false)
|
||||||
|
const activatedAtRef = useRef<number>(Date.now())
|
||||||
|
const prevSnapRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nodeId = node.id as AnyNodeId
|
||||||
|
const originalPath = originalPathRef.current
|
||||||
|
const [centerX, centerZ] = pathCenterXZ(originalPath)
|
||||||
|
const r = runRadiusM(duct)
|
||||||
|
const baseAabb = pathAabb(originalPath, r)
|
||||||
|
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
let committed = false
|
||||||
|
|
||||||
|
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
|
||||||
|
useScene.getState().nodes,
|
||||||
|
nodeId,
|
||||||
|
useViewer.getState().selection.levelId ?? node.parentId,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Moving an existing run: hide its 3D MESH imperatively (NOT the store
|
||||||
|
// `visible` flag — the 2D floor plan skips `visible:false` nodes, so a
|
||||||
|
// store hide makes the run 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)
|
||||||
|
|
||||||
|
const setPreview = (path: Vec3[]) => {
|
||||||
|
previewPathRef.current = path
|
||||||
|
setPreviewPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (event: GridEvent) => {
|
||||||
|
const bypass = event.nativeEvent?.shiftKey === true
|
||||||
|
const snap = bypass ? (v: number) => v : snapToGridStep
|
||||||
|
let dx = snap(event.localPosition[0] - centerX)
|
||||||
|
let dz = snap(event.localPosition[2] - centerZ)
|
||||||
|
|
||||||
|
// Figma-style alignment: snap the run's footprint box edges onto
|
||||||
|
// nearby geometry and publish the guides (Alt / Shift bypass).
|
||||||
|
if (!bypass) {
|
||||||
|
const proposed: Aabb2D = {
|
||||||
|
minX: baseAabb.minX + dx,
|
||||||
|
maxX: baseAabb.maxX + dx,
|
||||||
|
minZ: baseAabb.minZ + dz,
|
||||||
|
maxZ: baseAabb.maxZ + dz,
|
||||||
|
}
|
||||||
|
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
||||||
|
dx += sdx
|
||||||
|
dz += sdz
|
||||||
|
useAlignmentGuides.getState().set(guides)
|
||||||
|
} else {
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cur: [number, number] = [centerX + dx, centerZ + dz]
|
||||||
|
if (
|
||||||
|
!bypass &&
|
||||||
|
(!prevSnapRef.current ||
|
||||||
|
prevSnapRef.current[0] !== cur[0] ||
|
||||||
|
prevSnapRef.current[1] !== cur[1])
|
||||||
|
) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
prevSnapRef.current = cur
|
||||||
|
hasMovedRef.current = true
|
||||||
|
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = (event: GridEvent) => {
|
||||||
|
if (committed) return
|
||||||
|
if (Date.now() - activatedAtRef.current < 150) {
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hasMovedRef.current) {
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
const finalPath = previewPathRef.current
|
||||||
|
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
let selectId = nodeId
|
||||||
|
if (isNew && !useScene.getState().nodes[nodeId]) {
|
||||||
|
const created = DuctSegmentNode.parse({
|
||||||
|
...(node as Record<string, unknown>),
|
||||||
|
path: finalPath,
|
||||||
|
metadata: stripPlacementMetadataFlags(node.metadata),
|
||||||
|
visible: true,
|
||||||
|
})
|
||||||
|
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>)
|
||||||
|
useScene.getState().markDirty(nodeId)
|
||||||
|
}
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
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 = () => {
|
||||||
|
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)
|
||||||
|
useAlignmentGuides.getState().clear()
|
||||||
|
if (existedAtStart) setMeshHidden(false)
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
}
|
||||||
|
}, [duct, isNew, node])
|
||||||
|
|
||||||
|
const segments: Array<{ a: Vec3; b: Vec3 }> = []
|
||||||
|
for (let i = 0; i < previewPath.length - 1; i++) {
|
||||||
|
segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footprint box spanning the whole run (axis-aligned), drawn around the
|
||||||
|
// ghost the same way items get one. Recomputed from the live preview path.
|
||||||
|
const r = runRadiusM(duct)
|
||||||
|
const box = pathAabb(previewPath, r)
|
||||||
|
const boxY = previewPath[0]?.[1] ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{segments.map((seg, i) => (
|
||||||
|
<GhostSegment a={seg.a} b={seg.b} duct={duct} key={`ghost-${i}`} />
|
||||||
|
))}
|
||||||
|
<DragBoundingBox
|
||||||
|
centerY={0}
|
||||||
|
nodeId={node.id}
|
||||||
|
position={[(box.minX + box.maxX) / 2, boxY, (box.minZ + box.maxZ) / 2]}
|
||||||
|
size={[box.maxX - box.minX, runHeightM(duct), box.maxZ - box.minZ]}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Translucent stand-in for one duct section — mirrors the draw tool's
|
||||||
|
* `PreviewSegment` so the ghost matches what actually lands. */
|
||||||
|
function GhostSegment({ a, b, duct }: { a: Vec3; b: Vec3; duct: DuctSegmentNode }) {
|
||||||
|
const start = new Vector3(...a)
|
||||||
|
const end = new Vector3(...b)
|
||||||
|
const dir = new Vector3().subVectors(end, start)
|
||||||
|
const length = dir.length()
|
||||||
|
if (length < 1e-4) return null
|
||||||
|
dir.normalize()
|
||||||
|
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
|
||||||
|
|
||||||
|
if (duct.shape !== 'round') {
|
||||||
|
const w = duct.width * IN_TO_M
|
||||||
|
const h = duct.height * IN_TO_M
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
|
position={mid.toArray()}
|
||||||
|
ref={(m) => {
|
||||||
|
if (!m) return
|
||||||
|
const { width: x, height: z } = rectSectionAxes(dir, duct.roll)
|
||||||
|
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[w, length, h]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color={GHOST_COLOR}
|
||||||
|
depthTest={false}
|
||||||
|
opacity={GHOST_OPACITY}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const radius = (duct.diameter * IN_TO_M) / 2
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
|
position={mid.toArray()}
|
||||||
|
ref={(m) => {
|
||||||
|
if (!m) return
|
||||||
|
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<cylinderGeometry args={[radius, radius, length, 24, 1, false]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color={GHOST_COLOR}
|
||||||
|
depthTest={false}
|
||||||
|
opacity={GHOST_OPACITY}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MoveDuctSegmentTool
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { type DuctFittingNode, type ParametricDescriptor, useScene } from '@pascal-app/core'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
|
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
||||||
|
import { rollToContinueAcrossElbow } from './geometry'
|
||||||
|
import type { DuctSegmentNode } from './schema'
|
||||||
|
|
||||||
|
/** A run endpoint sitting this close to a collar counts as mated. */
|
||||||
|
const MATE_TOL_M = 0.03
|
||||||
|
|
||||||
|
function dist2(a: readonly [number, number, number], b: readonly [number, number, number]): number {
|
||||||
|
const dx = a[0] - b[0]
|
||||||
|
const dy = a[1] - b[1]
|
||||||
|
const dz = a[2] - b[2]
|
||||||
|
return dx * dx + dy * dy + dz * dz
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-section roll that keeps this run continuous through a fitting
|
||||||
|
* mated at either endpoint — the same continuity the draw tool computes
|
||||||
|
* for freshly drawn risers (`rollToContinueAcrossElbow`), recovered here
|
||||||
|
* for runs whose shape is flipped to rect AFTER they were drawn. Without
|
||||||
|
* it a riser falls back to the world-axis orientation and its profile
|
||||||
|
* lands 90° off the elbow it rises from. Returns null when no fitting is
|
||||||
|
* mated (roll 0 — the natural horizontal orientation — is correct).
|
||||||
|
*/
|
||||||
|
function rollFromMatedFitting(duct: DuctSegmentNode): number | null {
|
||||||
|
if (duct.path.length < 2) return null
|
||||||
|
const first = duct.path[0]!
|
||||||
|
const last = duct.path[duct.path.length - 1]!
|
||||||
|
const ends = [
|
||||||
|
{ point: first, away: duct.path[1]! },
|
||||||
|
{ point: last, away: duct.path[duct.path.length - 2]! },
|
||||||
|
]
|
||||||
|
const tol2 = MATE_TOL_M * MATE_TOL_M
|
||||||
|
for (const node of Object.values(useScene.getState().nodes)) {
|
||||||
|
if (node.type !== 'duct-fitting') continue
|
||||||
|
const fitting = node as DuctFittingNode
|
||||||
|
if (fitting.fittingType === 'reducer') continue
|
||||||
|
const ports = getDuctFittingPorts(fitting)
|
||||||
|
for (const end of ends) {
|
||||||
|
const mated = ports.find((p) => dist2(end.point, p.position) <= tol2)
|
||||||
|
if (!mated) continue
|
||||||
|
// The leg on the far side of the junction is the source the
|
||||||
|
// profile must stay continuous with: an elbow's other run leg, or
|
||||||
|
// the tee's run when this duct is the branch.
|
||||||
|
const source = ports.find((p) => p.id !== mated.id && p.id !== 'branch')
|
||||||
|
if (!source) continue
|
||||||
|
const srcDuct = Object.values(useScene.getState().nodes).find(
|
||||||
|
(n) =>
|
||||||
|
n.type === 'duct-segment' &&
|
||||||
|
n.id !== duct.id &&
|
||||||
|
((n as DuctSegmentNode).path.length >= 2
|
||||||
|
? dist2((n as DuctSegmentNode).path[0]!, source.position) <= tol2 ||
|
||||||
|
dist2(
|
||||||
|
(n as DuctSegmentNode).path[(n as DuctSegmentNode).path.length - 1]!,
|
||||||
|
source.position,
|
||||||
|
) <= tol2
|
||||||
|
: false),
|
||||||
|
) as DuctSegmentNode | undefined
|
||||||
|
const newDir = new Vector3(
|
||||||
|
end.away[0] - end.point[0],
|
||||||
|
end.away[1] - end.point[1],
|
||||||
|
end.away[2] - end.point[2],
|
||||||
|
)
|
||||||
|
if (newDir.lengthSq() < 1e-10) continue
|
||||||
|
newDir.normalize()
|
||||||
|
// Only steep runs are ambiguous (world-axis fallback); a
|
||||||
|
// horizontal run's roll-0 orientation is already canonical, and
|
||||||
|
// re-deriving it from a possibly-stale riser roll would corrupt it.
|
||||||
|
if (Math.abs(newDir.y) < Math.SQRT1_2) continue
|
||||||
|
const srcRoll = srcDuct && srcDuct.shape !== 'round' ? srcDuct.roll : 0
|
||||||
|
const srcDir = new Vector3(...source.direction)
|
||||||
|
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ductSegmentParametrics: ParametricDescriptor<DuctSegmentNode> = {
|
||||||
|
// Flipping a drawn run to rect / oval recovers the cross-section roll
|
||||||
|
// the draw tool would have computed — risers re-orient to stay
|
||||||
|
// continuous through the elbow they turn off instead of snapping to
|
||||||
|
// the world-axis fallback. Spiral is a round-only construction, so a
|
||||||
|
// non-round run can never hold it: leaving round (or picking spiral on
|
||||||
|
// a rect / oval run) falls back to plain sheet metal.
|
||||||
|
derive: (next, patch) => {
|
||||||
|
const out: Partial<DuctSegmentNode> = {}
|
||||||
|
if (next.ductMaterial === 'spiral' && next.shape !== 'round') {
|
||||||
|
out.ductMaterial = 'sheet-metal'
|
||||||
|
}
|
||||||
|
if ('shape' in patch && next.shape !== 'round') {
|
||||||
|
const roll = rollFromMatedFitting(next)
|
||||||
|
if (roll !== null) out.roll = roll
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: 'Air',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'system',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['supply', 'return'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'shape',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['round', 'rect', 'oval'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'diameter',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 4,
|
||||||
|
max: 24,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.shape === 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'width',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 4,
|
||||||
|
max: 60,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.shape !== 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'height',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 3,
|
||||||
|
max: 40,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.shape !== 'round',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Construction',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'ductMaterial',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['sheet-metal', 'spiral', 'flex', 'duct-board'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'seamDetail',
|
||||||
|
kind: 'boolean',
|
||||||
|
// Only meaningful where a body detail exists: round spiral
|
||||||
|
// (lock seam) and round flex (wire corrugation).
|
||||||
|
visibleIf: (n) =>
|
||||||
|
n.shape === 'round' && (n.ductMaterial === 'spiral' || n.ductMaterial === 'flex'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'insulated',
|
||||||
|
kind: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'insulationR',
|
||||||
|
kind: 'number',
|
||||||
|
min: 0,
|
||||||
|
max: 8,
|
||||||
|
step: 0.5,
|
||||||
|
visibleIf: (n) => n.insulated,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { DuctSegmentNode } from '@pascal-app/core'
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
analyzePortConnectivity,
|
||||||
|
type DuctSegmentNode,
|
||||||
|
type PortConnectivity,
|
||||||
|
pauseSceneHistory,
|
||||||
|
resolveConnectivityUpdates,
|
||||||
|
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, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports'
|
||||||
|
|
||||||
|
/** Handle pip radius (meters). */
|
||||||
|
const HANDLE_RADIUS = 0.09
|
||||||
|
/** Port-snap radius for dragged run endpoints (meters, XZ). */
|
||||||
|
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 duct runs: one draggable handle
|
||||||
|
* per path point.
|
||||||
|
*
|
||||||
|
* Handles are PORTALED into the duct's registered scene group so they
|
||||||
|
* share its exact frame — path coords are node-local, and the level /
|
||||||
|
* building transform above the group applies to the handles for free.
|
||||||
|
* Drag raycasts run in world space and convert hits back into the
|
||||||
|
* group's local frame before writing the path.
|
||||||
|
*
|
||||||
|
* Drag model: by default the point is CONSTRAINED to the axis the
|
||||||
|
* segment was drawn along — a horizontal duct's endpoint slides along
|
||||||
|
* its own length, a riser's endpoint slides vertically. Holding **Alt**
|
||||||
|
* releases the constraint into free horizontal-plane movement (at the
|
||||||
|
* point's height); in free mode dragged run endpoints (first / last
|
||||||
|
* point) also snap onto nearby typed ports so a loose run can be mated
|
||||||
|
* onto a fitting after the fact. Holding **Shift** bypasses grid
|
||||||
|
* snapping in either mode for a perfectly smooth precision drag.
|
||||||
|
*
|
||||||
|
* History does the single-undo dance: paused during the drag (the live
|
||||||
|
* `updateNode` ticks are untracked), then on release the path is
|
||||||
|
* reverted, history resumed, and the final path applied as one tracked
|
||||||
|
* change.
|
||||||
|
*/
|
||||||
|
const DuctSegmentSelectionAffordance = () => {
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
const duct = useScene((s) => {
|
||||||
|
if (selectedIds.length !== 1) return null
|
||||||
|
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||||
|
return node?.type === 'duct-segment' ? (node as DuctSegmentNode) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Portal target: the duct's registered group. Resolved with a rAF
|
||||||
|
// retry because registration happens on the renderer's mount, which
|
||||||
|
// can land a frame after selection.
|
||||||
|
const ductId = duct?.id ?? null
|
||||||
|
const [target, setTarget] = useState<Object3D | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ductId) {
|
||||||
|
setTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let frameId = 0
|
||||||
|
const resolve = () => {
|
||||||
|
const next = sceneRegistry.nodes.get(ductId as AnyNodeId) ?? null
|
||||||
|
setTarget((cur) => (cur === next ? cur : next))
|
||||||
|
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
return () => window.cancelAnimationFrame(frameId)
|
||||||
|
}, [ductId])
|
||||||
|
|
||||||
|
if (!duct || !target) return null
|
||||||
|
return createPortal(<DuctPointHandles duct={duct} target={target} />, target, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; 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)
|
||||||
|
// Set while a drag is live; null otherwise. Holds everything the window
|
||||||
|
// pointer handlers need so they never read stale React state.
|
||||||
|
const dragRef = useRef<{
|
||||||
|
index: number
|
||||||
|
initialPath: Point[]
|
||||||
|
current: Point
|
||||||
|
cleanup: () => void
|
||||||
|
// Connectivity snapshot taken at pointer-down: which fittings / ducts are
|
||||||
|
// mated to this run's endpoints, so they follow as the endpoint moves.
|
||||||
|
connectivity: PortConnectivity | null
|
||||||
|
} | 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed distance along `axisWorld` (unit, through `anchorWorld`) of the
|
||||||
|
* point on that line closest to the cursor ray. Null when the ray runs
|
||||||
|
* (near-)parallel to the axis and the projection is unstable.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World-space position of a local path point. */
|
||||||
|
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
|
||||||
|
/** Convert a world-space hit back into the duct group's local frame. */
|
||||||
|
const toLocal = (world: Vector3): Point => {
|
||||||
|
const local = target.worldToLocal(world.clone())
|
||||||
|
return [local.x, local.y, local.z]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow-updates for fittings / ducts mated to this run's endpoints, given
|
||||||
|
// the run's live path. Endpoints whose position didn't change resolve to a
|
||||||
|
// zero delta, so only the dragged endpoint's partner actually moves.
|
||||||
|
const connectivityUpdatesForPath = (
|
||||||
|
connectivity: PortConnectivity | null,
|
||||||
|
path: Point[],
|
||||||
|
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
||||||
|
if (!connectivity) return []
|
||||||
|
const preview = { ...(duct as Record<string, unknown>), path } as AnyNode
|
||||||
|
return resolveConnectivityUpdates(connectivity, preview).filter(
|
||||||
|
(u) => useScene.getState().nodes[u.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const initialPath = duct.path.map((p) => [...p] as Point)
|
||||||
|
const startPoint = initialPath[index]!
|
||||||
|
const connectivity = analyzePortConnectivity(duct as AnyNode, useScene.getState().nodes)
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
useViewer.getState().setInputDragging(true)
|
||||||
|
document.body.style.cursor = 'grabbing'
|
||||||
|
setDraggingIndex(index)
|
||||||
|
|
||||||
|
const isEndpoint = index === 0 || index === initialPath.length - 1
|
||||||
|
|
||||||
|
// Axis the segment was drawn along, at this point: from the
|
||||||
|
// neighbouring path point toward the dragged one. The default drag
|
||||||
|
// is constrained to this line.
|
||||||
|
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()
|
||||||
|
// World-space anchor + axis, derived once — the constraint line is
|
||||||
|
// fixed for the whole drag regardless of where the point currently is.
|
||||||
|
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
|
||||||
|
// Shift = precision: bypass grid snapping for a perfectly smooth
|
||||||
|
// drag (snap() is a no-op at step 0).
|
||||||
|
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||||
|
let next: Point | null = null
|
||||||
|
if (event.altKey) {
|
||||||
|
// Alt = freedom: slide on the horizontal plane at the point's
|
||||||
|
// height. Endpoints can port-snap here to mate onto a fitting.
|
||||||
|
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: duct.id, systems: DUCT_PORT_SYSTEMS }),
|
||||||
|
PORT_SNAP_RADIUS_M,
|
||||||
|
)
|
||||||
|
if (port) next = [port.position[0], port.position[1], port.position[2]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default: constrained to the axis the segment was drawn along —
|
||||||
|
// slide the point closer / further along its own line.
|
||||||
|
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 = duct.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
|
||||||
|
// Drag the run + any fittings mated to the moved endpoint as one batch.
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNodes([
|
||||||
|
{ id: duct.id as AnyNodeId, data: { path } },
|
||||||
|
...connectivityUpdatesForPath(drag.connectivity, path),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
const drag = dragRef.current
|
||||||
|
if (!drag) return
|
||||||
|
drag.cleanup()
|
||||||
|
dragRef.current = null
|
||||||
|
setDraggingIndex(null)
|
||||||
|
// Single-undo dance: revert (still paused), resume, re-apply the
|
||||||
|
// final path — plus any connected fitting moves — as one tracked batch.
|
||||||
|
const finalPath = drag.initialPath.map((p, i) =>
|
||||||
|
i === drag.index ? drag.current : p,
|
||||||
|
) as Point[]
|
||||||
|
const finalUpdates = connectivityUpdatesForPath(drag.connectivity, finalPath)
|
||||||
|
// Revert the run AND the followers to their pre-drag state while paused
|
||||||
|
// so history captures a clean before→after delta.
|
||||||
|
const revertUpdates = (drag.connectivity?.connections ?? []).flatMap((conn) =>
|
||||||
|
conn.kind === 'rigid-node'
|
||||||
|
? [{ id: conn.nodeId, data: { position: conn.startPosition } as Partial<AnyNode> }]
|
||||||
|
: [{ id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }],
|
||||||
|
)
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNodes([
|
||||||
|
{ id: duct.id as AnyNodeId, data: { path: drag.initialPath } },
|
||||||
|
...revertUpdates.filter((u) => useScene.getState().nodes[u.id]),
|
||||||
|
])
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
const moved = finalPath[drag.index]!.some(
|
||||||
|
(v, axis) => v !== drag.initialPath[drag.index]![axis],
|
||||||
|
)
|
||||||
|
if (moved) {
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNodes([{ id: duct.id as AnyNodeId, data: { path: finalPath } }, ...finalUpdates])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, connectivity }
|
||||||
|
window.addEventListener('pointermove', onMove)
|
||||||
|
window.addEventListener('pointerup', onUp)
|
||||||
|
window.addEventListener('pointercancel', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{duct.path.map((p, i) => {
|
||||||
|
const active = draggingIndex === i
|
||||||
|
const hovered = hoverIndex === i
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
key={`duct-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 &&
|
||||||
|
duct.path[draggingIndex] &&
|
||||||
|
(() => {
|
||||||
|
// Same pill as the draw tool: signed per-axis deltas from the
|
||||||
|
// drag-start position, dominant axis emphasised.
|
||||||
|
const point = duct.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 DuctSegmentSelectionAffordance
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
DuctSegmentNode,
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
getLevelHeight,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
CursorSphere,
|
||||||
|
DimensionPill,
|
||||||
|
EDITOR_LAYER,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} 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 { getDuctFittingPorts } from '../duct-fitting/ports'
|
||||||
|
import {
|
||||||
|
planCrossAtRunBody,
|
||||||
|
planElbowAtPort,
|
||||||
|
planElbowRealign,
|
||||||
|
planTeeAtRunBody,
|
||||||
|
} from '../shared/auto-fitting'
|
||||||
|
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||||||
|
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||||
|
import {
|
||||||
|
collectScenePorts,
|
||||||
|
DUCT_PORT_SYSTEMS,
|
||||||
|
findNearestPortXZ,
|
||||||
|
findNearestRunBodyXZ,
|
||||||
|
findRunBodyCrossingXZ,
|
||||||
|
type RunBodyHit,
|
||||||
|
type ScenePort,
|
||||||
|
} from '../shared/ports'
|
||||||
|
import { ductSegmentDefinition } from './definition'
|
||||||
|
import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-segment-at-a-time placement tool for round 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).
|
||||||
|
* - **Auto-elbow**: when either end snapped onto another RUN's open
|
||||||
|
* port at an angle (15–90°, vertical turns included), an elbow
|
||||||
|
* fitting is minted at the joint and the duct pulls back to its
|
||||||
|
* outlet collar — corners get real fittings instead of butt joints.
|
||||||
|
* - **Tee tap**: starting OR ending on the SIDE of an existing run
|
||||||
|
* (centerline snap) splits the trunk, mints a tee at the tap point,
|
||||||
|
* and the branch leaves square from its collar.
|
||||||
|
* - **Cross tap**: drawing a run straight THROUGH the side of an
|
||||||
|
* existing run (interior crossing) splits the trunk, mints a 4-way
|
||||||
|
* cross at the crossing, and the drawn run continues out the far
|
||||||
|
* branch — both fittings inherit the trunk's / branch's profile.
|
||||||
|
* - 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 the lock.
|
||||||
|
* - Hold **Alt** → vertical mode. Cursor XZ locks to the start;
|
||||||
|
* 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.
|
||||||
|
* - Esc clears an anchored start point.
|
||||||
|
*/
|
||||||
|
const PREVIEW_OPACITY = 0.55
|
||||||
|
/**
|
||||||
|
* Nominal US round-duct sizes (inches): 4"–10" in 1" steps, 12"+ in 2"
|
||||||
|
* steps — matches what flex and rigid round actually ship in.
|
||||||
|
*/
|
||||||
|
const DUCT_DIAMETERS_IN = [4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20] as const
|
||||||
|
/** Snap radius (meters) for joining onto an existing duct's start/end. */
|
||||||
|
const ENDPOINT_SNAP_RADIUS_M = 0.5
|
||||||
|
/** Snap radius (meters) for tapping the SIDE of an existing run — a tee
|
||||||
|
* is minted there. Tighter than the port radius so run ends keep
|
||||||
|
* priority near their last stretch. */
|
||||||
|
const BODY_SNAP_RADIUS_M = 0.35
|
||||||
|
/** Angle step (radians) for the XZ angle lock — 45°. */
|
||||||
|
const ANGLE_STEP_RAD = Math.PI / 4
|
||||||
|
/** Mouse pixels → meters mapping for Alt-vertical drag. 100 px ≈ 1 m. */
|
||||||
|
const ALT_PIXELS_PER_METER = 100
|
||||||
|
/** Bounds on Alt-driven Y so a wild fling doesn't fly off. */
|
||||||
|
const ALT_Y_MIN_M = -3
|
||||||
|
const ALT_Y_MAX_M = 10
|
||||||
|
|
||||||
|
function snap(value: number, step: number): number {
|
||||||
|
if (step <= 0) return value
|
||||||
|
return Math.round(value / step) * step
|
||||||
|
}
|
||||||
|
|
||||||
|
function dist2(a: readonly [number, number, number], b: readonly [number, number, number]): number {
|
||||||
|
const dx = a[0] - b[0]
|
||||||
|
const dy = a[1] - b[1]
|
||||||
|
const dz = a[2] - b[2]
|
||||||
|
return dx * dx + dy * dy + dz * dz
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-section roll for a new rect run leaving `port` along `newDir`,
|
||||||
|
* so its profile stays continuous with whatever it joined: a turn
|
||||||
|
* re-derives the roll through the (future) elbow, a straight
|
||||||
|
* continuation inherits the source's roll as-is. Sources: a rect run's
|
||||||
|
* open end, or a rect fitting's open collar (continuity then comes from
|
||||||
|
* the leg on the far side of the junction and the rect run mated
|
||||||
|
* there). Null when the port doesn't carry a rect orientation. Shared
|
||||||
|
* by the ghost preview and the commit so what you see is what lands.
|
||||||
|
*/
|
||||||
|
function continuityRollFrom(port: ScenePort | null, newDir: Vector3): number | null {
|
||||||
|
if (!port) return null
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const owner = nodes[port.nodeId]
|
||||||
|
let srcDir: Vector3 | null = null
|
||||||
|
let srcRoll = 0
|
||||||
|
if (
|
||||||
|
(owner?.type === 'hvac-equipment' || owner?.type === 'duct-terminal') &&
|
||||||
|
port.shape &&
|
||||||
|
port.shape !== 'round'
|
||||||
|
) {
|
||||||
|
// The collar mesh is built at the canonical `rectSectionAxes(dir, 0)`
|
||||||
|
// basis, so it reads as a source run pointing out along the port with
|
||||||
|
// roll 0 — the new leg rolls to continue that across its turn.
|
||||||
|
srcDir = new Vector3(...port.direction)
|
||||||
|
srcRoll = 0
|
||||||
|
} else if (owner?.type === 'duct-segment' && owner.shape !== 'round') {
|
||||||
|
srcDir = new Vector3(...port.direction)
|
||||||
|
srcRoll = owner.roll
|
||||||
|
} else if (
|
||||||
|
owner?.type === 'duct-fitting' &&
|
||||||
|
owner.shape !== 'round' &&
|
||||||
|
owner.fittingType !== 'reducer' &&
|
||||||
|
owner.fittingType !== 'transition'
|
||||||
|
) {
|
||||||
|
const source = getDuctFittingPorts(owner).find(
|
||||||
|
(p) => p.id !== port.id && p.id !== 'branch' && p.id !== 'branch2',
|
||||||
|
)
|
||||||
|
if (source) {
|
||||||
|
srcDir = new Vector3(...source.direction)
|
||||||
|
const tol2 = 0.03 * 0.03
|
||||||
|
for (const n of Object.values(nodes)) {
|
||||||
|
if (n.type !== 'duct-segment' || n.shape === 'round' || n.path.length < 2) continue
|
||||||
|
const ends = [n.path[0]!, n.path[n.path.length - 1]!]
|
||||||
|
if (ends.some((e) => dist2(e, source.position) <= tol2)) {
|
||||||
|
srcRoll = n.roll
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!srcDir) return null
|
||||||
|
const cross = new Vector3().crossVectors(srcDir, newDir)
|
||||||
|
if (cross.lengthSq() < 1e-8) return srcRoll
|
||||||
|
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nearest typed port — duct run ends, fitting collars, anything whose
|
||||||
|
* kind registers `def.ports` — within snap range of `point` on the XZ
|
||||||
|
* plane. Y is ignored for the distance check (grid events ride the floor
|
||||||
|
* while ports hang at duct height); the snap adopts the port's full 3D
|
||||||
|
* position. The full port is returned so the commit knows what it joined
|
||||||
|
* (auto-elbow insertion needs the port's direction and owner).
|
||||||
|
*/
|
||||||
|
function findNearbyPort(point: [number, number, number]): ScenePort | null {
|
||||||
|
return findNearestPortXZ(
|
||||||
|
point,
|
||||||
|
collectScenePorts({ systems: DUCT_PORT_SYSTEMS }),
|
||||||
|
ENDPOINT_SNAP_RADIUS_M,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function portPoint(port: ScenePort): [number, number, number] {
|
||||||
|
return [port.position[0], port.position[1], port.position[2]]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cross-section the tool draws with (and commits onto the node). Oval
|
||||||
|
* never comes from the Q toggle (round ↔ rect) — it enters by joining
|
||||||
|
* an existing oval run / fitting collar and continuing its profile. */
|
||||||
|
type DraftProfile = {
|
||||||
|
shape: 'round' | 'rect' | 'oval'
|
||||||
|
diameter: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile to inherit when the segment start snaps onto `port` — joining
|
||||||
|
* means continuing that thing: a rect trunk end keeps its W×H, a round
|
||||||
|
* run / fitting collar keeps its diameter. Equipment and terminal
|
||||||
|
* collars are round at the port's advertised size.
|
||||||
|
*/
|
||||||
|
function inheritProfile(port: ScenePort): DraftProfile | null {
|
||||||
|
const owner = useScene.getState().nodes[port.nodeId]
|
||||||
|
if (!owner) return null
|
||||||
|
if (owner.type === 'duct-segment' || owner.type === 'duct-fitting') {
|
||||||
|
return {
|
||||||
|
shape: owner.shape,
|
||||||
|
diameter: Math.min(
|
||||||
|
48,
|
||||||
|
Math.max(2, owner.type === 'duct-segment' ? owner.diameter : port.diameter),
|
||||||
|
),
|
||||||
|
width: owner.width,
|
||||||
|
height: owner.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (owner.type === 'hvac-equipment' || owner.type === 'duct-terminal') {
|
||||||
|
const defaults = ductSegmentDefinition.defaults() as DraftProfile
|
||||||
|
// Adopt the collar's cross-section so the run leaves a rect / oval
|
||||||
|
// plenum as rect / oval (rolled to match in `continuityRollFrom`),
|
||||||
|
// falling back to round at the advertised diameter.
|
||||||
|
if (port.shape && port.shape !== 'round') {
|
||||||
|
return {
|
||||||
|
shape: port.shape,
|
||||||
|
diameter: Math.min(48, Math.max(2, port.diameter)),
|
||||||
|
width: port.width ?? defaults.width,
|
||||||
|
height: port.height ?? defaults.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shape: 'round',
|
||||||
|
diameter: Math.min(48, Math.max(2, port.diameter)),
|
||||||
|
width: defaults.width,
|
||||||
|
height: defaults.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project `raw` onto the nearest of the eight 45° rays emanating from
|
||||||
|
* `from` in the XZ plane. Y is preserved from `from`. The projection
|
||||||
|
* keeps the cursor's *distance* along the chosen ray so the user feels
|
||||||
|
* the segment grow with their mouse motion rather than snap to a fixed
|
||||||
|
* length.
|
||||||
|
*/
|
||||||
|
function projectToAngleLock(
|
||||||
|
from: [number, number, number],
|
||||||
|
raw: [number, number, number],
|
||||||
|
): [number, number, number] {
|
||||||
|
const dx = raw[0] - from[0]
|
||||||
|
const dz = raw[2] - from[2]
|
||||||
|
const len = Math.hypot(dx, dz)
|
||||||
|
if (len < 1e-4) return [from[0], from[1], from[2]]
|
||||||
|
const theta = Math.atan2(dz, dx)
|
||||||
|
const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD
|
||||||
|
// Distance along the chosen ray = projection of raw onto that direction.
|
||||||
|
const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped)
|
||||||
|
const d = Math.max(0, proj)
|
||||||
|
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DuctSegmentTool = () => {
|
||||||
|
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||||
|
const unit = useViewer((s) => s.unit)
|
||||||
|
const cursorRef = useRef<Group>(null)
|
||||||
|
// Cross-section profile for the next committed segment. Q toggles
|
||||||
|
// round/rect, [ / ] steps the round diameter, and snapping the start
|
||||||
|
// onto an existing run / fitting INHERITS that node's profile — so
|
||||||
|
// continuing a 14×8 trunk keeps drawing 14×8, and branching off a
|
||||||
|
// round collar keeps its diameter. Seeded from `toolDefaults`.
|
||||||
|
const [profile, setProfile] = useState<DraftProfile>(() => {
|
||||||
|
const defaults = ductSegmentDefinition.defaults() as DraftProfile
|
||||||
|
const seeded = useEditor.getState().toolDefaults['duct-segment'] as
|
||||||
|
| Partial<DraftProfile>
|
||||||
|
| undefined
|
||||||
|
return {
|
||||||
|
shape: seeded?.shape ?? defaults.shape,
|
||||||
|
diameter: seeded?.diameter ?? defaults.diameter,
|
||||||
|
width: seeded?.width ?? defaults.width,
|
||||||
|
height: seeded?.height ?? defaults.height,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [draftPoints, setDraftPoints] = useState<Array<[number, number, number]>>([])
|
||||||
|
const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null)
|
||||||
|
// 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.
|
||||||
|
const [snapTarget, setSnapTarget] = useState<[number, number, number] | 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)
|
||||||
|
// Mirror into refs so emitter callbacks (closing over the first render's
|
||||||
|
// setState) read the latest values without re-subscribing.
|
||||||
|
const draftRef = useRef(draftPoints)
|
||||||
|
draftRef.current = draftPoints
|
||||||
|
const cursorPosRef = useRef(cursorPos)
|
||||||
|
cursorPosRef.current = cursorPos
|
||||||
|
const profileRef = useRef(profile)
|
||||||
|
profileRef.current = profile
|
||||||
|
const ceilingModeRef = useRef(ceilingMode)
|
||||||
|
ceilingModeRef.current = ceilingMode
|
||||||
|
// Port the anchored START point snapped onto (null = free placement).
|
||||||
|
// Read at commit so a turn off an existing run mints an elbow there.
|
||||||
|
const startPortRef = useRef<ScenePort | null>(null)
|
||||||
|
// Centerline hit the anchored START point snapped onto (null = none).
|
||||||
|
// Read at commit so a branch off a trunk's side mints a tee there.
|
||||||
|
const startBodyRef = useRef<RunBodyHit | null>(null)
|
||||||
|
// Anchor captured when Alt is pressed: screen Y at that moment and the
|
||||||
|
// base elevation (= last point's Y). Cleared on Alt release.
|
||||||
|
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
|
||||||
|
// Latest mouse clientY from grid:move; used so the Alt anchor knows where
|
||||||
|
// the cursor was at key-press time.
|
||||||
|
const lastClientYRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
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 15–90° 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).
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
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)]
|
||||||
|
// 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> }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
setDraftPoints([])
|
||||||
|
setSnapTarget(null)
|
||||||
|
startPortRef.current = null
|
||||||
|
startBodyRef.current = null
|
||||||
|
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 => {
|
||||||
|
if (!ceilingModeRef.current) return 0
|
||||||
|
const ceiling = getLevelHeight(activeLevelId, useScene.getState().nodes)
|
||||||
|
const p = profileRef.current
|
||||||
|
const verticalIn = p.shape === 'round' ? p.diameter : p.height
|
||||||
|
return Math.max(0, ceiling - (verticalIn * 0.0254) / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSnappedPoint = (
|
||||||
|
event: GridEvent,
|
||||||
|
): {
|
||||||
|
point: [number, number, number]
|
||||||
|
snapped: [number, number, number] | null
|
||||||
|
port: ScenePort | null
|
||||||
|
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.
|
||||||
|
if (!last) {
|
||||||
|
const baseY = resolveBaseY()
|
||||||
|
const raw: [number, number, number] = [
|
||||||
|
event.localPosition[0],
|
||||||
|
baseY,
|
||||||
|
event.localPosition[2],
|
||||||
|
]
|
||||||
|
const step = useEditor.getState().gridSnapStep
|
||||||
|
const shift = event.nativeEvent?.shiftKey === true
|
||||||
|
if (event.nativeEvent?.altKey !== true) {
|
||||||
|
const target = findNearbyPort(raw)
|
||||||
|
if (target)
|
||||||
|
return {
|
||||||
|
point: portPoint(target),
|
||||||
|
snapped: portPoint(target),
|
||||||
|
port: target,
|
||||||
|
body: null,
|
||||||
|
}
|
||||||
|
// No open end nearby — try the side of a run (tee tap). Probe
|
||||||
|
// with a grid-snapped cursor so the tap steps along the duct
|
||||||
|
// like every other placement; Shift frees it to ride smoothly.
|
||||||
|
const probe: [number, number, number] = shift
|
||||||
|
? raw
|
||||||
|
: [snap(raw[0], step), baseY, snap(raw[2], step)]
|
||||||
|
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||||||
|
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
point: [snap(raw[0], step), baseY, snap(raw[2], step)],
|
||||||
|
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.
|
||||||
|
const rawXZ: [number, number, number] = [
|
||||||
|
event.localPosition[0],
|
||||||
|
last[1],
|
||||||
|
event.localPosition[2],
|
||||||
|
]
|
||||||
|
const shift = event.nativeEvent?.shiftKey === true
|
||||||
|
const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ)
|
||||||
|
const step = useEditor.getState().gridSnapStep
|
||||||
|
// Port snap (Alt bypass) — checked against the RAW cursor, not the
|
||||||
|
// angle-locked projection, so a port slightly off the 45° ray can
|
||||||
|
// still capture the cursor. Joining beats the lock.
|
||||||
|
if (event.nativeEvent?.altKey !== true && !shift) {
|
||||||
|
const target = findNearbyPort(rawXZ)
|
||||||
|
if (target)
|
||||||
|
return { point: portPoint(target), snapped: portPoint(target), port: target, body: null }
|
||||||
|
// No open end nearby — landing on the side of a run taps a tee
|
||||||
|
// there (mirror of the first-point tee tap). Probe with a
|
||||||
|
// grid-snapped cursor so the tap steps along the duct instead of
|
||||||
|
// sliding smoothly (Shift above frees it). Checked against the
|
||||||
|
// cursor, not the 45° projection, so a slightly-off trunk captures.
|
||||||
|
const probe: [number, number, number] = [
|
||||||
|
snap(rawXZ[0], step),
|
||||||
|
rawXZ[1],
|
||||||
|
snap(rawXZ[2], step),
|
||||||
|
]
|
||||||
|
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||||||
|
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
point: [snap(angled[0], step), angled[1], snap(angled[2], step)],
|
||||||
|
snapped: null,
|
||||||
|
port: null,
|
||||||
|
body: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Alt-mode cursor position: XZ locked to the last point,
|
||||||
|
* Y driven by how far the mouse has moved vertically on screen since
|
||||||
|
* Alt was pressed. Returns null if there's no anchor (Alt not active).
|
||||||
|
*/
|
||||||
|
const resolveAltVerticalPoint = (clientY: number): [number, number, number] | null => {
|
||||||
|
const anchor = altAnchorRef.current
|
||||||
|
const last = draftRef.current.at(-1)
|
||||||
|
if (!anchor || !last) return null
|
||||||
|
const step = useEditor.getState().gridSnapStep
|
||||||
|
// Screen +Y points down, so subtract to map "drag up = raise Y".
|
||||||
|
const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
|
||||||
|
const snappedDy = snap(dy, step)
|
||||||
|
const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy))
|
||||||
|
return [last[0], y, last[2]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the cursor point (port / body / grid / angle snap) and then
|
||||||
|
// layer Figma-style alignment on top so a run lines up with other runs,
|
||||||
|
// fittings, and items as it's drawn. Snap is applied for a free point
|
||||||
|
// (first vertex, or Shift free-angle); an angle-locked continuation shows
|
||||||
|
// the guide passively without leaving its 45° ray. A port / body snap or
|
||||||
|
// Alt bypasses alignment entirely.
|
||||||
|
const resolveAlignedPoint = (event: GridEvent) => {
|
||||||
|
const r = resolveSnappedPoint(event)
|
||||||
|
const hasStart = draftRef.current.length > 0
|
||||||
|
const shift = event.nativeEvent?.shiftKey === true
|
||||||
|
const alt = event.nativeEvent?.altKey === true
|
||||||
|
const point = alignDrawPoint(r.point, {
|
||||||
|
applySnap: !hasStart || shift,
|
||||||
|
bypass: alt || r.snapped !== null,
|
||||||
|
})
|
||||||
|
return { ...r, point }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (event: GridEvent) => {
|
||||||
|
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
|
||||||
|
if (typeof clientY === 'number') lastClientYRef.current = clientY
|
||||||
|
// Alt vertical mode wins over the XZ logic.
|
||||||
|
if (altAnchorRef.current && typeof clientY === 'number') {
|
||||||
|
const point = resolveAltVerticalPoint(clientY)
|
||||||
|
if (point) {
|
||||||
|
clearDrawAlignment()
|
||||||
|
setCursorPos(point)
|
||||||
|
setSnapTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { point, snapped } = resolveAlignedPoint(event)
|
||||||
|
setCursorPos(point)
|
||||||
|
setSnapTarget(snapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick = (event: GridEvent) => {
|
||||||
|
const start = draftRef.current.at(-1)
|
||||||
|
// Vertical mode with a start anchored: the click commits the riser
|
||||||
|
// segment right there. Never falls through to the XZ logic — a
|
||||||
|
// no-op Alt click (height unchanged) must not place anything.
|
||||||
|
if (altAnchorRef.current && start) {
|
||||||
|
const clientY =
|
||||||
|
(event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current
|
||||||
|
if (typeof clientY === 'number') {
|
||||||
|
const point = resolveAltVerticalPoint(clientY)
|
||||||
|
if (point && Math.abs(point[1] - start[1]) >= 1e-4) {
|
||||||
|
commitSegment(start, point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { point, port, body } = resolveAlignedPoint(event)
|
||||||
|
if (!start) {
|
||||||
|
// First click: anchor the segment start, remembering the port or
|
||||||
|
// run body it snapped to so the commit can mint an elbow / tee.
|
||||||
|
// Joining a port INHERITS the source's cross-section — continuing
|
||||||
|
// a rect trunk keeps drawing rect at its W×H, a round collar its
|
||||||
|
// diameter. Body taps (tee branches) keep the tool's own profile.
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
startPortRef.current = port
|
||||||
|
startBodyRef.current = port ? null : body
|
||||||
|
if (port) {
|
||||||
|
const inherited = inheritProfile(port)
|
||||||
|
if (inherited) setProfile(inherited)
|
||||||
|
}
|
||||||
|
setDraftPoints([point])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Second click: commit the segment and re-arm. A body hit on the end
|
||||||
|
// (no end port) taps a tee into that run's side.
|
||||||
|
commitSegment(start, point, port, port ? null : body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterAltMode = () => {
|
||||||
|
const last = draftRef.current.at(-1)
|
||||||
|
if (!last || lastClientYRef.current === null) return
|
||||||
|
if (altAnchorRef.current) return
|
||||||
|
altAnchorRef.current = { clientY: lastClientYRef.current, baseY: last[1] }
|
||||||
|
setAltActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitAltMode = () => {
|
||||||
|
if (!altAnchorRef.current) return
|
||||||
|
altAnchorRef.current = null
|
||||||
|
setAltActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepDiameter = (step: 1 | -1) => {
|
||||||
|
const sizes = DUCT_DIAMETERS_IN
|
||||||
|
const current = profileRef.current.diameter
|
||||||
|
// Nearest catalogue index, then step — handles seeded off-catalogue
|
||||||
|
// values (e.g. a preset's 7.5") gracefully.
|
||||||
|
let nearest = 0
|
||||||
|
for (let i = 1; i < sizes.length; i++) {
|
||||||
|
if (Math.abs(sizes[i]! - current) < Math.abs(sizes[nearest]! - current)) nearest = i
|
||||||
|
}
|
||||||
|
const next = sizes[Math.min(sizes.length - 1, Math.max(0, nearest + step))]!
|
||||||
|
if (next === current) return
|
||||||
|
setProfile((p) => ({ ...p, diameter: next }))
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const tag = (e.target as HTMLElement | null)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||||
|
if (e.key === 'Alt') {
|
||||||
|
e.preventDefault()
|
||||||
|
enterAltMode()
|
||||||
|
} else if (e.key === '[') {
|
||||||
|
e.preventDefault()
|
||||||
|
stepDiameter(-1)
|
||||||
|
} else if (e.key === ']') {
|
||||||
|
e.preventDefault()
|
||||||
|
stepDiameter(1)
|
||||||
|
} else if (e.key === 'q' || e.key === 'Q') {
|
||||||
|
e.preventDefault()
|
||||||
|
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.
|
||||||
|
if (draftRef.current.length > 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
setCeilingMode((m) => !m)
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Alt') {
|
||||||
|
e.preventDefault()
|
||||||
|
exitAltMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
clearDrawAlignment()
|
||||||
|
if (draftRef.current.length === 0) return
|
||||||
|
markToolCancelConsumed()
|
||||||
|
setDraftPoints([])
|
||||||
|
setCursorPos(null)
|
||||||
|
setSnapTarget(null)
|
||||||
|
startPortRef.current = null
|
||||||
|
startBodyRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onMove)
|
||||||
|
emitter.on('grid:click', onClick)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
return () => {
|
||||||
|
emitter.off('grid:move', onMove)
|
||||||
|
emitter.off('grid:click', onClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
|
altAnchorRef.current = null
|
||||||
|
clearDrawAlignment()
|
||||||
|
}
|
||||||
|
}, [activeLevelId])
|
||||||
|
|
||||||
|
if (!activeLevelId) return null
|
||||||
|
|
||||||
|
const previewSegments: Array<{ a: [number, number, number]; b: [number, number, number] }> = []
|
||||||
|
for (let i = 0; i < draftPoints.length - 1; i++) {
|
||||||
|
previewSegments.push({ a: draftPoints[i]!, b: draftPoints[i + 1]! })
|
||||||
|
}
|
||||||
|
const last = draftPoints.at(-1)
|
||||||
|
if (last && cursorPos) {
|
||||||
|
previewSegments.push({ a: last, b: cursorPos })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// Alt-vertical mode, otherwise whichever horizontal axis dominates. A
|
||||||
|
// trailing Ø readout shows the diameter the next click commits ([ / ]).
|
||||||
|
const pillParts = cursorPos
|
||||||
|
? [
|
||||||
|
...(['x', 'y', 'z'] as const).map((axis, i) => ({
|
||||||
|
key: axis,
|
||||||
|
prefix: axis.toUpperCase(),
|
||||||
|
value: last ? cursorPos[i]! - last[i]! : cursorPos[i]!,
|
||||||
|
signed: !!last,
|
||||||
|
})),
|
||||||
|
...(profile.shape === 'round'
|
||||||
|
? [{ key: 'diameter', prefix: 'Ø', value: profile.diameter * 0.0254, signed: false }]
|
||||||
|
: [
|
||||||
|
{ key: 'trunk-w', prefix: 'W', value: profile.width * 0.0254, signed: false },
|
||||||
|
{ key: 'trunk-h', prefix: 'H', value: profile.height * 0.0254, signed: false },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: null
|
||||||
|
const pillPrimary =
|
||||||
|
last && cursorPos
|
||||||
|
? altActive
|
||||||
|
? 'y'
|
||||||
|
: Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2])
|
||||||
|
? 'x'
|
||||||
|
: 'z'
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LevelOffsetGroup>
|
||||||
|
{/* 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 && (
|
||||||
|
<>
|
||||||
|
<CursorSphere position={cursorPos} ref={cursorRef} />
|
||||||
|
{pillParts && (
|
||||||
|
<group position={cursorPos}>
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
position={[0, 0.35, 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} />
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</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}>
|
||||||
|
<sphereGeometry args={[0.07, 16, 12]} />
|
||||||
|
<meshBasicMaterial color="#818cf8" depthTest={false} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
{/* Preview sections */}
|
||||||
|
{previewSegments.map((seg, i) => (
|
||||||
|
<PreviewSegment
|
||||||
|
a={seg.a}
|
||||||
|
b={seg.b}
|
||||||
|
key={`seg-${i}`}
|
||||||
|
profile={profile}
|
||||||
|
startPort={startPortRef.current}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LevelOffsetGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewSegment({
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
profile,
|
||||||
|
startPort,
|
||||||
|
}: {
|
||||||
|
a: [number, number, number]
|
||||||
|
b: [number, number, number]
|
||||||
|
profile: DraftProfile
|
||||||
|
startPort: ScenePort | null
|
||||||
|
}) {
|
||||||
|
const start = new Vector3(...a)
|
||||||
|
const end = new Vector3(...b)
|
||||||
|
const dir = new Vector3().subVectors(end, start)
|
||||||
|
const length = dir.length()
|
||||||
|
if (length < 1e-4) return null
|
||||||
|
dir.normalize()
|
||||||
|
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
|
||||||
|
|
||||||
|
// Rect AND oval ghost as a box — close enough for a translucent guide.
|
||||||
|
if (profile.shape !== 'round') {
|
||||||
|
const w = profile.width * 0.0254
|
||||||
|
const h = profile.height * 0.0254
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
|
position={mid.toArray()}
|
||||||
|
ref={(m) => {
|
||||||
|
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 { width: x, height: z } = rectSectionAxes(dir, roll)
|
||||||
|
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[w, length, h]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#818cf8"
|
||||||
|
depthTest={false}
|
||||||
|
opacity={PREVIEW_OPACITY}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const radius = (profile.diameter * 0.0254) / 2
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
|
position={mid.toArray()}
|
||||||
|
ref={(m) => {
|
||||||
|
if (!m) return
|
||||||
|
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<cylinderGeometry args={[radius, radius, length, 24, 1, false]} />
|
||||||
|
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={PREVIEW_OPACITY} transparent />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DuctSegmentTool
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { NodeDefinition } from '@pascal-app/core'
|
||||||
|
import { buildDuctTerminalFloorplan } from './floorplan'
|
||||||
|
import { buildDuctTerminalGeometry } from './geometry'
|
||||||
|
import { ductTerminalParametrics } from './parametrics'
|
||||||
|
import { getDuctTerminalPorts } from './ports'
|
||||||
|
import { DuctTerminalNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3 of the HVAC node system — duct terminals: supply registers,
|
||||||
|
* ceiling diffusers, return grilles. The end of the air loop. One typed
|
||||||
|
* port at the collar (mount-aware direction) so duct runs end onto a
|
||||||
|
* terminal like any other port.
|
||||||
|
*
|
||||||
|
* Composition: `def.geometry` only. Yaw-only rotation — the editor's
|
||||||
|
* default R-rotate works on a selected terminal.
|
||||||
|
*/
|
||||||
|
export const ductTerminalDefinition: NodeDefinition<typeof DuctTerminalNode> = {
|
||||||
|
kind: 'duct-terminal',
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: DuctTerminalNode,
|
||||||
|
category: 'utility',
|
||||||
|
distributionRole: 'terminal',
|
||||||
|
|
||||||
|
defaults: () => ({
|
||||||
|
object: 'node',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
terminalType: 'supply-register',
|
||||||
|
mount: 'floor',
|
||||||
|
width: 0.3,
|
||||||
|
depth: 0.15,
|
||||||
|
collarShape: 'round',
|
||||||
|
collarDiameter: 6,
|
||||||
|
collarWidth: 10,
|
||||||
|
collarHeight: 6,
|
||||||
|
}),
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
selectable: { hitVolume: 'bbox' },
|
||||||
|
movable: { axes: ['x', 'z'], gridSnap: true, portSnap: { systems: ['supply', 'return'] } },
|
||||||
|
rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] },
|
||||||
|
duplicable: true,
|
||||||
|
deletable: true,
|
||||||
|
// A floor register rests on top of whatever slab is under it — the
|
||||||
|
// generic FloorElevationSystem lifts its mesh Y by the slab's elevation
|
||||||
|
// so the face sits on the slab surface instead of sinking into it.
|
||||||
|
// Ceiling / wall mounts derive their Y elsewhere, so `applies` skips them.
|
||||||
|
floorPlaced: {
|
||||||
|
footprint: (node) => {
|
||||||
|
const t = node as DuctTerminalNode
|
||||||
|
return { dimensions: [t.width, 0, t.depth], rotation: [0, t.rotation, 0] }
|
||||||
|
},
|
||||||
|
applies: (node) => (node as DuctTerminalNode).mount === 'floor',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
parametrics: ductTerminalParametrics,
|
||||||
|
|
||||||
|
geometry: buildDuctTerminalGeometry,
|
||||||
|
geometryKey: (n) =>
|
||||||
|
JSON.stringify([
|
||||||
|
n.terminalType,
|
||||||
|
n.mount,
|
||||||
|
n.width,
|
||||||
|
n.depth,
|
||||||
|
n.collarShape,
|
||||||
|
n.collarDiameter,
|
||||||
|
n.collarWidth,
|
||||||
|
n.collarHeight,
|
||||||
|
]),
|
||||||
|
|
||||||
|
ports: getDuctTerminalPorts,
|
||||||
|
|
||||||
|
floorplan: buildDuctTerminalFloorplan,
|
||||||
|
|
||||||
|
tool: () => import('./tool'),
|
||||||
|
toolHints: [
|
||||||
|
{ key: 'Click', label: 'Place register' },
|
||||||
|
{ key: 'M', label: 'Mount: floor / ceiling / wall' },
|
||||||
|
{ key: 'R / T', label: 'Rotate ±45° (floor / ceiling)' },
|
||||||
|
{ key: 'Shift', label: 'Smooth (no grid snap)' },
|
||||||
|
{ key: 'Esc', label: 'Exit' },
|
||||||
|
],
|
||||||
|
|
||||||
|
presentation: {
|
||||||
|
label: 'Register',
|
||||||
|
description:
|
||||||
|
'Duct terminal — supply register, ceiling diffuser, or return grille. Duct runs end at its collar.',
|
||||||
|
icon: { kind: 'url', src: '/icons/registers.png' },
|
||||||
|
paletteSection: 'structure',
|
||||||
|
paletteOrder: 93,
|
||||||
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
description:
|
||||||
|
'A duct terminal (supply register, ceiling diffuser, or return grille) with a single collar port. Mount (floor/ceiling/wall) drives the face orientation and collar direction.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
|
||||||
|
import { terminalSystem } from './ports'
|
||||||
|
import type { DuctTerminalNode } from './schema'
|
||||||
|
|
||||||
|
const SUPPLY_COLOR = '#d4825a'
|
||||||
|
const RETURN_COLOR = '#5a8ad4'
|
||||||
|
const FRAME_STROKE = '#6b7280'
|
||||||
|
const FACE_FILL = '#e5e7eb'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor-plan symbol for a duct terminal: the face rectangle (rotated by
|
||||||
|
* yaw) with the conventional register cross-slats hinted as a single
|
||||||
|
* mid-line, tinted by system. Wall mounts render the same footprint —
|
||||||
|
* the face projects to a thin strip, which is close enough for plan
|
||||||
|
* reading at this stage.
|
||||||
|
*/
|
||||||
|
export function buildDuctTerminalFloorplan(
|
||||||
|
node: DuctTerminalNode,
|
||||||
|
ctx: GeometryContext,
|
||||||
|
): FloorplanGeometry | null {
|
||||||
|
const [cx, , cz] = node.position
|
||||||
|
const cos = Math.cos(node.rotation)
|
||||||
|
const sin = Math.sin(node.rotation)
|
||||||
|
const hw = node.width / 2
|
||||||
|
const hd = (node.mount === 'wall' ? 0.06 : node.depth) / 2
|
||||||
|
const corner = (lx: number, lz: number): FloorplanPoint => [
|
||||||
|
cx + lx * cos + lz * sin,
|
||||||
|
cz - lx * sin + lz * cos,
|
||||||
|
]
|
||||||
|
const points: FloorplanPoint[] = [
|
||||||
|
corner(-hw, -hd),
|
||||||
|
corner(hw, -hd),
|
||||||
|
corner(hw, hd),
|
||||||
|
corner(-hw, hd),
|
||||||
|
]
|
||||||
|
|
||||||
|
const view = ctx.viewState
|
||||||
|
const palette = view?.palette
|
||||||
|
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
|
||||||
|
const accent = terminalSystem(node) === 'supply' ? SUPPLY_COLOR : RETURN_COLOR
|
||||||
|
const stroke = showSelectedChrome && palette ? palette.selectedStroke : FRAME_STROKE
|
||||||
|
|
||||||
|
const mid1 = corner(-hw * 0.8, 0)
|
||||||
|
const mid2 = corner(hw * 0.8, 0)
|
||||||
|
|
||||||
|
const children: FloorplanGeometry[] = [
|
||||||
|
{
|
||||||
|
kind: 'polygon',
|
||||||
|
points,
|
||||||
|
fill: FACE_FILL,
|
||||||
|
stroke,
|
||||||
|
strokeWidth: showSelectedChrome ? 0.025 : 0.015,
|
||||||
|
opacity: 0.92,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'line',
|
||||||
|
x1: mid1[0],
|
||||||
|
y1: mid1[1],
|
||||||
|
x2: mid2[0],
|
||||||
|
y2: mid2[1],
|
||||||
|
stroke: accent,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
vectorEffect: 'non-scaling-stroke',
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (showSelectedChrome) {
|
||||||
|
children.push({ kind: 'move-handle', point: [cx, cz] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'group', children }
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
BoxGeometry,
|
||||||
|
type BufferGeometry,
|
||||||
|
CylinderGeometry,
|
||||||
|
Group,
|
||||||
|
Mesh,
|
||||||
|
MeshStandardMaterial,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
|
import { createOvalSectionGeometry, INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||||
|
import { COLLAR_LENGTH, mountQuaternion, terminalSystem } from './ports'
|
||||||
|
import type { DuctTerminalNode } from './schema'
|
||||||
|
|
||||||
|
const RADIAL_SEGMENTS = 20
|
||||||
|
|
||||||
|
/** Radial clearance (meters) the collar sleeve carries over the duct's
|
||||||
|
* nominal cross-section, so a run leaving at the advertised size nests
|
||||||
|
* inside the sleeve instead of z-fighting its faces. ~5 mm ≈ a slip joint. */
|
||||||
|
const COLLAR_CLEARANCE_M = 0.005
|
||||||
|
|
||||||
|
const FRAME_COLOR = '#e3e5e8'
|
||||||
|
const SLAT_SUPPLY_COLOR = '#cdd1d6'
|
||||||
|
const SLAT_RETURN_COLOR = '#aeb4bb'
|
||||||
|
const COLLAR_COLOR = '#c2c2c2'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure geometry builder for a duct terminal, in the node's LOCAL frame —
|
||||||
|
* `<ParametricNodeRenderer>` applies `position` + yaw, and the builder
|
||||||
|
* applies the mount orientation itself.
|
||||||
|
*
|
||||||
|
* Canonical (floor) frame before the mount rotation: face plate lying
|
||||||
|
* in XZ at y=0 with its normal +Y, louver slats just above it, collar
|
||||||
|
* cylinder going -Y toward the duct side. Ceiling mounts flip it; wall
|
||||||
|
* mounts stand it up facing +Z.
|
||||||
|
*/
|
||||||
|
export function buildDuctTerminalGeometry(node: DuctTerminalNode): Group {
|
||||||
|
const group = new Group()
|
||||||
|
const oriented = new Group()
|
||||||
|
oriented.quaternion.copy(mountQuaternion(node.mount))
|
||||||
|
group.add(oriented)
|
||||||
|
|
||||||
|
const frameMaterial = new MeshStandardMaterial({
|
||||||
|
color: FRAME_COLOR,
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.5,
|
||||||
|
})
|
||||||
|
const slatMaterial = new MeshStandardMaterial({
|
||||||
|
color: terminalSystem(node) === 'return' ? SLAT_RETURN_COLOR : SLAT_SUPPLY_COLOR,
|
||||||
|
metalness: 0.45,
|
||||||
|
roughness: 0.55,
|
||||||
|
})
|
||||||
|
|
||||||
|
const frameThickness = 0.018
|
||||||
|
const frame = new Mesh(new BoxGeometry(node.width, frameThickness, node.depth), frameMaterial)
|
||||||
|
frame.name = 'terminal-frame'
|
||||||
|
frame.position.set(0, frameThickness / 2, 0)
|
||||||
|
oriented.add(frame)
|
||||||
|
|
||||||
|
// Louver slats across the face. Return grilles read denser; diffusers
|
||||||
|
// get concentric-ish wide slats via the same simple pattern.
|
||||||
|
const slatCount = node.terminalType === 'return-grille' ? 7 : 4
|
||||||
|
const innerDepth = node.depth * 0.82
|
||||||
|
const slatDepth = (innerDepth / slatCount) * 0.55
|
||||||
|
for (let i = 0; i < slatCount; i++) {
|
||||||
|
const slat = new Mesh(new BoxGeometry(node.width * 0.86, 0.006, slatDepth), slatMaterial)
|
||||||
|
slat.name = `terminal-slat-${i}`
|
||||||
|
const z = -innerDepth / 2 + (innerDepth / slatCount) * (i + 0.5)
|
||||||
|
slat.position.set(0, frameThickness + 0.002, z)
|
||||||
|
slat.rotation.x = node.terminalType === 'diffuser' ? 0 : -0.5
|
||||||
|
oriented.add(slat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collar runs along -Y from the face toward the duct. Round is a
|
||||||
|
// cylinder; rect a box; oval the flat-oval prism (its extrude basis
|
||||||
|
// already puts the run length on Y, matching the collar axis). The
|
||||||
|
// sleeve is grown one clearance on every side so a duct run leaving at
|
||||||
|
// the advertised size nests inside it instead of z-fighting its faces.
|
||||||
|
const grow = 2 * COLLAR_CLEARANCE_M
|
||||||
|
let collarGeom: BufferGeometry
|
||||||
|
if (node.collarShape === 'rect') {
|
||||||
|
collarGeom = new BoxGeometry(
|
||||||
|
node.collarWidth * INCHES_TO_METERS + grow,
|
||||||
|
COLLAR_LENGTH,
|
||||||
|
node.collarHeight * INCHES_TO_METERS + grow,
|
||||||
|
)
|
||||||
|
} else if (node.collarShape === 'oval') {
|
||||||
|
collarGeom = createOvalSectionGeometry(
|
||||||
|
node.collarWidth * INCHES_TO_METERS + grow,
|
||||||
|
node.collarHeight * INCHES_TO_METERS + grow,
|
||||||
|
COLLAR_LENGTH,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const radius = (node.collarDiameter * INCHES_TO_METERS + grow) / 2
|
||||||
|
collarGeom = new CylinderGeometry(radius, radius, COLLAR_LENGTH, RADIAL_SEGMENTS, 1, false)
|
||||||
|
}
|
||||||
|
const collar = new Mesh(
|
||||||
|
collarGeom,
|
||||||
|
new MeshStandardMaterial({ color: COLLAR_COLOR, metalness: 0.6, roughness: 0.4 }),
|
||||||
|
)
|
||||||
|
collar.name = 'terminal-collar'
|
||||||
|
collar.position.copy(new Vector3(0, -COLLAR_LENGTH / 2, 0))
|
||||||
|
oriented.add(collar)
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { ductTerminalDefinition } from './definition'
|
||||||
|
export { buildDuctTerminalGeometry } from './geometry'
|
||||||
|
export { getDuctTerminalPorts } from './ports'
|
||||||
|
export { DuctTerminalNode } from './schema'
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||||
|
import type { DuctTerminalNode } from './schema'
|
||||||
|
|
||||||
|
export const ductTerminalParametrics: ParametricDescriptor<DuctTerminalNode> = {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: 'Terminal',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'terminalType',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['supply-register', 'diffuser', 'return-grille'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mount',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['floor', 'ceiling', 'wall'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Face',
|
||||||
|
fields: [
|
||||||
|
{ key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 1.5, step: 0.05 },
|
||||||
|
{ key: 'depth', kind: 'number', unit: 'm', min: 0.05, max: 1.5, step: 0.05 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Collar',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'collarShape',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['round', 'rect', 'oval'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'collarDiameter',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 4,
|
||||||
|
max: 20,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.collarShape === 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'collarWidth',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 4,
|
||||||
|
max: 20,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.collarShape !== 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'collarHeight',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 3,
|
||||||
|
max: 20,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.collarShape !== 'round',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Placement',
|
||||||
|
fields: [{ key: 'position', kind: 'vec3' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { NodePort } from '@pascal-app/core'
|
||||||
|
import { Euler, Quaternion, Vector3 } from 'three'
|
||||||
|
import { equivalentDiameterIn, ovalEquivalentDiameterIn } from '../duct-segment/geometry'
|
||||||
|
import type { DuctTerminalNode } from './schema'
|
||||||
|
|
||||||
|
/** Collar stub length in meters behind the face. */
|
||||||
|
export const COLLAR_LENGTH = 0.12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount orientation: rotation applied to the canonical floor frame
|
||||||
|
* (face normal +Y, collar pointing -Y). Ceiling flips it; wall stands
|
||||||
|
* it up so the face looks along +Z and the collar points -Z (into the
|
||||||
|
* wall). Yaw is applied on top by the renderer / port transform.
|
||||||
|
*/
|
||||||
|
export function mountQuaternion(mount: DuctTerminalNode['mount']): Quaternion {
|
||||||
|
if (mount === 'ceiling') return new Quaternion().setFromEuler(new Euler(Math.PI, 0, 0))
|
||||||
|
if (mount === 'wall') return new Quaternion().setFromEuler(new Euler(Math.PI / 2, 0, 0))
|
||||||
|
return new Quaternion()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminalSystem(node: DuctTerminalNode): 'supply' | 'return' {
|
||||||
|
return node.terminalType === 'return-grille' ? 'return' : 'supply'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diameter (inches) the collar advertises at its port. Rect / oval
|
||||||
|
* collars report the area-equivalent round diameter so round runs mate
|
||||||
|
* at a sensible size — the same convention duct segments use.
|
||||||
|
*/
|
||||||
|
export function collarPortDiameterIn(node: DuctTerminalNode): number {
|
||||||
|
if (node.collarShape === 'rect') return equivalentDiameterIn(node.collarWidth, node.collarHeight)
|
||||||
|
if (node.collarShape === 'oval') {
|
||||||
|
return ovalEquivalentDiameterIn(node.collarWidth, node.collarHeight)
|
||||||
|
}
|
||||||
|
return node.collarDiameter
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `def.ports` — the single collar port in level-local space. Canonical
|
||||||
|
* frame: collar tip at (0, -COLLAR_LENGTH, 0) pointing -Y (away from the
|
||||||
|
* face); mount + yaw + position transform it. Direction points OUT of
|
||||||
|
* the terminal — i.e. toward the duct that should connect.
|
||||||
|
*/
|
||||||
|
export function getDuctTerminalPorts(node: DuctTerminalNode): NodePort[] {
|
||||||
|
const transform = new Quaternion()
|
||||||
|
.setFromEuler(new Euler(0, node.rotation, 0))
|
||||||
|
.multiply(mountQuaternion(node.mount))
|
||||||
|
const position = new Vector3(0, -COLLAR_LENGTH, 0)
|
||||||
|
.applyQuaternion(transform)
|
||||||
|
.add(new Vector3(node.position[0], node.position[1], node.position[2]))
|
||||||
|
const direction = new Vector3(0, -1, 0).applyQuaternion(transform).normalize()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'collar',
|
||||||
|
position: [position.x, position.y, position.z] as const,
|
||||||
|
direction: [direction.x, direction.y, direction.z] as const,
|
||||||
|
diameter: collarPortDiameterIn(node),
|
||||||
|
system: terminalSystem(node),
|
||||||
|
shape: node.collarShape,
|
||||||
|
width: node.collarWidth,
|
||||||
|
height: node.collarHeight,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { DuctTerminalNode } from '@pascal-app/core'
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
DuctTerminalNode,
|
||||||
|
emitter,
|
||||||
|
pointInPolygon,
|
||||||
|
resolveLevelId,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
type WallEvent,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
CursorSphere,
|
||||||
|
getFloorStackPreviewPosition,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { useThree } from '@react-three/fiber'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Euler, Matrix3, Matrix4, Plane, Quaternion, Raycaster, Vector2, Vector3 } from 'three'
|
||||||
|
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||||||
|
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||||
|
import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports'
|
||||||
|
import { ductTerminalDefinition } from './definition'
|
||||||
|
import { buildDuctTerminalGeometry } from './geometry'
|
||||||
|
import { COLLAR_LENGTH, mountQuaternion } from './ports'
|
||||||
|
|
||||||
|
const PREVIEW_OPACITY = 0.55
|
||||||
|
/** R/T yaw step — 45°. */
|
||||||
|
const ROTATE_STEP_RAD = Math.PI / 4
|
||||||
|
/** Fallback height (meters) for a ceiling node that carries no `height`. */
|
||||||
|
const DEFAULT_CEILING_HEIGHT = 2.5
|
||||||
|
/** Snap radius (meters) for mating the collar onto a nearby duct port. */
|
||||||
|
const PORT_SNAP_RADIUS_M = 0.5
|
||||||
|
|
||||||
|
type Mount = DuctTerminalNode['mount']
|
||||||
|
const MOUNT_CYCLE: Mount[] = ['floor', 'ceiling', 'wall']
|
||||||
|
|
||||||
|
function snap(value: number, step: number): number {
|
||||||
|
if (step <= 0) return value
|
||||||
|
return Math.round(value / step) * step
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collar-port offset from the node origin for a given mount + yaw, in
|
||||||
|
* level-local meters — the same transform `def.ports` applies, so the
|
||||||
|
* placement tool can predict where the collar lands and shift the whole
|
||||||
|
* terminal to mate it onto a duct port.
|
||||||
|
*/
|
||||||
|
function collarOffset(mount: Mount, yaw: number): Vector3 {
|
||||||
|
const transform = new Quaternion()
|
||||||
|
.setFromEuler(new Euler(0, yaw, 0))
|
||||||
|
.multiply(mountQuaternion(mount))
|
||||||
|
return new Vector3(0, -COLLAR_LENGTH, 0).applyQuaternion(transform)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The active level's mesh, or null. Carries the building transform plus the
|
||||||
|
* level's stacked elevation — the frame terminals are stored and parented in,
|
||||||
|
* so cursor hits resolve to true level-local coords on every floor. */
|
||||||
|
function activeLevelMesh() {
|
||||||
|
const levelId = useViewer.getState().selection.levelId
|
||||||
|
return levelId ? (sceneRegistry.nodes.get(levelId as AnyNodeId) ?? null) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
type Placement = {
|
||||||
|
position: [number, number, number]
|
||||||
|
/** Yaw radians applied to the ghost / committed node. */
|
||||||
|
yaw: number
|
||||||
|
/** Mount the ghost / committed node uses — inferred from the mated port
|
||||||
|
* when snapped, else the user's manual M selection. */
|
||||||
|
mount: Mount
|
||||||
|
/** True when the collar mated onto a nearby duct port (magnetic snap). */
|
||||||
|
snapped?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direction is "vertical" when its Y component dominates this much. */
|
||||||
|
const VERTICAL_DOT = 0.7
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the mount that makes a collar mate onto a duct port pointing
|
||||||
|
* `dir` (the port's outward direction). The collar leaves the face along
|
||||||
|
* −Y in the canonical frame, so the mount rotation must turn −Y to face
|
||||||
|
* *into* the port (i.e. opposite `dir`):
|
||||||
|
* - port pointing up (a riser top) → collar must point down → **floor**
|
||||||
|
* - port pointing down (a ceiling drop) → collar points up → **ceiling**
|
||||||
|
* - port horizontal (a wall stub) → **wall**, yawed so the collar runs
|
||||||
|
* back along the port. `lockYaw` is set only for wall (floor / ceiling
|
||||||
|
* yaw is free — the user keeps spinning the face with R/T).
|
||||||
|
*/
|
||||||
|
function inferMountFromPort(dir: readonly [number, number, number]): {
|
||||||
|
mount: Mount
|
||||||
|
lockYaw: number | null
|
||||||
|
} {
|
||||||
|
const v = new Vector3(dir[0], dir[1], dir[2])
|
||||||
|
if (v.lengthSq() < 1e-8) return { mount: 'floor', lockYaw: null }
|
||||||
|
v.normalize()
|
||||||
|
if (v.y > VERTICAL_DOT) return { mount: 'floor', lockYaw: null }
|
||||||
|
if (v.y < -VERTICAL_DOT) return { mount: 'ceiling', lockYaw: null }
|
||||||
|
// Wall collar dir after mount + yaw is (−sin yaw, 0, −cos yaw); set it
|
||||||
|
// opposite the port so the collar runs back into the wall stub.
|
||||||
|
return { mount: 'wall', lockYaw: Math.atan2(v.x, v.z) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a duct port is within snap range of `position` (XZ — ports hang at
|
||||||
|
* duct height, the grid hit rides the floor), mate the register onto it:
|
||||||
|
* the port's direction *picks the mount* (floor / ceiling / wall) and, for
|
||||||
|
* walls, the yaw; the whole terminal then hops so its collar lands exactly
|
||||||
|
* on the port. Null when nothing is in range. `fallbackYaw` keeps the
|
||||||
|
* user's R/T face orientation for floor / ceiling mounts.
|
||||||
|
*/
|
||||||
|
function resolvePortSnap(
|
||||||
|
position: [number, number, number],
|
||||||
|
fallbackYaw: number,
|
||||||
|
): { position: [number, number, number]; mount: Mount; yaw: number } | null {
|
||||||
|
const port = findNearestPortXZ(
|
||||||
|
position,
|
||||||
|
collectScenePorts({ systems: DUCT_PORT_SYSTEMS }),
|
||||||
|
PORT_SNAP_RADIUS_M,
|
||||||
|
)
|
||||||
|
if (!port) return null
|
||||||
|
const { mount, lockYaw } = inferMountFromPort(port.direction)
|
||||||
|
const yaw = lockYaw ?? fallbackYaw
|
||||||
|
const offset = collarOffset(mount, yaw)
|
||||||
|
return {
|
||||||
|
position: [
|
||||||
|
port.position[0] - offset.x,
|
||||||
|
port.position[1] - offset.y,
|
||||||
|
port.position[2] - offset.z,
|
||||||
|
],
|
||||||
|
mount,
|
||||||
|
yaw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-place tool for duct terminals (registers / diffusers / grilles).
|
||||||
|
*
|
||||||
|
* **Mount drives the target surface** (cycle with **M**): a floor register
|
||||||
|
* snaps to the floor grid, a ceiling diffuser snaps to a horizontal plane at
|
||||||
|
* ceiling height (derived from the level's ceilings/walls), and a wall
|
||||||
|
* register snaps flush onto whichever wall the cursor is over, its face
|
||||||
|
* oriented along the wall's outward normal. **R / T** rotate the floor/ceiling
|
||||||
|
* yaw ±45°; wall yaw is fixed by the wall it mates to.
|
||||||
|
*/
|
||||||
|
const DuctTerminalTool = () => {
|
||||||
|
const { camera, gl } = useThree()
|
||||||
|
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||||
|
const [mount, setMount] = useState<Mount>('floor')
|
||||||
|
const [placement, setPlacement] = useState<Placement | null>(null)
|
||||||
|
|
||||||
|
const mountRef = useRef<Mount>('floor')
|
||||||
|
const yawRef = useRef(0)
|
||||||
|
const raycaster = useRef(new Raycaster())
|
||||||
|
const pointer = useRef(new Vector2())
|
||||||
|
|
||||||
|
// The ghost mirrors whatever mount will actually be committed: a snap can
|
||||||
|
// override the manual M selection (port direction picks floor / ceiling /
|
||||||
|
// wall), so the preview must show the inferred mount, not the toolbar one.
|
||||||
|
const effectiveMount = placement?.mount ?? mount
|
||||||
|
const previewNode = useMemo(
|
||||||
|
() =>
|
||||||
|
DuctTerminalNode.parse({
|
||||||
|
...ductTerminalDefinition.defaults(),
|
||||||
|
name: 'Register',
|
||||||
|
mount: effectiveMount,
|
||||||
|
}),
|
||||||
|
[effectiveMount],
|
||||||
|
)
|
||||||
|
const ghost = useMemo(() => {
|
||||||
|
const group = buildDuctTerminalGeometry(previewNode)
|
||||||
|
group.traverse((child) => {
|
||||||
|
const mesh = child as { material?: { transparent: boolean; opacity: number } }
|
||||||
|
if (mesh.material) {
|
||||||
|
mesh.material.transparent = true
|
||||||
|
mesh.material.opacity = PREVIEW_OPACITY
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return group
|
||||||
|
}, [previewNode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeLevelId) return
|
||||||
|
const canvas = gl.domElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intersect the cursor ray with a level-local horizontal plane at `y`.
|
||||||
|
* The ray is transformed into level-local space first (building transform
|
||||||
|
* plus the floor's stacked elevation), so the hit is already in the frame
|
||||||
|
* terminals are stored and parented in — accurate on every floor.
|
||||||
|
*/
|
||||||
|
const hitLocalPlane = (nativeEvent: PointerEvent | MouseEvent, y: number): Vector3 | null => {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1
|
||||||
|
pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1
|
||||||
|
raycaster.current.setFromCamera(pointer.current, camera)
|
||||||
|
|
||||||
|
const level = activeLevelMesh()
|
||||||
|
const ray = raycaster.current.ray.clone()
|
||||||
|
if (level) {
|
||||||
|
const inv = new Matrix4().copy(level.matrixWorld).invert()
|
||||||
|
ray.applyMatrix4(inv)
|
||||||
|
}
|
||||||
|
const plane = new Plane(new Vector3(0, 1, 0), -y)
|
||||||
|
const hit = new Vector3()
|
||||||
|
return ray.intersectPlane(plane, hit) ? hit : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ceiling mount only lands where the cursor ray actually hits a real
|
||||||
|
* ceiling. Walk the active level's ceiling nodes, raycast each against a
|
||||||
|
* plane at its own height, and keep the lowest one whose polygon (minus
|
||||||
|
* holes) contains the hit — the surface you'd see looking up. Null when
|
||||||
|
* the ray misses every ceiling, so a ceiling register never drops onto a
|
||||||
|
* fixed virtual plane; the height comes from the ceiling itself.
|
||||||
|
*/
|
||||||
|
const resolveCeilingHit = (
|
||||||
|
nativeEvent: PointerEvent | MouseEvent,
|
||||||
|
): { hit: Vector3; height: number } | null => {
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
let best: { hit: Vector3; height: number } | null = null
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if (!node || node.type !== 'ceiling') continue
|
||||||
|
if (resolveLevelId(node, nodes) !== activeLevelId) continue
|
||||||
|
const ceiling = node as {
|
||||||
|
height?: number
|
||||||
|
polygon: Array<[number, number]>
|
||||||
|
holes?: Array<Array<[number, number]>>
|
||||||
|
}
|
||||||
|
const height = ceiling.height ?? DEFAULT_CEILING_HEIGHT
|
||||||
|
const hit = hitLocalPlane(nativeEvent, height)
|
||||||
|
if (!hit) continue
|
||||||
|
if (!pointInPolygon(hit.x, hit.z, ceiling.polygon)) continue
|
||||||
|
if (ceiling.holes?.some((h) => h.length >= 3 && pointInPolygon(hit.x, hit.z, h))) continue
|
||||||
|
if (!best || height < best.height) best = { hit, height }
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePlanar = (nativeEvent: PointerEvent | MouseEvent): Placement | null => {
|
||||||
|
// Floor sits on the grid (y=0; the slab lift is applied to the committed
|
||||||
|
// mesh by FloorElevationSystem). Ceiling resolves the real ceiling the
|
||||||
|
// ray hits and takes that surface's height — no fixed fallback plane.
|
||||||
|
let hit: Vector3 | null
|
||||||
|
let y: number
|
||||||
|
if (mountRef.current === 'ceiling') {
|
||||||
|
const ceiling = resolveCeilingHit(nativeEvent)
|
||||||
|
if (!ceiling) return null
|
||||||
|
hit = ceiling.hit
|
||||||
|
y = ceiling.height
|
||||||
|
} else {
|
||||||
|
y = 0
|
||||||
|
hit = hitLocalPlane(nativeEvent, y)
|
||||||
|
}
|
||||||
|
if (!hit) return null
|
||||||
|
const step = nativeEvent.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||||
|
// Grid-snap, then layer Figma-style alignment so a floor / ceiling
|
||||||
|
// register lines up with ducts, equipment, and items (Shift = free).
|
||||||
|
const position = alignDrawPoint([snap(hit.x, step), y, snap(hit.z, step)], {
|
||||||
|
applySnap: true,
|
||||||
|
bypass: nativeEvent.shiftKey === true,
|
||||||
|
})
|
||||||
|
// Magnetic port snap: if a duct run end / fitting collar is in range,
|
||||||
|
// the port's direction picks the mount (floor / ceiling / wall) and
|
||||||
|
// hops the whole register so its collar mates exactly onto it. Takes
|
||||||
|
// precedence over grid / alignment and the manual M mount; Shift
|
||||||
|
// bypasses.
|
||||||
|
if (!nativeEvent.shiftKey) {
|
||||||
|
const mated = resolvePortSnap(position, yawRef.current)
|
||||||
|
if (mated) {
|
||||||
|
return { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { position, yaw: yawRef.current, mount: mountRef.current }
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = (p: Placement) => {
|
||||||
|
const terminal = DuctTerminalNode.parse({
|
||||||
|
...ductTerminalDefinition.defaults(),
|
||||||
|
name: 'Register',
|
||||||
|
mount: p.mount,
|
||||||
|
position: p.position,
|
||||||
|
rotation: p.yaw,
|
||||||
|
})
|
||||||
|
useScene.getState().createNode(terminal, activeLevelId)
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [terminal.id] })
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Floor / ceiling: own raycast against a horizontal plane ----
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (mountRef.current === 'wall') return
|
||||||
|
setPlacement(resolvePlanar(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanvasClick = (e: MouseEvent) => {
|
||||||
|
if (mountRef.current === 'wall') return
|
||||||
|
if (useViewer.getState().cameraDragging) return
|
||||||
|
if ((e as PointerEvent).button !== undefined && (e as PointerEvent).button !== 0) return
|
||||||
|
const p = resolvePlanar(e)
|
||||||
|
if (p) commit(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Wall: consume wall hover/click events, orient to the wall ----
|
||||||
|
const resolveWall = (event: WallEvent): Placement | null => {
|
||||||
|
if (!event.normal) return null
|
||||||
|
// Wall faces are the ±Z faces in wall-local space; skip the thin
|
||||||
|
// top / end caps so the terminal only mounts onto a real face.
|
||||||
|
if (Math.abs(event.normal[2]) <= 0.7) return null
|
||||||
|
const worldNormal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
|
||||||
|
.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld))
|
||||||
|
.normalize()
|
||||||
|
// Face normal after the wall mount + yaw is (sin yaw, 0, cos yaw);
|
||||||
|
// align it with the wall's outward world normal.
|
||||||
|
const yaw = Math.atan2(worldNormal.x, worldNormal.z)
|
||||||
|
|
||||||
|
const world = new Vector3(event.position[0], event.position[1], event.position[2])
|
||||||
|
const level = activeLevelMesh()
|
||||||
|
const local = level ? level.worldToLocal(world.clone()) : world
|
||||||
|
return { position: [local.x, local.y, local.z], yaw, mount: 'wall' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallMove = (event: WallEvent) => {
|
||||||
|
if (mountRef.current !== 'wall') return
|
||||||
|
// Wall-mounted terminals snap flush to the wall — no plan alignment.
|
||||||
|
clearDrawAlignment()
|
||||||
|
const p = resolveWall(event)
|
||||||
|
if (p) setPlacement(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallClick = (event: WallEvent) => {
|
||||||
|
if (mountRef.current !== 'wall') return
|
||||||
|
if (useViewer.getState().cameraDragging) return
|
||||||
|
const p = resolveWall(event)
|
||||||
|
if (p) commit(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const tag = (e.target as HTMLElement | null)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||||
|
const key = e.key
|
||||||
|
if (key === 'm' || key === 'M') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const next = MOUNT_CYCLE[(MOUNT_CYCLE.indexOf(mountRef.current) + 1) % MOUNT_CYCLE.length]!
|
||||||
|
mountRef.current = next
|
||||||
|
setMount(next)
|
||||||
|
// Wall placement only resolves over a wall; clear the stale ghost.
|
||||||
|
if (next === 'wall') setPlacement(null)
|
||||||
|
triggerSFX('sfx:item-rotate')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key !== 'r' && key !== 'R' && key !== 't' && key !== 'T') return
|
||||||
|
// Wall yaw is dictated by the wall, so R/T only apply to planar mounts.
|
||||||
|
if (mountRef.current === 'wall') return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
|
||||||
|
yawRef.current += steps * ROTATE_STEP_RAD
|
||||||
|
setPlacement((prev) => (prev ? { ...prev, yaw: yawRef.current } : prev))
|
||||||
|
triggerSFX('sfx:item-rotate')
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointermove', onPointerMove)
|
||||||
|
canvas.addEventListener('click', onCanvasClick)
|
||||||
|
emitter.on('wall:move', onWallMove)
|
||||||
|
emitter.on('wall:click', onWallClick)
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('pointermove', onPointerMove)
|
||||||
|
canvas.removeEventListener('click', onCanvasClick)
|
||||||
|
emitter.off('wall:move', onWallMove)
|
||||||
|
emitter.off('wall:click', onWallClick)
|
||||||
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
clearDrawAlignment()
|
||||||
|
}
|
||||||
|
}, [activeLevelId, camera, gl])
|
||||||
|
|
||||||
|
if (!activeLevelId || !placement) return null
|
||||||
|
|
||||||
|
const mountLabel = effectiveMount.charAt(0).toUpperCase() + effectiveMount.slice(1)
|
||||||
|
|
||||||
|
// The committed mesh's slab lift is applied by FloorElevationSystem, but the
|
||||||
|
// ghost renders here directly — preview it on the slab top too so a floor
|
||||||
|
// register doesn't appear to sink in before the click.
|
||||||
|
const previewPosition =
|
||||||
|
effectiveMount === 'floor'
|
||||||
|
? getFloorStackPreviewPosition({
|
||||||
|
node: previewNode,
|
||||||
|
position: placement.position,
|
||||||
|
rotation: placement.yaw,
|
||||||
|
levelId: activeLevelId,
|
||||||
|
})
|
||||||
|
: placement.position
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LevelOffsetGroup>
|
||||||
|
{/* Same ground ring + vertical line + tool-icon badge the duct draw
|
||||||
|
tool shows in 3D (icon resolved from the active `duct-terminal`
|
||||||
|
structure-tools entry). In 2D the floorplan overlay draws this for
|
||||||
|
every tool; in 3D each tool renders its own. */}
|
||||||
|
<CursorSphere position={previewPosition} />
|
||||||
|
<group position={previewPosition} rotation={[0, placement.yaw, 0]}>
|
||||||
|
<primitive object={ghost} />
|
||||||
|
</group>
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
position={[previewPosition[0], previewPosition[1] + 0.45, previewPosition[2]]}
|
||||||
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
|
zIndexRange={[100, 0]}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||||
|
{placement.snapped && (
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-primary">Snapped to duct</span>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-foreground">Mount {mountLabel}</span>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">M surface</span>
|
||||||
|
{effectiveMount !== 'wall' && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">R/T rotate</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
</LevelOffsetGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DuctTerminalTool
|
||||||
@@ -13,6 +13,7 @@ import type { EyebrowVentNode } from './schema'
|
|||||||
* the preview doesn't intercept the cursor ray feeding the tool.
|
* the preview doesn't intercept the cursor ray feeding the tool.
|
||||||
*/
|
*/
|
||||||
const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid?: boolean }) => {
|
const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid?: boolean }) => {
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildEyebrowVentGeometry(node),
|
() => buildEyebrowVentGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
|
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) =>
|
|||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildEyebrowVentGeometry(node),
|
() => buildEyebrowVentGeometry(node),
|
||||||
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
|
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { GutterNode } from './schema'
|
|||||||
* placed gutter.
|
* placed gutter.
|
||||||
*/
|
*/
|
||||||
const GutterPreview = ({ node, invalid }: { node: GutterNode; invalid?: boolean }) => {
|
const GutterPreview = ({ node, invalid }: { node: GutterNode; invalid?: boolean }) => {
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildGutterGeometry(node),
|
() => buildGutterGeometry(node),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => {
|
|||||||
// the FULL host segment (the alignment needs wallHeight / overhang /
|
// the FULL host segment (the alignment needs wallHeight / overhang /
|
||||||
// pitch / roofType to derive each eave Y), which is a superset of what
|
// pitch / roofType to derive each eave Y), which is a superset of what
|
||||||
// the mitre detector reads — so one list feeds both.
|
// the mitre detector reads — so one list feeds both.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const { mitres, sharedEaveY } = useMemo(() => {
|
const { mitres, sharedEaveY } = useMemo(() => {
|
||||||
if (!effectiveSegment) return { mitres: NO_MITRES, sharedEaveY: undefined }
|
if (!effectiveSegment) return { mitres: NO_MITRES, sharedEaveY: undefined }
|
||||||
const segById = new Map<string, RoofSegmentNode>()
|
const segById = new Map<string, RoofSegmentNode>()
|
||||||
@@ -158,6 +159,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => {
|
|||||||
mitreNodes,
|
mitreNodes,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
||||||
const geometry = useMemo(
|
const geometry = useMemo(
|
||||||
() => buildGutterGeometry(node, mitres),
|
() => buildGutterGeometry(node, mitres),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { NodeDefinition } from '@pascal-app/core'
|
||||||
|
import { buildHvacEquipmentFloorplan } from './floorplan'
|
||||||
|
import { buildHvacEquipmentGeometry } from './geometry'
|
||||||
|
import { hvacEquipmentParametrics } from './parametrics'
|
||||||
|
import { getHvacEquipmentPorts } from './ports'
|
||||||
|
import { HvacEquipmentNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3 of the HVAC node system — equipment cabinets (furnace /
|
||||||
|
* air handler / condenser). Furnaces and air handlers expose supply +
|
||||||
|
* return ports, giving duct runs a real origin: the duct and fitting
|
||||||
|
* tools snap onto these collars like any other port.
|
||||||
|
*
|
||||||
|
* Composition: `def.geometry` only. Yaw-only rotation, so the editor's
|
||||||
|
* default R-rotate works on a selected unit without custom actions.
|
||||||
|
*/
|
||||||
|
export const hvacEquipmentDefinition: NodeDefinition<typeof HvacEquipmentNode> = {
|
||||||
|
kind: 'hvac-equipment',
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: HvacEquipmentNode,
|
||||||
|
category: 'utility',
|
||||||
|
distributionRole: 'equipment',
|
||||||
|
|
||||||
|
defaults: () => ({
|
||||||
|
object: 'node',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: 0,
|
||||||
|
equipmentType: 'furnace',
|
||||||
|
width: 0.56,
|
||||||
|
depth: 0.71,
|
||||||
|
height: 1.1,
|
||||||
|
supplyShape: 'round',
|
||||||
|
returnShape: 'round',
|
||||||
|
supplyDiameter: 8,
|
||||||
|
returnDiameter: 8,
|
||||||
|
supplyWidth: 12,
|
||||||
|
supplyHeight: 8,
|
||||||
|
returnWidth: 14,
|
||||||
|
returnHeight: 8,
|
||||||
|
}),
|
||||||
|
|
||||||
|
capabilities: {
|
||||||
|
selectable: { hitVolume: 'bbox' },
|
||||||
|
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||||
|
rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] },
|
||||||
|
duplicable: true,
|
||||||
|
deletable: true,
|
||||||
|
floorPlaced: {
|
||||||
|
footprint: (node) => {
|
||||||
|
const n = node as HvacEquipmentNode
|
||||||
|
return {
|
||||||
|
dimensions: [n.width, n.height, n.depth],
|
||||||
|
rotation: [0, n.rotation, 0],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
parametrics: hvacEquipmentParametrics,
|
||||||
|
|
||||||
|
geometry: buildHvacEquipmentGeometry,
|
||||||
|
geometryKey: (n) =>
|
||||||
|
JSON.stringify([
|
||||||
|
n.equipmentType,
|
||||||
|
n.width,
|
||||||
|
n.depth,
|
||||||
|
n.height,
|
||||||
|
n.supplyShape,
|
||||||
|
n.returnShape,
|
||||||
|
n.supplyDiameter,
|
||||||
|
n.returnDiameter,
|
||||||
|
n.supplyWidth,
|
||||||
|
n.supplyHeight,
|
||||||
|
n.returnWidth,
|
||||||
|
n.returnHeight,
|
||||||
|
]),
|
||||||
|
|
||||||
|
ports: getHvacEquipmentPorts,
|
||||||
|
|
||||||
|
floorplan: buildHvacEquipmentFloorplan,
|
||||||
|
|
||||||
|
tool: () => import('./tool'),
|
||||||
|
toolHints: [
|
||||||
|
{ key: 'Click', label: 'Place unit' },
|
||||||
|
{ key: 'R / T', label: 'Rotate ±45°' },
|
||||||
|
{ key: 'Shift', label: 'Smooth (no grid snap)' },
|
||||||
|
{ key: 'Esc', label: 'Exit' },
|
||||||
|
],
|
||||||
|
|
||||||
|
presentation: {
|
||||||
|
label: 'HVAC Unit',
|
||||||
|
description:
|
||||||
|
'Furnace, air handler, or condenser — duct runs connect to its supply/return collars.',
|
||||||
|
icon: { kind: 'url', src: '/icons/HVAC.png' },
|
||||||
|
paletteSection: 'structure',
|
||||||
|
paletteOrder: 92,
|
||||||
|
},
|
||||||
|
|
||||||
|
mcp: {
|
||||||
|
description:
|
||||||
|
'HVAC equipment cabinet (furnace, air handler, or condenser). Furnaces and air handlers have supply/return duct ports; every unit also has a refrigerant service port that a lineset run connects to. Position is level-local meters; rotation is yaw radians.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
|
||||||
|
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||||
|
import { getHvacEquipmentPorts } from './ports'
|
||||||
|
import type { HvacEquipmentNode } from './schema'
|
||||||
|
|
||||||
|
const BODY_FILL = '#c7cbd1'
|
||||||
|
const BODY_STROKE = '#6b7280'
|
||||||
|
const SUPPLY_COLOR = '#d4825a'
|
||||||
|
const RETURN_COLOR = '#5a8ad4'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor-plan footprint for HVAC equipment: the cabinet rectangle
|
||||||
|
* (rotated by yaw) with a diagonal so it reads as an equipment symbol,
|
||||||
|
* plus a supply/return collar dot per duct port. Selected → themed
|
||||||
|
* stroke + move handle.
|
||||||
|
*/
|
||||||
|
export function buildHvacEquipmentFloorplan(
|
||||||
|
node: HvacEquipmentNode,
|
||||||
|
ctx: GeometryContext,
|
||||||
|
): FloorplanGeometry | null {
|
||||||
|
const [cx, , cz] = node.position
|
||||||
|
const cos = Math.cos(node.rotation)
|
||||||
|
const sin = Math.sin(node.rotation)
|
||||||
|
const hw = node.width / 2
|
||||||
|
const hd = node.depth / 2
|
||||||
|
// Local corner → plan, applying yaw. Plan x = world x, plan y = world z;
|
||||||
|
// a +yaw about world Y maps local (x, z) to (x cos + z sin, -x sin + z cos).
|
||||||
|
const corner = (lx: number, lz: number): FloorplanPoint => [
|
||||||
|
cx + lx * cos + lz * sin,
|
||||||
|
cz - lx * sin + lz * cos,
|
||||||
|
]
|
||||||
|
const points: FloorplanPoint[] = [
|
||||||
|
corner(-hw, -hd),
|
||||||
|
corner(hw, -hd),
|
||||||
|
corner(hw, hd),
|
||||||
|
corner(-hw, hd),
|
||||||
|
]
|
||||||
|
|
||||||
|
const view = ctx.viewState
|
||||||
|
const palette = view?.palette
|
||||||
|
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
|
||||||
|
const stroke = showSelectedChrome && palette ? palette.selectedStroke : BODY_STROKE
|
||||||
|
|
||||||
|
const children: FloorplanGeometry[] = [
|
||||||
|
{
|
||||||
|
kind: 'polygon',
|
||||||
|
points,
|
||||||
|
fill: BODY_FILL,
|
||||||
|
stroke,
|
||||||
|
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
||||||
|
opacity: 0.92,
|
||||||
|
},
|
||||||
|
// Diagonal — the conventional "mechanical equipment" plan mark.
|
||||||
|
{
|
||||||
|
kind: 'line',
|
||||||
|
x1: points[0]![0],
|
||||||
|
y1: points[0]![1],
|
||||||
|
x2: points[2]![0],
|
||||||
|
y2: points[2]![1],
|
||||||
|
stroke,
|
||||||
|
strokeWidth: 1,
|
||||||
|
vectorEffect: 'non-scaling-stroke',
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const port of getHvacEquipmentPorts(node)) {
|
||||||
|
children.push({
|
||||||
|
kind: 'circle',
|
||||||
|
cx: port.position[0],
|
||||||
|
cy: port.position[2],
|
||||||
|
r: (port.diameter * INCHES_TO_METERS) / 2,
|
||||||
|
fill: port.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR,
|
||||||
|
opacity: 0.85,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSelectedChrome) {
|
||||||
|
children.push({ kind: 'move-handle', point: [cx, cz] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'group', children }
|
||||||
|
}
|
||||||
@@ -0,0 +1,862 @@
|
|||||||
|
import {
|
||||||
|
BoxGeometry,
|
||||||
|
type BufferGeometry,
|
||||||
|
CylinderGeometry,
|
||||||
|
ExtrudeGeometry,
|
||||||
|
Group,
|
||||||
|
Matrix4,
|
||||||
|
Mesh,
|
||||||
|
MeshStandardMaterial,
|
||||||
|
Path,
|
||||||
|
Shape,
|
||||||
|
TorusGeometry,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
|
import {
|
||||||
|
createOvalSectionGeometry,
|
||||||
|
INCHES_TO_METERS,
|
||||||
|
rectSectionAxes,
|
||||||
|
} from '../duct-segment/geometry'
|
||||||
|
import { localEquipmentPorts, localRefrigerantPorts } from './ports'
|
||||||
|
import type { HvacEquipmentNode } from './schema'
|
||||||
|
|
||||||
|
const RADIAL_SEGMENTS = 24
|
||||||
|
const SMALL_SEGMENTS = 16
|
||||||
|
|
||||||
|
// Shared cabinet white used by every equipment body (furnace, air handler,
|
||||||
|
// condenser) so the units read as one product family.
|
||||||
|
const EQUIPMENT_WHITE = '#eef0f2'
|
||||||
|
const EQUIPMENT_TRIM = '#cfd3d8'
|
||||||
|
|
||||||
|
const CABINET_COLOR = EQUIPMENT_WHITE
|
||||||
|
const INTERIOR_COLOR = '#9aa1a8'
|
||||||
|
const PANEL_COLOR = EQUIPMENT_TRIM
|
||||||
|
const CONTROL_COLOR = '#3f4549'
|
||||||
|
const CONDENSER_COLOR = EQUIPMENT_WHITE
|
||||||
|
const CONDENSER_FRAME_COLOR = EQUIPMENT_TRIM
|
||||||
|
const CONDENSER_FIN_COLOR = '#9aa1a8'
|
||||||
|
const FAN_COLOR = '#3f4549'
|
||||||
|
const BLOWER_COLOR = '#2f6fb0'
|
||||||
|
const BLOWER_BLADE_COLOR = '#274f7d'
|
||||||
|
const BURNER_COLOR = '#d9772e'
|
||||||
|
const GAS_PIPE_COLOR = '#d2691e'
|
||||||
|
const AIR_HANDLER_COLOR = EQUIPMENT_WHITE
|
||||||
|
const AIR_HANDLER_TRIM = EQUIPMENT_TRIM
|
||||||
|
const FAN_GRILLE_COLOR = '#3a3f44'
|
||||||
|
const FAN_BLADE_COLOR = '#d7dade'
|
||||||
|
const COIL_FIN_COLOR = '#9aa1a8'
|
||||||
|
const COPPER_COLOR = '#b06b3f'
|
||||||
|
const SERVICE_VALVE_COLOR = '#7a8086'
|
||||||
|
|
||||||
|
const UP = new Vector3(0, 1, 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure geometry builder for an HVAC equipment cabinet, in the node's
|
||||||
|
* LOCAL frame (origin at base center, +Z front, +X right) —
|
||||||
|
* `<ParametricNodeRenderer>` applies `position` + yaw.
|
||||||
|
*
|
||||||
|
* Furnace / air handler: the cabinet is built from individual sheet-metal
|
||||||
|
* walls (not a solid box) so the lower front can be left OPEN — a real
|
||||||
|
* cut that exposes the squirrel-cage circulating fan and, on a furnace,
|
||||||
|
* the orange burner manifold and gas valve. Furnaces also get the
|
||||||
|
* combustion train from the reference drawing: a draft hood + vent
|
||||||
|
* connector elbow on top and a gas pipe with drip leg down the front-left.
|
||||||
|
*
|
||||||
|
* Air handler: tall white cabinet with two stacked guarded axial fans on
|
||||||
|
* the front and finned coil bands down the sides (vertical fan-coil look).
|
||||||
|
* Condenser: squat cabinet with a fan ring and hub on top.
|
||||||
|
*/
|
||||||
|
export function buildHvacEquipmentGeometry(node: HvacEquipmentNode): Group {
|
||||||
|
const group = new Group()
|
||||||
|
if (node.equipmentType === 'condenser') return buildCondenser(node, group)
|
||||||
|
if (node.equipmentType === 'air-handler') return buildAirHandler(node, group)
|
||||||
|
|
||||||
|
const W = node.width
|
||||||
|
const H = node.height
|
||||||
|
const D = node.depth
|
||||||
|
const hw = W / 2
|
||||||
|
const hd = D / 2
|
||||||
|
const t = Math.min(0.02, W * 0.04, D * 0.04)
|
||||||
|
|
||||||
|
// Single-sided. Each wall is a thin slab whose interior-facing face is an
|
||||||
|
// outward face of its own box, so the cut still shows metal inside — and
|
||||||
|
// single-sided culling means coplanar butt joints can't z-fight.
|
||||||
|
const cabinet = new MeshStandardMaterial({
|
||||||
|
color: CABINET_COLOR,
|
||||||
|
metalness: 0.55,
|
||||||
|
roughness: 0.45,
|
||||||
|
})
|
||||||
|
const interior = new MeshStandardMaterial({
|
||||||
|
color: INTERIOR_COLOR,
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.6,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addBox = (
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
dd: number,
|
||||||
|
mat: MeshStandardMaterial,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
z: number,
|
||||||
|
name: string,
|
||||||
|
) => {
|
||||||
|
const mesh = new Mesh(new BoxGeometry(w, h, dd), mat)
|
||||||
|
mesh.name = name
|
||||||
|
mesh.position.set(x, y, z)
|
||||||
|
group.add(mesh)
|
||||||
|
return mesh
|
||||||
|
}
|
||||||
|
|
||||||
|
const ports = localEquipmentPorts(node)
|
||||||
|
const supplyPort = ports.find((p) => p.id === 'supply')
|
||||||
|
const returnPort = ports.find((p) => p.id === 'return')
|
||||||
|
|
||||||
|
// ── Cabinet shell as butt-jointed sheet-metal plates. Top + bottom span
|
||||||
|
// the full footprint; the four walls sit *between* them (height innerH),
|
||||||
|
// and back / front pieces sit *between* the side walls (width W - 2t). No
|
||||||
|
// two same-facing surfaces are ever coplanar, which is what was z-fighting
|
||||||
|
// when these were full-size overlapping boxes; single-sided materials
|
||||||
|
// (above) finish the job. Left wall carries the return hole, top the supply.
|
||||||
|
const innerH = H - 2 * t
|
||||||
|
const midY = H / 2
|
||||||
|
const frontZ = hd - t / 2
|
||||||
|
|
||||||
|
addBox(W, t, D, cabinet, 0, t / 2, 0, 'equipment-bottom')
|
||||||
|
addBox(t, innerH, D, interior, hw - t / 2, midY, 0, 'equipment-right')
|
||||||
|
addBox(W - 2 * t, innerH, t, interior, 0, midY, -hd + t / 2, 'equipment-back')
|
||||||
|
|
||||||
|
// Top plate, flat, with the supply hole at the cabinet center. Built
|
||||||
|
// centered in its own XY plane (x→W, y→D); rotate.x = -90° lays it flat.
|
||||||
|
const top = buildHolePlate(W, D, t, supplyPort, 0, 0, cabinet)
|
||||||
|
top.name = 'equipment-top'
|
||||||
|
top.rotation.x = -Math.PI / 2
|
||||||
|
top.position.set(0, H - t / 2, 0)
|
||||||
|
group.add(top)
|
||||||
|
|
||||||
|
// Left wall with the return hole. After rotate.y = -90° the plate's x→world
|
||||||
|
// -z and y→world height; centered at midY with the return port at world
|
||||||
|
// y = H*0.35, so the hole sits at plate-y (H*0.35 - midY).
|
||||||
|
const left = buildHolePlate(D, innerH, t, returnPort, 0, H * 0.35 - midY, interior)
|
||||||
|
left.name = 'equipment-left'
|
||||||
|
left.rotation.y = -Math.PI / 2
|
||||||
|
left.position.set(-hw + t / 2, midY, 0)
|
||||||
|
group.add(left)
|
||||||
|
|
||||||
|
// Front opening: framed sill, jambs and an upper control panel, all inset
|
||||||
|
// to (W - 2t) so they tuck between the side walls. The gap between sill
|
||||||
|
// and panel (and inside the jambs) is the visible cut.
|
||||||
|
const openBottom = H * 0.1
|
||||||
|
const openTop = H * 0.58
|
||||||
|
const jamb = W * 0.08
|
||||||
|
const frontW = W - 2 * t
|
||||||
|
const frontHalf = frontW / 2
|
||||||
|
const panelMat = new MeshStandardMaterial({
|
||||||
|
color: PANEL_COLOR,
|
||||||
|
metalness: 0.5,
|
||||||
|
roughness: 0.5,
|
||||||
|
})
|
||||||
|
addBox(frontW, openBottom - t, t, cabinet, 0, (t + openBottom) / 2, frontZ, 'equipment-sill')
|
||||||
|
addBox(frontW, H - t - openTop, t, panelMat, 0, (openTop + H - t) / 2, frontZ, 'equipment-panel')
|
||||||
|
addBox(
|
||||||
|
jamb,
|
||||||
|
openTop - openBottom,
|
||||||
|
t,
|
||||||
|
cabinet,
|
||||||
|
-frontHalf + jamb / 2,
|
||||||
|
(openBottom + openTop) / 2,
|
||||||
|
frontZ,
|
||||||
|
'equipment-jamb-l',
|
||||||
|
)
|
||||||
|
addBox(
|
||||||
|
jamb,
|
||||||
|
openTop - openBottom,
|
||||||
|
t,
|
||||||
|
cabinet,
|
||||||
|
frontHalf - jamb / 2,
|
||||||
|
(openBottom + openTop) / 2,
|
||||||
|
frontZ,
|
||||||
|
'equipment-jamb-r',
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Control area on the upper front panel (fan-limit switch + cover).
|
||||||
|
const ctrlMat = new MeshStandardMaterial({
|
||||||
|
color: CONTROL_COLOR,
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.6,
|
||||||
|
})
|
||||||
|
addBox(
|
||||||
|
W * 0.34,
|
||||||
|
(H - openTop) * 0.5,
|
||||||
|
0.012,
|
||||||
|
ctrlMat,
|
||||||
|
W * 0.18,
|
||||||
|
(openTop + H) / 2,
|
||||||
|
frontZ + 0.008,
|
||||||
|
'equipment-control',
|
||||||
|
)
|
||||||
|
addBox(
|
||||||
|
W * 0.1,
|
||||||
|
(H - openTop) * 0.3,
|
||||||
|
0.02,
|
||||||
|
ctrlMat,
|
||||||
|
-W * 0.22,
|
||||||
|
(openTop + H) / 2,
|
||||||
|
frontZ + 0.012,
|
||||||
|
'equipment-switch',
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Squirrel-cage circulating fan, seated in the open lower cavity. The
|
||||||
|
// round scroll housing faces front (+Z) so it shows through the cut.
|
||||||
|
const rB = Math.min(W * 0.34, (openTop - openBottom) * 0.42)
|
||||||
|
const housingD = D * 0.42
|
||||||
|
const cy = openBottom + rB + 0.01
|
||||||
|
const zc = hd - t - housingD / 2 - 0.01
|
||||||
|
const blowerMat = new MeshStandardMaterial({
|
||||||
|
color: BLOWER_COLOR,
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.6,
|
||||||
|
})
|
||||||
|
const bladeMat = new MeshStandardMaterial({
|
||||||
|
color: BLOWER_BLADE_COLOR,
|
||||||
|
metalness: 0.2,
|
||||||
|
roughness: 0.75,
|
||||||
|
})
|
||||||
|
const housing = new Mesh(new CylinderGeometry(rB, rB, housingD, RADIAL_SEGMENTS), blowerMat)
|
||||||
|
housing.name = 'blower-housing'
|
||||||
|
housing.rotation.x = Math.PI / 2 // axis Y → axis Z (round face toward front)
|
||||||
|
housing.position.set(0, cy, zc)
|
||||||
|
group.add(housing)
|
||||||
|
const intake = new Mesh(new TorusGeometry(rB * 0.7, rB * 0.12, 10, RADIAL_SEGMENTS), blowerMat)
|
||||||
|
intake.name = 'blower-intake'
|
||||||
|
intake.position.set(0, cy, hd - t - 0.005)
|
||||||
|
group.add(intake)
|
||||||
|
const hub = new Mesh(
|
||||||
|
new CylinderGeometry(rB * 0.18, rB * 0.18, housingD * 0.9, SMALL_SEGMENTS),
|
||||||
|
bladeMat,
|
||||||
|
)
|
||||||
|
hub.name = 'blower-hub'
|
||||||
|
hub.rotation.x = Math.PI / 2
|
||||||
|
hub.position.set(0, cy, zc)
|
||||||
|
group.add(hub)
|
||||||
|
// Radial cage blades around the hub axis (Z).
|
||||||
|
const BLADES = 14
|
||||||
|
for (let i = 0; i < BLADES; i++) {
|
||||||
|
const a = (i / BLADES) * Math.PI * 2
|
||||||
|
const blade = new Mesh(new BoxGeometry(0.006, rB * 0.62, housingD * 0.82), bladeMat)
|
||||||
|
blade.name = `blower-blade-${i}`
|
||||||
|
blade.position.set(Math.cos(a) * rB * 0.5, cy + Math.sin(a) * rB * 0.5, zc)
|
||||||
|
blade.rotation.z = a
|
||||||
|
group.add(blade)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildCombustionTrain(node, group, { hw, hd, H, openTop, frontZ })
|
||||||
|
buildGasLine(node, group, { hw, hd, H })
|
||||||
|
|
||||||
|
buildCollars(node, group)
|
||||||
|
buildServiceValves(node, group)
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orange burner manifold + gas valve above the blower (furnace only). */
|
||||||
|
function buildCombustionTrain(
|
||||||
|
node: HvacEquipmentNode,
|
||||||
|
group: Group,
|
||||||
|
dims: { hw: number; hd: number; H: number; openTop: number; frontZ: number },
|
||||||
|
): void {
|
||||||
|
const { hw, hd, H, openTop } = dims
|
||||||
|
const burnerMat = new MeshStandardMaterial({
|
||||||
|
color: BURNER_COLOR,
|
||||||
|
metalness: 0.35,
|
||||||
|
roughness: 0.55,
|
||||||
|
emissive: BURNER_COLOR,
|
||||||
|
emissiveIntensity: 0.12,
|
||||||
|
})
|
||||||
|
const y = openTop - 0.12
|
||||||
|
const z = hd - node.depth * 0.32
|
||||||
|
|
||||||
|
// Manifold pipe running across the unit (axis X), feeding the burners.
|
||||||
|
const manifold = new Mesh(
|
||||||
|
new CylinderGeometry(0.018, 0.018, node.width * 0.66, SMALL_SEGMENTS),
|
||||||
|
burnerMat,
|
||||||
|
)
|
||||||
|
manifold.name = 'burner-manifold'
|
||||||
|
manifold.rotation.z = Math.PI / 2
|
||||||
|
manifold.position.set(-node.width * 0.05, y, z)
|
||||||
|
group.add(manifold)
|
||||||
|
|
||||||
|
// 4 burner tubes shooting back into the heat exchanger (axis Z).
|
||||||
|
const tubes = 4
|
||||||
|
for (let i = 0; i < tubes; i++) {
|
||||||
|
const x = (-(tubes - 1) / 2 + i) * (node.width * 0.16)
|
||||||
|
const tube = new Mesh(
|
||||||
|
new CylinderGeometry(0.022, 0.022, node.depth * 0.34, SMALL_SEGMENTS),
|
||||||
|
burnerMat,
|
||||||
|
)
|
||||||
|
tube.name = `burner-tube-${i}`
|
||||||
|
tube.rotation.x = Math.PI / 2
|
||||||
|
tube.position.set(x, y, z - node.depth * 0.17)
|
||||||
|
group.add(tube)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gas valve block at the right end of the manifold.
|
||||||
|
const valve = new Mesh(new BoxGeometry(0.08, 0.07, 0.09), burnerMat)
|
||||||
|
valve.name = 'gas-valve'
|
||||||
|
valve.position.set(hw - 0.07, y, z + 0.02)
|
||||||
|
group.add(valve)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gas supply pipe with a capped drip leg, down the front-left (furnace). */
|
||||||
|
function buildGasLine(
|
||||||
|
node: HvacEquipmentNode,
|
||||||
|
group: Group,
|
||||||
|
dims: { hw: number; hd: number; H: number },
|
||||||
|
): void {
|
||||||
|
const { hw, hd, H } = dims
|
||||||
|
const gasMat = new MeshStandardMaterial({
|
||||||
|
color: GAS_PIPE_COLOR,
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.5,
|
||||||
|
})
|
||||||
|
const r = 0.014
|
||||||
|
const x = -hw + 0.06
|
||||||
|
const z = hd + 0.03
|
||||||
|
const teeY = H * 0.34
|
||||||
|
|
||||||
|
// Vertical main running down the front-left face.
|
||||||
|
const mainTop = H * 0.92
|
||||||
|
const mainLen = mainTop - teeY
|
||||||
|
const main = new Mesh(new CylinderGeometry(r, r, mainLen, SMALL_SEGMENTS), gasMat)
|
||||||
|
main.name = 'gas-main'
|
||||||
|
main.position.set(x, teeY + mainLen / 2, z)
|
||||||
|
group.add(main)
|
||||||
|
|
||||||
|
// Tee into the cabinet toward the gas valve (axis X, +).
|
||||||
|
const tee = new Mesh(new CylinderGeometry(r, r, 0.12, SMALL_SEGMENTS), gasMat)
|
||||||
|
tee.name = 'gas-tee'
|
||||||
|
tee.rotation.z = Math.PI / 2
|
||||||
|
tee.position.set(x + 0.06, teeY, z)
|
||||||
|
group.add(tee)
|
||||||
|
|
||||||
|
// Drip leg: short capped vertical pipe below the tee to catch sediment.
|
||||||
|
const legLen = H * 0.14
|
||||||
|
const leg = new Mesh(new CylinderGeometry(r, r, legLen, SMALL_SEGMENTS), gasMat)
|
||||||
|
leg.name = 'gas-drip-leg'
|
||||||
|
leg.position.set(x, teeY - legLen / 2, z)
|
||||||
|
group.add(leg)
|
||||||
|
const cap = new Mesh(new CylinderGeometry(r * 1.4, r * 1.4, 0.02, SMALL_SEGMENTS), gasMat)
|
||||||
|
cap.name = 'gas-drip-cap'
|
||||||
|
cap.position.set(x, teeY - legLen, z)
|
||||||
|
group.add(cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalPort = ReturnType<typeof localEquipmentPorts>[number]
|
||||||
|
|
||||||
|
type CollarSection = { shape: 'round' | 'rect' | 'oval'; widthM: number; heightM: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radial clearance (meters) the collar sleeve carries over the duct's
|
||||||
|
* nominal cross-section. A duct run leaves the port at the advertised size;
|
||||||
|
* the collar is built one clearance larger on every side so it reads as a
|
||||||
|
* sheet-metal sleeve wrapping the duct — and so their faces never coincide
|
||||||
|
* (no z-fighting where the run overlaps the stub). ~5 mm ≈ a real slip joint.
|
||||||
|
*/
|
||||||
|
const COLLAR_CLEARANCE_M = 0.005
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collar cross-section in meters, already grown by `COLLAR_CLEARANCE_M` so
|
||||||
|
* the sleeve sits over the duct. Round collapses to a single diameter on
|
||||||
|
* both axes; rect / oval carry the explicit width × height (width is the
|
||||||
|
* horizontal face, height the vertical). For round the port's `diameter`
|
||||||
|
* is the true round size; for rect / oval it is the area-equivalent value
|
||||||
|
* the port advertises, so the mesh uses width / height instead.
|
||||||
|
*/
|
||||||
|
function collarSection(port: LocalPort): CollarSection {
|
||||||
|
const shape = port.shape ?? 'round'
|
||||||
|
const grow = 2 * COLLAR_CLEARANCE_M
|
||||||
|
if (shape === 'round') {
|
||||||
|
const d = port.diameter * INCHES_TO_METERS + grow
|
||||||
|
return { shape, widthM: d, heightM: d }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shape,
|
||||||
|
widthM: (port.width ?? port.diameter) * INCHES_TO_METERS + grow,
|
||||||
|
heightM: (port.height ?? port.diameter) * INCHES_TO_METERS + grow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collar sleeve geometry with the run length on local Y and the
|
||||||
|
* cross-section on local X (width) × Z (height) — the basis the caller
|
||||||
|
* orients with `rectSectionAxes`. Round stays open-ended so you can see
|
||||||
|
* straight through into the hole. */
|
||||||
|
function collarGeometry(section: CollarSection, length: number): BufferGeometry {
|
||||||
|
if (section.shape === 'rect') return new BoxGeometry(section.widthM, length, section.heightM)
|
||||||
|
if (section.shape === 'oval') {
|
||||||
|
return createOvalSectionGeometry(section.widthM, section.heightM, length)
|
||||||
|
}
|
||||||
|
const r = section.widthM / 2
|
||||||
|
return new CylinderGeometry(r, r, length, RADIAL_SEGMENTS, 1, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hole `Path` in the plate's local XY (width → X, height → Y), centered at
|
||||||
|
* (`hx`, `hy`) and clamped to keep it inside the plate. Three.js corrects
|
||||||
|
* hole winding when extruding, so the path direction here is irrelevant.
|
||||||
|
*/
|
||||||
|
function collarHolePath(
|
||||||
|
section: CollarSection,
|
||||||
|
hx: number,
|
||||||
|
hy: number,
|
||||||
|
maxHalfW: number,
|
||||||
|
maxHalfH: number,
|
||||||
|
): Path | null {
|
||||||
|
if (section.shape === 'rect') {
|
||||||
|
const hw = Math.min(section.widthM / 2, maxHalfW)
|
||||||
|
const hh = Math.min(section.heightM / 2, maxHalfH)
|
||||||
|
if (hw <= 0 || hh <= 0) return null
|
||||||
|
return new Path()
|
||||||
|
.moveTo(hx - hw, hy - hh)
|
||||||
|
.lineTo(hx + hw, hy - hh)
|
||||||
|
.lineTo(hx + hw, hy + hh)
|
||||||
|
.lineTo(hx - hw, hy + hh)
|
||||||
|
.closePath()
|
||||||
|
}
|
||||||
|
if (section.shape === 'oval') {
|
||||||
|
const w = Math.min(section.widthM, maxHalfW * 2)
|
||||||
|
const h = Math.min(section.heightM, maxHalfH * 2)
|
||||||
|
const r = Math.min(w, h) / 2
|
||||||
|
const straight = Math.max(0, w - h) / 2
|
||||||
|
if (r <= 0) return null
|
||||||
|
const path = new Path()
|
||||||
|
path.absarc(hx + straight, hy, r, -Math.PI / 2, Math.PI / 2, false)
|
||||||
|
path.absarc(hx - straight, hy, r, Math.PI / 2, (3 * Math.PI) / 2, false)
|
||||||
|
path.closePath()
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
const r = Math.min(section.widthM / 2, maxHalfW, maxHalfH)
|
||||||
|
if (r <= 0) return null
|
||||||
|
const path = new Path()
|
||||||
|
path.absarc(hx, hy, r, 0, Math.PI * 2, true)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat rectangular plate of `thickness`, centered on the origin in its own
|
||||||
|
* XY plane (width → X, height → Y) and centered through the thickness on Z,
|
||||||
|
* with the duct opening for `port` punched at (`hx`, `hy`). Callers rotate /
|
||||||
|
* position it into a wall; the hole takes the collar's round / rect / oval
|
||||||
|
* cross-section.
|
||||||
|
*/
|
||||||
|
function buildHolePlate(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
thickness: number,
|
||||||
|
port: LocalPort | undefined,
|
||||||
|
hx: number,
|
||||||
|
hy: number,
|
||||||
|
material: MeshStandardMaterial,
|
||||||
|
): Mesh {
|
||||||
|
const hw = width / 2
|
||||||
|
const hh = height / 2
|
||||||
|
const shape = new Shape()
|
||||||
|
.moveTo(-hw, -hh)
|
||||||
|
.lineTo(hw, -hh)
|
||||||
|
.lineTo(hw, hh)
|
||||||
|
.lineTo(-hw, hh)
|
||||||
|
.lineTo(-hw, -hh)
|
||||||
|
|
||||||
|
const hole = port ? collarHolePath(collarSection(port), hx, hy, hw * 0.95, hh * 0.95) : null
|
||||||
|
if (hole) shape.holes.push(hole)
|
||||||
|
|
||||||
|
const geom = new ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false })
|
||||||
|
geom.translate(0, 0, -thickness / 2)
|
||||||
|
geom.computeVertexNormals()
|
||||||
|
return new Mesh(geom, material)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheet-metal sleeves at the supply/return ports. Each collar straddles the
|
||||||
|
* wall hole — part inside the cabinet, part outside — so a duct run slides
|
||||||
|
* through the opening instead of dead-ending on a panel. The collar takes
|
||||||
|
* the port's round / rect / oval cross-section, oriented with the same
|
||||||
|
* width-horizontal / height-vertical basis as the hole it sits in.
|
||||||
|
*/
|
||||||
|
function buildCollars(node: HvacEquipmentNode, group: Group): void {
|
||||||
|
const collarMaterial = new MeshStandardMaterial({
|
||||||
|
color: '#c2c2c2',
|
||||||
|
metalness: 0.6,
|
||||||
|
roughness: 0.4,
|
||||||
|
side: 2,
|
||||||
|
})
|
||||||
|
const OUT = 0.12 // sleeve length outside the cabinet
|
||||||
|
const IN = 0.05 // sleeve length reaching inside past the hole
|
||||||
|
const length = OUT + IN
|
||||||
|
for (const port of localEquipmentPorts(node)) {
|
||||||
|
const dir = port.direction.clone().normalize()
|
||||||
|
const sleeve = new Mesh(collarGeometry(collarSection(port), length), collarMaterial)
|
||||||
|
sleeve.name = `equipment-collar-${port.id}`
|
||||||
|
const { width: wAxis, height: hAxis } = rectSectionAxes(dir)
|
||||||
|
sleeve.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(wAxis, dir, hAxis))
|
||||||
|
sleeve.position.copy(port.position).addScaledVector(dir, (OUT - IN) / 2)
|
||||||
|
group.add(sleeve)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default lineset line radii (meters) — must mirror the lineset kind's
|
||||||
|
// defaults so the two service stubs sit exactly where its suction/liquid
|
||||||
|
// pipes run. See `lineset/geometry.ts` (suction 7/8", liquid 3/8", 3/8"
|
||||||
|
// foam jacket) and its symmetric ±offset about the path centerline.
|
||||||
|
const LINESET_SUCTION_R = (0.875 * INCHES_TO_METERS) / 2
|
||||||
|
const LINESET_LIQUID_R = (0.375 * INCHES_TO_METERS) / 2
|
||||||
|
const LINESET_JACKET_R = LINESET_SUCTION_R + 0.01
|
||||||
|
const LINESET_PAIR_OFFSET = LINESET_JACKET_R + LINESET_LIQUID_R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refrigerant service valves at the lineset port — a brass-grey valve body
|
||||||
|
* with two copper stubs the lineset run mates onto. Built on every
|
||||||
|
* equipment type so a split system can be piped from condenser to coil.
|
||||||
|
*
|
||||||
|
* A lineset is a parallel pair (insulated suction + bare liquid) offset
|
||||||
|
* symmetrically about its path centerline. The snap point is that
|
||||||
|
* centerline, so a single stub would sit in the empty gap between the two
|
||||||
|
* pipes. Instead we emit two stubs at exactly the lineset's ±offset along
|
||||||
|
* the port's horizontal perpendicular: the suction pipe lands on the wide
|
||||||
|
* stub, the liquid pipe on the narrow one, when the run leaves the face.
|
||||||
|
*/
|
||||||
|
function buildServiceValves(node: HvacEquipmentNode, group: Group): void {
|
||||||
|
const valveMat = new MeshStandardMaterial({
|
||||||
|
color: SERVICE_VALVE_COLOR,
|
||||||
|
metalness: 0.7,
|
||||||
|
roughness: 0.35,
|
||||||
|
})
|
||||||
|
const copperMat = new MeshStandardMaterial({
|
||||||
|
color: COPPER_COLOR,
|
||||||
|
metalness: 0.8,
|
||||||
|
roughness: 0.3,
|
||||||
|
})
|
||||||
|
for (const port of localRefrigerantPorts(node)) {
|
||||||
|
const dir = port.direction.clone().normalize()
|
||||||
|
// Horizontal perpendicular to the port — matches the lineset geometry's
|
||||||
|
// `horizontal.cross(UP)`, so the stub offsets track its pipe offsets.
|
||||||
|
const perp = dir.clone().cross(UP).normalize()
|
||||||
|
|
||||||
|
// Brass-grey valve body bolted to the cabinet face, spanning the pair.
|
||||||
|
const bodyWidth = 2 * LINESET_PAIR_OFFSET + 2 * LINESET_JACKET_R
|
||||||
|
const body = new Mesh(new BoxGeometry(0.05, 0.08, bodyWidth), valveMat)
|
||||||
|
body.name = 'service-valve-body'
|
||||||
|
body.position.copy(port.position).addScaledVector(dir, 0.025)
|
||||||
|
body.quaternion.setFromUnitVectors(UP, dir)
|
||||||
|
group.add(body)
|
||||||
|
|
||||||
|
const stubLen = 0.07
|
||||||
|
const addStub = (sign: number, radius: number, id: string) => {
|
||||||
|
const stub = new Mesh(
|
||||||
|
new CylinderGeometry(radius, radius, stubLen, SMALL_SEGMENTS),
|
||||||
|
copperMat,
|
||||||
|
)
|
||||||
|
stub.name = `service-valve-stub-${id}`
|
||||||
|
stub.position
|
||||||
|
.copy(port.position)
|
||||||
|
.addScaledVector(perp, sign * LINESET_PAIR_OFFSET)
|
||||||
|
.addScaledVector(dir, 0.05 + stubLen / 2)
|
||||||
|
stub.quaternion.setFromUnitVectors(UP, dir)
|
||||||
|
group.add(stub)
|
||||||
|
}
|
||||||
|
// Suction pipe is the lineset's -offset line; liquid is +offset.
|
||||||
|
addStub(-1, LINESET_SUCTION_R, 'suction')
|
||||||
|
addStub(1, LINESET_LIQUID_R, 'liquid')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Residential split-system condenser, matching the reference photos: a
|
||||||
|
* greenish-grey body wrapped in vertical louvered coil fins on all four
|
||||||
|
* sides, a dark base and dark top frame, and a top-mounted fan with a
|
||||||
|
* radial wire guard (concentric rings + spokes) over a recessed throat.
|
||||||
|
*/
|
||||||
|
function buildCondenser(node: HvacEquipmentNode, group: Group): Group {
|
||||||
|
const W = node.width
|
||||||
|
const H = node.height
|
||||||
|
const D = node.depth
|
||||||
|
const hw = W / 2
|
||||||
|
const hd = D / 2
|
||||||
|
|
||||||
|
const bodyMat = new MeshStandardMaterial({
|
||||||
|
color: CONDENSER_COLOR,
|
||||||
|
metalness: 0.5,
|
||||||
|
roughness: 0.5,
|
||||||
|
})
|
||||||
|
const frameMat = new MeshStandardMaterial({
|
||||||
|
color: CONDENSER_FRAME_COLOR,
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.6,
|
||||||
|
})
|
||||||
|
const finMat = new MeshStandardMaterial({
|
||||||
|
color: CONDENSER_FIN_COLOR,
|
||||||
|
metalness: 0.65,
|
||||||
|
roughness: 0.4,
|
||||||
|
})
|
||||||
|
|
||||||
|
const frameH = Math.min(0.07, H * 0.09)
|
||||||
|
const post = Math.min(0.04, W * 0.07)
|
||||||
|
|
||||||
|
// Inner body the fins wrap around (inset so corner posts read proud).
|
||||||
|
const body = new Mesh(new BoxGeometry(W - post, H - 2 * frameH, D - post), bodyMat)
|
||||||
|
body.name = 'equipment-body'
|
||||||
|
body.position.set(0, H / 2, 0)
|
||||||
|
group.add(body)
|
||||||
|
|
||||||
|
// Dark base + top frame rings.
|
||||||
|
const base = new Mesh(new BoxGeometry(W, frameH, D), frameMat)
|
||||||
|
base.name = 'condenser-base'
|
||||||
|
base.position.set(0, frameH / 2, 0)
|
||||||
|
group.add(base)
|
||||||
|
const topFrame = new Mesh(new BoxGeometry(W, frameH, D), frameMat)
|
||||||
|
topFrame.name = 'condenser-top-frame'
|
||||||
|
topFrame.position.set(0, H - frameH / 2, 0)
|
||||||
|
group.add(topFrame)
|
||||||
|
|
||||||
|
// Corner posts.
|
||||||
|
for (const sx of [-1, 1]) {
|
||||||
|
for (const sz of [-1, 1]) {
|
||||||
|
const p = new Mesh(new BoxGeometry(post, H, post), frameMat)
|
||||||
|
p.name = `condenser-post-${sx > 0 ? 'r' : 'l'}${sz > 0 ? 'f' : 'b'}`
|
||||||
|
p.position.set(sx * (hw - post / 2), H / 2, sz * (hd - post / 2))
|
||||||
|
group.add(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical louvered coil fins on all four faces. Each fin is a thin
|
||||||
|
// vertical slat standing slightly proud of the body; the gaps between
|
||||||
|
// them read as the coil louvers.
|
||||||
|
const finY = H / 2
|
||||||
|
const finH = H - 2 * frameH
|
||||||
|
const addFins = (count: number, span: number, fixed: number, axis: 'x' | 'z', sign: number) => {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const t = (i + 0.5) / count
|
||||||
|
const c = -span / 2 + t * span
|
||||||
|
const fin =
|
||||||
|
axis === 'x'
|
||||||
|
? new Mesh(new BoxGeometry(0.006, finH, 0.018), finMat)
|
||||||
|
: new Mesh(new BoxGeometry(0.018, finH, 0.006), finMat)
|
||||||
|
fin.name = `condenser-fin-${axis}${sign > 0 ? '+' : '-'}-${i}`
|
||||||
|
if (axis === 'x') fin.position.set(c, finY, sign * fixed)
|
||||||
|
else fin.position.set(sign * fixed, finY, c)
|
||||||
|
group.add(fin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const finsAlongW = Math.max(10, Math.round(W / 0.025))
|
||||||
|
const finsAlongD = Math.max(10, Math.round(D / 0.025))
|
||||||
|
addFins(finsAlongW, W - post, hd - post / 2 + 0.004, 'x', 1) // front
|
||||||
|
addFins(finsAlongW, W - post, hd - post / 2 + 0.004, 'x', -1) // back
|
||||||
|
addFins(finsAlongD, D - post, hw - post / 2 + 0.004, 'z', 1) // right
|
||||||
|
addFins(finsAlongD, D - post, hw - post / 2 + 0.004, 'z', -1) // left
|
||||||
|
|
||||||
|
buildCondenserFanGuard(group, W, H, D)
|
||||||
|
buildServiceValves(node, group)
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top fan: recessed throat + hub/blades under a radial wire guard. */
|
||||||
|
function buildCondenserFanGuard(group: Group, W: number, H: number, D: number): void {
|
||||||
|
const fanMat = new MeshStandardMaterial({
|
||||||
|
color: FAN_COLOR,
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.7,
|
||||||
|
})
|
||||||
|
const guardMat = new MeshStandardMaterial({
|
||||||
|
color: CONDENSER_FRAME_COLOR,
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.6,
|
||||||
|
})
|
||||||
|
const r = Math.min(W, D) * 0.4
|
||||||
|
const deckY = H
|
||||||
|
|
||||||
|
// Recessed throat dropping below the top deck so the fan reads as an
|
||||||
|
// opening, not a disc sitting on the lid.
|
||||||
|
const throat = new Mesh(new CylinderGeometry(r, r, H * 0.12, RADIAL_SEGMENTS, 1, true), fanMat)
|
||||||
|
throat.name = 'condenser-fan-throat'
|
||||||
|
throat.position.set(0, deckY - H * 0.06, 0)
|
||||||
|
group.add(throat)
|
||||||
|
|
||||||
|
// Hub + swept blades just below the deck.
|
||||||
|
const bladeMat = new MeshStandardMaterial({
|
||||||
|
color: '#5a6066',
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.6,
|
||||||
|
})
|
||||||
|
const hub = new Mesh(new CylinderGeometry(r * 0.16, r * 0.16, 0.04, SMALL_SEGMENTS), bladeMat)
|
||||||
|
hub.name = 'condenser-fan-hub'
|
||||||
|
hub.position.set(0, deckY - 0.02, 0)
|
||||||
|
group.add(hub)
|
||||||
|
const BLADES = 6
|
||||||
|
for (let i = 0; i < BLADES; i++) {
|
||||||
|
const a = (i / BLADES) * Math.PI * 2
|
||||||
|
const blade = new Mesh(new BoxGeometry(r * 0.7, 0.006, r * 0.28), bladeMat)
|
||||||
|
blade.name = `condenser-fan-blade-${i}`
|
||||||
|
blade.position.set(Math.cos(a) * r * 0.45, deckY - 0.02, Math.sin(a) * r * 0.45)
|
||||||
|
blade.rotation.y = a
|
||||||
|
blade.rotation.x = 0.35
|
||||||
|
group.add(blade)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radial wire guard: concentric rings + spokes, slightly domed above deck.
|
||||||
|
const guardY = deckY + 0.012
|
||||||
|
for (let k = 1; k <= 5; k++) {
|
||||||
|
const rr = (r * k) / 5
|
||||||
|
const ring = new Mesh(new TorusGeometry(rr, 0.004, 6, RADIAL_SEGMENTS), guardMat)
|
||||||
|
ring.name = `condenser-guard-ring-${k}`
|
||||||
|
ring.rotation.x = Math.PI / 2
|
||||||
|
ring.position.set(0, guardY, 0)
|
||||||
|
group.add(ring)
|
||||||
|
}
|
||||||
|
const SPOKES = 8
|
||||||
|
for (let i = 0; i < SPOKES; i++) {
|
||||||
|
const a = (i / SPOKES) * Math.PI
|
||||||
|
const spoke = new Mesh(new BoxGeometry(r * 2, 0.004, 0.004), guardMat)
|
||||||
|
spoke.name = `condenser-guard-spoke-${i}`
|
||||||
|
spoke.position.set(0, guardY, 0)
|
||||||
|
spoke.rotation.y = a
|
||||||
|
group.add(spoke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarded axial fan on the front (+Z) face: a recessed dark throat, a
|
||||||
|
* spider hub with swept blades, and a concentric wire grille — the look of
|
||||||
|
* the units in the air-handler reference. Centered at (`x`, `y`) on the
|
||||||
|
* cabinet front at `frontZ`, radius `r`.
|
||||||
|
*/
|
||||||
|
function buildAxialFan(
|
||||||
|
group: Group,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
frontZ: number,
|
||||||
|
r: number,
|
||||||
|
index: number,
|
||||||
|
): void {
|
||||||
|
const grilleMat = new MeshStandardMaterial({
|
||||||
|
color: FAN_GRILLE_COLOR,
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.6,
|
||||||
|
})
|
||||||
|
const bladeMat = new MeshStandardMaterial({
|
||||||
|
color: FAN_BLADE_COLOR,
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recessed throat behind the blades so the fan reads as an opening.
|
||||||
|
const throat = new Mesh(new CylinderGeometry(r, r, 0.04, RADIAL_SEGMENTS), grilleMat)
|
||||||
|
throat.name = `fan-${index}-throat`
|
||||||
|
throat.rotation.x = Math.PI / 2
|
||||||
|
throat.position.set(x, y, frontZ - 0.02)
|
||||||
|
group.add(throat)
|
||||||
|
|
||||||
|
// Hub + swept blades, sitting just proud of the throat.
|
||||||
|
const hub = new Mesh(new CylinderGeometry(r * 0.18, r * 0.18, 0.03, SMALL_SEGMENTS), bladeMat)
|
||||||
|
hub.name = `fan-${index}-hub`
|
||||||
|
hub.rotation.x = Math.PI / 2
|
||||||
|
hub.position.set(x, y, frontZ + 0.005)
|
||||||
|
group.add(hub)
|
||||||
|
|
||||||
|
const BLADES = 5
|
||||||
|
for (let i = 0; i < BLADES; i++) {
|
||||||
|
const a = (i / BLADES) * Math.PI * 2
|
||||||
|
const blade = new Mesh(new BoxGeometry(r * 0.34, 0.006, r * 0.78), bladeMat)
|
||||||
|
blade.name = `fan-${index}-blade-${i}`
|
||||||
|
// Position blade outward from hub, then tilt for an airfoil sweep.
|
||||||
|
const br = r * 0.5
|
||||||
|
blade.position.set(x + Math.cos(a) * br, y + Math.sin(a) * br, frontZ + 0.005)
|
||||||
|
blade.rotation.z = a
|
||||||
|
blade.rotation.y = 0.5
|
||||||
|
group.add(blade)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concentric wire grille (rings) over the front of the fan.
|
||||||
|
const ringMat = new MeshStandardMaterial({
|
||||||
|
color: AIR_HANDLER_TRIM,
|
||||||
|
metalness: 0.5,
|
||||||
|
roughness: 0.4,
|
||||||
|
})
|
||||||
|
for (let k = 1; k <= 3; k++) {
|
||||||
|
const rr = (r * k) / 3
|
||||||
|
const ring = new Mesh(new TorusGeometry(rr, 0.004, 6, RADIAL_SEGMENTS), ringMat)
|
||||||
|
ring.name = `fan-${index}-grille-${k}`
|
||||||
|
ring.position.set(x, y, frontZ + 0.02)
|
||||||
|
group.add(ring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Air handler / vertical fan-coil: a tall white cabinet with two stacked
|
||||||
|
* guarded axial fans on the front and finned coil bands down both sides —
|
||||||
|
* the unit in the reference photo. Keeps the supply/return collars (built
|
||||||
|
* by the shared `buildCollars`) so duct runs still connect.
|
||||||
|
*/
|
||||||
|
function buildAirHandler(node: HvacEquipmentNode, group: Group): Group {
|
||||||
|
const W = node.width
|
||||||
|
const H = node.height
|
||||||
|
const D = node.depth
|
||||||
|
const hw = W / 2
|
||||||
|
const hd = D / 2
|
||||||
|
|
||||||
|
const cabinetMat = new MeshStandardMaterial({
|
||||||
|
color: AIR_HANDLER_COLOR,
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.55,
|
||||||
|
})
|
||||||
|
const trimMat = new MeshStandardMaterial({
|
||||||
|
color: AIR_HANDLER_TRIM,
|
||||||
|
metalness: 0.4,
|
||||||
|
roughness: 0.5,
|
||||||
|
})
|
||||||
|
const finMat = new MeshStandardMaterial({
|
||||||
|
color: COIL_FIN_COLOR,
|
||||||
|
metalness: 0.6,
|
||||||
|
roughness: 0.45,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cabinet body + top/bottom trim caps.
|
||||||
|
const body = new Mesh(new BoxGeometry(W, H, D), cabinetMat)
|
||||||
|
body.name = 'equipment-body'
|
||||||
|
body.position.set(0, H / 2, 0)
|
||||||
|
group.add(body)
|
||||||
|
// Trim caps straddle the cabinet's top / bottom edges (centered on
|
||||||
|
// y = H and y = 0) so the body's end faces fall inside the cap volume.
|
||||||
|
// Sitting them flush instead (top face at y = H) leaves two coplanar
|
||||||
|
// full-footprint faces that z-fight.
|
||||||
|
const capH = Math.min(0.05, H * 0.06)
|
||||||
|
const topCap = new Mesh(new BoxGeometry(W * 1.04, capH, D * 1.04), trimMat)
|
||||||
|
topCap.name = 'air-handler-top-cap'
|
||||||
|
topCap.position.set(0, H, 0)
|
||||||
|
group.add(topCap)
|
||||||
|
const botCap = new Mesh(new BoxGeometry(W * 1.04, capH, D * 1.04), trimMat)
|
||||||
|
botCap.name = 'air-handler-bottom-cap'
|
||||||
|
botCap.position.set(0, 0, 0)
|
||||||
|
group.add(botCap)
|
||||||
|
|
||||||
|
// Two stacked axial fans on the front face, sized to the cabinet width.
|
||||||
|
const frontZ = hd + 0.001
|
||||||
|
const fanR = Math.min(W * 0.4, H * 0.22)
|
||||||
|
const margin = capH + fanR + H * 0.04
|
||||||
|
buildAxialFan(group, 0, H - margin, frontZ, fanR, 0)
|
||||||
|
buildAxialFan(group, 0, margin, frontZ, fanR, 1)
|
||||||
|
|
||||||
|
// Finned coil bands down both sides (horizontal slats = condenser fins).
|
||||||
|
const fins = Math.max(6, Math.floor(H / 0.06))
|
||||||
|
for (let side = -1; side <= 1; side += 2) {
|
||||||
|
for (let i = 0; i < fins; i++) {
|
||||||
|
const fy = capH + ((i + 0.5) / fins) * (H - 2 * capH)
|
||||||
|
const fin = new Mesh(new BoxGeometry(0.004, 0.012, D * 0.82), finMat)
|
||||||
|
fin.name = `coil-fin-${side > 0 ? 'r' : 'l'}-${i}`
|
||||||
|
fin.position.set(side * (hw + 0.002), fy, 0)
|
||||||
|
group.add(fin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildCollars(node, group)
|
||||||
|
buildServiceValves(node, group)
|
||||||
|
return group
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { hvacEquipmentDefinition } from './definition'
|
||||||
|
export { buildHvacEquipmentGeometry } from './geometry'
|
||||||
|
export { getHvacEquipmentPorts } from './ports'
|
||||||
|
export { HvacEquipmentNode } from './schema'
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||||
|
import type { HvacEquipmentNode } from './schema'
|
||||||
|
|
||||||
|
export const hvacEquipmentParametrics: ParametricDescriptor<HvacEquipmentNode> = {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
label: 'Equipment',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'equipmentType',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['furnace', 'air-handler', 'condenser'],
|
||||||
|
display: 'segmented',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cabinet',
|
||||||
|
fields: [
|
||||||
|
{ key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 },
|
||||||
|
{ key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 },
|
||||||
|
{ key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 2.5, step: 0.05 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Supply',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'supplyShape',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['round', 'rect', 'oval'],
|
||||||
|
display: 'segmented',
|
||||||
|
visibleIf: (n) => n.equipmentType !== 'condenser',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'supplyDiameter',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 6,
|
||||||
|
max: 30,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.equipmentType !== 'condenser' && n.supplyShape === 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'supplyWidth',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 6,
|
||||||
|
max: 30,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.equipmentType !== 'condenser' && n.supplyShape !== 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'supplyHeight',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 6,
|
||||||
|
max: 30,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.equipmentType !== 'condenser' && n.supplyShape !== 'round',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Return',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'returnShape',
|
||||||
|
kind: 'enum',
|
||||||
|
options: ['round', 'rect', 'oval'],
|
||||||
|
display: 'segmented',
|
||||||
|
visibleIf: (n) => n.equipmentType !== 'condenser',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'returnDiameter',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 6,
|
||||||
|
max: 30,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.equipmentType !== 'condenser' && n.returnShape === 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'returnWidth',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 6,
|
||||||
|
max: 30,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.equipmentType !== 'condenser' && n.returnShape !== 'round',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'returnHeight',
|
||||||
|
kind: 'number',
|
||||||
|
unit: 'in',
|
||||||
|
min: 6,
|
||||||
|
max: 30,
|
||||||
|
step: 1,
|
||||||
|
visibleIf: (n) => n.equipmentType !== 'condenser' && n.returnShape !== 'round',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { NodePort } from '@pascal-app/core'
|
||||||
|
import { Vector3 } from 'three'
|
||||||
|
import { equivalentDiameterIn, ovalEquivalentDiameterIn } from '../duct-segment/geometry'
|
||||||
|
import type { HvacEquipmentNode } from './schema'
|
||||||
|
|
||||||
|
type CollarShape = 'round' | 'rect' | 'oval'
|
||||||
|
|
||||||
|
type LocalPort = {
|
||||||
|
id: string
|
||||||
|
position: Vector3
|
||||||
|
direction: Vector3
|
||||||
|
diameter: number
|
||||||
|
system: 'supply' | 'return' | 'refrigerant'
|
||||||
|
// Duct collars only — the cross-section the collar mesh and wall hole
|
||||||
|
// take. `diameter` above is the area-equivalent round size the port
|
||||||
|
// advertises so round runs mate at a sensible size. Refrigerant ports
|
||||||
|
// are always round and omit these.
|
||||||
|
shape?: CollarShape
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Area-equivalent round diameter (inches) a shaped collar advertises. */
|
||||||
|
function collarDiameterIn(shape: CollarShape, diameter: number, width: number, height: number) {
|
||||||
|
if (shape === 'rect') return equivalentDiameterIn(width, height)
|
||||||
|
if (shape === 'oval') return ovalEquivalentDiameterIn(width, height)
|
||||||
|
return diameter
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nominal suction-line OD (inches) the refrigerant service connection
|
||||||
|
* advertises — matches the lineset kind's default suction diameter so a
|
||||||
|
* lineset run mates cleanly onto the valve. */
|
||||||
|
const REFRIGERANT_PORT_DIAMETER_IN = 0.875
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duct ports in the cabinet's LOCAL frame (origin at the base center,
|
||||||
|
* before yaw / position). Matches a typical upflow furnace / vertical air
|
||||||
|
* handler: supply plenum collar on top, return drop on the -X side near
|
||||||
|
* the bottom third. Condensers carry no duct ports — their connection is
|
||||||
|
* the refrigerant lineset (see `localRefrigerantPorts`).
|
||||||
|
*/
|
||||||
|
export function localEquipmentPorts(node: HvacEquipmentNode): LocalPort[] {
|
||||||
|
if (node.equipmentType === 'condenser') return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'supply',
|
||||||
|
position: new Vector3(0, node.height, 0),
|
||||||
|
direction: new Vector3(0, 1, 0),
|
||||||
|
diameter: collarDiameterIn(
|
||||||
|
node.supplyShape,
|
||||||
|
node.supplyDiameter,
|
||||||
|
node.supplyWidth,
|
||||||
|
node.supplyHeight,
|
||||||
|
),
|
||||||
|
system: 'supply',
|
||||||
|
shape: node.supplyShape,
|
||||||
|
width: node.supplyWidth,
|
||||||
|
height: node.supplyHeight,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'return',
|
||||||
|
position: new Vector3(-node.width / 2, node.height * 0.35, 0),
|
||||||
|
direction: new Vector3(-1, 0, 0),
|
||||||
|
diameter: collarDiameterIn(
|
||||||
|
node.returnShape,
|
||||||
|
node.returnDiameter,
|
||||||
|
node.returnWidth,
|
||||||
|
node.returnHeight,
|
||||||
|
),
|
||||||
|
system: 'return',
|
||||||
|
shape: node.returnShape,
|
||||||
|
width: node.returnWidth,
|
||||||
|
height: node.returnHeight,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refrigerant service connection in the cabinet's LOCAL frame — the point
|
||||||
|
* a lineset run leaves from (condenser) or arrives at (indoor coil on a
|
||||||
|
* furnace / air handler). Every equipment type exposes exactly one, on the
|
||||||
|
* +X service-valve face: a condenser/air-handler near the bottom third, a
|
||||||
|
* furnace near the top where the cased A-coil sits above the heat
|
||||||
|
* exchanger.
|
||||||
|
*/
|
||||||
|
export function localRefrigerantPorts(node: HvacEquipmentNode): LocalPort[] {
|
||||||
|
const y = node.equipmentType === 'furnace' ? node.height * 0.8 : node.height * 0.3
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'lineset',
|
||||||
|
position: new Vector3(node.width / 2, y, 0),
|
||||||
|
direction: new Vector3(1, 0, 0),
|
||||||
|
diameter: REFRIGERANT_PORT_DIAMETER_IN,
|
||||||
|
system: 'refrigerant',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `def.ports` — duct + refrigerant ports transformed into level-local
|
||||||
|
* space (yaw + position). */
|
||||||
|
export function getHvacEquipmentPorts(node: HvacEquipmentNode): NodePort[] {
|
||||||
|
const offset = new Vector3(node.position[0], node.position[1], node.position[2])
|
||||||
|
const local = [...localEquipmentPorts(node), ...localRefrigerantPorts(node)]
|
||||||
|
return local.map((port) => {
|
||||||
|
const position = port.position.clone().applyAxisAngle(new Vector3(0, 1, 0), node.rotation)
|
||||||
|
position.add(offset)
|
||||||
|
const direction = port.direction
|
||||||
|
.clone()
|
||||||
|
.applyAxisAngle(new Vector3(0, 1, 0), node.rotation)
|
||||||
|
.normalize()
|
||||||
|
return {
|
||||||
|
id: port.id,
|
||||||
|
position: [position.x, position.y, position.z] as const,
|
||||||
|
direction: [direction.x, direction.y, direction.z] as const,
|
||||||
|
diameter: port.diameter,
|
||||||
|
system: port.system,
|
||||||
|
shape: port.shape,
|
||||||
|
width: port.width,
|
||||||
|
height: port.height,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { HvacEquipmentNode } from '@pascal-app/core'
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user