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

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 (69) hide show
  1. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +12 -0
  2. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  3. package/dist/hooks/spatial-grid/spatial-grid-manager.js +39 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -1
  7. package/dist/lib/room-topology-index.d.ts +50 -0
  8. package/dist/lib/room-topology-index.d.ts.map +1 -0
  9. package/dist/lib/room-topology-index.js +237 -0
  10. package/dist/lib/space-detection.d.ts +29 -3
  11. package/dist/lib/space-detection.d.ts.map +1 -1
  12. package/dist/lib/space-detection.js +858 -242
  13. package/dist/registry/index.d.ts +3 -2
  14. package/dist/registry/index.d.ts.map +1 -1
  15. package/dist/registry/index.js +2 -1
  16. package/dist/registry/registry.d.ts +17 -1
  17. package/dist/registry/registry.d.ts.map +1 -1
  18. package/dist/registry/registry.js +74 -0
  19. package/dist/registry/types.d.ts +35 -0
  20. package/dist/registry/types.d.ts.map +1 -1
  21. package/dist/registry/use-registry-version.d.ts +11 -0
  22. package/dist/registry/use-registry-version.d.ts.map +1 -0
  23. package/dist/registry/use-registry-version.js +15 -0
  24. package/dist/schema/index.d.ts +1 -0
  25. package/dist/schema/index.d.ts.map +1 -1
  26. package/dist/schema/index.js +2 -0
  27. package/dist/schema/nodes/level.d.ts +3 -0
  28. package/dist/schema/nodes/level.d.ts.map +1 -1
  29. package/dist/schema/nodes/level.js +9 -0
  30. package/dist/schema/types.d.ts +1 -0
  31. package/dist/schema/types.d.ts.map +1 -1
  32. package/dist/services/index.d.ts +1 -1
  33. package/dist/services/index.d.ts.map +1 -1
  34. package/dist/services/index.js +1 -1
  35. package/dist/services/storey.d.ts +3 -10
  36. package/dist/services/storey.d.ts.map +1 -1
  37. package/dist/services/storey.js +45 -15
  38. package/dist/store/actions/node-actions.d.ts +8 -4
  39. package/dist/store/actions/node-actions.d.ts.map +1 -1
  40. package/dist/store/actions/node-actions.js +31 -4
  41. package/dist/store/history-control.d.ts +5 -0
  42. package/dist/store/history-control.d.ts.map +1 -1
  43. package/dist/store/history-control.js +68 -6
  44. package/dist/store/use-scene.d.ts.map +1 -1
  45. package/dist/store/use-scene.js +75 -163
  46. package/dist/systems/elevator/elevator-service.d.ts.map +1 -1
  47. package/dist/systems/elevator/elevator-service.js +16 -11
  48. package/dist/systems/slab/slab-support.d.ts +0 -31
  49. package/dist/systems/slab/slab-support.d.ts.map +1 -1
  50. package/dist/systems/slab/slab-support.js +26 -4
  51. package/dist/systems/stair/stair-opening-preview.d.ts +1 -0
  52. package/dist/systems/stair/stair-opening-preview.d.ts.map +1 -1
  53. package/dist/systems/stair/stair-rise.d.ts.map +1 -1
  54. package/dist/systems/stair/stair-rise.js +4 -2
  55. package/dist/systems/wall/wall-mitering.d.ts.map +1 -1
  56. package/dist/systems/wall/wall-mitering.js +88 -9
  57. package/dist/systems/wall/wall-topology.d.ts +53 -0
  58. package/dist/systems/wall/wall-topology.d.ts.map +1 -0
  59. package/dist/systems/wall/wall-topology.js +414 -0
  60. package/dist/utils/clone-scene-graph.d.ts +2 -0
  61. package/dist/utils/clone-scene-graph.d.ts.map +1 -1
  62. package/dist/utils/clone-scene-graph.js +13 -2
  63. package/dist/utils/scene-migrations.d.ts +1 -0
  64. package/dist/utils/scene-migrations.d.ts.map +1 -1
  65. package/dist/utils/scene-migrations.js +1 -0
  66. package/dist/utils/vertical-scene-migration.d.ts +13 -0
  67. package/dist/utils/vertical-scene-migration.d.ts.map +1 -0
  68. package/dist/utils/vertical-scene-migration.js +179 -0
  69. package/package.json +1 -1
@@ -7,7 +7,7 @@ import { BuildingNode } from '../schema';
7
7
  import { generateCollectionId } from '../schema/collections';
8
8
  import { DoorNode as DoorNodeSchema } from '../schema/nodes/door';
9
9
  import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator';
10
- import { LevelNode } from '../schema/nodes/level';
10
+ import { LevelNode, normalizeLevelBaseElevation } from '../schema/nodes/level';
11
11
  import { getPitchFromActiveRoofHeight, } from '../schema/nodes/roof-segment';
12
12
  import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls';
13
13
  import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf';
@@ -18,12 +18,9 @@ import { getEffectiveWallSurfaceMaterial } from '../schema/nodes/wall';
18
18
  import { WindowNode as WindowNodeSchema } from '../schema/nodes/window';
19
19
  import { generateSceneMaterialId, SceneMaterial, } from '../schema/scene-material';
20
20
  import { AnyNode as AnyNodeSchema } from '../schema/types';
