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:
Wassim SAMAD
2026-06-18 12:22:26 -04:00
415 changed files with 22805 additions and 1414 deletions
+8
View File
@@ -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
+3
View File
@@ -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,
+14
View File
@@ -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
+144 -1
View File
@@ -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