@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,442 +1,442 @@
1
- import { escapeAttr, getPathValue, resolvePath } from './renderUtils';
2
- import { rotateBounds, pickScale } from './viewportUtils';
3
- import { ViewportRenderInfo } from './labelsRender';
4
- import { matchesV2Filter } from './filters';
5
- import { ComputedViewportScale, SvgModelView, ViewportElement, AssetSpec } from './types';
6
- import { isRecord, getProp } from './typeGuards';
7
-
8
- type PolyPoint = { x: number; y: number };
9
- // Debug overlay for clip polygons. Keep false for normal builds.
10
- const DEBUG_CLIP_OVERLAY = false;
11
-
12
- type Mat = [number, number, number, number, number, number]; // a b c d e f
13
-
14
- const matTranslate = (tx: number, ty: number): Mat => [1, 0, 0, 1, tx, ty];
15
- const matScale = (sx: number, sy: number): Mat => [sx, 0, 0, sy, 0, 0];
16
- const matRotateDeg = (deg: number): Mat => {
17
- const rad = (deg * Math.PI) / 180;
18
- const c = Math.cos(rad);
19
- const s = Math.sin(rad);
20
- return [c, s, -s, c, 0, 0];
21
- };
22
- const matMultiply = (m1: Mat, m2: Mat): Mat => {
23
- const [a1, b1, c1, d1, e1, f1] = m1;
24
- const [a2, b2, c2, d2, e2, f2] = m2;
25
- return [
26
- a1 * a2 + c1 * b2,
27
- b1 * a2 + d1 * b2,
28
- a1 * c2 + c1 * d2,
29
- b1 * c2 + d1 * d2,
30
- a1 * e2 + c1 * f2 + e1,
31
- b1 * e2 + d1 * f2 + f1
32
- ];
33
- };
34
- const matApply = (m: Mat, p: PolyPoint): PolyPoint => {
35
- const [a, b, c, d, e, f] = m;
36
- return { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f };
37
- };
38
- export const renderViewportElement = (
39
- vp: ViewportElement,
40
- baseModel: SvgModelView | null,
41
- dataWithViewport: any,
42
- preferredScales: number[],
43
- scales: Record<string, { preferredScales?: number[] }>,
44
- assetMap: Map<string, AssetSpec>,
45
- viewportScales: Record<string, ComputedViewportScale>,
46
- viewportClipPolygonsLocal: Record<string, Array<Array<PolyPoint>>>,
47
- viewportClipBounds: Record<
48
- string,
49
- { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number }
50
- >,
51
- viewportClipLabels: Record<string, boolean>,
52
- viewportClipStyles: Record<
53
- string,
54
- { stroke?: string; strokeWidthMm?: number; strokeOpacity?: number; fill?: string; fillOpacity?: number }
55
- >,
56
- viewportHighlight: Record<string, { gids: Set<string> | null; styles: any }>,
57
- viewportRenderInfo: Record<string, ViewportRenderInfo>,
58
- warnings: string[],
59
- legendColorMaps?: Map<string, Map<string, string>>,
60
- precomputed?: {
61
- effectiveWidthModel?: number;
62
- effectiveHeightModel?: number;
63
- clipPolygonsMm?: Array<Array<PolyPoint>>;
64
- clipPolygonsLocal?: Array<Array<PolyPoint>>;
65
- clipBounds?: { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number };
66
- renderCenterModelX?: number;
67
- renderCenterModelY?: number;
68
- }
69
- ): string[] => {
70
- const parts: string[] = [];
71
- if (!baseModel) {
72
- // Warning already pushed by viewportPrep; avoid duplicate.
73
- return parts;
74
- }
75
-
76
- const model = baseModel;
77
- let renderWidthModel = precomputed?.effectiveWidthModel ?? model.widthModel;
78
- let renderHeightModel = precomputed?.effectiveHeightModel ?? model.heightModel;
79
- let renderCenterModelX = precomputed?.renderCenterModelX ?? renderWidthModel * 0.5;
80
- let renderCenterModelY = precomputed?.renderCenterModelY ?? renderHeightModel * 0.5;
81
-
82
- const clipBounds = viewportClipBounds[vp.id];
83
- const clipLocal = viewportClipPolygonsLocal[vp.id];
84
- const hasClip = !!clipBounds && Array.isArray(clipLocal) && clipLocal.length > 0;
85
- if (hasClip) {
86
- renderWidthModel = clipBounds!.width;
87
- renderHeightModel = clipBounds!.height;
88
- renderCenterModelX = (clipBounds!.minX + clipBounds!.maxX) * 0.5;
89
- renderCenterModelY = (clipBounds!.minY + clipBounds!.maxY) * 0.5;
90
- }
91
-
92
- const scaleInfo =
93
- viewportScales[vp.id] ??
94
- pickScale(
95
- vp,
96
- { ...model, widthModel: renderWidthModel, heightModel: renderHeightModel },
97
- preferredScales,
98
- scales
99
- );
100
- const rotationDeg = vp.rotationDeg ?? 0;
101
- const mmPerModel = scaleInfo.mmPerModelUnit;
102
- const contentWidthMm = renderWidthModel * mmPerModel;
103
- const contentHeightMm = renderHeightModel * mmPerModel;
104
- const rotated = rotateBounds(contentWidthMm, contentHeightMm, rotationDeg);
105
-
106
- const centerX = vp.align === 'topLeft' ? vp.xMm + rotated.width / 2 : vp.xMm + vp.widthMm / 2;
107
- const centerY = vp.align === 'topLeft' ? vp.yMm + rotated.height / 2 : vp.yMm + vp.heightMm / 2;
108
-
109
- const contentOffsetX = vp.align === 'topLeft' ? vp.xMm : vp.xMm + Math.max(0, (vp.widthMm - rotated.width) * 0.5);
110
- const contentOffsetY = vp.align === 'topLeft' ? vp.yMm : vp.yMm + Math.max(0, (vp.heightMm - rotated.height) * 0.5);
111
-
112
- const clipPolysLocal = clipLocal;
113
- let clipId: string | null = null;
114
- const clipDebugPolys: string[] = [];
115
- const clipStyle = viewportClipStyles[vp.id];
116
- const clipStroke = clipStyle?.stroke;
117
- const clipStrokeWidth = clipStyle?.strokeWidthMm;
118
- const clipStrokeOpacity = clipStyle?.strokeOpacity;
119
- const clipFill = clipStyle?.fill;
120
- const clipFillOpacity = clipStyle?.fillOpacity;
121
- const transformAttr = `translate(${centerX} ${centerY}) rotate(${rotationDeg}) scale(${mmPerModel}) translate(${-renderCenterModelX} ${-renderCenterModelY})`;
122
-
123
- // Debug: log model bounds and path breakdown
124
- // if (model.transform) {
125
- // console.log(`[DEBUG MODEL vp="${vp.id}"] Model bounds: viewMinX=${model.transform.viewMinX.toFixed(2)} viewMaxX=${model.transform.viewMaxX?.toFixed(2)} viewMinZ=${model.transform.viewMinZ.toFixed(2)} viewMaxZ=${model.transform.viewMaxZ.toFixed(2)} viewWidth=${model.transform.viewWidth.toFixed(2)} viewHeight=${model.transform.viewHeight.toFixed(2)}`);
126
-
127
- // if (model.rawPaths) {
128
- // const fills = model.rawPaths.filter(p => p.meta?.isFill);
129
- // const segments = model.rawPaths.filter(p => p.meta?.isSegment);
130
- // const other = model.rawPaths.filter(p => !p.meta?.isFill && !p.meta?.isSegment);
131
- // console.log(`[DEBUG MODEL vp="${vp.id}"] Path breakdown: ${model.rawPaths.length} total (${fills.length} fills, ${segments.length} segments, ${other.length} other)`);
132
-
133
- // // Sample a few segment paths
134
- // const sampleSegments = segments.slice(0, 3);
135
- // sampleSegments.forEach((seg, idx) => {
136
- // console.log(`[DEBUG MODEL vp="${vp.id}"] Segment ${idx}: class=${seg.meta?.class} fill=${seg.fill} stroke=${seg.stroke} strokeWidth=${seg.strokeWidth} d=${seg.d.substring(0, 50)}...`);
137
- // });
138
-
139
- // // Check if geometryTransform is present
140
- // if (model.geometryTransform) {
141
- // console.log(`[DEBUG MODEL vp="${vp.id}"] geometryTransform="${model.geometryTransform}"`);
142
- // } else {
143
- // console.log(`[DEBUG MODEL vp="${vp.id}"] WARNING: geometryTransform is MISSING!`);
144
- // }
145
- // }
146
- // }
147
-
148
- // Guard: detect NaN/Infinity in computed transform values
149
- if (
150
- !Number.isFinite(centerX) ||
151
- !Number.isFinite(centerY) ||
152
- !Number.isFinite(mmPerModel) ||
153
- !Number.isFinite(renderCenterModelX) ||
154
- !Number.isFinite(renderCenterModelY)
155
- ) {
156
- warnings.push(
157
- `Viewport '${vp.id}' has non-finite transform values (centerX=${centerX}, centerY=${centerY}, mmPerModel=${mmPerModel}). Skipping render.`
158
- );
159
- return parts;
160
- }
161
-
162
- // console.log(`[DEBUG CLIP APPLY vp="${vp.id}"] clipPolysLocal exists: ${!!clipPolysLocal}, length: ${clipPolysLocal?.length ?? 0}`);
163
- if (clipPolysLocal && clipPolysLocal.length) {
164
- // console.log(`[DEBUG CLIP APPLY vp="${vp.id}"] *** APPLYING CLIP-PATH *** with ${clipPolysLocal.length} polygons`);
165
- if (clipPolysLocal.every((poly) => !poly.length)) {
166
- warnings.push(`Viewport '${vp.id}' clip has no points after prep; skipping clip.`);
167
- }
168
- clipId = `clip-${vp.id}-${parts.length}`;
169
- const polyPartsPage: string[] = [];
170
- // Clip polygon points are in model-view coordinates (not SVG-internal),
171
- // so transform them with outer only (model-view → page). Do NOT apply
172
- // geometryTransform — that maps SVG-internal → model-view, which is only
173
- // correct for path data inside the <g transform="geometryTransform"> group.
174
- const outer = matMultiply(
175
- matTranslate(centerX, centerY),
176
- matMultiply(
177
- matRotateDeg(rotationDeg),
178
- matMultiply(matScale(mmPerModel, mmPerModel), matTranslate(-renderCenterModelX, -renderCenterModelY))
179
- )
180
- );
181
-
182
- // Debug: log clip polygon bounds
183
- let clipMinX = Infinity,
184
- clipMaxX = -Infinity,
185
- clipMinY = Infinity,
186
- clipMaxY = -Infinity;
187
- clipPolysLocal.forEach((poly) => {
188
- poly.forEach((p) => {
189
- if (p.x < clipMinX) clipMinX = p.x;
190
- if (p.x > clipMaxX) clipMaxX = p.x;
191
- if (p.y < clipMinY) clipMinY = p.y;
192
- if (p.y > clipMaxY) clipMaxY = p.y;
193
- });
194
- });
195
- // console.log(`[DEBUG CLIP vp="${vp.id}"] Local bounds: x=${clipMinX.toFixed(2)}..${clipMaxX.toFixed(2)} y=${clipMinY.toFixed(2)}..${clipMaxY.toFixed(2)} (w=${(clipMaxX - clipMinX).toFixed(2)} h=${(clipMaxY - clipMinY).toFixed(2)})`);
196
-
197
- clipPolysLocal.forEach((poly) => {
198
- const pagePtsArr = poly.map((p) => matApply(outer, p));
199
- const pagePts = pagePtsArr.map((p) => `${p.x},${p.y}`).join(' ');
200
- polyPartsPage.push(`<polygon points="${pagePts}" />`);
201
- if (clipStroke || clipFill) {
202
- clipDebugPolys.push(
203
- `<polygon points="${pagePts}"` +
204
- (clipStroke ? ` stroke="${escapeAttr(clipStroke)}"` : '') +
205
- (clipStrokeWidth ? ` stroke-width="${clipStrokeWidth}"` : '') +
206
- (clipStrokeOpacity !== undefined ? ` stroke-opacity="${clipStrokeOpacity}"` : '') +
207
- (clipFill ? ` fill="${escapeAttr(clipFill)}"` : ' fill="none"') +
208
- (clipFillOpacity !== undefined ? ` fill-opacity="${clipFillOpacity}"` : '') +
209
- ` />`
210
- );
211
- }
212
- if (DEBUG_CLIP_OVERLAY) {
213
- clipDebugPolys.push(
214
- `<polygon points="${pagePts}" stroke="#ff00ff" stroke-width="0.4" fill="#000000" fill-opacity="0.35" />`
215
- );
216
- }
217
- });
218
- parts.push(
219
- `<defs><clipPath id="${clipId}" clipPathUnits="userSpaceOnUse">${polyPartsPage.join('')}</clipPath></defs>`
220
- );
221
- }
222
-
223
- parts.push(`<g${clipId ? ` clip-path="url(#${clipId})"` : ''}>`);
224
- parts.push(`<g transform="${transformAttr}">`);
225
-
226
- // Render fill polygons below structural lines
227
- const allowedGids = viewportHighlight[vp.id]?.gids ?? null;
228
- const fillSvg = buildFillSvg(vp, dataWithViewport, model, allowedGids, legendColorMaps);
229
- if (fillSvg) parts.push(fillSvg);
230
-
231
- // Debug: log sample path coordinates from model.groupSvg
232
- if (clipId && model.rawPaths && model.rawPaths.length > 0) {
233
- const asMeta = (m: unknown): { isSegment?: boolean; isFill?: boolean; class?: string } | undefined =>
234
- m != null && typeof m === 'object'
235
- ? (m as { isSegment?: boolean; isFill?: boolean; class?: string })
236
- : undefined;
237
- const samplePath = model.rawPaths.find((p) => {
238
- const pm = asMeta(p.meta);
239
- return pm?.isSegment || (!pm?.isFill && !pm?.class);
240
- });
241
- if (samplePath) {
242
- const dMatch = samplePath.d.match(/M([\d.-]+)\s+([\d.-]+)/);
243
- if (dMatch) {
244
- // console.log(`[DEBUG PATH vp="${vp.id}"] Sample wall path starts at x=${dMatch[1]} y=${dMatch[2]} (world coords, will be transformed by geometryTransform)`);
245
- }
246
- }
247
- }
248
-
249
- // Compensate stroke-widths for the viewport scale transform.
250
- // Paths have stroke-width in model units; the scale(mmPerModel) transform
251
- // would make them thinner/thicker depending on zoom. Dividing by mmPerModel
252
- // ensures strokes render at their intended mm size regardless of viewport scale.
253
- const compensatedSvg = model.groupSvg.replace(
254
- /stroke-width="([0-9.eE+-]+)"/g,
255
- (_, w) => `stroke-width="${parseFloat(w) / mmPerModel}"`
256
- );
257
- parts.push(compensatedSvg);
258
- parts.push('</g>');
259
- parts.push('</g>');
260
-
261
- if (clipDebugPolys.length) {
262
- parts.push(`<g pointer-events="none">${clipDebugPolys.join('')}</g>`);
263
- }
264
-
265
- viewportRenderInfo[vp.id] = {
266
- mmPerModel,
267
- contentWidthMm: rotated.width,
268
- contentHeightMm: rotated.height,
269
- offsetX: contentOffsetX,
270
- offsetY: contentOffsetY,
271
- rotationDeg,
272
- transform: model.transform!,
273
- centerX,
274
- centerY,
275
- centerModelX: renderCenterModelX,
276
- centerModelY: renderCenterModelY
277
- };
278
-
279
- if (vp.border) {
280
- const borderStroke = vp.border.stroke ?? '#000';
281
- const borderWidth = vp.border.strokeWidthMm ?? 0.25;
282
- parts.push(
283
- `<rect x="${vp.xMm}" y="${vp.yMm}" width="${vp.widthMm}" height="${vp.heightMm}" stroke="${escapeAttr(
284
- borderStroke
285
- )}" stroke-width="${borderWidth}" fill="none"${vp.border.dashed ? ' stroke-dasharray="4 2"' : ''} />`
286
- );
287
- }
288
-
289
- if (vp.northArrow) {
290
- const arrow = vp.northArrow;
291
- const asset = assetMap.get(arrow.assetId);
292
- const size = arrow.sizeMm ?? 12;
293
- const offsetX = arrow.offsetXMm ?? 8;
294
- const offsetY = arrow.offsetYMm ?? 8;
295
- const arrowX = vp.xMm + vp.widthMm - offsetX - size;
296
- const arrowY = vp.yMm + offsetY;
297
- const arrowRotation = arrow.rotationDeg ?? 0;
298
- if (!asset) {
299
- warnings.push(`Missing asset '${arrow.assetId}' for north arrow on viewport '${vp.id}'.`);
300
- } else if (asset.src) {
301
- parts.push(
302
- `<image href="${escapeAttr(asset.src)}" x="${arrowX}" y="${arrowY}" width="${size}" height="${size}" transform="rotate(${arrowRotation}, ${arrowX + size / 2}, ${
303
- arrowY + size / 2
304
- })" />`
305
- );
306
- } else if (asset.content) {
307
- parts.push(
308
- `<g transform="translate(${arrowX} ${arrowY}) rotate(${arrowRotation} ${size / 2} ${size / 2})">${asset.content}</g>`
309
- );
310
- }
311
- }
312
-
313
- return parts;
314
- };
315
-
316
- // ---------------------------------------------------------------------------
317
- // Fill polygon rendering
318
- // ---------------------------------------------------------------------------
319
-
320
- /**
321
- * Build SVG fill polygons from viewport fill rules and item data.
322
- * Returns an SVG fragment in model-view coordinates (same space as model.groupSvg),
323
- * or null if no fills should be rendered.
324
- */
325
- function buildFillSvg(
326
- vp: ViewportElement,
327
- data: any,
328
- model: SvgModelView,
329
- allowedGids: Set<string> | null,
330
- legendColorMaps?: Map<string, Map<string, string>>
331
- ): string | null {
332
- const fills = vp.fills;
333
- if (!fills?.length || !model.transform) return null;
334
-
335
- const items = getPathValue(data, `viewports.${vp.id}.items`);
336
- if (!Array.isArray(items) || !items.length) return null;
337
-
338
- const { viewMinX, viewMaxZ } = model.transform;
339
- const parts: string[] = [];
340
-
341
- for (const rule of fills) {
342
- const opacity = rule.fillOpacity ?? 0.35;
343
- // Find items matching this fill rule
344
- let matching = items.filter((item: unknown) =>
345
- isRecord(item) ? matchesV2Filter(item, rule.class, rule.where) : false
346
- );
347
-
348
- // Filter by allowed GIDs (from clip polygon center point test)
349
- // Only apply if BOTH: clip filtering is active AND this fill rule opts in via matchClipFilter
350
- if (allowedGids !== null && rule.matchClipFilter === true) {
351
- matching = matching.filter((item: unknown) => {
352
- const gid = getProp(item, 'gid') ?? getProp(item, 'id');
353
- return gid !== null && gid !== undefined && allowedGids.has(String(gid));
354
- });
355
- // console.log(`[DEBUG FILL vp="${vp.id}"] Clip filter applied (matchClipFilter=true): ${beforeFilter} → ${matching.length} items (allowedGids.size=${allowedGids.size})`);
356
- } else if (rule.matchClipFilter === true) {
357
- // console.log(`[DEBUG FILL vp="${vp.id}"] Fill rule has matchClipFilter=true but no clip polygon active`);
358
- }
359
- // console.log(`[DEBUG FILL vp="${vp.id}"] Fill rule (class="${rule.class}", where=${JSON.stringify(rule.where)}) matched ${matching.length} items`);
360
- // if (matching.length > 0) {
361
- // console.log(`[DEBUG FILL vp="${vp.id}"] First matched item:`, {
362
- // gid: getProp(matching[0], 'gid'),
363
- // name: getProp(matching[0], 'name'),
364
- // ifcClass: getProp(matching[0], 'ifcClass'),
365
- // propertySets: getProp(matching[0], 'propertySets'),
366
- // polylines: Array.isArray(getProp(matching[0], 'polylines')) ? (getProp(matching[0], 'polylines') as any[]).length : 0
367
- // });
368
- // }
369
- if (!matching.length) continue;
370
-
371
- for (const item of matching) {
372
- const polylines = getProp(item, 'polylines');
373
- if (!Array.isArray(polylines)) continue;
374
-
375
- // Resolve fill color for this item
376
- const color = getProp(item, 'color') as [number, number, number, number] | undefined;
377
- let fillColor: string;
378
- if (rule.fill === 'sample') {
379
- if (!color) continue; // no sampled color available
380
- fillColor = `rgb(${color[0]},${color[1]},${color[2]})`;
381
- } else if (rule.fill === 'legend') {
382
- // Use color from legend color map
383
- const legendColorMap = legendColorMaps?.get(vp.id);
384
- if (!legendColorMap) {
385
- console.warn(
386
- `[FILL] fill="legend" specified but no legend color map found for viewport "${vp.id}". Available viewports:`,
387
- Array.from(legendColorMaps?.keys() ?? [])
388
- );
389
- continue;
390
- }
391
- // Find matching legend entry - need to determine which property to use for lookup
392
- // For now, try common properties (can be made configurable later)
393
- let legendKey: string | undefined;
394
- const possibleKeys = ['spacename', 'BIP.spacename', 'name'];
395
- for (const key of possibleKeys) {
396
- const value = resolvePath(item, key);
397
- if (value !== undefined && value !== null) {
398
- legendKey = String(value);
399
- break;
400
- }
401
- }
402
- if (!legendKey || !legendColorMap.has(legendKey)) {
403
- // No matching color in legend, skip this item
404
- // if (legendKey) {
405
- // console.log(`[FILL] No color mapping for "${legendKey}" (property: ${matchedProperty}). Available keys:`, Array.from(legendColorMap.keys()));
406
- // }
407
- continue;
408
- }
409
- fillColor = legendColorMap.get(legendKey)!;
410
- // console.log(`[FILL] ✓ Matched "${legendKey}" -> ${fillColor}`);
411
- } else {
412
- fillColor = rule.fill;
413
- }
414
-
415
- // console.log(`[FILL] Processing ${polylines.length} polyline(s) for item with color ${fillColor}`);
416
- for (const polyline of polylines) {
417
- const points = isRecord(polyline) ? getProp(polyline, 'points') : null;
418
- if (!Array.isArray(points) || points.length < 3) continue;
419
-
420
- // Build path in model-view coordinates
421
- const pathParts: string[] = [];
422
- for (let i = 0; i < points.length; i++) {
423
- const pt = points[i];
424
- const px = isRecord(pt) ? getProp(pt, 'x') : null;
425
- const pz = isRecord(pt) ? getProp(pt, 'z') : null;
426
- if (!Number.isFinite(px) || !Number.isFinite(pz)) continue;
427
- const mx = (px as number) - viewMinX;
428
- const my = viewMaxZ - (pz as number);
429
- pathParts.push(i === 0 ? `M ${mx} ${my}` : `L ${mx} ${my}`);
430
- }
431
- if (pathParts.length < 3) continue;
432
- pathParts.push('Z');
433
- parts.push(
434
- `<path d="${pathParts.join(' ')}" fill="${escapeAttr(fillColor)}" fill-opacity="${opacity}" stroke="none"/>`
435
- );
436
- }
437
- }
438
- }
439
-
440
- // console.log(`[FILL] Generated ${parts.length} fill polygon(s) for viewport "${vp.id}"`);
441
- return parts.length ? `<g>${parts.join('')}</g>` : null;
442
- }
1
+ import { escapeAttr, getPathValue, resolvePath } from './renderUtils';
2
+ import { rotateBounds, pickScale } from './viewportUtils';
3
+ import { ViewportRenderInfo } from './labelsRender';
4
+ import { matchesV2Filter } from './filters';
5
+ import { ComputedViewportScale, SvgModelView, ViewportElement, AssetSpec } from './types';
6
+ import { isRecord, getProp } from './typeGuards';
7
+
8
+ type PolyPoint = { x: number; y: number };
9
+ // Debug overlay for clip polygons. Keep false for normal builds.
10
+ const DEBUG_CLIP_OVERLAY = false;
11
+
12
+ type Mat = [number, number, number, number, number, number]; // a b c d e f
13
+
14
+ const matTranslate = (tx: number, ty: number): Mat => [1, 0, 0, 1, tx, ty];
15
+ const matScale = (sx: number, sy: number): Mat => [sx, 0, 0, sy, 0, 0];
16
+ const matRotateDeg = (deg: number): Mat => {
17
+ const rad = (deg * Math.PI) / 180;
18
+ const c = Math.cos(rad);
19
+ const s = Math.sin(rad);
20
+ return [c, s, -s, c, 0, 0];
21
+ };
22
+ const matMultiply = (m1: Mat, m2: Mat): Mat => {
23
+ const [a1, b1, c1, d1, e1, f1] = m1;
24
+ const [a2, b2, c2, d2, e2, f2] = m2;
25
+ return [
26
+ a1 * a2 + c1 * b2,
27
+ b1 * a2 + d1 * b2,
28
+ a1 * c2 + c1 * d2,
29
+ b1 * c2 + d1 * d2,
30
+ a1 * e2 + c1 * f2 + e1,
31
+ b1 * e2 + d1 * f2 + f1
32
+ ];
33
+ };
34
+ const matApply = (m: Mat, p: PolyPoint): PolyPoint => {
35
+ const [a, b, c, d, e, f] = m;
36
+ return { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f };
37
+ };
38
+ export const renderViewportElement = (
39
+ vp: ViewportElement,
40
+ baseModel: SvgModelView | null,
41
+ dataWithViewport: any,
42
+ preferredScales: number[],
43
+ scales: Record<string, { preferredScales?: number[] }>,
44
+ assetMap: Map<string, AssetSpec>,
45
+ viewportScales: Record<string, ComputedViewportScale>,
46
+ viewportClipPolygonsLocal: Record<string, Array<Array<PolyPoint>>>,
47
+ viewportClipBounds: Record<
48
+ string,
49
+ { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number }
50
+ >,
51
+ viewportClipLabels: Record<string, boolean>,
52
+ viewportClipStyles: Record<
53
+ string,
54
+ { stroke?: string; strokeWidthMm?: number; strokeOpacity?: number; fill?: string; fillOpacity?: number }
55
+ >,
56
+ viewportHighlight: Record<string, { gids: Set<string> | null; styles: any }>,
57
+ viewportRenderInfo: Record<string, ViewportRenderInfo>,
58
+ warnings: string[],
59
+ legendColorMaps?: Map<string, Map<string, string>>,
60
+ precomputed?: {
61
+ effectiveWidthModel?: number;
62
+ effectiveHeightModel?: number;
63
+ clipPolygonsMm?: Array<Array<PolyPoint>>;
64
+ clipPolygonsLocal?: Array<Array<PolyPoint>>;
65
+ clipBounds?: { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number };
66
+ renderCenterModelX?: number;
67
+ renderCenterModelY?: number;
68
+ }
69
+ ): string[] => {
70
+ const parts: string[] = [];
71
+ if (!baseModel) {
72
+ // Warning already pushed by viewportPrep; avoid duplicate.
73
+ return parts;
74
+ }
75
+
76
+ const model = baseModel;
77
+ let renderWidthModel = precomputed?.effectiveWidthModel ?? model.widthModel;
78
+ let renderHeightModel = precomputed?.effectiveHeightModel ?? model.heightModel;
79
+ let renderCenterModelX = precomputed?.renderCenterModelX ?? renderWidthModel * 0.5;
80
+ let renderCenterModelY = precomputed?.renderCenterModelY ?? renderHeightModel * 0.5;
81
+
82
+ const clipBounds = viewportClipBounds[vp.id];
83
+ const clipLocal = viewportClipPolygonsLocal[vp.id];
84
+ const hasClip = !!clipBounds && Array.isArray(clipLocal) && clipLocal.length > 0;
85
+ if (hasClip) {
86
+ renderWidthModel = clipBounds!.width;
87
+ renderHeightModel = clipBounds!.height;
88
+ renderCenterModelX = (clipBounds!.minX + clipBounds!.maxX) * 0.5;
89
+ renderCenterModelY = (clipBounds!.minY + clipBounds!.maxY) * 0.5;
90
+ }
91
+
92
+ const scaleInfo =
93
+ viewportScales[vp.id] ??
94
+ pickScale(
95
+ vp,
96
+ { ...model, widthModel: renderWidthModel, heightModel: renderHeightModel },
97
+ preferredScales,
98
+ scales
99
+ );
100
+ const rotationDeg = vp.rotationDeg ?? 0;
101
+ const mmPerModel = scaleInfo.mmPerModelUnit;
102
+ const contentWidthMm = renderWidthModel * mmPerModel;
103
+ const contentHeightMm = renderHeightModel * mmPerModel;
104
+ const rotated = rotateBounds(contentWidthMm, contentHeightMm, rotationDeg);
105
+
106
+ const centerX = vp.align === 'topLeft' ? vp.xMm + rotated.width / 2 : vp.xMm + vp.widthMm / 2;
107
+ const centerY = vp.align === 'topLeft' ? vp.yMm + rotated.height / 2 : vp.yMm + vp.heightMm / 2;
108
+
109
+ const contentOffsetX = vp.align === 'topLeft' ? vp.xMm : vp.xMm + Math.max(0, (vp.widthMm - rotated.width) * 0.5);
110
+ const contentOffsetY = vp.align === 'topLeft' ? vp.yMm : vp.yMm + Math.max(0, (vp.heightMm - rotated.height) * 0.5);
111
+
112
+ const clipPolysLocal = clipLocal;
113
+ let clipId: string | null = null;
114
+ const clipDebugPolys: string[] = [];
115
+ const clipStyle = viewportClipStyles[vp.id];
116
+ const clipStroke = clipStyle?.stroke;
117
+ const clipStrokeWidth = clipStyle?.strokeWidthMm;
118
+ const clipStrokeOpacity = clipStyle?.strokeOpacity;
119
+ const clipFill = clipStyle?.fill;
120
+ const clipFillOpacity = clipStyle?.fillOpacity;
121
+ const transformAttr = `translate(${centerX} ${centerY}) rotate(${rotationDeg}) scale(${mmPerModel}) translate(${-renderCenterModelX} ${-renderCenterModelY})`;
122
+
123
+ // Debug: log model bounds and path breakdown
124
+ // if (model.transform) {
125
+ // console.log(`[DEBUG MODEL vp="${vp.id}"] Model bounds: viewMinX=${model.transform.viewMinX.toFixed(2)} viewMaxX=${model.transform.viewMaxX?.toFixed(2)} viewMinZ=${model.transform.viewMinZ.toFixed(2)} viewMaxZ=${model.transform.viewMaxZ.toFixed(2)} viewWidth=${model.transform.viewWidth.toFixed(2)} viewHeight=${model.transform.viewHeight.toFixed(2)}`);
126
+
127
+ // if (model.rawPaths) {
128
+ // const fills = model.rawPaths.filter(p => p.meta?.isFill);
129
+ // const segments = model.rawPaths.filter(p => p.meta?.isSegment);
130
+ // const other = model.rawPaths.filter(p => !p.meta?.isFill && !p.meta?.isSegment);
131
+ // console.log(`[DEBUG MODEL vp="${vp.id}"] Path breakdown: ${model.rawPaths.length} total (${fills.length} fills, ${segments.length} segments, ${other.length} other)`);
132
+
133
+ // // Sample a few segment paths
134
+ // const sampleSegments = segments.slice(0, 3);
135
+ // sampleSegments.forEach((seg, idx) => {
136
+ // console.log(`[DEBUG MODEL vp="${vp.id}"] Segment ${idx}: class=${seg.meta?.class} fill=${seg.fill} stroke=${seg.stroke} strokeWidth=${seg.strokeWidth} d=${seg.d.substring(0, 50)}...`);
137
+ // });
138
+
139
+ // // Check if geometryTransform is present
140
+ // if (model.geometryTransform) {
141
+ // console.log(`[DEBUG MODEL vp="${vp.id}"] geometryTransform="${model.geometryTransform}"`);
142
+ // } else {
143
+ // console.log(`[DEBUG MODEL vp="${vp.id}"] WARNING: geometryTransform is MISSING!`);
144
+ // }
145
+ // }
146
+ // }
147
+
148
+ // Guard: detect NaN/Infinity in computed transform values
149
+ if (
150
+ !Number.isFinite(centerX) ||
151
+ !Number.isFinite(centerY) ||
152
+ !Number.isFinite(mmPerModel) ||
153
+ !Number.isFinite(renderCenterModelX) ||
154
+ !Number.isFinite(renderCenterModelY)
155
+ ) {
156
+ warnings.push(
157
+ `Viewport '${vp.id}' has non-finite transform values (centerX=${centerX}, centerY=${centerY}, mmPerModel=${mmPerModel}). Skipping render.`
158
+ );
159
+ return parts;
160
+ }
161
+
162
+ // console.log(`[DEBUG CLIP APPLY vp="${vp.id}"] clipPolysLocal exists: ${!!clipPolysLocal}, length: ${clipPolysLocal?.length ?? 0}`);
163
+ if (clipPolysLocal && clipPolysLocal.length) {
164
+ // console.log(`[DEBUG CLIP APPLY vp="${vp.id}"] *** APPLYING CLIP-PATH *** with ${clipPolysLocal.length} polygons`);
165
+ if (clipPolysLocal.every((poly) => !poly.length)) {
166
+ warnings.push(`Viewport '${vp.id}' clip has no points after prep; skipping clip.`);
167
+ }
168
+ clipId = `clip-${vp.id}-${parts.length}`;
169
+ const polyPartsPage: string[] = [];
170
+ // Clip polygon points are in model-view coordinates (not SVG-internal),
171
+ // so transform them with outer only (model-view → page). Do NOT apply
172
+ // geometryTransform — that maps SVG-internal → model-view, which is only
173
+ // correct for path data inside the <g transform="geometryTransform"> group.
174
+ const outer = matMultiply(
175
+ matTranslate(centerX, centerY),
176
+ matMultiply(
177
+ matRotateDeg(rotationDeg),
178
+ matMultiply(matScale(mmPerModel, mmPerModel), matTranslate(-renderCenterModelX, -renderCenterModelY))
179
+ )
180
+ );
181
+
182
+ // Debug: log clip polygon bounds
183
+ let clipMinX = Infinity,
184
+ clipMaxX = -Infinity,
185
+ clipMinY = Infinity,
186
+ clipMaxY = -Infinity;
187
+ clipPolysLocal.forEach((poly) => {
188
+ poly.forEach((p) => {
189
+ if (p.x < clipMinX) clipMinX = p.x;
190
+ if (p.x > clipMaxX) clipMaxX = p.x;
191
+ if (p.y < clipMinY) clipMinY = p.y;
192
+ if (p.y > clipMaxY) clipMaxY = p.y;
193
+ });
194
+ });
195
+ // console.log(`[DEBUG CLIP vp="${vp.id}"] Local bounds: x=${clipMinX.toFixed(2)}..${clipMaxX.toFixed(2)} y=${clipMinY.toFixed(2)}..${clipMaxY.toFixed(2)} (w=${(clipMaxX - clipMinX).toFixed(2)} h=${(clipMaxY - clipMinY).toFixed(2)})`);
196
+
197
+ clipPolysLocal.forEach((poly) => {
198
+ const pagePtsArr = poly.map((p) => matApply(outer, p));
199
+ const pagePts = pagePtsArr.map((p) => `${p.x},${p.y}`).join(' ');
200
+ polyPartsPage.push(`<polygon points="${pagePts}" />`);
201
+ if (clipStroke || clipFill) {
202
+ clipDebugPolys.push(
203
+ `<polygon points="${pagePts}"` +
204
+ (clipStroke ? ` stroke="${escapeAttr(clipStroke)}"` : '') +
205
+ (clipStrokeWidth ? ` stroke-width="${clipStrokeWidth}"` : '') +
206
+ (clipStrokeOpacity !== undefined ? ` stroke-opacity="${clipStrokeOpacity}"` : '') +
207
+ (clipFill ? ` fill="${escapeAttr(clipFill)}"` : ' fill="none"') +
208
+ (clipFillOpacity !== undefined ? ` fill-opacity="${clipFillOpacity}"` : '') +
209
+ ` />`
210
+ );
211
+ }
212
+ if (DEBUG_CLIP_OVERLAY) {
213
+ clipDebugPolys.push(
214
+ `<polygon points="${pagePts}" stroke="#ff00ff" stroke-width="0.4" fill="#000000" fill-opacity="0.35" />`
215
+ );
216
+ }
217
+ });
218
+ parts.push(
219
+ `<defs><clipPath id="${clipId}" clipPathUnits="userSpaceOnUse">${polyPartsPage.join('')}</clipPath></defs>`
220
+ );
221
+ }
222
+
223
+ parts.push(`<g${clipId ? ` clip-path="url(#${clipId})"` : ''}>`);
224
+ parts.push(`<g transform="${transformAttr}">`);
225
+
226
+ // Render fill polygons below structural lines
227
+ const allowedGids = viewportHighlight[vp.id]?.gids ?? null;
228
+ const fillSvg = buildFillSvg(vp, dataWithViewport, model, allowedGids, legendColorMaps);
229
+ if (fillSvg) parts.push(fillSvg);
230
+
231
+ // Debug: log sample path coordinates from model.groupSvg
232
+ if (clipId && model.rawPaths && model.rawPaths.length > 0) {
233
+ const asMeta = (m: unknown): { isSegment?: boolean; isFill?: boolean; class?: string } | undefined =>
234
+ m != null && typeof m === 'object'
235
+ ? (m as { isSegment?: boolean; isFill?: boolean; class?: string })
236
+ : undefined;
237
+ const samplePath = model.rawPaths.find((p) => {
238
+ const pm = asMeta(p.meta);
239
+ return pm?.isSegment || (!pm?.isFill && !pm?.class);
240
+ });
241
+ if (samplePath) {
242
+ const dMatch = samplePath.d.match(/M([\d.-]+)\s+([\d.-]+)/);
243
+ if (dMatch) {
244
+ // console.log(`[DEBUG PATH vp="${vp.id}"] Sample wall path starts at x=${dMatch[1]} y=${dMatch[2]} (world coords, will be transformed by geometryTransform)`);
245
+ }
246
+ }
247
+ }
248
+
249
+ // Compensate stroke-widths for the viewport scale transform.
250
+ // Paths have stroke-width in model units; the scale(mmPerModel) transform
251
+ // would make them thinner/thicker depending on zoom. Dividing by mmPerModel
252
+ // ensures strokes render at their intended mm size regardless of viewport scale.
253
+ const compensatedSvg = model.groupSvg.replace(
254
+ /stroke-width="([0-9.eE+-]+)"/g,
255
+ (_, w) => `stroke-width="${parseFloat(w) / mmPerModel}"`
256
+ );
257
+ parts.push(compensatedSvg);
258
+ parts.push('</g>');
259
+ parts.push('</g>');
260
+
261
+ if (clipDebugPolys.length) {
262
+ parts.push(`<g pointer-events="none">${clipDebugPolys.join('')}</g>`);
263
+ }
264
+
265
+ viewportRenderInfo[vp.id] = {
266
+ mmPerModel,
267
+ contentWidthMm: rotated.width,
268
+ contentHeightMm: rotated.height,
269
+ offsetX: contentOffsetX,
270
+ offsetY: contentOffsetY,
271
+ rotationDeg,
272
+ transform: model.transform!,
273
+ centerX,
274
+ centerY,
275
+ centerModelX: renderCenterModelX,
276
+ centerModelY: renderCenterModelY
277
+ };
278
+
279
+ if (vp.border) {
280
+ const borderStroke = vp.border.stroke ?? '#000';
281
+ const borderWidth = vp.border.strokeWidthMm ?? 0.25;
282
+ parts.push(
283
+ `<rect x="${vp.xMm}" y="${vp.yMm}" width="${vp.widthMm}" height="${vp.heightMm}" stroke="${escapeAttr(
284
+ borderStroke
285
+ )}" stroke-width="${borderWidth}" fill="none"${vp.border.dashed ? ' stroke-dasharray="4 2"' : ''} />`
286
+ );
287
+ }
288
+
289
+ if (vp.northArrow) {
290
+ const arrow = vp.northArrow;
291
+ const asset = assetMap.get(arrow.assetId);
292
+ const size = arrow.sizeMm ?? 12;
293
+ const offsetX = arrow.offsetXMm ?? 8;
294
+ const offsetY = arrow.offsetYMm ?? 8;
295
+ const arrowX = vp.xMm + vp.widthMm - offsetX - size;
296
+ const arrowY = vp.yMm + offsetY;
297
+ const arrowRotation = arrow.rotationDeg ?? 0;
298
+ if (!asset) {
299
+ warnings.push(`Missing asset '${arrow.assetId}' for north arrow on viewport '${vp.id}'.`);
300
+ } else if (asset.src) {
301
+ parts.push(
302
+ `<image href="${escapeAttr(asset.src)}" x="${arrowX}" y="${arrowY}" width="${size}" height="${size}" transform="rotate(${arrowRotation}, ${arrowX + size / 2}, ${
303
+ arrowY + size / 2
304
+ })" />`
305
+ );
306
+ } else if (asset.content) {
307
+ parts.push(
308
+ `<g transform="translate(${arrowX} ${arrowY}) rotate(${arrowRotation} ${size / 2} ${size / 2})">${asset.content}</g>`
309
+ );
310
+ }
311
+ }
312
+
313
+ return parts;
314
+ };
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Fill polygon rendering
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /**
321
+ * Build SVG fill polygons from viewport fill rules and item data.
322
+ * Returns an SVG fragment in model-view coordinates (same space as model.groupSvg),
323
+ * or null if no fills should be rendered.
324
+ */
325
+ function buildFillSvg(
326
+ vp: ViewportElement,
327
+ data: any,
328
+ model: SvgModelView,
329
+ allowedGids: Set<string> | null,
330
+ legendColorMaps?: Map<string, Map<string, string>>
331
+ ): string | null {
332
+ const fills = vp.fills;
333
+ if (!fills?.length || !model.transform) return null;
334
+
335
+ const items = getPathValue(data, `viewports.${vp.id}.items`);
336
+ if (!Array.isArray(items) || !items.length) return null;
337
+
338
+ const { viewMinX, viewMaxZ } = model.transform;
339
+ const parts: string[] = [];
340
+
341
+ for (const rule of fills) {
342
+ const opacity = rule.fillOpacity ?? 0.35;
343
+ // Find items matching this fill rule
344
+ let matching = items.filter((item: unknown) =>
345
+ isRecord(item) ? matchesV2Filter(item, rule.class, rule.where) : false
346
+ );
347
+
348
+ // Filter by allowed GIDs (from clip polygon center point test)
349
+ // Only apply if BOTH: clip filtering is active AND this fill rule opts in via matchClipFilter
350
+ if (allowedGids !== null && rule.matchClipFilter === true) {
351
+ matching = matching.filter((item: unknown) => {
352
+ const gid = getProp(item, 'gid') ?? getProp(item, 'id');
353
+ return gid !== null && gid !== undefined && allowedGids.has(String(gid));
354
+ });
355
+ // console.log(`[DEBUG FILL vp="${vp.id}"] Clip filter applied (matchClipFilter=true): ${beforeFilter} → ${matching.length} items (allowedGids.size=${allowedGids.size})`);
356
+ } else if (rule.matchClipFilter === true) {
357
+ // console.log(`[DEBUG FILL vp="${vp.id}"] Fill rule has matchClipFilter=true but no clip polygon active`);
358
+ }
359
+ // console.log(`[DEBUG FILL vp="${vp.id}"] Fill rule (class="${rule.class}", where=${JSON.stringify(rule.where)}) matched ${matching.length} items`);
360
+ // if (matching.length > 0) {
361
+ // console.log(`[DEBUG FILL vp="${vp.id}"] First matched item:`, {
362
+ // gid: getProp(matching[0], 'gid'),
363
+ // name: getProp(matching[0], 'name'),
364
+ // ifcClass: getProp(matching[0], 'ifcClass'),
365
+ // propertySets: getProp(matching[0], 'propertySets'),
366
+ // polylines: Array.isArray(getProp(matching[0], 'polylines')) ? (getProp(matching[0], 'polylines') as any[]).length : 0
367
+ // });
368
+ // }
369
+ if (!matching.length) continue;
370
+
371
+ for (const item of matching) {
372
+ const polylines = getProp(item, 'polylines');
373
+ if (!Array.isArray(polylines)) continue;
374
+
375
+ // Resolve fill color for this item
376
+ const color = getProp(item, 'color') as [number, number, number, number] | undefined;
377
+ let fillColor: string;
378
+ if (rule.fill === 'sample') {
379
+ if (!color) continue; // no sampled color available
380
+ fillColor = `rgb(${color[0]},${color[1]},${color[2]})`;
381
+ } else if (rule.fill === 'legend') {
382
+ // Use color from legend color map
383
+ const legendColorMap = legendColorMaps?.get(vp.id);
384
+ if (!legendColorMap) {
385
+ console.warn(
386
+ `[FILL] fill="legend" specified but no legend color map found for viewport "${vp.id}". Available viewports:`,
387
+ Array.from(legendColorMaps?.keys() ?? [])
388
+ );
389
+ continue;
390
+ }
391
+ // Find matching legend entry - need to determine which property to use for lookup
392
+ // For now, try common properties (can be made configurable later)
393
+ let legendKey: string | undefined;
394
+ const possibleKeys = ['spacename', 'BIP.spacename', 'name'];
395
+ for (const key of possibleKeys) {
396
+ const value = resolvePath(item, key);
397
+ if (value !== undefined && value !== null) {
398
+ legendKey = String(value);
399
+ break;
400
+ }
401
+ }
402
+ if (!legendKey || !legendColorMap.has(legendKey)) {
403
+ // No matching color in legend, skip this item
404
+ // if (legendKey) {
405
+ // console.log(`[FILL] No color mapping for "${legendKey}" (property: ${matchedProperty}). Available keys:`, Array.from(legendColorMap.keys()));
406
+ // }
407
+ continue;
408
+ }
409
+ fillColor = legendColorMap.get(legendKey)!;
410
+ // console.log(`[FILL] ✓ Matched "${legendKey}" -> ${fillColor}`);
411
+ } else {
412
+ fillColor = rule.fill;
413
+ }
414
+
415
+ // console.log(`[FILL] Processing ${polylines.length} polyline(s) for item with color ${fillColor}`);
416
+ for (const polyline of polylines) {
417
+ const points = isRecord(polyline) ? getProp(polyline, 'points') : null;
418
+ if (!Array.isArray(points) || points.length < 3) continue;
419
+
420
+ // Build path in model-view coordinates
421
+ const pathParts: string[] = [];
422
+ for (let i = 0; i < points.length; i++) {
423
+ const pt = points[i];
424
+ const px = isRecord(pt) ? getProp(pt, 'x') : null;
425
+ const pz = isRecord(pt) ? getProp(pt, 'z') : null;
426
+ if (!Number.isFinite(px) || !Number.isFinite(pz)) continue;
427
+ const mx = (px as number) - viewMinX;
428
+ const my = viewMaxZ - (pz as number);
429
+ pathParts.push(i === 0 ? `M ${mx} ${my}` : `L ${mx} ${my}`);
430
+ }
431
+ if (pathParts.length < 3) continue;
432
+ pathParts.push('Z');
433
+ parts.push(
434
+ `<path d="${pathParts.join(' ')}" fill="${escapeAttr(fillColor)}" fill-opacity="${opacity}" stroke="none"/>`
435
+ );
436
+ }
437
+ }
438
+ }
439
+
440
+ // console.log(`[FILL] Generated ${parts.length} fill polygon(s) for viewport "${vp.id}"`);
441
+ return parts.length ? `<g>${parts.join('')}</g>` : null;
442
+ }