@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
@@ -1,372 +1,372 @@
1
- import type { SectionPath, SectionLabel } from '@twinfinity/geometry';
2
- import type { SvgModelView, ViewportElement, LabelStyle, ResolvedIncludeFilter } from './types';
3
- import type { ModelMetadata } from './metadata';
4
- import { resolveIncludeClasses, resolveMeta } from './metadata';
5
- import { normalizeClass, matchesWhere } from './filters';
6
- import { escapeAttr, escapeText } from './renderUtils';
7
- import { BUILTIN_LABEL_DEFAULTS } from './renderDrawing';
8
-
9
- import { getProp, isRecord } from './typeGuards';
10
-
11
- export const buildModelViewFromRaw = (
12
- rawPaths: SectionPath[] | undefined,
13
- rawLabels: SectionLabel[] | undefined,
14
- transform: SvgModelView['transform'] | undefined,
15
- geometryTransform?: string,
16
- labelStyle?: LabelStyle
17
- ): SvgModelView | null => {
18
- if (!rawPaths || !transform || !geometryTransform) {
19
- return null;
20
- }
21
- const labelFill = labelStyle?.fill ?? BUILTIN_LABEL_DEFAULTS.fill;
22
- const labelFontFamily = labelStyle?.fontFamily ?? BUILTIN_LABEL_DEFAULTS.fontFamily;
23
- const labelFontWeight = labelStyle?.fontWeight ?? BUILTIN_LABEL_DEFAULTS.fontWeight;
24
- const escapedFontFamily = escapeAttr(labelFontFamily);
25
-
26
- const parts: string[] = [];
27
- parts.push(`<g transform="${escapeAttr(geometryTransform)}">`);
28
- rawPaths.forEach((path) => {
29
- parts.push(
30
- `<path d="${escapeAttr(path.d)}"` +
31
- (path.fill ? ` fill="${escapeAttr(path.fill)}"` : ' fill="none"') +
32
- (path.fillOpacity !== undefined ? ` fill-opacity="${path.fillOpacity}"` : '') +
33
- (path.stroke ? ` stroke="${escapeAttr(path.stroke)}"` : '') +
34
- (path.strokeWidth ? ` stroke-width="${path.strokeWidth}"` : '') +
35
- (path.strokeOpacity !== undefined ? ` stroke-opacity="${path.strokeOpacity}"` : '') +
36
- (path.fillRule ? ` fill-rule="${escapeAttr(path.fillRule)}"` : '') +
37
- ` stroke-linejoin="round" stroke-linecap="round"` +
38
- ` />`
39
- );
40
- });
41
- parts.push(`</g>`);
42
- if (rawLabels && rawLabels.length) {
43
- parts.push(`<g pointer-events="none">`);
44
- rawLabels.forEach((label) => {
45
- const viewX = label.x - transform.viewMinX;
46
- const viewY = transform.viewMaxZ - label.z;
47
- if (!Number.isFinite(viewX) || !Number.isFinite(viewY)) {
48
- return;
49
- }
50
- parts.push(
51
- `<text x="${viewX}" y="${viewY}" font-size="${label.fontSize}" text-anchor="middle" dominant-baseline="middle" fill="${escapeAttr(labelFill)}" font-family="${escapedFontFamily}" font-weight="${labelFontWeight}">${escapeText(label.text)}</text>`
52
- );
53
- });
54
- parts.push(`</g>`);
55
- }
56
- const svg = `<g>${parts.join('')}</g>`;
57
- return {
58
- groupSvg: svg,
59
- widthModel: transform.viewWidth,
60
- heightModel: transform.viewHeight,
61
- transform,
62
- geometryTransform,
63
- rawPaths,
64
- rawLabels,
65
- labelStyle
66
- };
67
- };
68
-
69
- /**
70
- * Check whether a path's resolved class matches any of the filter classes.
71
- * Used to determine which SVG paths are subject to GID-based include/exclude.
72
- */
73
- const matchesFilterClass = (
74
- cls: string | null,
75
- clsId: unknown,
76
- clsFromMap: string | null,
77
- filterClasses: Set<string>
78
- ): boolean => {
79
- if (!filterClasses.size) return false;
80
- if (cls && filterClasses.has(cls)) return true;
81
- if (clsFromMap && filterClasses.has(clsFromMap)) return true;
82
- if (clsId && typeof clsId === 'string') {
83
- const normalized = normalizeClass(clsId);
84
- if (normalized && filterClasses.has(normalized)) return true;
85
- }
86
- return false;
87
- };
88
-
89
- /**
90
- * Check if an item matches any of the include filters.
91
- *
92
- * @param includeFilters - Array of filters (strings or class+where objects)
93
- * @param cls - Normalized IFC class name
94
- * @param clsId - Predefined IFC class ID
95
- * @param meta - Item metadata (for property matching)
96
- * @returns true if the item matches any filter, false otherwise
97
- */
98
- const matchesIncludeFilter = (
99
- includeFilters: ResolvedIncludeFilter[],
100
- cls: string | null,
101
- clsId: unknown,
102
- meta: unknown
103
- ): boolean => {
104
- for (const filter of includeFilters) {
105
- // String filter: class name only
106
- if (typeof filter === 'string') {
107
- const normalizedFilter = normalizeClass(filter);
108
- if (cls && normalizedFilter === cls) return true;
109
- if (clsId && typeof clsId === 'string') {
110
- const normalizedId = normalizeClass(clsId);
111
- if (normalizedId === normalizedFilter) return true;
112
- }
113
- continue;
114
- }
115
-
116
- // Object filter: class + where
117
- if (isRecord(filter)) {
118
- const filterClass = filter.class;
119
- const filterWhere = filter.where;
120
-
121
- // Check class match (if specified)
122
- let classMatches = true;
123
- if (filterClass !== undefined) {
124
- const classes = Array.isArray(filterClass) ? filterClass : [filterClass];
125
- classMatches = false;
126
- for (const c of classes) {
127
- const normalizedC = normalizeClass(c);
128
- if (normalizedC && cls === normalizedC) {
129
- classMatches = true;
130
- break;
131
- }
132
- if (clsId && typeof clsId === 'string') {
133
- const normalizedId = normalizeClass(clsId);
134
- if (normalizedId === normalizedC) {
135
- classMatches = true;
136
- break;
137
- }
138
- }
139
- }
140
- }
141
-
142
- // Check where condition (if specified)
143
- let whereMatches = true;
144
- if (filterWhere && Object.keys(filterWhere).length > 0) {
145
- whereMatches = matchesWhere(meta, filterWhere);
146
- }
147
-
148
- if (classMatches && whereMatches) return true;
149
- }
150
- }
151
-
152
- return false;
153
- };
154
-
155
- export const filterModelForViewport = (
156
- model: SvgModelView,
157
- vp: ViewportElement,
158
- metadataById?: ModelMetadata,
159
- includePresets?: Record<string, string[]>,
160
- allowedGids?: Set<string> | null,
161
- highlight?: {
162
- gids: Set<string> | null;
163
- styles?: {
164
- stroke?: string;
165
- strokeWidthMm?: number;
166
- strokeOpacity?: number;
167
- fill?: string;
168
- fillOpacity?: number;
169
- };
170
- },
171
- filterClasses?: Set<string> | null
172
- ): SvgModelView => {
173
- const resolvedIncludeClasses = resolveIncludeClasses(vp.includeClasses as unknown, includePresets ?? {});
174
- const includeFilters = resolvedIncludeClasses ?? null;
175
- // Classes subject to GID-based filtering (highlight/clip include/exclude).
176
- // Derived from template highlight.class and clip.class by the caller.
177
- const effectiveFilterClasses = filterClasses ?? new Set<string>();
178
- let styleMutated = false;
179
- if (!model.rawPaths || !model.geometryTransform || !model.transform) {
180
- return model;
181
- }
182
- const filteredPaths: SectionPath[] = [];
183
- model.rawPaths.forEach((p: SectionPath) => {
184
- const rawMeta = getProp(p, 'meta');
185
- const meta = resolveMeta(rawMeta, metadataById);
186
- const metaFromMap = (() => {
187
- const pid =
188
- getProp(meta, 'productId') ??
189
- getProp(meta, 'gid') ??
190
- getProp(meta, 'id') ??
191
- getProp(rawMeta, 'gid') ??
192
- getProp(rawMeta, 'id');
193
- return pid && metadataById ? metadataById[String(pid)] : null;
194
- })();
195
- const productIdForMeta =
196
- getProp(meta, 'productId') ??
197
- getProp(meta, 'gid') ??
198
- getProp(meta, 'id') ??
199
- getProp(rawMeta, 'gid') ??
200
- getProp(rawMeta, 'id') ??
201
- metaFromMap?.productId ??
202
- metaFromMap?.gid ??
203
- null;
204
- const pid = productIdForMeta ? String(productIdForMeta) : null;
205
- const cls = normalizeClass(getProp(meta, 'ifcClass') ?? getProp(rawMeta, 'ifcClass') ?? metaFromMap?.ifcClass);
206
- const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? metaFromMap?.ifcClassId ?? null;
207
- const clsFromMap = normalizeClass(metaFromMap?.ifcClass);
208
- const isFilterable = matchesFilterClass(cls, clsId, clsFromMap, effectiveFilterClasses);
209
- const shouldHighlight = !!highlight?.gids && highlight.gids.size > 0 && !!pid && highlight.gids.has(pid);
210
- if (includeFilters && includeFilters.length > 0) {
211
- if (isFilterable) {
212
- // Highlight/clip class: only check includeClasses if no GID filtering will be applied
213
- const hasGidFiltering =
214
- (highlight?.gids && highlight.gids.size > 0) || (allowedGids && allowedGids.size > 0);
215
- if (!hasGidFiltering) {
216
- // No GID filtering active, so apply includeClasses filter
217
- const matches = matchesIncludeFilter(includeFilters, cls, clsId, meta);
218
- if (!matches) {
219
- return;
220
- }
221
- }
222
- } else {
223
- // Not a highlight/clip class: always apply includeClasses filter
224
- const matches = matchesIncludeFilter(includeFilters, cls, clsId, meta);
225
- if (!matches) {
226
- return;
227
- }
228
- }
229
- }
230
- if (isFilterable) {
231
- if (highlight?.gids && highlight.gids.size > 0) {
232
- if (!pid || !highlight.gids.has(pid)) {
233
- return;
234
- }
235
- }
236
- // Clip filtering logic: null means no filtering, empty Set means filtering active but no matches
237
- const allowed =
238
- !allowedGids || (allowedGids.size > 0 && !!(pid && allowedGids.has(pid))) || shouldHighlight;
239
- if (!allowed) return;
240
- }
241
- // CRITICAL: Exclude space fills from model - buildFillSvg will handle them with matchClipFilter support
242
- // Only keep space segments (outlines) in the model
243
- // EXCEPT: Keep highlighted space fills so they can be styled with highlight colors
244
- const isFill = getProp(meta, 'isFill') ?? getProp(rawMeta, 'isFill');
245
- if (isFilterable && isFill && !shouldHighlight) {
246
- return;
247
- }
248
- // Apply highlight styling when requested
249
- if (shouldHighlight) {
250
- const styledPath: SectionPath = { ...p };
251
- if (highlight?.styles?.stroke !== undefined) {
252
- styledPath.stroke = highlight.styles.stroke;
253
- }
254
- if (highlight?.styles?.strokeWidthMm !== undefined) {
255
- styledPath.strokeWidth = highlight.styles.strokeWidthMm;
256
- }
257
- if (highlight?.styles?.strokeOpacity !== undefined) {
258
- styledPath.strokeOpacity = highlight.styles.strokeOpacity;
259
- }
260
- if (highlight?.styles?.fill !== undefined) {
261
- styledPath.fill = highlight.styles.fill;
262
- }
263
- if (highlight?.styles?.fillOpacity !== undefined) {
264
- styledPath.fillOpacity = highlight.styles.fillOpacity;
265
- }
266
- styleMutated = true;
267
- filteredPaths.push(styledPath);
268
- } else {
269
- filteredPaths.push(p);
270
- }
271
- });
272
-
273
- const filteredLabels: SectionLabel[] = [];
274
- (model.rawLabels ?? []).forEach((l: SectionLabel) => {
275
- const rawMeta = getProp(l, 'meta');
276
- const meta = resolveMeta(rawMeta, metadataById);
277
- const cls = normalizeClass(getProp(meta, 'ifcClass'));
278
- const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? null;
279
- const productIdForMeta =
280
- getProp(meta, 'productId') ??
281
- getProp(meta, 'gid') ??
282
- getProp(meta, 'id') ??
283
- getProp(rawMeta, 'gid') ??
284
- getProp(rawMeta, 'id') ??
285
- null;
286
- if (includeFilters && includeFilters.length > 0) {
287
- if (!matchesIncludeFilter(includeFilters, cls, clsId, meta)) {
288
- return;
289
- }
290
- }
291
- if (matchesFilterClass(cls, clsId, null, effectiveFilterClasses)) {
292
- const pid = productIdForMeta ? String(productIdForMeta) : null;
293
- if (highlight?.gids && highlight.gids.size > 0) {
294
- if (!pid || !highlight.gids.has(pid)) {
295
- return;
296
- }
297
- }
298
- if (allowedGids && allowedGids.size > 0) {
299
- if (!pid || !allowedGids.has(pid)) {
300
- return;
301
- }
302
- }
303
- }
304
- filteredLabels.push(l);
305
- });
306
-
307
- // Count paths by IFC class before and after filtering
308
- const classCountsBefore = new Map<string, number>();
309
- const classCountsAfter = new Map<string, number>();
310
- const excluded: Array<{ cls: string | null; clsId: unknown }> = [];
311
-
312
- model.rawPaths.forEach((p: SectionPath) => {
313
- const rawMeta = getProp(p, 'meta');
314
- const meta = resolveMeta(rawMeta, metadataById);
315
- const metaFromMap = (() => {
316
- const pid =
317
- getProp(meta, 'productId') ??
318
- getProp(meta, 'gid') ??
319
- getProp(meta, 'id') ??
320
- getProp(rawMeta, 'gid') ??
321
- getProp(rawMeta, 'id');
322
- return pid && metadataById ? metadataById[String(pid)] : null;
323
- })();
324
- const cls = normalizeClass(getProp(meta, 'ifcClass') ?? getProp(rawMeta, 'ifcClass') ?? metaFromMap?.ifcClass);
325
- const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? metaFromMap?.ifcClassId ?? null;
326
- const key = cls ?? String(clsId) ?? 'unknown';
327
- classCountsBefore.set(key, (classCountsBefore.get(key) ?? 0) + 1);
328
- });
329
-
330
- filteredPaths.forEach((p: SectionPath) => {
331
- const rawMeta = getProp(p, 'meta');
332
- const meta = resolveMeta(rawMeta, metadataById);
333
- const metaFromMap = (() => {
334
- const pid =
335
- getProp(meta, 'productId') ??
336
- getProp(meta, 'gid') ??
337
- getProp(meta, 'id') ??
338
- getProp(rawMeta, 'gid') ??
339
- getProp(rawMeta, 'id');
340
- return pid && metadataById ? metadataById[String(pid)] : null;
341
- })();
342
- const cls = normalizeClass(getProp(meta, 'ifcClass') ?? getProp(rawMeta, 'ifcClass') ?? metaFromMap?.ifcClass);
343
- const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? metaFromMap?.ifcClassId ?? null;
344
- const key = cls ?? String(clsId) ?? 'unknown';
345
- classCountsAfter.set(key, (classCountsAfter.get(key) ?? 0) + 1);
346
- });
347
-
348
- // Find excluded classes
349
- classCountsBefore.forEach((countBefore, cls) => {
350
- const countAfter = classCountsAfter.get(cls) ?? 0;
351
- if (countAfter < countBefore) {
352
- excluded.push({ cls, clsId: `${countBefore - countAfter} paths removed` });
353
- }
354
- });
355
-
356
- if (
357
- !styleMutated &&
358
- filteredPaths.length === model.rawPaths.length &&
359
- filteredLabels.length === (model.rawLabels?.length ?? 0)
360
- ) {
361
- return model;
362
- }
363
-
364
- const rebuilt = buildModelViewFromRaw(
365
- filteredPaths,
366
- filteredLabels,
367
- model.transform,
368
- model.geometryTransform,
369
- model.labelStyle
370
- );
371
- return rebuilt ?? model;
372
- };
1
+ import type { SectionPath, SectionLabel } from '@twinfinity/geometry';
2
+ import type { SvgModelView, ViewportElement, LabelStyle, ResolvedIncludeFilter } from './types';
3
+ import type { ModelMetadata } from './metadata';
4
+ import { resolveIncludeClasses, resolveMeta } from './metadata';
5
+ import { normalizeClass, matchesWhere } from './filters';
6
+ import { escapeAttr, escapeText } from './renderUtils';
7
+ import { BUILTIN_LABEL_DEFAULTS } from './renderDrawing';
8
+
9
+ import { getProp, isRecord } from './typeGuards';
10
+
11
+ export const buildModelViewFromRaw = (
12
+ rawPaths: SectionPath[] | undefined,
13
+ rawLabels: SectionLabel[] | undefined,
14
+ transform: SvgModelView['transform'] | undefined,
15
+ geometryTransform?: string,
16
+ labelStyle?: LabelStyle
17
+ ): SvgModelView | null => {
18
+ if (!rawPaths || !transform || !geometryTransform) {
19
+ return null;
20
+ }
21
+ const labelFill = labelStyle?.fill ?? BUILTIN_LABEL_DEFAULTS.fill;
22
+ const labelFontFamily = labelStyle?.fontFamily ?? BUILTIN_LABEL_DEFAULTS.fontFamily;
23
+ const labelFontWeight = labelStyle?.fontWeight ?? BUILTIN_LABEL_DEFAULTS.fontWeight;
24
+ const escapedFontFamily = escapeAttr(labelFontFamily);
25
+
26
+ const parts: string[] = [];
27
+ parts.push(`<g transform="${escapeAttr(geometryTransform)}">`);
28
+ rawPaths.forEach((path) => {
29
+ parts.push(
30
+ `<path d="${escapeAttr(path.d)}"` +
31
+ (path.fill ? ` fill="${escapeAttr(path.fill)}"` : ' fill="none"') +
32
+ (path.fillOpacity !== undefined ? ` fill-opacity="${path.fillOpacity}"` : '') +
33
+ (path.stroke ? ` stroke="${escapeAttr(path.stroke)}"` : '') +
34
+ (path.strokeWidth ? ` stroke-width="${path.strokeWidth}"` : '') +
35
+ (path.strokeOpacity !== undefined ? ` stroke-opacity="${path.strokeOpacity}"` : '') +
36
+ (path.fillRule ? ` fill-rule="${escapeAttr(path.fillRule)}"` : '') +
37
+ ` stroke-linejoin="round" stroke-linecap="round"` +
38
+ ` />`
39
+ );
40
+ });
41
+ parts.push(`</g>`);
42
+ if (rawLabels && rawLabels.length) {
43
+ parts.push(`<g pointer-events="none">`);
44
+ rawLabels.forEach((label) => {
45
+ const viewX = label.x - transform.viewMinX;
46
+ const viewY = transform.viewMaxZ - label.z;
47
+ if (!Number.isFinite(viewX) || !Number.isFinite(viewY)) {
48
+ return;
49
+ }
50
+ parts.push(
51
+ `<text x="${viewX}" y="${viewY}" font-size="${label.fontSize}" text-anchor="middle" dominant-baseline="middle" fill="${escapeAttr(labelFill)}" font-family="${escapedFontFamily}" font-weight="${labelFontWeight}">${escapeText(label.text)}</text>`
52
+ );
53
+ });
54
+ parts.push(`</g>`);
55
+ }
56
+ const svg = `<g>${parts.join('')}</g>`;
57
+ return {
58
+ groupSvg: svg,
59
+ widthModel: transform.viewWidth,
60
+ heightModel: transform.viewHeight,
61
+ transform,
62
+ geometryTransform,
63
+ rawPaths,
64
+ rawLabels,
65
+ labelStyle
66
+ };
67
+ };
68
+
69
+ /**
70
+ * Check whether a path's resolved class matches any of the filter classes.
71
+ * Used to determine which SVG paths are subject to GID-based include/exclude.
72
+ */
73
+ const matchesFilterClass = (
74
+ cls: string | null,
75
+ clsId: unknown,
76
+ clsFromMap: string | null,
77
+ filterClasses: Set<string>
78
+ ): boolean => {
79
+ if (!filterClasses.size) return false;
80
+ if (cls && filterClasses.has(cls)) return true;
81
+ if (clsFromMap && filterClasses.has(clsFromMap)) return true;
82
+ if (clsId && typeof clsId === 'string') {
83
+ const normalized = normalizeClass(clsId);
84
+ if (normalized && filterClasses.has(normalized)) return true;
85
+ }
86
+ return false;
87
+ };
88
+
89
+ /**
90
+ * Check if an item matches any of the include filters.
91
+ *
92
+ * @param includeFilters - Array of filters (strings or class+where objects)
93
+ * @param cls - Normalized IFC class name
94
+ * @param clsId - Predefined IFC class ID
95
+ * @param meta - Item metadata (for property matching)
96
+ * @returns true if the item matches any filter, false otherwise
97
+ */
98
+ const matchesIncludeFilter = (
99
+ includeFilters: ResolvedIncludeFilter[],
100
+ cls: string | null,
101
+ clsId: unknown,
102
+ meta: unknown
103
+ ): boolean => {
104
+ for (const filter of includeFilters) {
105
+ // String filter: class name only
106
+ if (typeof filter === 'string') {
107
+ const normalizedFilter = normalizeClass(filter);
108
+ if (cls && normalizedFilter === cls) return true;
109
+ if (clsId && typeof clsId === 'string') {
110
+ const normalizedId = normalizeClass(clsId);
111
+ if (normalizedId === normalizedFilter) return true;
112
+ }
113
+ continue;
114
+ }
115
+
116
+ // Object filter: class + where
117
+ if (isRecord(filter)) {
118
+ const filterClass = filter.class;
119
+ const filterWhere = filter.where;
120
+
121
+ // Check class match (if specified)
122
+ let classMatches = true;
123
+ if (filterClass !== undefined) {
124
+ const classes = Array.isArray(filterClass) ? filterClass : [filterClass];
125
+ classMatches = false;
126
+ for (const c of classes) {
127
+ const normalizedC = normalizeClass(c);
128
+ if (normalizedC && cls === normalizedC) {
129
+ classMatches = true;
130
+ break;
131
+ }
132
+ if (clsId && typeof clsId === 'string') {
133
+ const normalizedId = normalizeClass(clsId);
134
+ if (normalizedId === normalizedC) {
135
+ classMatches = true;
136
+ break;
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ // Check where condition (if specified)
143
+ let whereMatches = true;
144
+ if (filterWhere && Object.keys(filterWhere).length > 0) {
145
+ whereMatches = matchesWhere(meta, filterWhere);
146
+ }
147
+
148
+ if (classMatches && whereMatches) return true;
149
+ }
150
+ }
151
+
152
+ return false;
153
+ };
154
+
155
+ export const filterModelForViewport = (
156
+ model: SvgModelView,
157
+ vp: ViewportElement,
158
+ metadataById?: ModelMetadata,
159
+ includePresets?: Record<string, string[]>,
160
+ allowedGids?: Set<string> | null,
161
+ highlight?: {
162
+ gids: Set<string> | null;
163
+ styles?: {
164
+ stroke?: string;
165
+ strokeWidthMm?: number;
166
+ strokeOpacity?: number;
167
+ fill?: string;
168
+ fillOpacity?: number;
169
+ };
170
+ },
171
+ filterClasses?: Set<string> | null
172
+ ): SvgModelView => {
173
+ const resolvedIncludeClasses = resolveIncludeClasses(vp.includeClasses as unknown, includePresets ?? {});
174
+ const includeFilters = resolvedIncludeClasses ?? null;
175
+ // Classes subject to GID-based filtering (highlight/clip include/exclude).
176
+ // Derived from template highlight.class and clip.class by the caller.
177
+ const effectiveFilterClasses = filterClasses ?? new Set<string>();
178
+ let styleMutated = false;
179
+ if (!model.rawPaths || !model.geometryTransform || !model.transform) {
180
+ return model;
181
+ }
182
+ const filteredPaths: SectionPath[] = [];
183
+ model.rawPaths.forEach((p: SectionPath) => {
184
+ const rawMeta = getProp(p, 'meta');
185
+ const meta = resolveMeta(rawMeta, metadataById);
186
+ const metaFromMap = (() => {
187
+ const pid =
188
+ getProp(meta, 'productId') ??
189
+ getProp(meta, 'gid') ??
190
+ getProp(meta, 'id') ??
191
+ getProp(rawMeta, 'gid') ??
192
+ getProp(rawMeta, 'id');
193
+ return pid && metadataById ? metadataById[String(pid)] : null;
194
+ })();
195
+ const productIdForMeta =
196
+ getProp(meta, 'productId') ??
197
+ getProp(meta, 'gid') ??
198
+ getProp(meta, 'id') ??
199
+ getProp(rawMeta, 'gid') ??
200
+ getProp(rawMeta, 'id') ??
201
+ metaFromMap?.productId ??
202
+ metaFromMap?.gid ??
203
+ null;
204
+ const pid = productIdForMeta ? String(productIdForMeta) : null;
205
+ const cls = normalizeClass(getProp(meta, 'ifcClass') ?? getProp(rawMeta, 'ifcClass') ?? metaFromMap?.ifcClass);
206
+ const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? metaFromMap?.ifcClassId ?? null;
207
+ const clsFromMap = normalizeClass(metaFromMap?.ifcClass);
208
+ const isFilterable = matchesFilterClass(cls, clsId, clsFromMap, effectiveFilterClasses);
209
+ const shouldHighlight = !!highlight?.gids && highlight.gids.size > 0 && !!pid && highlight.gids.has(pid);
210
+ if (includeFilters && includeFilters.length > 0) {
211
+ if (isFilterable) {
212
+ // Highlight/clip class: only check includeClasses if no GID filtering will be applied
213
+ const hasGidFiltering =
214
+ (highlight?.gids && highlight.gids.size > 0) || (allowedGids && allowedGids.size > 0);
215
+ if (!hasGidFiltering) {
216
+ // No GID filtering active, so apply includeClasses filter
217
+ const matches = matchesIncludeFilter(includeFilters, cls, clsId, meta);
218
+ if (!matches) {
219
+ return;
220
+ }
221
+ }
222
+ } else {
223
+ // Not a highlight/clip class: always apply includeClasses filter
224
+ const matches = matchesIncludeFilter(includeFilters, cls, clsId, meta);
225
+ if (!matches) {
226
+ return;
227
+ }
228
+ }
229
+ }
230
+ if (isFilterable) {
231
+ if (highlight?.gids && highlight.gids.size > 0) {
232
+ if (!pid || !highlight.gids.has(pid)) {
233
+ return;
234
+ }
235
+ }
236
+ // Clip filtering logic: null means no filtering, empty Set means filtering active but no matches
237
+ const allowed =
238
+ !allowedGids || (allowedGids.size > 0 && !!(pid && allowedGids.has(pid))) || shouldHighlight;
239
+ if (!allowed) return;
240
+ }
241
+ // CRITICAL: Exclude space fills from model - buildFillSvg will handle them with matchClipFilter support
242
+ // Only keep space segments (outlines) in the model
243
+ // EXCEPT: Keep highlighted space fills so they can be styled with highlight colors
244
+ const isFill = getProp(meta, 'isFill') ?? getProp(rawMeta, 'isFill');
245
+ if (isFilterable && isFill && !shouldHighlight) {
246
+ return;
247
+ }
248
+ // Apply highlight styling when requested
249
+ if (shouldHighlight) {
250
+ const styledPath: SectionPath = { ...p };
251
+ if (highlight?.styles?.stroke !== undefined) {
252
+ styledPath.stroke = highlight.styles.stroke;
253
+ }
254
+ if (highlight?.styles?.strokeWidthMm !== undefined) {
255
+ styledPath.strokeWidth = highlight.styles.strokeWidthMm;
256
+ }
257
+ if (highlight?.styles?.strokeOpacity !== undefined) {
258
+ styledPath.strokeOpacity = highlight.styles.strokeOpacity;
259
+ }
260
+ if (highlight?.styles?.fill !== undefined) {
261
+ styledPath.fill = highlight.styles.fill;
262
+ }
263
+ if (highlight?.styles?.fillOpacity !== undefined) {
264
+ styledPath.fillOpacity = highlight.styles.fillOpacity;
265
+ }
266
+ styleMutated = true;
267
+ filteredPaths.push(styledPath);
268
+ } else {
269
+ filteredPaths.push(p);
270
+ }
271
+ });
272
+
273
+ const filteredLabels: SectionLabel[] = [];
274
+ (model.rawLabels ?? []).forEach((l: SectionLabel) => {
275
+ const rawMeta = getProp(l, 'meta');
276
+ const meta = resolveMeta(rawMeta, metadataById);
277
+ const cls = normalizeClass(getProp(meta, 'ifcClass'));
278
+ const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? null;
279
+ const productIdForMeta =
280
+ getProp(meta, 'productId') ??
281
+ getProp(meta, 'gid') ??
282
+ getProp(meta, 'id') ??
283
+ getProp(rawMeta, 'gid') ??
284
+ getProp(rawMeta, 'id') ??
285
+ null;
286
+ if (includeFilters && includeFilters.length > 0) {
287
+ if (!matchesIncludeFilter(includeFilters, cls, clsId, meta)) {
288
+ return;
289
+ }
290
+ }
291
+ if (matchesFilterClass(cls, clsId, null, effectiveFilterClasses)) {
292
+ const pid = productIdForMeta ? String(productIdForMeta) : null;
293
+ if (highlight?.gids && highlight.gids.size > 0) {
294
+ if (!pid || !highlight.gids.has(pid)) {
295
+ return;
296
+ }
297
+ }
298
+ if (allowedGids && allowedGids.size > 0) {
299
+ if (!pid || !allowedGids.has(pid)) {
300
+ return;
301
+ }
302
+ }
303
+ }
304
+ filteredLabels.push(l);
305
+ });
306
+
307
+ // Count paths by IFC class before and after filtering
308
+ const classCountsBefore = new Map<string, number>();
309
+ const classCountsAfter = new Map<string, number>();
310
+ const excluded: Array<{ cls: string | null; clsId: unknown }> = [];
311
+
312
+ model.rawPaths.forEach((p: SectionPath) => {
313
+ const rawMeta = getProp(p, 'meta');
314
+ const meta = resolveMeta(rawMeta, metadataById);
315
+ const metaFromMap = (() => {
316
+ const pid =
317
+ getProp(meta, 'productId') ??
318
+ getProp(meta, 'gid') ??
319
+ getProp(meta, 'id') ??
320
+ getProp(rawMeta, 'gid') ??
321
+ getProp(rawMeta, 'id');
322
+ return pid && metadataById ? metadataById[String(pid)] : null;
323
+ })();
324
+ const cls = normalizeClass(getProp(meta, 'ifcClass') ?? getProp(rawMeta, 'ifcClass') ?? metaFromMap?.ifcClass);
325
+ const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? metaFromMap?.ifcClassId ?? null;
326
+ const key = cls ?? String(clsId) ?? 'unknown';
327
+ classCountsBefore.set(key, (classCountsBefore.get(key) ?? 0) + 1);
328
+ });
329
+
330
+ filteredPaths.forEach((p: SectionPath) => {
331
+ const rawMeta = getProp(p, 'meta');
332
+ const meta = resolveMeta(rawMeta, metadataById);
333
+ const metaFromMap = (() => {
334
+ const pid =
335
+ getProp(meta, 'productId') ??
336
+ getProp(meta, 'gid') ??
337
+ getProp(meta, 'id') ??
338
+ getProp(rawMeta, 'gid') ??
339
+ getProp(rawMeta, 'id');
340
+ return pid && metadataById ? metadataById[String(pid)] : null;
341
+ })();
342
+ const cls = normalizeClass(getProp(meta, 'ifcClass') ?? getProp(rawMeta, 'ifcClass') ?? metaFromMap?.ifcClass);
343
+ const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? metaFromMap?.ifcClassId ?? null;
344
+ const key = cls ?? String(clsId) ?? 'unknown';
345
+ classCountsAfter.set(key, (classCountsAfter.get(key) ?? 0) + 1);
346
+ });
347
+
348
+ // Find excluded classes
349
+ classCountsBefore.forEach((countBefore, cls) => {
350
+ const countAfter = classCountsAfter.get(cls) ?? 0;
351
+ if (countAfter < countBefore) {
352
+ excluded.push({ cls, clsId: `${countBefore - countAfter} paths removed` });
353
+ }
354
+ });
355
+
356
+ if (
357
+ !styleMutated &&
358
+ filteredPaths.length === model.rawPaths.length &&
359
+ filteredLabels.length === (model.rawLabels?.length ?? 0)
360
+ ) {
361
+ return model;
362
+ }
363
+
364
+ const rebuilt = buildModelViewFromRaw(
365
+ filteredPaths,
366
+ filteredLabels,
367
+ model.transform,
368
+ model.geometryTransform,
369
+ model.labelStyle
370
+ );
371
+ return rebuilt ?? model;
372
+ };