@pascal-app/core 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/dist/events/bus.d.ts +30 -2
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/hooks/scene-registry/item-clip-registry.d.ts +18 -0
  4. package/dist/hooks/scene-registry/item-clip-registry.d.ts.map +1 -0
  5. package/dist/hooks/scene-registry/item-clip-registry.js +10 -0
  6. package/dist/hooks/scene-registry/scene-registry.d.ts +8 -1
  7. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  8. package/dist/hooks/scene-registry/scene-registry.js +27 -1
  9. package/dist/hooks/scene-registry/scene-registry.test.d.ts +2 -0
  10. package/dist/hooks/scene-registry/scene-registry.test.d.ts.map +1 -0
  11. package/dist/hooks/scene-registry/scene-registry.test.js +26 -0
  12. package/dist/hooks/spatial-grid/floor-placed-elevation.test.js +93 -0
  13. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +69 -12
  14. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  15. package/dist/hooks/spatial-grid/spatial-grid-manager.js +476 -195
  16. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  17. package/dist/hooks/spatial-grid/spatial-grid-sync.js +19 -1
  18. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts +2 -0
  19. package/dist/hooks/spatial-grid/wall-slab-overlap.test.d.ts.map +1 -0
  20. package/dist/hooks/spatial-grid/wall-slab-overlap.test.js +363 -0
  21. package/dist/index.d.ts +16 -6
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +15 -5
  24. package/dist/lib/level-name.d.ts +4 -0
  25. package/dist/lib/level-name.d.ts.map +1 -0
  26. package/dist/lib/level-name.js +10 -0
  27. package/dist/lib/measurement-geometry.d.ts +19 -0
  28. package/dist/lib/measurement-geometry.d.ts.map +1 -0
  29. package/dist/lib/measurement-geometry.js +232 -0
  30. package/dist/lib/measurement-geometry.test.d.ts +2 -0
  31. package/dist/lib/measurement-geometry.test.d.ts.map +1 -0
  32. package/dist/lib/measurement-geometry.test.js +102 -0
  33. package/dist/lib/polygon-geometry.d.ts +0 -1
  34. package/dist/lib/polygon-geometry.d.ts.map +1 -1
  35. package/dist/lib/polygon-geometry.js +0 -18
  36. package/dist/lib/selection-proxy.d.ts +4 -0
  37. package/dist/lib/selection-proxy.d.ts.map +1 -0
  38. package/dist/lib/selection-proxy.js +12 -0
  39. package/dist/lib/slab-polygon.d.ts +31 -2
  40. package/dist/lib/slab-polygon.d.ts.map +1 -1
  41. package/dist/lib/slab-polygon.js +525 -31
  42. package/dist/lib/slab-polygon.test.d.ts +2 -0
  43. package/dist/lib/slab-polygon.test.d.ts.map +1 -0
  44. package/dist/lib/slab-polygon.test.js +635 -0
  45. package/dist/lib/slots.d.ts +14 -0
  46. package/dist/lib/slots.d.ts.map +1 -0
  47. package/dist/lib/slots.js +26 -0
  48. package/dist/lib/space-detection.d.ts +25 -2
  49. package/dist/lib/space-detection.d.ts.map +1 -1
  50. package/dist/lib/space-detection.js +414 -118
  51. package/dist/lib/space-detection.test.js +301 -2
  52. package/dist/lib/wall-distance.d.ts +60 -0
  53. package/dist/lib/wall-distance.d.ts.map +1 -0
  54. package/dist/lib/wall-distance.js +85 -0
  55. package/dist/lib/zone-quantities.d.ts +21 -0
  56. package/dist/lib/zone-quantities.d.ts.map +1 -0
  57. package/dist/lib/zone-quantities.js +183 -0
  58. package/dist/lib/zone-quantities.test.d.ts +2 -0
  59. package/dist/lib/zone-quantities.test.d.ts.map +1 -0
  60. package/dist/lib/zone-quantities.test.js +82 -0
  61. package/dist/material-library.d.ts +20 -1
  62. package/dist/material-library.d.ts.map +1 -1
  63. package/dist/material-library.js +2181 -441
  64. package/dist/registry/handles.d.ts +75 -2
  65. package/dist/registry/handles.d.ts.map +1 -1
  66. package/dist/registry/index.d.ts +3 -3
  67. package/dist/registry/index.d.ts.map +1 -1
  68. package/dist/registry/index.js +1 -1
  69. package/dist/registry/registry.d.ts +46 -3
  70. package/dist/registry/registry.d.ts.map +1 -1
  71. package/dist/registry/registry.js +82 -1
  72. package/dist/registry/registry.test.js +14 -1
  73. package/dist/registry/subtree.d.ts.map +1 -1
  74. package/dist/registry/subtree.js +4 -0
  75. package/dist/registry/subtree.test.js +30 -0
  76. package/dist/registry/types.d.ts +717 -38
  77. package/dist/registry/types.d.ts.map +1 -1
  78. package/dist/schema/index.d.ts +21 -5
  79. package/dist/schema/index.d.ts.map +1 -1
  80. package/dist/schema/index.js +17 -3
  81. package/dist/schema/material.d.ts +3 -2
  82. package/dist/schema/material.d.ts.map +1 -1
  83. package/dist/schema/material.js +7 -5
  84. package/dist/schema/material.test.d.ts +2 -0
  85. package/dist/schema/material.test.d.ts.map +1 -0
  86. package/dist/schema/material.test.js +43 -0
  87. package/dist/schema/nodes/box-vent.d.ts +2 -2
  88. package/dist/schema/nodes/cabinet.d.ts +534 -0
  89. package/dist/schema/nodes/cabinet.d.ts.map +1 -0
  90. package/dist/schema/nodes/cabinet.js +137 -0
  91. package/dist/schema/nodes/ceiling.d.ts +3 -2
  92. package/dist/schema/nodes/ceiling.d.ts.map +1 -1
  93. package/dist/schema/nodes/ceiling.js +4 -0
  94. package/dist/schema/nodes/chimney.d.ts +4 -4
  95. package/dist/schema/nodes/column.d.ts +3 -2
  96. package/dist/schema/nodes/column.d.ts.map +1 -1
  97. package/dist/schema/nodes/column.js +3 -0
  98. package/dist/schema/nodes/cupola.d.ts +2 -2
  99. package/dist/schema/nodes/door.d.ts +13 -5
  100. package/dist/schema/nodes/door.d.ts.map +1 -1
  101. package/dist/schema/nodes/door.js +12 -0
  102. package/dist/schema/nodes/dormer.d.ts +8 -8
  103. package/dist/schema/nodes/dormer.js +1 -1
  104. package/dist/schema/nodes/downspout.d.ts +2 -2
  105. package/dist/schema/nodes/duct-fitting.d.ts +91 -0
  106. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -0
  107. package/dist/schema/nodes/duct-fitting.js +92 -0
  108. package/dist/schema/nodes/duct-segment.d.ts +61 -0
  109. package/dist/schema/nodes/duct-segment.d.ts.map +1 -0
  110. package/dist/schema/nodes/duct-segment.js +73 -0
  111. package/dist/schema/nodes/duct-terminal.d.ts +57 -0
  112. package/dist/schema/nodes/duct-terminal.d.ts.map +1 -0
  113. package/dist/schema/nodes/duct-terminal.js +51 -0
  114. package/dist/schema/nodes/elevator.d.ts +3 -2
  115. package/dist/schema/nodes/elevator.d.ts.map +1 -1
  116. package/dist/schema/nodes/elevator.js +3 -0
  117. package/dist/schema/nodes/eyebrow-vent.d.ts +2 -2
  118. package/dist/schema/nodes/fence.d.ts +18 -2
  119. package/dist/schema/nodes/fence.d.ts.map +1 -1
  120. package/dist/schema/nodes/fence.js +25 -2
  121. package/dist/schema/nodes/gutter.d.ts +2 -2
  122. package/dist/schema/nodes/hvac-equipment.d.ts +63 -0
  123. package/dist/schema/nodes/hvac-equipment.d.ts.map +1 -0
  124. package/dist/schema/nodes/hvac-equipment.js +55 -0
  125. package/dist/schema/nodes/item.d.ts +8 -0
  126. package/dist/schema/nodes/item.d.ts.map +1 -1
  127. package/dist/schema/nodes/item.js +11 -0
  128. package/dist/schema/nodes/level.d.ts +1 -1
  129. package/dist/schema/nodes/level.d.ts.map +1 -1
  130. package/dist/schema/nodes/level.js +2 -0
  131. package/dist/schema/nodes/lineset.d.ts +42 -0
  132. package/dist/schema/nodes/lineset.d.ts.map +1 -0
  133. package/dist/schema/nodes/lineset.js +38 -0
  134. package/dist/schema/nodes/liquid-line.d.ts +34 -0
  135. package/dist/schema/nodes/liquid-line.d.ts.map +1 -0
  136. package/dist/schema/nodes/liquid-line.js +24 -0
  137. package/dist/schema/nodes/measurement.d.ts +306 -0
  138. package/dist/schema/nodes/measurement.d.ts.map +1 -0
  139. package/dist/schema/nodes/measurement.js +87 -0
  140. package/dist/schema/nodes/measurement.test.d.ts +2 -0
  141. package/dist/schema/nodes/measurement.test.d.ts.map +1 -0
  142. package/dist/schema/nodes/measurement.test.js +139 -0
  143. package/dist/schema/nodes/pipe-fitting.d.ts +56 -0
  144. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -0
  145. package/dist/schema/nodes/pipe-fitting.js +45 -0
  146. package/dist/schema/nodes/pipe-segment.d.ts +48 -0
  147. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -0
  148. package/dist/schema/nodes/pipe-segment.js +36 -0
  149. package/dist/schema/nodes/pipe-trap.d.ts +43 -0
  150. package/dist/schema/nodes/pipe-trap.d.ts.map +1 -0
  151. package/dist/schema/nodes/pipe-trap.js +36 -0
  152. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts +2 -0
  153. package/dist/schema/nodes/ridge-vent-defaults.test.d.ts.map +1 -0
  154. package/dist/schema/nodes/ridge-vent-defaults.test.js +205 -0
  155. package/dist/schema/nodes/ridge-vent.d.ts +18 -3
  156. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  157. package/dist/schema/nodes/ridge-vent.js +221 -3
  158. package/dist/schema/nodes/roof-segment-shape.d.ts +90 -0
  159. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -0
  160. package/dist/schema/nodes/roof-segment-shape.js +318 -0
  161. package/dist/schema/nodes/roof-segment-shape.test.d.ts +2 -0
  162. package/dist/schema/nodes/roof-segment-shape.test.d.ts.map +1 -0
  163. package/dist/schema/nodes/roof-segment-shape.test.js +175 -0
  164. package/dist/schema/nodes/roof-segment-surface.test.d.ts +2 -0
  165. package/dist/schema/nodes/roof-segment-surface.test.d.ts.map +1 -0
  166. package/dist/schema/nodes/roof-segment-surface.test.js +48 -0
  167. package/dist/schema/nodes/roof-segment-trim.test.d.ts +2 -0
  168. package/dist/schema/nodes/roof-segment-trim.test.d.ts.map +1 -0
  169. package/dist/schema/nodes/roof-segment-trim.test.js +107 -0
  170. package/dist/schema/nodes/roof-segment-walls.d.ts +101 -0
  171. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -0
  172. package/dist/schema/nodes/roof-segment-walls.js +330 -0
  173. package/dist/schema/nodes/roof-segment-walls.test.d.ts +2 -0
  174. package/dist/schema/nodes/roof-segment-walls.test.d.ts.map +1 -0
  175. package/dist/schema/nodes/roof-segment-walls.test.js +92 -0
  176. package/dist/schema/nodes/roof-segment.d.ts +84 -8
  177. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  178. package/dist/schema/nodes/roof-segment.js +290 -5
  179. package/dist/schema/nodes/roof.d.ts +8 -8
  180. package/dist/schema/nodes/shelf.d.ts +3 -2
  181. package/dist/schema/nodes/shelf.d.ts.map +1 -1
  182. package/dist/schema/nodes/shelf.js +6 -0
  183. package/dist/schema/nodes/skylight.d.ts +10 -10
  184. package/dist/schema/nodes/slab.d.ts +3 -2
  185. package/dist/schema/nodes/slab.d.ts.map +1 -1
  186. package/dist/schema/nodes/slab.js +4 -0
  187. package/dist/schema/nodes/solar-panel.d.ts +4 -4
  188. package/dist/schema/nodes/stair-segment.d.ts +4 -4
  189. package/dist/schema/nodes/stair.d.ts +13 -12
  190. package/dist/schema/nodes/stair.d.ts.map +1 -1
  191. package/dist/schema/nodes/stair.js +3 -0
  192. package/dist/schema/nodes/turbine-vent.d.ts +2 -2
  193. package/dist/schema/nodes/wall.d.ts +245 -6
  194. package/dist/schema/nodes/wall.d.ts.map +1 -1
  195. package/dist/schema/nodes/wall.js +257 -0
  196. package/dist/schema/nodes/wall.test.d.ts +2 -0
  197. package/dist/schema/nodes/wall.test.d.ts.map +1 -0
  198. package/dist/schema/nodes/wall.test.js +208 -0
  199. package/dist/schema/nodes/window.d.ts +11 -3
  200. package/dist/schema/nodes/window.d.ts.map +1 -1
  201. package/dist/schema/nodes/window.js +12 -0
  202. package/dist/schema/nodes/zone.d.ts +2 -0
  203. package/dist/schema/nodes/zone.d.ts.map +1 -1
  204. package/dist/schema/nodes/zone.js +6 -0
  205. package/dist/schema/scene-material.d.ts +41 -0
  206. package/dist/schema/scene-material.d.ts.map +1 -0
  207. package/dist/schema/scene-material.js +9 -0
  208. package/dist/schema/types.d.ts +1089 -94
  209. package/dist/schema/types.d.ts.map +1 -1
  210. package/dist/schema/types.js +23 -0
  211. package/dist/services/alignment-anchors.d.ts.map +1 -1
  212. package/dist/services/alignment-anchors.js +30 -1
  213. package/dist/services/alignment.d.ts +5 -0
  214. package/dist/services/alignment.d.ts.map +1 -1
  215. package/dist/services/alignment.js +2 -0
  216. package/dist/services/hosting.d.ts +8 -0
  217. package/dist/services/hosting.d.ts.map +1 -1
  218. package/dist/services/hosting.js +13 -4
  219. package/dist/services/hosting.test.js +32 -1
  220. package/dist/services/index.d.ts +8 -2
  221. package/dist/services/index.d.ts.map +1 -1
  222. package/dist/services/index.js +8 -2
  223. package/dist/services/level-height.d.ts +28 -0
  224. package/dist/services/level-height.d.ts.map +1 -0
  225. package/dist/services/level-height.js +62 -0
  226. package/dist/services/opening-guides.d.ts +141 -0
  227. package/dist/services/opening-guides.d.ts.map +1 -0
  228. package/dist/services/opening-guides.js +267 -0
  229. package/dist/services/opening-guides.test.d.ts +2 -0
  230. package/dist/services/opening-guides.test.d.ts.map +1 -0
  231. package/dist/services/opening-guides.test.js +208 -0
  232. package/dist/services/port-connectivity.d.ts +115 -0
  233. package/dist/services/port-connectivity.d.ts.map +1 -0
  234. package/dist/services/port-connectivity.js +300 -0
  235. package/dist/services/port-connectivity.test.d.ts +2 -0
  236. package/dist/services/port-connectivity.test.d.ts.map +1 -0
  237. package/dist/services/port-connectivity.test.js +297 -0
  238. package/dist/services/riser-diagram.d.ts +40 -0
  239. package/dist/services/riser-diagram.d.ts.map +1 -0
  240. package/dist/services/riser-diagram.js +110 -0
  241. package/dist/services/riser-diagram.test.d.ts +2 -0
  242. package/dist/services/riser-diagram.test.d.ts.map +1 -0
  243. package/dist/services/riser-diagram.test.js +66 -0
  244. package/dist/services/snap.d.ts +9 -1
  245. package/dist/services/snap.d.ts.map +1 -1
  246. package/dist/services/snap.js +21 -1
  247. package/dist/services/snap.test.js +43 -1
  248. package/dist/services/system-graph.d.ts +30 -0
  249. package/dist/services/system-graph.d.ts.map +1 -0
  250. package/dist/services/system-graph.js +164 -0
  251. package/dist/services/system-graph.test.d.ts +2 -0
  252. package/dist/services/system-graph.test.d.ts.map +1 -0
  253. package/dist/services/system-graph.test.js +138 -0
  254. package/dist/services/validate-dwv.d.ts +32 -0
  255. package/dist/services/validate-dwv.d.ts.map +1 -0
  256. package/dist/services/validate-dwv.js +118 -0
  257. package/dist/services/validate-dwv.test.d.ts +2 -0
  258. package/dist/services/validate-dwv.test.d.ts.map +1 -0
  259. package/dist/services/validate-dwv.test.js +109 -0
  260. package/dist/store/actions/node-actions.d.ts.map +1 -1
  261. package/dist/store/actions/node-actions.js +94 -1
  262. package/dist/store/actions/ridge-vent-update.test.d.ts +2 -0
  263. package/dist/store/actions/ridge-vent-update.test.d.ts.map +1 -0
  264. package/dist/store/actions/ridge-vent-update.test.js +264 -0
  265. package/dist/store/history-control.d.ts +11 -0
  266. package/dist/store/history-control.d.ts.map +1 -1
  267. package/dist/store/history-control.js +33 -0
  268. package/dist/store/use-scene-dirty-tracking.test.d.ts +2 -0
  269. package/dist/store/use-scene-dirty-tracking.test.d.ts.map +1 -0
  270. package/dist/store/use-scene-dirty-tracking.test.js +68 -0
  271. package/dist/store/use-scene-measurement-roundtrip.test.d.ts +2 -0
  272. package/dist/store/use-scene-measurement-roundtrip.test.d.ts.map +1 -0
  273. package/dist/store/use-scene-measurement-roundtrip.test.js +97 -0
  274. package/dist/store/use-scene-plugins.test.d.ts +2 -0
  275. package/dist/store/use-scene-plugins.test.d.ts.map +1 -0
  276. package/dist/store/use-scene-plugins.test.js +51 -0
  277. package/dist/store/use-scene-wall-slot-migration.test.d.ts +2 -0
  278. package/dist/store/use-scene-wall-slot-migration.test.d.ts.map +1 -0
  279. package/dist/store/use-scene-wall-slot-migration.test.js +210 -0
  280. package/dist/store/use-scene-window-migration.test.d.ts +2 -0
  281. package/dist/store/use-scene-window-migration.test.d.ts.map +1 -0
  282. package/dist/store/use-scene-window-migration.test.js +85 -0
  283. package/dist/store/use-scene.d.ts +17 -2
  284. package/dist/store/use-scene.d.ts.map +1 -1
  285. package/dist/store/use-scene.js +358 -51
  286. package/dist/systems/elevator/elevator-service.js +1 -1
  287. package/dist/systems/fence/fence-centerline.d.ts +12 -0
  288. package/dist/systems/fence/fence-centerline.d.ts.map +1 -0
  289. package/dist/systems/fence/fence-centerline.js +37 -0
  290. package/dist/systems/fence/fence-spline.d.ts +39 -0
  291. package/dist/systems/fence/fence-spline.d.ts.map +1 -0
  292. package/dist/systems/fence/fence-spline.js +196 -0
  293. package/dist/systems/fence/fence-spline.test.d.ts +2 -0
  294. package/dist/systems/fence/fence-spline.test.d.ts.map +1 -0
  295. package/dist/systems/fence/fence-spline.test.js +136 -0
  296. package/dist/systems/stair/stair-footprint.d.ts.map +1 -1
  297. package/dist/systems/stair/stair-footprint.js +21 -11
  298. package/dist/systems/stair/stair-opening-preview.d.ts +1139 -469
  299. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  300. package/dist/systems/stair/stair-opening-sync.d.ts.map +1 -1
  301. package/dist/systems/stair/stair-opening-sync.js +1 -18
  302. package/dist/systems/stair/stair-opening-sync.test.js +39 -0
  303. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  304. package/dist/systems/wall/wall-mitering.js +38 -2
  305. package/dist/systems/wall/wall-mitering.test.d.ts +2 -0
  306. package/dist/systems/wall/wall-mitering.test.d.ts.map +1 -0
  307. package/dist/systems/wall/wall-mitering.test.js +63 -0
  308. package/dist/utils/clone-scene-graph.d.ts +1 -0
  309. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  310. package/dist/utils/clone-scene-graph.js +23 -3
  311. package/dist/utils/clone-scene-graph.test.js +3 -0
  312. package/dist/utils/heal-scene-graph.d.ts +18 -0
  313. package/dist/utils/heal-scene-graph.d.ts.map +1 -0
  314. package/dist/utils/heal-scene-graph.js +90 -0
  315. package/dist/utils/heal-scene-graph.test.d.ts +2 -0
  316. package/dist/utils/heal-scene-graph.test.d.ts.map +1 -0
  317. package/dist/utils/heal-scene-graph.test.js +88 -0
  318. package/dist/validation/validate-build-json.d.ts +1 -0
  319. package/dist/validation/validate-build-json.d.ts.map +1 -1
  320. package/dist/validation/validate-build-json.js +31 -2
  321. package/package.json +3 -3
