@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,351 +1,351 @@
1
- import { filterModelForViewport } from './model';
2
- import { ModelMetadata } from './metadata';
3
- import { ViewportElement, SvgModelView, ComputedViewportScale } from './types';
4
- import { pushWarning } from './warnings';
5
- import { pickScale } from './viewportUtils';
6
- import type { ResolvedPresets } from './presets';
7
- import { getPathValue } from './renderUtils';
8
- import { pointInPolygon, offsetPolygons, Polygon } from './clipUtils';
9
- import { matchesV2Filter, normalizeClass } from './filters';
10
- import { EPSILON_MIN_DIMENSION } from './geometryConstants';
11
-
12
- export interface ViewportPrepResult {
13
- viewportScales: Record<string, ComputedViewportScale>;
14
- viewportClipPolygonsLocal: Record<string, Array<Array<{ x: number; y: number }>>>;
15
- viewportClipPolygonsWorld: Record<string, Array<Array<{ x: number; z: number }>>>;
16
- viewportClipBounds: Record<
17
- string,
18
- { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number }
19
- >;
20
- viewportClipLabels: Record<string, boolean>;
21
- viewportClipStyles: Record<
22
- string,
23
- { stroke?: string; strokeWidthMm?: number; strokeOpacity?: number; fill?: string; fillOpacity?: number }
24
- >;
25
- viewportClipWhere: Record<string, WhereCondition | undefined>;
26
- viewportHighlight: Record<
27
- string,
28
- {
29
- gids: Set<string> | null;
30
- styles: {
31
- stroke?: string;
32
- strokeWidthMm?: number;
33
- strokeOpacity?: number;
34
- fill?: string;
35
- fillOpacity?: number;
36
- };
37
- }
38
- >;
39
- filteredModelsByViewport: Record<string, SvgModelView>;
40
- }
41
-
42
- import { isRecord, getProp } from './typeGuards';
43
- import type { WhereCondition } from './types';
44
-
45
- export const prepareViewports = (
46
- viewports: ViewportElement[],
47
- models: Record<string, SvgModelView | undefined>,
48
- data: unknown,
49
- preferredScales: number[],
50
- presets: ResolvedPresets,
51
- options: { metadataById?: unknown; warnings: string[] }
52
- ): ViewportPrepResult => {
53
- const viewportScales: Record<string, ComputedViewportScale> = {};
54
- const viewportClipPolygonsLocal: Record<string, Array<Array<{ x: number; y: number }>>> = {};
55
- const viewportClipPolygonsWorld: Record<string, Array<Array<{ x: number; z: number }>>> = {};
56
- const viewportClipBounds: Record<
57
- string,
58
- { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number }
59
- > = {};
60
- const viewportClipLabels: Record<string, boolean> = {};
61
- const viewportClipStyles: Record<
62
- string,
63
- { stroke?: string; strokeWidthMm?: number; strokeOpacity?: number; fill?: string; fillOpacity?: number }
64
- > = {};
65
- const viewportClipWhere: Record<string, WhereCondition | undefined> = {};
66
- const viewportHighlight: ViewportPrepResult['viewportHighlight'] = {};
67
- const filteredModelsByViewport: Record<string, SvgModelView> = {};
68
- const scales = presets.scales;
69
-
70
- viewports.forEach((vp) => {
71
- const baseModel = models[vp.id];
72
- let clipAllowedGids: Set<string> | null = null;
73
-
74
- // Highlight: optional class+where filter on normalized items
75
- let highlightGids: Set<string> | null = null;
76
- const hlCfg = vp.highlight;
77
- if (hlCfg && hlCfg.where) {
78
- const sourceItems = getPathValue(data, `viewports.${vp.id}.items`);
79
- if (Array.isArray(sourceItems) && sourceItems.length) {
80
- const matching = sourceItems.filter((it) => matchesV2Filter(it, hlCfg.class, hlCfg.where));
81
- if (matching.length) {
82
- highlightGids = new Set(
83
- matching
84
- .map((it: unknown) => getProp(it, 'gid') ?? getProp(it, 'id') ?? null)
85
- .filter((v: unknown) => v !== null && v !== undefined)
86
- .map((v: unknown) => String(v))
87
- );
88
-
89
- // Intersect with referenced viewport if specified
90
- if (hlCfg.intersectsViewport) {
91
- const refItems = getPathValue(data, `viewports.${hlCfg.intersectsViewport}.items`);
92
- if (Array.isArray(refItems) && refItems.length) {
93
- const refGids = new Set(
94
- refItems
95
- .map((it: unknown) => getProp(it, 'gid') ?? getProp(it, 'id') ?? null)
96
- .filter((v: unknown) => v !== null && v !== undefined)
97
- .map((v: unknown) => String(v))
98
- );
99
- // Keep only items that appear in both viewports
100
- const intersection = new Set<string>();
101
- highlightGids.forEach((gid) => {
102
- if (refGids.has(gid)) intersection.add(gid);
103
- });
104
- highlightGids = intersection;
105
- } else {
106
- pushWarning(
107
- options.warnings,
108
- `Viewport '${vp.id}' highlight intersectsViewport="${hlCfg.intersectsViewport}": referenced viewport has no items.`
109
- );
110
- highlightGids = new Set(); // No items in referenced viewport = no highlights
111
- }
112
- }
113
- } else {
114
- highlightGids = new Set(); // explicit no matches
115
- pushWarning(
116
- options.warnings,
117
- `Viewport '${vp.id}' highlight filter matched 0 of ${sourceItems.length} items.`
118
- );
119
- }
120
- } else if (Array.isArray(sourceItems) && !sourceItems.length) {
121
- pushWarning(options.warnings, `Viewport '${vp.id}' highlight: no items available to filter.`);
122
- }
123
- }
124
-
125
- // Clip: class+where filter — find matching items, extract their polylines as clip polygons
126
- let clipEffectiveWidthModel: number | undefined;
127
- let clipEffectiveHeightModel: number | undefined;
128
- const clipCfg = vp.clip;
129
- if (clipCfg && clipCfg.where && baseModel?.transform) {
130
- viewportClipWhere[vp.id] = clipCfg.where;
131
- const sourceItems = getPathValue(data, `viewports.${vp.id}.items`);
132
- if (Array.isArray(sourceItems) && sourceItems.length) {
133
- const matchingItems = sourceItems.filter((it) => matchesV2Filter(it, clipCfg.class, clipCfg.where));
134
- if (!matchingItems.length) {
135
- pushWarning(
136
- options.warnings,
137
- `Viewport '${vp.id}' clip filter matched 0 of ${sourceItems.length} items.`
138
- );
139
- }
140
-
141
- // Extract polylines from matched items
142
- const allPolylines: Array<{ points?: Array<{ x: number; z: number }> }> = [];
143
- for (const item of matchingItems) {
144
- const polylines = getProp(item, 'polylines');
145
- if (Array.isArray(polylines)) {
146
- for (const pl of polylines) {
147
- if (isRecord(pl)) allPolylines.push(pl as { points?: Array<{ x: number; z: number }> });
148
- }
149
- }
150
- }
151
-
152
- if (allPolylines.length) {
153
- // Project polyline points from world (x/z) to model view space
154
- const projectedLocal: Polygon[] = [];
155
- for (const poly of allPolylines) {
156
- const pts = poly.points;
157
- if (!Array.isArray(pts) || pts.length < 3) continue;
158
- const mapped: Polygon = [];
159
- for (const pt of pts) {
160
- const px = pt?.x;
161
- const pz = pt?.z;
162
- if (!Number.isFinite(px) || !Number.isFinite(pz)) continue;
163
- mapped.push({
164
- x: px - baseModel.transform!.viewMinX,
165
- y: baseModel.transform!.viewMaxZ - pz
166
- });
167
- }
168
- if (mapped.length >= 3) projectedLocal.push(mapped);
169
- }
170
- const expandByM =
171
- typeof clipCfg.expandByM === 'number' &&
172
- Number.isFinite(clipCfg.expandByM) &&
173
- clipCfg.expandByM > 0
174
- ? clipCfg.expandByM
175
- : 0;
176
- const buffered = expandByM > 0 ? offsetPolygons(projectedLocal, expandByM) : projectedLocal;
177
-
178
- if (buffered.length) {
179
- // Calculate bounds from EXPANDED polygon for viewport sizing (so everything fits)
180
- // Fill filtering (clipAllowedGids) uses ORIGINAL polygon from worldPolygons (line 239)
181
- const boundsSource = buffered; // Use EXPANDED for viewport sizing
182
- const flat = boundsSource.flat();
183
- let minX = Infinity,
184
- maxX = -Infinity,
185
- minY = Infinity,
186
- maxY = -Infinity;
187
- for (let i = 0; i < flat.length; i++) {
188
- const p = flat[i];
189
- if (p.x < minX) minX = p.x;
190
- if (p.x > maxX) maxX = p.x;
191
- if (p.y < minY) minY = p.y;
192
- if (p.y > maxY) maxY = p.y;
193
- }
194
- clipEffectiveWidthModel = Math.max(EPSILON_MIN_DIMENSION, maxX - minX);
195
- clipEffectiveHeightModel = Math.max(EPSILON_MIN_DIMENSION, maxY - minY);
196
- viewportClipPolygonsLocal[vp.id] = buffered;
197
- viewportClipLabels[vp.id] = !!clipCfg.clipLabels;
198
- viewportClipStyles[vp.id] = {
199
- stroke: clipCfg.stroke ?? '#000000',
200
- strokeWidthMm: clipCfg.strokeWidthMm ?? 0.5,
201
- strokeOpacity: clipCfg.strokeOpacity ?? 1,
202
- fill: clipCfg.fill ?? 'none',
203
- fillOpacity: clipCfg.fillOpacity ?? 0
204
- };
205
- viewportClipBounds[vp.id] = {
206
- minX,
207
- maxX,
208
- minY,
209
- maxY,
210
- width: clipEffectiveWidthModel,
211
- height: clipEffectiveHeightModel
212
- };
213
-
214
- // Build clipAllowedGids: include items whose center falls inside the clip polygons
215
- // Polygon is in XZ coordinates (horizontal plane), Y is vertical (plane height)
216
- const worldPolygons = allPolylines
217
- .filter((pl) => Array.isArray(pl.points) && pl.points.length >= 3)
218
- .map((pl) => pl.points!.map((p) => ({ x: p.x, z: p.z })));
219
- if (worldPolygons.length) {
220
- viewportClipPolygonsWorld[vp.id] = worldPolygons;
221
- clipAllowedGids = new Set<string>();
222
- for (const item of sourceItems) {
223
- // Use item's direct x/z properties (same approach as labels)
224
- const ix = getProp(item, 'x');
225
- const iz = getProp(item, 'z');
226
-
227
- if (!Number.isFinite(ix) || !Number.isFinite(iz)) {
228
- continue;
229
- }
230
- // Convert polygon {x,z} to {x,y} for pointInPolygon (same as labels do)
231
- const inside = worldPolygons.some((poly) => {
232
- const polyWith2D = poly.map((p) => ({ x: p.x, y: p.z }));
233
- return pointInPolygon(polyWith2D, ix as number, iz as number);
234
- });
235
- if (inside) {
236
- const gid = getProp(item, 'gid') ?? getProp(item, 'id');
237
- if (gid !== null && gid !== undefined) {
238
- clipAllowedGids.add(String(gid));
239
- }
240
- }
241
- }
242
- }
243
- }
244
- }
245
- }
246
- }
247
-
248
- // Effective allowed set: if highlight defined, use highlight set; else clipAllowed; if both exist, intersect.
249
- // IMPORTANT: Store empty Set (not null) when clip filtering is active but finds no matches
250
- let effectiveAllowed: Set<string> | null = null;
251
- if (highlightGids && highlightGids.size > 0 && clipAllowedGids) {
252
- // Both highlight and clip active - intersect them (even if clipAllowedGids is empty)
253
- const intersection = new Set<string>();
254
- highlightGids.forEach((gid) => {
255
- if (clipAllowedGids!.has(gid)) intersection.add(gid);
256
- });
257
- effectiveAllowed = intersection;
258
- } else if (highlightGids && highlightGids.size > 0) {
259
- effectiveAllowed = highlightGids;
260
- } else if (clipAllowedGids !== null) {
261
- // Clip filtering is active - use clipAllowedGids even if empty (means zero items allowed)
262
- effectiveAllowed = clipAllowedGids;
263
- }
264
-
265
- const hlStyles = hlCfg
266
- ? {
267
- stroke: typeof hlCfg.stroke === 'string' ? hlCfg.stroke : '#ff0000',
268
- strokeWidthMm: typeof hlCfg.strokeWidthMm === 'number' ? hlCfg.strokeWidthMm : 0.5,
269
- strokeOpacity: typeof hlCfg.strokeOpacity === 'number' ? hlCfg.strokeOpacity : 1,
270
- fill: typeof hlCfg.fill === 'string' ? hlCfg.fill : '#ffff00',
271
- fillOpacity: typeof hlCfg.fillOpacity === 'number' ? hlCfg.fillOpacity : 0.5
272
- }
273
- : {
274
- stroke: undefined,
275
- strokeWidthMm: undefined,
276
- strokeOpacity: undefined,
277
- fill: undefined,
278
- fillOpacity: undefined
279
- };
280
-
281
- // Build the set of classes subject to GID-based filtering from highlight/clip configs
282
- const filterClasses = new Set<string>();
283
- const addFilterClasses = (cls: string | string[] | undefined): void => {
284
- if (!cls) return;
285
- const classes = Array.isArray(cls) ? cls : [cls];
286
- for (const c of classes) {
287
- const normalized = normalizeClass(c);
288
- if (normalized) filterClasses.add(normalized);
289
- }
290
- };
291
- addFilterClasses(hlCfg?.class);
292
- addFilterClasses(clipCfg?.class);
293
-
294
- const model = baseModel
295
- ? filterModelForViewport(
296
- baseModel,
297
- vp,
298
- options?.metadataById as ModelMetadata | undefined,
299
- presets.includeClasses,
300
- effectiveAllowed,
301
- {
302
- gids: highlightGids,
303
- styles: hlStyles
304
- },
305
- filterClasses.size > 0 ? filterClasses : null
306
- )
307
- : null;
308
- viewportHighlight[vp.id] = {
309
- gids: effectiveAllowed, // Use effectiveAllowed (includes both clip and highlight GIDs)
310
- styles: hlStyles
311
- };
312
- if (!model) {
313
- pushWarning(options.warnings, `Missing model for viewport '${vp.id}'.`);
314
- return;
315
- }
316
- filteredModelsByViewport[vp.id] = model;
317
- const effectiveWidthModel = clipEffectiveWidthModel ?? model.widthModel;
318
- const effectiveHeightModel = clipEffectiveHeightModel ?? model.heightModel;
319
-
320
- const scale = pickScale(
321
- vp,
322
- { ...model, widthModel: effectiveWidthModel, heightModel: effectiveHeightModel },
323
- preferredScales,
324
- scales
325
- );
326
- viewportScales[vp.id] = scale;
327
- viewportClipBounds[vp.id] = viewportClipBounds[vp.id] ?? {
328
- minX: model.transform?.viewMinX ?? 0,
329
- maxX: model.transform ? model.transform.viewMinX + effectiveWidthModel : effectiveWidthModel,
330
- minY: model.transform?.viewMinZ ?? 0,
331
- maxY: model.transform ? model.transform.viewMinZ + effectiveHeightModel : effectiveHeightModel,
332
- width: effectiveWidthModel,
333
- height: effectiveHeightModel
334
- };
335
- if (!viewportClipPolygonsLocal[vp.id]) {
336
- viewportClipPolygonsLocal[vp.id] = [];
337
- }
338
- });
339
-
340
- return {
341
- viewportScales,
342
- viewportClipPolygonsLocal,
343
- viewportClipPolygonsWorld,
344
- viewportClipBounds,
345
- viewportClipLabels,
346
- viewportClipStyles,
347
- viewportClipWhere,
348
- viewportHighlight,
349
- filteredModelsByViewport
350
- };
351
- };
1
+ import { filterModelForViewport } from './model';
2
+ import { ModelMetadata } from './metadata';
3
+ import { ViewportElement, SvgModelView, ComputedViewportScale } from './types';
4
+ import { pushWarning } from './warnings';
5
+ import { pickScale } from './viewportUtils';
6
+ import type { ResolvedPresets } from './presets';
7
+ import { getPathValue } from './renderUtils';
8
+ import { pointInPolygon, offsetPolygons, Polygon } from './clipUtils';
9
+ import { matchesV2Filter, normalizeClass } from './filters';
10
+ import { EPSILON_MIN_DIMENSION } from './geometryConstants';
11
+
12
+ export interface ViewportPrepResult {
13
+ viewportScales: Record<string, ComputedViewportScale>;
14
+ viewportClipPolygonsLocal: Record<string, Array<Array<{ x: number; y: number }>>>;
15
+ viewportClipPolygonsWorld: Record<string, Array<Array<{ x: number; z: number }>>>;
16
+ viewportClipBounds: Record<
17
+ string,
18
+ { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number }
19
+ >;
20
+ viewportClipLabels: Record<string, boolean>;
21
+ viewportClipStyles: Record<
22
+ string,
23
+ { stroke?: string; strokeWidthMm?: number; strokeOpacity?: number; fill?: string; fillOpacity?: number }
24
+ >;
25
+ viewportClipWhere: Record<string, WhereCondition | undefined>;
26
+ viewportHighlight: Record<
27
+ string,
28
+ {
29
+ gids: Set<string> | null;
30
+ styles: {
31
+ stroke?: string;
32
+ strokeWidthMm?: number;
33
+ strokeOpacity?: number;
34
+ fill?: string;
35
+ fillOpacity?: number;
36
+ };
37
+ }
38
+ >;
39
+ filteredModelsByViewport: Record<string, SvgModelView>;
40
+ }
41
+
42
+ import { isRecord, getProp } from './typeGuards';
43
+ import type { WhereCondition } from './types';
44
+
45
+ export const prepareViewports = (
46
+ viewports: ViewportElement[],
47
+ models: Record<string, SvgModelView | undefined>,
48
+ data: unknown,
49
+ preferredScales: number[],
50
+ presets: ResolvedPresets,
51
+ options: { metadataById?: unknown; warnings: string[] }
52
+ ): ViewportPrepResult => {
53
+ const viewportScales: Record<string, ComputedViewportScale> = {};
54
+ const viewportClipPolygonsLocal: Record<string, Array<Array<{ x: number; y: number }>>> = {};
55
+ const viewportClipPolygonsWorld: Record<string, Array<Array<{ x: number; z: number }>>> = {};
56
+ const viewportClipBounds: Record<
57
+ string,
58
+ { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number }
59
+ > = {};
60
+ const viewportClipLabels: Record<string, boolean> = {};
61
+ const viewportClipStyles: Record<
62
+ string,
63
+ { stroke?: string; strokeWidthMm?: number; strokeOpacity?: number; fill?: string; fillOpacity?: number }
64
+ > = {};
65
+ const viewportClipWhere: Record<string, WhereCondition | undefined> = {};
66
+ const viewportHighlight: ViewportPrepResult['viewportHighlight'] = {};
67
+ const filteredModelsByViewport: Record<string, SvgModelView> = {};
68
+ const scales = presets.scales;
69
+
70
+ viewports.forEach((vp) => {
71
+ const baseModel = models[vp.id];
72
+ let clipAllowedGids: Set<string> | null = null;
73
+
74
+ // Highlight: optional class+where filter on normalized items
75
+ let highlightGids: Set<string> | null = null;
76
+ const hlCfg = vp.highlight;
77
+ if (hlCfg && hlCfg.where) {
78
+ const sourceItems = getPathValue(data, `viewports.${vp.id}.items`);
79
+ if (Array.isArray(sourceItems) && sourceItems.length) {
80
+ const matching = sourceItems.filter((it) => matchesV2Filter(it, hlCfg.class, hlCfg.where));
81
+ if (matching.length) {
82
+ highlightGids = new Set(
83
+ matching
84
+ .map((it: unknown) => getProp(it, 'gid') ?? getProp(it, 'id') ?? null)
85
+ .filter((v: unknown) => v !== null && v !== undefined)
86
+ .map((v: unknown) => String(v))
87
+ );
88
+
89
+ // Intersect with referenced viewport if specified
90
+ if (hlCfg.intersectsViewport) {
91
+ const refItems = getPathValue(data, `viewports.${hlCfg.intersectsViewport}.items`);
92
+ if (Array.isArray(refItems) && refItems.length) {
93
+ const refGids = new Set(
94
+ refItems
95
+ .map((it: unknown) => getProp(it, 'gid') ?? getProp(it, 'id') ?? null)
96
+ .filter((v: unknown) => v !== null && v !== undefined)
97
+ .map((v: unknown) => String(v))
98
+ );
99
+ // Keep only items that appear in both viewports
100
+ const intersection = new Set<string>();
101
+ highlightGids.forEach((gid) => {
102
+ if (refGids.has(gid)) intersection.add(gid);
103
+ });
104
+ highlightGids = intersection;
105
+ } else {
106
+ pushWarning(
107
+ options.warnings,
108
+ `Viewport '${vp.id}' highlight intersectsViewport="${hlCfg.intersectsViewport}": referenced viewport has no items.`
109
+ );
110
+ highlightGids = new Set(); // No items in referenced viewport = no highlights
111
+ }
112
+ }
113
+ } else {
114
+ highlightGids = new Set(); // explicit no matches
115
+ pushWarning(
116
+ options.warnings,
117
+ `Viewport '${vp.id}' highlight filter matched 0 of ${sourceItems.length} items.`
118
+ );
119
+ }
120
+ } else if (Array.isArray(sourceItems) && !sourceItems.length) {
121
+ pushWarning(options.warnings, `Viewport '${vp.id}' highlight: no items available to filter.`);
122
+ }
123
+ }
124
+
125
+ // Clip: class+where filter — find matching items, extract their polylines as clip polygons
126
+ let clipEffectiveWidthModel: number | undefined;
127
+ let clipEffectiveHeightModel: number | undefined;
128
+ const clipCfg = vp.clip;
129
+ if (clipCfg && clipCfg.where && baseModel?.transform) {
130
+ viewportClipWhere[vp.id] = clipCfg.where;
131
+ const sourceItems = getPathValue(data, `viewports.${vp.id}.items`);
132
+ if (Array.isArray(sourceItems) && sourceItems.length) {
133
+ const matchingItems = sourceItems.filter((it) => matchesV2Filter(it, clipCfg.class, clipCfg.where));
134
+ if (!matchingItems.length) {
135
+ pushWarning(
136
+ options.warnings,
137
+ `Viewport '${vp.id}' clip filter matched 0 of ${sourceItems.length} items.`
138
+ );
139
+ }
140
+
141
+ // Extract polylines from matched items
142
+ const allPolylines: Array<{ points?: Array<{ x: number; z: number }> }> = [];
143
+ for (const item of matchingItems) {
144
+ const polylines = getProp(item, 'polylines');
145
+ if (Array.isArray(polylines)) {
146
+ for (const pl of polylines) {
147
+ if (isRecord(pl)) allPolylines.push(pl as { points?: Array<{ x: number; z: number }> });
148
+ }
149
+ }
150
+ }
151
+
152
+ if (allPolylines.length) {
153
+ // Project polyline points from world (x/z) to model view space
154
+ const projectedLocal: Polygon[] = [];
155
+ for (const poly of allPolylines) {
156
+ const pts = poly.points;
157
+ if (!Array.isArray(pts) || pts.length < 3) continue;
158
+ const mapped: Polygon = [];
159
+ for (const pt of pts) {
160
+ const px = pt?.x;
161
+ const pz = pt?.z;
162
+ if (!Number.isFinite(px) || !Number.isFinite(pz)) continue;
163
+ mapped.push({
164
+ x: px - baseModel.transform!.viewMinX,
165
+ y: baseModel.transform!.viewMaxZ - pz
166
+ });
167
+ }
168
+ if (mapped.length >= 3) projectedLocal.push(mapped);
169
+ }
170
+ const expandByM =
171
+ typeof clipCfg.expandByM === 'number' &&
172
+ Number.isFinite(clipCfg.expandByM) &&
173
+ clipCfg.expandByM > 0
174
+ ? clipCfg.expandByM
175
+ : 0;
176
+ const buffered = expandByM > 0 ? offsetPolygons(projectedLocal, expandByM) : projectedLocal;
177
+
178
+ if (buffered.length) {
179
+ // Calculate bounds from EXPANDED polygon for viewport sizing (so everything fits)
180
+ // Fill filtering (clipAllowedGids) uses ORIGINAL polygon from worldPolygons (line 239)
181
+ const boundsSource = buffered; // Use EXPANDED for viewport sizing
182
+ const flat = boundsSource.flat();
183
+ let minX = Infinity,
184
+ maxX = -Infinity,
185
+ minY = Infinity,
186
+ maxY = -Infinity;
187
+ for (let i = 0; i < flat.length; i++) {
188
+ const p = flat[i];
189
+ if (p.x < minX) minX = p.x;
190
+ if (p.x > maxX) maxX = p.x;
191
+ if (p.y < minY) minY = p.y;
192
+ if (p.y > maxY) maxY = p.y;
193
+ }
194
+ clipEffectiveWidthModel = Math.max(EPSILON_MIN_DIMENSION, maxX - minX);
195
+ clipEffectiveHeightModel = Math.max(EPSILON_MIN_DIMENSION, maxY - minY);
196
+ viewportClipPolygonsLocal[vp.id] = buffered;
197
+ viewportClipLabels[vp.id] = !!clipCfg.clipLabels;
198
+ viewportClipStyles[vp.id] = {
199
+ stroke: clipCfg.stroke ?? '#000000',
200
+ strokeWidthMm: clipCfg.strokeWidthMm ?? 0.5,
201
+ strokeOpacity: clipCfg.strokeOpacity ?? 1,
202
+ fill: clipCfg.fill ?? 'none',
203
+ fillOpacity: clipCfg.fillOpacity ?? 0
204
+ };
205
+ viewportClipBounds[vp.id] = {
206
+ minX,
207
+ maxX,
208
+ minY,
209
+ maxY,
210
+ width: clipEffectiveWidthModel,
211
+ height: clipEffectiveHeightModel
212
+ };
213
+
214
+ // Build clipAllowedGids: include items whose center falls inside the clip polygons
215
+ // Polygon is in XZ coordinates (horizontal plane), Y is vertical (plane height)
216
+ const worldPolygons = allPolylines
217
+ .filter((pl) => Array.isArray(pl.points) && pl.points.length >= 3)
218
+ .map((pl) => pl.points!.map((p) => ({ x: p.x, z: p.z })));
219
+ if (worldPolygons.length) {
220
+ viewportClipPolygonsWorld[vp.id] = worldPolygons;
221
+ clipAllowedGids = new Set<string>();
222
+ for (const item of sourceItems) {
223
+ // Use item's direct x/z properties (same approach as labels)
224
+ const ix = getProp(item, 'x');
225
+ const iz = getProp(item, 'z');
226
+
227
+ if (!Number.isFinite(ix) || !Number.isFinite(iz)) {
228
+ continue;
229
+ }
230
+ // Convert polygon {x,z} to {x,y} for pointInPolygon (same as labels do)
231
+ const inside = worldPolygons.some((poly) => {
232
+ const polyWith2D = poly.map((p) => ({ x: p.x, y: p.z }));
233
+ return pointInPolygon(polyWith2D, ix as number, iz as number);
234
+ });
235
+ if (inside) {
236
+ const gid = getProp(item, 'gid') ?? getProp(item, 'id');
237
+ if (gid !== null && gid !== undefined) {
238
+ clipAllowedGids.add(String(gid));
239
+ }
240
+ }
241
+ }
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ // Effective allowed set: if highlight defined, use highlight set; else clipAllowed; if both exist, intersect.
249
+ // IMPORTANT: Store empty Set (not null) when clip filtering is active but finds no matches
250
+ let effectiveAllowed: Set<string> | null = null;
251
+ if (highlightGids && highlightGids.size > 0 && clipAllowedGids) {
252
+ // Both highlight and clip active - intersect them (even if clipAllowedGids is empty)
253
+ const intersection = new Set<string>();
254
+ highlightGids.forEach((gid) => {
255
+ if (clipAllowedGids!.has(gid)) intersection.add(gid);
256
+ });
257
+ effectiveAllowed = intersection;
258
+ } else if (highlightGids && highlightGids.size > 0) {
259
+ effectiveAllowed = highlightGids;
260
+ } else if (clipAllowedGids !== null) {
261
+ // Clip filtering is active - use clipAllowedGids even if empty (means zero items allowed)
262
+ effectiveAllowed = clipAllowedGids;
263
+ }
264
+
265
+ const hlStyles = hlCfg
266
+ ? {
267
+ stroke: typeof hlCfg.stroke === 'string' ? hlCfg.stroke : '#ff0000',
268
+ strokeWidthMm: typeof hlCfg.strokeWidthMm === 'number' ? hlCfg.strokeWidthMm : 0.5,
269
+ strokeOpacity: typeof hlCfg.strokeOpacity === 'number' ? hlCfg.strokeOpacity : 1,
270
+ fill: typeof hlCfg.fill === 'string' ? hlCfg.fill : '#ffff00',
271
+ fillOpacity: typeof hlCfg.fillOpacity === 'number' ? hlCfg.fillOpacity : 0.5
272
+ }
273
+ : {
274
+ stroke: undefined,
275
+ strokeWidthMm: undefined,
276
+ strokeOpacity: undefined,
277
+ fill: undefined,
278
+ fillOpacity: undefined
279
+ };
280
+
281
+ // Build the set of classes subject to GID-based filtering from highlight/clip configs
282
+ const filterClasses = new Set<string>();
283
+ const addFilterClasses = (cls: string | string[] | undefined): void => {
284
+ if (!cls) return;
285
+ const classes = Array.isArray(cls) ? cls : [cls];
286
+ for (const c of classes) {
287
+ const normalized = normalizeClass(c);
288
+ if (normalized) filterClasses.add(normalized);
289
+ }
290
+ };
291
+ addFilterClasses(hlCfg?.class);
292
+ addFilterClasses(clipCfg?.class);
293
+
294
+ const model = baseModel
295
+ ? filterModelForViewport(
296
+ baseModel,
297
+ vp,
298
+ options?.metadataById as ModelMetadata | undefined,
299
+ presets.includeClasses,
300
+ effectiveAllowed,
301
+ {
302
+ gids: highlightGids,
303
+ styles: hlStyles
304
+ },
305
+ filterClasses.size > 0 ? filterClasses : null
306
+ )
307
+ : null;
308
+ viewportHighlight[vp.id] = {
309
+ gids: effectiveAllowed, // Use effectiveAllowed (includes both clip and highlight GIDs)
310
+ styles: hlStyles
311
+ };
312
+ if (!model) {
313
+ pushWarning(options.warnings, `Missing model for viewport '${vp.id}'.`);
314
+ return;
315
+ }
316
+ filteredModelsByViewport[vp.id] = model;
317
+ const effectiveWidthModel = clipEffectiveWidthModel ?? model.widthModel;
318
+ const effectiveHeightModel = clipEffectiveHeightModel ?? model.heightModel;
319
+
320
+ const scale = pickScale(
321
+ vp,
322
+ { ...model, widthModel: effectiveWidthModel, heightModel: effectiveHeightModel },
323
+ preferredScales,
324
+ scales
325
+ );
326
+ viewportScales[vp.id] = scale;
327
+ viewportClipBounds[vp.id] = viewportClipBounds[vp.id] ?? {
328
+ minX: model.transform?.viewMinX ?? 0,
329
+ maxX: model.transform ? model.transform.viewMinX + effectiveWidthModel : effectiveWidthModel,
330
+ minY: model.transform?.viewMinZ ?? 0,
331
+ maxY: model.transform ? model.transform.viewMinZ + effectiveHeightModel : effectiveHeightModel,
332
+ width: effectiveWidthModel,
333
+ height: effectiveHeightModel
334
+ };
335
+ if (!viewportClipPolygonsLocal[vp.id]) {
336
+ viewportClipPolygonsLocal[vp.id] = [];
337
+ }
338
+ });
339
+
340
+ return {
341
+ viewportScales,
342
+ viewportClipPolygonsLocal,
343
+ viewportClipPolygonsWorld,
344
+ viewportClipBounds,
345
+ viewportClipLabels,
346
+ viewportClipStyles,
347
+ viewportClipWhere,
348
+ viewportHighlight,
349
+ filteredModelsByViewport
350
+ };
351
+ };