@ifc-lite/viewer 1.17.6 → 1.18.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 (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -34,6 +34,12 @@ import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
34
34
  import { createChatSlice, type ChatSlice } from './slices/chatSlice.js';
35
35
  import { createCesiumSlice, type CesiumSlice } from './slices/cesiumSlice.js';
36
36
  import { createDesktopEntitlementSlice, type DesktopEntitlementSlice } from './slices/desktopEntitlementSlice.js';
37
+ import { createScheduleSlice, type ScheduleSlice } from './slices/scheduleSlice.js';
38
+ import { createPlaybackSlice, type PlaybackSlice } from './slices/playbackSlice.js';
39
+ import { createOverlaySlice, type OverlaySlice } from './slices/overlaySlice.js';
40
+ import { createSearchSlice, type SearchSlice } from './slices/searchSlice.js';
41
+ import { createAnnotationsSlice, type AnnotationsSlice } from './slices/annotationsSlice.js';
42
+ import { createAddElementSlice, type AddElementSlice } from './slices/addElementSlice.js';
37
43
  import { invalidateVisibleBasketCache } from './basketVisibleSet.js';
38
44
 
39
45
  // Import constants for reset function
@@ -83,6 +89,21 @@ export type { DesktopEntitlementSlice } from './slices/desktopEntitlementSlice.j
83
89
  // Re-export Cesium types
84
90
  export type { CesiumSlice, CesiumDataSource } from './slices/cesiumSlice.js';
85
91
 
92
+ // Re-export Schedule (4D) types + selectors
93
+ export type { ScheduleSlice, ScheduleTimeRange, GanttTimeScale } from './slices/scheduleSlice.js';
94
+ export type { PlaybackSlice } from './slices/playbackSlice.js';
95
+ export type { OverlaySlice, OverlayLayer, RGBA as OverlayRGBA } from './slices/overlaySlice.js';
96
+ export { composeLayers as composeOverlayLayers } from './slices/overlaySlice.js';
97
+ export {
98
+ computeScheduleRange,
99
+ computeHiddenProductIds,
100
+ computeActiveProductIds,
101
+ taskStartEpoch,
102
+ taskFinishEpoch,
103
+ parseIsoDate,
104
+ } from './slices/scheduleSlice.js';
105
+ export { resolveScheduleSourceModelId } from './slices/schedule-edit-helpers.js';
106
+
86
107
  // Combined store type
87
108
  export type ViewerState = LoadingSlice &
88
109
  SelectionSlice &
@@ -105,7 +126,13 @@ export type ViewerState = LoadingSlice &
105
126
  ScriptSlice &
106
127
  ChatSlice &
107
128
  CesiumSlice &
108
- DesktopEntitlementSlice & {
129
+ DesktopEntitlementSlice &
130
+ ScheduleSlice &
131
+ PlaybackSlice &
132
+ OverlaySlice &
133
+ SearchSlice &
134
+ AnnotationsSlice &
135
+ AddElementSlice & {
109
136
  resetViewerState: () => void;
110
137
  };
111
138
 
@@ -136,6 +163,12 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
136
163
  ...createChatSlice(...args),
137
164
  ...createCesiumSlice(...args),
138
165
  ...createDesktopEntitlementSlice(...args),
166
+ ...createScheduleSlice(...args),
167
+ ...createPlaybackSlice(...args),
168
+ ...createOverlaySlice(...args),
169
+ ...createSearchSlice(...args),
170
+ ...createAnnotationsSlice(...args),
171
+ ...createAddElementSlice(...args),
139
172
 
140
173
  // Reset all viewer state when loading new file
141
174
  // Note: Does NOT clear models - use clearAllModels() for that
@@ -338,6 +371,21 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
338
371
  chatError: null,
339
372
  chatAbortController: null,
340
373
 
374
+ // Schedule (4D) - drop panel + data; definitions are re-extracted on
375
+ // next load. `playbackSpeed`, `playbackLoop`, and `ganttTimeScale` are
376
+ // intentionally preserved as user preferences that survive file loads.
377
+ ganttPanelVisible: false,
378
+ generateScheduleDialogOpen: false,
379
+ scheduleData: null,
380
+ scheduleRange: null,
381
+ activeWorkScheduleId: '',
382
+ expandedTaskGlobalIds: new Set<string>(),
383
+ hoveredTaskGlobalId: null,
384
+ selectedTaskGlobalIds: new Set<string>(),
385
+ animationEnabled: false,
386
+ playbackIsPlaying: false,
387
+ playbackTime: 0,
388
+
341
389
  // Mutations - clear all mutation state so stale changes don't carry over
342
390
  mutationViews: new Map(),
343
391
  changeSets: new Map(),
@@ -346,6 +394,27 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
346
394
  redoStacks: new Map(),
347
395
  dirtyModels: new Set(),
348
396
  mutationVersion: get().mutationVersion + 1,
397
+
398
+ // Search - results reference the previous model's expressIds, drop them.
399
+ searchQuery: '',
400
+ searchOpen: false,
401
+ searchHighlightIndex: 0,
402
+ searchIndexes: new Map(),
403
+ searchVimCycle: null,
404
+ searchModalOpen: false,
405
+ searchFieldFilter: 'all',
406
+ searchModelFilter: null,
407
+ searchFilterResult: null,
408
+ searchFilterRunning: false,
409
+ searchFilterError: null,
410
+ searchFilter: { rules: [], combinator: 'AND', limit: 500 },
411
+ searchFilterSchema: new Map(),
412
+
413
+ // Annotations — drop draft + selection so a new file doesn't
414
+ // inherit the previous file's pin authoring state. Persisted
415
+ // pins themselves stay in localStorage (cross-file workspace).
416
+ draft: null,
417
+ selectedAnnotationId: null,
349
418
  });
