Merge remote-tracking branch 'origin/main' into feat/paint-slots
# Conflicts: # packages/core/src/store/use-scene.ts # packages/editor/src/components/editor/index.tsx
This commit is contained in:
@@ -11,13 +11,22 @@ import type {
|
||||
DoorNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
DuctTerminalNode,
|
||||
ElevatorNode,
|
||||
EyebrowVentNode,
|
||||
FenceNode,
|
||||
GuideNode,
|
||||
GutterNode,
|
||||
HvacEquipmentNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
LinesetNode,
|
||||
LiquidLineNode,
|
||||
PipeFittingNode,
|
||||
PipeSegmentNode,
|
||||
PipeTrapNode,
|
||||
RidgeVentNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
@@ -107,6 +116,15 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
||||
export type SkylightEvent = NodeEvent<SkylightNode>
|
||||
export type DormerEvent = NodeEvent<DormerNode>
|
||||
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
|
||||
export const eventSuffixes = [
|
||||
@@ -261,6 +279,15 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'skylight', SkylightEvent> &
|
||||
NodeEvents<'dormer', DormerEvent> &
|
||||
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 &
|
||||
ToolEvents &
|
||||
GuideEvents &
|
||||
|
||||
@@ -93,6 +93,14 @@ export {
|
||||
type Space,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
export {
|
||||
closestOnSegment,
|
||||
collectLevelWallSegments,
|
||||
nearestWallSegment,
|
||||
WALL_SNAP_DISTANCE_M,
|
||||
type WallSegment,
|
||||
type WallSegmentClosest,
|
||||
} from './lib/wall-distance'
|
||||
export {
|
||||
getCatalogMaterialById,
|
||||
getLibraryMaterialIdFromRef,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { WallNode } from '../schema/nodes/wall'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { isCurvedWall } from '../systems/wall/wall-curve'
|
||||
|
||||
/**
|
||||
* Pure plan-space wall-distance math shared by the 2D opening snap
|
||||
* (`findClosestWallInPlan` in @pascal-app/nodes) and the editor's 2D
|
||||
* Voronoi debug overlay. One source of truth means the overlay is a
|
||||
* faithful picture of what the snap actually decides.
|
||||
*
|
||||
* A wall segment's Voronoi cell is exactly "the points whose nearest wall
|
||||
* is this segment", so nearest-segment classification == the segment
|
||||
* Voronoi diagram. Curved walls are excluded (the opening snap rejects
|
||||
* them — mitering + arc + opening tears in 3D).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Max cursor-to-wall plan distance (metres) for a 2D opening to snap onto a
|
||||
* wall. Tight, because plan walls are thin and often close together — a large
|
||||
* radius would let a far wall's region reach across a nearer one. Shared so the
|
||||
* snap and the Voronoi debug overlay clip to the exact same range.
|
||||
*/
|
||||
export const WALL_SNAP_DISTANCE_M = 0.4
|
||||
|
||||
export type WallSegment = {
|
||||
wall: WallNode
|
||||
/** [x, z] plan start. */
|
||||
start: readonly [number, number]
|
||||
/** [x, z] plan end. */
|
||||
end: readonly [number, number]
|
||||
/** Unit direction (start → end) in plan. */
|
||||
dirX: number
|
||||
dirY: number
|
||||
/** Segment length in metres. */
|
||||
length: number
|
||||
}
|
||||
|
||||
export type WallSegmentClosest = {
|
||||
segment: WallSegment
|
||||
/** Distance from the query point to the closest point on the segment. */
|
||||
distance: number
|
||||
/** Distance along the wall from `start`, clamped to [0, length]. */
|
||||
along: number
|
||||
/** Signed perpendicular offset from the wall axis (+ on the front side). */
|
||||
perp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the straight (non-curved) wall segments that are direct children
|
||||
* of a level — the candidates an opening can snap onto.
|
||||
*/
|
||||
export function collectLevelWallSegments(
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
levelId: AnyNodeId | null,
|
||||
): WallSegment[] {
|
||||
if (!levelId) return []
|
||||
const level = nodes[levelId]
|
||||
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
|
||||
if (!Array.isArray(childIds)) return []
|
||||
|
||||
const segments: WallSegment[] = []
|
||||
for (const childId of childIds) {
|
||||
const node = nodes[childId]
|
||||
if (node?.type !== 'wall') continue
|
||||
const wall = node as WallNode
|
||||
if (isCurvedWall(wall)) continue
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dy = wall.end[1] - wall.start[1]
|
||||
const length = Math.hypot(dx, dy)
|
||||
if (length < 1e-6) continue
|
||||
segments.push({
|
||||
wall,
|
||||
start: wall.start,
|
||||
end: wall.end,
|
||||
dirX: dx / length,
|
||||
dirY: dy / length,
|
||||
length,
|
||||
})
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
/** Closest point + signed offset of one query point against one segment. */
|
||||
export function closestOnSegment(
|
||||
segment: WallSegment,
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
): { distance: number; along: number; perp: number } {
|
||||
const px = pointX - segment.start[0]
|
||||
const py = pointY - segment.start[1]
|
||||
const along = Math.max(0, Math.min(segment.length, px * segment.dirX + py * segment.dirY))
|
||||
const perp = px * -segment.dirY + py * segment.dirX
|
||||
const closestX = segment.start[0] + segment.dirX * along
|
||||
const closestY = segment.start[1] + segment.dirY * along
|
||||
const distance = Math.hypot(pointX - closestX, pointY - closestY)
|
||||
return { distance, along, perp }
|
||||
}
|
||||
|
||||
/**
|
||||
* The single nearest wall segment to a plan point — its Voronoi cell. Returns
|
||||
* null when `segments` is empty or (when `maxDistance` is given) nothing is
|
||||
* within range. Ties resolve to the first segment scanned; callers pass an
|
||||
* already-curved-filtered list from `collectLevelWallSegments`.
|
||||
*/
|
||||
export function nearestWallSegment(
|
||||
segments: readonly WallSegment[],
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
maxDistance = Number.POSITIVE_INFINITY,
|
||||
excludeWallId?: AnyNodeId,
|
||||
): WallSegmentClosest | null {
|
||||
let best: WallSegmentClosest | null = null
|
||||
for (const segment of segments) {
|
||||
if (excludeWallId && segment.wall.id === excludeWallId) continue
|
||||
const { distance, along, perp } = closestOnSegment(segment, pointX, pointY)
|
||||
if (distance > maxDistance) continue
|
||||
if (best && distance >= best.distance) continue
|
||||
best = { segment, distance, along, perp }
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -116,6 +116,14 @@ export type LinearResizeHandle<N> = {
|
||||
anchor: HandleAnchor
|
||||
currentValue: (node: N) => number
|
||||
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
|
||||
/**
|
||||
* Optional per-tick hook fired while this handle is being dragged, with the
|
||||
* live (in-progress, override-merged) node. A pure side-channel for transient
|
||||
* feedback — doors/windows use it to publish proximity / sill guides for the
|
||||
* edge being resized. The return value is ignored; the resize itself is driven
|
||||
* by `apply`.
|
||||
*/
|
||||
onDrag?: (node: N, sceneApi: SceneApi) => void
|
||||
/**
|
||||
* Cross-node redirect. By default the drag's live override + the
|
||||
* committed write both land on the SELECTED node. When this returns
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
discoverPlugins,
|
||||
getHostRefFields,
|
||||
getSelectableKinds,
|
||||
hasRegistry3DMoveTool,
|
||||
isDrawnViaTool,
|
||||
isDrawnViaToolKind,
|
||||
isPresettable,
|
||||
@@ -55,6 +56,7 @@ export type {
|
||||
Capabilities,
|
||||
CapabilityCtx,
|
||||
CuttableConfig,
|
||||
DistributionRole,
|
||||
DragAction,
|
||||
EditorCtx,
|
||||
FloorPlacedConfig,
|
||||
@@ -84,6 +86,7 @@ export type {
|
||||
MovableConfig,
|
||||
NodeCategory,
|
||||
NodeDefinition,
|
||||
NodePort,
|
||||
NodeRegistry,
|
||||
PaintCapability,
|
||||
PaintEffectiveMaterialArgs,
|
||||
|
||||
@@ -146,6 +146,20 @@ export function isRegistryMovable(kind: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the kind has a move tool that MOUNTS in the 3D viewport — the
|
||||
* generic `capabilities.movable` mover or a bespoke `affordanceTools.move`.
|
||||
* Narrower than {@link isRegistryMovable}, which also accepts floorplan-only
|
||||
* movers (e.g. zone) that have no 3D tool. Gates 3D direct move: Ctrl/Meta-drag
|
||||
* and the move-cross grip. Kept beside `isRegistryMovable` so the 2D and 3D
|
||||
* movability predicates can't drift apart.
|
||||
*/
|
||||
export function hasRegistry3DMoveTool(kind: string): boolean {
|
||||
const def = nodeRegistry.get(kind)
|
||||
if (!def) return false
|
||||
return def.capabilities.movable !== undefined || def.affordanceTools?.move !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the kind can be saved as a reusable preset. Default: an
|
||||
* explicit `capabilities.presettable` boolean wins; otherwise the kind
|
||||
|
||||
@@ -178,6 +178,40 @@ export type FloorplanStyle = {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
//
|
||||
// A single key + label entry in the contextual shortcut hint panel.
|
||||
@@ -450,6 +484,20 @@ export type FloorplanGeometry =
|
||||
/** Rotation in radians. The renderer auto-flips to keep text upright. */
|
||||
angle: number
|
||||
}
|
||||
/**
|
||||
* Equal-spacing badge — a small accent pill marking one gap in a run of
|
||||
* (near-)equally-spaced openings (the 2D counterpart of Figma's "=" distance
|
||||
* chips). Emitted once per equal gap so the repeated value reads as a rhythm.
|
||||
* `text` is the shared gap distance; `angle` orients the pill along the wall
|
||||
* (the renderer auto-flips it upright).
|
||||
*/
|
||||
| {
|
||||
kind: 'equal-spacing-badge'
|
||||
point: FloorplanPoint
|
||||
text: string
|
||||
/** Rotation in radians. */
|
||||
angle: number
|
||||
}
|
||||
/**
|
||||
* Architect's dimension overlay — extension lines from the edge
|
||||
* endpoints out past the dimension line, two dimension line halves
|
||||
@@ -626,6 +674,14 @@ export type FloorplanMoveTargetSession = {
|
||||
* returns.
|
||||
*/
|
||||
commit?(): void
|
||||
/**
|
||||
* Optional R-key flip toggle. Kinds with a directional facing
|
||||
* (door / window: front ↔ back) implement this so the overlay can flip
|
||||
* the orientation mid-placement before commit. Toggling just records the
|
||||
* intent; the visible change lands when the overlay re-runs `apply()` with
|
||||
* the last pointer position. Kinds with no facing leave it unset.
|
||||
*/
|
||||
flipSide?(): void
|
||||
}
|
||||
|
||||
export type FloorplanMoveTarget<N> = (args: {
|
||||
@@ -654,12 +710,40 @@ export type SurfaceRole =
|
||||
| 'glazing'
|
||||
| '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>> = {
|
||||
kind: string
|
||||
schemaVersion: number
|
||||
schema: S
|
||||
category: NodeCategory
|
||||
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
|
||||
/**
|
||||
* When `distributionRole` is `'fitting'`, controls whether this fitting
|
||||
* is dragged as a rigid follower when a connected run endpoint moves.
|
||||
*
|
||||
* - `true` (default for `distributionRole === 'fitting'`): the fitting
|
||||
* translates rigidly so its mated collar stays on the moved port — the
|
||||
* right behaviour for in-line fittings (elbows, tees, wyes, crosses).
|
||||
* - `false`: the fitting is anchored in space; moving a connected run
|
||||
* endpoint stretches the run arm, not the fitting. Use this for
|
||||
* fixed-position fixtures like `pipe-trap`.
|
||||
*
|
||||
* Has no effect when `distributionRole` is not `'fitting'`.
|
||||
*/
|
||||
portConnectivityFollow?: boolean
|
||||
|
||||
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
|
||||
migrate?: Record<number, (old: unknown) => unknown>
|
||||
@@ -817,6 +901,15 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
liveOverrides: Map<string, Record<string, unknown>>
|
||||
}) => 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
|
||||
tool?: LazyComponent
|
||||
/**
|
||||
@@ -903,6 +996,14 @@ export type KeyboardActions = {
|
||||
r?: KeyboardAction
|
||||
/** T / Shift+T secondary action. */
|
||||
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 = {
|
||||
@@ -1308,6 +1409,31 @@ export type CapabilityCtx = { node: AnyNode }
|
||||
export type MovableConfig = {
|
||||
axes: ReadonlyArray<'x' | 'y' | 'z'>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1440,7 +1566,24 @@ export type Relations = {
|
||||
export type ParametricDescriptor<N> = {
|
||||
groups: ParamGroup<N>[]
|
||||
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 }> }>
|
||||
/**
|
||||
* Extra buttons rendered in the inspector's Actions section
|
||||
|
||||
@@ -59,6 +59,9 @@ export {
|
||||
getEffectiveDormerSurfaceMaterial,
|
||||
} from './nodes/dormer'
|
||||
export { DownspoutNode } from './nodes/downspout'
|
||||
export { DuctFittingNode } from './nodes/duct-fitting'
|
||||
export { DuctSegmentNode } from './nodes/duct-segment'
|
||||
export { DuctTerminalNode } from './nodes/duct-terminal'
|
||||
export {
|
||||
ElevatorDoorPanelStyle,
|
||||
ElevatorDoorStyle,
|
||||
@@ -69,6 +72,7 @@ export { EyebrowVentNode } from './nodes/eyebrow-vent'
|
||||
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
||||
export { GutterNode, GutterOutlet } from './nodes/gutter'
|
||||
export { HvacEquipmentNode } from './nodes/hvac-equipment'
|
||||
export type {
|
||||
AnimationEffect,
|
||||
Asset,
|
||||
@@ -88,6 +92,11 @@ export {
|
||||
LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT,
|
||||
} from './nodes/item'
|
||||
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
|
||||
export { RidgeVentNode } from './nodes/ridge-vent'
|
||||
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 { DormerNode } from './nodes/dormer'
|
||||
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 { EyebrowVentNode } from './nodes/eyebrow-vent'
|
||||
import { FenceNode } from './nodes/fence'
|
||||
import { GuideNode } from './nodes/guide'
|
||||
import { GutterNode } from './nodes/gutter'
|
||||
import { HvacEquipmentNode } from './nodes/hvac-equipment'
|
||||
import { ItemNode } from './nodes/item'
|
||||
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 { RoofNode } from './nodes/roof'
|
||||
import { RoofSegmentNode } from './nodes/roof-segment'
|
||||
@@ -65,6 +74,15 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
SkylightNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DuctSegmentNode,
|
||||
DuctFittingNode,
|
||||
DuctTerminalNode,
|
||||
HvacEquipmentNode,
|
||||
LinesetNode,
|
||||
LiquidLineNode,
|
||||
PipeSegmentNode,
|
||||
PipeFittingNode,
|
||||
PipeTrapNode,
|
||||
])
|
||||
|
||||
export type AnyNode = z.infer<typeof AnyNode>
|
||||
|
||||
@@ -295,8 +295,43 @@ export function nodeAlignmentAnchors(
|
||||
const poly = (node as { polygon?: [number, number][] }).polygon
|
||||
return poly ? polygonAnchors(node.id, poly) : []
|
||||
}
|
||||
|
||||
const anchors: AlignmentAnchor[] = []
|
||||
|
||||
// Box footprint (items, columns, shelves, stairs, …).
|
||||
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,
|
||||
type Vec3,
|
||||
} from './hosting'
|
||||
export {
|
||||
DEFAULT_LEVEL_HEIGHT,
|
||||
getLevelHeight,
|
||||
} from './level-height'
|
||||
export {
|
||||
type AxisLock,
|
||||
applyAxisLock,
|
||||
@@ -49,6 +53,39 @@ export {
|
||||
moveToward,
|
||||
resolveMovable,
|
||||
} from './movement'
|
||||
export {
|
||||
type AlongWallAlignment,
|
||||
type AlongWallFeature,
|
||||
computeEdgeGaps,
|
||||
computeOpeningGuides,
|
||||
DEFAULT_OPENING_GUIDE_TOLERANCES,
|
||||
detectAlongWallAlignment,
|
||||
detectEqualSpacing,
|
||||
detectVerticalAlignment,
|
||||
type EdgeGap,
|
||||
type EqualSpacingRun,
|
||||
type OpeningGuideInput,
|
||||
type OpeningGuides,
|
||||
type OpeningGuideTolerances,
|
||||
type OpeningSpan,
|
||||
type SillHeadGuide,
|
||||
type VerticalAlignment,
|
||||
type VerticalFeature,
|
||||
type WallExtent,
|
||||
} 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 {
|
||||
DEFAULT_ANGLE_STEP,
|
||||
DEFAULT_GRID_STEP,
|
||||
@@ -62,3 +99,13 @@ export {
|
||||
snapVec3ToGrid,
|
||||
snapWorldXZToBuildingLocal,
|
||||
} from './snap'
|
||||
export {
|
||||
buildPortComponents,
|
||||
type SystemSummary,
|
||||
summarizeSystemFor,
|
||||
} from './system-graph'
|
||||
export {
|
||||
type DwvFinding,
|
||||
type DwvSeverity,
|
||||
validateDwv,
|
||||
} from './validate-dwv'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CeilingNode, LevelNode, WallNode } from '../schema'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
|
||||
export const DEFAULT_LEVEL_HEIGHT = 2.5
|
||||
|
||||
/**
|
||||
* Optional resolver for a wall's rendered base Y (mesh elevation).
|
||||
*
|
||||
* `packages/core` is pure domain logic and must not read viewer/Three.js
|
||||
* state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have
|
||||
* registry access (viewer systems, node tools) may pass a resolver so the
|
||||
* mesh elevation is factored in; pure/headless callers (MCP, tests, server)
|
||||
* omit it and get a deterministic result from serialized node data alone.
|
||||
*/
|
||||
export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined
|
||||
|
||||
export function getLevelHeight(
|
||||
levelId: string,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
resolveWallBaseY?: WallBaseYResolver,
|
||||
): number {
|
||||
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 baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0
|
||||
if (baseY < 0) baseY = 0
|
||||
const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
||||
if (top > maxTop) maxTop = top
|
||||
}
|
||||
}
|
||||
|
||||
return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
computeEdgeGaps,
|
||||
computeOpeningGuides,
|
||||
detectAlongWallAlignment,
|
||||
detectEqualSpacing,
|
||||
detectVerticalAlignment,
|
||||
type OpeningSpan,
|
||||
type WallExtent,
|
||||
} from './opening-guides'
|
||||
|
||||
function span(id: string, centerS: number, width: number, centerY = 1, height = 1): OpeningSpan {
|
||||
return { id, centerS, width, centerY, height }
|
||||
}
|
||||
|
||||
const WALL: WallExtent = { length: 10, height: 2.5 }
|
||||
|
||||
describe('detectEqualSpacing', () => {
|
||||
test('returns null for fewer than three openings', () => {
|
||||
const a = span('a', 0.5, 1)
|
||||
const b = span('b', 2.5, 1)
|
||||
expect(detectEqualSpacing([a, b], 'b', 0.03, 0.02)).toBeNull()
|
||||
})
|
||||
|
||||
test('detects a run of equal gaps across three openings', () => {
|
||||
// width 1 each: a[0,1] b[2,3] c[4,5] → two gaps of 1m.
|
||||
const a = span('a', 0.5, 1)
|
||||
const b = span('b', 2.5, 1)
|
||||
const c = span('c', 4.5, 1)
|
||||
const run = detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)
|
||||
expect(run).not.toBeNull()
|
||||
expect(run?.gap).toBeCloseTo(1)
|
||||
expect(run?.segments).toHaveLength(2)
|
||||
expect(run?.openingIds).toEqual(['a', 'b', 'c'])
|
||||
expect(run?.segments[0]).toEqual({ fromS: 1, toS: 2 })
|
||||
expect(run?.segments[1]).toEqual({ fromS: 3, toS: 4 })
|
||||
})
|
||||
|
||||
test('extends a run across four openings (three gaps)', () => {
|
||||
const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.5, 1), span('d', 6.5, 1)]
|
||||
const run = detectEqualSpacing(openings, 'c', 0.03, 0.02)
|
||||
expect(run?.segments).toHaveLength(3)
|
||||
expect(run?.openingIds).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
test('returns null when gaps differ beyond tolerance', () => {
|
||||
const a = span('a', 0.5, 1) // [0,1]
|
||||
const b = span('b', 2.5, 1) // [2,3] → gap 1
|
||||
const c = span('c', 5, 1) // [4.5,5.5] → gap 1.5
|
||||
expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when the moving opening is not part of the equal run', () => {
|
||||
const a = span('a', 0.5, 1)
|
||||
const b = span('b', 2.5, 1)
|
||||
const c = span('c', 4.5, 1) // a,b,c form equal gaps of 1
|
||||
const d = span('d', 10, 1) // far right, breaks the run
|
||||
expect(detectEqualSpacing([a, b, c, d], 'd', 0.03, 0.02)).toBeNull()
|
||||
})
|
||||
|
||||
test('a near-zero (touching) gap breaks a run', () => {
|
||||
const a = span('a', 0.5, 1) // [0,1]
|
||||
const b = span('b', 1.505, 1) // [1.005,2.005] → gap 0.005 < minGap
|
||||
const c = span('c', 3.005, 1) // [2.505,3.505] → gap 0.5
|
||||
expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull()
|
||||
})
|
||||
|
||||
test('honours the equal-spacing tolerance', () => {
|
||||
const a = span('a', 0.5, 1) // [0,1]
|
||||
const b = span('b', 2.5, 1) // [2,3] → gap 1.0
|
||||
const c = span('c', 4.52, 1) // [4.02,5.02] → gap 1.02
|
||||
expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)?.segments).toHaveLength(2)
|
||||
expect(detectEqualSpacing([a, b, c], 'b', 0.01, 0.02)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeEdgeGaps', () => {
|
||||
test('measures clearance to the nearest neighbour on each side', () => {
|
||||
const moving = span('m', 5, 1) // [4.5,5.5]
|
||||
const left = span('l', 2, 1) // [1.5,2.5]
|
||||
const right = span('r', 8, 1) // [7.5,8.5]
|
||||
const gaps = computeEdgeGaps(moving, [left, right], WALL, 0.02)
|
||||
const byside = Object.fromEntries(gaps.map((g) => [g.side, g]))
|
||||
expect(byside.left?.distance).toBeCloseTo(2)
|
||||
expect(byside.left?.target).toBe('opening')
|
||||
expect(byside.left?.targetId).toBe('l')
|
||||
expect(byside.right?.distance).toBeCloseTo(2)
|
||||
expect(byside.right?.targetId).toBe('r')
|
||||
})
|
||||
|
||||
test('falls back to wall ends with no neighbour', () => {
|
||||
const moving = span('m', 5, 1) // [4.5,5.5]
|
||||
const gaps = computeEdgeGaps(moving, [], WALL, 0.02)
|
||||
const byside = Object.fromEntries(gaps.map((g) => [g.side, g]))
|
||||
expect(byside.left?.target).toBe('wall-start')
|
||||
expect(byside.left?.distance).toBeCloseTo(4.5)
|
||||
expect(byside.right?.target).toBe('wall-end')
|
||||
expect(byside.right?.distance).toBeCloseTo(4.5)
|
||||
})
|
||||
|
||||
test('omits a side that is flush / overlapping (below minGap)', () => {
|
||||
const moving = span('m', 5, 1) // [4.5,5.5]
|
||||
const flush = span('l', 4, 1) // [3.5,4.5] right edge touches moving left
|
||||
const gaps = computeEdgeGaps(moving, [flush], WALL, 0.02)
|
||||
expect(gaps.find((g) => g.side === 'left')).toBeUndefined()
|
||||
expect(gaps.find((g) => g.side === 'right')?.target).toBe('wall-end')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectAlongWallAlignment', () => {
|
||||
test('detects edge-to-edge alignment within tolerance', () => {
|
||||
const moving = span('m', 5, 2) // [4,6]
|
||||
const sib = span('s', 7.05, 2) // left edge 6.05
|
||||
const a = detectAlongWallAlignment(moving, [sib], 0.08)
|
||||
expect(a?.movingFeature).toBe('right')
|
||||
expect(a?.targetFeature).toBe('left')
|
||||
expect(a?.snap).toBeCloseTo(0.05)
|
||||
expect(a?.s).toBeCloseTo(6.05)
|
||||
})
|
||||
|
||||
test('detects centre alignment', () => {
|
||||
const moving = span('m', 5, 2)
|
||||
const sib = span('s', 5.03, 0.5) // centre 5.03, edges far from moving edges
|
||||
const a = detectAlongWallAlignment(moving, [sib], 0.08)
|
||||
expect(a?.movingFeature).toBe('center')
|
||||
expect(a?.targetFeature).toBe('center')
|
||||
expect(a?.snap).toBeCloseTo(0.03)
|
||||
})
|
||||
|
||||
test('returns null when nothing is within tolerance', () => {
|
||||
const moving = span('m', 5, 2)
|
||||
const sib = span('s', 9, 2)
|
||||
expect(detectAlongWallAlignment(moving, [sib], 0.08)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectVerticalAlignment', () => {
|
||||
test('detects a shared sill within tolerance', () => {
|
||||
const moving = span('m', 5, 1, 1.5, 1) // sill 1.0
|
||||
const sib = span('s', 8, 1, 2.04, 2) // sill 1.04
|
||||
const a = detectVerticalAlignment(moving, [sib], 0.08)
|
||||
expect(a?.movingFeature).toBe('sill')
|
||||
expect(a?.targetFeature).toBe('sill')
|
||||
expect(a?.snap).toBeCloseTo(0.04)
|
||||
expect(a?.y).toBeCloseTo(1.04)
|
||||
})
|
||||
|
||||
test('returns null when sills/tops differ beyond tolerance', () => {
|
||||
const moving = span('m', 5, 1, 1.5, 1) // sill 1, top 2, centre 1.5
|
||||
const sib = span('s', 8, 1, 0.4, 0.4) // sill 0.2, top 0.6, centre 0.4
|
||||
expect(detectVerticalAlignment(moving, [sib], 0.08)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeOpeningGuides', () => {
|
||||
test('includes sill/head for windows', () => {
|
||||
const moving = span('m', 5, 1, 1.5, 1) // bottom 1, top 2
|
||||
const guides = computeOpeningGuides({
|
||||
moving,
|
||||
siblings: [],
|
||||
wall: WALL,
|
||||
includeVertical: true,
|
||||
})
|
||||
expect(guides.sillHead?.sill).toBeCloseTo(1)
|
||||
expect(guides.sillHead?.head).toBeCloseTo(0.5) // 2.5 - 2
|
||||
expect(guides.sillHead?.bottomY).toBeCloseTo(1)
|
||||
expect(guides.sillHead?.topY).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
test('omits vertical guides for doors (sit on the floor)', () => {
|
||||
const moving = span('m', 5, 1, 1, 2)
|
||||
const sib = span('s', 8, 1, 1, 2)
|
||||
const guides = computeOpeningGuides({
|
||||
moving,
|
||||
siblings: [sib],
|
||||
wall: WALL,
|
||||
includeVertical: false,
|
||||
})
|
||||
expect(guides.sillHead).toBeNull()
|
||||
expect(guides.vertical).toBeNull()
|
||||
// along-wall + proximity still computed for doors
|
||||
expect(guides.gaps.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('combines proximity and equal-spacing in one pass', () => {
|
||||
const moving = span('b', 2.5, 1)
|
||||
const guides = computeOpeningGuides({
|
||||
moving,
|
||||
siblings: [span('a', 0.5, 1), span('c', 4.5, 1)],
|
||||
wall: WALL,
|
||||
includeVertical: true,
|
||||
})
|
||||
expect(guides.gaps).toHaveLength(2)
|
||||
expect(guides.equalSpacing?.gap).toBeCloseTo(1)
|
||||
expect(guides.equalSpacing?.openingIds).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('opening-guides — review regressions', () => {
|
||||
test('detectEqualSpacing finds a run that starts partway through a drifting sequence', () => {
|
||||
// gaps 1.00, 1.02, 1.04 — only [b,c,d] is equal within 0.03 and includes the
|
||||
// moving opening; a first-gap-anchored greedy scan used to drop it.
|
||||
const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.52, 1), span('d', 6.56, 1)]
|
||||
const run = detectEqualSpacing(openings, 'd', 0.03, 0.02)
|
||||
expect(run?.openingIds).toEqual(['b', 'c', 'd'])
|
||||
expect(run?.gap).toBeCloseTo(1.03)
|
||||
expect(run?.segments).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('detectEqualSpacing prefers the leftmost run on a length tie', () => {
|
||||
// gaps 1,1,2,2 with the moving opening in the middle — two equal-length runs.
|
||||
const openings = [
|
||||
span('a', 0.5, 1),
|
||||
span('b', 2.5, 1),
|
||||
span('c', 4.5, 1),
|
||||
span('d', 7.5, 1),
|
||||
span('e', 10.5, 1),
|
||||
]
|
||||
expect(detectEqualSpacing(openings, 'c', 0.03, 0.02)?.openingIds).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
test('computeEdgeGaps suppresses both sides when a sibling overlaps', () => {
|
||||
const moving = span('m', 5, 1) // [4.5,5.5]
|
||||
const containing = span('s', 5, 2) // [4,6] straddles both edges
|
||||
expect(computeEdgeGaps(moving, [containing], WALL, 0.02)).toEqual([])
|
||||
})
|
||||
|
||||
test('alignment detectors ignore the moving opening if present in siblings', () => {
|
||||
const moving = span('m', 5, 2, 1.5, 1)
|
||||
expect(detectAlongWallAlignment(moving, [moving], 0.08)).toBeNull()
|
||||
expect(detectVerticalAlignment(moving, [moving], 0.08)).toBeNull()
|
||||
})
|
||||
|
||||
test('detectAlongWallAlignment reports a negative snap when the feature is past the target', () => {
|
||||
const moving = span('m', 5, 2) // centre 5
|
||||
const sib = span('s', 4.96, 0.5) // centre 4.96
|
||||
const a = detectAlongWallAlignment(moving, [sib], 0.08)
|
||||
expect(a?.movingFeature).toBe('center')
|
||||
expect(a?.snap).toBeCloseTo(-0.04)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,404 @@
|
||||
// Proximity / alignment guides for wall-hosted openings (doors, windows).
|
||||
//
|
||||
// Pure geometry over a single host wall's LOCAL frame — no Three.js, no scene
|
||||
// store, no React — so it runs identically for the 3D viewport and the 2D
|
||||
// floor plan and is unit-testable in isolation. Callers extract the spans from
|
||||
// the scene graph (an opening's `position[0]` is its along-wall centre, its
|
||||
// `position[1]` its vertical centre with the wall base at y=0) and feed them in;
|
||||
// the renderers transform the returned wall-local coordinates back to world
|
||||
// (3D) or plan (2D).
|
||||
//
|
||||
// What it produces, mirroring the affordances architects expect (and Figma's
|
||||
// smart guides):
|
||||
// - sill/head : a window's bottom edge → floor and top edge → wall top.
|
||||
// - edge gaps : along-wall clearance to the nearest neighbour opening (or
|
||||
// the wall end) on each side.
|
||||
// - alongWall : the moving opening's edge/centre lining up with a
|
||||
// neighbour's edge/centre along the wall.
|
||||
// - vertical : two openings sharing a sill / head / vertical centre.
|
||||
// - equalSpacing : a run of 3+ openings with (near-)equal gaps between them.
|
||||
//
|
||||
// Detection is passive — it reports what currently coincides within tolerance
|
||||
// and the snap delta that would make it exact, leaving the snap decision to the
|
||||
// caller's manipulation policy (grid vs. alignment vs. Shift bypass).
|
||||
|
||||
/** An opening's footprint in its host wall's local frame. */
|
||||
export type OpeningSpan = {
|
||||
id: string
|
||||
/** Centre along the wall, measured from `wall.start` (m). */
|
||||
centerS: number
|
||||
/** Along-wall extent (m). */
|
||||
width: number
|
||||
/** Vertical centre above the wall base (floor at y=0) (m). */
|
||||
centerY: number
|
||||
/** Vertical extent (m). */
|
||||
height: number
|
||||
}
|
||||
|
||||
export type WallExtent = {
|
||||
/** Wall length (m). */
|
||||
length: number
|
||||
/** Wall height (m). */
|
||||
height: number
|
||||
}
|
||||
|
||||
export type OpeningGuideTolerances = {
|
||||
/** Max distance for an edge/centre to count as aligned with a neighbour (m). */
|
||||
align: number
|
||||
/** Max difference between two gaps for them to count as equal (m). */
|
||||
equalSpacing: number
|
||||
/** Gaps below this are treated as touching/overlap noise and ignored (m). */
|
||||
minGap: number
|
||||
}
|
||||
|
||||
export const DEFAULT_OPENING_GUIDE_TOLERANCES: OpeningGuideTolerances = {
|
||||
// Parity with the along-wall snap threshold (`ALONG_WALL_ALIGN_THRESHOLD_M`).
|
||||
align: 0.08,
|
||||
equalSpacing: 0.03,
|
||||
minGap: 0.02,
|
||||
}
|
||||
|
||||
/** Which along-wall feature of an opening a guide references. */
|
||||
export type AlongWallFeature = 'left' | 'center' | 'right'
|
||||
/** Which vertical feature of an opening a guide references. */
|
||||
export type VerticalFeature = 'sill' | 'center' | 'top'
|
||||
|
||||
export type SillHeadGuide = {
|
||||
/** Floor (y=0) → the opening's bottom edge (m). */
|
||||
sill: number
|
||||
/** Wall-local y of the bottom edge. */
|
||||
bottomY: number
|
||||
/** The opening's top edge → the wall top (m). */
|
||||
head: number
|
||||
/** Wall-local y of the top edge. */
|
||||
topY: number
|
||||
}
|
||||
|
||||
export type EdgeGap = {
|
||||
side: 'left' | 'right'
|
||||
/** Clearance along the wall (m). */
|
||||
distance: number
|
||||
/** Wall-local s of the moving opening's edge. */
|
||||
fromS: number
|
||||
/** Wall-local s of the neighbour edge / wall end. */
|
||||
toS: number
|
||||
target: 'opening' | 'wall-start' | 'wall-end'
|
||||
/** Set when `target === 'opening'`. */
|
||||
targetId?: string
|
||||
}
|
||||
|
||||
export type AlongWallAlignment = {
|
||||
/** Wall-local s the two features share. */
|
||||
s: number
|
||||
movingFeature: AlongWallFeature
|
||||
targetId: string
|
||||
targetFeature: AlongWallFeature
|
||||
/** Delta to add to the moving opening's `centerS` to make them coincide. */
|
||||
snap: number
|
||||
}
|
||||
|
||||
export type VerticalAlignment = {
|
||||
/** Wall-local y the two features share. */
|
||||
y: number
|
||||
movingFeature: VerticalFeature
|
||||
targetId: string
|
||||
targetFeature: VerticalFeature
|
||||
/** Delta to add to the moving opening's `centerY` to make them coincide. */
|
||||
snap: number
|
||||
}
|
||||
|
||||
export type EqualSpacingRun = {
|
||||
/** The repeated gap value (average of the run's gaps) (m). */
|
||||
gap: number
|
||||
/** The equal-gap segments along the wall, in order (left → right). */
|
||||
segments: { fromS: number; toS: number }[]
|
||||
/** Participating opening ids, ordered along the wall, including the moving one. */
|
||||
openingIds: string[]
|
||||
}
|
||||
|
||||
export type OpeningGuides = {
|
||||
sillHead: SillHeadGuide | null
|
||||
gaps: EdgeGap[]
|
||||
alongWall: AlongWallAlignment | null
|
||||
vertical: VerticalAlignment | null
|
||||
equalSpacing: EqualSpacingRun | null
|
||||
}
|
||||
|
||||
export type OpeningGuideInput = {
|
||||
moving: OpeningSpan
|
||||
/** Other openings on the SAME wall (the moving opening excluded). */
|
||||
siblings: readonly OpeningSpan[]
|
||||
wall: WallExtent
|
||||
/**
|
||||
* Whether to compute vertical (sill/head/vertical-alignment) guides. True for
|
||||
* windows; false for doors, which sit on the floor so their sill is always 0.
|
||||
*/
|
||||
includeVertical: boolean
|
||||
tolerances?: Partial<OpeningGuideTolerances>
|
||||
}
|
||||
|
||||
const leftEdge = (s: OpeningSpan) => s.centerS - s.width / 2
|
||||
const rightEdge = (s: OpeningSpan) => s.centerS + s.width / 2
|
||||
const bottomEdge = (s: OpeningSpan) => s.centerY - s.height / 2
|
||||
const topEdge = (s: OpeningSpan) => s.centerY + s.height / 2
|
||||
|
||||
function alongWallFeatureCoord(s: OpeningSpan, feature: AlongWallFeature): number {
|
||||
if (feature === 'left') return leftEdge(s)
|
||||
if (feature === 'right') return rightEdge(s)
|
||||
return s.centerS
|
||||
}
|
||||
|
||||
function verticalFeatureCoord(s: OpeningSpan, feature: VerticalFeature): number {
|
||||
if (feature === 'sill') return bottomEdge(s)
|
||||
if (feature === 'top') return topEdge(s)
|
||||
return s.centerY
|
||||
}
|
||||
|
||||
const ALONG_WALL_FEATURES: AlongWallFeature[] = ['left', 'center', 'right']
|
||||
const VERTICAL_FEATURES: VerticalFeature[] = ['sill', 'center', 'top']
|
||||
|
||||
/**
|
||||
* Edge-to-edge clearance from the moving opening to the nearest neighbour on
|
||||
* each side, falling back to the wall ends when there is no neighbour — the
|
||||
* "how much wall is left here" reading. Returns 0–2 gaps (one per side); a side
|
||||
* is omitted when its clearance is below `minGap` (the opening is flush against
|
||||
* or overlapping that neighbour).
|
||||
*/
|
||||
export function computeEdgeGaps(
|
||||
moving: OpeningSpan,
|
||||
siblings: readonly OpeningSpan[],
|
||||
wall: WallExtent,
|
||||
minGap: number,
|
||||
): EdgeGap[] {
|
||||
const movingLeft = leftEdge(moving)
|
||||
const movingRight = rightEdge(moving)
|
||||
|
||||
// A sibling that straddles one of the moving opening's edges is an OVERLAP,
|
||||
// not a neighbour: there is no clearance on that side, and we must not fall
|
||||
// back to the wall end (which would report a misleading distance measured
|
||||
// "through" the overlapping opening).
|
||||
const leftCrossed = siblings.some((s) => leftEdge(s) < movingLeft && rightEdge(s) > movingLeft)
|
||||
const rightCrossed = siblings.some((s) => leftEdge(s) < movingRight && rightEdge(s) > movingRight)
|
||||
|
||||
let leftNeighbour: { s: number; id: string } | null = null
|
||||
let rightNeighbour: { s: number; id: string } | null = null
|
||||
for (const sib of siblings) {
|
||||
const sibRight = rightEdge(sib)
|
||||
const sibLeft = leftEdge(sib)
|
||||
// Entirely to the left of the moving opening → candidate left neighbour.
|
||||
if (sibRight <= movingLeft && (leftNeighbour === null || sibRight > leftNeighbour.s)) {
|
||||
leftNeighbour = { s: sibRight, id: sib.id }
|
||||
}
|
||||
// Entirely to the right → candidate right neighbour.
|
||||
if (sibLeft >= movingRight && (rightNeighbour === null || sibLeft < rightNeighbour.s)) {
|
||||
rightNeighbour = { s: sibLeft, id: sib.id }
|
||||
}
|
||||
}
|
||||
|
||||
const gaps: EdgeGap[] = []
|
||||
|
||||
if (!leftCrossed) {
|
||||
const leftToS = leftNeighbour ? leftNeighbour.s : 0
|
||||
const leftDistance = movingLeft - leftToS
|
||||
if (leftDistance >= minGap) {
|
||||
gaps.push({
|
||||
side: 'left',
|
||||
distance: leftDistance,
|
||||
fromS: movingLeft,
|
||||
toS: leftToS,
|
||||
target: leftNeighbour ? 'opening' : 'wall-start',
|
||||
targetId: leftNeighbour?.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!rightCrossed) {
|
||||
const rightToS = rightNeighbour ? rightNeighbour.s : wall.length
|
||||
const rightDistance = rightToS - movingRight
|
||||
if (rightDistance >= minGap) {
|
||||
gaps.push({
|
||||
side: 'right',
|
||||
distance: rightDistance,
|
||||
fromS: movingRight,
|
||||
toS: rightToS,
|
||||
target: rightNeighbour ? 'opening' : 'wall-end',
|
||||
targetId: rightNeighbour?.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
/**
|
||||
* The closest coincidence between any of the moving opening's edges/centre and
|
||||
* any sibling's edges/centre along the wall, within `tolerance`. Edge-to-edge
|
||||
* and centre-to-centre are weighed equally; the single closest pair wins
|
||||
* (matching the one-guide-per-axis behaviour of the floor-plane resolver).
|
||||
*/
|
||||
export function detectAlongWallAlignment(
|
||||
moving: OpeningSpan,
|
||||
siblings: readonly OpeningSpan[],
|
||||
tolerance: number,
|
||||
): AlongWallAlignment | null {
|
||||
let best: AlongWallAlignment | null = null
|
||||
let bestAbs = tolerance
|
||||
for (const movingFeature of ALONG_WALL_FEATURES) {
|
||||
const movingCoord = alongWallFeatureCoord(moving, movingFeature)
|
||||
for (const sib of siblings) {
|
||||
if (sib.id === moving.id) continue
|
||||
for (const targetFeature of ALONG_WALL_FEATURES) {
|
||||
const targetCoord = alongWallFeatureCoord(sib, targetFeature)
|
||||
const diff = targetCoord - movingCoord
|
||||
const abs = Math.abs(diff)
|
||||
if (abs <= bestAbs && (best === null || abs < bestAbs)) {
|
||||
bestAbs = abs
|
||||
best = {
|
||||
s: targetCoord,
|
||||
movingFeature,
|
||||
targetId: sib.id,
|
||||
targetFeature,
|
||||
snap: diff,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* The closest coincidence between the moving opening's sill/centre/top and any
|
||||
* sibling's sill/centre/top, within `tolerance` — the "these two windows share
|
||||
* a sill height" detector. Same single-best-match policy as the along-wall
|
||||
* variant.
|
||||
*/
|
||||
export function detectVerticalAlignment(
|
||||
moving: OpeningSpan,
|
||||
siblings: readonly OpeningSpan[],
|
||||
tolerance: number,
|
||||
): VerticalAlignment | null {
|
||||
let best: VerticalAlignment | null = null
|
||||
let bestAbs = tolerance
|
||||
for (const movingFeature of VERTICAL_FEATURES) {
|
||||
const movingCoord = verticalFeatureCoord(moving, movingFeature)
|
||||
for (const sib of siblings) {
|
||||
if (sib.id === moving.id) continue
|
||||
for (const targetFeature of VERTICAL_FEATURES) {
|
||||
const targetCoord = verticalFeatureCoord(sib, targetFeature)
|
||||
const diff = targetCoord - movingCoord
|
||||
const abs = Math.abs(diff)
|
||||
if (abs <= bestAbs && (best === null || abs < bestAbs)) {
|
||||
bestAbs = abs
|
||||
best = {
|
||||
y: targetCoord,
|
||||
movingFeature,
|
||||
targetId: sib.id,
|
||||
targetFeature,
|
||||
snap: diff,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Figma-style equal-spacing detection: order all openings along the wall, look
|
||||
* at the clearances BETWEEN consecutive openings, and return the longest run of
|
||||
* ≥2 consecutive gaps that are equal within `tolerance` and that the moving
|
||||
* opening participates in (so the badges only appear while the drag is actually
|
||||
* forming or extending a series). Returns null when no such run exists.
|
||||
*
|
||||
* Gaps below `minGap` (touching/overlapping openings) break a run — a row of
|
||||
* flush openings is not "equally spaced".
|
||||
*/
|
||||
export function detectEqualSpacing(
|
||||
allOpenings: readonly OpeningSpan[],
|
||||
movingId: string,
|
||||
tolerance: number,
|
||||
minGap: number,
|
||||
): EqualSpacingRun | null {
|
||||
if (allOpenings.length < 3) return null
|
||||
const sorted = [...allOpenings].sort((a, b) => a.centerS - b.centerS)
|
||||
const movingIndex = sorted.findIndex((s) => s.id === movingId)
|
||||
if (movingIndex < 0) return null
|
||||
|
||||
// Clearance between opening i and i+1.
|
||||
const gaps: { value: number; fromS: number; toS: number }[] = []
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i]
|
||||
const b = sorted[i + 1]
|
||||
if (!a || !b) continue
|
||||
const fromS = rightEdge(a)
|
||||
const toS = leftEdge(b)
|
||||
gaps.push({ value: toS - fromS, fromS, toS })
|
||||
}
|
||||
|
||||
// Longest contiguous window of gaps that are (a) each ≥ minGap and (b)
|
||||
// mutually equal within tolerance (window max − min ≤ tolerance), spanning at
|
||||
// least 2 gaps and including the moving opening. Brute force over windows
|
||||
// (openings per wall are few). A first-gap-anchored greedy scan is NOT
|
||||
// equivalent: it drops a valid run that begins partway through a drifting
|
||||
// sequence — e.g. gaps 1.00, 1.02, 1.04 with the moving opening at the end,
|
||||
// where [1.02, 1.04] is a real run. On a length tie the leftmost window wins,
|
||||
// for determinism.
|
||||
let best: EqualSpacingRun | null = null
|
||||
for (let lo = 0; lo < gaps.length; lo++) {
|
||||
let min = Number.POSITIVE_INFINITY
|
||||
let max = Number.NEGATIVE_INFINITY
|
||||
for (let hi = lo; hi < gaps.length; hi++) {
|
||||
const gap = gaps[hi]
|
||||
if (!gap || gap.value < minGap) break // a sub-minGap gap can't join a run
|
||||
min = Math.min(min, gap.value)
|
||||
max = Math.max(max, gap.value)
|
||||
if (max - min > tolerance) break // extending only widens the spread
|
||||
const gapCount = hi - lo + 1
|
||||
if (gapCount < 2) continue
|
||||
const firstOpening = lo // gap i sits between openings i and i+1
|
||||
const lastOpening = hi + 1
|
||||
if (movingIndex < firstOpening || movingIndex > lastOpening) continue
|
||||
if (best !== null && gapCount <= best.segments.length) continue
|
||||
const windowGaps = gaps.slice(lo, hi + 1)
|
||||
best = {
|
||||
gap: windowGaps.reduce((sum, g) => sum + g.value, 0) / windowGaps.length,
|
||||
segments: windowGaps.map((g) => ({ fromS: g.fromS, toS: g.toS })),
|
||||
openingIds: sorted.slice(firstOpening, lastOpening + 1).map((s) => s.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute every proximity/alignment guide for the moving opening in one pass.
|
||||
* Pure: feed it the moving opening's wall-local span, its same-wall siblings,
|
||||
* and the wall extent; render the result in whichever view.
|
||||
*/
|
||||
export function computeOpeningGuides(input: OpeningGuideInput): OpeningGuides {
|
||||
const tol = { ...DEFAULT_OPENING_GUIDE_TOLERANCES, ...input.tolerances }
|
||||
const { moving, siblings, wall, includeVertical } = input
|
||||
|
||||
const sillHead: SillHeadGuide | null = includeVertical
|
||||
? {
|
||||
sill: bottomEdge(moving),
|
||||
bottomY: bottomEdge(moving),
|
||||
head: wall.height - topEdge(moving),
|
||||
topY: topEdge(moving),
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
sillHead,
|
||||
gaps: computeEdgeGaps(moving, siblings, wall, tol.minGap),
|
||||
alongWall: detectAlongWallAlignment(moving, siblings, tol.align),
|
||||
vertical: includeVertical ? detectVerticalAlignment(moving, siblings, tol.align) : null,
|
||||
equalSpacing: detectEqualSpacing(
|
||||
[moving, ...siblings],
|
||||
moving.id,
|
||||
tol.equalSpacing,
|
||||
tol.minGap,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { nodeRegistry } from '../registry'
|
||||
import type { AnyNode, AnyNodeId } from '../schema'
|
||||
|
||||
/**
|
||||
* Connectivity-aware editing for port-bearing distribution kinds
|
||||
* (HVAC ductwork AND DWV plumbing).
|
||||
*
|
||||
* 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; system?: string }> | undefined {
|
||||
return nodeRegistry.get(node.type)?.ports?.(node) as
|
||||
| ReadonlyArray<{ id: string; position: Point; system?: string }>
|
||||
| undefined
|
||||
}
|
||||
|
||||
/** A node's distribution role from the registry (run / fitting / …). */
|
||||
function roleOf(node: AnyNode): string | undefined {
|
||||
return nodeRegistry.get(node.type)?.distributionRole
|
||||
}
|
||||
|
||||
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 `run`-role partners (segments — endpoint stretch) and `fitting`-role
|
||||
* partners (rigid follow) are tracked — terminals and equipment usually mount
|
||||
* to a surface and shouldn't be yanked off it when an adjacent fitting nudges.
|
||||
*/
|
||||
export function analyzePortConnectivity(
|
||||
movedNode: AnyNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): PortConnectivity {
|
||||
const movedPorts = portsOf(movedNode) ?? []
|
||||
const startMovedPorts: Record<string, Point> = {}
|
||||
const movedPortSystem: Record<string, string | undefined> = {}
|
||||
for (const p of movedPorts) {
|
||||
startMovedPorts[p.id] = p.position
|
||||
movedPortSystem[p.id] = p.system
|
||||
}
|
||||
|
||||
const connections: PortConnection[] = []
|
||||
const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M
|
||||
|
||||
for (const other of Object.values(nodes)) {
|
||||
if (!other || other.id === movedNode.id) continue
|
||||
// Generalised across every distribution family (HVAC duct + DWV pipe):
|
||||
// `run` partners stretch an endpoint, `fitting` partners follow rigidly.
|
||||
// Terminals/equipment mount to surfaces and are intentionally NOT dragged.
|
||||
// Fittings that declare `portConnectivityFollow: false` are anchored
|
||||
// fixtures (e.g. pipe-trap) — moving a connected run stretches the arm.
|
||||
const otherRole = roleOf(other)
|
||||
if (otherRole !== 'run' && otherRole !== 'fitting') continue
|
||||
const otherDef = nodeRegistry.get(other.type)
|
||||
if (otherRole === 'fitting' && otherDef?.portConnectivityFollow === false) continue
|
||||
const otherPorts = portsOf(other)
|
||||
if (!otherPorts) continue
|
||||
|
||||
for (const op of otherPorts) {
|
||||
// Find which of the moved node's ports this partner port sits on.
|
||||
let matchedId: string | null = null
|
||||
for (const mp of movedPorts) {
|
||||
if (distSq(op.position, mp.position) > epsSq) continue
|
||||
// Don't fuse ports from incompatible systems (e.g. a supply duct
|
||||
// and a waste pipe that happen to cross): only mate when both
|
||||
// ports declare the same system, or at least one is unscoped.
|
||||
const ms = movedPortSystem[mp.id]
|
||||
if (ms && op.system && ms !== op.system) continue
|
||||
matchedId = mp.id
|
||||
break
|
||||
}
|
||||
if (!matchedId) continue
|
||||
|
||||
if (otherRole === 'run') {
|
||||
const path = (other as unknown as { path?: Point[] }).path
|
||||
if (!Array.isArray(path) || path.length < 2) continue
|
||||
// Port id 'start' → first point, 'end' → last point.
|
||||
const pathIndex = op.id === 'start' ? 0 : path.length - 1
|
||||
connections.push({
|
||||
kind: 'duct-endpoint',
|
||||
nodeId: other.id,
|
||||
pathIndex,
|
||||
movedPortId: matchedId,
|
||||
startPath: path.map((p) => [...p] as Point),
|
||||
})
|
||||
} else {
|
||||
const position = (other as unknown as { position?: Point }).position
|
||||
if (!position) continue
|
||||
connections.push({
|
||||
kind: 'rigid-node',
|
||||
nodeId: other.id,
|
||||
movedPortId: matchedId,
|
||||
startPosition: [position[0], position[1], position[2]],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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?.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
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type SceneMaterialId,
|
||||
} from '../schema/scene-material'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { healSceneNodes } from '../utils/heal-scene-graph'
|
||||
import * as nodeActions from './actions/node-actions'
|
||||
import { resetSceneHistoryPauseDepth } from './history-control'
|
||||
|
||||
@@ -542,7 +543,10 @@ function migrateNodes(nodes: Record<string, any>): {
|
||||
nodes: Record<string, AnyNode>
|
||||
mintedMaterials: Record<SceneMaterialId, SceneMaterial>
|
||||
} {
|
||||
const patchedNodes = { ...nodes }
|
||||
// Repair pre-existing corruption (null children, zero-length walls) before
|
||||
// any per-type migration runs, so already-saved scenes load cleanly.
|
||||
const { nodes: healed } = healSceneNodes(nodes)
|
||||
const patchedNodes = { ...healed } as Record<string, any>
|
||||
// Scene materials minted while moving legacy wall fields onto `node.slots`;
|
||||
// merged into the scene material map by the caller (`setScene`).
|
||||
const mintedMaterials: Record<SceneMaterialId, SceneMaterial> = {}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { WallNode } from '../../schema'
|
||||
import { calculateLevelMiters, getWallMiterBoundaryPoints } from './wall-mitering'
|
||||
|
||||
function wall(id: string, start: [number, number], end: [number, number]): WallNode {
|
||||
return {
|
||||
id,
|
||||
type: 'wall',
|
||||
object: 'node',
|
||||
visible: true,
|
||||
parentId: 'level_test',
|
||||
children: [],
|
||||
start,
|
||||
end,
|
||||
thickness: 0.1,
|
||||
height: 2.5,
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
metadata: {},
|
||||
} as WallNode
|
||||
}
|
||||
|
||||
function maxBoundaryCoord(walls: WallNode[]): number {
|
||||
const miter = calculateLevelMiters(walls)
|
||||
let max = 0
|
||||
for (const w of walls) {
|
||||
const bp = getWallMiterBoundaryPoints(w, miter)
|
||||
expect(bp).not.toBeNull()
|
||||
if (!bp) continue
|
||||
for (const p of [bp.startLeft, bp.startRight, bp.endLeft, bp.endRight]) {
|
||||
expect(Number.isFinite(p.x)).toBe(true)
|
||||
expect(Number.isFinite(p.y)).toBe(true)
|
||||
max = Math.max(max, Math.abs(p.x), Math.abs(p.y))
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
describe('wall mitering miter limit', () => {
|
||||
// Two 3 m walls sharing the origin, meeting at decreasing angles. Without a
|
||||
// miter limit the joint point runs to infinity as the angle → 0 (∝ 1/sin θ),
|
||||
// which is the "infinite wall" seen when a room-preset preview lands on top of
|
||||
// an existing wall. The boundary must stay bounded near the wall length.
|
||||
test.each([90, 30, 10, 5, 1, 0.1, 0.01])('stays bounded at a %s° junction', (deg) => {
|
||||
const rad = (deg * Math.PI) / 180
|
||||
const walls = [
|
||||
wall('A', [0, 0], [3, 0]),
|
||||
wall('B', [0, 0], [3 * Math.cos(rad), 3 * Math.sin(rad)]),
|
||||
]
|
||||
// 3 m walls + a few cm of joint: anything past ~4 m is a runaway spike.
|
||||
expect(maxBoundaryCoord(walls)).toBeLessThan(4)
|
||||
})
|
||||
|
||||
test('still miters a normal 90° corner', () => {
|
||||
const walls = [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [0, 3])]
|
||||
const miter = calculateLevelMiters(walls)
|
||||
const bpA = getWallMiterBoundaryPoints(walls[0]!, miter)
|
||||
expect(bpA).not.toBeNull()
|
||||
if (!bpA) throw new Error('expected miter boundary points')
|
||||
// The shared corner is pulled to the mitred intersection, offset from the
|
||||
// raw butt position (halfThickness 0.05) by the diagonal of the joint.
|
||||
const startSideX = Math.min(bpA.startLeft.x, bpA.startRight.x)
|
||||
expect(startSideX).toBeLessThan(-0.001)
|
||||
expect(startSideX).toBeGreaterThan(-0.5)
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,17 @@ type JunctionData = Map<string, WallIntersections>
|
||||
|
||||
const TOLERANCE = 0.001
|
||||
|
||||
// Miter joints are line-line intersections, so the joint point sits a distance
|
||||
// ≈ halfThickness / sin(θ) from the junction, where θ is the angle between the
|
||||
// two walls. As θ → 0 (two walls nearly collinear — e.g. a room-preset preview
|
||||
// dragged on top of an existing wall, or a freshly-drawn wall almost parallel
|
||||
// to its neighbour) that distance runs away to infinity and the wall renders as
|
||||
// an infinite spike. Cap the joint at this multiple of the wall half-thickness;
|
||||
// beyond it we fall back to a square (butt) joint, exactly like the existing
|
||||
// parallel-walls guard. 10× preserves every realistic corner (a 0.1 m wall keeps
|
||||
// mitering down to ~11°) while bounding the pathological near-collinear case.
|
||||
const MITER_LIMIT = 10
|
||||
|
||||
function pointToKey(p: Point2D, tolerance = TOLERANCE): string {
|
||||
const snap = 1 / tolerance
|
||||
return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}`
|
||||
@@ -198,6 +209,7 @@ interface ProcessedWall {
|
||||
edgeA: LineEquation // Left edge
|
||||
edgeB: LineEquation // Right edge
|
||||
isPassthrough: boolean // True if wall passes through junction (T-junction)
|
||||
halfThickness: number // Used to bound the miter joint against runaway spikes
|
||||
}
|
||||
|
||||
function calculateJunctionIntersections(
|
||||
@@ -228,7 +240,14 @@ function calculateJunctionIntersections(
|
||||
const edgeB = createLineFromPointAndVector(pB, v)
|
||||
const angle = Math.atan2(v.y, v.x)
|
||||
|
||||
processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: true })
|
||||
processedWalls.push({
|
||||
wallId: wall.id,
|
||||
angle,
|
||||
edgeA,
|
||||
edgeB,
|
||||
isPassthrough: true,
|
||||
halfThickness: halfT,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Normal wall endpoint (start or end)
|
||||
@@ -245,7 +264,14 @@ function calculateJunctionIntersections(
|
||||
const edgeB = createLineFromPointAndVector(pB, v)
|
||||
const angle = Math.atan2(v.y, v.x)
|
||||
|
||||
processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: false })
|
||||
processedWalls.push({
|
||||
wallId: wall.id,
|
||||
angle,
|
||||
edgeA,
|
||||
edgeB,
|
||||
isPassthrough: false,
|
||||
halfThickness: halfT,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +301,21 @@ function calculateJunctionIntersections(
|
||||
y: (wall2.edgeB.a * wall1.edgeA.c - wall1.edgeA.a * wall2.edgeB.c) / det,
|
||||
}
|
||||
|
||||
// Miter limit: `det` only catches walls that are *exactly* parallel. Two
|
||||
// walls meeting at a shallow angle have a small-but-nonzero `det`, so `p`
|
||||
// lands far from the junction (∝ 1/sin θ) and the wall renders as an
|
||||
// infinite spike. Reject any joint farther than MITER_LIMIT half-thicknesses
|
||||
// from the meeting point — those walls fall back to a square joint.
|
||||
const maxMiter = MITER_LIMIT * Math.max(wall1.halfThickness, wall2.halfThickness)
|
||||
const dx = p.x - meetingPoint.x
|
||||
const dy = p.y - meetingPoint.y
|
||||
if (
|
||||
!(Number.isFinite(p.x) && Number.isFinite(p.y)) ||
|
||||
dx * dx + dy * dy > maxMiter * maxMiter
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only assign intersection to non-passthrough walls
|
||||
// Passthrough walls don't receive junction data (their geometry doesn't change)
|
||||
if (!wall1.isPassthrough) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { healSceneNodes } from './heal-scene-graph'
|
||||
|
||||
describe('healSceneNodes', () => {
|
||||
test('strips non-string (null) children entries', () => {
|
||||
const { nodes, strippedChildRefs } = healSceneNodes({
|
||||
wall_a: {
|
||||
id: 'wall_a',
|
||||
type: 'wall',
|
||||
start: [0, 0],
|
||||
end: [1, 0],
|
||||
children: [null, 'item_x'],
|
||||
},
|
||||
item_x: { id: 'item_x', type: 'item' },
|
||||
})
|
||||
expect(strippedChildRefs).toBe(1)
|
||||
expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x'])
|
||||
})
|
||||
|
||||
test('drops childless zero-length walls and removes their parent reference', () => {
|
||||
const { nodes, droppedWallIds } = healSceneNodes({
|
||||
level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] },
|
||||
wall_zero: { id: 'wall_zero', type: 'wall', start: [5, 5], end: [5, 5], children: [] },
|
||||
wall_real: { id: 'wall_real', type: 'wall', start: [0, 0], end: [3, 0], children: [] },
|
||||
})
|
||||
expect(droppedWallIds).toEqual(['wall_zero'])
|
||||
expect('wall_zero' in nodes).toBe(false)
|
||||
expect((nodes.level_0 as { children: string[] }).children).toEqual(['wall_real'])
|
||||
})
|
||||
|
||||
test('keeps a zero-length wall that still hosts a door/window', () => {
|
||||
const { nodes, droppedWallIds } = healSceneNodes({
|
||||
wall_z: { id: 'wall_z', type: 'wall', start: [1, 1], end: [1, 1], children: ['door_1'] },
|
||||
door_1: { id: 'door_1', type: 'door' },
|
||||
})
|
||||
expect(droppedWallIds).toEqual([])
|
||||
expect('wall_z' in nodes).toBe(true)
|
||||
})
|
||||
|
||||
test('passes a clean scene through untouched', () => {
|
||||
const input = {
|
||||
wall_a: { id: 'wall_a', type: 'wall', start: [0, 0], end: [2, 0], children: ['door_1'] },
|
||||
door_1: { id: 'door_1', type: 'door' },
|
||||
}
|
||||
const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes(input)
|
||||
expect(droppedWallIds).toEqual([])
|
||||
expect(strippedChildRefs).toBe(0)
|
||||
expect(nodes.wall_a).toBe(input.wall_a)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// Repairs scene-graph corruption that pre-dates the source fixes, so existing
|
||||
// saved scenes still load. Two known kinds of damage, both produced by the
|
||||
// capture wall-merge before it was fixed:
|
||||
//
|
||||
// 1. A `children` array containing a non-string entry. The merge re-attached a
|
||||
// wall-hosted item without minting an id, so `undefined` was pushed into the
|
||||
// wall's children — which serializes to `[null]`. The wall schema rejects
|
||||
// `null` children, so the whole scene fails to load.
|
||||
// 2. A zero-length wall (start === end). It renders nothing, but lingers as a
|
||||
// junk node and is a foot-gun for snapping/mitering.
|
||||
//
|
||||
// Both are also prevented at the source now (see merge-walls.ts and the wall
|
||||
// miter limit); this is the load-time safety net for already-saved scenes.
|
||||
|
||||
const ZERO_LENGTH_EPS = 1e-6
|
||||
|
||||
export interface HealSceneResult {
|
||||
nodes: Record<string, unknown>
|
||||
/** Ids of zero-length walls that were dropped. */
|
||||
droppedWallIds: string[]
|
||||
/** Count of non-string (e.g. null) entries removed from `children` arrays. */
|
||||
strippedChildRefs: number
|
||||
}
|
||||
|
||||
function isWallLike(node: unknown): node is { start: [number, number]; end: [number, number] } {
|
||||
if (!node || typeof node !== 'object') return false
|
||||
const n = node as Record<string, unknown>
|
||||
return (
|
||||
n.type === 'wall' &&
|
||||
Array.isArray(n.start) &&
|
||||
Array.isArray(n.end) &&
|
||||
typeof n.start[0] === 'number' &&
|
||||
typeof n.start[1] === 'number' &&
|
||||
typeof n.end[0] === 'number' &&
|
||||
typeof n.end[1] === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a healed copy of a `nodes` map. Pure — does not mutate `input`.
|
||||
* Nodes that need no repair are passed through by reference.
|
||||
*/
|
||||
export function healSceneNodes(input: Record<string, unknown>): HealSceneResult {
|
||||
const droppedWallIds: string[] = []
|
||||
|
||||
// Pass 1: drop childless zero-length walls. (Only childless ones — a wall
|
||||
// carrying a door/window must keep its hosts, degenerate or not.)
|
||||
const kept: Record<string, unknown> = {}
|
||||
for (const [id, node] of Object.entries(input)) {
|
||||
if (isWallLike(node)) {
|
||||
const children = (node as { children?: unknown }).children
|
||||
const childless = !Array.isArray(children) || children.length === 0
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
if (childless && Math.hypot(dx, dz) <= ZERO_LENGTH_EPS) {
|
||||
droppedWallIds.push(id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
kept[id] = node
|
||||
}
|
||||
|
||||
const dropped = new Set(droppedWallIds)
|
||||
let strippedChildRefs = 0
|
||||
|
||||
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]` bug)
|
||||
// and references to walls we just removed.
|
||||
const nodes: Record<string, unknown> = {}
|
||||
for (const [id, node] of Object.entries(kept)) {
|
||||
const children = (node as { children?: unknown })?.children
|
||||
if (Array.isArray(children)) {
|
||||
const cleaned = children.filter((c): c is string => typeof c === 'string' && !dropped.has(c))
|
||||
if (cleaned.length !== children.length) {
|
||||
strippedChildRefs += children.length - cleaned.length
|
||||
nodes[id] = { ...(node as Record<string, unknown>), children: cleaned }
|
||||
continue
|
||||
}
|
||||
}
|
||||
nodes[id] = node
|
||||
}
|
||||
|
||||
return { nodes, droppedWallIds, strippedChildRefs }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AnyNode, type AnyNodeType } from '../schema/types'
|
||||
import { healSceneNodes } from '../utils/heal-scene-graph'
|
||||
|
||||
export type ValidationSeverity = 'error' | 'warning'
|
||||
|
||||
@@ -127,9 +128,22 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = nodesRaw as Record<string, unknown>
|
||||
// Heal known pre-existing corruption (null children, zero-length walls) up
|
||||
// front, so a scene saved before the source fixes still imports instead of
|
||||
// hard-failing schema validation. `parsed` below carries the repaired nodes.
|
||||
const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes(
|
||||
nodesRaw as Record<string, unknown>,
|
||||
)
|
||||
const rootNodeIds = rootNodeIdsRaw as string[]
|
||||
|
||||
if (strippedChildRefs > 0 || droppedWallIds.length > 0) {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'auto_repaired',
|
||||
message: `Repaired on import: removed ${strippedChildRefs} invalid child reference${strippedChildRefs === 1 ? '' : 's'} and ${droppedWallIds.length} zero-length wall${droppedWallIds.length === 1 ? '' : 's'}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (rootNodeIds.length === 0) {
|
||||
errors.push({
|
||||
severity: 'error',
|
||||
|
||||
@@ -77,7 +77,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
||||
}
|
||||
|
||||
if (mode === 'material-paint') {
|
||||
return { kind: 'asset', iconSrc: '/icons/paint.png' }
|
||||
return { kind: 'asset', iconSrc: '/icons/paint.webp' }
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -48,6 +48,13 @@ export function FloorplanRegistryActionMenu() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
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)
|
||||
|
||||
@@ -56,7 +63,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
||||
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
||||
const isRegistryKind = !!def
|
||||
const isVisible = isRegistryKind && !movingNode
|
||||
const isVisible = isRegistryKind && !movingNode && isFloorplanHovered
|
||||
const isWall = selectedKind === 'wall'
|
||||
|
||||
useEffect(() => {
|
||||
@@ -191,6 +198,11 @@ export function FloorplanRegistryActionMenu() {
|
||||
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
||||
? (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 }
|
||||
const parsed = def.schema.parse(cloned) as AnyNode
|
||||
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
||||
|
||||
@@ -124,6 +124,14 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
// to be consumed. That legacy flow is gone in the registry layer;
|
||||
// all entries use the action menu now.
|
||||
let hasMovedSinceStart = false
|
||||
// Live cursor location — updated on EVERY pointermove (even over the 3D
|
||||
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
|
||||
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
|
||||
// the 2D pane the overlay claimed R forever and the 3D flip went dead.
|
||||
let pointerOverFloorplan = false
|
||||
const onPointerTrack = (event: PointerEvent) => {
|
||||
pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
// Skip 3D-canvas / other-UI cursor moves so the overlay only
|
||||
@@ -283,6 +291,33 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
}
|
||||
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
// R flips a directional kind's facing mid-placement (door / window:
|
||||
// front ↔ back). The session records the flip and re-runs its last
|
||||
// apply so the 2D symbol updates immediately; kinds without a facing
|
||||
// leave `flipSide` unset and R falls through to the global handler.
|
||||
//
|
||||
// Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
|
||||
// move tool ALSO listens for R on `window`. We own R only while the
|
||||
// cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
|
||||
// 2D mover has actually engaged (`hasMovedSinceStart`); then we
|
||||
// `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
|
||||
// first) so the 3D tool can't also flip. When the cursor is over the 3D
|
||||
// pane we yield — the 3D tool owns R there. (The old sticky
|
||||
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
|
||||
// the first 2D move, killing the 3D flip.)
|
||||
if (event.key === 'r' || event.key === 'R') {
|
||||
if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
|
||||
if (event.repeat) return
|
||||
const t = event.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
session.flipSide()
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Escape') return
|
||||
// Claim teardown ownership so the 3D move tool's cleanup skips
|
||||
// its own restore — without this, both sides would race to
|
||||
@@ -328,13 +363,18 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
setMovingNode(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onPointerTrack)
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('keydown', onKey)
|
||||
// Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
|
||||
// listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
|
||||
// then pre-empts it so only one handler flips.
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onPointerTrack)
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
// Unmount cleanup. `historyPaused === true` here means none of
|
||||
// our terminal paths (commit, Esc) ran in this overlay — they
|
||||
// each call `resumeSceneHistory` and flip the flag.
|
||||
@@ -389,11 +429,32 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
|
||||
if (!entry) return
|
||||
|
||||
const originalPosition = ((
|
||||
movingNode as unknown as {
|
||||
position?: [number, number, number]
|
||||
}
|
||||
).position ?? [0, 0, 0]) as [number, number, number]
|
||||
// Polyline kinds (duct / pipe / lineset) carry a `path`, not a
|
||||
// `position` — translating a `position` here would write a field their
|
||||
// schema ignores and snap the run back. For those we move every path
|
||||
// point by the cursor delta and commit the translated `path` instead.
|
||||
// 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(
|
||||
(movingNode as { metadata?: unknown }).metadata,
|
||||
)
|
||||
@@ -410,13 +471,34 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
const otherId = el.getAttribute('data-node-id')
|
||||
if (!otherId || otherId === movingNode.id) continue
|
||||
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))
|
||||
}
|
||||
|
||||
let lastSnapped: [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) => {
|
||||
// Same target guard as Path 1 — pointer must be over the floor
|
||||
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
|
||||
@@ -487,6 +569,7 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
const dx = finalX - originalPosition[0]
|
||||
const dz = finalZ - originalPosition[2]
|
||||
entry.setAttribute('transform', `translate(${dx} ${dz})`)
|
||||
boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
|
||||
lastSnapped = [finalX, finalZ]
|
||||
}
|
||||
|
||||
@@ -500,6 +583,33 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
const [, oldY] = originalPosition
|
||||
setMovingNodeOrigin('2d')
|
||||
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) {
|
||||
selectedId =
|
||||
commitFreshPlacementSubtree(
|
||||
@@ -552,6 +662,10 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
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()
|
||||
}
|
||||
}, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
|
||||
|
||||
+4
-1
@@ -27,6 +27,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
||||
*/
|
||||
export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
|
||||
const node = usePlacementPreview((s) => s.node)
|
||||
const parentNode = usePlacementPreview((s) => s.parentNode)
|
||||
if (!node) return null
|
||||
|
||||
const builder = nodeRegistry.get(node.type)?.floorplan
|
||||
@@ -37,11 +38,13 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
|
||||
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
|
||||
// and `parent: null` short-circuits the elevator's level walk) so the layer
|
||||
// never subscribes to / bulk-reads the nodes map during render.
|
||||
// `parentNode` is the synthetic wall for an off-wall door/window ghost so
|
||||
// its builder draws the real swing-arc / pane symbol (see use-placement-preview).
|
||||
const ctx = {
|
||||
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
parent: parentNode ?? null,
|
||||
viewState: undefined,
|
||||
} as unknown as GeometryContext
|
||||
|
||||
|
||||
@@ -506,7 +506,26 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
if (live) {
|
||||
const floorPlaced = def?.capabilities?.floorPlaced
|
||||
const hasPosition = Array.isArray((node as { position?: unknown }).position)
|
||||
if (floorPlaced && hasPosition) {
|
||||
if (node.type === 'door' || node.type === 'window') {
|
||||
// Door / window movers publish WALL-LOCAL live transforms
|
||||
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
|
||||
// wiki/architecture/tools.md. The mover only writes
|
||||
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
|
||||
// updates the 3D mesh imperatively but never the scene node —
|
||||
// without applying the live transform here the 2D symbol stays
|
||||
// frozen while the cursor slides. Merge the wall-local position +
|
||||
// rotation onto the node but KEEP `parentId` (the wall) so
|
||||
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
|
||||
// real swing-arc / pane symbol at the live spot.
|
||||
const r = (node as { rotation?: unknown }).rotation
|
||||
effectiveNode = {
|
||||
...node,
|
||||
position: live.position,
|
||||
rotation: Array.isArray(r)
|
||||
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
|
||||
: r,
|
||||
} as AnyNode
|
||||
} else if (floorPlaced && hasPosition) {
|
||||
effectiveNode = applyPositionLiveTransform(node, live)
|
||||
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
||||
const dx = live.position[0]
|
||||
@@ -1656,6 +1675,58 @@ function InteractiveGeometry({
|
||||
</g>
|
||||
)
|
||||
}
|
||||
case 'equal-spacing-badge': {
|
||||
// A distinct accent (Figma-style "=" rhythm) so equal spacing reads
|
||||
// apart from the orange placement dimensions. Same screen-upright flip
|
||||
// as the dimension-label case above.
|
||||
const accent = '#ec4899'
|
||||
let degrees = (g.angle * 180) / Math.PI
|
||||
let screenDegrees = degrees + sceneRotationDeg
|
||||
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
|
||||
if (screenDegrees > 90) degrees -= 180
|
||||
else if (screenDegrees <= -90) degrees += 180
|
||||
|
||||
const label = `= ${g.text}`
|
||||
const padX = unitsPerPixel * 6
|
||||
const padY = unitsPerPixel * 3
|
||||
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
||||
const textWidth = label.length * unitsPerPixel * 6.2
|
||||
const plateW = textWidth + padX * 2
|
||||
const plateH = fontSize + padY * 2
|
||||
return (
|
||||
<g
|
||||
key={keyHint}
|
||||
pointerEvents="none"
|
||||
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${degrees})`}
|
||||
>
|
||||
<rect
|
||||
fill="#ffffff"
|
||||
height={plateH}
|
||||
opacity={0.95}
|
||||
rx={unitsPerPixel * 3}
|
||||
ry={unitsPerPixel * 3}
|
||||
stroke={accent}
|
||||
strokeWidth={unitsPerPixel * 0.75}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
width={plateW}
|
||||
x={-plateW / 2}
|
||||
y={-plateH / 2}
|
||||
/>
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill={accent}
|
||||
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||
fontSize={fontSize}
|
||||
fontWeight={700}
|
||||
textAnchor="middle"
|
||||
x={0}
|
||||
y={0}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
case 'dimension': {
|
||||
if (!palette) return <></>
|
||||
const stroke = g.stroke ?? palette.measurementStroke
|
||||
@@ -1959,6 +2030,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
||||
'rotate-arrow',
|
||||
'dimension',
|
||||
'dimension-label',
|
||||
'equal-spacing-badge',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { collectLevelWallSegments, useScene, WALL_SNAP_DISTANCE_M } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { memo, useMemo } from 'react'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
/**
|
||||
* Dev-only 2D debug overlay for the opening (door / window) wall snap.
|
||||
*
|
||||
* The snap (`findClosestWallInPlan`) attaches to a wall when the cursor is
|
||||
* within `WALL_SNAP_DISTANCE_M` of the wall's centerline, picking the
|
||||
* nearest such wall. The set of points within that radius of a segment is a
|
||||
* **capsule** (stadium): a band of half-width = the snap radius along the
|
||||
* wall, with semicircular caps at each end. That capsule IS the wall's
|
||||
* (normally invisible) hit target — so this layer draws it directly, one
|
||||
* analytic `<path>` per wall, instead of sampling a grid (which produced the
|
||||
* stair-stepped boundary the previous version showed). No per-point
|
||||
* classification, so it's cheap regardless of plan size.
|
||||
*
|
||||
* Where two walls sit closer than 2× the radius their capsules overlap; the
|
||||
* snap resolves the overlap to the nearer wall (the translucent fills just
|
||||
* blend there — a darker patch reads as "either wall is in reach, nearest
|
||||
* wins"). Drawing the true bisector-clipped cells would need the expensive
|
||||
* per-point pass this rewrite removes, and the hit-area view is what the
|
||||
* user asked for.
|
||||
*
|
||||
* Gated on `useEditor.show2dVoronoi` (developer menu). Renders inside the
|
||||
* floor-plan scene `<g>`, so it shares the plan→SVG transform and the scene
|
||||
* rotation with every other floor-plan layer.
|
||||
*/
|
||||
|
||||
/** Stable hue per wall id so a wall keeps its colour across re-renders. */
|
||||
function wallHue(id: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0
|
||||
}
|
||||
// Spread hues around the wheel with an offset that avoids a muddy
|
||||
// red-orange clump for short, similar ids.
|
||||
return (hash * 47) % 360
|
||||
}
|
||||
|
||||
export const FloorplanVoronoiLayer = memo(function FloorplanVoronoiLayer() {
|
||||
const show2dVoronoi = useEditor((s) => s.show2dVoronoi)
|
||||
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
||||
// Recompute only when the wall geometry on this level changes — not on
|
||||
// every scene write. Dragging a door updates the door node every frame;
|
||||
// keying the build on this string means the capsule paths don't rebuild
|
||||
// for openings, only for wall edits.
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const wallsKey = useMemo(() => {
|
||||
if (!show2dVoronoi || !selectedLevelId) return ''
|
||||
const segments = collectLevelWallSegments(nodes, selectedLevelId)
|
||||
return segments
|
||||
.map((s) => `${s.wall.id}:${s.start[0]},${s.start[1]},${s.end[0]},${s.end[1]}`)
|
||||
.join('|')
|
||||
}, [show2dVoronoi, selectedLevelId, nodes])
|
||||
|
||||
const walls = useMemo(() => {
|
||||
if (!show2dVoronoi || !selectedLevelId || !wallsKey) return null
|
||||
// Read nodes imperatively: `wallsKey` already encodes every wall change,
|
||||
// so this memo is keyed on it rather than on the per-frame `nodes` ref.
|
||||
const segments = collectLevelWallSegments(useScene.getState().nodes, selectedLevelId)
|
||||
if (segments.length === 0) return null
|
||||
|
||||
const R = WALL_SNAP_DISTANCE_M
|
||||
const r = R.toFixed(3)
|
||||
return segments.map((s) => {
|
||||
// Capsule outline. Normal = dir rotated +90° = (-dirY, dirX). Offset the
|
||||
// segment endpoints ±R along the normal for the long sides, then a
|
||||
// semicircular cap (radius R, sweep-flag 0 bulges outward past each end)
|
||||
// joins them. Verified winding holds for every orientation because the
|
||||
// whole construction is a rigid transform of the axis-aligned case.
|
||||
const nx = -s.dirY
|
||||
const ny = s.dirX
|
||||
const ax = (s.start[0] + nx * R).toFixed(3)
|
||||
const ay = (s.start[1] + ny * R).toFixed(3)
|
||||
const bx = (s.end[0] + nx * R).toFixed(3)
|
||||
const by = (s.end[1] + ny * R).toFixed(3)
|
||||
const cx = (s.end[0] - nx * R).toFixed(3)
|
||||
const cy = (s.end[1] - ny * R).toFixed(3)
|
||||
const dx = (s.start[0] - nx * R).toFixed(3)
|
||||
const dy = (s.start[1] - ny * R).toFixed(3)
|
||||
const d = `M${ax} ${ay}L${bx} ${by}A${r} ${r} 0 0 0 ${cx} ${cy}L${dx} ${dy}A${r} ${r} 0 0 0 ${ax} ${ay}Z`
|
||||
return {
|
||||
wallId: s.wall.id,
|
||||
d,
|
||||
hue: wallHue(s.wall.id),
|
||||
x1: s.start[0],
|
||||
y1: s.start[1],
|
||||
x2: s.end[0],
|
||||
y2: s.end[1],
|
||||
}
|
||||
})
|
||||
}, [show2dVoronoi, selectedLevelId, wallsKey])
|
||||
|
||||
if (!walls) return null
|
||||
|
||||
return (
|
||||
<g className="floorplan-voronoi-debug" pointerEvents="none">
|
||||
{walls.map(({ wallId, d, hue }) => (
|
||||
<path
|
||||
d={d}
|
||||
fill={`hsla(${hue}, 80%, 55%, 0.22)`}
|
||||
key={`hit-${wallId}`}
|
||||
stroke={`hsl(${hue}, 85%, 50%)`}
|
||||
strokeOpacity={0.5}
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
{walls.map(({ wallId, hue, x1, y1, x2, y2 }) => (
|
||||
<line
|
||||
key={`line-${wallId}`}
|
||||
stroke={`hsl(${hue}, 85%, 42%)`}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={x1}
|
||||
x2={x2}
|
||||
y1={y1}
|
||||
y2={y2}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
sceneRegistry,
|
||||
summarizeSystemFor,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
WallNode,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||
@@ -41,6 +42,21 @@ import useEditor from '../../store/use-editor'
|
||||
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
||||
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 = [
|
||||
'item',
|
||||
'door',
|
||||
@@ -200,6 +216,8 @@ export function FloatingActionMenu() {
|
||||
// flips only at drag start / end, so subscribing here is cheap — the live
|
||||
// height value is written imperatively in the useFrame below.
|
||||
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 menuScaleRef = useRef<HTMLDivElement>(null)
|
||||
@@ -490,10 +508,26 @@ export function FloatingActionMenu() {
|
||||
// item without clicking" bug. (Item has its own
|
||||
// draft-committing move tool, so it must skip the generic
|
||||
// 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)) {
|
||||
// Registry-driven kinds: offset the position slightly so the
|
||||
// duplicate doesn't overlap exactly, then create + hand to the
|
||||
// move tool. Mirrors the roof-segment / stair-segment behavior.
|
||||
// Registry-driven kinds: offset slightly so the duplicate doesn't
|
||||
// overlap exactly, then create + hand to the move tool. Mirrors the
|
||||
// roof-segment / stair-segment behavior.
|
||||
if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
|
||||
const pos = (duplicate as { position: [number, number, number] }).position
|
||||
;(duplicate as { position: [number, number, number] }).position = [
|
||||
@@ -501,6 +535,12 @@ export function FloatingActionMenu() {
|
||||
pos[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)
|
||||
}
|
||||
@@ -643,9 +683,86 @@ export function FloatingActionMenu() {
|
||||
/>
|
||||
</div>
|
||||
) : 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>
|
||||
</Html>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
calculateLevelMiters,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
type DoorNode,
|
||||
DoorNode as DoorNodeSchema,
|
||||
type ElevatorNode,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
@@ -45,7 +46,9 @@ import {
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
WallNode as WallNodeSchema,
|
||||
type WindowNode,
|
||||
WindowNode as WindowNodeSchema,
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
type ZoneNode as ZoneNodeType,
|
||||
} from '@pascal-app/core'
|
||||
@@ -85,6 +88,7 @@ import { cn } from '../../lib/utils'
|
||||
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
||||
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
||||
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
|
||||
import usePlacementPreview from '../../store/use-placement-preview'
|
||||
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
|
||||
@@ -102,6 +106,7 @@ import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-
|
||||
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
|
||||
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
|
||||
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
||||
import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
|
||||
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import { snapToHalf } from '../tools/item/placement-math'
|
||||
@@ -5501,6 +5506,63 @@ export function FloorplanPanel({
|
||||
|
||||
return 0
|
||||
}, [isWindowBuildActive, movingNode, shiftPressed])
|
||||
// Float the faithful door/window symbol at the cursor while it isn't over a
|
||||
// wall (the off-wall placement ghost), by publishing a transient opening on a
|
||||
// synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer`
|
||||
// renders it through the real `def.floorplan` builder (swing arc / panes), so
|
||||
// it reads as a real door/window, not a bare rectangle. Off any wall there's
|
||||
// no orientation to inherit, so the synthetic wall runs along plan-X.
|
||||
const showOpeningGhost = useCallback(
|
||||
(planPoint: WallPlanPoint) => {
|
||||
const isDoor = movingOpeningType === 'door' || (isDoorBuildActive && !movingOpeningType)
|
||||
// Synthetic wall centred at the cursor; the opening sits at its midpoint.
|
||||
const half =
|
||||
(isDoor
|
||||
? movingNode?.type === 'door'
|
||||
? movingNode.width
|
||||
: 0.9
|
||||
: movingNode?.type === 'window'
|
||||
? movingNode.width
|
||||
: 1.5) /
|
||||
2 +
|
||||
0.5
|
||||
const wall = WallNodeSchema.parse({
|
||||
start: [planPoint[0] - half, planPoint[1]],
|
||||
end: [planPoint[0] + half, planPoint[1]],
|
||||
thickness: 0.1,
|
||||
})
|
||||
// Clone the moving opening (carries width / type / hinge / swing) onto the
|
||||
// synthetic wall, or parse a default for build mode. position[0] = the
|
||||
// along-wall midpoint so the symbol centres on the cursor.
|
||||
const base =
|
||||
movingNode?.type === 'door' || movingNode?.type === 'window'
|
||||
? { ...movingNode }
|
||||
: isDoor
|
||||
? DoorNodeSchema.parse({})
|
||||
: WindowNodeSchema.parse({})
|
||||
const ghost = {
|
||||
...base,
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
position: [half, floorplanOpeningLocalY, 0] as [number, number, number],
|
||||
rotation: [0, 0, 0] as [number, number, number],
|
||||
} as AnyNode
|
||||
usePlacementPreview.getState().set(ghost, wall)
|
||||
},
|
||||
[floorplanOpeningLocalY, isDoorBuildActive, movingNode, movingOpeningType],
|
||||
)
|
||||
// Drop the floating opening ghost whenever opening placement ends (commit,
|
||||
// tool change, mode switch, cancel) or the active level changes, so a stale
|
||||
// ghost never lingers on the wrong level.
|
||||
useEffect(() => {
|
||||
if (!isOpeningPlacementActive) usePlacementPreview.getState().clear()
|
||||
}, [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(() => {
|
||||
usePlacementPreview.getState().clear()
|
||||
}, [levelId])
|
||||
const isMarqueeSelectionToolActive =
|
||||
mode === 'select' &&
|
||||
floorplanSelectionTool === 'marquee' &&
|
||||
@@ -8568,7 +8630,16 @@ export function FloorplanPanel({
|
||||
// `wall:move` events the door / window placement tools listen for.
|
||||
// Same reason `handleBackgroundPlacementClick` runs its opening
|
||||
// branch before its grid catch-all.
|
||||
if (isOpeningPlacementActive) {
|
||||
//
|
||||
// Only the pure BUILD case (a door/window tool armed with no
|
||||
// `movingNode`) drives placement through these synthesized `wall:*`
|
||||
// events. When a door/window `movingNode` is set — the community
|
||||
// preset / catalog flow — `FloorplanRegistryMoveOverlay` owns 2D
|
||||
// placement end-to-end via `def.floorplanMoveTarget` (faithful symbol,
|
||||
// plan-space snap, single-undo commit, R-flip). Running both at once
|
||||
// made them fight (R-flip overwritten on the next move, click-commit
|
||||
// dropped), so the move case is excluded here.
|
||||
if (isOpeningBuildActive && !isOpeningMoveActive) {
|
||||
const closest = findClosestWallPoint(planPoint, walls, {
|
||||
canUseWall: (wall) => !isCurvedWall(wall),
|
||||
})
|
||||
@@ -8595,9 +8666,23 @@ export function FloorplanPanel({
|
||||
} else {
|
||||
emitter.emit('wall:move', wallEvent as any)
|
||||
}
|
||||
} else if (hoveredWallIdRef.current) {
|
||||
emitFloorplanWallLeave(hoveredWallIdRef.current)
|
||||
hoveredWallIdRef.current = null
|
||||
// Snapped to a wall — the real on-wall draft is the preview; drop
|
||||
// the loose free-follow ghost.
|
||||
usePlacementPreview.getState().clear()
|
||||
} else {
|
||||
if (hoveredWallIdRef.current) {
|
||||
emitFloorplanWallLeave(hoveredWallIdRef.current)
|
||||
hoveredWallIdRef.current = null
|
||||
}
|
||||
// Off any wall — float the FAITHFUL door/window symbol (swing arc /
|
||||
// panes) following the cursor, not a bare rectangle. The glyph
|
||||
// builder needs a wall for `ctx.parent`, so we publish the opening on
|
||||
// a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to
|
||||
// `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it
|
||||
// through the real `def.floorplan` builder. Shift bypasses grid snap.
|
||||
const snappedPoint =
|
||||
shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||
showOpeningGhost(snappedPoint)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -8622,7 +8707,13 @@ export function FloorplanPanel({
|
||||
// window are also registered kinds, but need wall events — see
|
||||
// comment there). Wall build skips this so its own branch below
|
||||
// updates local `draftEnd` state alongside the registry tool.
|
||||
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
|
||||
//
|
||||
// A door/window MOVE (community preset) is owned by
|
||||
// `FloorplanRegistryMoveOverlay`; `isRegistryToolBuildActive` is true
|
||||
// for it (build mode + a registered `door`/`window` tool), so without
|
||||
// this exclusion the catch-all would emit `grid:move` and re-drive the
|
||||
// 3D MoveDoorTool's free-follow, fighting the overlay again.
|
||||
if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
@@ -8715,7 +8806,14 @@ export function FloorplanPanel({
|
||||
isFenceBuildActive,
|
||||
isFloorplanGridInteractionActive,
|
||||
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,
|
||||
isRoofBuildActive,
|
||||
isSlabBuildActive,
|
||||
@@ -8969,8 +9067,15 @@ export function FloorplanPanel({
|
||||
isCeilingBuildActive,
|
||||
isCeilingItemPlacementActive,
|
||||
isFenceBuildActive,
|
||||
isFloorplanGridInteractionActive,
|
||||
isOpeningPlacementActive,
|
||||
// Exclude the door/window MOVE case: `isRegistryToolBuildActive` makes the
|
||||
// grid catch-all true for it, but the overlay owns its commit (its own
|
||||
// pointerup). Letting the catch-all emit `grid:click` here would consume
|
||||
// the commit click and fight the overlay.
|
||||
isFloorplanGridInteractionActive: isFloorplanGridInteractionActive && !isOpeningMoveActive,
|
||||
// Only the pure-build opening case (tool armed, no movingNode) commits via
|
||||
// the synthesized `wall:click`; the move case (community preset) is owned
|
||||
// by FloorplanRegistryMoveOverlay, which commits on its own pointerup.
|
||||
isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
|
||||
isPolygonBuildActive,
|
||||
isRoofBuildActive,
|
||||
isSlabBuildActive,
|
||||
@@ -10211,14 +10316,14 @@ export function FloorplanPanel({
|
||||
(compassHost ? (
|
||||
createPortal(
|
||||
<FloorplanCompassButton
|
||||
northRotationDeg={-floorplanUserRotationDeg}
|
||||
northRotationDeg={floorplanUserRotationDeg}
|
||||
onAlignNorth={alignFloorplanViewToNorth}
|
||||
/>,
|
||||
compassHost,
|
||||
)
|
||||
) : (
|
||||
<FloorplanCompassButton
|
||||
northRotationDeg={-floorplanUserRotationDeg}
|
||||
northRotationDeg={floorplanUserRotationDeg}
|
||||
onAlignNorth={alignFloorplanViewToNorth}
|
||||
/>
|
||||
))}
|
||||
@@ -10412,6 +10517,13 @@ export function FloorplanPanel({
|
||||
showGrid={showGrid}
|
||||
/>
|
||||
|
||||
{/* Dev-only: draw each wall's opening-snap hit area (the
|
||||
capsule of points within the snap radius of its centerline).
|
||||
Gated on the developer-menu toggle. Painted right after the
|
||||
grid so the translucent capsules sit under the wall / opening
|
||||
glyphs. */}
|
||||
<FloorplanVoronoiLayer />
|
||||
|
||||
<FloorplanReferenceFloorLayer
|
||||
data={referenceFloorData}
|
||||
opacity={referenceFloorOpacity}
|
||||
|
||||
@@ -12,12 +12,27 @@ import {
|
||||
DoubleSide,
|
||||
ExtrudeGeometry,
|
||||
type Group,
|
||||
type Intersection,
|
||||
Mesh,
|
||||
type Raycaster,
|
||||
Shape,
|
||||
TorusGeometry,
|
||||
} from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
// While a press-drag move is in flight (`placementDragMode`), the move tool
|
||||
// owns the pointer and the handle rig rides the moving node — so a handle hit
|
||||
// area would sit under the cursor and starve the tool's surface raycast
|
||||
// (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
|
||||
// Make every handle hit area inert for the duration; the indicator mesh still
|
||||
// renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
|
||||
function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
|
||||
if (useEditor.getState().placementDragMode) return
|
||||
Mesh.prototype.raycast.call(this, raycaster, intersects)
|
||||
}
|
||||
|
||||
export const ARROW_SCALE = 0.65
|
||||
export const ARROW_COLOR = '#8381ed'
|
||||
@@ -382,6 +397,7 @@ export function InvisibleHandleHitArea({
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
raycast={hitAreaRaycast}
|
||||
renderOrder={HIT_AREA_RENDER_ORDER}
|
||||
scale={scale}
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,7 @@ import useEditor from '../../store/use-editor'
|
||||
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-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 { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
@@ -61,6 +62,7 @@ import { Grid } from './grid'
|
||||
import { GroupMoveHandle } from './group-move-handle'
|
||||
import { GroupRotateHandle } from './group-rotate-handle'
|
||||
import { NodeArrowHandles } from './node-arrow-handles'
|
||||
import { RiserDiagramPanel } from './riser-diagram-panel'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { SlabHoleHighlights } from './slab-hole-highlights'
|
||||
@@ -573,7 +575,7 @@ function PaintCursorBadge({
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
||||
src="/icons/paint.png"
|
||||
src="/icons/paint.webp"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -605,11 +607,15 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
}) {
|
||||
// Studio mode is a clean render/snapshot surface — no selection or editing
|
||||
// affordances. It mirrors version-preview's chrome gating on the canvas.
|
||||
const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode
|
||||
// Capture (snapshot) mode is camera-only for the same reason: suppress
|
||||
// selection, editing handles, and the tool manager (which mounts the site
|
||||
// boundary flags) so the framed shot stays clean.
|
||||
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||
const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode || isCaptureMode
|
||||
return (
|
||||
<>
|
||||
<SceneEnvironment />
|
||||
{!(isFirstPersonMode || isStudioMode) && <SelectionManager />}
|
||||
{!(isFirstPersonMode || isStudioMode || isCaptureMode) && <SelectionManager />}
|
||||
{!noEditing && <BoxSelectTool />}
|
||||
{!noEditing && <NodeArrowHandles />}
|
||||
{!noEditing && <GroupRotateHandle />}
|
||||
@@ -624,6 +630,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
<CeilingSelectionAffordanceSystem />
|
||||
{!noEditing && <SelectionAffordanceManager />}
|
||||
<RoofEditSystem />
|
||||
<StairEditSystem />
|
||||
{!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
|
||||
@@ -1294,6 +1301,7 @@ export default function Editor({
|
||||
<div className="pointer-events-auto">
|
||||
<HelperManager />
|
||||
</div>
|
||||
<RiserDiagramPanel />
|
||||
{isFirstPersonMode && (
|
||||
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
||||
)}
|
||||
|
||||
@@ -23,11 +23,64 @@ const PART_ORDER: { key: MeasurePart; prefix: string }[] = [
|
||||
{ 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
|
||||
* the actively-dragged dimension emphasised. Styled to match the top-center
|
||||
* floating info bar (rounded-full, design-token colours) so it tracks the
|
||||
* app theme.
|
||||
* the actively-dragged dimension emphasised.
|
||||
*
|
||||
* 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
|
||||
@@ -52,24 +105,11 @@ export const MeasurementPill = forwardRef(function MeasurementPill(
|
||||
) {
|
||||
const values: Record<MeasurePart, number> = { height, length, thickness }
|
||||
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">
|
||||
{PART_ORDER.map((part, index) => (
|
||||
<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} ${formatMeasurement(values[part.key], unit)}`}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<DimensionPill
|
||||
parts={PART_ORDER.map((part) => ({ ...part, value: values[part.key] }))}
|
||||
primary={primary}
|
||||
primaryRef={primaryRef}
|
||||
unit={unit}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useOpeningGuides from '../../store/use-opening-guides'
|
||||
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
||||
import {
|
||||
@@ -70,6 +71,10 @@ const _resizePositionW = new Vector3()
|
||||
const _resizeRay = new Ray()
|
||||
const _resizeRayW = new Vector3()
|
||||
|
||||
// Tilt that stands a flat XZ-plane move cross up into a node's facing plane
|
||||
// (its local XY = a wall face) for `plane: 'node-normal'` handles.
|
||||
const NODE_NORMAL_TILT: [number, number, number] = [Math.PI / 2, 0, 0]
|
||||
|
||||
function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) {
|
||||
target.set(0, 0, 0)
|
||||
if (axis === 'x') target.x = 1
|
||||
@@ -589,6 +594,9 @@ function LinearArrow({
|
||||
// floating dimension pill (via `activeHandleDrag`) and its own in-world
|
||||
// chip is suppressed — matches the wall height handle.
|
||||
const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined
|
||||
// Optional per-tick feedback hook (doors/windows publish proximity/sill guides
|
||||
// for the edge being resized); cleared when the drag ends.
|
||||
const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
|
||||
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
||||
const basePosition = descriptor.placement.position(node, placementSceneApi)
|
||||
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
|
||||
@@ -671,6 +679,7 @@ function LinearArrow({
|
||||
if (measureLabel) {
|
||||
useEditor.getState().setActiveHandleDrag(null)
|
||||
}
|
||||
if (onDrag) useOpeningGuides.getState().clear()
|
||||
},
|
||||
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
|
||||
const currentPointer =
|
||||
@@ -686,7 +695,10 @@ function LinearArrow({
|
||||
? snapScalar(rawNext, gridSnapStep)
|
||||
: rawNext
|
||||
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
|
||||
return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
||||
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
||||
// Let the kind publish live guides for the edge being resized.
|
||||
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
|
||||
return patch
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -1230,7 +1242,7 @@ function TranslateArrow({
|
||||
|
||||
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
|
||||
// it lies in the item-local XY plane (= the wall face).
|
||||
const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0]
|
||||
const iconRotation: [number, number, number] = isWallPlane ? NODE_NORMAL_TILT : [0, 0, 0]
|
||||
|
||||
return (
|
||||
<HandleArrow
|
||||
@@ -1288,16 +1300,20 @@ function TapActionArrow({
|
||||
)
|
||||
}
|
||||
|
||||
// Default 'arrow' shape — the standard chevron.
|
||||
const baseScale = zoom * ARROW_SCALE
|
||||
// A `move-cross` with `plane: 'node-normal'` stands up into the node's facing
|
||||
// plane (a wall face) like the door / window / wall-item move grips; other
|
||||
// tap-actions keep their in-plane `rotationY`.
|
||||
const rotation: [number, number, number] =
|
||||
descriptor.plane === 'node-normal' ? NODE_NORMAL_TILT : [0, rotationY, 0]
|
||||
return (
|
||||
<HandleArrow
|
||||
cursor={cursor}
|
||||
hover={isHovered}
|
||||
onHoverChange={setIsHovered}
|
||||
onPointerDown={onActivate}
|
||||
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
||||
shape="chevron"
|
||||
placement={{ position, rotation, baseScale }}
|
||||
shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { memo, useEffect, useLayoutEffect, useMemo } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import useOpeningGuides, {
|
||||
type OpeningGuide3D,
|
||||
type OpeningGuideVec3,
|
||||
} from '../../store/use-opening-guides'
|
||||
import { formatMeasurement } from './measurement-pill'
|
||||
|
||||
const DIMENSION_COLOR = 0x81_8c_f8 // indigo — a neutral measurement
|
||||
const ALIGN_COLOR = 0xef_44_44 // red — a snapped alignment (matches the 2D guide accent)
|
||||
const DIMENSION_PILL = '#6366f1'
|
||||
const BADGE_PILL = '#ec4899' // pink — matches the 2D equal-spacing badge
|
||||
|
||||
// Shared depth-test-off materials so the guides read on top of the wall and
|
||||
// don't rebuild GPU buffers as guides churn during a drag.
|
||||
const dimensionMaterial = new LineBasicNodeMaterial({
|
||||
color: DIMENSION_COLOR,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
})
|
||||
const alignMaterial = new LineBasicNodeMaterial({
|
||||
color: ALIGN_COLOR,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
})
|
||||
|
||||
const mid = (a: OpeningGuideVec3, b: OpeningGuideVec3): OpeningGuideVec3 => [
|
||||
(a[0] + b[0]) / 2,
|
||||
(a[1] + b[1]) / 2,
|
||||
(a[2] + b[2]) / 2,
|
||||
]
|
||||
|
||||
/**
|
||||
* Wall-plane proximity / alignment guides for the 3D editor — the spatial twin
|
||||
* of the floor-plan placement dimensions + equal-spacing badges. Subscribes to
|
||||
* `useOpeningGuides` (published by the door/window move, placement, and resize
|
||||
* interactions each drag tick) and draws sill/head + edge-proximity dimensions, a sill-alignment line, and
|
||||
* equal-spacing badges. Coordinates are already in the move tool's render frame
|
||||
* (the producer reuses the cursor's `wallLocalToWorld`, so they share the cursor's
|
||||
* building-local frame), so this layer mounts beside `Alignment3DGuideLayer` and
|
||||
* renders them as-is.
|
||||
*/
|
||||
export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() {
|
||||
const guides = useOpeningGuides((s) => s.guides)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
if (guides.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
{guides.map((guide) => (
|
||||
<OpeningGuide guide={guide} key={guide.id} unit={unit} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' | 'imperial' }) {
|
||||
if (guide.kind === 'badge') {
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
position={guide.at}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
<div
|
||||
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-sans font-semibold text-[11px] text-white"
|
||||
style={{ backgroundColor: BADGE_PILL }}
|
||||
>
|
||||
{`= ${formatMeasurement(guide.value, unit)}`}
|
||||
</div>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
const material = guide.kind === 'align-line' ? alignMaterial : dimensionMaterial
|
||||
return (
|
||||
<>
|
||||
<GuideSegment from={guide.from} material={material} to={guide.to} />
|
||||
{guide.kind === 'dimension' ? (
|
||||
<Html
|
||||
center
|
||||
position={mid(guide.from, guide.to)}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
<div
|
||||
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
|
||||
style={{ backgroundColor: DIMENSION_PILL }}
|
||||
>
|
||||
{formatMeasurement(guide.value, unit)}
|
||||
</div>
|
||||
</Html>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GuideSegment({
|
||||
from,
|
||||
to,
|
||||
material,
|
||||
}: {
|
||||
from: OpeningGuideVec3
|
||||
to: OpeningGuideVec3
|
||||
material: LineBasicNodeMaterial
|
||||
}) {
|
||||
// Build the THREE.Line once with a preallocated 2-point position buffer and
|
||||
// mount it via <primitive> (the intrinsic <line> JSX element collides with
|
||||
// React's SVG <line>). `material` is a module-level constant, so this memo
|
||||
// runs exactly once per mounted slot; subsequent drag ticks mutate the
|
||||
// existing buffer in place via the layout effect below rather than rebuilding
|
||||
// the geometry, line, and GPU buffer every frame.
|
||||
const { line, position } = useMemo(() => {
|
||||
const position = new Float32BufferAttribute(new Float32Array(6), 3)
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', position)
|
||||
const line = new ThreeLine(geometry, material)
|
||||
line.frustumCulled = false
|
||||
line.layers.set(EDITOR_LAYER)
|
||||
line.renderOrder = 1000
|
||||
return { line, position }
|
||||
}, [material])
|
||||
|
||||
const [fx, fy, fz] = from
|
||||
const [tx, ty, tz] = to
|
||||
useLayoutEffect(() => {
|
||||
position.setXYZ(0, fx, fy, fz)
|
||||
position.setXYZ(1, tx, ty, tz)
|
||||
position.needsUpdate = true
|
||||
}, [position, fx, fy, fz, tx, ty, tz])
|
||||
|
||||
useEffect(() => () => line.geometry.dispose(), [line])
|
||||
return <primitive object={line} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1104,7 +1104,11 @@ export const SelectionManager = () => {
|
||||
}
|
||||
|
||||
const onEnter = (event: NodeEvent) => {
|
||||
if (boxSelectHandled) return
|
||||
// A host-driven drag (handle resize/rotate) sets `inputDragging`.
|
||||
// useNodeEvents now emits hover events during such a drag so surface
|
||||
// move tools keep tracking the cursor — but paint preview must not fire
|
||||
// mid-drag, so gate on `inputDragging` here too.
|
||||
if (boxSelectHandled || useViewer.getState().inputDragging) return
|
||||
|
||||
const interaction = getPaintInteraction(event)
|
||||
if (!interaction) return
|
||||
@@ -1676,6 +1680,11 @@ export const SelectionManager = () => {
|
||||
if (movingNode || curvingWall || curvingFence) return
|
||||
|
||||
const onEnter = (event: NodeEvent) => {
|
||||
// A host-driven drag (handle resize/rotate, box-select) sets
|
||||
// `inputDragging`. useNodeEvents still emits hover events during it so
|
||||
// surface move tools keep tracking — but the select-hover outline must
|
||||
// stay put, so don't repaint under the cursor mid-drag.
|
||||
if (useViewer.getState().inputDragging) return
|
||||
const node = event.node
|
||||
const currentPhase = useEditor.getState().phase
|
||||
|
||||
@@ -1703,6 +1712,7 @@ export const SelectionManager = () => {
|
||||
}
|
||||
|
||||
const onLeave = (event: NodeEvent) => {
|
||||
if (useViewer.getState().inputDragging) return
|
||||
const nodeId = event?.node?.id
|
||||
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
|
||||
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
|
||||
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import usePlacementPreview from '../../store/use-placement-preview'
|
||||
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||
@@ -152,6 +153,9 @@ export function useFloorplanBackgroundPlacement({
|
||||
stopPropagation: () => {},
|
||||
} as any)
|
||||
}
|
||||
// Drop the off-wall ghost on commit so it doesn't linger at the
|
||||
// just-placed spot before the next pointer move re-evaluates.
|
||||
usePlacementPreview.getState().clear()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,9 @@ export const ZoneSystem = () => {
|
||||
const selectedLevelId = useViewer.getState().selection.levelId
|
||||
const selectedZoneId = useViewer.getState().selection.zoneId
|
||||
const hoveredId = useViewer.getState().hoveredId
|
||||
// Snapshot capture is a clean, camera-only surface — never show zone
|
||||
// geometry or the HTML zone tags in the framed shot.
|
||||
const isCaptureMode = useEditor.getState().isCaptureMode
|
||||
|
||||
const zoneGeometryVisible = structureLayer === 'zones'
|
||||
const zones = sceneRegistry.byType.zone || new Set()
|
||||
@@ -35,8 +38,14 @@ export const ZoneSystem = () => {
|
||||
// Keep group visible (so <Html> labels stay active), hide/show meshes only.
|
||||
// Show meshes when: in zone mode, selected, or delete-hovered.
|
||||
if (!obj.visible) obj.visible = true
|
||||
const meshVisible = zoneGeometryVisible || isSelected || isDeleteHovered
|
||||
const targetOpacity = isSelected || isDeleteHovered ? 1 : zoneGeometryVisible ? 1 : 0
|
||||
const meshVisible = !isCaptureMode && (zoneGeometryVisible || isSelected || isDeleteHovered)
|
||||
const targetOpacity = isCaptureMode
|
||||
? 0
|
||||
: isSelected || isDeleteHovered
|
||||
? 1
|
||||
: zoneGeometryVisible
|
||||
? 1
|
||||
: 0
|
||||
|
||||
const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
|
||||
if (walls) {
|
||||
@@ -73,8 +82,9 @@ export const ZoneSystem = () => {
|
||||
obj.userData.__raycastDisabled = true
|
||||
}
|
||||
|
||||
// Labels: always visible on the current level (regardless of mode)
|
||||
const showLabel = !!selectedLevelId && isOnSelectedLevel
|
||||
// Labels: visible on the current level (regardless of mode), but never
|
||||
// during snapshot capture.
|
||||
const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel
|
||||
const labelOpacity = showLabel ? '1' : '0'
|
||||
const labelEl = document.getElementById(`${zoneId}-label`)
|
||||
if (labelEl && labelEl.style.opacity !== labelOpacity) {
|
||||
|
||||
@@ -9,14 +9,17 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
|
||||
/**
|
||||
* MoveTool dispatcher. Routes to (in order):
|
||||
*
|
||||
* 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
|
||||
* declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
|
||||
* …).
|
||||
* 2. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
|
||||
* via `getRegistryAffordanceTool`. Covers both generic movers
|
||||
* (slab / ceiling / wall / fence / column / item / door / window) and
|
||||
* the bespoke roof / roof-segment / stair / stair-segment / building
|
||||
* movers ported into `@pascal-app/nodes`.
|
||||
* 1. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
|
||||
* via `getRegistryAffordanceTool`. Covers generic movers
|
||||
* (slab / ceiling / wall / fence / column / item / door / window), the
|
||||
* bespoke roof / roof-segment / stair / stair-segment / building
|
||||
* movers, and the polyline / fitting ghost-placement movers
|
||||
* (duct-segment / duct-fitting). A kind that ships its own mover wins
|
||||
* even if it also declares `capabilities.movable` (duct-fitting keeps
|
||||
* `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
|
||||
* mover hasn't been ported to a kind-owned affordance yet.
|
||||
*/
|
||||
@@ -29,9 +32,6 @@ export const MoveTool: React.FC<{
|
||||
if (!movingNode) return null
|
||||
|
||||
const def = nodeRegistry.get(movingNode.type)
|
||||
if (def?.capabilities?.movable) {
|
||||
return <MoveRegistryNodeTool node={movingNode} />
|
||||
}
|
||||
|
||||
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
|
||||
if (RegistryMove) {
|
||||
@@ -42,6 +42,10 @@ export const MoveTool: React.FC<{
|
||||
)
|
||||
}
|
||||
|
||||
if (def?.capabilities?.movable) {
|
||||
return <MoveRegistryNodeTool node={movingNode} />
|
||||
}
|
||||
|
||||
if (movingNode.type === 'elevator')
|
||||
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
|
||||
return null
|
||||
|
||||
@@ -192,8 +192,10 @@ export interface PlacementCoordinatorConfig {
|
||||
initialState?: PlacementState
|
||||
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
|
||||
defaultScale?: [number, number, number]
|
||||
/** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */
|
||||
preserveFloorDragOffset?: boolean
|
||||
/** Move-mode sessions keep the grabbed item offset from the first surface hit
|
||||
* (floor / wall / ceiling / item-surface / shelf) instead of snapping the
|
||||
* item's origin under the cursor. */
|
||||
preserveDragOffset?: boolean
|
||||
}
|
||||
|
||||
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
||||
@@ -461,6 +463,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
||||
}
|
||||
|
||||
const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
|
||||
}
|
||||
|
||||
const applyTransition = (result: TransitionResult) => {
|
||||
// Alignment guides are floor-only; clear them when the cursor moves
|
||||
// onto a wall / ceiling / item surface (only those paths call this).
|
||||
@@ -528,11 +536,95 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
// ---- Init draft ----
|
||||
configRef.current.initDraft(gridPosition.current)
|
||||
const preserveFloorDragOffset =
|
||||
configRef.current.preserveFloorDragOffset === true &&
|
||||
placementState.current.surface === 'floor' &&
|
||||
!asset.attachTo
|
||||
const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null
|
||||
const preserveDragOffset = configRef.current.preserveDragOffset === true
|
||||
const relativeFloorStart =
|
||||
preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
|
||||
? gridPosition.current.clone()
|
||||
: null
|
||||
|
||||
// Grab anchors for the non-floor surfaces. Each captures the cursor's
|
||||
// surface-local position and the item's stored position on the first move
|
||||
// for a given host, then offsets every later move by
|
||||
// `start + (raw - anchor)` — mirroring the floor path and the door/window
|
||||
// move tools so the item tracks the grabbed point instead of teleporting
|
||||
// its origin under the cursor. Reset on host change (re-seeded from the
|
||||
// item's then-current position) by the surface leave handlers.
|
||||
let wallDragAnchor: {
|
||||
wallId: string
|
||||
rawX: number
|
||||
rawY: number
|
||||
startX: number
|
||||
startY: number
|
||||
} | null = null
|
||||
let ceilingDragAnchor: {
|
||||
ceilingId: string
|
||||
rawX: number
|
||||
rawZ: number
|
||||
startX: number
|
||||
startZ: number
|
||||
} | null = null
|
||||
let hostSurfaceDragAnchor: {
|
||||
hostId: string
|
||||
rawX: number
|
||||
rawZ: number
|
||||
startX: number
|
||||
startZ: number
|
||||
} | null = null
|
||||
|
||||
// Item-surface / shelf moves snap from a WORLD cursor hit projected into the
|
||||
// host's local frame. Re-project the offset-corrected local point back to
|
||||
// world so the strategy (which re-derives both the stored position and the
|
||||
// visual cursor from `event.position`) stays self-consistent.
|
||||
const resolveHostSurfaceWorld = (
|
||||
hostId: string,
|
||||
worldPos: readonly [number, number, number],
|
||||
): [number, number, number] | null => {
|
||||
const draft = draftNode.current
|
||||
const hostMesh = sceneRegistry.nodes.get(hostId)
|
||||
if (!(preserveDragOffset && draft && hostMesh)) return null
|
||||
const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
|
||||
if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
|
||||
hostSurfaceDragAnchor = {
|
||||
hostId,
|
||||
rawX: rawLocal.x,
|
||||
rawZ: rawLocal.z,
|
||||
startX: draft.position[0],
|
||||
startZ: draft.position[2],
|
||||
}
|
||||
}
|
||||
const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
|
||||
const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ)
|
||||
const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ))
|
||||
return [world.x, world.y, world.z]
|
||||
}
|
||||
|
||||
// Floor grab-offset: the item tracks the grabbed point instead of snapping
|
||||
// its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid
|
||||
// (`event.position`) on its default path and only reads `event.localPosition`
|
||||
// under Shift, so both frames must carry the offset; the world point is
|
||||
// derived from the corrected local one so the two stay consistent.
|
||||
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
|
||||
if (relativeFloorStart === null) return event
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
const anchor = floorDragAnchor ?? [rawX, rawZ]
|
||||
floorDragAnchor = anchor
|
||||
const correctedLocal: [number, number, number] = [
|
||||
relativeFloorStart.x + (rawX - anchor[0]),
|
||||
event.localPosition[1],
|
||||
relativeFloorStart.z + (rawZ - anchor[1]),
|
||||
]
|
||||
const correctedWorld = buildingLocalToWorld(
|
||||
correctedLocal[0],
|
||||
correctedLocal[1],
|
||||
correctedLocal[2],
|
||||
)
|
||||
return {
|
||||
...event,
|
||||
position: [correctedWorld.x, event.position[1], correctedWorld.z],
|
||||
localPosition: correctedLocal,
|
||||
}
|
||||
}
|
||||
|
||||
// Sync cursor to the draft mesh's world position and rotation
|
||||
if (draftNode.current) {
|
||||
@@ -656,23 +748,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
detachItemSurfaceToFloor(event as unknown as ItemEvent)
|
||||
}
|
||||
|
||||
const floorEvent =
|
||||
relativeFloorStart !== null
|
||||
? (() => {
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
const anchor = floorDragAnchor ?? [rawX, rawZ]
|
||||
floorDragAnchor = anchor
|
||||
return {
|
||||
...event,
|
||||
localPosition: [
|
||||
relativeFloorStart.x + (rawX - anchor[0]),
|
||||
event.localPosition[1],
|
||||
relativeFloorStart.z + (rawZ - anchor[1]),
|
||||
] as [number, number, number],
|
||||
}
|
||||
})()
|
||||
: event
|
||||
const floorEvent = applyFloorGrabOffset(event)
|
||||
|
||||
lastRawPos.current.set(
|
||||
floorEvent.localPosition[0],
|
||||
@@ -865,7 +941,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return
|
||||
}
|
||||
|
||||
const result = wallStrategy.move(ctx, event, getActiveValidators())
|
||||
let wallMoveEvent = event
|
||||
if (preserveDragOffset && draftNode.current) {
|
||||
const rawX = event.localPosition[0]
|
||||
const rawY = event.localPosition[1]
|
||||
if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
|
||||
wallDragAnchor = {
|
||||
wallId: event.node.id,
|
||||
rawX,
|
||||
rawY,
|
||||
startX: draftNode.current.position[0],
|
||||
startY: draftNode.current.position[1],
|
||||
}
|
||||
}
|
||||
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
|
||||
const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
|
||||
const wallMesh = sceneRegistry.nodes.get(event.node.id)
|
||||
// Derive the world cursor from the corrected wall-local point so the
|
||||
// visual cursor (world) and the stored position (wall-local) agree; if
|
||||
// the wall mesh is somehow absent, keep the raw world hit unchanged.
|
||||
const correctedWorld = wallMesh
|
||||
? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
|
||||
: null
|
||||
wallMoveEvent = {
|
||||
...event,
|
||||
localPosition: [correctedX, correctedY, event.localPosition[2]],
|
||||
position: correctedWorld
|
||||
? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
|
||||
: event.position,
|
||||
}
|
||||
}
|
||||
const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -962,6 +1068,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
|
||||
const onWallLeave = (event: WallEvent) => {
|
||||
wallDragAnchor = null
|
||||
const result = wallStrategy.leave(getContext())
|
||||
if (!result) return
|
||||
|
||||
@@ -1133,6 +1240,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// ---- Item Surface Handlers ----
|
||||
|
||||
const detachItemSurfaceToFloor = (event: ItemEvent) => {
|
||||
hostSurfaceDragAnchor = null
|
||||
const buildingLocalPoint = worldToBuildingLocal(
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
@@ -1233,8 +1341,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return
|
||||
}
|
||||
|
||||
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
||||
const result = itemSurfaceStrategy.move(ctx, event)
|
||||
const surfaceWorld =
|
||||
ctx.state.surfaceItemId !== null
|
||||
? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position)
|
||||
: null
|
||||
const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event
|
||||
lastRawPos.current.set(
|
||||
itemMoveEvent.position[0],
|
||||
itemMoveEvent.position[1],
|
||||
itemMoveEvent.position[2],
|
||||
)
|
||||
const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -1428,8 +1545,34 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return
|
||||
}
|
||||
|
||||
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
||||
const result = ceilingStrategy.move(getContext(), event)
|
||||
let ceilingMoveEvent = event
|
||||
if (preserveDragOffset && draftNode.current) {
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
|
||||
ceilingDragAnchor = {
|
||||
ceilingId: event.node.id,
|
||||
rawX,
|
||||
rawZ,
|
||||
startX: draftNode.current.position[0],
|
||||
startZ: draftNode.current.position[2],
|
||||
}
|
||||
}
|
||||
ceilingMoveEvent = {
|
||||
...event,
|
||||
localPosition: [
|
||||
ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX),
|
||||
event.localPosition[1],
|
||||
ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ),
|
||||
],
|
||||
}
|
||||
}
|
||||
lastRawPos.current.set(
|
||||
ceilingMoveEvent.localPosition[0],
|
||||
ceilingMoveEvent.localPosition[1],
|
||||
ceilingMoveEvent.localPosition[2],
|
||||
)
|
||||
const result = ceilingStrategy.move(getContext(), ceilingMoveEvent)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -1493,6 +1636,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
|
||||
const onCeilingLeave = (event: CeilingEvent) => {
|
||||
ceilingDragAnchor = null
|
||||
const result = ceilingStrategy.leave(getContext())
|
||||
if (!result) return
|
||||
|
||||
@@ -1566,7 +1710,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
return
|
||||
}
|
||||
const result = shelfSurfaceStrategy.move(ctx, event)
|
||||
const shelfWorld =
|
||||
ctx.state.shelfId !== null
|
||||
? resolveHostSurfaceWorld(ctx.state.shelfId, event.position)
|
||||
: null
|
||||
const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event
|
||||
const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
@@ -1905,6 +2054,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const draft = draftNode.current
|
||||
if (!(draft && viewerLevelId) || asset.attachTo) return
|
||||
if (draft.parentId === viewerLevelId) return
|
||||
// A non-attach item resting on a host surface (table / counter / shelf) is
|
||||
// intentionally parented to that host while it's moved — the surface move
|
||||
// handlers keep it hosted and the commit writes the host parent back. Only
|
||||
// free floor items get re-homed to the level here; yanking a hosted item
|
||||
// onto the level would re-interpret its host-local position in level space
|
||||
// and float the dragged mesh off the host toward the building origin.
|
||||
const draftParent = draft.parentId
|
||||
? useScene.getState().nodes[draft.parentId as AnyNodeId]
|
||||
: undefined
|
||||
if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
|
||||
draft.parentId = viewerLevelId
|
||||
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
||||
}, [viewerLevelId, draftNode, asset])
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../three-types'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
collectAlignmentAnchors,
|
||||
type EventSuffix,
|
||||
emitter,
|
||||
@@ -12,9 +13,12 @@ import {
|
||||
movingFootprintAnchors,
|
||||
type NodeEvent,
|
||||
nodeRegistry,
|
||||
type PortConnectivity,
|
||||
resolveAlignment,
|
||||
resolveConnectivityUpdates,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -44,6 +48,65 @@ const snapToGridStep = (value: number) => {
|
||||
/** 45° steps, matching the GLB item placement rotation. */
|
||||
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
|
||||
* 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. */
|
||||
@@ -145,6 +208,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
|
||||
// and committed to the scene on drop.
|
||||
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
|
||||
// (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 { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
|
||||
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
|
||||
// can't read React state without stale closures.
|
||||
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))
|
||||
|
||||
// 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,
|
||||
)
|
||||
|
||||
// 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 rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
@@ -286,7 +416,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
cursor: [rawX, rawZ],
|
||||
original: [originalPosition[0], originalPosition[2]],
|
||||
anchor: dragAnchorRef.current,
|
||||
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
|
||||
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
|
||||
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
|
||||
})
|
||||
dragAnchorRef.current = resolved.anchor
|
||||
@@ -313,6 +443,23 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
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) {
|
||||
// Build the preview node at the ORIGINAL position but with the LIVE
|
||||
// rotation so `def.ports` reflects any mid-drag R/T rotation. Without
|
||||
// this the snap solver mates the pre-rotation collar and commit then
|
||||
// writes the rotated node offset from the port it visually snapped to.
|
||||
const snapNode = buildPreviewNode(originalPosition, rotationRef.current)
|
||||
const mated = resolvePortSnap(snapNode, [x, z], portSnapConfig)
|
||||
if (mated) {
|
||||
x = mated[0]
|
||||
z = mated[1]
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}
|
||||
|
||||
const position: [number, number, number] = [x, originalPosition[1], z]
|
||||
const visualPosition = getVisualPosition(position)
|
||||
hasMovedRef.current = true
|
||||
@@ -337,6 +484,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
rotation: rotationRef.current,
|
||||
})
|
||||
markMovedNodeDirty()
|
||||
// Carry connected ductwork along (preview only — committed on drop).
|
||||
previewConnectivity(position, rotationRef.current)
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) {
|
||||
@@ -403,8 +552,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
committedId = finalId
|
||||
}
|
||||
} 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.getState().updateNode(node.id, data)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
|
||||
useScene.temporal.getState().pause()
|
||||
committed = true
|
||||
}
|
||||
@@ -430,6 +589,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// canonical position, then restamp the lifted presentation Y for the
|
||||
// current frame.
|
||||
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)
|
||||
if (mesh) {
|
||||
mesh.position.set(...visualPosition)
|
||||
@@ -491,6 +653,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
rotation: rotationRef.current,
|
||||
})
|
||||
markMovedNodeDirty()
|
||||
// Rotating the fitting swings its collars — connected ducts follow.
|
||||
previewConnectivity(position, rotationRef.current)
|
||||
// Rotation changes the footprint's collision span — re-check validity.
|
||||
recomputeValidity()
|
||||
}
|
||||
@@ -533,6 +697,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
clearConnectivityOverrides()
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
} else {
|
||||
@@ -570,6 +735,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
||||
if (!(committed || isNew || finalisedBy2D)) {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
clearConnectivityOverrides()
|
||||
sceneRegistry.nodes
|
||||
.get(node.id)
|
||||
?.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
||||
@@ -579,6 +745,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
}
|
||||
}, [
|
||||
boxDimensions,
|
||||
cursorAttached,
|
||||
portSnapConfig,
|
||||
exitMoveMode,
|
||||
isFreshPlacement,
|
||||
node,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { type ComponentType, lazy, Suspense } from 'react'
|
||||
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
||||
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
|
||||
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
|
||||
import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
|
||||
import { ElevatorTool } from './elevator/elevator-tool'
|
||||
import { MoveTool } from './item/move-tool'
|
||||
@@ -283,6 +284,9 @@ export const ToolManager: React.FC = () => {
|
||||
tools above. Lives inside the building-local group so the
|
||||
building-local guide coords render at the right world position. */}
|
||||
<Alignment3DGuideLayer />
|
||||
{/* Wall-plane proximity / sill / equal-spacing guides for openings,
|
||||
published by the door/window move tools in the same world frame. */}
|
||||
<OpeningGuides3DLayer />
|
||||
{/* "Magnetic" beacon at the active wall-draft snap point. */}
|
||||
<WallSnapBeaconLayer />
|
||||
</group>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
||||
alt="Orbit Left"
|
||||
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
src="/icons/rotate.webp"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
@@ -50,7 +50,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
||||
alt="Orbit Right"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
src="/icons/rotate.webp"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
@@ -69,7 +69,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
||||
alt="Top View"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/topview.png"
|
||||
src="/icons/topview.webp"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
@@ -24,7 +24,7 @@ type ControlConfig = {
|
||||
const controls: ControlConfig[] = [
|
||||
{
|
||||
id: 'select',
|
||||
imageSrc: '/icons/select.png',
|
||||
imageSrc: '/icons/select.webp',
|
||||
label: 'Select',
|
||||
shortcut: 'V',
|
||||
color: 'hover:bg-blue-500/20 hover:text-blue-400',
|
||||
@@ -32,7 +32,7 @@ const controls: ControlConfig[] = [
|
||||
},
|
||||
{
|
||||
id: 'zone',
|
||||
imageSrc: '/icons/zone.png',
|
||||
imageSrc: '/icons/zone.webp',
|
||||
label: 'Zone',
|
||||
shortcut: 'Z',
|
||||
color: 'hover:bg-green-500/20 hover:text-green-400',
|
||||
|
||||
@@ -8,9 +8,9 @@ export type FurnishToolConfig = {
|
||||
}
|
||||
|
||||
export const furnishTools: FurnishToolConfig[] = [
|
||||
{ id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' },
|
||||
{ id: 'item', iconSrc: '/icons/appliance.png', label: 'Appliance', catalogCategory: 'appliance' },
|
||||
{ id: 'item', iconSrc: '/icons/kitchen.png', label: 'Kitchen', catalogCategory: 'kitchen' },
|
||||
{ id: 'item', iconSrc: '/icons/bathroom.png', label: 'Bathroom', catalogCategory: 'bathroom' },
|
||||
{ id: 'item', iconSrc: '/icons/tree.png', label: 'Outdoor', catalogCategory: 'outdoor' },
|
||||
{ id: 'item', iconSrc: '/icons/couch.webp', label: 'Furniture', catalogCategory: 'furniture' },
|
||||
{ id: 'item', iconSrc: '/icons/appliance.webp', label: 'Appliance', catalogCategory: 'appliance' },
|
||||
{ id: 'item', iconSrc: '/icons/kitchen.webp', label: 'Kitchen', catalogCategory: 'kitchen' },
|
||||
{ id: 'item', iconSrc: '/icons/bathroom.webp', label: 'Bathroom', catalogCategory: 'bathroom' },
|
||||
{ id: 'item', iconSrc: '/icons/tree.webp', label: 'Outdoor', catalogCategory: 'outdoor' },
|
||||
]
|
||||
|
||||
@@ -12,17 +12,26 @@ export type ToolConfig = {
|
||||
// for cursor/floorplan indicators. Roof-mounted accessories are intentionally
|
||||
// absent — they're placed from the roof inspector's "Add element" section.
|
||||
export const tools: ToolConfig[] = [
|
||||
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
|
||||
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
||||
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
||||
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
||||
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
||||
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
|
||||
{ id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' },
|
||||
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||
{ id: 'spawn', iconSrc: '/icons/spawn-point.png', label: 'Spawn Point' },
|
||||
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
||||
{ id: 'wall', iconSrc: '/icons/wall.webp', label: 'Wall' },
|
||||
{ id: 'door', iconSrc: '/icons/door.webp', label: 'Door' },
|
||||
{ id: 'window', iconSrc: '/icons/window.webp', label: 'Window' },
|
||||
{ id: 'stair', iconSrc: '/icons/stairs.webp', label: 'Stairs' },
|
||||
{ id: 'roof', iconSrc: '/icons/roof.webp', label: 'Gable Roof' },
|
||||
{ id: 'fence', iconSrc: '/icons/fence.webp', label: 'Fence' },
|
||||
{ id: 'column', iconSrc: '/icons/column.webp', label: 'Column' },
|
||||
{ id: 'elevator', iconSrc: '/icons/elevator.webp', label: 'Elevator' },
|
||||
{ id: 'slab', iconSrc: '/icons/floor.webp', label: 'Slab' },
|
||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.webp', label: 'Ceiling' },
|
||||
{ id: 'zone', iconSrc: '/icons/zone.webp', label: 'Zone' },
|
||||
{ id: 'spawn', iconSrc: '/icons/spawn-point.webp', label: 'Spawn Point' },
|
||||
{ id: 'shelf', iconSrc: '/icons/shelf.webp', label: 'Shelf' },
|
||||
{ id: 'duct-segment', iconSrc: '/icons/duct.webp', label: 'Duct' },
|
||||
{ id: 'duct-fitting', iconSrc: '/icons/duct-fitting.webp', label: 'Duct Fitting' },
|
||||
{ id: 'duct-terminal', iconSrc: '/icons/registers.webp', label: 'Register' },
|
||||
{ id: 'hvac-equipment', iconSrc: '/icons/HVAC.webp', label: 'HVAC Unit' },
|
||||
{ id: 'pipe-segment', iconSrc: '/icons/dwv-pipes.webp', label: 'DWV Pipe' },
|
||||
{ id: 'pipe-trap', iconSrc: '/icons/dwv-pipes.webp', label: 'Trap' },
|
||||
{ id: 'pipe-fitting', iconSrc: '/icons/duct-fitting.webp', label: 'Pipe Fitting' },
|
||||
{ id: 'lineset', iconSrc: '/icons/lineset.webp', label: 'Lineset' },
|
||||
{ id: 'liquid-line', iconSrc: '/icons/lineset.webp', label: 'Liquid Line' },
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
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 { useShallow } from 'zustand/react/shallow'
|
||||
import { getLevelDisplayName } from '@pascal-app/core'
|
||||
@@ -226,7 +226,7 @@ function GuidesControl() {
|
||||
<img
|
||||
alt="Guides"
|
||||
className="h-[28px] w-[28px] object-contain"
|
||||
src="/icons/floorplan.png"
|
||||
src="/icons/floorplan.webp"
|
||||
/>
|
||||
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
||||
{guides.length}
|
||||
@@ -265,7 +265,7 @@ function GuidesControl() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
||||
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
|
||||
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.webp" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground text-sm">Guide images</p>
|
||||
@@ -305,7 +305,7 @@ function GuidesControl() {
|
||||
<img
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
||||
src="/icons/floorplan.png"
|
||||
src="/icons/floorplan.webp"
|
||||
/>
|
||||
<p className="truncate font-medium text-foreground text-sm">
|
||||
{guide.name || `Guide image ${index + 1}`}
|
||||
@@ -466,7 +466,7 @@ function ScansControl() {
|
||||
variant="ghost"
|
||||
>
|
||||
<div className="relative">
|
||||
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
||||
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.webp" />
|
||||
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
||||
{scans.length}
|
||||
</span>
|
||||
@@ -504,7 +504,7 @@ function ScansControl() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
||||
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
|
||||
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.webp" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-foreground text-sm">Scans</p>
|
||||
@@ -544,7 +544,7 @@ function ScansControl() {
|
||||
<img
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
|
||||
src="/icons/mesh.png"
|
||||
src="/icons/mesh.webp"
|
||||
/>
|
||||
<p className="truncate font-medium text-foreground text-sm">
|
||||
{scan.name || `Scan ${index + 1}`}
|
||||
@@ -765,7 +765,7 @@ function ReferencesControl() {
|
||||
<img
|
||||
alt="References"
|
||||
className="h-[28px] w-[28px] object-contain"
|
||||
src="/icons/floorplan.png"
|
||||
src="/icons/floorplan.webp"
|
||||
/>
|
||||
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
||||
{total}
|
||||
@@ -808,7 +808,7 @@ function ReferencesControl() {
|
||||
)}
|
||||
<ReferenceListSection
|
||||
emptyText={REFERENCES_EMPTY_TEXT}
|
||||
iconSrc="/icons/mesh.png"
|
||||
iconSrc="/icons/mesh.webp"
|
||||
nodes={scans}
|
||||
noun="scan"
|
||||
onError={setUploadError}
|
||||
@@ -819,7 +819,7 @@ function ReferencesControl() {
|
||||
<div className="h-px bg-border/45" />
|
||||
<ReferenceListSection
|
||||
emptyText={REFERENCES_EMPTY_TEXT}
|
||||
iconSrc="/icons/floorplan.png"
|
||||
iconSrc="/icons/floorplan.webp"
|
||||
nodes={guides}
|
||||
noun="guide image"
|
||||
onError={setUploadError}
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export { GridSnapControl }
|
||||
@@ -1008,6 +1031,7 @@ export function ViewToggles() {
|
||||
<ScansControl />
|
||||
<GuidesControl />
|
||||
<ReferenceFloorControl />
|
||||
<RiserControl />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,26 +6,26 @@ export type NodeDisplay = {
|
||||
}
|
||||
|
||||
const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
|
||||
item: { icon: '/icons/furniture.png', label: 'Item' },
|
||||
wall: { icon: '/icons/wall.png', label: 'Wall' },
|
||||
door: { icon: '/icons/door.png', label: 'Door' },
|
||||
window: { icon: '/icons/window.png', label: 'Window' },
|
||||
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
||||
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
||||
column: { icon: '/icons/column.png', label: 'Column' },
|
||||
elevator: { icon: '/icons/elevator.png', label: 'Elevator' },
|
||||
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
||||
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
||||
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
||||
stair: { icon: '/icons/stair.png', label: 'Stair' },
|
||||
'stair-segment': { icon: '/icons/stair.png', label: 'Stair segment' },
|
||||
scan: { icon: '/icons/mesh.png', label: '3D Scan' },
|
||||
guide: { icon: '/icons/floorplan.png', label: 'Guide image' },
|
||||
item: { icon: '/icons/furniture.webp', label: 'Item' },
|
||||
wall: { icon: '/icons/wall.webp', label: 'Wall' },
|
||||
door: { icon: '/icons/door.webp', label: 'Door' },
|
||||
window: { icon: '/icons/window.webp', label: 'Window' },
|
||||
slab: { icon: '/icons/floor.webp', label: 'Slab' },
|
||||
ceiling: { icon: '/icons/ceiling.webp', label: 'Ceiling' },
|
||||
column: { icon: '/icons/column.webp', label: 'Column' },
|
||||
elevator: { icon: '/icons/elevator.webp', label: 'Elevator' },
|
||||
fence: { icon: '/icons/fence.webp', label: 'Fence' },
|
||||
roof: { icon: '/icons/roof.webp', label: 'Roof' },
|
||||
'roof-segment': { icon: '/icons/roof.webp', label: 'Roof segment' },
|
||||
stair: { icon: '/icons/stair.webp', label: 'Stair' },
|
||||
'stair-segment': { icon: '/icons/stair.webp', label: 'Stair segment' },
|
||||
scan: { icon: '/icons/mesh.webp', label: '3D Scan' },
|
||||
guide: { icon: '/icons/floorplan.webp', label: 'Guide image' },
|
||||
}
|
||||
|
||||
export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay {
|
||||
if (!node) return { icon: '/icons/select.png', label: 'Selection' }
|
||||
const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.png', label: node.type }
|
||||
if (!node) return { icon: '/icons/select.webp', label: 'Selection' }
|
||||
const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.webp', label: node.type }
|
||||
// Item nodes carry an asset with its own thumbnail/name
|
||||
if (node.type === 'item') {
|
||||
return {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const InspectorFooterContext = createContext<React.ReactNode>(null)
|
||||
|
||||
interface PanelWrapperProps {
|
||||
title: string
|
||||
/** Either a URL path (legacy panels pass `/icons/floor.png` etc.,
|
||||
/** Either a URL path (legacy panels pass `/icons/floor.webp` etc.,
|
||||
* rendered via next/image) OR a React node (registry-driven
|
||||
* inspector renders `<Icon icon="lucide:fence" />` from
|
||||
* `def.presentation.icon`). */
|
||||
|
||||
@@ -62,9 +62,22 @@ export function ParametricInspector({
|
||||
const handleUpdate = useCallback(
|
||||
(patch: Partial<AnyNode>) => {
|
||||
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(() => {
|
||||
|
||||
@@ -22,13 +22,13 @@ interface IconRailProps {
|
||||
|
||||
const sitePanel: { id: PanelId; iconSrc: string; label: string } = {
|
||||
id: 'site',
|
||||
iconSrc: '/icons/level.png',
|
||||
iconSrc: '/icons/level.webp',
|
||||
label: 'Site',
|
||||
}
|
||||
|
||||
const settingsPanel: { id: PanelId; iconSrc: string; label: string } = {
|
||||
id: 'settings',
|
||||
iconSrc: '/icons/settings.png',
|
||||
iconSrc: '/icons/settings.webp',
|
||||
label: 'Settings',
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export const CeilingTreeNode = memo(function CeilingTreeNode({
|
||||
expanded={expanded}
|
||||
hasChildren={children.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/ceiling.webp" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
|
||||
@@ -57,7 +57,7 @@ export const ChimneyTreeNode = memo(function ChimneyTreeNode({
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src="/icons/roof.png"
|
||||
src="/icons/roof.webp"
|
||||
width={14}
|
||||
/>
|
||||
</SnapTargetIcon>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ColumnTreeNode = memo(function ColumnTreeNode({
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/column.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/column.webp" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
|
||||
@@ -57,7 +57,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<SnapTargetIcon target={snapTarget}>
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/door.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/door.webp" width={14} />
|
||||
</SnapTargetIcon>
|
||||
}
|
||||
isHovered={isHovered}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const DormerTreeNode = memo(function DormerTreeNode({
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src="/icons/roof.png"
|
||||
src="/icons/roof.webp"
|
||||
width={14}
|
||||
/>
|
||||
</SnapTargetIcon>
|
||||
|
||||
@@ -49,7 +49,7 @@ export const ElevatorTreeNode = memo(function ElevatorTreeNode({
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/elevator.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/elevator.webp" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/fence.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/fence.webp" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
|
||||
@@ -57,7 +57,7 @@ export const GutterTreeNode = memo(function GutterTreeNode({
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src="/icons/roof.png"
|
||||
src="/icons/roof.webp"
|
||||
width={14}
|
||||
/>
|
||||
</SnapTargetIcon>
|
||||
|
||||
@@ -356,13 +356,13 @@ const ReferenceItem = memo(function ReferenceItem({
|
||||
<img
|
||||
alt="Scan"
|
||||
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
|
||||
src="/icons/mesh.png"
|
||||
src="/icons/mesh.webp"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
alt="Guide"
|
||||
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
|
||||
src="/icons/floorplan.png"
|
||||
src="/icons/floorplan.webp"
|
||||
/>
|
||||
)}
|
||||
<InlineRenameInput
|
||||
@@ -721,7 +721,7 @@ const LevelItem = memo(function LevelItem({
|
||||
'h-4 w-4 shrink-0 object-contain transition-all duration-200',
|
||||
!isSelected && 'opacity-60 grayscale',
|
||||
)}
|
||||
src="/icons/level.png"
|
||||
src="/icons/level.webp"
|
||||
/>
|
||||
<InlineRenameInput
|
||||
defaultName={getDefaultLevelName(level.level)}
|
||||
@@ -999,7 +999,7 @@ const LayerToggle = memo(function LayerToggle() {
|
||||
'mb-1 h-6 w-6 transition-all',
|
||||
activeTab !== 'structure' && 'opacity-50 grayscale',
|
||||
)}
|
||||
src="/icons/room.png"
|
||||
src="/icons/room.webp"
|
||||
/>
|
||||
Structure
|
||||
</div>
|
||||
@@ -1035,7 +1035,7 @@ const LayerToggle = memo(function LayerToggle() {
|
||||
'mb-1 h-6 w-6 transition-all',
|
||||
activeTab !== 'furnish' && 'opacity-50 grayscale',
|
||||
)}
|
||||
src="/icons/couch.png"
|
||||
src="/icons/couch.webp"
|
||||
/>
|
||||
Furnish
|
||||
</div>
|
||||
@@ -1072,7 +1072,7 @@ const LayerToggle = memo(function LayerToggle() {
|
||||
'mb-1 h-6 w-6 transition-all',
|
||||
activeTab !== 'zones' && 'opacity-50 grayscale',
|
||||
)}
|
||||
src="/icons/kitchen.png"
|
||||
src="/icons/kitchen.webp"
|
||||
/>
|
||||
Zones
|
||||
</div>
|
||||
@@ -1413,7 +1413,7 @@ const BuildingItem = memo(function BuildingItem({
|
||||
'h-5 w-5 object-contain transition-all',
|
||||
!isBuildingActive && 'opacity-60 grayscale',
|
||||
)}
|
||||
src="/icons/building.png"
|
||||
src="/icons/building.webp"
|
||||
/>
|
||||
<span className="truncate font-medium text-sm">{building.name || 'Building'}</span>
|
||||
</div>
|
||||
@@ -1569,7 +1569,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
|
||||
'h-5 w-5 object-contain transition-all',
|
||||
phase !== 'site' && 'opacity-60 grayscale',
|
||||
)}
|
||||
src="/icons/site-flag.png"
|
||||
src="/icons/site-flag.webp"
|
||||
/>
|
||||
<span className="font-medium text-sm">{siteNode.name || 'Site'}</span>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
door: '/icons/door.png',
|
||||
window: '/icons/window.png',
|
||||
furniture: '/icons/couch.png',
|
||||
appliance: '/icons/appliance.png',
|
||||
kitchen: '/icons/kitchen.png',
|
||||
bathroom: '/icons/bathroom.png',
|
||||
outdoor: '/icons/tree.png',
|
||||
door: '/icons/door.webp',
|
||||
window: '/icons/window.webp',
|
||||
furniture: '/icons/couch.webp',
|
||||
appliance: '/icons/appliance.webp',
|
||||
kitchen: '/icons/kitchen.webp',
|
||||
bathroom: '/icons/bathroom.webp',
|
||||
outdoor: '/icons/tree.webp',
|
||||
}
|
||||
|
||||
interface ItemTreeNodeProps {
|
||||
@@ -88,7 +88,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({
|
||||
const handleStartEditing = useCallback(() => setIsEditing(true), [])
|
||||
const handleStopEditing = useCallback(() => setIsEditing(false), [])
|
||||
|
||||
const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png'
|
||||
const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.webp'
|
||||
const snapTarget = resolveNodeSnapTarget(node)
|
||||
const defaultName = asset?.name || 'Item'
|
||||
const hasChildren = children.length > 0
|
||||
|
||||
@@ -40,7 +40,7 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
|
||||
|
||||
const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
|
||||
const icon = presentation?.icon
|
||||
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png'
|
||||
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp'
|
||||
const snapTarget = resolveNodeSnapTarget(node)
|
||||
const defaultName = node?.name || presentation?.label || 'Node'
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({
|
||||
expanded={expanded}
|
||||
hasChildren={segments.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/roof.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/roof.webp" width={14} />
|
||||
}
|
||||
isDropTarget={isValidDropTarget && isDropTarget}
|
||||
isHovered={isHovered || isDropTarget}
|
||||
@@ -230,7 +230,7 @@ function RoofSegmentTreeNode({
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src="/icons/roof.png"
|
||||
src="/icons/roof.webp"
|
||||
width={14}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
|
||||
expanded={expanded}
|
||||
hasChildren={hasChildren}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/shelf.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/shelf.webp" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const SlabTreeNode = memo(function SlabTreeNode({
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/floor.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/floor.webp" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src="/icons/roof.png"
|
||||
src="/icons/roof.webp"
|
||||
width={14}
|
||||
/>
|
||||
</SnapTargetIcon>
|
||||
|
||||
@@ -54,7 +54,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
|
||||
alt=""
|
||||
className="object-contain"
|
||||
height={14}
|
||||
src="/icons/spawn-point.png"
|
||||
src="/icons/spawn-point.webp"
|
||||
width={14}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export const StairTreeNode = memo(function StairTreeNode({
|
||||
expanded={expanded}
|
||||
hasChildren={segments.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/stairs.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/stairs.webp" width={14} />
|
||||
}
|
||||
isDropTarget={isValidDropTarget && isDropTarget}
|
||||
isHovered={isHovered || isDropTarget}
|
||||
@@ -206,7 +206,7 @@ function StairSegmentTreeNode({
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src="/icons/stairs.png"
|
||||
src="/icons/stairs.webp"
|
||||
width={14}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const WallTreeNode = memo(function WallTreeNode({
|
||||
expanded={expanded}
|
||||
hasChildren={children.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/wall.webp" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
|
||||
@@ -57,7 +57,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<SnapTargetIcon target={snapTarget}>
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/window.png" width={14} />
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/window.webp" width={14} />
|
||||
</SnapTargetIcon>
|
||||
}
|
||||
isHovered={isHovered}
|
||||
|
||||
@@ -6,9 +6,9 @@ export type SnapTarget = 'wall' | 'ceiling' | 'roof'
|
||||
export type SnapTargetBadgeSize = 'tile' | 'tree'
|
||||
|
||||
const SNAP_TARGET_ICONS: Record<SnapTarget, string> = {
|
||||
wall: '/icons/wall.png',
|
||||
ceiling: '/icons/ceiling.png',
|
||||
roof: '/icons/roof.png',
|
||||
wall: '/icons/wall.webp',
|
||||
ceiling: '/icons/ceiling.webp',
|
||||
roof: '/icons/roof.webp',
|
||||
}
|
||||
|
||||
const SNAP_TARGET_LABELS: Record<SnapTarget, string> = {
|
||||
|
||||
@@ -67,19 +67,19 @@ const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', s
|
||||
const wallModeConfig = {
|
||||
up: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Full Height" height={28} src="/icons/room.png" width={28} {...props} />
|
||||
<img alt="Full Height" height={28} src="/icons/room.webp" width={28} {...props} />
|
||||
),
|
||||
label: 'Full Height',
|
||||
},
|
||||
cutaway: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Cutaway" height={28} src="/icons/wallcut.png" width={28} {...props} />
|
||||
<img alt="Cutaway" height={28} src="/icons/wallcut.webp" width={28} {...props} />
|
||||
),
|
||||
label: 'Cutaway',
|
||||
},
|
||||
down: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Low" height={28} src="/icons/walllow.png" width={28} {...props} />
|
||||
<img alt="Low" height={28} src="/icons/walllow.webp" width={28} {...props} />
|
||||
),
|
||||
label: 'Low',
|
||||
},
|
||||
@@ -481,7 +481,7 @@ export const ViewerOverlay = ({
|
||||
<img
|
||||
alt="Scans"
|
||||
className="h-[28px] w-[28px] object-contain"
|
||||
src="/icons/mesh.png"
|
||||
src="/icons/mesh.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
)}
|
||||
@@ -502,7 +502,7 @@ export const ViewerOverlay = ({
|
||||
<img
|
||||
alt="Guides"
|
||||
className="h-[28px] w-[28px] object-contain"
|
||||
src="/icons/floorplan.png"
|
||||
src="/icons/floorplan.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
)}
|
||||
@@ -608,7 +608,7 @@ export const ViewerOverlay = ({
|
||||
<img
|
||||
alt="Orbit Left"
|
||||
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
src="/icons/rotate.png"
|
||||
src="/icons/rotate.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
@@ -623,7 +623,7 @@ export const ViewerOverlay = ({
|
||||
<img
|
||||
alt="Orbit Right"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
src="/icons/rotate.png"
|
||||
src="/icons/rotate.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
@@ -638,7 +638,7 @@ export const ViewerOverlay = ({
|
||||
<img
|
||||
alt="Top View"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
src="/icons/topview.png"
|
||||
src="/icons/topview.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,16 @@ export const useKeyboard = ({
|
||||
return
|
||||
}
|
||||
|
||||
// True while a door/window is being placed: either a fresh clone is moving
|
||||
// (preset / duplicate path) or a door/window build tool is armed. The
|
||||
// placement tool owns R/T then (flip the draft before commit), so the
|
||||
// global selection-based R/T handler must stand down to avoid double-firing.
|
||||
const isPlacingOpening = () => {
|
||||
const ed = useEditor.getState()
|
||||
if (ed.movingNode?.type === 'door' || ed.movingNode?.type === 'window') return true
|
||||
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle shortcuts if user is typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
@@ -171,11 +181,16 @@ export const useKeyboard = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
|
||||
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode && !isPlacingOpening()) {
|
||||
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
||||
// Doors use R to flip side (front ↔ back, rotation += π); their
|
||||
// open/close toggle lives on E. Windows still use R to toggle
|
||||
// their open/closed state.
|
||||
//
|
||||
// Skipped entirely while a door/window placement is active
|
||||
// (`isPlacingOpening`): the placement tool owns R then (flip the draft
|
||||
// before commit), and the user can have a node selected at the same
|
||||
// time — without this guard both would fire (double flip + sfx).
|
||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedNodeIds.length === 1) {
|
||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||
@@ -225,7 +240,7 @@ export const useKeyboard = ({
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
|
||||
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
|
||||
// Rotate selected node counter-clockwise
|
||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedNodeIds.length === 1) {
|
||||
|
||||
@@ -12,7 +12,34 @@ export { default as Editor } from './components/editor'
|
||||
// surface uses the shorter, shell-friendly names from the unified
|
||||
// preset-system spec.
|
||||
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
||||
export { formatMeasurement, MeasurementPill } from './components/editor/measurement-pill'
|
||||
// Embed surface — the editor's real in-canvas affordances, so a host can mount
|
||||
// authentic selection handles, interactive build tools, and the mover on top
|
||||
// of a bare `<Viewer>` without the full `<Editor>` shell.
|
||||
// - `NodeArrowHandles` renders the selected node's registry resize/rotate/move
|
||||
// handles.
|
||||
// - `MoveTool` runs the kind-owned mover once a translate handle arms
|
||||
// `useEditor.movingNode`.
|
||||
// - `ToolManager` mounts the active registry build tool (wall / door / window /
|
||||
// …) for interactive placement when `useEditor` is in build mode with a
|
||||
// tool, plus the snap/alignment guide layers. Mount it only while a tool is
|
||||
// active to avoid its select-mode boundary editors.
|
||||
// - `Grid` is the interactive drafting plane: it raycasts the pointer and
|
||||
// emits the `grid:move` / `grid:click` events the build tools consume (the
|
||||
// wall tool is driven entirely by them; door/window use them for free-follow
|
||||
// alongside the viewer's `wall:*` mesh events). Without it the tools mount
|
||||
// but their cursor never tracks the pointer. Mount it while a tool is active.
|
||||
// All read `useViewer` selection + `useEditor` state, and cooperate with host
|
||||
// camera controls via the `useViewer.inputDragging` / `useEditor.movingNode`
|
||||
// flags. Tools place onto `useViewer.selection.levelId`, so the host must set a
|
||||
// building + level selection first.
|
||||
export { Grid } from './components/editor/grid'
|
||||
export {
|
||||
DimensionPill,
|
||||
type DimensionPillPart,
|
||||
formatMeasurement,
|
||||
MeasurementPill,
|
||||
} from './components/editor/measurement-pill'
|
||||
export { NodeArrowHandles } from './components/editor/node-arrow-handles'
|
||||
export {
|
||||
type SnapshotCameraData,
|
||||
ThumbnailGenerator,
|
||||
@@ -35,6 +62,7 @@ export {
|
||||
type FencePlanPoint,
|
||||
snapFenceDraftPoint,
|
||||
} from './components/tools/fence/fence-drafting'
|
||||
export { MoveTool } from './components/tools/item/move-tool'
|
||||
// Placement-math helpers — shared by kind-owned placement tools in
|
||||
// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement,
|
||||
// item drop) so kinds don't reach into editor internals.
|
||||
@@ -96,6 +124,7 @@ export {
|
||||
DEFAULT_STAIR_TYPE,
|
||||
DEFAULT_STAIR_WIDTH,
|
||||
} from './components/tools/stair/stair-defaults'
|
||||
export { ToolManager } from './components/tools/tool-manager'
|
||||
export {
|
||||
createWallOnCurrentLevel,
|
||||
getSegmentGridStep,
|
||||
@@ -299,6 +328,11 @@ export type {
|
||||
WorkspaceMode,
|
||||
} from './store/use-editor'
|
||||
export { default as useEditor } from './store/use-editor'
|
||||
export {
|
||||
default as useOpeningGuides,
|
||||
type OpeningGuide3D,
|
||||
type OpeningGuideVec3,
|
||||
} from './store/use-opening-guides'
|
||||
export {
|
||||
type PaletteView,
|
||||
type PaletteViewProps,
|
||||
|
||||
@@ -62,14 +62,16 @@ describe('resolveDirectRotationDragDelta', () => {
|
||||
})
|
||||
|
||||
describe('canDirectMoveNode', () => {
|
||||
test('excludes floorplan-only move targets from 3D direct move', () => {
|
||||
// Accepts kinds with a 3D-mountable move tool (`movable` or
|
||||
// `affordanceTools.move`); floorplan-only movers (zone) are excluded.
|
||||
test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
|
||||
const kind = 'direct-move-floorplan-only-test'
|
||||
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
|
||||
|
||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
||||
})
|
||||
|
||||
test('excludes bespoke move tools from 3D direct move', () => {
|
||||
test('accepts kinds with a bespoke move tool', () => {
|
||||
const kind = 'direct-move-bespoke-tool-test'
|
||||
registerTestDefinition(kind, {
|
||||
affordanceTools: {
|
||||
@@ -77,7 +79,7 @@ describe('canDirectMoveNode', () => {
|
||||
} as never,
|
||||
})
|
||||
|
||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
||||
})
|
||||
|
||||
test('accepts nodes with the generic movable capability', () => {
|
||||
@@ -90,4 +92,11 @@ describe('canDirectMoveNode', () => {
|
||||
|
||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects kinds with no registered move path', () => {
|
||||
const kind = 'direct-move-none-test'
|
||||
registerTestDefinition(kind, {})
|
||||
|
||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createSceneApi,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
type HandleDescriptor,
|
||||
hasRegistry3DMoveTool,
|
||||
nodeRegistry,
|
||||
type SceneApi,
|
||||
useScene,
|
||||
@@ -34,7 +35,10 @@ export function canDirectRotateNode(node: AnyNode): boolean {
|
||||
}
|
||||
|
||||
export function canDirectMoveNode(node: AnyNode): boolean {
|
||||
return nodeRegistry.get(node.type)?.capabilities?.movable !== undefined
|
||||
// 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
|
||||
// mounts in 3D — distinct from `isRegistryMovable`, which also accepts
|
||||
// floorplan-only movers (zone) for the 2D plan.
|
||||
return hasRegistry3DMoveTool(node.type)
|
||||
}
|
||||
|
||||
export function snapDirectRotationDelta(delta: number, free: boolean): number {
|
||||
|
||||
@@ -106,6 +106,15 @@ export type StructureTool =
|
||||
| 'dormer'
|
||||
| 'gutter'
|
||||
| 'downspout'
|
||||
| 'duct-segment'
|
||||
| 'duct-fitting'
|
||||
| 'duct-terminal'
|
||||
| 'hvac-equipment'
|
||||
| 'lineset'
|
||||
| 'liquid-line'
|
||||
| 'pipe-segment'
|
||||
| 'pipe-fitting'
|
||||
| 'pipe-trap'
|
||||
|
||||
// Furnish mode tools (items and decoration)
|
||||
export type FurnishTool = 'item'
|
||||
@@ -292,6 +301,14 @@ type EditorState = {
|
||||
*/
|
||||
activeHandleDrag: { nodeId: AnyNodeId; label: string } | null
|
||||
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
|
||||
setCurvingWall: (wall: WallNode | null) => void
|
||||
curvingFence: FenceNode | null
|
||||
@@ -347,6 +364,10 @@ type EditorState = {
|
||||
toggleFloorplanOpen: () => void
|
||||
isFloorplanHovered: boolean
|
||||
setFloorplanHovered: (hovered: boolean) => void
|
||||
// Toggleable DWV riser-diagram (plumbing isometric) overlay.
|
||||
isRiserOpen: boolean
|
||||
setRiserOpen: (open: boolean) => void
|
||||
toggleRiserOpen: () => void
|
||||
navigationSyncPose: NavigationSyncPose | null
|
||||
publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
|
||||
floorplanSelectionTool: FloorplanSelectionTool
|
||||
@@ -368,6 +389,11 @@ type EditorState = {
|
||||
// Development-only camera debug flag for inspecting underside geometry
|
||||
allowUndergroundCamera: boolean
|
||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||
// Development-only debug overlay: draw each wall's opening-snap hit area
|
||||
// (the capsule of points within the snap radius of its centerline). Lets us
|
||||
// see why a door/window snaps where it does.
|
||||
show2dVoronoi: boolean
|
||||
setShow2dVoronoi: (enabled: boolean) => void
|
||||
// First-person walkthrough mode (street view)
|
||||
isFirstPersonMode: boolean
|
||||
_viewModeBeforeFirstPerson: ViewMode | null
|
||||
@@ -661,6 +687,11 @@ export function selectSiteFloorplanContext() {
|
||||
})
|
||||
}
|
||||
|
||||
// Stashes the view mode the user was in before entering capture, so we can
|
||||
// restore it on exit. Snapshot capture always frames in 3D — the 2D/split
|
||||
// floorplan panes render nothing meaningful for a thumbnail.
|
||||
let viewModeBeforeCapture: ViewMode | null = null
|
||||
|
||||
const useEditor = create<EditorState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -802,6 +833,13 @@ const useEditor = create<EditorState>()(
|
||||
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
||||
activeHandleDrag: null,
|
||||
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,
|
||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||
curvingFence: null,
|
||||
@@ -911,7 +949,35 @@ const useEditor = create<EditorState>()(
|
||||
setCaptureMode: (next) => {
|
||||
const resolved: CaptureMode =
|
||||
typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next
|
||||
set({ captureMode: resolved, isCaptureMode: resolved.mode !== 'idle' })
|
||||
const entering = resolved.mode !== 'idle'
|
||||
set((state) => {
|
||||
if (entering) {
|
||||
// Force 3D for the shot. Remember the prior mode only on the first
|
||||
// entry (viewMode is already '3d' on re-entry), so we restore the
|
||||
// user's real choice — not the forced '3d' — when capture ends.
|
||||
if (state.viewMode !== '3d') {
|
||||
viewModeBeforeCapture = state.viewMode
|
||||
return {
|
||||
captureMode: resolved,
|
||||
isCaptureMode: true,
|
||||
viewMode: '3d',
|
||||
isFloorplanOpen: false,
|
||||
}
|
||||
}
|
||||
return { captureMode: resolved, isCaptureMode: true }
|
||||
}
|
||||
const restore = viewModeBeforeCapture
|
||||
viewModeBeforeCapture = null
|
||||
if (restore && restore !== '3d') {
|
||||
return {
|
||||
captureMode: resolved,
|
||||
isCaptureMode: false,
|
||||
viewMode: restore,
|
||||
isFloorplanOpen: true,
|
||||
}
|
||||
}
|
||||
return { captureMode: resolved, isCaptureMode: false }
|
||||
})
|
||||
},
|
||||
viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
|
||||
setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
|
||||
@@ -926,6 +992,9 @@ const useEditor = create<EditorState>()(
|
||||
}),
|
||||
isFloorplanHovered: false,
|
||||
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
||||
isRiserOpen: false,
|
||||
setRiserOpen: (open) => set({ isRiserOpen: open }),
|
||||
toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })),
|
||||
navigationSyncPose: null,
|
||||
publishNavigationSyncPose: (pose) =>
|
||||
set((state) => ({
|
||||
@@ -952,6 +1021,8 @@ const useEditor = create<EditorState>()(
|
||||
set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
|
||||
allowUndergroundCamera: false,
|
||||
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
||||
show2dVoronoi: false,
|
||||
setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
|
||||
isFirstPersonMode: false,
|
||||
_viewModeBeforeFirstPerson: null as ViewMode | null,
|
||||
setFirstPersonMode: (enabled) => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Ephemeral store for the 3D opening proximity/alignment guides published by the
|
||||
// door/window move + placement tools during a drag — the wall-plane counterpart
|
||||
// of `useAlignmentGuides` (which only carries floor-plane XZ guides). Guides are
|
||||
// already transformed into the move tool's render frame — the same building-local
|
||||
// frame as the drag cursor (ToolManager's group) — so the renderer stays dumb.
|
||||
// Producers clear on commit, cancel, leave, and unmount.
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type OpeningGuideVec3 = [number, number, number]
|
||||
|
||||
// A stable identity per guide slot (`sill`, `head`, `gap:left`, `vertical`,
|
||||
// `spacing:0`, …) so the renderer can key by semantic role: as the guide set
|
||||
// churns each drag tick, a slot that persists keeps its React element — and its
|
||||
// drei `<Html>` portal — mounted instead of remounting when the list shape
|
||||
// shifts under index keys.
|
||||
export type OpeningGuide3D =
|
||||
// A measured line + distance pill: sill (floor → bottom edge), head (top edge
|
||||
// → wall top), or along-wall edge-to-edge proximity.
|
||||
| { kind: 'dimension'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number }
|
||||
// A dashed line connecting two openings that share a sill / centre / top.
|
||||
| { kind: 'align-line'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3 }
|
||||
// A Figma-style "=" badge marking one gap in an equal-spacing run.
|
||||
| { kind: 'badge'; id: string; at: OpeningGuideVec3; value: number }
|
||||
|
||||
type OpeningGuidesState = {
|
||||
guides: OpeningGuide3D[]
|
||||
set(guides: OpeningGuide3D[]): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
const useOpeningGuides = create<OpeningGuidesState>((set) => ({
|
||||
guides: [],
|
||||
set: (guides) => set({ guides }),
|
||||
// No-op when already empty so the common no-guide hover frame (fallback
|
||||
// cursor, invalid target, roof hover) doesn't push a fresh `[]` and notify
|
||||
// subscribers — the layer would re-render to the same nothing every tick.
|
||||
clear: () => set((s) => (s.guides.length > 0 ? { guides: [] } : s)),
|
||||
}))
|
||||
|
||||
export default useOpeningGuides
|
||||
@@ -18,14 +18,22 @@ type PlacementPreviewState = {
|
||||
/** Transient preview node, already positioned + rotated at the (snapped,
|
||||
* aligned) cursor. `null` when no placement is active. */
|
||||
node: AnyNode | null
|
||||
set(node: AnyNode | null): void
|
||||
/** Optional synthetic parent for the preview's `def.floorplan` context.
|
||||
* Door / window glyph builders need `ctx.parent` to be a wall to draw their
|
||||
* real symbol (swing arc / panes); off any real wall we hand them a
|
||||
* synthetic wall segment centred at the cursor so the floating ghost shows
|
||||
* the faithful blueprint symbol instead of a bare rectangle. `null` for
|
||||
* self-contained kinds (column / elevator). */
|
||||
parentNode: AnyNode | null
|
||||
set(node: AnyNode | null, parentNode?: AnyNode | null): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
const usePlacementPreview = create<PlacementPreviewState>((set) => ({
|
||||
node: null,
|
||||
set: (node) => set({ node }),
|
||||
clear: () => set({ node: null }),
|
||||
parentNode: null,
|
||||
set: (node, parentNode = null) => set({ node, parentNode }),
|
||||
clear: () => set({ node: null, parentNode: null }),
|
||||
}))
|
||||
|
||||
export default usePlacementPreview
|
||||
|
||||
@@ -220,7 +220,7 @@ export const boxVentDefinition: NodeDefinition<typeof BoxVentNode> = {
|
||||
presentation: {
|
||||
label: 'Box Vent',
|
||||
description: 'Small louvered exhaust vent that sits on a roof slope.',
|
||||
icon: { kind: 'url', src: '/icons/roof.png' },
|
||||
icon: { kind: 'url', src: '/icons/roof.webp' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 120,
|
||||
},
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function BoxVentPanel() {
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/roof.png"
|
||||
icon="/icons/roof.webp"
|
||||
onBack={node.roofSegmentId ? handleBack : undefined}
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Box Vent'}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { INVALID_GHOST_COLOR } from '../shared/ghost-materials'
|
||||
import { buildBoxVentGeometry } from './geometry'
|
||||
import type { BoxVentNode } from './schema'
|
||||
|
||||
@@ -15,7 +16,8 @@ import type { BoxVentNode } from './schema'
|
||||
* leaving raycast active would cause the preview itself to intercept
|
||||
* the cursor ray and starve the placement tool of `roof:move` events.
|
||||
*/
|
||||
const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
|
||||
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(
|
||||
() => buildBoxVentGeometry(node),
|
||||
[node.width, node.depth, node.height, node.hoodOverhang, node.style],
|
||||
@@ -24,17 +26,17 @@ const BoxVentPreview = ({ node }: { node: BoxVentNode }) => {
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0x6c_a3_ff,
|
||||
color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff,
|
||||
emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff,
|
||||
emissiveIntensity: 0.18,
|
||||
roughness: 0.85,
|
||||
metalness: 0.05,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
opacity: invalid ? 0.4 : 0.35,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
[],
|
||||
[invalid],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
@@ -75,6 +75,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
||||
// every parametric field, including the per-style ones. Listing them
|
||||
// explicitly keeps the dep array tight (vs. `[node]` which would
|
||||
// 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(
|
||||
() => buildBoxVentGeometry(node),
|
||||
[
|
||||
|
||||
@@ -127,11 +127,11 @@ const BoxVentTool = () => {
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
activeBuildingId={activeBuildingId}
|
||||
ghost={<BoxVentPreview node={previewNode} invalid />}
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
}}
|
||||
size={[0.6, 0.4, 0.6]}
|
||||
/>
|
||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||
<group position={previewPos}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user