21
- import { deriveLegacyLevelHeight } from '../services/level-height';
22
- import { getCeilingClampBound } from '../services/storey';
23
- import { computeWallSlabSupport } from '../systems/slab/slab-support';
24
- import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint';
25
21
  import { healSceneNodes } from '../utils/heal-scene-graph';
26
22
  import { removeRetiredDrawingSheetNodes } from '../utils/retired-scene-nodes';
23
+ import { migrateVerticalSceneNodes } from '../utils/vertical-scene-migration';
27
24
  import * as nodeActions from './actions/node-actions';
28
25
  import { areSceneSnapshotsEqual, getSceneHistoryPauseDepth, notifySceneCommit, pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, } from './history-control';
29
26
  import useLiveNodeOverrides from './use-live-node-overrides';
@@ -485,14 +482,6 @@ function migrateWallAssembly(node) {
485
482
  const { assemblyLayers: _assemblyLayers, ...wall } = node;
486
483
  return assemblyThickness > 0 ? { ...wall, thickness: assemblyThickness } : wall;
487
484
  }
488
- // Walls whose top lands within this of the storey plane become plane-bound;
489
- // ceilings whose stored height lands within this of their clamp bound become
490
- // follows-mode (step 3f) — same census-backed threshold for both.
491
- // From a prod census: the 0.15-short "hole pattern" (default 2.5 walls next to
492
- // a taller wall) must snap to the plane, while intentional 0.20-short walls
493
- // (2.5 under a 2.7 plane, 2.3 under a 2.5 plane) must keep their explicit
494
- // height — hence 0.20 with a strictly-less-than comparison.
495
- const PLANE_BOUND_EPSILON = 0.2;
496
485
  function migrateNodes(nodes) {
497
486
  // Repair pre-existing corruption (null children, zero-length walls) before
498
487
  // any per-type migration runs, so already-saved scenes load cleanly.
@@ -749,6 +738,7 @@ function migrateNodes(nodes) {
749
738
  const levelNumber = getFiniteNumber(node.level, 0);
750
739
  patchedNodes[id] = {
751
740
  ...node,
741
+ baseElevation: normalizeLevelBaseElevation(node.baseElevation),
752
742
  level: levelNumber,
753
743
  children: validChildren,
754
744
  };
@@ -771,155 +761,8 @@ function migrateNodes(nodes) {
771
761
  patchedNodes[id] = normalized;
772
762
  }
773
763
  }
774
- // Pass 3: vertical building model.
775
- // A level without `height` marks a scene saved before the vertical model
776
- // landed. Computed before this pass mutates anything: the stair-rise
777
- // cleanup below must never run on already-migrated scenes.
778
- const isLegacyScene = Object.values(patchedNodes).some((node) => node?.type === 'level' && !('height' in node));
779
- // 3a. Ordinal renumber — always runs, per building (idempotent
780
- // self-healing; MCP's create-level historically wrote its elevation PARAM
781
- // into the ordinal, so fractional/duplicate ordinals exist in the wild).
782
- const buildingNodes = Object.values(patchedNodes).filter((node) => node?.type === 'building');
783
- const levelsByBuilding = new Map();
784
- for (const [id, node] of Object.entries(patchedNodes)) {
785
- if (node?.type !== 'level')
786
- continue;
787
- // Mirrors the building resolution in services/storey.ts: an explicit
788
- // parentId pointing at a building wins, membership in a building's
789
- // children array is the legacy fallback, and unresolvable levels share
790
- // one orphan bucket.
791
- const buildingId = buildingNodes.find((building) => building.id === node.parentId)?.id ??
792
- buildingNodes.find((building) => getStringArray(building.children).includes(id))?.id ??
793
- null;
794
- const bucket = levelsByBuilding.get(buildingId) ?? [];
795
- bucket.push({ id, ordinal: getFiniteNumber(node.level, 0) });
796
- levelsByBuilding.set(buildingId, bucket);
797
- }
798
- for (const bucket of levelsByBuilding.values()) {
799
- // Anchored at zero on purpose: ordinals are semantic — `level < 0`
800
- // renders "Basement N" and `level === 0` is the ground-floor default —
801
- // so negatives compact upward toward −1 and non-negatives compact down
802
- // to 0. A blind 0..n renumber would rename basements.
803
- const sorted = [...bucket].sort((a, b) => a.ordinal - b.ordinal);
804
- const negativeCount = sorted.filter((entry) => entry.ordinal < 0).length;
805
- sorted.forEach((entry, index) => {
806
- const nextOrdinal = index - negativeCount;
807
- const current = patchedNodes[entry.id];
808
- if (current.level !== nextOrdinal) {
809
- patchedNodes[entry.id] = { ...current, level: nextOrdinal };
810
- }
811
- });
812
- }
813
- // 3b. Stored storey heights: materialize the legacy stacked height verbatim
814
- // (never rounded or snapped — snapping would move existing buildings).
815
- // All planes derive before any wall height below mutates.
816
- const legacyLevelIds = Object.entries(patchedNodes)
817
- .filter(([, node]) => node?.type === 'level' && !('height' in node))
818
- .map(([id]) => id);
819
- const derivedHeights = new Map();
820
- for (const levelId of legacyLevelIds) {
821
- derivedHeights.set(levelId, deriveLegacyLevelHeight(levelId, patchedNodes));
822
- }
823
- for (const levelId of legacyLevelIds) {
824
- const plane = derivedHeights.get(levelId);
825
- const level = patchedNodes[levelId];
826
- patchedNodes[levelId] = { ...level, height: plane };
827
- // 3c. Wall-top classification against the just-written plane, using the
828
- // same slab-support election as deriveLegacyLevelHeight (call shape
829
- // mirrored from services/level-height.ts). Walls whose top meets the
830
- // plane drop their explicit height and follow the level from now on;
831
- // walls ending short (or tall) keep an explicit height — materializing
832
- // the 2.5 default onto absent-height walls that end short of the plane.
833
- const children = getStringArray(level.children)
834
- .map((childId) => patchedNodes[childId])
835
- .filter((child) => child !== undefined);
836
- const slabs = children.filter((child) => child.type === 'slab');
837
- const walls = children.filter((child) => child.type === 'wall');
838
- for (const wall of walls) {
839
- const electedBase = computeWallSlabSupport({
840
- start: wall.start,
841
- end: wall.end,
842
- curveOffset: wall.curveOffset,
843
- thickness: wall.thickness,
844
- }, slabs, walls).elevation;
845
- const effectiveHeight = wall.height ?? DEFAULT_WALL_HEIGHT;
846
- const top = Math.max(0, electedBase) + effectiveHeight;
847
- if (Math.abs(plane - top) < PLANE_BOUND_EPSILON) {
848
- if ('height' in wall) {
849
- const { height: _height, ...planeBound } = wall;
850
- patchedNodes[wall.id] = planeBound;
851
- }
852
- }
853
- else {
854
- patchedNodes[wall.id] = { ...wall, height: effectiveHeight };
855
- }
856
- }
857
- }
858
- // 3d. Stair rise: on legacy scenes a totalRise of exactly 2.5 is the old
859
- // schema default, not a user choice — drop it so the rise derives from the
860
- // storey height. Gated on isLegacyScene because on a post-migration scene
861
- // a stored 2.5 IS a deliberately typed value and must survive reloads.
862
- if (isLegacyScene) {
863
- for (const [id, node] of Object.entries(patchedNodes)) {
864
- if (node?.type !== 'stair')
865
- continue;
866
- if (node.totalRise !== 2.5)
867
- continue;
868
- const { totalRise: _totalRise, ...derivedRise } = node;
869
- patchedNodes[id] = derivedRise;
870
- }
871
- }
872
- // 3e. Slab placement/thickness split. `elevation` stays the walking surface;
873
- // the new `thickness` grows downward so the solid occupies
874
- // [elevation − thickness, elevation]. Legacy solids extruded [0, elevation],
875
- // so thickness = elevation EXACTLY (including degenerate 0 — MIN_SLAB_THICKNESS
876
- // applies to edits only, never here) keeps the occupied interval identical.
877
- // Legacy pools (elevation < 0) become explicit `recessed` intent with
878
- // elevation unchanged. Gated per slab on a missing `thickness` — the
879
- // migration output is cast, so schema defaults never materialize on load.
880
- for (const [id, node] of Object.entries(patchedNodes)) {
881
- if (node?.type !== 'slab' || 'thickness' in node)
882
- continue;
883
- const elevation = getFiniteNumber(node.elevation, 0.05);
884
- patchedNodes[id] =
885
- elevation < 0
886
- ? { ...node, thickness: 0.05, recessed: true }
887
- : { ...node, thickness: elevation };
888
- }
889
- // 3f. Ceiling follows-mode classification (the ceiling mirror of 3c; runs
890
- // after 3b/3e so the clamp bound sees stored level heights and split slab
891
- // thicknesses). A stored ceiling height within PLANE_BOUND_EPSILON of its
892
- // clamp bound (min(storey plane, covering-slab underside) − margin, via
893
- // getCeilingClampBound) is the legacy default tracking the level top, not
894
- // a choice — drop it so the ceiling follows the level from now on.
895
- // autoFromWalls ceilings always convert: their height was derived by the
896
- // space-detection sync, never user intent. Gated on isLegacyScene, which
897
- // is exact — nothing shipped between the level-height migration and this
898
- // one — and makes the step idempotent. Known accepted edge: a
899
- // post-migration user typing a custom height exactly equal to the bound
900
- // keeps it (the gate prevents re-classification on later loads).
901
- if (isLegacyScene) {
902
- for (const [id, node] of Object.entries(patchedNodes)) {
903
- if (node?.type !== 'ceiling' || !('height' in node))
904
- continue;
905
- const dropHeight = () => {
906
- const { height: _height, ...follows } = node;
907
- patchedNodes[id] = follows;
908
- };
909
- if (node.autoFromWalls === true) {
910
- dropHeight();
911
- continue;
912
- }
913
- if (typeof node.parentId !== 'string')
914
- continue;
915
- const bound = getCeilingClampBound(node.parentId, patchedNodes, Array.isArray(node.polygon) ? node.polygon : []);
916
- const stored = getFiniteNumber(node.height, Number.NaN);
917
- if (Number.isFinite(bound) && Math.abs(stored - bound) < PLANE_BOUND_EPSILON) {
918
- dropHeight();
919
- }
920
- }
921
- }
922
- return { nodes: patchedNodes, mintedMaterials };
764
+ const vertical = migrateVerticalSceneNodes(patchedNodes);
765
+ return { nodes: vertical.nodes, mintedMaterials };
923
766
  }