350
419
  },
351
420
  }));
@@ -0,0 +1,365 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Build simple "instant preview" meshes for newly-added elements so
7
+ * the user sees them in 3D the moment the builder commits — without
8
+ * waiting for an export+re-parse round-trip.
9
+ *
10
+ * Coordinate-system note (matches the rest of the viewer):
11
+ * - Builder params are in **IFC storey-local** metres (Z-up).
12
+ * - The renderer is **Y-up** with `viewer.y = ifc.z + storeyElevation`,
13
+ * `viewer.x = ifc.x`, `viewer.z = -ifc.y`.
14
+ * - We emit positions directly in renderer-frame so the mesh slots
15
+ * into the standard mesh-list pipeline.
16
+ *
17
+ * What we don't try to do here: cut openings, host walls, model door
18
+ * leaves correctly, tessellate non-convex polygons. The preview is a
19
+ * faithful extrusion of the user's parametric input, not a final
20
+ * presentation render — when the user exports, the IFC pipeline emits
21
+ * the proper sub-graph and a re-parse yields the canonical geometry.
22
+ */
23
+
24
+ import type { MeshData } from '@ifc-lite/geometry';
25
+ import type {
26
+ AddElementType,
27
+ AddElementWallParams,
28
+ AddElementSlabParams,
29
+ AddElementBeamParams,
30
+ AddElementColumnParams,
31
+ AddElementDoorParams,
32
+ AddElementWindowParams,
33
+ AddElementSpaceParams,
34
+ AddElementRoofParams,
35
+ AddElementPlateParams,
36
+ AddElementMemberParams,
37
+ } from './addElementSlice';
38
+
39
+ type Vec3 = [number, number, number];
40
+
41
+ /** Per-type colour palette for the preview mesh. RGBA, 0..1. */
42
+ const COLORS: Record<AddElementType, [number, number, number, number]> = {
43
+ wall: [0.85, 0.85, 0.82, 1.0],
44
+ slab: [0.78, 0.78, 0.78, 1.0],
45
+ beam: [0.65, 0.50, 0.35, 1.0],
46
+ column: [0.65, 0.50, 0.35, 1.0],
47
+ door: [0.55, 0.35, 0.20, 1.0],
48
+ window: [0.45, 0.65, 0.85, 0.45],
49
+ space: [0.30, 0.85, 0.55, 0.18],
50
+ roof: [0.55, 0.35, 0.30, 1.0],
51
+ plate: [0.70, 0.70, 0.72, 1.0],
52
+ member: [0.55, 0.55, 0.50, 1.0],
53
+ };
54
+
55
+ const IFC_TYPE: Record<AddElementType, string> = {
56
+ wall: 'IfcWall',
57
+ slab: 'IfcSlab',
58
+ beam: 'IfcBeam',
59
+ column: 'IfcColumn',
60
+ door: 'IfcDoor',
61
+ window: 'IfcWindow',
62
+ space: 'IfcSpace',
63
+ roof: 'IfcRoof',
64
+ plate: 'IfcPlate',
65
+ member: 'IfcMember',
66
+ };
67
+
68
+ export interface ElementBuildContext {
69
+ type: AddElementType;
70
+ /** New entity's globalId (federation-aware). Tags every vertex. */
71
+ globalId: number;
72
+ /** Storey elevation in IFC Z (metres) — added to vertex Y in renderer. */
73
+ storeyElevation: number;
74
+ /** Per-element-type discriminated params + click points. */
75
+ payload: ElementMeshPayload;
76
+ }
77
+
78
+ export type ElementMeshPayload =
79
+ | { type: 'wall'; params: AddElementWallParams; start: Vec3; end: Vec3 }
80
+ | { type: 'beam'; params: AddElementBeamParams; start: Vec3; end: Vec3 }
81
+ | { type: 'member'; params: AddElementMemberParams; start: Vec3; end: Vec3 }
82
+ | { type: 'column'; params: AddElementColumnParams; position: Vec3 }
83
+ | { type: 'door'; params: AddElementDoorParams; position: Vec3 }
84
+ | { type: 'window'; params: AddElementWindowParams; position: Vec3 }
85
+ | { type: 'slab'; params: AddElementSlabParams; corners: Vec3[] }
86
+ | { type: 'space'; params: AddElementSpaceParams; corners: Vec3[] }
87
+ | { type: 'roof'; params: AddElementRoofParams; corners: Vec3[] }
88
+ | { type: 'plate'; params: AddElementPlateParams; corners: Vec3[] };
89
+
90
+ /**
91
+ * Build a renderer-frame `MeshData` for a freshly-added element.
92
+ * Returns `null` when the payload is degenerate (zero-length wall etc.).
93
+ */
94
+ export function buildElementMesh(ctx: ElementBuildContext): MeshData | null {
95
+ const { type, globalId, storeyElevation, payload } = ctx;
96
+ switch (payload.type) {
97
+ case 'wall':
98
+ case 'beam':
99
+ case 'member': {
100
+ // Linear extrusion: a (length × thickness × height) box centred
101
+ // along the click→click axis, sitting on the storey floor.
102
+ const thickness = 'Thickness' in payload.params
103
+ ? payload.params.Thickness
104
+ : payload.params.Width;
105
+ const height = 'Height' in payload.params ? payload.params.Height : 0.1;
106
+ return buildLinearBox(globalId, type, payload.start, payload.end, thickness, height, storeyElevation);
107
+ }
108
+ case 'column': {
109
+ const { Width, Depth, Height } = payload.params;
110
+ return buildAxisBox(globalId, type, payload.position, Width, Depth, Height, storeyElevation);
111
+ }
112
+ case 'door': {
113
+ const { Width, Height, FrameThickness } = payload.params;
114
+ return buildAxisBox(globalId, type, payload.position, Width, FrameThickness, Height, storeyElevation);
115
+ }
116
+ case 'window': {
117
+ const { Width, Height, FrameThickness } = payload.params;
118
+ return buildAxisBox(globalId, type, payload.position, Width, FrameThickness, Height, storeyElevation);
119
+ }
120
+ case 'slab':
121
+ case 'roof':
122
+ case 'plate': {
123
+ const thickness = payload.params.Thickness;
124
+ return buildPolygonExtrusion(globalId, type, payload.corners, thickness, storeyElevation, /* extrudeUp */ true);
125
+ }
126
+ case 'space': {
127
+ const height = payload.params.Height;
128
+ return buildPolygonExtrusion(globalId, type, payload.corners, height, storeyElevation, /* extrudeUp */ true);
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Linear segment extruded into a thickness × height box (wall / beam /
135
+ * member shape). The bottom ring follows the segment's actual start/end
136
+ * Z so a sloped beam previews as a sloped prism instead of pinning to
137
+ * `startIfc[2]`. Walls reject sloped axes upstream
138
+ * (`addWallToStore` enforces planar XY); beams / members do not, which
139
+ * is why this routine has to honour both endpoints' Z.
140
+ */
141
+ function buildLinearBox(
142
+ globalId: number,
143
+ type: AddElementType,
144
+ startIfc: Vec3,
145
+ endIfc: Vec3,
146
+ thickness: number,
147
+ height: number,
148
+ storeyElevation: number,
149
+ ): MeshData | null {
150
+ const dx = endIfc[0] - startIfc[0];
151
+ const dy = endIfc[1] - startIfc[1];
152
+ // Cross-section plane is perpendicular to the ground-plane axis;
153
+ // even on sloped segments we keep the cross-section vertical so
154
+ // walls/beams/members read like building elements (extrusion is
155
+ // along +Z, not perpendicular to the segment direction).
156
+ const lenXY = Math.hypot(dx, dy);
157
+ if (lenXY < 1e-6) return null;
158
+ const ax = dx / lenXY;
159
+ const ay = dy / lenXY;
160
+ const nx = -ay;
161
+ const ny = ax;
162
+ const half = thickness / 2;
163
+ const startBaseZ = startIfc[2];
164
+ const endBaseZ = endIfc[2];
165
+ const startTopZ = startBaseZ + height;
166
+ const endTopZ = endBaseZ + height;
167
+ const ifcCorners: Vec3[] = [
168
+ [startIfc[0] + nx * half, startIfc[1] + ny * half, startBaseZ],
169
+ [endIfc[0] + nx * half, endIfc[1] + ny * half, endBaseZ],
170
+ [endIfc[0] - nx * half, endIfc[1] - ny * half, endBaseZ],
171
+ [startIfc[0] - nx * half, startIfc[1] - ny * half, startBaseZ],
172
+ [startIfc[0] + nx * half, startIfc[1] + ny * half, startTopZ],
173
+ [endIfc[0] + nx * half, endIfc[1] + ny * half, endTopZ],
174
+ [endIfc[0] - nx * half, endIfc[1] - ny * half, endTopZ],
175
+ [startIfc[0] - nx * half, startIfc[1] - ny * half, startTopZ],
176
+ ];
177
+ return buildBoxFromIfcCorners(globalId, type, ifcCorners, storeyElevation);
178
+ }
179
+
180
+ /** Axis-aligned box centred on a single point (column / door / window shape). */
181
+ function buildAxisBox(
182
+ globalId: number,
183
+ type: AddElementType,
184
+ centerIfc: Vec3,
185
+ sizeX: number,
186
+ sizeY: number,
187
+ sizeZ: number,
188
+ storeyElevation: number,
189
+ ): MeshData {
190
+ const hx = sizeX / 2;
191
+ const hy = sizeY / 2;
192
+ const baseZ = centerIfc[2];
193
+ const topZ = baseZ + sizeZ;
194
+ const cx = centerIfc[0];
195
+ const cy = centerIfc[1];
196
+ const ifcCorners: Vec3[] = [
197
+ [cx - hx, cy - hy, baseZ],
198
+ [cx + hx, cy - hy, baseZ],
199
+ [cx + hx, cy + hy, baseZ],
200
+ [cx - hx, cy + hy, baseZ],
201
+ [cx - hx, cy - hy, topZ],
202
+ [cx + hx, cy - hy, topZ],
203
+ [cx + hx, cy + hy, topZ],
204
+ [cx - hx, cy + hy, topZ],
205
+ ];
206
+ return buildBoxFromIfcCorners(globalId, type, ifcCorners, storeyElevation);
207
+ }
208
+
209
+ /** Polygon footprint extruded vertically (slab / space / roof / plate). */
210
+ function buildPolygonExtrusion(
211
+ globalId: number,
212
+ type: AddElementType,
213
+ ifcFootprint: Vec3[],
214
+ thickness: number,
215
+ storeyElevation: number,
216
+ extrudeUp: boolean,
217
+ ): MeshData | null {
218
+ const n = ifcFootprint.length;
219
+ if (n < 3 || thickness <= 0) return null;
220
+ const baseZ = ifcFootprint[0][2];
221
+ const topZ = baseZ + (extrudeUp ? thickness : -thickness);
222
+
223
+ // Vertex layout: bottom ring [0..n-1], top ring [n..2n-1]. Side
224
+ // quads built from consecutive ring pairs. Cap fans triangulate
225
+ // both rings around vertex 0 — fine for convex profiles, tolerable
226
+ // for slightly concave (the export still emits the proper polygon).
227
+ const vertCount = 2 * n;
228
+ const positions = new Float32Array(vertCount * 3);
229
+ const normals = new Float32Array(vertCount * 3);
230
+ for (let i = 0; i < n; i++) {
231
+ const [ix, iy] = ifcFootprint[i];
232
+ // Bottom ring (renderer-frame)
233
+ positions[i * 3 + 0] = ix;
234
+ positions[i * 3 + 1] = baseZ + storeyElevation;
235
+ positions[i * 3 + 2] = -iy;
236
+ normals[i * 3 + 0] = 0;
237
+ normals[i * 3 + 1] = -1;
238
+ normals[i * 3 + 2] = 0;
239
+ // Top ring
240
+ const j = (n + i) * 3;
241
+ positions[j + 0] = ix;
242
+ positions[j + 1] = topZ + storeyElevation;
243
+ positions[j + 2] = -iy;
244
+ normals[j + 0] = 0;
245
+ normals[j + 1] = 1;
246
+ normals[j + 2] = 0;
247
+ }
248
+
249
+ // Triangle counts: 2(n-2) for caps + 2n for sides = 4n - 4 triangles.
250
+ const triCount = 4 * n - 4;
251
+ const indices = new Uint32Array(triCount * 3);
252
+ let k = 0;
253
+
254
+ // Bottom cap — fan around vertex 0 (CW so it faces -Y / down).
255
+ for (let i = 1; i < n - 1; i++) {
256
+ indices[k++] = 0;
257
+ indices[k++] = i + 1;
258
+ indices[k++] = i;
259
+ }
260
+ // Top cap — fan around vertex n (CCW so it faces +Y / up).
261
+ for (let i = 1; i < n - 1; i++) {
262
+ indices[k++] = n;
263
+ indices[k++] = n + i;
264
+ indices[k++] = n + i + 1;
265
+ }
266
+ // Side quads — two triangles per edge.
267
+ for (let i = 0; i < n; i++) {
268
+ const i0 = i;
269
+ const i1 = (i + 1) % n;
270
+ const t0 = n + i0;
271
+ const t1 = n + i1;
272
+ indices[k++] = i0;
273
+ indices[k++] = i1;
274
+ indices[k++] = t1;
275
+ indices[k++] = i0;
276
+ indices[k++] = t1;
277
+ indices[k++] = t0;
278
+ }
279
+
280
+ const entityIds = new Uint32Array(vertCount);
281
+ entityIds.fill(globalId);
282
+
283
+ return {
284
+ expressId: globalId,
285
+ ifcType: IFC_TYPE[type],
286
+ positions,
287
+ normals,
288
+ indices,
289
+ color: COLORS[type],
290
+ entityIds,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Box mesh from 8 IFC-frame corners (bottom ring 0..3, top ring 4..7).
296
+ * Emits 12 triangles with face-aligned normals.
297
+ */
298
+ function buildBoxFromIfcCorners(
299
+ globalId: number,
300
+ type: AddElementType,
301
+ ifcCorners: Vec3[],
302
+ storeyElevation: number,
303
+ ): MeshData {
304
+ // Each face has 4 unique vertices (normal welded per face) → 24 verts.
305
+ // Faces: bottom, top, +U, +V, -U, -V (where U/V are the two sides).
306
+ const faces: Array<{ corners: number[]; normal: Vec3 }> = [
307
+ { corners: [0, 1, 2, 3], normal: [0, 0, -1] }, // bottom (IFC -Z)
308
+ { corners: [4, 7, 6, 5], normal: [0, 0, 1] }, // top (IFC +Z)
309
+ { corners: [0, 4, 5, 1], normal: faceNormal(ifcCorners, 0, 4, 1) },
310
+ { corners: [1, 5, 6, 2], normal: faceNormal(ifcCorners, 1, 5, 2) },
311
+ { corners: [2, 6, 7, 3], normal: faceNormal(ifcCorners, 2, 6, 3) },
312
+ { corners: [3, 7, 4, 0], normal: faceNormal(ifcCorners, 3, 7, 0) },
313
+ ];
314
+ const positions = new Float32Array(24 * 3);
315
+ const normals = new Float32Array(24 * 3);
316
+ const indices = new Uint32Array(36);
317
+ let v = 0;
318
+ let i = 0;
319
+ for (const face of faces) {
320
+ const baseV = v;
321
+ for (const ci of face.corners) {
322
+ const [ix, iy, iz] = ifcCorners[ci];
323
+ positions[v * 3 + 0] = ix;
324
+ positions[v * 3 + 1] = iz + storeyElevation;
325
+ positions[v * 3 + 2] = -iy;
326
+ // IFC-frame normal → renderer-frame.
327
+ normals[v * 3 + 0] = face.normal[0];
328
+ normals[v * 3 + 1] = face.normal[2];
329
+ normals[v * 3 + 2] = -face.normal[1];
330
+ v++;
331
+ }
332
+ // Two triangles per face — split as (0,1,2) + (0,2,3).
333
+ indices[i++] = baseV;
334
+ indices[i++] = baseV + 1;
335
+ indices[i++] = baseV + 2;
336
+ indices[i++] = baseV;
337
+ indices[i++] = baseV + 2;
338
+ indices[i++] = baseV + 3;
339
+ }
340
+ const entityIds = new Uint32Array(24);
341
+ entityIds.fill(globalId);
342
+ return {
343
+ expressId: globalId,
344
+ ifcType: IFC_TYPE[type],
345
+ positions,
346
+ normals,
347
+ indices,
348
+ color: COLORS[type],
349
+ entityIds,
350
+ };
351
+ }
352
+
353
+ function faceNormal(corners: Vec3[], a: number, b: number, c: number): Vec3 {
354
+ const ux = corners[b][0] - corners[a][0];
355
+ const uy = corners[b][1] - corners[a][1];
356
+ const uz = corners[b][2] - corners[a][2];
357
+ const vx = corners[c][0] - corners[a][0];
358
+ const vy = corners[c][1] - corners[a][1];
359
+ const vz = corners[c][2] - corners[a][2];
360
+ const nx = uy * vz - uz * vy;
361
+ const ny = uz * vx - ux * vz;
362
+ const nz = ux * vy - uy * vx;
363
+ const len = Math.hypot(nx, ny, nz) || 1;
364
+ return [nx / len, ny / len, nz / len];
365
+ }