@twinfinity/printing 6.0.1-ci.28952-beta → 6.0.2-beta

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 (82) hide show
  1. package/README.md +3 -3
  2. package/dist/BuildInfo.js +3 -3
  3. package/dist/BuildInfo.js.map +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/layout/analyzeTemplate.js.map +1 -1
  6. package/dist/layout/clipUtils.js.map +1 -1
  7. package/dist/layout/colorConstants.js.map +1 -1
  8. package/dist/layout/elementKinds.js.map +1 -1
  9. package/dist/layout/exportDrawing.js.map +1 -1
  10. package/dist/layout/filters.js.map +1 -1
  11. package/dist/layout/geometryConstants.js.map +1 -1
  12. package/dist/layout/index.js.map +1 -1
  13. package/dist/layout/itemBuilders.js.map +1 -1
  14. package/dist/layout/labelsRender.js.map +1 -1
  15. package/dist/layout/legendConstants.js.map +1 -1
  16. package/dist/layout/legendRender.js.map +1 -1
  17. package/dist/layout/legendRows.js.map +1 -1
  18. package/dist/layout/legendUtils.js.map +1 -1
  19. package/dist/layout/metadata.js.map +1 -1
  20. package/dist/layout/model.js.map +1 -1
  21. package/dist/layout/options.js.map +1 -1
  22. package/dist/layout/presets.js.map +1 -1
  23. package/dist/layout/qrRender.js.map +1 -1
  24. package/dist/layout/renderDrawing.js.map +1 -1
  25. package/dist/layout/renderUtils.js.map +1 -1
  26. package/dist/layout/sectionValidation.js.map +1 -1
  27. package/dist/layout/templateVariables.js.map +1 -1
  28. package/dist/layout/typeGuards.js.map +1 -1
  29. package/dist/layout/types.js.map +1 -1
  30. package/dist/layout/utils.js.map +1 -1
  31. package/dist/layout/validation.js.map +1 -1
  32. package/dist/layout/validationHelpers.js.map +1 -1
  33. package/dist/layout/validationUtils.js.map +1 -1
  34. package/dist/layout/viewportPrep.js.map +1 -1
  35. package/dist/layout/viewportRender.js.map +1 -1
  36. package/dist/layout/viewportUtils.js.map +1 -1
  37. package/dist/layout/warnings.js.map +1 -1
  38. package/dist/printToPdf.js.map +1 -1
  39. package/dist/printToSvg.js.map +1 -1
  40. package/dist/sections.js.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/package.json +2 -2
  43. package/src/BuildInfo.ts +41 -41
  44. package/src/index.ts +10 -10
  45. package/src/layout/analyzeTemplate.ts +218 -218
  46. package/src/layout/clipUtils.ts +193 -193
  47. package/src/layout/colorConstants.ts +16 -16
  48. package/src/layout/elementKinds.ts +35 -35
  49. package/src/layout/exportDrawing.ts +206 -206
  50. package/src/layout/filters.ts +80 -80
  51. package/src/layout/geometryConstants.ts +11 -11
  52. package/src/layout/index.ts +671 -671
  53. package/src/layout/itemBuilders.ts +159 -159
  54. package/src/layout/labelsRender.ts +170 -170
  55. package/src/layout/legendConstants.ts +11 -11
  56. package/src/layout/legendRender.ts +543 -543
  57. package/src/layout/legendRows.ts +62 -62
  58. package/src/layout/legendUtils.ts +144 -144
  59. package/src/layout/metadata.ts +210 -210
  60. package/src/layout/model.ts +372 -372
  61. package/src/layout/options.ts +17 -17
  62. package/src/layout/presets.ts +51 -51
  63. package/src/layout/qrRender.ts +126 -126
  64. package/src/layout/qrcode.d.ts +2 -2
  65. package/src/layout/renderDrawing.ts +710 -710
  66. package/src/layout/renderUtils.ts +138 -138
  67. package/src/layout/sectionValidation.ts +2 -2
  68. package/src/layout/templateVariables.ts +317 -317
  69. package/src/layout/typeGuards.ts +9 -9
  70. package/src/layout/types.ts +835 -835
  71. package/src/layout/utils.ts +85 -85
  72. package/src/layout/validation.ts +392 -392
  73. package/src/layout/validationHelpers.ts +59 -59
  74. package/src/layout/validationUtils.ts +6 -6
  75. package/src/layout/viewportPrep.ts +351 -351
  76. package/src/layout/viewportRender.ts +442 -442
  77. package/src/layout/viewportUtils.ts +50 -50
  78. package/src/layout/warnings.ts +3 -3
  79. package/src/printToPdf.ts +108 -108
  80. package/src/printToSvg.ts +188 -188
  81. package/src/sections.ts +1019 -1019
  82. package/src/types.ts +49 -49