924
767
  function getNodeChildIds(node) {
925
768
  if (!('children' in node && Array.isArray(node.children))) {
@@ -971,7 +814,76 @@ function collectReachableNodeIds(nodes, rootNodeIds) {
971
814
  }
972
815
  function sceneHistorySnapshotFromState(state) {
973
816
  const { nodes, rootNodeIds, collections, materials, installedPlugins } = state;
974
- return { nodes, rootNodeIds, collections, materials, installedPlugins };
817
+ // Fresh placement nodes are renderable drafts, not document history. Excluding their
818
+ // entire subtree here protects both local undo and external commit subscribers.
819
+ const transientNodeIds = new Set();
820
+ for (const node of Object.values(nodes)) {
821
+ const metadata = node.metadata;
822
+ if (metadata &&
823
+ typeof metadata === 'object' &&
824
+ !Array.isArray(metadata) &&
825
+ metadata.isNew === true) {
826
+ transientNodeIds.add(node.id);
827
+ }
828
+ }
829
+ if (transientNodeIds.size === 0) {
830
+ return { nodes, rootNodeIds, collections, materials, installedPlugins };
831
+ }
832
+ const childIdsByParentId = new Map();
833
+ const addChild = (parentId, childId) => {
834
+ const childIds = childIdsByParentId.get(parentId) ?? new Set();
835
+ childIds.add(childId);
836
+ childIdsByParentId.set(parentId, childIds);
837
+ };
838
+ for (const node of Object.values(nodes)) {
839
+ if (node.parentId)
840
+ addChild(node.parentId, node.id);
841
+ for (const childId of getNodeChildIds(node))
842
+ addChild(node.id, childId);
843
+ }
844
+ const pendingIds = [...transientNodeIds];
845
+ while (pendingIds.length > 0) {
846
+ const parentId = pendingIds.pop();
847
+ if (!parentId)
848
+ continue;
849
+ for (const childId of childIdsByParentId.get(parentId) ?? []) {
850
+ if (transientNodeIds.has(childId))
851
+ continue;
852
+ transientNodeIds.add(childId);
853
+ pendingIds.push(childId);
854
+ }
855
+ }
856
+ const historyNodes = {};
857
+ for (const [id, node] of Object.entries(nodes)) {
858
+ if (transientNodeIds.has(id))
859
+ continue;
860
+ if (!('children' in node && Array.isArray(node.children))) {
861
+ historyNodes[id] = node;
862
+ continue;
863
+ }
864
+ const children = node.children.filter((childId) => !transientNodeIds.has(childId));
865
+ historyNodes[id] =
866
+ children.length === node.children.length ? node : { ...node, children };
867
+ }
868
+ const historyCollections = {};
869
+ for (const [id, collection] of Object.entries(collections)) {
870
+ const nodeIds = collection.nodeIds.filter((nodeId) => !transientNodeIds.has(nodeId));
871
+ if (collection.controlNodeId && transientNodeIds.has(collection.controlNodeId)) {
872
+ const { controlNodeId: _controlNodeId, ...rest } = collection;
873
+ historyCollections[id] = { ...rest, nodeIds };
874
+ }
875
+ else {
876
+ historyCollections[id] =
877
+ nodeIds.length === collection.nodeIds.length ? collection : { ...collection, nodeIds };
878
+ }
879
+ }
880
+ return {
881
+ nodes: historyNodes,
882
+ rootNodeIds: rootNodeIds.filter((id) => !transientNodeIds.has(id)),
883
+ collections: historyCollections,
884
+ materials,
885
+ installedPlugins,
886
+ };
975
887
  }
976
888
  const useScene = create()(temporal((set, get) => ({
977
889
  // 1. Flat dictionary of all nodes
@@ -1 +1 @@
1
- {"version":3,"file":"elevator-service.d.ts","sourceRoot":"","sources":["../../../src/systems/elevator/elevator-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAa,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAG/E,MAAM,MAAM,kBAAkB,GAAG;IAC/B,EAAE,EAAE,SAAS,CAAC,IAAI,CAAC,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AA2BD,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,SAAS,EAAE,CAEb;AAED,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,MAAM,EAAE,CAEV;AAED,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,SAAS,EAAE,CAsBb;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B;IACD,OAAO,EAAE,kBAAkB,EAAE,CAAA;IAC7B,YAAY,EAAE,kBAAkB,GAAG,IAAI,CAAA;IACvC,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;CACpB,CA0CA"}
1
+ {"version":3,"file":"elevator-service.d.ts","sourceRoot":"","sources":["../../../src/systems/elevator/elevator-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAa,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAG/E,MAAM,MAAM,kBAAkB,GAAG;IAC/B,EAAE,EAAE,SAAS,CAAC,IAAI,CAAC,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AA2BD,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,SAAS,EAAE,CAEb;AAED,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,MAAM,EAAE,CAEV;AAED,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,SAAS,EAAE,CAsBb;AAED,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,YAAY,EACtB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B;IACD,OAAO,EAAE,kBAAkB,EAAE,CAAA;IAC7B,YAAY,EAAE,kBAAkB,GAAG,IAAI,CAAA;IACvC,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;CACpB,CA6CA"}
@@ -1,4 +1,4 @@
1
- import { getStoredLevelHeight } from '../../services/storey';
1
+ import { getLevelElevations } from '../../services/storey';
2
2
  function getBuildingLevels(elevator, nodes) {
3
3
  const building = elevator.parentId && nodes[elevator.parentId]?.type === 'building'
4
4
  ? nodes[elevator.parentId]
@@ -51,17 +51,12 @@ export function resolveElevatorServiceLevels(elevator, nodes) {
51
51
  }
52
52
  export function resolveElevatorLevels(elevator, nodes) {
53
53
  const allLevels = resolveElevatorBuildingLevels(elevator, nodes);
54
- const baseYByLevelId = new Map();
55
- let cumulativeY = 0;
56
- for (const level of allLevels) {
57
- baseYByLevelId.set(level.id, cumulativeY);
58
- cumulativeY += getStoredLevelHeight(level);
59
- }
54
+ const levelElevations = getLevelElevations(nodes);
60
55
  const serviceLevels = resolveElevatorServiceLevels(elevator, nodes);
61
56
  const entries = serviceLevels.map((level) => ({
62
57
  id: level.id,
63
58
  label: String(level.level),
64
- baseY: baseYByLevelId.get(level.id) ?? 0,
59
+ baseY: levelElevations.get(level.id)?.baseY ?? 0,
65
60
  }));
66
61
  const defaultEntry = entries.find((entry) => entry.id === elevator.defaultLevelId) ??
67
62
  entries.find((entry) => entry.id === elevator.fromLevelId) ??
@@ -69,15 +64,25 @@ export function resolveElevatorLevels(elevator, nodes) {
69
64
  null;
70
65
  const firstServedLevel = serviceLevels[0] ?? null;
71
66
  const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null;
72
- const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0;
67
+ const shaftBaseY = firstServedLevel ? (levelElevations.get(firstServedLevel.id)?.baseY ?? 0) : 0;
73
68
  const lastServedIndex = lastServedLevel
74
69
  ? allLevels.findIndex((level) => level.id === lastServedLevel.id)
75
70
  : -1;
76
71
  const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null;
72
+ // Highest ceiling in the stack, not the topmost level's: a negative
73
+ // baseElevation on the top level can put its ceiling below the level
74
+ // beneath it, and a shaft top under a served level would clip the cab.
75
+ let stackTopY = 0;
76
+ for (const level of allLevels) {
77
+ const elevation = levelElevations.get(level.id);
78
+ if (!elevation)
79
+ continue;
80
+ stackTopY = Math.max(stackTopY, elevation.baseY + elevation.height);
81
+ }
77
82
  const shaftTopY = nextLevel
78
- ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
83
+ ? (levelElevations.get(nextLevel.id)?.baseY ?? stackTopY)
79
84
  : lastServedLevel
80
- ? cumulativeY
85
+ ? stackTopY
81
86
  : elevator.cabHeight + 0.3;
82
87
  return {
83
88
  entries,
@@ -114,37 +114,6 @@ export type WallSlabSupportSegment = {
114
114
  end: number;
115
115
  elevation: number;
116
116
  };
117
- /**
118
- * `preferredSlabId` is a persisted support host (`wall.supportSlabId`):
119
- * while that slab is still in the candidate set (still overlaps the wall
120
- * band with enough covered length), the elected `elevation` is pinned to
121
- * it instead of the majority/best-coverage election. `baseSegments` /
122
- * `baseElevation` (fill-down) still derive from ALL supporting slabs
123
- * unchanged. A preferred slab that no longer qualifies is silently
124
- * ignored — deliberately never cleared here, so the host resumes if the
125
- * slab's polygon returns (only slab deletion strips the stored field).
126
- *
127
- * `maxElevation` is the pointer-decided support cap (level-local Y, same
128
- * semantics as the item election): when set, elevation groups whose
129
- * walking surface sits above `maxElevation + SUPPORT_ELEVATION_EPSILON`
130
- * are excluded from the majority/best election — a deck hanging above the
131
- * surface the cursor ray actually hit never captures the elected base.
132
- * `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and
133
- * an explicit `preferredSlabId` still wins over the cap.
134
- *
135
- * `levelBase` is what "no slab supports this here" evaluates to — the
136
- * sculpted ground under the wall (`levelBaseElevationAt`), or 0 for a level
137
- * with no terrain under it. A caller-resolved scalar rather than a terrain
138
- * lookup in here, so this stays pure and the sample stays at the wall's own
139
- * XZ. It substitutes for every place this function used to write a literal
140
- * `0`, and nowhere else: a slab that supports the wall's faces still wins
141
- * outright, so a slab pad on a hillside keeps its wall at the pad's
142
- * elevation instead of being overruled by the ground around it. The one
143
- * clamp — center-only support, where the old code already clamped a
144
- * recessed slab up to `0` so a wall over a pool didn't sink — generalizes to
145
- * "never below the ground", which is the same rule with the ground no longer
146
- * assumed flat.
147
- */
148
117
  export declare function computeWallSlabSupport(wallLike: WallOverlapInput, slabs: readonly SlabNode[], levelWalls: WallNode[], preferredSlabId?: string | null, maxElevation?: number | null, levelBase?: number): WallSlabSupport;
149
118
  export declare function computeWallSlabElevation(wallLike: WallOverlapInput, slabs: readonly SlabNode[], levelWalls: WallNode[]): number;
150
119
  //# sourceMappingURL=slab-support.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"slab-support.d.ts","sourceRoot":"","sources":["../../../src/systems/slab/slab-support.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"slab-support.d.ts","sourceRoot":"","sources":["../../../src/systems/slab/slab-support.ts"],"names":[],"mappings":"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"}
@@ -396,6 +396,31 @@ const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4;
396
396
  * "never below the ground", which is the same rule with the ground no longer
397
397
  * assumed flat.
398
398
  */
399
+ // A rendered slab polygon depends only on the slab set and the level's walls,
400
+ // never on the wall being tested — but a per-frame pass asks for support once
401
+ // per wall, so the identical polygons were rebuilt for every wall on the level
402
+ // (and each rebuild scans all of `levelWalls`). Keyed on array identity: the
403
+ // caller derives those arrays once and only rebuilds them when the scene or a
404
+ // live preview changes, so a hit means the inputs are the same objects.
405
+ let polygonMemoSlabs = null;
406
+ let polygonMemoWalls = null;
407
+ let polygonMemo = new Map();
408
+ function renderedSlabPolygon(slab, slabs, levelWalls) {
409
+ if (polygonMemoSlabs !== slabs || polygonMemoWalls !== levelWalls) {
410
+ polygonMemoSlabs = slabs;
411
+ polygonMemoWalls = levelWalls;
412
+ polygonMemo = new Map();
413
+ }
414
+ const cached = polygonMemo.get(slab.id);
415
+ if (cached)
416
+ return cached;
417
+ const polygon = getRenderableSlabPolygon(slab, {
418
+ walls: levelWalls,
419
+ siblingSlabs: slabs.filter((other) => other.id !== slab.id),
420
+ });
421
+ polygonMemo.set(slab.id, polygon);
422
+ return polygon;
423
+ }
399
424
  export function computeWallSlabSupport(wallLike, slabs, levelWalls, preferredSlabId, maxElevation, levelBase = 0) {
400
425
  const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike;
401
426
  const halfThickness = Math.max(thickness / 2, 0);
@@ -417,10 +442,7 @@ export function computeWallSlabSupport(wallLike, slabs, levelWalls, preferredSla
417
442
  for (const slab of slabs) {
418
443
  if (slab.polygon.length < 3)
419
444
  continue;
420
- const renderedPolygon = getRenderableSlabPolygon(slab, {
421
- walls: levelWalls,
422
- siblingSlabs: slabs.filter((other) => other.id !== slab.id),
423
- });
445
+ const renderedPolygon = renderedSlabPolygon(slab, slabs, levelWalls);
424
446
  let supported = 0;
425
447
  const perPolyline = polylines.map((line) => {
426
448
  let intervals = polylineInsideIntervals(line, renderedPolygon);
@@ -1338,6 +1338,7 @@ export declare function getNodesWithLiveStairOpeningInputs(nodes: Record<string,
1338
1338
  type: "level";
1339
1339
  children: (`item_${string}` | `ceiling_${string}` | `column_${string}` | `wall_${string}` | `fence_${string}` | `construction-dimension_${string}` | `structural-grid_${string}` | `zone_${string}` | `slab_${string}` | `roof_${string}` | `stair_${string}` | `scan_${string}` | `guide_${string}` | `measurement_${string}` | `spawn_${string}` | `shelf_${string}` | `duct-segment_${string}` | `duct-fitting_${string}` | `duct-terminal_${string}` | `hvac-equipment_${string}` | `lineset_${string}` | `liquid-line_${string}` | `pipe-segment_${string}` | `pipe-fitting_${string}` | `pipe-trap_${string}`)[];
1340
1340
  level: number;
1341
+ baseElevation: number;
1341
1342
  name?: string | undefined;
1342
1343
  camera?: {
1343
1344
  position: [number, number, number];
@@ -1 +1 @@
1
- {"version":3,"file":"stair-opening-preview.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-opening-preview.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAC7E,OAA6B,EAAE,KAAK,iBAAiB,EAAE,MAAM,qCAAqC,CAAA;AAClG,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAA;AAGpE,KAAK,oBAAoB,GAAG;IAC1B,EAAE,EAAE,SAAS,CAAA;IACb,IAAI,EAAE,OAAO,CAAC,QAAQ,GAAG,WAAW,CAAC,CAAA;CACtC,CAAA;AAoBD,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,cAAc,EAAE,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,EAClD,aAAa,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,EACrD,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,WAYvC;AAED,wBAAgB,kCAAkC,CAChD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,cAAc,EAAE,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,EAClD,aAAa,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,EACrD,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwBvC;AAED,wBAAgB,qCAAqC;;mBAUlC,oBAAoB,EAAE;;EAiCxC"}
1
+ {"version":3,"file":"stair-opening-preview.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-opening-preview.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAC7E,OAA6B,EAAE,KAAK,iBAAiB,EAAE,MAAM,qCAAqC,CAAA;AAClG,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAA;AAGpE,KAAK,oBAAoB,GAAG;IAC1B,EAAE,EAAE,SAAS,CAAA;IACb,IAAI,EAAE,OAAO,CAAC,QAAQ,GAAG,WAAW,CAAC,CAAA;CACtC,CAAA;AAoBD,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,cAAc,EAAE,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,EAClD,aAAa,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,EACrD,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,WAYvC;AAED,wBAAgB,kCAAkC,CAChD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,cAAc,EAAE,WAAW,CAAC,MAAM,EAAE,aAAa,CAAC,EAClD,aAAa,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,EACrD,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwBvC;AAED,wBAAgB,qCAAqC;;mBAUlC,oBAAoB,EAAE;;EAiCxC"}
@@ -1 +1 @@
1
- {"version":3,"file":"stair-rise.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-rise.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAoB,MAAM,cAAc,CAAA;AAInF,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CA+B9F;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,KAAK,CAAC;IAAE,EAAE,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,CAAC,CAgClD"}
1
+ {"version":3,"file":"stair-rise.d.ts","sourceRoot":"","sources":["../../../src/systems/stair/stair-rise.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAoB,MAAM,cAAc,CAAA;AAInF,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAiC9F;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,KAAK,CAAC;IAAE,EAAE,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,CAAC,CAgClD"}
@@ -1,6 +1,6 @@
1
1
  import { getFloorStackedPosition } from '../../hooks/spatial-grid/floor-placed-elevation';
2
2
  import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height';
3
- import { getStoredLevelHeight } from '../../services/storey';
3
+ import { getLevelFloorToFloorHeight } from '../../services/storey';
4
4
  export function resolveStairTotalRise(stair, nodes) {
5
5
  if (stair.totalRise !== undefined)
6
6
  return stair.totalRise;
@@ -27,7 +27,9 @@ export function resolveStairTotalRise(stair, nodes) {
27
27
  return (deck.elevation ?? 0.05) - baseElevation;
28
28
  }
29
29
  }
30
- return level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT;
30
+ return level?.type === 'level'
31
+ ? getLevelFloorToFloorHeight(level.id, nodes)
32
+ : DEFAULT_LEVEL_HEIGHT;
31
33
  }
32
34
  const RISE_SYNC_EPSILON = 1e-4;
33
35
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"wall-mitering.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-mitering.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAO5C,MAAM,WAAW,OAAO;IACtB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV;AAED,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,OAAO,CAAA;IAClB,UAAU,EAAE,OAAO,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,OAAO,CAAA;CAClB;AASD,KAAK,iBAAiB,GAAG,GAAG,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAAA;AAGzE,KAAK,YAAY,GAAG,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAA;AAmBlD,iBAAS,UAAU,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,SAAY,GAAG,MAAM,CAG7D;AA8CD,UAAU,QAAQ;IAChB,YAAY,EAAE,OAAO,CAAA;IACrB,cAAc,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,QAAQ,CAAC;QAAC,OAAO,EAAE,OAAO,GAAG,KAAK,GAAG,aAAa,CAAA;KAAE,CAAC,CAAA;CACpF;AAkPD,MAAM,WAAW,aAAa;IAE5B,YAAY,EAAE,YAAY,CAAA;IAE1B,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;CACjC;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,aAAa,CAWrE;AAED,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,QAAQ,EACd,SAAS,EAAE,aAAa,GACvB,uBAAuB,GAAG,IAAI,CA0BhC;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,EAAE,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,CAAC,CA8C/F;AAGD,OAAO,EAAE,UAAU,EAAE,CAAA"}
1
+ {"version":3,"file":"wall-mitering.d.ts","sourceRoot":"","sources":["../../../src/systems/wall/wall-mitering.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAO5C,MAAM,WAAW,OAAO;IACtB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;CACV;AAED,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,OAAO,CAAA;IAClB,UAAU,EAAE,OAAO,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,OAAO,CAAA;CAClB;AASD,KAAK,iBAAiB,GAAG,GAAG,CAAC,MAAM,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAAA;AAGzE,KAAK,YAAY,GAAG,GAAG,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAA;AAmBlD,iBAAS,UAAU,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,SAAY,GAAG,MAAM,CAG7D;AA8CD,UAAU,QAAQ;IAChB,YAAY,EAAE,OAAO,CAAA;IACrB,cAAc,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,QAAQ,CAAC;QAAC,OAAO,EAAE,OAAO,GAAG,KAAK,GAAG,aAAa,CAAA;KAAE,CAAC,CAAA;CACpF;AAyUD,MAAM,WAAW,aAAa;IAE5B,YAAY,EAAE,YAAY,CAAA;IAE1B,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;CACjC;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,aAAa,CAWrE;AAED,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,QAAQ,EACd,SAAS,EAAE,aAAa,GACvB,uBAAuB,GAAG,IAAI,CA0BhC;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,EAAE,EAAE,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,CAAC,CA8C/F;AAGD,OAAO,EAAE,UAAU,EAAE,CAAA"}
@@ -52,6 +52,49 @@ function pointOnWallSegment(point, wall, tolerance = TOLERANCE) {
52
52
  const dist = Math.sqrt((point.x - projX) ** 2 + (point.y - projY) ** 2);
53
53
  return dist < tolerance;
54
54
  }
55
+ // --- Uniform grid used to prefilter T-junction candidates --------------------
56
+ // 2 m cells: small enough that a dense imported floor spreads across many
57
+ // buckets, large enough that an ordinary room wall touches only a few.
58
+ const JUNCTION_GRID_CELL = 2.0;
59
+ // A wall whose AABB would touch more than this many cells (a very long diagonal)
60
+ // is kept in a fallback list checked against every junction. Such walls are rare,
61
+ // and a model made only of them is a model with very few walls — where the naive
62
+ // scan was never the problem.
63
+ const JUNCTION_GRID_MAX_CELLS_PER_WALL = 64;
64
+ function cellKey(x, y) {
65
+ return `${Math.floor(x / JUNCTION_GRID_CELL)},${Math.floor(y / JUNCTION_GRID_CELL)}`;
66
+ }
67
+ function buildJunctionGrid(walls) {
68
+ const grid = new Map();
69
+ const oversized = [];
70
+ for (const wall of walls) {
71
+ // Pad by TOLERANCE so a point sitting exactly on the AABB edge still lands
72
+ // in a covered cell.
73
+ const minX = Math.min(wall.start[0], wall.end[0]) - TOLERANCE;
74
+ const maxX = Math.max(wall.start[0], wall.end[0]) + TOLERANCE;
75
+ const minY = Math.min(wall.start[1], wall.end[1]) - TOLERANCE;
76
+ const maxY = Math.max(wall.start[1], wall.end[1]) + TOLERANCE;
77
+ const cx0 = Math.floor(minX / JUNCTION_GRID_CELL);
78
+ const cx1 = Math.floor(maxX / JUNCTION_GRID_CELL);
79
+ const cy0 = Math.floor(minY / JUNCTION_GRID_CELL);
80
+ const cy1 = Math.floor(maxY / JUNCTION_GRID_CELL);
81
+ if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > JUNCTION_GRID_MAX_CELLS_PER_WALL) {
82
+ oversized.push(wall);
83
+ continue;
84
+ }
85
+ for (let cx = cx0; cx <= cx1; cx++) {
86
+ for (let cy = cy0; cy <= cy1; cy++) {
87
+ const key = `${cx},${cy}`;
88
+ const bucket = grid.get(key);
89
+ if (bucket)
90
+ bucket.push(wall);
91
+ else
92
+ grid.set(key, [wall]);
93
+ }
94
+ }
95
+ }
96
+ return { grid, oversized };
97
+ }
55
98
  function findJunctions(walls) {
56
99
  const junctions = new Map();
57
100
  // First pass: group walls by their endpoints
@@ -69,17 +112,47 @@ function findJunctions(walls) {
69
112
  }
70
113
  junctions.get(keyEnd)?.connectedWalls.push({ wall, endType: 'end' });
71
114
  }
72
- // Second pass: detect T-junctions (walls passing through junction points)
115
+ // Second pass: detect T-junctions (walls passing through junction points).
116
+ //
117
+ // The naive form of this pass is `for each junction: for each wall` — O(J×N).
118
+ // On a real imported floor (1081 walls, 2047 endpoint keys) that is ~2.2M
119
+ // pointOnWallSegment calls and measured 584 ms per findJunctions() call, which
120
+ // WallSystem then repeats every frame while progressively rebuilding.
121
+ //
122
+ // A T-junction can only exist where the junction point lies ON the wall
123
+ // segment, so it must lie inside the wall's AABB. Bucketing walls by the grid
124
+ // cells their AABB covers therefore loses nothing: the cell containing the
125
+ // point is always one of the cells the wall was indexed into. With the input
126
+ // ordering restored below, the result matches the naive pass exactly; measured
127
+ // 11 ms on the same geometry.
128
+ const { grid, oversized } = buildJunctionGrid(walls);
129
+ const wallOrder = new Map(walls.map((wall, index) => [wall.id, index]));
73
130
  for (const [_key, junction] of junctions.entries()) {
74
- for (const wall of walls) {
75
- // Skip if wall already in this junction
76
- if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id))
131
+ const p = junction.meetingPoint;
132
+ const cellCandidates = grid.get(cellKey(p.x, p.y));
133
+ const passthrough = [];
134
+ for (const bucket of [cellCandidates, oversized]) {
135
+ if (!bucket || bucket.length === 0)
77
136
  continue;
78
- // Check if junction point lies on this wall's segment (not at endpoints)
79
- if (pointOnWallSegment(junction.meetingPoint, wall)) {
80
- junction.connectedWalls.push({ wall, endType: 'passthrough' });
137
+ for (const wall of bucket) {
138
+ // Skip if wall already in this junction
139
+ if (junction.connectedWalls.some((cw) => cw.wall.id === wall.id))
140
+ continue;
141
+ // Check if junction point lies on this wall's segment (not at endpoints)
142
+ if (pointOnWallSegment(junction.meetingPoint, wall)) {
143
+ passthrough.push(wall);
144
+ }
81
145
  }
82
146
  }
147
+ // Append in input order, not bucket order. Two collinear walls overlapping a
148
+ // junction tie on angle in `calculateJunctionIntersections`, so its stable
149
+ // sort leaves them in the order they were appended here — and an oversized
150
+ // wall would otherwise land after a shorter collinear neighbour it precedes
151
+ // in `walls`, picking the other wall's thickness for the miter.
152
+ passthrough.sort((a, b) => (wallOrder.get(a.id) ?? 0) - (wallOrder.get(b.id) ?? 0));
153
+ for (const wall of passthrough) {
154
+ junction.connectedWalls.push({ wall, endType: 'passthrough' });
155
+ }
83
156
  }
84
157
  // Filter to only junctions with 2+ walls
85
158
  const actualJunctions = new Map();
@@ -186,8 +259,14 @@ function calculateJunctionIntersections(junction, getThickness) {
186
259
  });
187
260
  }
188
261
  }
189
- // Sort by outgoing angle
190
- processedWalls.sort((a, b) => a.angle - b.angle);
262
+ // Sort by outgoing angle, then by wall ID so equal-angle walls produce the
263
+ // same pairing regardless of scene iteration order.
264
+ processedWalls.sort((a, b) => {
265
+ const angleOrder = a.angle - b.angle;
266
+ if (angleOrder !== 0)
267
+ return angleOrder;
268
+ return a.wallId < b.wallId ? -1 : a.wallId > b.wallId ? 1 : 0;
269
+ });
191
270
  const wallIntersections = new Map();
192
271
  const n = processedWalls.length;
193
272
  if (n < 2)