@@ -1,7 +1,9 @@
1
1
  import type { ComponentType } from 'react';
2
- import type { BufferGeometry, Object3D } from 'three';
2
+ import type { AnimationClip, BufferGeometry, Object3D, Ray } from 'three';
3
3
  import type { ZodObject, z } from 'zod';
4
- import type { MaterialSchema } from '../schema/material';
4
+ import type { MaterialSchema, MaterialTarget } from '../schema/material';
5
+ import type { MeasurementFeatureReference, MeasurementPoint } from '../schema/nodes/measurement';
6
+ import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material';
5
7
  import type { AnyNode, AnyNodeId } from '../schema/types';
6
8
  import type { HandleList } from './handles';
7
9
  import type { CloneNodesIntoOptions, Subtree } from './subtree';
@@ -16,20 +18,27 @@ export type GeometryContext = {
16
18
  parent: AnyNode | null;
17
19
  /**
18
20
  * Pre-computed level-batch data, populated by the dispatcher when the
19
- * kind declares `def.computeLevelData`. Shared across every
20
- * `def.geometry(node, ctx)` call in the same level batch within a
21
- * single frame, so kinds whose geometry depends on cross-sibling
22
- * data (wall mitering, gradient sky uniforms across a zone, etc.)
23
- * don't pay an O(N²) recomputation cost.
21
+ * kind declares `def.computeLevelData` (3D) or
22
+ * `def.computeFloorplanLevelData` (2D). Shared across every builder call
23
+ * in the same level batch within a single frame/render pass, so kinds
24
+ * whose geometry depends on cross-sibling data (wall mitering, gradient
25
+ * sky uniforms across a zone, etc.) don't pay an O(N²) recomputation cost.
24
26
  *
25
27
  * Typed as `unknown` at the framework boundary — kinds cast to their
26
- * own `LevelData` shape inside `def.geometry` (the same kind owns
27
- * both the `computeLevelData` return shape and the `geometry`
28
- * consumer, so the cast is internal). Only populated for `def.
29
- * geometry` calls today; not used by `def.floorplan` (which already
30
- * has cheap access to siblings through `ctx.siblings`).
28
+ * own `LevelData` shape inside `def.geometry` / `def.floorplan` (the
29
+ * same kind owns both the compute hook's return shape and the builder
30
+ * consumer, so the cast is internal).
31
31
  */
32
32
  levelData?: unknown;
33
+ /**
34
+ * The scene's shared material library (`useScene.materials`), passed so a
35
+ * pure geometry builder can resolve `scene:<id>` slot refs without importing
36
+ * `useScene`. Populated by `<GeometrySystem>` for every `def.geometry` call;
37
+ * undefined for `def.floorplan`. `library:<id>` refs resolve against the
38
+ * static catalog and need no store, so builders only consult this for
39
+ * `scene:` refs.
40
+ */
41
+ materials?: Record<SceneMaterialId, SceneMaterial>;
33
42
  /**
34
43
  * Optional view state — only populated for `def.floorplan` builders. The
35
44
  * 2D floor-plan layer surfaces selection / hover here so kinds can vary
@@ -40,6 +49,7 @@ export type GeometryContext = {
40
49
  */
41
50
  viewState?: {
42
51
  selected: boolean;
52
+ unit: 'metric' | 'imperial';
43
53
  /** Marquee or programmatic highlight — shows selected chrome without keyboard focus. */
44
54
  highlighted: boolean;
45
55
  /** Pointer-hovered. */
@@ -60,6 +70,69 @@ export type GeometryContext = {
60
70
  palette: FloorplanPalette;
61
71
  };
62
72
  };
73
+ export type MeasurementSnapKind = 'endpoint' | 'midpoint' | 'edge' | 'center' | 'face' | 'ridge' | 'height';
74
+ export type MeasurementFeatureGeometry = {
75
+ kind: 'point';
76
+ point: MeasurementPoint;
77
+ } | {
78
+ kind: 'segment';
79
+ start: MeasurementPoint;
80
+ end: MeasurementPoint;
81
+ } | {
82
+ kind: 'path';
83
+ points: MeasurementPoint[];
84
+ closed?: boolean;
85
+ } | {
86
+ kind: 'polygon';
87
+ points: MeasurementPoint[];
88
+ };
89
+ export type MeasurementFeature = {
90
+ /** Stable within the node kind; presentation labels must not be used as IDs. */
91
+ id: string;
92
+ label: string;
93
+ snapKind: MeasurementSnapKind;
94
+ geometry: MeasurementFeatureGeometry;
95
+ /**
96
+ * Level-local surface normal for contact markers. Continuous features may
97
+ * provide the normal from `resolve(...)` after applying reference parameters.
98
+ */
99
+ normal?: MeasurementPoint;
100
+ /** Higher values win when multiple candidates occupy the same screen-space radius. */
101
+ priority?: number;
102
+ };
103
+ export type MeasurementFeatureBinding = {
104
+ featureId: string;
105
+ point: MeasurementPoint;
106
+ parameters?: Record<string, string | number | boolean>;
107
+ distance: number;
108
+ };
109
+ export type QuickMeasurementQuantity = 'length' | 'area' | 'volume';
110
+ export type QuickMeasurementMetric = {
111
+ key: string;
112
+ label: string;
113
+ abbreviation: string;
114
+ quantity: QuickMeasurementQuantity;
115
+ /** Canonical metres, square metres, or cubic metres according to `quantity`. */
116
+ value: number;
117
+ };
118
+ export type QuickMeasurementReport = {
119
+ title: string;
120
+ kindLabel: string;
121
+ /** Stable level-local label anchor chosen by the node kind. */
122
+ anchor: MeasurementPoint;
123
+ metrics: QuickMeasurementMetric[];
124
+ note?: string;
125
+ };
126
+ export type MeasurementContribution<N = AnyNode> = {
127
+ /** Enumerates semantic candidates for hover, quick measure, and snapping. */
128
+ features: (node: N, ctx: GeometryContext) => MeasurementFeature[];
129
+ /** Resolve IDs that cannot be fully enumerated by `features`. */
130
+ resolve?: (node: N, ctx: GeometryContext, reference: MeasurementFeatureReference) => MeasurementFeature | null;
131
+ /** Kind-aware nearest semantic binding for a level-local surface hit. */
132
+ match?: (node: N, ctx: GeometryContext, point: MeasurementPoint, maxDistance: number) => MeasurementFeatureBinding | null;
133
+ /** Live, non-persistent quantities shown by the smart measurement tool. */
134
+ quickMeasure?: (node: N, ctx: GeometryContext) => QuickMeasurementReport | null;
135
+ };
63
136
  export type FloorplanPalette = {
64
137
  selectedStroke: string;
65
138
  selectedFill: string;
@@ -133,11 +206,62 @@ export type FloorplanStyle = {
133
206
  */
134
207
  cursor?: string;
135
208
  };
209
+ export type NodePort = {
210
+ /** Stable identifier within the node, e.g. 'start', 'end', 'branch'. */
211
+ id: string;
212
+ /** Level-local meters. */
213
+ position: readonly [number, number, number];
214
+ /** Unit vector pointing OUT of the port (away from the node body). */
215
+ direction: readonly [number, number, number];
216
+ /** Nominal connection diameter in inches. For a rect / oval port this is
217
+ * the area-equivalent round size, so a round run still mates sensibly. */
218
+ diameter: number;
219
+ /** Which distribution loop the port belongs to, e.g. 'supply' | 'return'. */
220
+ system?: string;
221
+ /** Cross-section of the connection. Omitted = round at `diameter`. A duct
222
+ * run joining a rect / oval port adopts this shape and rolls its
223
+ * cross-section to line up with the collar. */
224
+ shape?: 'round' | 'rect' | 'oval';
225
+ /** Rect / oval cross-section in inches: width is the collar's horizontal
226
+ * face at roll 0, height the vertical one. */
227
+ width?: number;
228
+ height?: number;
229
+ };
136
230
  export type ToolHint = {
137
231
  /** Key combo or input label, e.g. 'Left click', 'Shift', 'Esc'. */
138
232
  key: string;
139
233
  /** Description of what the input does. Sentence case. */
140
234
  label: string;
235
+ /**
236
+ * Only show this hint once the in-progress draft has at least this many
237
+ * vertices (reads `useEditor.draftVertexCount`). Lets a polygon tool's
238
+ * "Finish" hint appear only when finishing is actually possible (≥ 3 points),
239
+ * so the HUD reflects reality. Omit for always-shown hints.
240
+ */
241
+ minDraftVertices?: number;
242
+ /**
243
+ * Render this hint as a live mode chip — like the snapping / continuation
244
+ * chips — instead of a static key row: the HUD shows the current value's
245
+ * label and clicking the row (or pressing `key`) cycles it. The kind owns
246
+ * the state (typically its own ephemeral store); `label` above becomes the
247
+ * fallback when the current value has no entry in `chip.labels`.
248
+ */
249
+ chip?: ToolHintChip;
250
+ };
251
+ export type ToolHintChip = {
252
+ /** Subscribe to live value changes (Zustand-store-like); returns unsubscribe. */
253
+ subscribe: (onChange: () => void) => () => void;
254
+ /** Current value token, resolved through `labels` / `icons` for display. */
255
+ value: () => string;
256
+ /** Advance to the next value — the chip's click action. The keyboard path is
257
+ * the tool's own handler for the hint's `key`; both must hit the same store. */
258
+ cycle: () => void;
259
+ /** value → chip row label, e.g. `{ cabinet: 'Type: Cabinet', island: 'Type: Island' }`. */
260
+ labels: Record<string, string>;
261
+ /** value → iconify icon name. */
262
+ icons?: Record<string, string>;
263
+ /** Hover tooltip, e.g. 'Placement type — click or press I to cycle'. */
264
+ tooltip?: string;
141
265
  };
142
266
  export type FloorplanGeometry = ({
143
267
  kind: 'path';
@@ -396,7 +520,8 @@ export type FloorplanGeometry = ({
396
520
  }
397
521
  /**
398
522
  * Centered length / distance label. Renders as a small rounded
399
- * background plate with text, oriented along `angle` (radians). The
523
+ * background plate by default, or as outlined text when `appearance`
524
+ * is `'outlined'`, oriented along `angle` (radians). The
400
525
  * 2D layer flips the label upright when it would otherwise be upside
401
526
  * down. Use this for simple "what length am I?" badges (fence, item
402
527
  * width, draft preview).
@@ -408,6 +533,26 @@ export type FloorplanGeometry = ({
408
533
  text: string;
409
534
  /** Rotation in radians. The renderer auto-flips to keep text upright. */
410
535
  angle: number;
536
+ /** Keep the plate horizontal on screen instead of following a segment. */
537
+ screenUpright?: boolean;
538
+ /** Perpendicular screen-pixel offset from the anchor segment. */
539
+ offsetPx?: number;
540
+ /** Match map-style labels without changing the default editing badge. */
541
+ appearance?: 'plate' | 'outlined';
542
+ }
543
+ /**
544
+ * Equal-spacing badge — a small accent pill marking one gap in a run of
545
+ * (near-)equally-spaced openings (the 2D counterpart of Figma's "=" distance
546
+ * chips). Emitted once per equal gap so the repeated value reads as a rhythm.
547
+ * `text` is the shared gap distance; `angle` orients the pill along the wall
548
+ * (the renderer auto-flips it upright).
549
+ */
550
+ | {
551
+ kind: 'equal-spacing-badge';
552
+ point: FloorplanPoint;
553
+ text: string;
554
+ /** Rotation in radians. */
555
+ angle: number;
411
556
  }
412
557
  /**
413
558
  * Architect's dimension overlay — extension lines from the edge
@@ -447,17 +592,11 @@ export type FloorplanAffordanceSession = {
447
592
  /** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */
448
593
  affectedIds: AnyNodeId[];
449
594
  /**
450
- * Run a single drag tick. Two patterns are supported:
451
- * - **Scene-write preview**: implementation calls `scene.updateNodes`
452
- * each tick; the dispatcher captures a pre-drag snapshot and runs
453
- * a single-undo dance on commit (revert resume re-apply diff).
454
- * Suitable for affordances whose commit is a pure diff of the
455
- * affected fields.
456
- * - **Live-override preview**: implementation publishes per-frame
457
- * overrides to `useLiveNodeOverrides` (or another preview store);
458
- * `useScene` stays untouched during the drag. The session must
459
- * also expose `commit()` below, since there's no scene diff for
460
- * the dispatcher to write back.
595
+ * Run a single drag tick. New implementations publish per-frame overrides to
596
+ * `useLiveNodeOverrides` / `useLiveTransforms` (or another preview store);
597
+ * `useScene` stays untouched during the drag. Legacy sessions that still
598
+ * write preview state into `useScene` are supported only by the dispatcher's
599
+ * snapshot-diff compatibility path.
461
600
  *
462
601
  * Snap logic, linked-node cascade, and angle locking live here.
463
602
  */
@@ -472,13 +611,12 @@ export type FloorplanAffordanceSession = {
472
611
  */
473
612
  canCommit(): boolean;
474
613
  /**
475
- * Optional atomic-commit hook — mirror of the same field on
476
- * `FloorplanMoveTargetSession`. When present, the dispatcher
477
- * reverts to the pre-drag baseline (no-op if `apply()` never wrote
478
- * to scene), resumes history, then calls `commit()` instead of
479
- * re-applying a diff. The session owns the full final write
480
- * (typically `applyNodeChanges` or `updateNodes`) plus clearing any
481
- * live overrides it published in `apply()`.
614
+ * Optional atomic commit hook — mirror of the same field on
615
+ * `FloorplanMoveTargetSession`. New live-preview sessions should provide
616
+ * this so the dispatcher can revert to the pre-drag baseline, resume
617
+ * history, then call `commit()`. The session owns the full final write
618
+ * (typically `applyNodeChanges` or `updateNodes`) plus clearing any live
619
+ * overrides it published in `apply()`.
482
620
  */
483
621
  commit?(): void;
484
622
  };
@@ -499,8 +637,10 @@ export type FloorplanMoveTargetSession = {
499
637
  /** Node IDs the move may mutate. Used by the dispatcher for snapshot capture. */
500
638
  affectedIds: AnyNodeId[];
501
639
  /**
502
- * Single move-preview tick. Implementations call `scene.updateNodes`
503
- * directly to drive the live preview (no separate draft state).
640
+ * Single move-preview tick. Implementations publish per-frame overrides to
641
+ * `useLiveNodeOverrides` / `useLiveTransforms`; the scene store stays
642
+ * untouched during the drag. Provide `commit()` below so the final scene
643
+ * write happens once at the end.
504
644
  */
505
645
  apply(args: {
506
646
  planPoint: FloorplanAffordancePoint;
@@ -529,6 +669,14 @@ export type FloorplanMoveTargetSession = {
529
669
  * returns.
530
670
  */
531
671
  commit?(): void;
672
+ /**
673
+ * Optional R-key flip toggle. Kinds with a directional facing
674
+ * (door / window: front ↔ back) implement this so the overlay can flip
675
+ * the orientation mid-placement before commit. Toggling just records the
676
+ * intent; the visible change lands when the overlay re-runs `apply()` with
677
+ * the last pointer position. Kinds with no facing leave it unset.
678
+ */
679
+ flipSide?(): void;
532
680
  };
533
681
  export type FloorplanMoveTarget<N> = (args: {
534
682
  node: N;
@@ -541,17 +689,91 @@ export type Plugin = {
541
689
  };
542
690
  export type AnyNodeDefinition = NodeDefinition<ZodObject<any>>;
543
691
  export type SurfaceRole = 'wall' | 'floor' | 'ceiling' | 'roof' | 'joinery' | 'glazing' | 'furnishing';
692
+ /** Role a kind plays in a duct / pipe / lineset distribution system. */
693
+ export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment';
694
+ /**
695
+ * A kind's snapping profile (see `NodeDefinition.snapProfile`).
696
+ * - `'item'` free object (furniture/fixtures): lines-default, no grid lattice, no angle.
697
+ * - `'structural'` walls / fences / slabs / ceilings / roofs / zones: grid-default, and an
698
+ * angle lock while *setting direction* (drafting a run/polygon, dragging an endpoint or a
699
+ * polygon vertex). A plain translate or a curve of a structural node has no angle.
700
+ */
701
+ export type SnapProfile = 'item' | 'structural';
702
+ /**
703
+ * How a kind is treated by the GLB bake and the baked `/viewer`. See
704
+ * plans/editor-plugin-trees-example.md → Part D.
705
+ * - `'static'` (default) — baked as geometry; the viewer shows the baked mesh.
706
+ * - `'strip'` — excluded from the bake; the viewer rebuilds it live from
707
+ * `scene_graph` via the registry renderer (heavy reference assets: scans, guides).
708
+ * - `'replace'` — baked as *static* geometry (a plain glTF viewer still shows it),
709
+ * but our viewer removes the baked meshes for this kind and re-renders the node
710
+ * live from `scene_graph` — for dynamic content whose runtime look differs from a
711
+ * frozen snapshot (shader wind, interactivity), rendered through its own path.
712
+ */
713
+ export type BakePolicy = 'static' | 'strip' | 'replace';
714
+ export type ExportAnimationContext<N = AnyNode> = {
715
+ node: N;
716
+ object: Object3D;
717
+ };
544
718
  export type NodeDefinition<S extends ZodObject<any>> = {
545
719
  kind: string;
546
720
  schemaVersion: number;
547
721
  schema: S;
548
722
  category: NodeCategory;
549
723
  surfaceRole?: SurfaceRole;
724
+ /**
725
+ * Show a floor direction-triangle while placing/moving — the kind has a
726
+ * meaningful front. `true` points along the node's local +Z (forward).
727
+ * `{ reversed: true }` points along local -Z, for kinds whose front is the
728
+ * -Z side (a stair faces *out* of its run: you approach from the low end,
729
+ * which sits on the -Z side of the footprint).
730
+ */
731
+ facingIndicator?: boolean | {
732
+ reversed?: boolean;
733
+ };
734
+ /**
735
+ * Role this kind plays in a distribution system (HVAC duct / DWV pipe /
736
+ * refrigerant lineset). Lets the system-graph summary classify a
737
+ * component without branching on `node.type`:
738
+ * - `'run'` — a duct / pipe / lineset segment (carries `path`).
739
+ * - `'fitting'` — an inline fitting (elbow / tee / reducer / trap).
740
+ * - `'terminal'` — a grille / register / diffuser endpoint.
741
+ * - `'equipment'` — a furnace / air handler / condenser source.
742
+ * Kinds outside any distribution system leave this unset.
743
+ */
744
+ distributionRole?: DistributionRole;
745
+ /**
746
+ * When `distributionRole` is `'fitting'`, controls whether this fitting
747
+ * is dragged as a rigid follower when a connected run endpoint moves.
748
+ *
749
+ * - `true` (default for `distributionRole === 'fitting'`): the fitting
750
+ * translates rigidly so its mated collar stays on the moved port — the
751
+ * right behaviour for in-line fittings (elbows, tees, wyes, crosses).
752
+ * - `false`: the fitting is anchored in space; moving a connected run
753
+ * endpoint stretches the run arm, not the fitting. Use this for
754
+ * fixed-position fixtures like `pipe-trap`.
755
+ *
756
+ * Has no effect when `distributionRole` is not `'fitting'`.
757
+ */
758
+ portConnectivityFollow?: boolean;
550
759
  defaults: () => Omit<z.infer<S>, 'id' | 'type'>;
551
760
  migrate?: Record<number, (old: unknown) => unknown>;
552
761
  capabilities: Capabilities;
553
762
  relations?: Relations;
554
763
  parametrics?: ParametricDescriptor<z.infer<S>>;
764
+ /**
765
+ * Whether scene mutations add this kind to `dirtyNodes` (the per-frame
766
+ * rebuild queue). Default true. Set `false` for structural/organizational
767
+ * kinds (site, building, level, zone, guide) that no dirty consumer ever
768
+ * rebuilds — no `def.geometry`, no legacy viewer system, no
769
+ * `capabilities.floorPlaced`. Their marks are never cleared, so they
770
+ * accumulate for the whole session, defeat every consumer's empty-set
771
+ * early exit each frame, and pollute the perf overlay's DIRTY readout.
772
+ * If a kind later gains a dirty consumer, delete the flag.
773
+ */
774
+ dirtyTracking?: boolean;
775
+ /** GLB bake treatment for this kind (default `'static'`). See {@link BakePolicy}. */
776
+ bake?: BakePolicy;
555
777
  /**
556
778
  * Renderer for this kind. Optional under the three-checkbox composition
557
779
  * model (see `wiki/architecture/node-definitions.md`): when omitted, the
@@ -564,6 +786,17 @@ export type NodeDefinition<S extends ZodObject<any>> = {
564
786
  * already null-guard on `def.renderer` so omitting it is safe.
565
787
  */
566
788
  renderer?: RendererSource<z.infer<S>>;
789
+ /**
790
+ * Collective renderer the baked `/viewer` uses to re-render this kind live when
791
+ * `bake === 'replace'`. It receives every node of this kind under one baked
792
+ * level and is portaled into that level's `Object3D`, so an instanced kind can
793
+ * draw them as instanced meshes in level-local space (riding level stacking for
794
+ * free) instead of the frozen baked meshes (which the viewer hides). Needed when
795
+ * the normal per-node `renderer` can't stand alone in a baked scene (e.g. an
796
+ * instanced kind whose `renderer` is an invisible selection proxy and whose real
797
+ * geometry comes from a `system`). See plans/editor-plugin-trees-example.md → Part D.
798
+ */
799
+ bakeReplaceRenderer?: BakeReplaceRenderer<z.infer<S>>;
567
800
  /**
568
801
  * Pure geometry builder. When set, the framework's generic
569
802
  * `<GeometrySystem>` calls this on every dirty mark — `nodes` keyed by
@@ -577,6 +810,14 @@ export type NodeDefinition<S extends ZodObject<any>> = {
577
810
  * work (animations, named-mesh material poking).
578
811
  */
579
812
  geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D;
813
+ /**
814
+ * Optional GLB export animation hook for kind-owned moving parts. The
815
+ * exporter calls this against the cloned export subtree after material/mesh
816
+ * cleanup; implementations should leave `object` in its intended rest pose
817
+ * and return engine-agnostic Three.js clips that target objects inside that
818
+ * subtree.
819
+ */
820
+ exportAnimation?: (ctx: ExportAnimationContext<z.infer<S>>) => AnimationClip | AnimationClip[] | null | undefined;
580
821
  /**
581
822
  * Optional cache key over the geometry-relevant inputs of `node`. When
582
823
  * set, `<GeometrySystem>` skips the rebuild (dispose + re-create the
@@ -608,6 +849,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
608
849
  * runs once even when many walls are dirty in the same frame.
609
850
  */
610
851
  computeLevelData?: (siblings: ReadonlyArray<z.infer<S>>) => unknown;
852
+ /**
853
+ * Floor-plan level-batch precompute hook. The floor-plan layer calls this
854
+ * once per level per render pass, de-duplicated by kind, before the
855
+ * per-node `def.floorplan` calls. The result lands in `ctx.levelData` for
856
+ * every node of this kind in the level.
857
+ *
858
+ * Used to hoist cross-sibling floor-plan work that would otherwise be
859
+ * O(N²) when rebuilding every node in a kind — e.g. wall mitering. `nodes`
860
+ * is the live-merged scene snapshot; `siblings` is every node of this kind
861
+ * in the level, also live-merged.
862
+ */
863
+ computeFloorplanLevelData?: (args: {
864
+ siblings: ReadonlyArray<z.infer<S>>;
865
+ nodes: Record<string, AnyNode>;
866
+ }) => unknown;
611
867
  /**
612
868
  * Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
613
869
  * plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
@@ -623,6 +879,10 @@ export type NodeDefinition<S extends ZodObject<any>> = {
623
879
  * the legacy `floorplan-panel.tsx` monolith.
624
880
  */
625
881
  floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null;
882
+ /** Extra node IDs whose committed changes invalidate this node's floor-plan cache. */
883
+ floorplanDependencies?: (node: z.infer<S>) => readonly AnyNodeId[];
884
+ /** Stable semantic geometry that associative measurement anchors may reference. */
885
+ measurement?: MeasurementContribution<z.infer<S>>;
626
886
  /**
627
887
  * Which scope the floor-plan layer walks to find instances of this
628
888
  * kind. Default `'level'` — the layer's DFS from the active level id
@@ -642,8 +902,9 @@ export type NodeDefinition<S extends ZodObject<any>> = {
642
902
  * `def.floorplanAffordances?.[affordance].start({...})` on pointer-down,
643
903
  * receives a session, calls `apply(...)` on pointer-move and
644
904
  * `commit()` / `cancel()` on pointer-up / pointer-cancel. The session
645
- * mutates scene state directly during `apply`; the dispatcher handles
646
- * the snapshot + single-undo dance around it.
905
+ * previews through live override/transform stores during `apply`. Legacy
906
+ * sessions that still write preview state into `useScene` are handled by
907
+ * the dispatcher's snapshot + single-undo compatibility path.
647
908
  *
648
909
  * Mirrors the existing 3D `affordanceTools` map but for 2D SVG events,
649
910
  * and operates on plain JS data instead of mounting React. Kinds with
@@ -665,6 +926,71 @@ export type NodeDefinition<S extends ZodObject<any>> = {
665
926
  * unset and rely on the generic overlay path.
666
927
  */
667
928
  floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>;
929
+ /**
930
+ * Extra floating-menu actions contributed by this kind. The editor renders
931
+ * the returned descriptors generically; kind-specific mutation stays here
932
+ * and runs through `SceneApi`.
933
+ */
934
+ quickActions?: NodeQuickActionProvider<z.infer<S>>;
935
+ /**
936
+ * Sidebar-tree presentation hooks. Lets a kind reshape how the generic
937
+ * scene tree walks its subtree — hiding derived/managed nodes and
938
+ * flattening intermediate containers — without the tree hardcoding any
939
+ * kind. The tree consults these for every node whose kind declares them;
940
+ * kinds whose scene-graph shape matches their desired tree shape omit
941
+ * this entirely.
942
+ */
943
+ tree?: {
944
+ /**
945
+ * Hide this node's row in the sidebar tree (e.g. derived/managed nodes
946
+ * whose contents surface elsewhere via `childIds`).
947
+ */
948
+ hidden?: (node: AnyNode, nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>) => boolean;
949
+ /**
950
+ * Optional tree-row label override. When unset the host falls back to
951
+ * `node.name` / `def.presentation.label`.
952
+ */
953
+ label?: (node: AnyNode, nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>) => string;
954
+ /**
955
+ * Override the child ids the sidebar tree renders under this node.
956
+ * When unset the tree falls back to the node's own `children`.
957
+ */
958
+ childIds?: (node: AnyNode, nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>) => AnyNodeId[];
959
+ };
960
+ /**
961
+ * Selection-proxy behavior overrides. A node opts into proxying by writing
962
+ * `metadata.nodeSelectionProxyId` (see `lib/selection-proxy.ts` for the
963
+ * metadata contract); grouped affordances (move / rotate) then key off the
964
+ * proxy target. `bypassDirectPick` lets a kind keep the proxy for those
965
+ * grouped affordances while still routing a direct canvas pick to the
966
+ * clicked node itself — e.g. corner-generated cabinet modules stay
967
+ * individually selectable even though they proxy to their run.
968
+ */
969
+ selectionProxy?: {
970
+ /** Return true when a direct pick of `node` should select it instead of
971
+ * its resolved proxy target. */
972
+ bypassDirectPick?: (node: AnyNode, proxyTarget: AnyNode) => boolean;
973
+ };
974
+ /**
975
+ * Geometry reads sibling/parent/child nodes (e.g. wall miters, opening
976
+ * dimensions); the floor-plan layer must rebuild it whenever a
977
+ * sibling-affecting node is being dragged live.
978
+ */
979
+ floorplanDependsOnSiblings?: boolean;
980
+ /**
981
+ * Optional hook for kinds whose floor-plan cache invalidation reaches beyond
982
+ * the default framework relationships (wall junction neighbours, host wall
983
+ * opening cuts, gutter siblings under one roof). Called when a node of this
984
+ * kind has a live drag/override in flight; returns the extra entry ids that
985
+ * must rebuild this frame.
986
+ */
987
+ floorplanAffectedIds?: (args: {
988
+ nodeId: AnyNodeId;
989
+ node: AnyNode;
990
+ nodes: Record<AnyNodeId, AnyNode>;
991
+ liveTransforms: Map<string, LiveTransformLike>;
992
+ liveOverrides: Map<string, Record<string, unknown>>;
993
+ }) => readonly AnyNodeId[];
668
994
  /**
669
995
  * Optional hook letting a kind project the `useLiveNodeOverrides` map
670
996
  * into a fresh `nodes` snapshot before its `def.floorplan` builder
@@ -687,8 +1013,18 @@ export type NodeDefinition<S extends ZodObject<any>> = {
687
1013
  floorplanSiblingOverrides?: (args: {
688
1014
  nodeId: AnyNodeId;
689
1015
  nodes: Record<AnyNodeId, AnyNode>;
1016
+ liveTransforms: Map<string, LiveTransformLike>;
690
1017
  liveOverrides: Map<string, Record<string, unknown>>;
691
1018
  }) => Record<AnyNodeId, AnyNode>;
1019
+ /**
1020
+ * Typed connection points this kind exposes (duct/pipe open ends,
1021
+ * fitting collars, equipment plenums). Pure function of the node —
1022
+ * returns LEVEL-LOCAL positions/directions (the kind applies its own
1023
+ * transform). Consumed by placement tools for port-snapping and, in a
1024
+ * later slice, by the system graph for connectivity. Kinds with no
1025
+ * connectable geometry omit this.
1026
+ */
1027
+ ports?: (node: z.infer<S>) => NodePort[];
692
1028
  system?: SystemContribution;
693
1029
  tool?: LazyComponent;
694
1030
  /**
@@ -720,6 +1056,27 @@ export type NodeDefinition<S extends ZodObject<any>> = {
720
1056
  * its bespoke helper component instead.
721
1057
  */
722
1058
  toolHints?: ToolHint[];
1059
+ /**
1060
+ * Which snapping profile this kind uses, so the editor's contextual snapping
1061
+ * HUD + snap math + force-place affordance are node-declared rather than
1062
+ * switched on the kind name (`'item'` free object vs `'structural'` wall/slab/
1063
+ * surface — see `SnapProfile`). The angle lock is derived from the *action*
1064
+ * (setting direction), not declared here. Also gates the "force place" hint:
1065
+ * structural kinds don't collision-reject, so they don't show it.
1066
+ * Omit it for kinds whose placement/move tools haven't moved onto the unified
1067
+ * snapping model yet — they get no snapping chip (no Shift-cycle) until they do.
1068
+ */
1069
+ snapProfile?: SnapProfile;
1070
+ /**
1071
+ * For `structural` kinds: does drafting this kind set a DIRECTION (so the
1072
+ * angle-lock snapping mode is meaningful)? Wall/fence/slab/ceiling drafting
1073
+ * draws directed edges → `true` (the default). Roof/stair/elevator are placed
1074
+ * as axis-aligned footprints, not directional draws → `false`, so their
1075
+ * drafting uses the no-angle `polygon` snap context (grid / lines / off)
1076
+ * instead of the angle-bearing `wall` context. Ignored for `item` kinds
1077
+ * (their context never carries an angle lock).
1078
+ */
1079
+ snapDraftDirectional?: boolean;
723
1080
  /**
724
1081
  * Optional translucent preview of the node — used by the move tool to
725
1082
  * show where the node will land, and by the placement tool's cursor.
@@ -773,6 +1130,16 @@ export type KeyboardActions = {
773
1130
  r?: KeyboardAction;
774
1131
  /** T / Shift+T secondary action. */
775
1132
  t?: KeyboardAction;
1133
+ /** E interaction action — operate the node (doors, drawers, appliances). */
1134
+ e?: KeyboardAction;
1135
+ /**
1136
+ * Set for kinds whose R/T rotation turns around a user-cyclable world
1137
+ * axis (Alt cycles Y → X → Z) — duct / pipe fittings with full 3D
1138
+ * orientation. The floating action menu reads this to surface the
1139
+ * active-axis pill above the selected node; kinds with plain Y-only
1140
+ * rotation omit it.
1141
+ */
1142
+ axisCycling?: boolean;
776
1143
  };
777
1144
  export type KeyboardAction = {
778
1145
  /**
@@ -809,6 +1176,9 @@ export type Presentation = {
809
1176
  /** Set true for kinds that exist but should NOT appear in the palette
810
1177
  * (containers like `site`/`building`/`level`, internal nodes). */
811
1178
  hidden?: boolean;
1179
+ /** Set false when selection is edited directly through in-scene affordances
1180
+ * and the generic floating action menu would duplicate or conflict with them. */
1181
+ actionMenu?: boolean;
812
1182
  };
813
1183
  export type IconRef =
814
1184
  /** Iconify identifier, e.g. `lucide:square`. Matches the @iconify-react
@@ -856,13 +1226,27 @@ export type RendererSource<N> = {
856
1226
  kind: 'instanced-glb';
857
1227
  getAsset: (n: N) => AssetRef;
858
1228
  };
1229
+ /**
1230
+ * A collective renderer for the baked `/viewer` (see `NodeDefinition.bakeReplaceRenderer`):
1231
+ * a lazy module whose default export takes all of one level's `replace` nodes and
1232
+ * is portaled into that baked level. Three-free indirection, same as `system`.
1233
+ */
1234
+ export type BakeReplaceRenderer<N> = {
1235
+ module: () => Promise<{
1236
+ default: ComponentType<{
1237
+ nodes: N[];
1238
+ }>;
1239
+ }>;
1240
+ };
859
1241
  export type AssetRef = {
860
1242
  id: string;
861
1243
  src: string;
862
1244
  };
863
1245
  export type SystemContribution = {
864
1246
  module: () => Promise<{
865
- default: ComponentType;
1247
+ default: ComponentType<{
1248
+ sceneApi: SceneApi;
1249
+ }>;
866
1250
  }>;
867
1251
  priority?: number;
868
1252
  };
@@ -870,6 +1254,22 @@ export type McpOverrides = {
870
1254
  description?: string;
871
1255
  semantic?: boolean;
872
1256
  };
1257
+ export type DuplicateSubtreeCloneArgs = {
1258
+ root: AnyNode;
1259
+ descendants: AnyNode[];
1260
+ rootId: AnyNodeId;
1261
+ rootPatch: Partial<AnyNode>;
1262
+ nodes: Readonly<Record<AnyNodeId, AnyNode>>;
1263
+ };
1264
+ export type DuplicateSubtreeCloneResult = {
1265
+ root?: AnyNode;
1266
+ descendants?: AnyNode[];
1267
+ parentId?: AnyNodeId | null;
1268
+ };
1269
+ export type DuplicableConfig = {
1270
+ subtree?: boolean;
1271
+ prepareSubtreeClone?: (args: DuplicateSubtreeCloneArgs) => DuplicateSubtreeCloneResult;
1272
+ };
873
1273
  export type Capabilities = {
874
1274
  movable?: MovableConfig;
875
1275
  rotatable?: RotatableConfig;
@@ -878,7 +1278,7 @@ export type Capabilities = {
878
1278
  cuttable?: CuttableConfig;
879
1279
  snappable?: SnappableConfig;
880
1280
  surfaces?: SurfacesConfig;
881
- duplicable?: boolean;
1281
+ duplicable?: boolean | DuplicableConfig;
882
1282
  deletable?: boolean;
883
1283
  groupable?: boolean;
884
1284
  selectable?: SelectableConfig;
@@ -901,11 +1301,15 @@ export type Capabilities = {
901
1301
  * the box to wrap just the shaft they're moving.
902
1302
  *
903
1303
  * `size`: `[width, height, depth]` in the node's local frame.
1304
+ * `center`: optional full local center. Use this when the footprint is
1305
+ * offset from the node origin, such as a composite cabinet run after modules
1306
+ * have been deleted or shifted.
904
1307
  * `centerY`: optional Y center; defaults to `size[1] / 2` (box sits on
905
1308
  * the ground plane). Override when the local origin isn't at the base.
906
1309
  */
907
1310
  dragBounds?: (node: AnyNode, nodes?: Readonly<Record<string, AnyNode>>) => {
908
1311
  size: [number, number, number];
1312
+ center?: [number, number, number];
909
1313
  centerY?: number;
910
1314
  };
911
1315
  roofAccessory?: RoofAccessoryConfig;
@@ -916,6 +1320,25 @@ export type Capabilities = {
916
1320
  */
917
1321
  ceilingCut?: CeilingCutCapability;
918
1322
  paint?: PaintCapability;
1323
+ /**
1324
+ * In-scene click action dispatch (e.g. a cooktop knob toggling its burner).
1325
+ * The editor's selection-manager walks the pointer hit's object chain
1326
+ * through `resolveTarget`; when it returns non-null, `activate` runs and a
1327
+ * `true` return consumes the click (no selection change). Keeps interactive
1328
+ * sub-meshes registry-driven instead of `if (node.type === '<kind>')` arms
1329
+ * in the editor.
1330
+ */
1331
+ sceneAction?: SceneActionCapability;
1332
+ /**
1333
+ * Declares the kind's paintable slots — the `{ slotId, label, default }`
1334
+ * contract shared by items (scanned from the GLB) and procedural kinds
1335
+ * (declared here). Procedural generators tag their emitted geometry with
1336
+ * `userData.slotId` and resolve each slot's material from
1337
+ * `node.slots[slotId]` → this declaration's `default` → role colour. The
1338
+ * declaration is a function of the node because a kind's slot set can depend
1339
+ * on its parameters (a shelf has a `back` slot only when it has a back).
1340
+ */
1341
+ slots?: (node: AnyNode) => SlotDeclaration[];
919
1342
  /**
920
1343
  * Kind is placed by clicking on a wall (door, window). When set, the
921
1344
  * floor-plan layer lets wall background clicks pass through during
@@ -1006,7 +1429,31 @@ export type Capabilities = {
1006
1429
  * the `selectedMaterialTarget` round-trip, the paint-mode toolbar.
1007
1430
  * Kinds with no paint behaviour omit `paint`.
1008
1431
  */
1432
+ /**
1433
+ * One paintable slot a kind exposes. `slotId` is the stable key written into
1434
+ * `node.slots`; `label` is the human name (sentence case). `default` is the
1435
+ * slot's fallback appearance when no override is set — either a `MaterialRef`
1436
+ * (`library:<id>` / `scene:<id>`) or a `#rrggbb` colour. Mirrors the shape
1437
+ * items derive from their GLB material names.
1438
+ */
1439
+ export type SlotDeclaration = {
1440
+ slotId: string;
1441
+ label: string;
1442
+ default?: string;
1443
+ };
1009
1444
  export type PaintCapability = {
1445
+ /**
1446
+ * Material-picker target represented by this paint capability. Omit when
1447
+ * the kind should not show up as a toolbar target from plain selection.
1448
+ */
1449
+ materialTarget?: MaterialTarget;
1450
+ /**
1451
+ * Opt this kind into the painter's `room` application scope: a paint click
1452
+ * spreads to every same-kind node bounding the clicked node's room (walls and
1453
+ * slabs). The room geometry is resolved by the editor from `Space.polygon`;
1454
+ * this flag only declares that the kind participates.
1455
+ */
1456
+ roomScope?: boolean;
1010
1457
  /**
1011
1458
  * Resolve which logical surface the user clicked. Returns `null`
1012
1459
  * when the face shouldn't be painted (e.g. interior slot exposed
@@ -1018,6 +1465,14 @@ export type PaintCapability = {
1018
1465
  * `role`. Returned partial is merged into the node by the editor.
1019
1466
  */
1020
1467
  buildPatch: (args: PaintPatchArgs) => Partial<AnyNode>;
1468
+ /**
1469
+ * Optional: fully own the click-commit instead of the default
1470
+ * `updateNode(node.id, buildPatch(...))`. Kinds whose commit has a side
1471
+ * effect (items create a scene material for one-off colours, then store a
1472
+ * `scene:<id>` ref) implement this; kinds that just patch the node omit it.
1473
+ * Must perform its mutations as a single undo step.
1474
+ */
1475
+ commit?: (args: PaintPatchArgs) => void;
1021
1476
  /**
1022
1477
  * Apply a preview to the kind's registered mesh subtree at
1023
1478
  * `role`. The kind builds whatever preview material(s) it needs
@@ -1046,6 +1501,52 @@ export type PaintCapability = {
1046
1501
  materialPreset: string | undefined;
1047
1502
  } | null;
1048
1503
  };
1504
+ /**
1505
+ * Per-kind in-scene click actions. A kind that builds interactive sub-meshes
1506
+ * (a gas-hob knob, a switch) tags them via `userData` in its geometry builder,
1507
+ * resolves the tag back out of the pointer hit in `resolveTarget`, and runs
1508
+ * the state change in `activate`. The editor owns only the generic dispatch:
1509
+ * walk the hit object's parent chain, and when `resolveTarget` returns
1510
+ * non-null, call `activate`; a `true` return consumes the click.
1511
+ *
1512
+ * `activate` receives a `SceneApi` so the kind never imports `useScene`
1513
+ * directly; transient animation frames may write through
1514
+ * `useLiveNodeOverrides` + `markDirty` and commit once at the end.
1515
+ */
1516
+ export type SceneActionCapability<T = unknown> = {
1517
+ /** Extract this kind's action target from one object in the hit chain. */
1518
+ resolveTarget: (object: {
1519
+ userData: Record<string, unknown>;
1520
+ }) => T | null;
1521
+ /** Run the action. Return `true` to consume the click (skip selection). */
1522
+ activate: (node: AnyNode, target: T, sceneApi: SceneApi) => boolean;
1523
+ };
1524
+ export type NodeQuickActionIcon = 'add-left' | 'add-right' | 'add' | 'convert';
1525
+ export type NodeQuickActionResult = {
1526
+ selectedIds?: AnyNodeId[];
1527
+ };
1528
+ export type NodeQuickAction = {
1529
+ id: string;
1530
+ label: string;
1531
+ title?: string;
1532
+ /**
1533
+ * Builtin glyph token (side-add arrows, convert) or an {@link IconRef}
1534
+ * for kind-owned marks — quick actions with bespoke glyphs ship them
1535
+ * from the kind's package instead of the menus hardcoding per-action
1536
+ * SVG.
1537
+ */
1538
+ icon?: NodeQuickActionIcon | IconRef;
1539
+ disabled?: boolean;
1540
+ history?: 'single';
1541
+ run: (args: {
1542
+ node: AnyNode;
1543
+ sceneApi: SceneApi;
1544
+ }) => NodeQuickActionResult | undefined;
1545
+ };
1546
+ export type NodeQuickActionProvider<N> = (args: {
1547
+ node: N;
1548
+ nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>;
1549
+ }) => NodeQuickAction[];
1049
1550
  export type PaintResolveArgs = {
1050
1551
  node: AnyNode;
1051
1552
  /**
@@ -1060,6 +1561,16 @@ export type PaintResolveArgs = {
1060
1561
  localPosition?: readonly [number, number, number];
1061
1562
  /** Optional: name of the three.js object that received the hit. Stair uses this. */
1062
1563
  hitObjectName?: string;
1564
+ /** Optional: the three.js object that received the pointer hit. Items read userData.slotId off it. */
1565
+ hitObject?: Object3D;
1566
+ /**
1567
+ * Optional: the pointer's world ray, so a kind can re-raycast its OWN subtree
1568
+ * to pick the precise sub-mesh under the cursor — independent of what the
1569
+ * shared scene raycast hit first. Door/window use this: their opening proxy
1570
+ * (a proud invisible cutout) wins the scene raycast over the wall in front of
1571
+ * the recessed door body, then they re-raycast their parts to find the slot.
1572
+ */
1573
+ ray?: Ray;
1063
1574
  };
1064
1575
  export type PaintPatchArgs = {
1065
1576
  node: AnyNode;
@@ -1104,6 +1615,23 @@ export type PaintEffectiveMaterialArgs = {
1104
1615
  */
1105
1616
  export type RoofAccessoryConfig = {
1106
1617
  buildCut?: (node: AnyNode, hostSegment: AnyNode) => BufferGeometry | null;
1618
+ /**
1619
+ * Which segment brushes `buildCut` subtracts from. Wall-face openings
1620
+ * (door / window) cut only the wall brush — subtracting the same box
1621
+ * from the shin / deck slabs is pointless work and creates tangential
1622
+ * / coplanar CSG cases near the gable and shed slopes. Defaults to
1623
+ * all three (skylight / dormer genuinely poke through the deck).
1624
+ */
1625
+ cutScope?: 'all' | 'wall';
1626
+ /**
1627
+ * The kind's own dirty-driven geometry system consumes its dirty
1628
+ * marks (door / window via DoorSystem / WindowSystem, which already
1629
+ * cascade to the host segment through `parentId`). The roof-merge
1630
+ * loop must then leave those marks alone — consuming them would
1631
+ * starve that system whenever it defers a rebuild (mesh not mounted
1632
+ * yet, per-frame rebuild budget exhausted).
1633
+ */
1634
+ dirtyHandledByOwnSystem?: boolean;
1107
1635
  };
1108
1636
  /**
1109
1637
  * Capability for kinds that cut a hole in their host ceiling when the node is
@@ -1126,8 +1654,103 @@ export type CapabilityCtx = {
1126
1654
  export type MovableConfig = {
1127
1655
  axes: ReadonlyArray<'x' | 'y' | 'z'>;
1128
1656
  gridSnap?: boolean;
1657
+ /**
1658
+ * Pin the dragged node to the cursor (absolute placement) instead of the
1659
+ * default offset-preserving drag, where the node moves by the cursor's
1660
+ * delta from where the drag started. Offset preservation suits large
1661
+ * furniture you grab by an edge; small connector-like kinds (duct
1662
+ * fittings) read as "lagging behind the mouse" — they want the cursor.
1663
+ */
1664
+ cursorAttached?: boolean;
1665
+ /**
1666
+ * Magnetically snap one of this kind's own ports onto a nearby scene
1667
+ * port while dragging — e.g. a register's collar onto a duct run end.
1668
+ * The dragged node shifts in XZ so its closest matching port lands on
1669
+ * the target port. Alt bypasses the snap. Kinds without `def.ports`
1670
+ * can't use this. Snap takes precedence over grid / alignment snap.
1671
+ */
1672
+ portSnap?: {
1673
+ /**
1674
+ * Distribution loops a target port must belong to (e.g.
1675
+ * `['supply', 'return']`). A target port with no `system` always
1676
+ * matches. Omit to match every port.
1677
+ */
1678
+ systems?: readonly string[];
1679
+ /** Snap radius in meters (XZ). Defaults to 0.5. */
1680
+ radius?: number;
1681
+ };
1682
+ /**
1683
+ * The node's `position` lives in a parent node's local frame (a cabinet
1684
+ * module inside its run) rather than the level frame. The generic move
1685
+ * tool converts the plan-frame cursor through these hooks, previews the
1686
+ * child via `useLiveNodeOverrides` (dirtying the parent so its composite
1687
+ * geometry re-flows), and skips the world-frame floor-collision box.
1688
+ */
1689
+ parentFrame?: MovableParentFrame;
1690
+ /**
1691
+ * Optional group-move snap for the generic multi-selection translate gizmo.
1692
+ * Returns an adjusted candidate position for this node when the moving group
1693
+ * should magnetically settle onto a nearby feature (for example, a cabinet
1694
+ * run snapping flush to a wall while the whole selected kitchen moves as one).
1695
+ */
1696
+ groupMoveSnap?: (args: GroupMoveSnapArgs) => [number, number, number] | null;
1129
1697
  override?: (ctx: CapabilityCtx) => MovableConfig | null;
1130
1698
  };
1699
+ export type MovableParentFrame = {
1700
+ /** The parent node owning the local frame; `null` → move in plan frame. */
1701
+ resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) => AnyNode | null;
1702
+ /** Parent's Y rotation, composed onto the child's preview rotation. */
1703
+ parentRotationY: (parent: AnyNode, nodes?: Readonly<Record<string, AnyNode>>) => number;
1704
+ localToPlan: (parent: AnyNode, local: readonly [number, number, number], nodes?: Readonly<Record<string, AnyNode>>) => [number, number, number];
1705
+ planToLocal: (parent: AnyNode, planX: number, localY: number, planZ: number, nodes?: Readonly<Record<string, AnyNode>>) => [number, number, number];
1706
+ /**
1707
+ * Optional 2D live-transform projection. Used by the floor-plan layer for
1708
+ * nodes whose live position is already in the parent-local frame and must not
1709
+ * be treated as a level-frame / floor-placed position.
1710
+ */
1711
+ floorplanLiveTransform?: (args: {
1712
+ node: AnyNode;
1713
+ live: LiveTransformLike;
1714
+ }) => AnyNode;
1715
+ /**
1716
+ * Optional magnetic snap in the parent's local frame (e.g. a module edge
1717
+ * mating flush with a sibling module). Runs when magnetic snapping is
1718
+ * active; returns the (possibly unchanged) local position.
1719
+ */
1720
+ magneticSnap?: (node: AnyNode, parent: AnyNode, local: readonly [number, number, number], nodes: Readonly<Record<string, AnyNode>>) => [number, number, number];
1721
+ /** Optional snap-line matches for the parent-frame magnetic snap result. */
1722
+ magneticSnapMatches?: (node: AnyNode, parent: AnyNode, local: readonly [number, number, number], snappedLocal: readonly [number, number, number], nodes: Readonly<Record<string, AnyNode>>) => ParentFrameSnapMatch[];
1723
+ /**
1724
+ * Called after a move of the child commits, with the LIVE (post-commit)
1725
+ * child and parent. Lets the kind run derived-state maintenance the
1726
+ * generic tool can't know about (a cabinet run re-flowing its layout and
1727
+ * re-anchoring linked corner runs to the moved module's new edge).
1728
+ */
1729
+ onCommit?: (node: AnyNode, parent: AnyNode, sceneApi: SceneApi) => void;
1730
+ };
1731
+ export type ParentFrameSnapMatch = {
1732
+ axis: 'x' | 'z';
1733
+ candidateNodeId: AnyNodeId;
1734
+ from: {
1735
+ x: number;
1736
+ z: number;
1737
+ };
1738
+ to: {
1739
+ x: number;
1740
+ z: number;
1741
+ };
1742
+ };
1743
+ export type GroupMoveSnapArgs = {
1744
+ node: AnyNode;
1745
+ candidatePosition: [number, number, number];
1746
+ movingIds: readonly AnyNodeId[];
1747
+ nodes: Readonly<Record<string, AnyNode>>;
1748
+ levelId: AnyNodeId | null;
1749
+ };
1750
+ export type LiveTransformLike = {
1751
+ position: [number, number, number];
1752
+ rotation: number;
1753
+ };
1131
1754
  export type RotatableConfig = {
1132
1755
  axes: ReadonlyArray<'x' | 'y' | 'z'>;
1133
1756
  snapAngles?: readonly number[];
@@ -1198,6 +1821,15 @@ export type FloorPlacedConfig = {
1198
1821
  footprint?: FloorPlacedFootprintResolver;
1199
1822
  footprints?: FloorPlacedFootprintsResolver;
1200
1823
  applies?: (node: AnyNode) => boolean;
1824
+ /**
1825
+ * Opt this kind into floor-placement collision: its footprint blocks other
1826
+ * placements (it's an obstacle in `canPlaceOnFloor`) AND its own
1827
+ * placement/move refuses to overlap another colliding footprint (red ghost,
1828
+ * Alt to force). Solid furniture-like kinds (item / shelf / column) set this;
1829
+ * markers and port-mated kinds (spawn / MEP / stair) leave it off so they
1830
+ * neither block nor get blocked. Default off.
1831
+ */
1832
+ collides?: boolean;
1201
1833
  };
1202
1834
  /**
1203
1835
  * Plan footprint a kind contributes to the alignment-anchor pool when it is
@@ -1241,7 +1873,54 @@ export type Relations = {
1241
1873
  export type ParametricDescriptor<N> = {
1242
1874
  groups: ParamGroup<N>[];
1243
1875
  invariants?: ReadonlyArray<(n: N) => Issue[]>;
1244
- derive?: (n: N) => Partial<N>;
1876
+ /**
1877
+ * Co-update hook for fields that must stay consistent when edited
1878
+ * from the inspector. Called with the node AFTER `patch` is merged
1879
+ * plus the patch itself (so the hook can tell which field the user
1880
+ * touched); whatever it returns is folded into the same update.
1881
+ * Direct store/MCP writes bypass it — keep real invariants in
1882
+ * `invariants`.
1883
+ */
1884
+ derive?: (next: N, patch: Partial<N>) => Partial<N>;
1885
+ /**
1886
+ * Cross-node companion to `derive`: after an inspector edit lands on
1887
+ * this node, return patches for OTHER nodes that must follow to keep
1888
+ * the scene consistent — e.g. duct runs re-trimmed onto a resized
1889
+ * fitting's collars. `prev` is the node before the edit, `next` after
1890
+ * (with `derive` already folded in). Applied in the same gesture via
1891
+ * `updateNodes`.
1892
+ */
1893
+ reconcile?: (prev: N, next: N) => Array<{
1894
+ id: AnyNodeId;
1895
+ data: Partial<AnyNode>;
1896
+ }>;
1897
+ /**
1898
+ * Deletion companion to `reconcile`: when a node of this kind is about
1899
+ * to be removed, return patches for OTHER nodes that must follow to
1900
+ * undo whatever the node imposed on its neighbours — e.g. an
1901
+ * auto-inserted elbow re-extends the duct runs it trimmed back onto the
1902
+ * corner it replaced. Called with the node and the live scene `nodes`
1903
+ * map BEFORE the deletion lands; patches targeting nodes also being
1904
+ * deleted are ignored. Applied in the same `set` as the delete so it's
1905
+ * one undo step. Fires only on `deleteNodes` (user-intent deletes) —
1906
+ * NOT on `applyNodeChanges`, whose deletes are internal re-routes that
1907
+ * rewrite neighbours explicitly in the same batch and would fight a
1908
+ * restore.
1909
+ */
1910
+ onDelete?: (node: N, nodes: Record<AnyNodeId, AnyNode>) => Array<{
1911
+ id: AnyNodeId;
1912
+ data: Partial<AnyNode>;
1913
+ }>;
1914
+ /**
1915
+ * Companion deletes that should be folded into the same user-intent delete
1916
+ * gesture — e.g. deleting the last module of a cabinet run should remove
1917
+ * the now-empty run node too. Called against the live scene BEFORE
1918
+ * deletion; returned ids are recursively expanded through the normal
1919
+ * descendant cascade. `pendingDeleteIds` holds every id already part of
1920
+ * the gesture so "would my parent become empty?" checks see sibling
1921
+ * deletes from the same multi-select.
1922
+ */
1923
+ onDeleteCascade?: (node: N, nodes: Record<AnyNodeId, AnyNode>, pendingDeleteIds: ReadonlySet<AnyNodeId>) => AnyNodeId[];
1245
1924
  customPanel?: () => Promise<{
1246
1925
  default: ComponentType<{
1247
1926
  node: N;