package/src/sections.ts CHANGED
@@ -1,1019 +1,1019 @@
1
- import type {
2
- Vec3,
3
- SectionLabel,
4
- SectionPath,
5
- SectionRenderState,
6
- TransformInfo,
7
- ViewWindow
8
- } from '@twinfinity/geometry';
9
-
10
- export type { SectionLabel, SectionPath, SectionRenderState, TransformInfo, ViewWindow } from '@twinfinity/geometry';
11
-
12
- /** Minimal product interface for section fill matching. */
13
- interface SectionProduct {
14
- class?: { is(c: unknown): boolean; type?: string; id?: string };
15
- className?: string;
16
- propertySets?: Record<string, unknown>;
17
- properties?: { data?: Record<string, unknown>; sets?: Record<string, unknown> };
18
- }
19
-
20
- /** Local replacement for PlaneSectionEntry (not available in @twinfinity/geometry). */
21
- type PlaneSectionEntry<TProduct extends SectionProduct = SectionProduct> = {
22
- product: TProduct;
23
- segments: [Vec3, Vec3][];
24
- polylines: { points: Vec3[]; closed: boolean }[];
25
- };
26
-
27
- /**
28
- * Resolve a dotted property path (e.g. "BIP.spacetype") against a product.
29
- * Handles both the new T8 API (propertySets → .properties → .value) and
30
- * legacy BimIfcObject (properties.sets / properties.data).
31
- *
32
- * Returns the resolved value, or undefined if not found.
33
- */
34
- export function resolveProductProperty(product: SectionProduct | unknown, path: string): unknown {
35
- const p = product as SectionProduct;
36
- const parts = path.split('.');
37
- if (parts.length < 2) return undefined;
38
-
39
- const [setName, ...propParts] = parts;
40
- const propName = propParts.join('.');
41
-
42
- // New T8 API: propertySets['BIP'].properties['spacetype'].value
43
- const pset = (p?.propertySets as Record<string, any>)?.[setName];
44
- if (pset) {
45
- const prop = pset.properties?.[propName];
46
- if (prop !== undefined) {
47
- return prop?.value !== undefined ? prop.value : prop;
48
- }
49
- }
50
-
51
- // Legacy: properties.sets['BIP']['spacetype']
52
- const legacyPset = (p?.properties?.sets as Record<string, any>)?.[setName];
53
- if (legacyPset) {
54
- const val = legacyPset[propName];
55
- if (val !== undefined) {
56
- return val?.value !== undefined ? val.value : val;
57
- }
58
- }
59
-
60
- // Fallback: properties.data['BIP']['spacetype'] (old printing code path)
61
- let value: any = p?.properties?.data;
62
- if (value) {
63
- for (const part of parts) {
64
- value = value?.[part];
65
- if (value === undefined) break;
66
- }
67
- if (value !== undefined) {
68
- return value?.value !== undefined ? value.value : value;
69
- }
70
- }
71
-
72
- return undefined;
73
- }
74
-
75
- /**
76
- * Sample the actual rendered color of a product's first mesh.
77
- * Uses BimProductMesh.copyColorTo/getColorComponent for accurate colors
78
- * (includes overrides, highlights). Falls back to style.surfaceColor/color.
79
- */
80
- const sampleProductColorRgba = (product: unknown): { r: number; g: number; b: number; a: number } | undefined => {
81
- /** Duck-typed mesh with optional color-access methods. */
82
- interface DuckMesh {
83
- copyColorTo?: (out: Uint8Array, offset: number) => void;
84
- getColorComponent?: (index: number) => number;
85
- style?: { surfaceColor?: readonly number[]; color?: readonly number[] };
86
- }
87
- const p = product as Record<string, unknown>;
88
- const meshes = (p?.meshes ?? p?.productMeshes) as DuckMesh[] | undefined;
89
- if (!meshes || !meshes.length) return undefined;
90
-
91
- const rgba = new Uint8Array(4);
92
-
93
- for (const mesh of meshes) {
94
- try {
95
- // BimProductMesh: read actual rendered color via copyColorTo or getColorComponent
96
- if (typeof mesh.copyColorTo === 'function') {
97
- mesh.copyColorTo(rgba, 0);
98
- } else if (typeof mesh.getColorComponent === 'function') {
99
- rgba[0] = mesh.getColorComponent(0); // Red
100
- rgba[1] = mesh.getColorComponent(1); // Green
101
- rgba[2] = mesh.getColorComponent(2); // Blue
102
- rgba[3] = mesh.getColorComponent(3); // Alpha
103
- } else {
104
- // ModelMesh fallback: read from style
105
- const style = mesh.style;
106
- const c = style?.surfaceColor ?? style?.color;
107
- if (c) return { r: c[0], g: c[1], b: c[2], a: (c[3] ?? 255) / 255 };
108
- continue;
109
- }
110
- } catch {
111
- continue;
112
- }
113
-
114
- const [r, g, b, a] = rgba;
115
- if (a === 0 && r === 0 && g === 0 && b === 0) continue;
116
- return { r, g, b, a: a / 255 };
117
- }
118
-
119
- return undefined;
120
- };
121
-
122
- /** Base SVG display width in layout units. */
123
- export const BASE_WIDTH = 240;
124
- /** Minimum SVG display height in layout units. */
125
- export const MIN_HEIGHT = 140;
126
- /** Margin ratio applied around section bounds (fraction of world extent). */
127
- export const SECTION_MARGIN_RATIO = 0.05;
128
- /** Maximum zoom factor for viewport clamping. */
129
- export const MAX_ZOOM_FACTOR = 12;
130
- /** Multiplicative step for zooming in. */
131
- export const ZOOM_IN_STEP = 0.8;
132
- /** Multiplicative step for zooming out. */
133
- export const ZOOM_OUT_STEP = 1 / ZOOM_IN_STEP;
134
- const OUTLINE_THICKNESS_SCALE = 0.5;
135
-
136
- /** Clamp a number to a min/max range. */
137
- export function clampValue(value: number, min: number, max: number): number {
138
- return Math.min(max, Math.max(min, value));
139
- }
140
-
141
- export interface SectionBuildOptions {
142
- /** If true, attach source product metadata to paths/labels for downstream filtering */
143
- includeMeta?: boolean;
144
- /** When true, create fill paths for closed polygons. When false, create only stroke/outline paths. Defaults to true for backward compatibility. */
145
- createFills?: boolean;
146
- /** Plane normal vector for determining 2D projection axes. If omitted, defaults to horizontal plan (Y-up). */
147
- planeNormal?: Vec3;
148
- }
149
-
150
- /**
151
- * Extract product metadata for path/label attribution.
152
- * Casts to `any` once here to avoid scattering `as any` across the codebase.
153
- * BimIfcObject's runtime shape exposes these fields but the public typings
154
- * don't always surface them.
155
- */
156
- function extractProductMeta(product: unknown): {
157
- productId: string;
158
- ifcClass: string | null;
159
- ifcClassId: string | null;
160
- properties: unknown;
161
- } {
162
- const p = product as Record<string, any>;
163
- return {
164
- productId: p?.gid ?? p?.id ?? String(product),
165
- ifcClass: p?.class?.type ?? null,
166
- ifcClassId: p?.class?.id ?? null,
167
- properties: p?.properties?.data ?? p?.properties ?? null
168
- };
169
- }
170
-
171
- /** Default fill opacity when a fill rule omits `fillOpacity`. */
172
- export const DEFAULT_FILL_OPACITY = 0.35;
173
-
174
- /**
175
- * Fill rule controlling which products get visible fills and at what opacity.
176
- * Mirrors the template-level FillRule but kept minimal to avoid circular deps.
177
- */
178
- export interface SectionFillRule {
179
- /** IFC class filter. When omitted, matches all products. */
180
- class?: string | string[];
181
- /** Property-based filter (e.g. { "BIP.spacetype": "ROOM" }). */
182
- where?: Record<string, string | number | boolean | Array<string | number>>;
183
- /** Fill opacity (0–1). Default: {@link DEFAULT_FILL_OPACITY}. */
184
- fillOpacity?: number;
185
- /** When true, only apply fill to products whose centroid is inside the clip polygon. @default false */
186
- matchClipFilter?: boolean;
187
- }
188
-
189
- export interface BuildSectionStateOptions {
190
- viewport?: ViewWindow;
191
- strokeWidth?: number;
192
- labelFontSize?: number;
193
- /**
194
- * Fill rules controlling which products get visible fills and at what opacity.
195
- * Products matching a fill rule are rendered below non-matching products.
196
- * When absent or empty, all products render uniformly (no fill-based reordering).
197
- */
198
- fills?: SectionFillRule[];
199
- includeMeta?: boolean;
200
- /** Enable labels. Requires buildLabels callback. */
201
- includeLabels?: boolean;
202
- /** Callback to build labels for matching products. Required when includeLabels is true. */
203
- buildLabels?: (products: any[], fontSize: number) => SectionLabel[];
204
- /** Clip polygon in world coordinates (x, z). Used when fill.matchClipFilter is true for spatial filtering. */
205
- clipPolygon?: Array<Array<{ x: number; z: number }>>;
206
- /** When true, create fill paths for closed polygons. When false, create only stroke/outline paths. Defaults to true for backward compatibility. */
207
- createFills?: boolean;
208
- /** Plane normal vector for determining 2D projection axes. If omitted, defaults to horizontal plan (Y-up). */
209
- planeNormal?: Vec3;
210
- }
211
-
212
- /** Convert plane section polylines to SVG path data with fill colors and stroke styles. */
213
- export function createSectionSvg(
214
- perProduct: PlaneSectionEntry[],
215
- viewport?: ViewWindow,
216
- strokeWidthHint?: number,
217
- labels?: SectionLabel[],
218
- options?: SectionBuildOptions
219
- ): SectionRenderState | null {
220
- if (!perProduct.length) {
221
- return null;
222
- }
223
-
224
- const polylines = perProduct.flatMap((entry) => entry.polylines);
225
- const hasSegments = perProduct.some((entry) => entry.segments && entry.segments.length > 0);
226
-
227
- if (!polylines.length && !hasSegments) {
228
- return null;
229
- }
230
-
231
- const planeNormal = options?.planeNormal;
232
- const axes = getProjectionAxes(planeNormal);
233
-
234
- let viewMinX: number;
235
- let viewMaxX: number;
236
- let viewMinZ: number;
237
- let viewMaxZ: number;
238
-
239
- if (viewport) {
240
- viewMinX = viewport.viewMinX;
241
- viewMaxX = viewport.viewMaxX;
242
- viewMinZ = viewport.viewMinZ;
243
- viewMaxZ = viewport.viewMaxZ;
244
- } else {
245
- let minU = Number.POSITIVE_INFINITY;
246
- let maxU = Number.NEGATIVE_INFINITY;
247
- let minV = Number.POSITIVE_INFINITY;
248
- let maxV = Number.NEGATIVE_INFINITY;
249
-
250
- polylines.forEach((polyline: { points: Vec3[]; closed: boolean }) => {
251
- polyline.points.forEach((point: Vec3) => {
252
- if (!point) {
253
- return;
254
- }
255
- const u = axes.getU(point);
256
- const v = axes.getV(point);
257
- if (!Number.isFinite(u) || !Number.isFinite(v)) {
258
- return;
259
- }
260
- if (u < minU) minU = u;
261
- if (u > maxU) maxU = u;
262
- if (v < minV) minV = v;
263
- if (v > maxV) maxV = v;
264
- });
265
- });
266
-
267
- // Also include segments in bounding box calculation
268
- perProduct.forEach((entry) => {
269
- if (entry.segments && entry.segments.length) {
270
- entry.segments.forEach((segment) => {
271
- const [p1, p2] = segment;
272
- const u1 = axes.getU(p1);
273
- const v1 = axes.getV(p1);
274
- const u2 = axes.getU(p2);
275
- const v2 = axes.getV(p2);
276
- if (Number.isFinite(u1) && Number.isFinite(v1)) {
277
- if (u1 < minU) minU = u1;
278
- if (u1 > maxU) maxU = u1;
279
- if (v1 < minV) minV = v1;
280
- if (v1 > maxV) maxV = v1;
281
- }
282
- if (Number.isFinite(u2) && Number.isFinite(v2)) {
283
- if (u2 < minU) minU = u2;
284
- if (u2 > maxU) maxU = u2;
285
- if (v2 < minV) minV = v2;
286
- if (v2 > maxV) maxV = v2;
287
- }
288
- });
289
- }
290
- });
291
-
292
- if (!Number.isFinite(minU) || !Number.isFinite(maxU) || !Number.isFinite(minV) || !Number.isFinite(maxV)) {
293
- return null;
294
- }
295
-
296
- if (maxU - minU < 1e-3) {
297
- minU -= 0.5;
298
- maxU += 0.5;
299
- }
300
- if (maxV - minV < 1e-3) {
301
- minV -= 0.5;
302
- maxV += 0.5;
303
- }
304
-
305
- const worldWidth = maxU - minU;
306
- const worldHeight = maxV - minV;
307
- const marginU = Math.max(worldWidth * SECTION_MARGIN_RATIO, 0.5);
308
- const marginV = Math.max(worldHeight * SECTION_MARGIN_RATIO, 0.5);
309
-
310
- viewMinX = minU - marginU;
311
- viewMaxX = maxU + marginU;
312
- viewMinZ = minV - marginV;
313
- viewMaxZ = maxV + marginV;
314
- }
315
-
316
- const viewWidth = viewMaxX - viewMinX;
317
- const viewHeight = viewMaxZ - viewMinZ;
318
-
319
- if (!Number.isFinite(viewWidth) || !Number.isFinite(viewHeight) || viewWidth <= 1e-6 || viewHeight <= 1e-6) {
320
- return null;
321
- }
322
-
323
- const autoStrokeBase = Math.max(Math.min(viewWidth, viewHeight) * 0.008, 0.08);
324
- const autoStroke = Math.max(autoStrokeBase * OUTLINE_THICKNESS_SCALE, 0.02);
325
- const strokeWidth = Math.max(strokeWidthHint ?? autoStroke, 0.02);
326
- const geometryTransform = buildGeometryTransform(viewMinX, viewMaxX, viewMinZ, viewMaxZ);
327
-
328
- const includeMeta = !!options?.includeMeta;
329
- const createFills = options?.createFills !== false; // Default to true for backward compatibility
330
-
331
- const allPaths: SectionPath[] = [];
332
- if (includeMeta) {
333
- perProduct.forEach((entry, idx) => {
334
- // Skip entries with neither polylines nor segments
335
- if (!entry.polylines.length && (!entry.segments || !entry.segments.length)) {
336
- return;
337
- }
338
- const colorInfo = sampleProductColorRgba(entry.product);
339
- const fillColor = colorInfo ? `rgb(${colorInfo.r},${colorInfo.g},${colorInfo.b})` : '#ffffff';
340
- const fillOpacity = colorInfo ? clampValue(colorInfo.a, 0, 1) : 1;
341
- const meta = extractProductMeta(entry.product);
342
-
343
- const closedSubpaths: string[] = [];
344
- const holeSubpaths: string[] = [];
345
- const openSubpaths: string[] = [];
346
-
347
- entry.polylines.forEach((polyline) => {
348
- const pathData = getCachedPolylinePath(polyline, planeNormal);
349
- if (!pathData) {
350
- return;
351
- }
352
- if (pathData.closed) {
353
- if (pathData.hole) {
354
- holeSubpaths.push(pathData.d);
355
- } else {
356
- closedSubpaths.push(pathData.d);
357
- }
358
- } else {
359
- openSubpaths.push(pathData.d);
360
- }
361
- });
362
-
363
- // Only create fill paths if createFills is true
364
- if (createFills && (closedSubpaths.length || holeSubpaths.length)) {
365
- const fillSubpaths = closedSubpaths.length ? closedSubpaths.concat(holeSubpaths) : holeSubpaths;
366
- allPaths.push({
367
- key: `fill-${idx}`,
368
- d: fillSubpaths.join(' '),
369
- fill: fillColor,
370
- fillOpacity: clampValue(fillOpacity, 0, 1),
371
- stroke: '#1f2937',
372
- strokeWidth,
373
- fillRule: 'evenodd',
374
- meta: meta ? { ...meta, isFill: true } : undefined
375
- });
376
- }
377
-
378
- if (openSubpaths.length) {
379
- allPaths.push({
380
- key: `outline-${idx}`,
381
- d: openSubpaths.join(' '),
382
- fill: 'none',
383
- fillOpacity: 1,
384
- stroke: '#1f2937',
385
- strokeWidth,
386
- meta: meta ? { ...meta, isOutline: true } : undefined
387
- });
388
- }
389
-
390
- // Render segments (cut edges) as strokes
391
- if (entry.segments && entry.segments.length) {
392
- const segmentPaths: string[] = [];
393
- for (const segment of entry.segments) {
394
- const path = segmentToPath(segment, planeNormal);
395
- if (path) segmentPaths.push(path);
396
- }
397
- if (segmentPaths.length) {
398
- allPaths.push({
399
- key: `segments-${idx}`,
400
- d: segmentPaths.join(' '),
401
- fill: 'none',
402
- fillOpacity: 1,
403
- stroke: '#1f2937',
404
- strokeWidth,
405
- meta: meta ? { ...meta, isSegment: true } : undefined
406
- });
407
- }
408
- }
409
- });
410
- } else {
411
- const fillGroups = new Map<string, FillGroup>();
412
- const openSubpaths: string[] = [];
413
- const allSegmentPaths: string[] = [];
414
-
415
- perProduct.forEach((entry) => {
416
- // Process segments first (always render, even without polylines)
417
- if (entry.segments && entry.segments.length) {
418
- for (const segment of entry.segments) {
419
- const path = segmentToPath(segment, planeNormal);
420
- if (path) allSegmentPaths.push(path);
421
- }
422
- }
423
-
424
- if (!entry.polylines.length) {
425
- return;
426
- }
427
-
428
- const colorInfo = sampleProductColorRgba(entry.product);
429
- const fillColor = colorInfo ? `rgb(${colorInfo.r},${colorInfo.g},${colorInfo.b})` : '#ffffff';
430
- const fillOpacity = colorInfo ? clampValue(colorInfo.a, 0, 1) : 1;
431
- const groupKey = `${fillColor}|${Math.round(fillOpacity * 1000)}`;
432
- let group = fillGroups.get(groupKey);
433
- if (!group) {
434
- group = {
435
- color: fillColor,
436
- opacity: fillOpacity,
437
- closed: [],
438
- holes: []
439
- };
440
- fillGroups.set(groupKey, group);
441
- }
442
-
443
- entry.polylines.forEach((polyline) => {
444
- const pathData = getCachedPolylinePath(polyline, planeNormal);
445
- if (!pathData) {
446
- return;
447
- }
448
- if (pathData.closed) {
449
- if (pathData.hole) {
450
- group!.holes.push(pathData.d);
451
- } else {
452
- group!.closed.push(pathData.d);
453
- }
454
- return;
455
- }
456
- openSubpaths.push(pathData.d);
457
- });
458
- });
459
-
460
- let groupIndex = 0;
461
- fillGroups.forEach((group) => {
462
- const fillSubpaths = group.closed.length ? group.closed.concat(group.holes) : group.holes;
463
- if (!fillSubpaths.length) {
464
- return;
465
- }
466
- allPaths.push({
467
- key: `fill-${groupIndex++}`,
468
- d: fillSubpaths.join(' '),
469
- fill: group.color,
470
- fillOpacity: clampValue(group.opacity, 0, 1),
471
- stroke: '#1f2937',
472
- strokeWidth,
473
- fillRule: 'evenodd'
474
- });
475
- });
476
-
477
- if (openSubpaths.length) {
478
- allPaths.push({
479
- key: 'outlines',
480
- d: openSubpaths.join(' '),
481
- fill: 'none',
482
- fillOpacity: 1,
483
- stroke: '#1f2937',
484
- strokeWidth
485
- });
486
- }
487
-
488
- // Render all segments (collected above in forEach loop)
489
- if (allSegmentPaths.length) {
490
- allPaths.push({
491
- key: 'segments',
492
- d: allSegmentPaths.join(' '),
493
- fill: 'none',
494
- fillOpacity: 1,
495
- stroke: '#1f2937',
496
- strokeWidth
497
- });
498
- }
499
- }
500
-
501
- if (!allPaths.length) {
502
- return null;
503
- }
504
-
505
- const displayWidth = BASE_WIDTH;
506
- const displayHeight = Math.max(MIN_HEIGHT, (viewHeight / viewWidth) * BASE_WIDTH);
507
-
508
- return {
509
- paths: allPaths,
510
- transform: {
511
- viewMinX,
512
- viewMaxX,
513
- viewMinZ,
514
- viewMaxZ,
515
- viewWidth,
516
- viewHeight,
517
- displayWidth,
518
- displayHeight
519
- },
520
- strokeWidth,
521
- geometryTransform,
522
- labels: labels?.length
523
- ? labels
524
- .filter((label) => {
525
- if (!Number.isFinite(label.x) || !Number.isFinite(label.z)) {
526
- return false;
527
- }
528
- return label.x >= viewMinX && label.x <= viewMaxX && label.z >= viewMinZ && label.z <= viewMaxZ;
529
- })
530
- .map((label) => ({
531
- key: label.key,
532
- text: label.text,
533
- x: label.x,
534
- y: label.y,
535
- z: label.z,
536
- fontSize: label.fontSize,
537
- meta: includeMeta ? (label.meta ?? {}) : undefined
538
- }))
539
- : []
540
- };
541
- }
542
-
543
- /**
544
- * Build a complete SectionRenderState from plane section entries, with optional
545
- * fill-rule-based separation, ordering, and label generation.
546
- *
547
- * When `fills` rules are provided, entries matching a fill rule are rendered
548
- * below non-matching entries, with their fill opacity set by the matching rule.
549
- */
550
- export function buildSectionStateForEntries(
551
- perProduct: PlaneSectionEntry[],
552
- options: BuildSectionStateOptions
553
- ): (SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] }) | null {
554
- if (!perProduct.length) {
555
- return null;
556
- }
557
-
558
- const fills = options.fills;
559
- const includeMeta = !!options?.includeMeta;
560
- const includeLabels = options?.includeLabels ?? false;
561
- const viewport = options?.viewport;
562
- const strokeWidth = options?.strokeWidth;
563
- const labelFontSize = includeLabels && typeof options?.labelFontSize === 'number' ? options.labelFontSize : null;
564
- const buildLabels = options?.buildLabels;
565
-
566
- // Point-in-polygon test (ray casting algorithm)
567
- const pointInPolygon = (polygon: Array<{ x: number; z: number }>, x: number, z: number): boolean => {
568
- let inside = false;
569
- for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
570
- const xi = polygon[i].x;
571
- const zi = polygon[i].z;
572
- const xj = polygon[j].x;
573
- const zj = polygon[j].z;
574
- const intersect = zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi;
575
- if (intersect) inside = !inside;
576
- }
577
- return inside;
578
- };
579
-
580
- // Calculate centroid from entry's polylines
581
- const getCentroid = (entry: PlaneSectionEntry): { x: number; z: number } | null => {
582
- const polylines = entry.polylines;
583
- if (!polylines.length) return null;
584
- let sumX = 0;
585
- let sumZ = 0;
586
- let count = 0;
587
- for (const polyline of polylines) {
588
- for (const point of polyline.points) {
589
- sumX += point[0];
590
- sumZ += point[2];
591
- count++;
592
- }
593
- }
594
- if (count === 0) return null;
595
- return { x: sumX / count, z: sumZ / count };
596
- };
597
-
598
- // Spatial matcher for clip filter - checks if product centroid is inside clip polygon
599
- const isInsideClipPolygon = (entry: PlaneSectionEntry): boolean => {
600
- if (!options?.clipPolygon?.length) return true;
601
- const centroid = getCentroid(entry);
602
- if (!centroid) return false;
603
- // Check if centroid is inside any of the clip polygons
604
- return options.clipPolygon.some((polygon) => pointInPolygon(polygon, centroid.x, centroid.z));
605
- };
606
-
607
- // Match an entry against fill rules. Returns the first matching rule, or undefined.
608
- const matchFillRule = (entry: PlaneSectionEntry): SectionFillRule | undefined => {
609
- if (!fills?.length) return undefined;
610
- const product = entry.product;
611
- for (const rule of fills) {
612
- // If rule requires clip filter match, check if product centroid is inside clip polygon
613
- if (rule.matchClipFilter && !isInsideClipPolygon(entry)) {
614
- continue;
615
- }
616
-
617
- // Check class match
618
- let classMatches: boolean;
619
- if (rule.class) {
620
- const classes = Array.isArray(rule.class) ? rule.class : [rule.class];
621
- const cls = product.class;
622
- classMatches = classes.some((c) => {
623
- if (cls?.is) return cls.is(c as string);
624
- const pName = (product.className ?? '').toLowerCase();
625
- return pName === (c as string).toLowerCase() || pName === `ifc${(c as string).toLowerCase()}`;
626
- });
627
- if (!classMatches) continue;
628
- }
629
-
630
- // Check where condition (property-based filter)
631
- if (rule.where && Object.keys(rule.where).length > 0) {
632
- let whereMatches = true;
633
- for (const [path, expected] of Object.entries(rule.where)) {
634
- const actualValue = resolveProductProperty(product, path);
635
- if (actualValue === undefined) {
636
- whereMatches = false;
637
- break;
638
- }
639
-
640
- const normalize = (v: unknown): unknown => (typeof v === 'string' ? v.toLowerCase().trim() : v);
641
-
642
- if (Array.isArray(expected)) {
643
- if (!expected.some((exp) => normalize(exp) === normalize(actualValue))) {
644
- whereMatches = false;
645
- break;
646
- }
647
- } else {
648
- if (normalize(actualValue) !== normalize(expected)) {
649
- whereMatches = false;
650
- break;
651
- }
652
- }
653
- }
654
- if (!whereMatches) continue;
655
- }
656
-
657
- return rule;
658
- }
659
- return undefined;
660
- };
661
-
662
- const hasFills = !!fills?.length;
663
-
664
- // Split entries by fill rule match
665
- const fillEntries: PlaneSectionEntry[] = [];
666
- const fillOpacities: number[] = []; // parallel to fillEntries
667
- const nonFillEntries: PlaneSectionEntry[] = [];
668
-
669
- for (const entry of perProduct) {
670
- const rule = hasFills ? matchFillRule(entry) : undefined;
671
- if (rule) {
672
- const opacity = rule.fillOpacity ?? DEFAULT_FILL_OPACITY;
673
- // Skip entries with zero opacity - don't render them at all
674
- if (opacity > 0) {
675
- fillEntries.push(entry);
676
- fillOpacities.push(opacity);
677
- }
678
- } else {
679
- nonFillEntries.push(entry);
680
- }
681
- }
682
-
683
- // Build labels from fill-matched entries (e.g. spaces)
684
- const fillLabels =
685
- includeLabels && fillEntries.length && labelFontSize !== null && buildLabels
686
- ? (buildLabels(
687
- fillEntries.map((entry) => entry.product),
688
- labelFontSize
689
- ) as SectionLabel[])
690
- : undefined;
691
-
692
- // When fill rules are present, render fill and non-fill groups separately
693
- // so we can adjust opacity and control ordering.
694
- // When no fill rules, render everything in one pass (unchanged behavior).
695
- if (hasFills && (fillEntries.length > 0 || nonFillEntries.length > 0)) {
696
- const nonFillState =
697
- nonFillEntries.length > 0
698
- ? createSectionSvg(nonFillEntries, viewport, strokeWidth, undefined, {
699
- includeMeta,
700
- planeNormal: options.planeNormal
701
- })
702
- : null;
703
- const fillState =
704
- fillEntries.length > 0
705
- ? createSectionSvg(fillEntries, viewport, strokeWidth, includeLabels ? fillLabels : undefined, {
706
- includeMeta,
707
- planeNormal: options.planeNormal
708
- })
709
- : null;
710
-
711
- const baseState = nonFillState ?? fillState ?? null;
712
- if (!baseState) return null;
713
-
714
- // Adjust fill opacity per-entry. When includeMeta is true, each entry has
715
- // its own path, so we can apply per-entry opacity. When false, paths are
716
- // grouped by color — apply the minimum matching opacity.
717
- const adjustedFillPaths =
718
- fillState?.paths.map((path, idx) => {
719
- // When fill rules match, use the rule opacity directly (don't multiply by material alpha).
720
- // Material alpha was useful for auto-coloring, but fill rules should have full control.
721
- const ruleOpacity = idx < fillOpacities.length ? fillOpacities[idx] : (fills![0]?.fillOpacity ?? 0.35);
722
- const finalOpacity = clampValue(ruleOpacity, 0, 1);
723
- return {
724
- ...path,
725
- fillOpacity: finalOpacity
726
- };
727
- }) ?? [];
728
-
729
- const nonFillPaths = nonFillState?.paths ?? [];
730
- // Fill-matched entries always render below non-fill entries
731
- const mergedPaths = [...adjustedFillPaths, ...nonFillPaths];
732
-
733
- const merged: SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] } = {
734
- ...baseState,
735
- paths: mergedPaths,
736
- labels: includeLabels ? (fillState?.labels ?? fillLabels ?? []) : []
737
- };
738
-
739
- return merged;
740
- }
741
-
742
- // No fill rules — create paths with fills for highlight support, but set opacity to 0
743
- const state = createSectionSvg(perProduct, viewport, strokeWidth, includeLabels ? fillLabels : undefined, {
744
- includeMeta,
745
- createFills: true,
746
- planeNormal: options.planeNormal
747
- });
748
- if (!state) return null;
749
-
750
- // Set all fill opacities to 0 when no fill rules exist (but keep paths for highlight support)
751
- const pathsWithZeroFillOpacity = state.paths.map((path) => ({
752
- ...path,
753
- fillOpacity: 0
754
- }));
755
-
756
- return {
757
- ...state,
758
- paths: pathsWithZeroFillOpacity,
759
- labels: includeLabels ? (state.labels ?? []) : []
760
- };
761
- }
762
-
763
- /** Extract the viewport bounding box from a TransformInfo. */
764
- export function extractViewWindow(transform: TransformInfo): ViewWindow {
765
- return {
766
- viewMinX: transform.viewMinX,
767
- viewMaxX: transform.viewMaxX,
768
- viewMinZ: transform.viewMinZ,
769
- viewMaxZ: transform.viewMaxZ
770
- };
771
- }
772
-
773
- /** Compare two viewport windows for equality within a tolerance. */
774
- export function windowsEqual(a: ViewWindow, b: ViewWindow, epsilon = 1e-4): boolean {
775
- return (
776
- Math.abs(a.viewMinX - b.viewMinX) <= epsilon &&
777
- Math.abs(a.viewMaxX - b.viewMaxX) <= epsilon &&
778
- Math.abs(a.viewMinZ - b.viewMinZ) <= epsilon &&
779
- Math.abs(a.viewMaxZ - b.viewMaxZ) <= epsilon
780
- );
781
- }
782
-
783
- /** Constrain a viewport window within base bounds, preserving aspect ratio. */
784
- export function clampViewWindow(base: TransformInfo, candidate: ViewWindow): ViewWindow {
785
- const baseWidth = base.viewWidth;
786
- const baseHeight = base.viewHeight;
787
- const aspect = baseWidth / baseHeight;
788
-
789
- let width = candidate.viewMaxX - candidate.viewMinX;
790
-
791
- if (!Number.isFinite(width) || width <= 1e-6) {
792
- width = baseWidth;
793
- }
794
- width = clampValue(width, baseWidth / MAX_ZOOM_FACTOR, baseWidth);
795
- const height = width / aspect;
796
-
797
- let centerX = (candidate.viewMinX + candidate.viewMaxX) * 0.5;
798
- if (!Number.isFinite(centerX)) {
799
- centerX = (base.viewMinX + base.viewMaxX) * 0.5;
800
- }
801
-
802
- let centerZ = (candidate.viewMinZ + candidate.viewMaxZ) * 0.5;
803
- if (!Number.isFinite(centerZ)) {
804
- centerZ = (base.viewMinZ + base.viewMaxZ) * 0.5;
805
- }
806
-
807
- let minX = centerX - width * 0.5;
808
- let maxX = minX + width;
809
- if (minX < base.viewMinX) {
810
- minX = base.viewMinX;
811
- maxX = minX + width;
812
- }
813
- if (maxX > base.viewMaxX) {
814
- maxX = base.viewMaxX;
815
- minX = maxX - width;
816
- }
817
-
818
- let minZ = centerZ - height * 0.5;
819
- let maxZ = minZ + height;
820
- if (minZ < base.viewMinZ) {
821
- minZ = base.viewMinZ;
822
- maxZ = minZ + height;
823
- }
824
- if (maxZ > base.viewMaxZ) {
825
- maxZ = base.viewMaxZ;
826
- minZ = maxZ - height;
827
- }
828
-
829
- return {
830
- viewMinX: minX,
831
- viewMaxX: maxX,
832
- viewMinZ: minZ,
833
- viewMaxZ: maxZ
834
- };
835
- }
836
-
837
- /** Adjust stroke width proportionally to zoom level. */
838
- export function strokeWidthForWindow(baseStroke: number, baseTransform: TransformInfo, window: ViewWindow): number {
839
- const baseWidth = baseTransform.viewWidth;
840
- const currentWidth = window.viewMaxX - window.viewMinX;
841
- if (!Number.isFinite(baseWidth) || baseWidth <= 1e-6 || !Number.isFinite(currentWidth) || currentWidth <= 1e-6) {
842
- return baseStroke;
843
- }
844
- const ratio = clampValue(currentWidth / baseWidth, 1 / MAX_ZOOM_FACTOR, 1);
845
- const scaled = baseStroke * ratio;
846
- return Math.max(scaled, baseStroke / (MAX_ZOOM_FACTOR + 1));
847
- }
848
-
849
- interface SvgPathData {
850
- d: string;
851
- closed: boolean;
852
- hole: boolean;
853
- }
854
-
855
- interface FillGroup {
856
- color: string;
857
- opacity: number;
858
- closed: string[];
859
- holes: string[];
860
- }
861
-
862
- interface CachedPolylinePath {
863
- pointsRef: Vec3[];
864
- signature: number;
865
- data: SvgPathData;
866
- }
867
-
868
- const polylinePathCache = new WeakMap<{ points: Vec3[]; closed: boolean }, CachedPolylinePath>();
869
-
870
- /**
871
- * Determine which axes to use for 2D projection based on plane normal.
872
- * Returns accessor functions for horizontal and vertical SVG coordinates.
873
- */
874
- function getProjectionAxes(planeNormal?: Vec3): {
875
- getU: (p: Vec3) => number;
876
- getV: (p: Vec3) => number;
877
- axisNames: string;
878
- } {
879
- if (!planeNormal) {
880
- // Default: horizontal plan (looking down Y axis) -> X,Z
881
- return {
882
- getU: (p) => p[0],
883
- getV: (p) => p[2],
884
- axisNames: 'XZ'
885
- };
886
- }
887
-
888
- // Normalize and find dominant axis
889
- const absX = Math.abs(planeNormal[0]);
890
- const absY = Math.abs(planeNormal[1]);
891
- const absZ = Math.abs(planeNormal[2]);
892
-
893
- if (absY > absX && absY > absZ) {
894
- // Horizontal plan (normal ~ Y axis) -> looking down Y, use X,Z
895
- return {
896
- getU: (p) => p[0],
897
- getV: (p) => p[2],
898
- axisNames: 'XZ'
899
- };
900
- } else if (absZ > absX) {
901
- // Vertical section perpendicular to Z -> looking along Z, use X,Y
902
- return {
903
- getU: (p) => p[0],
904
- getV: (p) => p[1],
905
- axisNames: 'XY'
906
- };
907
- } else {
908
- // Vertical section perpendicular to X -> looking along X, use Z,Y
909
- return {
910
- getU: (p) => p[2],
911
- getV: (p) => p[1],
912
- axisNames: 'ZY'
913
- };
914
- }
915
- }
916
-
917
- /**
918
- * Convert a segment (pair of points) to an SVG path string.
919
- */
920
- function segmentToPath(segment: [Vec3, Vec3], planeNormal?: Vec3): string {
921
- const [p1, p2] = segment;
922
- const axes = getProjectionAxes(planeNormal);
923
- const u1 = axes.getU(p1);
924
- const v1 = axes.getV(p1);
925
- const u2 = axes.getU(p2);
926
- const v2 = axes.getV(p2);
927
-
928
- if (!Number.isFinite(u1) || !Number.isFinite(v1) || !Number.isFinite(u2) || !Number.isFinite(v2)) {
929
- return '';
930
- }
931
- return `M${formatNumber(u1)} ${formatNumber(v1)} L${formatNumber(u2)} ${formatNumber(v2)}`;
932
- }
933
-
934
- function getCachedPolylinePath(polyline: { points: Vec3[]; closed: boolean }, planeNormal?: Vec3): SvgPathData | null {
935
- if (!polyline.points.length) {
936
- return null;
937
- }
938
-
939
- const signature = computePointsSignature(polyline.points, polyline.closed, planeNormal);
940
- const cached = polylinePathCache.get(polyline);
941
- if (cached && cached.pointsRef === polyline.points && cached.signature === signature) {
942
- return cached.data;
943
- }
944
-
945
- const axes = getProjectionAxes(planeNormal);
946
- const commands: string[] = [];
947
- polyline.points.forEach((point: Vec3, index: number) => {
948
- const prefix = index === 0 ? 'M' : 'L';
949
- const u = axes.getU(point);
950
- const v = axes.getV(point);
951
- commands.push(`${prefix}${formatNumber(u)} ${formatNumber(v)}`);
952
- });
953
-
954
- if (commands.length < 2) {
955
- return null;
956
- }
957
-
958
- let closed = false;
959
- let hole = false;
960
-
961
- if (polyline.closed && commands.length > 2) {
962
- const area = signedArea(polyline.points, axes);
963
- hole = area < 0;
964
- closed = true;
965
- commands.push('Z');
966
- }
967
-
968
- const data: SvgPathData = { d: commands.join(' '), closed, hole };
969
- polylinePathCache.set(polyline, { pointsRef: polyline.points, signature, data });
970
- return data;
971
- }
972
-
973
- function signedArea(points: Vec3[], axes: { getU: (p: Vec3) => number; getV: (p: Vec3) => number }): number {
974
- if (points.length < 3) {
975
- return 0;
976
- }
977
-
978
- let area = 0;
979
- for (let i = 0; i < points.length; i++) {
980
- const current = points[i];
981
- const next = points[(i + 1) % points.length];
982
- const u1 = axes.getU(current);
983
- const v1 = axes.getV(current);
984
- const u2 = axes.getU(next);
985
- const v2 = axes.getV(next);
986
- area += u1 * v2 - u2 * v1;
987
- }
988
- return area * 0.5;
989
- }
990
-
991
- function formatNumber(value: number): string {
992
- if (!Number.isFinite(value)) {
993
- return '0';
994
- }
995
- const formatted = value.toFixed(3);
996
- return formatted.replace(/\.?0+$/, '');
997
- }
998
-
999
- function buildGeometryTransform(viewMinX: number, viewMaxX: number, viewMinZ: number, viewMaxZ: number): string {
1000
- const translateX = formatNumber(-viewMinX);
1001
- const translateY = formatNumber(viewMaxZ);
1002
- return `matrix(1 0 0 -1 ${translateX} ${translateY})`;
1003
- }
1004
-
1005
- function computePointsSignature(points: readonly Vec3[], closed: boolean, planeNormal?: Vec3): number {
1006
- const axes = getProjectionAxes(planeNormal);
1007
- // FNV-1a-inspired hash — compact fixed-size key instead of concatenating all coordinates
1008
- let hash = 2166136261 ^ (points.length * 16777619) ^ (closed ? 1 : 0);
1009
- for (let i = 0; i < points.length; i++) {
1010
- const point = points[i];
1011
- if (!point) continue;
1012
- const u = axes.getU(point);
1013
- const v = axes.getV(point);
1014
- // Mix in coordinate bits (multiply by large prime, XOR)
1015
- hash = Math.imul(hash ^ ((u * 1e4) | 0), 16777619);
1016
- hash = Math.imul(hash ^ ((v * 1e4) | 0), 16777619);
1017
- }
1018
- return hash;
1019
- }
1
+ import type {
2
+ Vec3,
3
+ SectionLabel,
4
+ SectionPath,
5
+ SectionRenderState,
6
+ TransformInfo,
7
+ ViewWindow
8
+ } from '@twinfinity/geometry';
9
+
10
+ export type { SectionLabel, SectionPath, SectionRenderState, TransformInfo, ViewWindow } from '@twinfinity/geometry';
11
+
12
+ /** Minimal product interface for section fill matching. */
13
+ interface SectionProduct {
14
+ class?: { is(c: unknown): boolean; type?: string; id?: string };
15
+ className?: string;
16
+ propertySets?: Record<string, unknown>;
17
+ properties?: { data?: Record<string, unknown>; sets?: Record<string, unknown> };
18
+ }
19
+
20
+ /** Local replacement for PlaneSectionEntry (not available in @twinfinity/geometry). */
21
+ type PlaneSectionEntry<TProduct extends SectionProduct = SectionProduct> = {
22
+ product: TProduct;
23
+ segments: [Vec3, Vec3][];
24
+ polylines: { points: Vec3[]; closed: boolean }[];
25
+ };
26
+
27
+ /**
28
+ * Resolve a dotted property path (e.g. "BIP.spacetype") against a product.
29
+ * Handles both the new T8 API (propertySets → .properties → .value) and
30
+ * legacy BimIfcObject (properties.sets / properties.data).
31
+ *
32
+ * Returns the resolved value, or undefined if not found.
33
+ */
34
+ export function resolveProductProperty(product: SectionProduct | unknown, path: string): unknown {
35
+ const p = product as SectionProduct;
36
+ const parts = path.split('.');
37
+ if (parts.length < 2) return undefined;
38
+
39
+ const [setName, ...propParts] = parts;
40
+ const propName = propParts.join('.');
41
+
42
+ // New T8 API: propertySets['BIP'].properties['spacetype'].value
43
+ const pset = (p?.propertySets as Record<string, any>)?.[setName];
44
+ if (pset) {
45
+ const prop = pset.properties?.[propName];
46
+ if (prop !== undefined) {
47
+ return prop?.value !== undefined ? prop.value : prop;
48
+ }
49
+ }
50
+
51
+ // Legacy: properties.sets['BIP']['spacetype']
52
+ const legacyPset = (p?.properties?.sets as Record<string, any>)?.[setName];
53
+ if (legacyPset) {
54
+ const val = legacyPset[propName];
55
+ if (val !== undefined) {
56
+ return val?.value !== undefined ? val.value : val;
57
+ }
58
+ }
59
+
60
+ // Fallback: properties.data['BIP']['spacetype'] (old printing code path)
61
+ let value: any = p?.properties?.data;
62
+ if (value) {
63
+ for (const part of parts) {
64
+ value = value?.[part];
65
+ if (value === undefined) break;
66
+ }
67
+ if (value !== undefined) {
68
+ return value?.value !== undefined ? value.value : value;
69
+ }
70
+ }
71
+
72
+ return undefined;
73
+ }
74
+
75
+ /**
76
+ * Sample the actual rendered color of a product's first mesh.
77
+ * Uses BimProductMesh.copyColorTo/getColorComponent for accurate colors
78
+ * (includes overrides, highlights). Falls back to style.surfaceColor/color.
79
+ */
80
+ const sampleProductColorRgba = (product: unknown): { r: number; g: number; b: number; a: number } | undefined => {
81
+ /** Duck-typed mesh with optional color-access methods. */
82
+ interface DuckMesh {
83
+ copyColorTo?: (out: Uint8Array, offset: number) => void;
84
+ getColorComponent?: (index: number) => number;
85
+ style?: { surfaceColor?: readonly number[]; color?: readonly number[] };
86
+ }
87
+ const p = product as Record<string, unknown>;
88
+ const meshes = (p?.meshes ?? p?.productMeshes) as DuckMesh[] | undefined;
89
+ if (!meshes || !meshes.length) return undefined;
90
+
91
+ const rgba = new Uint8Array(4);
92
+
93
+ for (const mesh of meshes) {
94
+ try {
95
+ // BimProductMesh: read actual rendered color via copyColorTo or getColorComponent
96
+ if (typeof mesh.copyColorTo === 'function') {
97
+ mesh.copyColorTo(rgba, 0);
98
+ } else if (typeof mesh.getColorComponent === 'function') {
99
+ rgba[0] = mesh.getColorComponent(0); // Red
100
+ rgba[1] = mesh.getColorComponent(1); // Green
101
+ rgba[2] = mesh.getColorComponent(2); // Blue
102
+ rgba[3] = mesh.getColorComponent(3); // Alpha
103
+ } else {
104
+ // ModelMesh fallback: read from style
105
+ const style = mesh.style;
106
+ const c = style?.surfaceColor ?? style?.color;
107
+ if (c) return { r: c[0], g: c[1], b: c[2], a: (c[3] ?? 255) / 255 };
108
+ continue;
109
+ }
110
+ } catch {
111
+ continue;
112
+ }
113
+
114
+ const [r, g, b, a] = rgba;
115
+ if (a === 0 && r === 0 && g === 0 && b === 0) continue;
116
+ return { r, g, b, a: a / 255 };
117
+ }
118
+
119
+ return undefined;
120
+ };
121
+
122
+ /** Base SVG display width in layout units. */
123
+ export const BASE_WIDTH = 240;
124
+ /** Minimum SVG display height in layout units. */
125
+ export const MIN_HEIGHT = 140;
126
+ /** Margin ratio applied around section bounds (fraction of world extent). */
127
+ export const SECTION_MARGIN_RATIO = 0.05;
128
+ /** Maximum zoom factor for viewport clamping. */
129
+ export const MAX_ZOOM_FACTOR = 12;
130
+ /** Multiplicative step for zooming in. */
131
+ export const ZOOM_IN_STEP = 0.8;
132
+ /** Multiplicative step for zooming out. */
133
+ export const ZOOM_OUT_STEP = 1 / ZOOM_IN_STEP;
134
+ const OUTLINE_THICKNESS_SCALE = 0.5;
135
+
136
+ /** Clamp a number to a min/max range. */
137
+ export function clampValue(value: number, min: number, max: number): number {
138
+ return Math.min(max, Math.max(min, value));
139
+ }
140
+
141
+ export interface SectionBuildOptions {
142
+ /** If true, attach source product metadata to paths/labels for downstream filtering */
143
+ includeMeta?: boolean;
144
+ /** When true, create fill paths for closed polygons. When false, create only stroke/outline paths. Defaults to true for backward compatibility. */
145
+ createFills?: boolean;
146
+ /** Plane normal vector for determining 2D projection axes. If omitted, defaults to horizontal plan (Y-up). */
147
+ planeNormal?: Vec3;
148
+ }
149
+
150
+ /**
151
+ * Extract product metadata for path/label attribution.
152
+ * Casts to `any` once here to avoid scattering `as any` across the codebase.
153
+ * BimIfcObject's runtime shape exposes these fields but the public typings
154
+ * don't always surface them.
155
+ */
156
+ function extractProductMeta(product: unknown): {
157
+ productId: string;
158
+ ifcClass: string | null;
159
+ ifcClassId: string | null;
160
+ properties: unknown;
161
+ } {
162
+ const p = product as Record<string, any>;
163
+ return {
164
+ productId: p?.gid ?? p?.id ?? String(product),
165
+ ifcClass: p?.class?.type ?? null,
166
+ ifcClassId: p?.class?.id ?? null,
167
+ properties: p?.properties?.data ?? p?.properties ?? null
168
+ };
169
+ }
170
+
171
+ /** Default fill opacity when a fill rule omits `fillOpacity`. */
172
+ export const DEFAULT_FILL_OPACITY = 0.35;
173
+
174
+ /**
175
+ * Fill rule controlling which products get visible fills and at what opacity.
176
+ * Mirrors the template-level FillRule but kept minimal to avoid circular deps.
177
+ */
178
+ export interface SectionFillRule {
179
+ /** IFC class filter. When omitted, matches all products. */
180
+ class?: string | string[];
181
+ /** Property-based filter (e.g. { "BIP.spacetype": "ROOM" }). */
182
+ where?: Record<string, string | number | boolean | Array<string | number>>;
183
+ /** Fill opacity (0–1). Default: {@link DEFAULT_FILL_OPACITY}. */
184
+ fillOpacity?: number;
185
+ /** When true, only apply fill to products whose centroid is inside the clip polygon. @default false */
186
+ matchClipFilter?: boolean;
187
+ }
188
+
189
+ export interface BuildSectionStateOptions {
190
+ viewport?: ViewWindow;
191
+ strokeWidth?: number;
192
+ labelFontSize?: number;
193
+ /**
194
+ * Fill rules controlling which products get visible fills and at what opacity.
195
+ * Products matching a fill rule are rendered below non-matching products.
196
+ * When absent or empty, all products render uniformly (no fill-based reordering).
197
+ */
198
+ fills?: SectionFillRule[];
199
+ includeMeta?: boolean;
200
+ /** Enable labels. Requires buildLabels callback. */
201
+ includeLabels?: boolean;
202
+ /** Callback to build labels for matching products. Required when includeLabels is true. */
203
+ buildLabels?: (products: any[], fontSize: number) => SectionLabel[];
204
+ /** Clip polygon in world coordinates (x, z). Used when fill.matchClipFilter is true for spatial filtering. */
205
+ clipPolygon?: Array<Array<{ x: number; z: number }>>;
206
+ /** When true, create fill paths for closed polygons. When false, create only stroke/outline paths. Defaults to true for backward compatibility. */
207
+ createFills?: boolean;
208
+ /** Plane normal vector for determining 2D projection axes. If omitted, defaults to horizontal plan (Y-up). */
209
+ planeNormal?: Vec3;
210
+ }
211
+
212
+ /** Convert plane section polylines to SVG path data with fill colors and stroke styles. */
213
+ export function createSectionSvg(
214
+ perProduct: PlaneSectionEntry[],
215
+ viewport?: ViewWindow,
216
+ strokeWidthHint?: number,
217
+ labels?: SectionLabel[],
218
+ options?: SectionBuildOptions
219
+ ): SectionRenderState | null {
220
+ if (!perProduct.length) {
221
+ return null;
222
+ }
223
+
224
+ const polylines = perProduct.flatMap((entry) => entry.polylines);
225
+ const hasSegments = perProduct.some((entry) => entry.segments && entry.segments.length > 0);
226
+
227
+ if (!polylines.length && !hasSegments) {
228
+ return null;
229
+ }
230
+
231
+ const planeNormal = options?.planeNormal;
232
+ const axes = getProjectionAxes(planeNormal);
233
+
234
+ let viewMinX: number;
235
+ let viewMaxX: number;
236
+ let viewMinZ: number;
237
+ let viewMaxZ: number;
238
+
239
+ if (viewport) {
240
+ viewMinX = viewport.viewMinX;
241
+ viewMaxX = viewport.viewMaxX;
242
+ viewMinZ = viewport.viewMinZ;
243
+ viewMaxZ = viewport.viewMaxZ;
244
+ } else {
245
+ let minU = Number.POSITIVE_INFINITY;
246
+ let maxU = Number.NEGATIVE_INFINITY;
247
+ let minV = Number.POSITIVE_INFINITY;
248
+ let maxV = Number.NEGATIVE_INFINITY;
249
+
250
+ polylines.forEach((polyline: { points: Vec3[]; closed: boolean }) => {
251
+ polyline.points.forEach((point: Vec3) => {
252
+ if (!point) {
253
+ return;
254
+ }
255
+ const u = axes.getU(point);
256
+ const v = axes.getV(point);
257
+ if (!Number.isFinite(u) || !Number.isFinite(v)) {
258
+ return;
259
+ }
260
+ if (u < minU) minU = u;
261
+ if (u > maxU) maxU = u;
262
+ if (v < minV) minV = v;
263
+ if (v > maxV) maxV = v;
264
+ });
265
+ });
266
+
267
+ // Also include segments in bounding box calculation
268
+ perProduct.forEach((entry) => {
269
+ if (entry.segments && entry.segments.length) {
270
+ entry.segments.forEach((segment) => {
271
+ const [p1, p2] = segment;
272
+ const u1 = axes.getU(p1);
273
+ const v1 = axes.getV(p1);
274
+ const u2 = axes.getU(p2);
275
+ const v2 = axes.getV(p2);
276
+ if (Number.isFinite(u1) && Number.isFinite(v1)) {
277
+ if (u1 < minU) minU = u1;
278
+ if (u1 > maxU) maxU = u1;
279
+ if (v1 < minV) minV = v1;
280
+ if (v1 > maxV) maxV = v1;
281
+ }
282
+ if (Number.isFinite(u2) && Number.isFinite(v2)) {
283
+ if (u2 < minU) minU = u2;
284
+ if (u2 > maxU) maxU = u2;
285
+ if (v2 < minV) minV = v2;
286
+ if (v2 > maxV) maxV = v2;
287
+ }
288
+ });
289
+ }
290
+ });
291
+
292
+ if (!Number.isFinite(minU) || !Number.isFinite(maxU) || !Number.isFinite(minV) || !Number.isFinite(maxV)) {
293
+ return null;
294
+ }
295
+
296
+ if (maxU - minU < 1e-3) {
297
+ minU -= 0.5;
298
+ maxU += 0.5;
299
+ }
300
+ if (maxV - minV < 1e-3) {
301
+ minV -= 0.5;
302
+ maxV += 0.5;
303
+ }
304
+
305
+ const worldWidth = maxU - minU;
306
+ const worldHeight = maxV - minV;
307
+ const marginU = Math.max(worldWidth * SECTION_MARGIN_RATIO, 0.5);
308
+ const marginV = Math.max(worldHeight * SECTION_MARGIN_RATIO, 0.5);
309
+
310
+ viewMinX = minU - marginU;
311
+ viewMaxX = maxU + marginU;
312
+ viewMinZ = minV - marginV;
313
+ viewMaxZ = maxV + marginV;
314
+ }
315
+
316
+ const viewWidth = viewMaxX - viewMinX;
317
+ const viewHeight = viewMaxZ - viewMinZ;
318
+
319
+ if (!Number.isFinite(viewWidth) || !Number.isFinite(viewHeight) || viewWidth <= 1e-6 || viewHeight <= 1e-6) {
320
+ return null;
321
+ }
322
+
323
+ const autoStrokeBase = Math.max(Math.min(viewWidth, viewHeight) * 0.008, 0.08);
324
+ const autoStroke = Math.max(autoStrokeBase * OUTLINE_THICKNESS_SCALE, 0.02);
325
+ const strokeWidth = Math.max(strokeWidthHint ?? autoStroke, 0.02);
326
+ const geometryTransform = buildGeometryTransform(viewMinX, viewMaxX, viewMinZ, viewMaxZ);
327
+
328
+ const includeMeta = !!options?.includeMeta;
329
+ const createFills = options?.createFills !== false; // Default to true for backward compatibility
330
+
331
+ const allPaths: SectionPath[] = [];
332
+ if (includeMeta) {
333
+ perProduct.forEach((entry, idx) => {
334
+ // Skip entries with neither polylines nor segments
335
+ if (!entry.polylines.length && (!entry.segments || !entry.segments.length)) {
336
+ return;
337
+ }
338
+ const colorInfo = sampleProductColorRgba(entry.product);
339
+ const fillColor = colorInfo ? `rgb(${colorInfo.r},${colorInfo.g},${colorInfo.b})` : '#ffffff';
340
+ const fillOpacity = colorInfo ? clampValue(colorInfo.a, 0, 1) : 1;
341
+ const meta = extractProductMeta(entry.product);
342
+
343
+ const closedSubpaths: string[] = [];
344
+ const holeSubpaths: string[] = [];
345
+ const openSubpaths: string[] = [];
346
+
347
+ entry.polylines.forEach((polyline) => {
348
+ const pathData = getCachedPolylinePath(polyline, planeNormal);
349
+ if (!pathData) {
350
+ return;
351
+ }
352
+ if (pathData.closed) {
353
+ if (pathData.hole) {
354
+ holeSubpaths.push(pathData.d);
355
+ } else {
356
+ closedSubpaths.push(pathData.d);
357
+ }
358
+ } else {
359
+ openSubpaths.push(pathData.d);
360
+ }
361
+ });
362
+
363
+ // Only create fill paths if createFills is true
364
+ if (createFills && (closedSubpaths.length || holeSubpaths.length)) {
365
+ const fillSubpaths = closedSubpaths.length ? closedSubpaths.concat(holeSubpaths) : holeSubpaths;
366
+ allPaths.push({
367
+ key: `fill-${idx}`,
368
+ d: fillSubpaths.join(' '),
369
+ fill: fillColor,
370
+ fillOpacity: clampValue(fillOpacity, 0, 1),
371
+ stroke: '#1f2937',
372
+ strokeWidth,
373
+ fillRule: 'evenodd',
374
+ meta: meta ? { ...meta, isFill: true } : undefined
375
+ });
376
+ }
377
+
378
+ if (openSubpaths.length) {
379
+ allPaths.push({
380
+ key: `outline-${idx}`,
381
+ d: openSubpaths.join(' '),
382
+ fill: 'none',
383
+ fillOpacity: 1,
384
+ stroke: '#1f2937',
385
+ strokeWidth,
386
+ meta: meta ? { ...meta, isOutline: true } : undefined
387
+ });
388
+ }
389
+
390
+ // Render segments (cut edges) as strokes
391
+ if (entry.segments && entry.segments.length) {
392
+ const segmentPaths: string[] = [];
393
+ for (const segment of entry.segments) {
394
+ const path = segmentToPath(segment, planeNormal);
395
+ if (path) segmentPaths.push(path);
396
+ }
397
+ if (segmentPaths.length) {
398
+ allPaths.push({
399
+ key: `segments-${idx}`,
400
+ d: segmentPaths.join(' '),
401
+ fill: 'none',
402
+ fillOpacity: 1,
403
+ stroke: '#1f2937',
404
+ strokeWidth,
405
+ meta: meta ? { ...meta, isSegment: true } : undefined
406
+ });
407
+ }
408
+ }
409
+ });
410
+ } else {
411
+ const fillGroups = new Map<string, FillGroup>();
412
+ const openSubpaths: string[] = [];
413
+ const allSegmentPaths: string[] = [];
414
+
415
+ perProduct.forEach((entry) => {
416
+ // Process segments first (always render, even without polylines)
417
+ if (entry.segments && entry.segments.length) {
418
+ for (const segment of entry.segments) {
419
+ const path = segmentToPath(segment, planeNormal);
420
+ if (path) allSegmentPaths.push(path);
421
+ }
422
+ }
423
+
424
+ if (!entry.polylines.length) {
425
+ return;
426
+ }
427
+
428
+ const colorInfo = sampleProductColorRgba(entry.product);
429
+ const fillColor = colorInfo ? `rgb(${colorInfo.r},${colorInfo.g},${colorInfo.b})` : '#ffffff';
430
+ const fillOpacity = colorInfo ? clampValue(colorInfo.a, 0, 1) : 1;
431
+ const groupKey = `${fillColor}|${Math.round(fillOpacity * 1000)}`;
432
+ let group = fillGroups.get(groupKey);
433
+ if (!group) {
434
+ group = {
435
+ color: fillColor,
436
+ opacity: fillOpacity,
437
+ closed: [],
438
+ holes: []
439
+ };
440
+ fillGroups.set(groupKey, group);
441
+ }
442
+
443
+ entry.polylines.forEach((polyline) => {
444
+ const pathData = getCachedPolylinePath(polyline, planeNormal);
445
+ if (!pathData) {
446
+ return;
447
+ }
448
+ if (pathData.closed) {
449
+ if (pathData.hole) {
450
+ group!.holes.push(pathData.d);
451
+ } else {
452
+ group!.closed.push(pathData.d);
453
+ }
454
+ return;
455
+ }
456
+ openSubpaths.push(pathData.d);
457
+ });
458
+ });
459
+
460
+ let groupIndex = 0;
461
+ fillGroups.forEach((group) => {
462
+ const fillSubpaths = group.closed.length ? group.closed.concat(group.holes) : group.holes;
463
+ if (!fillSubpaths.length) {
464
+ return;
465
+ }
466
+ allPaths.push({
467
+ key: `fill-${groupIndex++}`,
468
+ d: fillSubpaths.join(' '),
469
+ fill: group.color,
470
+ fillOpacity: clampValue(group.opacity, 0, 1),
471
+ stroke: '#1f2937',
472
+ strokeWidth,
473
+ fillRule: 'evenodd'
474
+ });
475
+ });
476
+
477
+ if (openSubpaths.length) {
478
+ allPaths.push({
479
+ key: 'outlines',
480
+ d: openSubpaths.join(' '),
481
+ fill: 'none',
482
+ fillOpacity: 1,
483
+ stroke: '#1f2937',
484
+ strokeWidth
485
+ });
486
+ }
487
+
488
+ // Render all segments (collected above in forEach loop)
489
+ if (allSegmentPaths.length) {
490
+ allPaths.push({
491
+ key: 'segments',
492
+ d: allSegmentPaths.join(' '),
493
+ fill: 'none',
494
+ fillOpacity: 1,
495
+ stroke: '#1f2937',
496
+ strokeWidth
497
+ });
498
+ }
499
+ }
500
+
501
+ if (!allPaths.length) {
502
+ return null;
503
+ }
504
+
505
+ const displayWidth = BASE_WIDTH;
506
+ const displayHeight = Math.max(MIN_HEIGHT, (viewHeight / viewWidth) * BASE_WIDTH);
507
+
508
+ return {
509
+ paths: allPaths,
510
+ transform: {
511
+ viewMinX,
512
+ viewMaxX,
513
+ viewMinZ,
514
+ viewMaxZ,
515
+ viewWidth,
516
+ viewHeight,
517
+ displayWidth,
518
+ displayHeight
519
+ },
520
+ strokeWidth,
521
+ geometryTransform,
522
+ labels: labels?.length
523
+ ? labels
524
+ .filter((label) => {
525
+ if (!Number.isFinite(label.x) || !Number.isFinite(label.z)) {
526
+ return false;
527
+ }
528
+ return label.x >= viewMinX && label.x <= viewMaxX && label.z >= viewMinZ && label.z <= viewMaxZ;
529
+ })
530
+ .map((label) => ({
531
+ key: label.key,
532
+ text: label.text,
533
+ x: label.x,
534
+ y: label.y,
535
+ z: label.z,
536
+ fontSize: label.fontSize,
537
+ meta: includeMeta ? (label.meta ?? {}) : undefined
538
+ }))
539
+ : []
540
+ };
541
+ }
542
+
543
+ /**
544
+ * Build a complete SectionRenderState from plane section entries, with optional
545
+ * fill-rule-based separation, ordering, and label generation.
546
+ *
547
+ * When `fills` rules are provided, entries matching a fill rule are rendered
548
+ * below non-matching entries, with their fill opacity set by the matching rule.
549
+ */
550
+ export function buildSectionStateForEntries(
551
+ perProduct: PlaneSectionEntry[],
552
+ options: BuildSectionStateOptions
553
+ ): (SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] }) | null {
554
+ if (!perProduct.length) {
555
+ return null;
556
+ }
557
+
558
+ const fills = options.fills;
559
+ const includeMeta = !!options?.includeMeta;
560
+ const includeLabels = options?.includeLabels ?? false;
561
+ const viewport = options?.viewport;
562
+ const strokeWidth = options?.strokeWidth;
563
+ const labelFontSize = includeLabels && typeof options?.labelFontSize === 'number' ? options.labelFontSize : null;
564
+ const buildLabels = options?.buildLabels;
565
+
566
+ // Point-in-polygon test (ray casting algorithm)
567
+ const pointInPolygon = (polygon: Array<{ x: number; z: number }>, x: number, z: number): boolean => {
568
+ let inside = false;
569
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
570
+ const xi = polygon[i].x;
571
+ const zi = polygon[i].z;
572
+ const xj = polygon[j].x;
573
+ const zj = polygon[j].z;
574
+ const intersect = zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi;
575
+ if (intersect) inside = !inside;
576
+ }
577
+ return inside;
578
+ };
579
+
580
+ // Calculate centroid from entry's polylines
581
+ const getCentroid = (entry: PlaneSectionEntry): { x: number; z: number } | null => {
582
+ const polylines = entry.polylines;
583
+ if (!polylines.length) return null;
584
+ let sumX = 0;
585
+ let sumZ = 0;
586
+ let count = 0;
587
+ for (const polyline of polylines) {
588
+ for (const point of polyline.points) {
589
+ sumX += point[0];
590
+ sumZ += point[2];
591
+ count++;
592
+ }
593
+ }
594
+ if (count === 0) return null;
595
+ return { x: sumX / count, z: sumZ / count };
596
+ };
597
+
598
+ // Spatial matcher for clip filter - checks if product centroid is inside clip polygon
599
+ const isInsideClipPolygon = (entry: PlaneSectionEntry): boolean => {
600
+ if (!options?.clipPolygon?.length) return true;
601
+ const centroid = getCentroid(entry);
602
+ if (!centroid) return false;
603
+ // Check if centroid is inside any of the clip polygons
604
+ return options.clipPolygon.some((polygon) => pointInPolygon(polygon, centroid.x, centroid.z));
605
+ };
606
+
607
+ // Match an entry against fill rules. Returns the first matching rule, or undefined.
608
+ const matchFillRule = (entry: PlaneSectionEntry): SectionFillRule | undefined => {
609
+ if (!fills?.length) return undefined;
610
+ const product = entry.product;
611
+ for (const rule of fills) {
612
+ // If rule requires clip filter match, check if product centroid is inside clip polygon
613
+ if (rule.matchClipFilter && !isInsideClipPolygon(entry)) {
614
+ continue;
615
+ }
616
+
617
+ // Check class match
618
+ let classMatches: boolean;
619
+ if (rule.class) {
620
+ const classes = Array.isArray(rule.class) ? rule.class : [rule.class];
621
+ const cls = product.class;
622
+ classMatches = classes.some((c) => {
623
+ if (cls?.is) return cls.is(c as string);
624
+ const pName = (product.className ?? '').toLowerCase();
625
+ return pName === (c as string).toLowerCase() || pName === `ifc${(c as string).toLowerCase()}`;
626
+ });
627
+ if (!classMatches) continue;
628
+ }
629
+
630
+ // Check where condition (property-based filter)
631
+ if (rule.where && Object.keys(rule.where).length > 0) {
632
+ let whereMatches = true;
633
+ for (const [path, expected] of Object.entries(rule.where)) {
634
+ const actualValue = resolveProductProperty(product, path);
635
+ if (actualValue === undefined) {
636
+ whereMatches = false;
637
+ break;
638
+ }
639
+
640
+ const normalize = (v: unknown): unknown => (typeof v === 'string' ? v.toLowerCase().trim() : v);
641
+
642
+ if (Array.isArray(expected)) {
643
+ if (!expected.some((exp) => normalize(exp) === normalize(actualValue))) {
644
+ whereMatches = false;
645
+ break;
646
+ }
647
+ } else {
648
+ if (normalize(actualValue) !== normalize(expected)) {
649
+ whereMatches = false;
650
+ break;
651
+ }
652
+ }
653
+ }
654
+ if (!whereMatches) continue;
655
+ }
656
+
657
+ return rule;
658
+ }
659
+ return undefined;
660
+ };
661
+
662
+ const hasFills = !!fills?.length;
663
+
664
+ // Split entries by fill rule match
665
+ const fillEntries: PlaneSectionEntry[] = [];
666
+ const fillOpacities: number[] = []; // parallel to fillEntries
667
+ const nonFillEntries: PlaneSectionEntry[] = [];
668
+
669
+ for (const entry of perProduct) {
670
+ const rule = hasFills ? matchFillRule(entry) : undefined;
671
+ if (rule) {
672
+ const opacity = rule.fillOpacity ?? DEFAULT_FILL_OPACITY;
673
+ // Skip entries with zero opacity - don't render them at all
674
+ if (opacity > 0) {
675
+ fillEntries.push(entry);
676
+ fillOpacities.push(opacity);
677
+ }
678
+ } else {
679
+ nonFillEntries.push(entry);
680
+ }
681
+ }
682
+
683
+ // Build labels from fill-matched entries (e.g. spaces)
684
+ const fillLabels =
685
+ includeLabels && fillEntries.length && labelFontSize !== null && buildLabels
686
+ ? (buildLabels(
687
+ fillEntries.map((entry) => entry.product),
688
+ labelFontSize
689
+ ) as SectionLabel[])
690
+ : undefined;
691
+
692
+ // When fill rules are present, render fill and non-fill groups separately
693
+ // so we can adjust opacity and control ordering.
694
+ // When no fill rules, render everything in one pass (unchanged behavior).
695
+ if (hasFills && (fillEntries.length > 0 || nonFillEntries.length > 0)) {
696
+ const nonFillState =
697
+ nonFillEntries.length > 0
698
+ ? createSectionSvg(nonFillEntries, viewport, strokeWidth, undefined, {
699
+ includeMeta,
700
+ planeNormal: options.planeNormal
701
+ })
702
+ : null;
703
+ const fillState =
704
+ fillEntries.length > 0
705
+ ? createSectionSvg(fillEntries, viewport, strokeWidth, includeLabels ? fillLabels : undefined, {
706
+ includeMeta,
707
+ planeNormal: options.planeNormal
708
+ })
709
+ : null;
710
+
711
+ const baseState = nonFillState ?? fillState ?? null;
712
+ if (!baseState) return null;
713
+
714
+ // Adjust fill opacity per-entry. When includeMeta is true, each entry has
715
+ // its own path, so we can apply per-entry opacity. When false, paths are
716
+ // grouped by color — apply the minimum matching opacity.
717
+ const adjustedFillPaths =
718
+ fillState?.paths.map((path, idx) => {
719
+ // When fill rules match, use the rule opacity directly (don't multiply by material alpha).
720
+ // Material alpha was useful for auto-coloring, but fill rules should have full control.
721
+ const ruleOpacity = idx < fillOpacities.length ? fillOpacities[idx] : (fills![0]?.fillOpacity ?? 0.35);
722
+ const finalOpacity = clampValue(ruleOpacity, 0, 1);
723
+ return {
724
+ ...path,
725
+ fillOpacity: finalOpacity
726
+ };
727
+ }) ?? [];
728
+
729
+ const nonFillPaths = nonFillState?.paths ?? [];
730
+ // Fill-matched entries always render below non-fill entries
731
+ const mergedPaths = [...adjustedFillPaths, ...nonFillPaths];
732
+
733
+ const merged: SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] } = {
734
+ ...baseState,
735
+ paths: mergedPaths,
736
+ labels: includeLabels ? (fillState?.labels ?? fillLabels ?? []) : []
737
+ };
738
+
739
+ return merged;
740
+ }
741
+
742
+ // No fill rules — create paths with fills for highlight support, but set opacity to 0
743
+ const state = createSectionSvg(perProduct, viewport, strokeWidth, includeLabels ? fillLabels : undefined, {
744
+ includeMeta,
745
+ createFills: true,
746
+ planeNormal: options.planeNormal
747
+ });
748
+ if (!state) return null;
749
+
750
+ // Set all fill opacities to 0 when no fill rules exist (but keep paths for highlight support)
751
+ const pathsWithZeroFillOpacity = state.paths.map((path) => ({
752
+ ...path,
753
+ fillOpacity: 0
754
+ }));
755
+
756
+ return {
757
+ ...state,
758
+ paths: pathsWithZeroFillOpacity,
759
+ labels: includeLabels ? (state.labels ?? []) : []
760
+ };
761
+ }
762
+
763
+ /** Extract the viewport bounding box from a TransformInfo. */
764
+ export function extractViewWindow(transform: TransformInfo): ViewWindow {
765
+ return {
766
+ viewMinX: transform.viewMinX,
767
+ viewMaxX: transform.viewMaxX,
768
+ viewMinZ: transform.viewMinZ,
769
+ viewMaxZ: transform.viewMaxZ
770
+ };
771
+ }
772
+
773
+ /** Compare two viewport windows for equality within a tolerance. */
774
+ export function windowsEqual(a: ViewWindow, b: ViewWindow, epsilon = 1e-4): boolean {
775
+ return (
776
+ Math.abs(a.viewMinX - b.viewMinX) <= epsilon &&
777
+ Math.abs(a.viewMaxX - b.viewMaxX) <= epsilon &&
778
+ Math.abs(a.viewMinZ - b.viewMinZ) <= epsilon &&
779
+ Math.abs(a.viewMaxZ - b.viewMaxZ) <= epsilon
780
+ );
781
+ }
782
+
783
+ /** Constrain a viewport window within base bounds, preserving aspect ratio. */
784
+ export function clampViewWindow(base: TransformInfo, candidate: ViewWindow): ViewWindow {
785
+ const baseWidth = base.viewWidth;
786
+ const baseHeight = base.viewHeight;
787
+ const aspect = baseWidth / baseHeight;
788
+
789
+ let width = candidate.viewMaxX - candidate.viewMinX;
790
+
791
+ if (!Number.isFinite(width) || width <= 1e-6) {
792
+ width = baseWidth;
793
+ }
794
+ width = clampValue(width, baseWidth / MAX_ZOOM_FACTOR, baseWidth);
795
+ const height = width / aspect;
796
+
797
+ let centerX = (candidate.viewMinX + candidate.viewMaxX) * 0.5;
798
+ if (!Number.isFinite(centerX)) {
799
+ centerX = (base.viewMinX + base.viewMaxX) * 0.5;
800
+ }
801
+
802
+ let centerZ = (candidate.viewMinZ + candidate.viewMaxZ) * 0.5;
803
+ if (!Number.isFinite(centerZ)) {
804
+ centerZ = (base.viewMinZ + base.viewMaxZ) * 0.5;
805
+ }
806
+
807
+ let minX = centerX - width * 0.5;
808
+ let maxX = minX + width;
809
+ if (minX < base.viewMinX) {
810
+ minX = base.viewMinX;
811
+ maxX = minX + width;
812
+ }
813
+ if (maxX > base.viewMaxX) {
814
+ maxX = base.viewMaxX;
815
+ minX = maxX - width;
816
+ }
817
+
818
+ let minZ = centerZ - height * 0.5;
819
+ let maxZ = minZ + height;
820
+ if (minZ < base.viewMinZ) {
821
+ minZ = base.viewMinZ;
822
+ maxZ = minZ + height;
823
+ }
824
+ if (maxZ > base.viewMaxZ) {
825
+ maxZ = base.viewMaxZ;
826
+ minZ = maxZ - height;
827
+ }
828
+
829
+ return {
830
+ viewMinX: minX,
831
+ viewMaxX: maxX,
832
+ viewMinZ: minZ,
833
+ viewMaxZ: maxZ
834
+ };
835
+ }
836
+
837
+ /** Adjust stroke width proportionally to zoom level. */
838
+ export function strokeWidthForWindow(baseStroke: number, baseTransform: TransformInfo, window: ViewWindow): number {
839
+ const baseWidth = baseTransform.viewWidth;
840
+ const currentWidth = window.viewMaxX - window.viewMinX;
841
+ if (!Number.isFinite(baseWidth) || baseWidth <= 1e-6 || !Number.isFinite(currentWidth) || currentWidth <= 1e-6) {
842
+ return baseStroke;
843
+ }
844
+ const ratio = clampValue(currentWidth / baseWidth, 1 / MAX_ZOOM_FACTOR, 1);
845
+ const scaled = baseStroke * ratio;
846
+ return Math.max(scaled, baseStroke / (MAX_ZOOM_FACTOR + 1));
847
+ }
848
+
849
+ interface SvgPathData {
850
+ d: string;
851
+ closed: boolean;
852
+ hole: boolean;
853
+ }
854
+
855
+ interface FillGroup {
856
+ color: string;
857
+ opacity: number;
858
+ closed: string[];
859
+ holes: string[];
860
+ }
861
+
862
+ interface CachedPolylinePath {
863
+ pointsRef: Vec3[];
864
+ signature: number;
865
+ data: SvgPathData;
866
+ }
867
+
868
+ const polylinePathCache = new WeakMap<{ points: Vec3[]; closed: boolean }, CachedPolylinePath>();
869
+
870
+ /**
871
+ * Determine which axes to use for 2D projection based on plane normal.
872
+ * Returns accessor functions for horizontal and vertical SVG coordinates.
873
+ */
874
+ function getProjectionAxes(planeNormal?: Vec3): {
875
+ getU: (p: Vec3) => number;
876
+ getV: (p: Vec3) => number;
877
+ axisNames: string;
878
+ } {
879
+ if (!planeNormal) {
880
+ // Default: horizontal plan (looking down Y axis) -> X,Z
881
+ return {
882
+ getU: (p) => p[0],
883
+ getV: (p) => p[2],
884
+ axisNames: 'XZ'
885
+ };
886
+ }
887
+
888
+ // Normalize and find dominant axis
889
+ const absX = Math.abs(planeNormal[0]);
890
+ const absY = Math.abs(planeNormal[1]);
891
+ const absZ = Math.abs(planeNormal[2]);
892
+
893
+ if (absY > absX && absY > absZ) {
894
+ // Horizontal plan (normal ~ Y axis) -> looking down Y, use X,Z
895
+ return {
896
+ getU: (p) => p[0],
897
+ getV: (p) => p[2],
898
+ axisNames: 'XZ'
899
+ };
900
+ } else if (absZ > absX) {
901
+ // Vertical section perpendicular to Z -> looking along Z, use X,Y
902
+ return {
903
+ getU: (p) => p[0],
904
+ getV: (p) => p[1],
905
+ axisNames: 'XY'
906
+ };
907
+ } else {
908
+ // Vertical section perpendicular to X -> looking along X, use Z,Y
909
+ return {
910
+ getU: (p) => p[2],
911
+ getV: (p) => p[1],
912
+ axisNames: 'ZY'
913
+ };
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Convert a segment (pair of points) to an SVG path string.
919
+ */
920
+ function segmentToPath(segment: [Vec3, Vec3], planeNormal?: Vec3): string {
921
+ const [p1, p2] = segment;
922
+ const axes = getProjectionAxes(planeNormal);
923
+ const u1 = axes.getU(p1);
924
+ const v1 = axes.getV(p1);
925
+ const u2 = axes.getU(p2);
926
+ const v2 = axes.getV(p2);
927
+
928
+ if (!Number.isFinite(u1) || !Number.isFinite(v1) || !Number.isFinite(u2) || !Number.isFinite(v2)) {
929
+ return '';
930
+ }
931
+ return `M${formatNumber(u1)} ${formatNumber(v1)} L${formatNumber(u2)} ${formatNumber(v2)}`;
932
+ }
933
+
934
+ function getCachedPolylinePath(polyline: { points: Vec3[]; closed: boolean }, planeNormal?: Vec3): SvgPathData | null {
935
+ if (!polyline.points.length) {
936
+ return null;
937
+ }
938
+
939
+ const signature = computePointsSignature(polyline.points, polyline.closed, planeNormal);
940
+ const cached = polylinePathCache.get(polyline);
941
+ if (cached && cached.pointsRef === polyline.points && cached.signature === signature) {
942
+ return cached.data;
943
+ }
944
+
945
+ const axes = getProjectionAxes(planeNormal);
946
+ const commands: string[] = [];
947
+ polyline.points.forEach((point: Vec3, index: number) => {
948
+ const prefix = index === 0 ? 'M' : 'L';
949
+ const u = axes.getU(point);
950
+ const v = axes.getV(point);
951
+ commands.push(`${prefix}${formatNumber(u)} ${formatNumber(v)}`);
952
+ });
953
+
954
+ if (commands.length < 2) {
955
+ return null;
956
+ }
957
+
958
+ let closed = false;
959
+ let hole = false;
960
+
961
+ if (polyline.closed && commands.length > 2) {
962
+ const area = signedArea(polyline.points, axes);
963
+ hole = area < 0;
964
+ closed = true;
965
+ commands.push('Z');
966
+ }
967
+
968
+ const data: SvgPathData = { d: commands.join(' '), closed, hole };
969
+ polylinePathCache.set(polyline, { pointsRef: polyline.points, signature, data });
970
+ return data;
971
+ }
972
+
973
+ function signedArea(points: Vec3[], axes: { getU: (p: Vec3) => number; getV: (p: Vec3) => number }): number {
974
+ if (points.length < 3) {
975
+ return 0;
976
+ }
977
+
978
+ let area = 0;
979
+ for (let i = 0; i < points.length; i++) {
980
+ const current = points[i];
981
+ const next = points[(i + 1) % points.length];
982
+ const u1 = axes.getU(current);
983
+ const v1 = axes.getV(current);
984
+ const u2 = axes.getU(next);
985
+ const v2 = axes.getV(next);
986
+ area += u1 * v2 - u2 * v1;
987
+ }
988
+ return area * 0.5;
989
+ }
990
+
991
+ function formatNumber(value: number): string {
992
+ if (!Number.isFinite(value)) {
993
+ return '0';
994
+ }
995
+ const formatted = value.toFixed(3);
996
+ return formatted.replace(/\.?0+$/, '');
997
+ }
998
+
999
+ function buildGeometryTransform(viewMinX: number, viewMaxX: number, viewMinZ: number, viewMaxZ: number): string {
1000
+ const translateX = formatNumber(-viewMinX);
1001
+ const translateY = formatNumber(viewMaxZ);
1002
+ return `matrix(1 0 0 -1 ${translateX} ${translateY})`;
1003
+ }
1004
+
1005
+ function computePointsSignature(points: readonly Vec3[], closed: boolean, planeNormal?: Vec3): number {
1006
+ const axes = getProjectionAxes(planeNormal);
1007
+ // FNV-1a-inspired hash — compact fixed-size key instead of concatenating all coordinates
1008
+ let hash = 2166136261 ^ (points.length * 16777619) ^ (closed ? 1 : 0);
1009
+ for (let i = 0; i < points.length; i++) {
1010
+ const point = points[i];
1011
+ if (!point) continue;
1012
+ const u = axes.getU(point);
1013
+ const v = axes.getV(point);
1014
+ // Mix in coordinate bits (multiply by large prime, XOR)
1015
+ hash = Math.imul(hash ^ ((u * 1e4) | 0), 16777619);
1016
+ hash = Math.imul(hash ^ ((v * 1e4) | 0), 16777619);
1017
+ }
1018
+ return hash;
1019
+ }