@pascal-app/core 1.0.0-beta.4 → 1.0.0

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 (263) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/dist/capture/index.d.ts +3 -0
  4. package/dist/capture/index.d.ts.map +1 -0
  5. package/dist/capture/index.js +2 -0
  6. package/dist/capture/schema.d.ts +417 -0
  7. package/dist/capture/schema.d.ts.map +1 -0
  8. package/dist/capture/schema.js +348 -0
  9. package/dist/capture/source.d.ts +60 -0
  10. package/dist/capture/source.d.ts.map +1 -0
  11. package/dist/capture/source.js +254 -0
  12. package/dist/events/bus.d.ts +55 -7
  13. package/dist/events/bus.d.ts.map +1 -1
  14. package/dist/events/hidden-wall-pointer-hold.d.ts +28 -0
  15. package/dist/events/hidden-wall-pointer-hold.d.ts.map +1 -0
  16. package/dist/events/hidden-wall-pointer-hold.js +37 -0
  17. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +12 -0
  18. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  19. package/dist/hooks/spatial-grid/spatial-grid-manager.js +39 -1
  20. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +2785 -1
  21. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  22. package/dist/hooks/spatial-grid/spatial-grid-sync.js +132 -19
  23. package/dist/hooks/spatial-grid/support-host-patch.d.ts +45 -1
  24. package/dist/hooks/spatial-grid/support-host-patch.d.ts.map +1 -1
  25. package/dist/hooks/spatial-grid/support-host-patch.js +171 -6
  26. package/dist/index.d.ts +15 -6
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +17 -5
  29. package/dist/lib/asset-storage.d.ts.map +1 -1
  30. package/dist/lib/asset-storage.js +4 -1
  31. package/dist/lib/polygon-union.d.ts +4 -0
  32. package/dist/lib/polygon-union.d.ts.map +1 -0
  33. package/dist/lib/polygon-union.js +306 -0
  34. package/dist/lib/roof-overlap.d.ts +32 -0
  35. package/dist/lib/roof-overlap.d.ts.map +1 -0
  36. package/dist/lib/roof-overlap.js +72 -0
  37. package/dist/lib/room-topology-index.d.ts +50 -0
  38. package/dist/lib/room-topology-index.d.ts.map +1 -0
  39. package/dist/lib/room-topology-index.js +237 -0
  40. package/dist/lib/slab-polygon.d.ts +35 -2
  41. package/dist/lib/slab-polygon.d.ts.map +1 -1
  42. package/dist/lib/slab-polygon.js +133 -27
  43. package/dist/lib/space-detection.d.ts +40 -3
  44. package/dist/lib/space-detection.d.ts.map +1 -1
  45. package/dist/lib/space-detection.js +890 -246
  46. package/dist/registry/handles.d.ts +24 -3
  47. package/dist/registry/handles.d.ts.map +1 -1
  48. package/dist/registry/index.d.ts +3 -3
  49. package/dist/registry/index.d.ts.map +1 -1
  50. package/dist/registry/index.js +1 -1
  51. package/dist/registry/registry.d.ts +29 -6
  52. package/dist/registry/registry.d.ts.map +1 -1
  53. package/dist/registry/registry.js +111 -4
  54. package/dist/registry/scene-api.d.ts +16 -0
  55. package/dist/registry/scene-api.d.ts.map +1 -1
  56. package/dist/registry/scene-api.js +49 -1
  57. package/dist/registry/types.d.ts +261 -13
  58. package/dist/registry/types.d.ts.map +1 -1
  59. package/dist/registry/use-registry-version.d.ts +11 -0
  60. package/dist/registry/use-registry-version.d.ts.map +1 -0
  61. package/dist/registry/use-registry-version.js +15 -0
  62. package/dist/schema/__bench__/node-parsers.bench.d.ts +24 -0
  63. package/dist/schema/__bench__/node-parsers.bench.d.ts.map +1 -0
  64. package/dist/schema/__bench__/node-parsers.bench.js +314 -0
  65. package/dist/schema/__fixtures__/node-fixtures.d.ts +28 -0
  66. package/dist/schema/__fixtures__/node-fixtures.d.ts.map +1 -0
  67. package/dist/schema/__fixtures__/node-fixtures.js +125 -0
  68. package/dist/schema/base.d.ts +1 -1
  69. package/dist/schema/base.d.ts.map +1 -1
  70. package/dist/schema/base.js +8 -1
  71. package/dist/schema/compiled-node-parsers.d.ts +40 -0
  72. package/dist/schema/compiled-node-parsers.d.ts.map +1 -0
  73. package/dist/schema/compiled-node-parsers.js +95 -0
  74. package/dist/schema/index.d.ts +20 -15
  75. package/dist/schema/index.d.ts.map +1 -1
  76. package/dist/schema/index.js +19 -12
  77. package/dist/schema/material.d.ts +2 -1
  78. package/dist/schema/material.d.ts.map +1 -1
  79. package/dist/schema/material.js +1 -0
  80. package/dist/schema/nodes/automatic-downspout.d.ts +17 -0
  81. package/dist/schema/nodes/automatic-downspout.d.ts.map +1 -0
  82. package/dist/schema/nodes/automatic-downspout.js +237 -0
  83. package/dist/schema/nodes/block.d.ts +107 -0
  84. package/dist/schema/nodes/block.d.ts.map +1 -0
  85. package/dist/schema/nodes/block.js +207 -0
  86. package/dist/schema/nodes/box-vent.d.ts +8 -2
  87. package/dist/schema/nodes/box-vent.d.ts.map +1 -1
  88. package/dist/schema/nodes/box-vent.js +2 -0
  89. package/dist/schema/nodes/building.d.ts +1 -1
  90. package/dist/schema/nodes/cabinet.d.ts +25 -3
  91. package/dist/schema/nodes/cabinet.d.ts.map +1 -1
  92. package/dist/schema/nodes/cabinet.js +26 -5
  93. package/dist/schema/nodes/ceiling.d.ts +1 -1
  94. package/dist/schema/nodes/chimney.d.ts +2 -2
  95. package/dist/schema/nodes/column.d.ts +6 -6
  96. package/dist/schema/nodes/construction-dimension.d.ts +4 -4
  97. package/dist/schema/nodes/cupola.d.ts +9 -1
  98. package/dist/schema/nodes/cupola.d.ts.map +1 -1
  99. package/dist/schema/nodes/cupola.js +2 -0
  100. package/dist/schema/nodes/door.d.ts +1 -1
  101. package/dist/schema/nodes/dormer.d.ts +44 -1
  102. package/dist/schema/nodes/dormer.d.ts.map +1 -1
  103. package/dist/schema/nodes/dormer.js +131 -7
  104. package/dist/schema/nodes/downspout.d.ts +11 -1
  105. package/dist/schema/nodes/downspout.d.ts.map +1 -1
  106. package/dist/schema/nodes/downspout.js +24 -0
  107. package/dist/schema/nodes/duct-fitting.d.ts +18 -1
  108. package/dist/schema/nodes/duct-fitting.d.ts.map +1 -1
  109. package/dist/schema/nodes/duct-fitting.js +25 -4
  110. package/dist/schema/nodes/duct-segment.d.ts +23 -1
  111. package/dist/schema/nodes/duct-segment.d.ts.map +1 -1
  112. package/dist/schema/nodes/duct-segment.js +20 -0
  113. package/dist/schema/nodes/duct-terminal.d.ts +1 -1
  114. package/dist/schema/nodes/elevator.d.ts +1 -1
  115. package/dist/schema/nodes/eyebrow-vent.d.ts +8 -2
  116. package/dist/schema/nodes/eyebrow-vent.d.ts.map +1 -1
  117. package/dist/schema/nodes/eyebrow-vent.js +2 -0
  118. package/dist/schema/nodes/fence.d.ts +1 -1
  119. package/dist/schema/nodes/guide.d.ts +1 -1
  120. package/dist/schema/nodes/gutter.d.ts +36 -2
  121. package/dist/schema/nodes/gutter.d.ts.map +1 -1
  122. package/dist/schema/nodes/gutter.js +308 -1
  123. package/dist/schema/nodes/hanger-overrides.d.ts +7 -0
  124. package/dist/schema/nodes/hanger-overrides.d.ts.map +1 -0
  125. package/dist/schema/nodes/hanger-overrides.js +6 -0
  126. package/dist/schema/nodes/hvac-equipment.d.ts +1 -1
  127. package/dist/schema/nodes/item.d.ts +4 -3
  128. package/dist/schema/nodes/item.d.ts.map +1 -1
  129. package/dist/schema/nodes/item.js +5 -0
  130. package/dist/schema/nodes/lean-to-extension.d.ts +212 -0
  131. package/dist/schema/nodes/lean-to-extension.d.ts.map +1 -0
  132. package/dist/schema/nodes/lean-to-extension.js +114 -0
  133. package/dist/schema/nodes/level.d.ts +5 -2
  134. package/dist/schema/nodes/level.d.ts.map +1 -1
  135. package/dist/schema/nodes/level.js +9 -0
  136. package/dist/schema/nodes/lineset.d.ts +1 -1
  137. package/dist/schema/nodes/liquid-line.d.ts +1 -1
  138. package/dist/schema/nodes/measurement.d.ts +1 -1
  139. package/dist/schema/nodes/pipe-fitting.d.ts +9 -1
  140. package/dist/schema/nodes/pipe-fitting.d.ts.map +1 -1
  141. package/dist/schema/nodes/pipe-fitting.js +6 -2
  142. package/dist/schema/nodes/pipe-segment.d.ts +23 -1
  143. package/dist/schema/nodes/pipe-segment.d.ts.map +1 -1
  144. package/dist/schema/nodes/pipe-segment.js +20 -0
  145. package/dist/schema/nodes/pipe-trap.d.ts +1 -1
  146. package/dist/schema/nodes/ridge-vent.d.ts +1 -1
  147. package/dist/schema/nodes/ridge-vent.d.ts.map +1 -1
  148. package/dist/schema/nodes/ridge-vent.js +4 -1
  149. package/dist/schema/nodes/roof-segment-shape.d.ts +4 -0
  150. package/dist/schema/nodes/roof-segment-shape.d.ts.map +1 -1
  151. package/dist/schema/nodes/roof-segment-shape.js +64 -1
  152. package/dist/schema/nodes/roof-segment-walls.d.ts +3 -1
  153. package/dist/schema/nodes/roof-segment-walls.d.ts.map +1 -1
  154. package/dist/schema/nodes/roof-segment-walls.js +1 -1
  155. package/dist/schema/nodes/roof-segment.d.ts +41 -1
  156. package/dist/schema/nodes/roof-segment.d.ts.map +1 -1
  157. package/dist/schema/nodes/roof-segment.js +79 -2
  158. package/dist/schema/nodes/roof.d.ts +22 -1
  159. package/dist/schema/nodes/roof.d.ts.map +1 -1
  160. package/dist/schema/nodes/roof.js +14 -0
  161. package/dist/schema/nodes/scan.d.ts +19 -2
  162. package/dist/schema/nodes/scan.d.ts.map +1 -1
  163. package/dist/schema/nodes/scan.js +11 -1
  164. package/dist/schema/nodes/shelf.d.ts +2 -2
  165. package/dist/schema/nodes/site.d.ts +1 -1
  166. package/dist/schema/nodes/skylight.d.ts +1 -1
  167. package/dist/schema/nodes/slab.d.ts +1 -1
  168. package/dist/schema/nodes/solar-panel.d.ts +1 -1
  169. package/dist/schema/nodes/spawn.d.ts +1 -1
  170. package/dist/schema/nodes/stair-segment.d.ts +1 -1
  171. package/dist/schema/nodes/stair.d.ts +1 -1
  172. package/dist/schema/nodes/structural-grid.d.ts +1 -1
  173. package/dist/schema/nodes/turbine-vent.d.ts +7 -1
  174. package/dist/schema/nodes/turbine-vent.d.ts.map +1 -1
  175. package/dist/schema/nodes/turbine-vent.js +2 -0
  176. package/dist/schema/nodes/wall.d.ts +4 -4
  177. package/dist/schema/nodes/wall.d.ts.map +1 -1
  178. package/dist/schema/nodes/wall.js +7 -1
  179. package/dist/schema/nodes/window.d.ts +10 -3
  180. package/dist/schema/nodes/window.d.ts.map +1 -1
  181. package/dist/schema/nodes/window.js +4 -0
  182. package/dist/schema/nodes/zone.d.ts +2 -2
  183. package/dist/schema/nodes/zone.js +1 -1
  184. package/dist/schema/types.d.ts +485 -109
  185. package/dist/schema/types.d.ts.map +1 -1
  186. package/dist/schema/types.js +27 -1
  187. package/dist/services/hosting.d.ts +2 -2
  188. package/dist/services/hosting.d.ts.map +1 -1
  189. package/dist/services/hosting.js +4 -4
  190. package/dist/services/index.d.ts +2 -2
  191. package/dist/services/index.d.ts.map +1 -1
  192. package/dist/services/index.js +2 -2
  193. package/dist/services/storey.d.ts +3 -10
  194. package/dist/services/storey.d.ts.map +1 -1
  195. package/dist/services/storey.js +45 -15
  196. package/dist/services/system-graph.d.ts +11 -0
  197. package/dist/services/system-graph.d.ts.map +1 -1
  198. package/dist/services/system-graph.js +35 -5
  199. package/dist/store/actions/node-actions.d.ts +15 -4
  200. package/dist/store/actions/node-actions.d.ts.map +1 -1
  201. package/dist/store/actions/node-actions.js +458 -19
  202. package/dist/store/history-control.d.ts +5 -0
  203. package/dist/store/history-control.d.ts.map +1 -1
  204. package/dist/store/history-control.js +68 -6
  205. package/dist/store/history-invalidation.d.ts +3 -0
  206. package/dist/store/history-invalidation.d.ts.map +1 -0
  207. package/dist/store/history-invalidation.js +72 -0
  208. package/dist/store/scene-hydration.d.ts +5 -0
  209. package/dist/store/scene-hydration.d.ts.map +1 -0
  210. package/dist/store/scene-hydration.js +57 -0
  211. package/dist/store/use-scene.d.ts +3 -0
  212. package/dist/store/use-scene.d.ts.map +1 -1
  213. package/dist/store/use-scene.js +407 -227
  214. package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
  215. package/dist/systems/elevator/elevator-service.js +16 -11
  216. package/dist/systems/roof/roof-elevation-system.d.ts +3 -0
  217. package/dist/systems/roof/roof-elevation-system.d.ts.map +1 -0
  218. package/dist/systems/roof/roof-elevation-system.js +94 -0
  219. package/dist/systems/roof/roof-elevation.d.ts +4 -0
  220. package/dist/systems/roof/roof-elevation.d.ts.map +1 -0
  221. package/dist/systems/roof/roof-elevation.js +95 -0
  222. package/dist/systems/roof/roof-footprint.d.ts +17 -0
  223. package/dist/systems/roof/roof-footprint.d.ts.map +1 -0
  224. package/dist/systems/roof/roof-footprint.js +83 -0
  225. package/dist/systems/slab/slab-support.d.ts +0 -31
  226. package/dist/systems/slab/slab-support.d.ts.map +1 -1
  227. package/dist/systems/slab/slab-support.js +26 -4
  228. package/dist/systems/stair/stair-flight.d.ts +17 -0
  229. package/dist/systems/stair/stair-flight.d.ts.map +1 -0
  230. package/dist/systems/stair/stair-flight.js +27 -0
  231. package/dist/systems/stair/stair-opening-preview.d.ts +1556 -1328
  232. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  233. package/dist/systems/stair/stair-opening-system.d.ts +1 -0
  234. package/dist/systems/stair/stair-opening-system.d.ts.map +1 -1
  235. package/dist/systems/stair/stair-opening-system.js +93 -96
  236. package/dist/systems/stair/stair-rise.js +21 -20
  237. package/dist/systems/wall/wall-curve.d.ts +1 -0
  238. package/dist/systems/wall/wall-curve.d.ts.map +1 -1
  239. package/dist/systems/wall/wall-curve.js +73 -0
  240. package/dist/systems/wall/wall-footprint.d.ts +1 -0
  241. package/dist/systems/wall/wall-footprint.d.ts.map +1 -1
  242. package/dist/systems/wall/wall-footprint.js +1 -0
  243. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  244. package/dist/systems/wall/wall-mitering.js +88 -9
  245. package/dist/systems/wall/wall-topology.d.ts +53 -0
  246. package/dist/systems/wall/wall-topology.d.ts.map +1 -0
  247. package/dist/systems/wall/wall-topology.js +414 -0
  248. package/dist/utils/clone-scene-graph.d.ts +2 -0
  249. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  250. package/dist/utils/clone-scene-graph.js +39 -2
  251. package/dist/utils/scene-migrations.d.ts +1 -0
  252. package/dist/utils/scene-migrations.d.ts.map +1 -1
  253. package/dist/utils/scene-migrations.js +1 -0
  254. package/dist/utils/vertical-scene-migration.d.ts +13 -0
  255. package/dist/utils/vertical-scene-migration.d.ts.map +1 -0
  256. package/dist/utils/vertical-scene-migration.js +179 -0
  257. package/dist/validation/validate-build-json.d.ts +3 -0
  258. package/dist/validation/validate-build-json.d.ts.map +1 -1
  259. package/dist/validation/validate-build-json.js +49 -2
  260. package/package.json +10 -3
  261. package/dist/systems/elevator/elevator-runtime-system.d.ts +0 -2
  262. package/dist/systems/elevator/elevator-runtime-system.d.ts.map +0 -1
  263. package/dist/systems/elevator/elevator-runtime-system.js +0 -8
@@ -1,5 +1,7 @@
1
1
  import { nodeRegistry } from '../../registry/registry';
2
- import { AnyNode as AnyNodeSchema, createDefaultRidgeVentsForSegment, getEffectiveWallSurfaceMaterial, getWallSurfaceMaterialSignature, isAutoRidgeVentEnabled, isDefaultRidgeVentNode, } from '../../schema';
2
+ import { AnyNode as AnyNodeSchema, createDefaultGuttersForSegment, createDefaultRidgeVentsForSegment, DownspoutNode as DownspoutNodeSchema, defaultDownspoutMetadata, generateId, getDefaultGutterSide, getEffectiveWallSurfaceMaterial, getWallSurfaceMaterialSignature, isAutoGutterEnabled, isAutoRidgeVentEnabled, isDefaultDownspoutNode, isDefaultGutterNode, isDefaultRidgeVentNode, parseNode, planAutomaticDownspouts, resolveAutomaticDownspoutLength, } from '../../schema';
3
+ import { constrainWallCurveOffsetToAvoidIntersections } from '../../systems/wall/wall-curve';
4
+ import { addActiveSceneCommitNodeIds, runWithSceneCommitNodeIds } from '../history-control';
3
5
  const DEFAULT_RIDGE_VENT_REFRESH_FIELDS = new Set([
4
6
  'roofType',
5
7
  'width',
@@ -14,6 +16,18 @@ const DEFAULT_RIDGE_VENT_REFRESH_FIELDS = new Set([
14
16
  'dutchWaistLengthRatio',
15
17
  'dutchGabletRake',
16
18
  ]);
19
+ const DEFAULT_GUTTER_REFRESH_FIELDS = new Set([
20
+ 'metadata',
21
+ 'position',
22
+ 'rotation',
23
+ 'roofType',
24
+ 'width',
25
+ 'depth',
26
+ 'wallHeight',
27
+ 'pitch',
28
+ 'overhang',
29
+ 'trim',
30
+ ]);
17
31
  const NUMBER_FORMAT_BOUNDS = {
18
32
  safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
19
33
  int32: [-2147483648, 2147483647],
@@ -318,21 +332,42 @@ function formatNumericValue(value) {
318
332
  return '-Infinity';
319
333
  return String(value);
320
334
  }
321
- function numericSanitizeIssuesToMessage(issues) {
322
- return issues
323
- .map((issue) => {
324
- const path = issue.path.map(String).join('.') || '<root>';
325
- const to = issue.to === undefined ? '' : ` -> ${formatNumericValue(issue.to)}`;
326
- return `${path}: ${formatNumericValue(issue.from)} ${issue.action}${to}`;
327
- })
328
- .join('; ');
335
+ export function numericSanitizeIssuesToMessage(issues) {
336
+ if (!Array.isArray(issues))
337
+ return '';
338
+ try {
339
+ return issues
340
+ .map((issue) => {
341
+ const path = Array.isArray(issue?.path)
342
+ ? issue.path.map(String).join('.') || '<root>'
343
+ : '<unknown>';
344
+ const to = issue?.to === undefined ? '' : ` -> ${formatNumericValue(issue.to)}`;
345
+ return `${path}: ${formatNumericValue(issue?.from)} ${issue?.action ?? 'sanitized'}${to}`;
346
+ })
347
+ .join('; ');
348
+ }
349
+ catch {
350
+ return '<diagnostic unavailable>';
351
+ }
329
352
  }
330
353
  function warnSanitizedNodeMutation(mutation, nodeId, issues) {
331
- console.warn(`[Scene] Sanitized invalid numeric node ${mutation}`, nodeId, numericSanitizeIssuesToMessage(issues));
354
+ let message = '<diagnostic unavailable>';
355
+ try {
356
+ message = numericSanitizeIssuesToMessage(issues);
357
+ }
358
+ catch {
359
+ // Reporting must never interrupt a node mutation.
360
+ }
361
+ try {
362
+ console.warn(`[Scene] Sanitized invalid numeric node ${mutation}`, nodeId, message);
363
+ }
364
+ catch {
365
+ // A broken diagnostic sink must not interrupt a node mutation either.
366
+ }
332
367
  }
333
368
  function parseCreatedNode(node, parentId) {
334
369
  const candidate = { ...node, parentId };
335
- const parsed = AnyNodeSchema.safeParse(candidate);
370
+ const parsed = parseNode(candidate);
336
371
  if (parsed.success)
337
372
  return parsed.data;
338
373
  const schema = getNodeSchemaForType(candidate.type);
@@ -358,7 +393,7 @@ function mergeNodeUpdate(currentNode, patch) {
358
393
  }
359
394
  function parseUpdatedNode(currentNode, data) {
360
395
  const candidate = mergeNodeUpdate(currentNode, data);
361
- const parsed = AnyNodeSchema.safeParse(candidate);
396
+ const parsed = parseNode(candidate);
362
397
  if (parsed.success)
363
398
  return parsed.data;
364
399
  const schema = getNodeSchemaForType(candidate.type);
@@ -397,6 +432,306 @@ function refreshDefaultRidgeVentsForSegment(nextNodes, segment) {
397
432
  };
398
433
  return nextVents.map((vent) => vent.id);
399
434
  }
435
+ function shouldRefreshDefaultGutters(data) {
436
+ return Object.keys(data).some((key) => DEFAULT_GUTTER_REFRESH_FIELDS.has(key));
437
+ }
438
+ function getLeanToGutterExclusions(nodes, segmentId) {
439
+ return Object.values(nodes).flatMap((node) => {
440
+ if (node.type !== 'lean-to-extension' ||
441
+ node.connectionMode !== 'auto' ||
442
+ node.hostRoofSegmentId !== segmentId ||
443
+ !node.hostRoofEdge) {
444
+ return [];
445
+ }
446
+ const range = node.hostRoofEdgeRange ?? [0, 1];
447
+ return [{ side: node.hostRoofEdge, from: range[0], to: range[1] }];
448
+ });
449
+ }
450
+ function addLeanToHostRoofId(node, nodes, roofIds) {
451
+ if (node?.type !== 'lean-to-extension' || !node.hostRoofSegmentId)
452
+ return;
453
+ const segment = nodes[node.hostRoofSegmentId];
454
+ const roofId = segment?.type === 'roof-segment'
455
+ ? segment.parentId
456
+ : node.hostRoofId;
457
+ if (roofId && nodes[roofId]?.type === 'roof')
458
+ roofIds.add(roofId);
459
+ }
460
+ // When an eave carries several default gutter runs on the same side (e.g. a run
461
+ // split by a lean-to exclusion), reuse the existing node whose plan position is
462
+ // closest to the desired run rather than an arbitrary queue order — otherwise
463
+ // the runs swap positions and their downspouts follow the wrong segment.
464
+ function takeNearestGutterId(candidateIds, desired, nodes) {
465
+ if (candidateIds.length === 0)
466
+ return undefined;
467
+ let bestIndex = 0;
468
+ let bestDistance = Number.POSITIVE_INFINITY;
469
+ for (let i = 0; i < candidateIds.length; i++) {
470
+ const node = nodes[candidateIds[i]];
471
+ const position = node && 'position' in node ? node.position : undefined;
472
+ const dx = (position?.[0] ?? 0) - desired.position[0];
473
+ const dz = (position?.[2] ?? 0) - desired.position[2];
474
+ const distance = dx * dx + dz * dz;
475
+ if (distance < bestDistance) {
476
+ bestDistance = distance;
477
+ bestIndex = i;
478
+ }
479
+ }
480
+ return candidateIds.splice(bestIndex, 1)[0];
481
+ }
482
+ function refreshDefaultGuttersForSegment(nextNodes, segment, roofSegments) {
483
+ const childIds = Array.isArray(segment.children) ? segment.children : [];
484
+ const existingIds = childIds.filter((childId) => isDefaultGutterNode(nextNodes[childId], segment.id));
485
+ if (!isAutoGutterEnabled(segment, nextNodes) && existingIds.length === 0) {
486
+ return { dirtyIds: [], deletedIds: [] };
487
+ }
488
+ const existingBySide = new Map();
489
+ for (const id of existingIds) {
490
+ const side = getDefaultGutterSide(nextNodes[id], segment.id);
491
+ if (!side)
492
+ continue;
493
+ const ids = existingBySide.get(side) ?? [];
494
+ ids.push(id);
495
+ existingBySide.set(side, ids);
496
+ }
497
+ const desiredGutters = isAutoGutterEnabled(segment, nextNodes)
498
+ ? createDefaultGuttersForSegment(segment, roofSegments, getLeanToGutterExclusions(nextNodes, segment.id))
499
+ : [];
500
+ const desiredChildIds = [];
501
+ const dirtyIds = [];
502
+ for (const desired of desiredGutters) {
503
+ const side = getDefaultGutterSide(desired, segment.id);
504
+ if (!side)
505
+ continue;
506
+ const matchingIds = existingBySide.get(side);
507
+ const existingId = matchingIds
508
+ ? takeNearestGutterId(matchingIds, desired, nextNodes)
509
+ : undefined;
510
+ if (existingId) {
511
+ const existing = nextNodes[existingId];
512
+ nextNodes[existingId] = {
513
+ ...existing,
514
+ parentId: segment.id,
515
+ roofSegmentId: segment.id,
516
+ position: desired.position,
517
+ rotation: desired.rotation,
518
+ length: desired.length,
519
+ };
520
+ desiredChildIds.push(existingId);
521
+ dirtyIds.push(existingId);
522
+ continue;
523
+ }
524
+ const desiredId = desired.id;
525
+ nextNodes[desiredId] = { ...desired, parentId: segment.id };
526
+ desiredChildIds.push(desiredId);
527
+ dirtyIds.push(desiredId);
528
+ }
529
+ const retainedIdSet = new Set(desiredChildIds);
530
+ const deletedIds = existingIds.filter((id) => !retainedIdSet.has(id));
531
+ const deletedGutterIds = new Set(deletedIds);
532
+ for (const [nodeId, node] of Object.entries(nextNodes)) {
533
+ if (node.type === 'downspout' &&
534
+ node.gutterId &&
535
+ deletedGutterIds.has(node.gutterId)) {
536
+ deletedIds.push(nodeId);
537
+ }
538
+ }
539
+ const deletedIdSet = new Set(deletedIds);
540
+ for (const id of deletedIds)
541
+ delete nextNodes[id];
542
+ nextNodes[segment.id] = {
543
+ ...segment,
544
+ children: [
545
+ ...childIds.filter((childId) => !deletedIdSet.has(childId) && !existingIds.includes(childId)),
546
+ ...desiredChildIds,
547
+ ],
548
+ };
549
+ return { dirtyIds, deletedIds };
550
+ }
551
+ function getRoofSegments(nextNodes, segment) {
552
+ const roof = segment.parentId ? nextNodes[segment.parentId] : undefined;
553
+ if (!(roof && roof.type === 'roof'))
554
+ return [segment];
555
+ return (roof.children ?? [])
556
+ .map((childId) => nextNodes[childId])
557
+ .filter((node) => node?.type === 'roof-segment');
558
+ }
559
+ function refreshDefaultDownspoutsForRoof(nextNodes, roofSegments) {
560
+ const gutters = roofSegments.flatMap((segment) => {
561
+ const current = nextNodes[segment.id];
562
+ if (current?.type !== 'roof-segment')
563
+ return [];
564
+ return (current.children ?? [])
565
+ .map((childId) => nextNodes[childId])
566
+ .filter((node) => node?.type === 'gutter' && isDefaultGutterNode(node, current.id));
567
+ });
568
+ const gutterById = new Map(gutters.map((gutter) => [gutter.id, gutter]));
569
+ const downspouts = Object.values(nextNodes).filter((node) => node?.type === 'downspout' && Boolean(node.gutterId && gutterById.has(node.gutterId)));
570
+ const generated = downspouts.filter((downspout) => {
571
+ if (!isDefaultDownspoutNode(downspout))
572
+ return false;
573
+ const gutter = downspout.gutterId ? gutterById.get(downspout.gutterId) : undefined;
574
+ return gutter?.outlets.some((outlet) => outlet.id === downspout.outletId && outlet.generatedBy === 'default-downspout');
575
+ });
576
+ const generatedIds = new Set(generated.map((downspout) => downspout.id));
577
+ const manual = downspouts.filter((downspout) => !generatedIds.has(downspout.id));
578
+ const placements = planAutomaticDownspouts({
579
+ segments: roofSegments,
580
+ gutters,
581
+ downspouts: manual,
582
+ });
583
+ const segmentById = new Map(roofSegments.map((segment) => [segment.id, segment]));
584
+ const availableByGutter = new Map();
585
+ for (const downspout of generated) {
586
+ if (!downspout.gutterId)
587
+ continue;
588
+ const available = availableByGutter.get(downspout.gutterId) ?? [];
589
+ available.push(downspout);
590
+ availableByGutter.set(downspout.gutterId, available);
591
+ }
592
+ const retainedIds = new Set();
593
+ const retainedOutletIds = new Set();
594
+ const dirtyIds = new Set();
595
+ const deletedIds = [];
596
+ const outletsByGutter = new Map(gutters.map((gutter) => [gutter.id, [...(gutter.outlets ?? [])]]));
597
+ for (const placement of placements) {
598
+ const gutter = gutterById.get(placement.gutterId);
599
+ if (!gutter?.roofSegmentId)
600
+ continue;
601
+ const segment = segmentById.get(gutter.roofSegmentId);
602
+ if (!segment)
603
+ continue;
604
+ const outlets = outletsByGutter.get(gutter.id) ?? [];
605
+ const available = availableByGutter.get(gutter.id) ?? [];
606
+ let bestIndex = -1;
607
+ let bestDistance = Number.POSITIVE_INFINITY;
608
+ for (let index = 0; index < available.length; index++) {
609
+ const candidate = available[index];
610
+ const outlet = outlets.find((entry) => entry.id === candidate.outletId);
611
+ const distance = outlet ? Math.abs(outlet.offset - placement.offset) : 0;
612
+ if (distance < bestDistance) {
613
+ bestDistance = distance;
614
+ bestIndex = index;
615
+ }
616
+ }
617
+ const existing = bestIndex >= 0 ? available.splice(bestIndex, 1)[0] : undefined;
618
+ const outletId = existing?.outletId ?? generateId('outlet');
619
+ const outletIndex = outlets.findIndex((outlet) => outlet.id === outletId);
620
+ const outlet = {
621
+ id: outletId,
622
+ offset: placement.offset,
623
+ diameter: 0.07,
624
+ generatedBy: 'default-downspout',
625
+ };
626
+ if (outletIndex >= 0)
627
+ outlets[outletIndex] = { ...outlets[outletIndex], ...outlet };
628
+ else
629
+ outlets.push(outlet);
630
+ outletsByGutter.set(gutter.id, outlets);
631
+ retainedOutletIds.add(outletId);
632
+ const length = resolveAutomaticDownspoutLength(nextNodes, segment, gutter, placement.offset);
633
+ const downspout = existing
634
+ ? {
635
+ ...existing,
636
+ parentId: segment.id,
637
+ gutterId: gutter.id,
638
+ outletId,
639
+ length: existing.lengthMode === 'manual' ? existing.length : length,
640
+ lengthMode: existing.lengthMode === 'manual' ? 'manual' : 'to-ground',
641
+ }
642
+ : DownspoutNodeSchema.parse({
643
+ name: 'Downspout',
644
+ parentId: segment.id,
645
+ gutterId: gutter.id,
646
+ outletId,
647
+ length,
648
+ lengthMode: 'to-ground',
649
+ diameter: outlet.diameter,
650
+ metadata: defaultDownspoutMetadata(),
651
+ });
652
+ nextNodes[downspout.id] = downspout;
653
+ retainedIds.add(downspout.id);
654
+ dirtyIds.add(downspout.id);
655
+ const currentSegment = nextNodes[segment.id];
656
+ if (currentSegment?.type === 'roof-segment') {
657
+ nextNodes[segment.id] = {
658
+ ...currentSegment,
659
+ children: Array.from(new Set([...(currentSegment.children ?? []), downspout.id])),
660
+ };
661
+ dirtyIds.add(segment.id);
662
+ }
663
+ }
664
+ for (const [gutterId, outlets] of outletsByGutter) {
665
+ const gutter = nextNodes[gutterId];
666
+ if (gutter?.type !== 'gutter')
667
+ continue;
668
+ nextNodes[gutterId] = {
669
+ ...gutter,
670
+ outlets: outlets.filter((outlet) => outlet.generatedBy !== 'default-downspout' || retainedOutletIds.has(outlet.id)),
671
+ };
672
+ dirtyIds.add(gutterId);
673
+ }
674
+ for (const downspout of generated) {
675
+ const downspoutId = downspout.id;
676
+ if (retainedIds.has(downspoutId))
677
+ continue;
678
+ const gutter = downspout.gutterId ? nextNodes[downspout.gutterId] : undefined;
679
+ if (gutter?.type === 'gutter' && downspout.outletId) {
680
+ nextNodes[gutter.id] = {
681
+ ...gutter,
682
+ outlets: (gutter.outlets ?? []).filter((outlet) => outlet.id !== downspout.outletId),
683
+ };
684
+ dirtyIds.add(gutter.id);
685
+ }
686
+ const parent = downspout.parentId ? nextNodes[downspout.parentId] : undefined;
687
+ if (parent?.type === 'roof-segment') {
688
+ nextNodes[parent.id] = {
689
+ ...parent,
690
+ children: (parent.children ?? []).filter((childId) => childId !== downspout.id),
691
+ };
692
+ dirtyIds.add(parent.id);
693
+ }
694
+ delete nextNodes[downspoutId];
695
+ deletedIds.push(downspoutId);
696
+ }
697
+ return { dirtyIds: [...dirtyIds], deletedIds };
698
+ }
699
+ function refreshDefaultGuttersForRoof(nextNodes, segment) {
700
+ const roofSegments = getRoofSegments(nextNodes, segment);
701
+ const dirtyIds = [];
702
+ const deletedIds = [];
703
+ for (const roofSegment of roofSegments) {
704
+ const current = nextNodes[roofSegment.id];
705
+ if (current?.type !== 'roof-segment')
706
+ continue;
707
+ const result = refreshDefaultGuttersForSegment(nextNodes, current, roofSegments);
708
+ dirtyIds.push(...result.dirtyIds);
709
+ deletedIds.push(...result.deletedIds);
710
+ }
711
+ const downspoutResult = refreshDefaultDownspoutsForRoof(nextNodes, roofSegments);
712
+ dirtyIds.push(...downspoutResult.dirtyIds);
713
+ deletedIds.push(...downspoutResult.deletedIds);
714
+ return { dirtyIds, deletedIds };
715
+ }
716
+ function collectDefaultGutterRefresh(result, dirtyIds, deletedIds) {
717
+ for (const id of result.dirtyIds)
718
+ dirtyIds.add(id);
719
+ for (const id of result.deletedIds)
720
+ deletedIds.add(id);
721
+ }
722
+ function refreshDefaultGuttersForRoofIds(nextNodes, roofIds, dirtyIds, deletedIds) {
723
+ for (const roofId of new Set(roofIds)) {
724
+ const roof = nextNodes[roofId];
725
+ if (roof?.type !== 'roof')
726
+ continue;
727
+ const segment = (roof.children ?? [])
728
+ .map((childId) => nextNodes[childId])
729
+ .find((child) => child?.type === 'roof-segment');
730
+ if (!segment)
731
+ continue;
732
+ collectDefaultGutterRefresh(refreshDefaultGuttersForRoof(nextNodes, segment), dirtyIds, deletedIds);
733
+ }
734
+ }
400
735
  // Track pending RAF for updateNodesAction to prevent multiple queued callbacks
401
736
  let pendingRafId = null;
402
737
  let pendingUpdates = new Set();
@@ -547,9 +882,11 @@ function buildWallMergePlans(nodes, idsToDelete) {
547
882
  }
548
883
  return mergePlans;
549
884
  }
550
- export const createNodesAction = (set, get, ops) => {
885
+ const createNodesActionImpl = (set, get, ops) => {
551
886
  if (get().readOnly)
552
887
  return;
888
+ const extraNodesToMarkDirty = new Set();
889
+ const extraNodesToClearDirty = new Set();
553
890
  set((state) => {
554
891
  const nextNodes = { ...state.nodes };
555
892
  const nextRootIds = [...state.rootNodeIds];
@@ -584,6 +921,16 @@ export const createNodesAction = (set, get, ops) => {
584
921
  }
585
922
  }
586
923
  }
924
+ const refreshedRoofIds = new Set();
925
+ for (const { node } of ops) {
926
+ const created = nextNodes[node.id];
927
+ if (created?.type === 'roof-segment' && created.parentId) {
928
+ refreshedRoofIds.add(created.parentId);
929
+ }
930
+ addLeanToHostRoofId(created, nextNodes, refreshedRoofIds);
931
+ }
932
+ refreshDefaultGuttersForRoofIds(nextNodes, refreshedRoofIds, extraNodesToMarkDirty, extraNodesToClearDirty);
933
+ addActiveSceneCommitNodeIds([...extraNodesToMarkDirty, ...extraNodesToClearDirty]);
587
934
  return { nodes: nextNodes, rootNodeIds: nextRootIds };
588
935
  });
589
936
  // 4. System Sync
@@ -594,25 +941,33 @@ export const createNodesAction = (set, get, ops) => {
594
941
  else if (node.parentId)
595
942
  get().markDirty(node.parentId);
596
943
  });
944
+ for (const id of extraNodesToMarkDirty)
945
+ get().markDirty(id);
946
+ for (const id of extraNodesToClearDirty)
947
+ get().clearDirty(id);
597
948
  };
598
- export const applyNodeChangesAction = (set, get, changes) => {
949
+ const applyNodeChangesActionImpl = (set, get, changes) => {
599
950
  if (get().readOnly)
600
951
  return;
601
952
  const createOps = changes.create ?? [];
602
953
  const updateOps = changes.update ?? [];
603
954
  const deleteOps = changes.delete ?? [];
604
955
  const nodesToMarkDirty = new Set();
956
+ const nodesToClearDirty = new Set();
605
957
  const parentsToMarkDirty = new Set();
606
958
  set((state) => {
607
959
  const nextNodes = { ...state.nodes };
608
960
  const nextCollections = { ...state.collections };
609
961
  const nextRootIds = [...state.rootNodeIds];
610
962
  let resolvedRootIds = nextRootIds;
963
+ const roofsToRefresh = new Set();
611
964
  for (const { id, data } of updateOps) {
612
965
  const currentNode = nextNodes[id];
613
966
  if (!currentNode)
614
967
  continue;
968
+ addLeanToHostRoofId(currentNode, nextNodes, roofsToRefresh);
615
969
  const updatedNode = parseUpdatedNode(currentNode, data);
970
+ addLeanToHostRoofId(updatedNode, nextNodes, roofsToRefresh);
616
971
  if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
617
972
  const oldParentId = currentNode.parentId;
618
973
  if (oldParentId && nextNodes[oldParentId]) {
@@ -639,6 +994,11 @@ export const applyNodeChangesAction = (set, get, changes) => {
639
994
  nodesToMarkDirty.add(ventId);
640
995
  }
641
996
  }
997
+ const currentSegment = nextNodes[id];
998
+ if (currentSegment?.type === 'roof-segment' && shouldRefreshDefaultGutters(data)) {
999
+ if (currentSegment.parentId)
1000
+ roofsToRefresh.add(currentSegment.parentId);
1001
+ }
642
1002
  nodesToMarkDirty.add(id);
643
1003
  }
644
1004
  for (const { node, parentId } of createOps) {
@@ -646,6 +1006,10 @@ export const applyNodeChangesAction = (set, get, changes) => {
646
1006
  const newNode = parseCreatedNode(node, effectiveParentId);
647
1007
  nextNodes[newNode.id] = newNode;
648
1008
  nodesToMarkDirty.add(newNode.id);
1009
+ if (newNode.type === 'roof-segment' && effectiveParentId) {
1010
+ roofsToRefresh.add(effectiveParentId);
1011
+ }
1012
+ addLeanToHostRoofId(newNode, nextNodes, roofsToRefresh);
649
1013
  if (effectiveParentId && nextNodes[effectiveParentId]) {
650
1014
  const parent = nextNodes[effectiveParentId];
651
1015
  if ('children' in parent && Array.isArray(parent.children)) {
@@ -675,6 +1039,13 @@ export const applyNodeChangesAction = (set, get, changes) => {
675
1039
  for (const id of deleteOps) {
676
1040
  collectDelete(id);
677
1041
  }
1042
+ for (const id of allIdsToDelete) {
1043
+ const node = nextNodes[id];
1044
+ addLeanToHostRoofId(node, nextNodes, roofsToRefresh);
1045
+ if (node?.type === 'roof-segment' && node.parentId) {
1046
+ roofsToRefresh.add(node.parentId);
1047
+ }
1048
+ }
678
1049
  for (const id of allIdsToDelete) {
679
1050
  const node = nextNodes[id];
680
1051
  if (!node)
@@ -704,11 +1075,21 @@ export const applyNodeChangesAction = (set, get, changes) => {
704
1075
  }
705
1076
  delete nextNodes[id];
706
1077
  }
1078
+ refreshDefaultGuttersForRoofIds(nextNodes, roofsToRefresh, nodesToMarkDirty, nodesToClearDirty);
1079
+ addActiveSceneCommitNodeIds([
1080
+ ...allIdsToDelete,
1081
+ ...nodesToMarkDirty,
1082
+ ...nodesToClearDirty,
1083
+ ...parentsToMarkDirty,
1084
+ ]);
707
1085
  return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections };
708
1086
  });
709
1087
  for (const id of nodesToMarkDirty) {
710
1088
  get().markDirty(id);
711
1089
  }
1090
+ for (const id of nodesToClearDirty) {
1091
+ get().clearDirty(id);
1092
+ }
712
1093
  for (const id of parentsToMarkDirty) {
713
1094
  get().markDirty(id);
714
1095
  const parent = get().nodes[id];
@@ -719,18 +1100,29 @@ export const applyNodeChangesAction = (set, get, changes) => {
719
1100
  }
720
1101
  }
721
1102
  };
722
- export const updateNodesAction = (set, get, updates) => {
1103
+ const updateNodesActionImpl = (set, get, updates) => {
723
1104
  if (get().readOnly)
724
1105
  return;
725
1106
  const parentsToUpdate = new Set();
726
1107
  const extraNodesToUpdate = new Set();
1108
+ const extraNodesToDelete = new Set();
1109
+ const roofsToRefresh = new Set();
727
1110
  set((state) => {
728
1111
  const nextNodes = { ...state.nodes };
729
1112
  for (const { id, data } of updates) {
730
1113
  const currentNode = nextNodes[id];
731
1114
  if (!currentNode)
732
1115
  continue;
733
- const updatedNode = parseUpdatedNode(currentNode, data);
1116
+ addLeanToHostRoofId(currentNode, nextNodes, roofsToRefresh);
1117
+ const curveOffset = currentNode.type === 'wall' ? data.curveOffset : undefined;
1118
+ const constrainedData = currentNode.type === 'wall' && typeof curveOffset === 'number'
1119
+ ? {
1120
+ ...data,
1121
+ curveOffset: constrainWallCurveOffsetToAvoidIntersections(currentNode, curveOffset, Object.values(nextNodes).filter((node) => node.type === 'wall' && node.parentId === currentNode.parentId)),
1122
+ }
1123
+ : data;
1124
+ const updatedNode = parseUpdatedNode(currentNode, constrainedData);
1125
+ addLeanToHostRoofId(updatedNode, nextNodes, roofsToRefresh);
734
1126
  // Handle Reparenting Logic
735
1127
  if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
736
1128
  // 1. Remove from old parent
@@ -772,11 +1164,28 @@ export const updateNodesAction = (set, get, updates) => {
772
1164
  extraNodesToUpdate.add(ventId);
773
1165
  }
774
1166
  }
1167
+ const currentSegment = nextNodes[id];
1168
+ if (currentSegment?.type === 'roof-segment' && shouldRefreshDefaultGutters(data)) {
1169
+ if (currentSegment.parentId)
1170
+ roofsToRefresh.add(currentSegment.parentId);
1171
+ }
775
1172
  }
1173
+ refreshDefaultGuttersForRoofIds(nextNodes, roofsToRefresh, extraNodesToUpdate, extraNodesToDelete);
1174
+ addActiveSceneCommitNodeIds([
1175
+ ...updates.map(({ id }) => id),
1176
+ ...parentsToUpdate,
1177
+ ...extraNodesToUpdate,
1178
+ ...extraNodesToDelete,
1179
+ ]);
776
1180
  return { nodes: nextNodes };
777
1181
  });
778
1182
  // Batch dirty-marking into a single RAF to avoid redundant callbacks during rapid updates
779
1183
  for (const u of updates) {
1184
+ // Visibility is applied by React before the deferred dirty callback. Mark
1185
+ // it now so render systems can release collective geometry in that same
1186
+ // frame, including when the host uses render-on-demand.
1187
+ if (u.data.visible !== undefined)
1188
+ get().markDirty(u.id);
780
1189
  pendingUpdates.add(u.id);
781
1190
  }
782
1191
  for (const pId of parentsToUpdate) {
@@ -785,6 +1194,9 @@ export const updateNodesAction = (set, get, updates) => {
785
1194
  for (const id of extraNodesToUpdate) {
786
1195
  pendingUpdates.add(id);
787
1196
  }
1197
+ for (const id of extraNodesToDelete) {
1198
+ get().clearDirty(id);
1199
+ }
788
1200
  if (pendingRafId !== null) {
789
1201
  cancelAnimationFrame(pendingRafId);
790
1202
  }
@@ -796,13 +1208,14 @@ export const updateNodesAction = (set, get, updates) => {
796
1208
  pendingRafId = null;
797
1209
  });
798
1210
  };
799
- export const deleteNodesAction = (set, get, ids) => {
1211
+ const deleteNodesActionImpl = (set, get, ids) => {
800
1212
  if (get().readOnly)
801
1213
  return;
802
1214
  const parentsToMarkDirty = new Set();
803
1215
  const nodesToMarkDirty = new Set();
804
1216
  const deletedIds = new Set();
805
1217
  const mergePlans = buildWallMergePlans(get().nodes, ids);
1218
+ const requestedDeleteIds = new Set(ids);
806
1219
  set((state) => {
807
1220
  const nextNodes = { ...state.nodes };
808
1221
  const nextCollections = { ...state.collections };
@@ -816,7 +1229,9 @@ export const deleteNodesAction = (set, get, ids) => {
816
1229
  allIds.add(id);
817
1230
  const node = nextNodes[id];
818
1231
  const cascadeDeletes = node
819
- ? nodeRegistry.get(node.type)?.parametrics?.onDeleteCascade?.(node, nextNodes, allIds)
1232
+ ? nodeRegistry
1233
+ .get(node.type)
1234
+ ?.parametrics?.onDeleteCascade?.(node, nextNodes, allIds, requestedDeleteIds)
820
1235
  : null;
821
1236
  if (cascadeDeletes) {
822
1237
  for (const companionId of cascadeDeletes)
@@ -832,6 +1247,14 @@ export const deleteNodesAction = (set, get, ids) => {
832
1247
  for (const plan of mergePlans) {
833
1248
  allIds.add(plan.secondaryWallId);
834
1249
  }
1250
+ const affectedRoofIds = new Set();
1251
+ for (const id of allIds) {
1252
+ const node = nextNodes[id];
1253
+ addLeanToHostRoofId(node, nextNodes, affectedRoofIds);
1254
+ if (node?.type === 'roof-segment' && node.parentId) {
1255
+ affectedRoofIds.add(node.parentId);
1256
+ }
1257
+ }
835
1258
  for (const id of allIds)
836
1259
  deletedIds.add(id);
837
1260
  // Let each deleted kind undo what it imposed on its neighbours (e.g. an
@@ -845,7 +1268,7 @@ export const deleteNodesAction = (set, get, ids) => {
845
1268
  const onDelete = nodeRegistry.get(node.type)?.parametrics?.onDelete;
846
1269
  if (!onDelete)
847
1270
  continue;
848
- for (const { id: targetId, data } of onDelete(node, nextNodes)) {
1271
+ for (const { id: targetId, data } of onDelete(node, nextNodes, allIds, requestedDeleteIds)) {
849
1272
  if (allIds.has(targetId))
850
1273
  continue;
851
1274
  const target = nextNodes[targetId];
@@ -935,6 +1358,8 @@ export const deleteNodesAction = (set, get, ids) => {
935
1358
  // 4. Delete the node itself
936
1359
  delete nextNodes[id];
937
1360
  }
1361
+ refreshDefaultGuttersForRoofIds(nextNodes, affectedRoofIds, nodesToMarkDirty, deletedIds);
1362
+ addActiveSceneCommitNodeIds([...deletedIds, ...parentsToMarkDirty, ...nodesToMarkDirty]);
938
1363
  return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections };
939
1364
  });
940
1365
  // Deleted ids must leave the dirty set: every consumer skips missing
@@ -957,3 +1382,17 @@ export const deleteNodesAction = (set, get, ids) => {
957
1382
  get().markDirty(id);
958
1383
  });
959
1384
  };
1385
+ export const createNodesAction = (set, get, ops) => runWithSceneCommitNodeIds(ops.flatMap(({ node, parentId }) => {
1386
+ const effectiveParentId = parentId ?? node.parentId;
1387
+ return effectiveParentId ? [node.id, effectiveParentId] : [node.id];
1388
+ }), () => createNodesActionImpl(set, get, ops));
1389
+ export const applyNodeChangesAction = (set, get, changes) => runWithSceneCommitNodeIds([
1390
+ ...(changes.create ?? []).flatMap(({ node, parentId }) => {
1391
+ const effectiveParentId = parentId ?? node.parentId;
1392
+ return effectiveParentId ? [node.id, effectiveParentId] : [node.id];
1393
+ }),
1394
+ ...(changes.update ?? []).map(({ id }) => id),
1395
+ ...(changes.delete ?? []),
1396
+ ], () => applyNodeChangesActionImpl(set, get, changes));
1397
+ export const updateNodesAction = (set, get, updates) => runWithSceneCommitNodeIds(updates.map(({ id }) => id), () => updateNodesActionImpl(set, get, updates));
1398
+ export const deleteNodesAction = (set, get, ids) => runWithSceneCommitNodeIds(ids, () => deleteNodesActionImpl(set, get, ids));
@@ -13,6 +13,7 @@ export type SceneCommit = {
13
13
  origin: SceneCommitOrigin;
14
14
  before: SceneSnapshot;
15
15
  current: SceneSnapshot;
16
+ changedNodeIds?: ReadonlySet<AnyNodeId>;
16
17
  };
17
18
  export type SceneCommitListener = (commit: SceneCommit) => void;
18
19
  type TemporalStoreLike = {
@@ -33,11 +34,15 @@ type TemporalHistoryStoreLike<TPastState> = {
33
34
  }): void;
34
35
  };
35
36
  };
37
+ export declare function activeSceneCommitNodeIds(): ReadonlySet<AnyNodeId> | undefined;
38
+ export declare function addActiveSceneCommitNodeIds(nodeIds: Iterable<AnyNodeId>): void;
39
+ export declare function runWithSceneCommitNodeIds<TResult>(nodeIds: Iterable<AnyNodeId>, run: () => TResult): TResult;
36
40
  export declare function areSceneSnapshotsEqual(left: SceneSnapshot, right: SceneSnapshot): boolean;
37
41
  export declare function subscribeSceneCommits(listener: SceneCommitListener): () => void;
38
42
  export declare function notifySceneCommit(commit: SceneCommit): void;
39
43
  export declare function pauseSceneHistory(sceneStore: TemporalStoreLike): void;
40
44
  export declare function resumeSceneHistory(sceneStore: TemporalStoreLike): void;
45
+ export declare function acquireSceneHistoryPause(sceneStore: TemporalStoreLike): () => void;
41
46
  export declare function getSceneHistoryPauseDepth(): number;
42
47
  export declare function resetSceneHistoryPauseDepth(): void;
43
48
  export declare function runAsSingleSceneHistoryStep<TPastState, TResult>(sceneStore: TemporalHistoryStoreLike<TPastState>, run: () => TResult): TResult;