@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,671 +1,671 @@
1
- import type { SectionRenderState } from '@twinfinity/geometry';
2
- import defaultTemplateJson from '../templates/default-template.json';
3
- import {
4
- pointInPolygon,
5
- filterItemsInPolygons,
6
- filterEntriesByClip,
7
- offsetPolygons,
8
- projectPolygonsToLocal
9
- } from './clipUtils';
10
- import { uniquePositiveDenoms } from './utils';
11
- import { validateLayoutTemplate as validateLayoutTemplateExternal } from './validation';
12
- import { renderViewportElement } from './viewportRender';
13
- import { prepareViewports } from './viewportPrep';
14
- import { renderLabels, ViewportRenderInfo } from './labelsRender';
15
- import { renderQrElements } from './qrRender';
16
- import { applyLegendFilter, buildLegendGroup } from './legendRender';
17
- import { RenderLayoutOptions } from './options';
18
- import { resolvePresets } from './presets';
19
- import { pushWarning } from './warnings';
20
- import { escapeAttr, escapeText, interpolate, getPathValue } from './renderUtils';
21
- import { validateSectionState } from './sectionValidation';
22
- import {
23
- ELEMENT_KIND_VIEWPORT,
24
- ELEMENT_KIND_TEXT,
25
- ELEMENT_KIND_IMAGE,
26
- ELEMENT_KIND_LINE,
27
- ELEMENT_KIND_RECT,
28
- ELEMENT_KIND_CIRCLE,
29
- ELEMENT_KIND_POLYLINE,
30
- ELEMENT_KIND_POLYGON,
31
- ELEMENT_KIND_SCALE_BAR,
32
- ELEMENT_KIND_QR,
33
- ELEMENT_KIND_LABELS,
34
- ELEMENT_KIND_LEGEND,
35
- ELEMENT_KIND_GROUP
36
- } from './elementKinds';
37
- import {
38
- AssetSpec,
39
- LayoutElement,
40
- ViewportElement,
41
- TextElement,
42
- ImageElement,
43
- LineElement,
44
- RectElement,
45
- CircleElement,
46
- PolylineElement,
47
- PolygonElement,
48
- ScaleBarElement,
49
- QrElement,
50
- LabelsElement,
51
- LegendElement,
52
- GroupElement,
53
- LayoutTemplate,
54
- ModelContext,
55
- LayoutData,
56
- ValidationIssue,
57
- ComputedViewportScale
58
- } from './types';
59
-
60
- export type LayoutDataValidationResult =
61
- | { ok: true }
62
- | {
63
- ok: false;
64
- errors: string[];
65
- };
66
-
67
- export type PreflightResult =
68
- | { ok: true }
69
- | {
70
- ok: false;
71
- templateErrors?: ValidationIssue[];
72
- dataErrors?: string[];
73
- sectionErrors?: string[];
74
- };
75
-
76
- import { isRecord, getProp } from './typeGuards';
77
-
78
- /**
79
- * Validate template, layout data, and section state in a single call.
80
- * Combines `validateLayoutTemplate`, `validateLayoutData`, and `validateSectionState`.
81
- */
82
- export const preflight = (
83
- template: LayoutTemplate,
84
- data: unknown,
85
- expectedViewportIds: string[],
86
- sectionState?: SectionRenderState
87
- ): PreflightResult => {
88
- const templateValidation = validateLayoutTemplate(template);
89
- const dataValidation = validateLayoutData(data, expectedViewportIds);
90
- const sectionValidation = sectionState ? validateSectionState(sectionState) : { ok: true as const };
91
-
92
- const ok = templateValidation.ok && dataValidation.ok && sectionValidation.ok;
93
- if (ok) return { ok: true };
94
-
95
- return {
96
- ok: false,
97
- templateErrors: templateValidation.ok ? undefined : templateValidation.errors,
98
- dataErrors: dataValidation.ok ? undefined : dataValidation.errors,
99
- sectionErrors: sectionValidation.ok ? undefined : sectionValidation.errors
100
- };
101
- };
102
-
103
- /**
104
- * Validate layout data before rendering to catch missing propertySets or items arrays.
105
- */
106
- export const validateLayoutData = (data: unknown, expectedViewportIds: string[]): LayoutDataValidationResult => {
107
- const errors: string[] = [];
108
- if (!isRecord(data)) {
109
- return { ok: false, errors: ['layout data must be an object'] };
110
- }
111
- expectedViewportIds.forEach((vpId) => {
112
- const viewports = getProp(data, 'viewports');
113
- const viewport = isRecord(viewports) ? getProp(viewports, vpId) : undefined;
114
- const items = getProp(viewport, 'items');
115
- if (!Array.isArray(items)) {
116
- errors.push(`viewports.${vpId}.items is required and must be an array.`);
117
- return;
118
- }
119
- items.forEach((it: unknown, idx: number) => {
120
- if (!isRecord(it)) {
121
- errors.push(`viewports.${vpId}.items[${idx}] must be an object.`);
122
- return;
123
- }
124
- const sets = getProp(it, 'propertySets');
125
- if (!isRecord(sets)) {
126
- errors.push(`viewports.${vpId}.items[${idx}] is missing propertySets.<Set>.<Prop>.value.`);
127
- return;
128
- }
129
- Object.entries(sets).forEach(([setName, props]) => {
130
- if (!isRecord(props)) {
131
- errors.push(`viewports.${vpId}.items[${idx}].propertySets.${setName} must be an object.`);
132
- return;
133
- }
134
- Object.entries(props).forEach(([propName, payload]) => {
135
- if (!isRecord(payload) || !('value' in payload)) {
136
- errors.push(
137
- `viewports.${vpId}.items[${idx}].propertySets.${setName}.${propName} must include a value (propertySets.<Set>.<Prop>.value).`
138
- );
139
- }
140
- });
141
- });
142
- });
143
- });
144
- return errors.length ? { ok: false, errors } : { ok: true };
145
- };
146
-
147
- export type {
148
- PageSpec,
149
- TextStyle,
150
- StyleMap,
151
- AssetSpec,
152
- LayoutElement,
153
- BaseElement,
154
- ViewportElement,
155
- TextElement,
156
- ImageElement,
157
- LineElement,
158
- RectElement,
159
- CircleElement,
160
- PolylineElement,
161
- PolygonElement,
162
- PointMm,
163
- ScaleBarElement,
164
- QrElement,
165
- LabelsElement,
166
- LegendElement,
167
- GroupElement,
168
- LayoutTemplate,
169
- LegendAnchor,
170
- LegendColumn,
171
- SvgModelView,
172
- ModelContext,
173
- LayoutData,
174
- LayoutDataViewports,
175
- ViewportItemsMap,
176
- PagePresetKey,
177
- PageOrientation,
178
- ValidationOptions,
179
- ValidationIssue,
180
- ValidationResult,
181
- ComputedViewportScale,
182
- NormalizedProductItem,
183
- SectionStateValidationResult,
184
- LabelStyle,
185
- WhereCondition,
186
- ClipConfig,
187
- HighlightConfig,
188
- ViewportLineStyle,
189
- ViewportLineStyles,
190
- SectionResult,
191
- FillRule,
192
- RenderDrawingInput,
193
- RenderDrawingResult,
194
- IncludeClassesFilter,
195
- ResolvedIncludeFilter,
196
- TemplateVariableDef,
197
- TemplateVariableInfo
198
- } from './types';
199
-
200
- export type { RenderLayoutOptions } from './options';
201
-
202
- const validateLayoutTemplate = validateLayoutTemplateExternal;
203
-
204
- export { pointInPolygon, filterItemsInPolygons, filterEntriesByClip, offsetPolygons, projectPolygonsToLocal };
205
- /** Normalize IFC class names to lowercase for consistent matching. */
206
- export { normalizeClass } from './filters';
207
- /** Test whether an item matches a class + where filter (used by clip, highlight, labels, legend). */
208
- export { matchesV2Filter } from './filters';
209
- /** Metadata utilities: build lookup maps, resolve IFC class includes, and merge metadata sources. */
210
- export {
211
- buildMetadataMap,
212
- normalizePropertySets,
213
- resolveIfcClassName,
214
- resolveIfcClassId,
215
- resolveIncludeClasses,
216
- resolveAllowedClasses,
217
- resolveMeta
218
- } from './metadata';
219
- /** Validate a layout template JSON structure. Returns `{ ok, errors }` or `{ ok, template }`. */
220
- export { validateLayoutTemplate as validateLayoutTemplateExternal } from './validation';
221
- /** Layout utility functions: scale denominators, label sizing, color parsing, and line style application. */
222
- export { uniquePositiveDenoms, computeLabelFontSize, colorFromString, applyLineStyleToPaths } from './utils';
223
- /** Property resolution: traverse nested objects with dot-path strings like `"BIP.spacename"`. */
224
- export { getPathValue, resolveProperty, resolvePath, matchesPropertyFilter } from './renderUtils';
225
-
226
- const SVG_NS = 'http://www.w3.org/2000/svg';
227
-
228
- /** Build legend table rows from product items with property formatting. */
229
- export { buildLegendRows, type LegendRow, type LegendRowOptions } from './legendRows';
230
- /** Product normalization, viewport item builders, and section-state-to-SVG-model conversion. */
231
- export type { ProductLike, BuildViewportItemsOptions } from './itemBuilders';
232
- export { normalizeProductEntry, buildViewportItems, sectionStateToModelView } from './itemBuilders';
233
- /**
234
- * High-level facade: renders a complete drawing from a template + section geometry.
235
- * Internalizes all data wiring (items, colors, labels, metadata) that `renderLayout` requires manually.
236
- */
237
- export {
238
- renderDrawing,
239
- resolveFillColor,
240
- BUILTIN_LINE_DEFAULTS,
241
- BUILTIN_HIDDEN_DEFAULTS,
242
- BUILTIN_LABEL_DEFAULTS,
243
- CATEGORY_BUILTIN_OVERRIDES,
244
- setRenderLayoutBinding
245
- } from './renderDrawing';
246
- export type { ResolvedLineStyles } from './renderDrawing';
247
-
248
- export { EXAMPLE_BUILDING_CLASSES } from './presets';
249
-
250
- export { analyzeTemplate } from './analyzeTemplate';
251
- export type { TemplateRequirements } from './analyzeTemplate';
252
-
253
- /** Drawing export utilities: convert SVG markup to PDF or SVG files. */
254
- export { exportToPdf, exportToSvg } from './exportDrawing';
255
- export type { ExportPdfOptions, ExportSvgOptions } from './exportDrawing';
256
-
257
- /** Template variable utilities: extract, validate, and resolve template parameters. */
258
- export {
259
- getTemplateVariables,
260
- getTemplateDefaults,
261
- resolveTemplateValues,
262
- applyTemplateVariables
263
- } from './templateVariables';
264
-
265
- /** Result of {@link renderLayout}: the final SVG markup, per-viewport scale info, and any warnings. */
266
- export interface RenderLayoutResult {
267
- /** Complete SVG markup string ready for embedding or PDF conversion. */
268
- svg: string;
269
- /** Per-viewport computed scale information (denominator, mm-per-model-unit, fit status). */
270
- viewportScales: Record<string, ComputedViewportScale>;
271
- /** Non-fatal issues encountered during rendering (missing assets, unresolved styles, etc.). */
272
- warnings: string[];
273
- }
274
-
275
- /** Built-in default layout template (A3 landscape with a single viewport). */
276
- export const DEFAULT_LAYOUT_TEMPLATE = defaultTemplateJson as LayoutTemplate;
277
-
278
- const dashArray = (dashed?: boolean): string => (dashed ? ' stroke-dasharray="4 2"' : '');
279
-
280
- const formatNumber = (value: number): number => {
281
- if (!Number.isFinite(value)) return 0;
282
- return Number.parseFloat(value.toFixed(3));
283
- };
284
-
285
- /**
286
- * Render a layout template to SVG.
287
- *
288
- * This is the low-level rendering function. For most use cases, prefer {@link renderDrawing}
289
- * which handles all data wiring automatically.
290
- *
291
- * @param template - Validated layout template (page, elements, styles, presets).
292
- * @param data - Layout data with viewport items, template variables, and legend rows.
293
- * @param models - Per-viewport SVG model views (geometry + transforms).
294
- * @param options - Optional rendering settings (remote assets, scale overrides, metadata).
295
- * @returns SVG markup string, computed viewport scales, and any warnings.
296
- */
297
- export async function renderLayout(
298
- template: LayoutTemplate,
299
- data: LayoutData,
300
- models: ModelContext,
301
- options?: RenderLayoutOptions
302
- ): Promise<RenderLayoutResult> {
303
- const presets = resolvePresets(template?.presets);
304
-
305
- const validation = validateLayoutTemplate(template, {
306
- allowRemoteAssets: options?.allowRemoteAssets,
307
- allowDataUrls: options?.allowDataUrls,
308
- maxViewports: options?.maxViewports
309
- });
310
- if (!validation.ok) {
311
- throw new Error(
312
- `Template validation failed: ${validation.errors.map((e: ValidationIssue) => `${e.path}: ${e.message}`).join('; ')}`
313
- );
314
- }
315
-
316
- const preferredScales = uniquePositiveDenoms(options?.scaleDenominators);
317
- const page = template.page;
318
- const warnings: string[] = [];
319
-
320
- // Flatten all elements to collect viewports, qrs, labels, legends regardless of grouping
321
- const allElements: LayoutElement[] = [];
322
- const collectElements = (elements: LayoutElement[]): void => {
323
- elements.forEach((el) => {
324
- allElements.push(el);
325
- if (el.kind === ELEMENT_KIND_GROUP) {
326
- collectElements((el as GroupElement).children);
327
- }
328
- });
329
- };
330
- collectElements(template.elements);
331
-
332
- const viewports = allElements.filter((el) => el.kind === ELEMENT_KIND_VIEWPORT) as ViewportElement[];
333
-
334
- const dataValidation = validateLayoutData(
335
- data,
336
- viewports.map((v) => v.id)
337
- );
338
- if (!dataValidation.ok) {
339
- throw new Error(`Layout data validation failed: ${dataValidation.errors.join('; ')}`);
340
- }
341
-
342
- const {
343
- viewportScales,
344
- viewportClipPolygonsLocal,
345
- viewportClipPolygonsWorld,
346
- viewportClipBounds,
347
- viewportClipLabels,
348
- viewportClipStyles,
349
- // viewportClipWhere intentionally omitted — not needed for rendering
350
- viewportHighlight,
351
- filteredModelsByViewport
352
- } = prepareViewports(viewports, models, data, preferredScales, presets, {
353
- metadataById: options?.metadataById,
354
- warnings
355
- });
356
-
357
- const viewportData: Record<
358
- string,
359
- {
360
- scaleDenom: number;
361
- scaleText: string;
362
- mmPerModelUnit: number;
363
- fits: boolean;
364
- }
365
- > = {};
366
-
367
- Object.entries(viewportScales).forEach(([id, scale]) => {
368
- viewportData[id] = {
369
- scaleDenom: scale.denom,
370
- scaleText: `1:${scale.denom}`,
371
- mmPerModelUnit: scale.mmPerModelUnit,
372
- fits: scale.fits
373
- };
374
- });
375
-
376
- const dataWithViewport = { ...data, viewport: viewportData };
377
-
378
- const sorted = [...template.elements].sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
379
-
380
- const parts: string[] = [];
381
- parts.push(
382
- `<svg xmlns="${SVG_NS}" width="${page.widthMm}mm" height="${page.heightMm}mm" viewBox="0 0 ${page.widthMm} ${page.heightMm}">`
383
- );
384
-
385
- if (page.backgroundColor) {
386
- parts.push(
387
- `<rect x="0" y="0" width="${page.widthMm}" height="${page.heightMm}" fill="${escapeAttr(page.backgroundColor)}"/>`
388
- );
389
- }
390
-
391
- const assetMap = new Map<string, AssetSpec>();
392
- (template.assets ?? []).forEach((asset) => assetMap.set(asset.id, asset));
393
-
394
- const qrElements = allElements.filter((el) => el.kind === ELEMENT_KIND_QR) as QrElement[];
395
- const labelElements = allElements.filter((el) => el.kind === ELEMENT_KIND_LABELS) as LabelsElement[];
396
- const viewportRenderInfo: Record<string, ViewportRenderInfo> = {};
397
-
398
- // Pre-process legends to collect color maps (needed before viewport rendering)
399
- const legendColorMaps = new Map<string, Map<string, string>>(); // viewport ID -> color map
400
- const legendElements = allElements.filter((el) => el.kind === ELEMENT_KIND_LEGEND) as LegendElement[];
401
- legendElements.forEach((legend) => {
402
- if (legend.assignColors?.enabled && legend.groupBy) {
403
- const legendItems = getPathValue(data, `viewports.${legend.viewport}.items`);
404
- const rawLegendItems = Array.isArray(legendItems) ? legendItems : [];
405
- const legendDataFiltered = applyLegendFilter(
406
- rawLegendItems,
407
- legend,
408
- viewports,
409
- viewportClipPolygonsLocal,
410
- viewportRenderInfo
411
- );
412
- if (legendDataFiltered && legendDataFiltered.length) {
413
- const vpEl = viewports.find((v) => v.id === legend.viewport);
414
- const fillOpacity = vpEl?.fills?.length ? (vpEl.fills[0].fillOpacity ?? 0.35) : undefined;
415
- const result = buildLegendGroup(
416
- legend,
417
- template.styles,
418
- page,
419
- page.marginMm,
420
- legendDataFiltered,
421
- fillOpacity
422
- );
423
- if (result?.colorMap) {
424
- legendColorMaps.set(legend.viewport, result.colorMap);
425
- console.log(
426
- `[LEGEND PRE-PROCESS] Stored color map for viewport "${legend.viewport}": ${result.colorMap.size} colors`
427
- );
428
- }
429
- }
430
- }
431
- });
432
-
433
- sorted.forEach((el) => {
434
- if (el.kind === ELEMENT_KIND_RECT) {
435
- const r = el as RectElement;
436
- parts.push(
437
- `<rect x="${r.xMm}" y="${r.yMm}" width="${r.widthMm}" height="${r.heightMm}"` +
438
- (r.stroke ? ` stroke="${escapeAttr(r.stroke)}"` : '') +
439
- (r.strokeWidthMm ? ` stroke-width="${r.strokeWidthMm}"` : '') +
440
- (r.fill ? ` fill="${escapeAttr(r.fill)}"` : ' fill="none"') +
441
- dashArray(r.dashed) +
442
- ` />`
443
- );
444
- } else if (el.kind === ELEMENT_KIND_LINE) {
445
- const l = el as LineElement;
446
- parts.push(
447
- `<line x1="${l.x1Mm}" y1="${l.y1Mm}" x2="${l.x2Mm}" y2="${l.y2Mm}"` +
448
- (l.stroke ? ` stroke="${escapeAttr(l.stroke)}"` : '') +
449
- (l.strokeWidthMm ? ` stroke-width="${l.strokeWidthMm}"` : '') +
450
- dashArray(l.dashed) +
451
- ` />`
452
- );
453
- } else if (el.kind === ELEMENT_KIND_CIRCLE) {
454
- const c = el as CircleElement;
455
- parts.push(
456
- `<circle cx="${c.cxMm}" cy="${c.cyMm}" r="${c.rMm}"` +
457
- (c.stroke ? ` stroke="${escapeAttr(c.stroke)}"` : '') +
458
- (c.strokeWidthMm ? ` stroke-width="${c.strokeWidthMm}"` : '') +
459
- (c.fill ? ` fill="${escapeAttr(c.fill)}"` : ' fill="none"') +
460
- dashArray(c.dashed) +
461
- ` />`
462
- );
463
- } else if (el.kind === ELEMENT_KIND_POLYLINE) {
464
- const pl = el as PolylineElement;
465
- const pointsStr = pl.points.map((p) => `${formatNumber(p.xMm)},${formatNumber(p.yMm)}`).join(' ');
466
- parts.push(
467
- `<polyline points="${pointsStr}" stroke="${escapeAttr(pl.stroke)}" stroke-width="${pl.strokeWidthMm}" fill="none"${dashArray(pl.dashed)} />`
468
- );
469
- } else if (el.kind === ELEMENT_KIND_POLYGON) {
470
- const pg = el as PolygonElement;
471
- const pointsStr = pg.points.map((p) => `${formatNumber(p.xMm)},${formatNumber(p.yMm)}`).join(' ');
472
- parts.push(
473
- `<polygon points="${pointsStr}"` +
474
- (pg.stroke ? ` stroke="${escapeAttr(pg.stroke)}"` : '') +
475
- (pg.strokeWidthMm ? ` stroke-width="${pg.strokeWidthMm}"` : '') +
476
- (pg.fill ? ` fill="${escapeAttr(pg.fill)}"` : ' fill="none"') +
477
- dashArray(pg.dashed) +
478
- ` />`
479
- );
480
- } else if (el.kind === ELEMENT_KIND_TEXT) {
481
- const tEl = el as TextElement;
482
- const styleName = tEl.textStyle;
483
- const style = styleName ? template.styles?.[styleName] : undefined;
484
- if (!style) {
485
- pushWarning(warnings, `Missing text style '${styleName ?? '(none)'}' for element '${tEl.id}'.`);
486
- return;
487
- }
488
- const text = interpolate(tEl.text, dataWithViewport, warnings);
489
- const anchor = tEl.align === 'center' ? 'middle' : tEl.align === 'right' ? 'end' : 'start';
490
- parts.push(
491
- `<text x="${tEl.xMm}" y="${tEl.yMm}" font-family="${escapeAttr(style.fontFamily)}" font-size="${style.fontSizeMm}"` +
492
- (style.fontWeight ? ` font-weight="${escapeAttr(style.fontWeight)}"` : '') +
493
- (style.color ? ` fill="${escapeAttr(style.color)}"` : '') +
494
- (style.letterSpacingMm !== undefined ? ` letter-spacing="${style.letterSpacingMm}"` : '') +
495
- (style.lineHeightMm !== undefined ? ` line-height="${style.lineHeightMm}"` : '') +
496
- ` text-anchor="${anchor}">${escapeText(text)}</text>`
497
- );
498
- } else if (el.kind === ELEMENT_KIND_IMAGE) {
499
- const img = el as ImageElement;
500
- const asset = assetMap.get(img.assetId);
501
- if (!asset) {
502
- pushWarning(warnings, `Missing asset '${img.assetId}' for image '${img.id}'.`);
503
- return;
504
- }
505
- if (asset.src) {
506
- parts.push(
507
- `<image href="${escapeAttr(asset.src)}" x="${img.xMm}" y="${img.yMm}" width="${img.widthMm}" height="${img.heightMm}"` +
508
- (img.fit === 'cover'
509
- ? ' preserveAspectRatio="xMidYMid slice"'
510
- : img.fit === 'stretch'
511
- ? ' preserveAspectRatio="none"'
512
- : ' preserveAspectRatio="xMidYMid meet"') +
513
- ` />`
514
- );
515
- } else if (asset.content) {
516
- parts.push(
517
- `<g transform="translate(${img.xMm} ${img.yMm}) scale(${img.widthMm} ${img.heightMm})">${asset.content}</g>`
518
- );
519
- }
520
- } else if (el.kind === ELEMENT_KIND_SCALE_BAR) {
521
- const sb = el as ScaleBarElement;
522
- // Scale bar declares which viewport it binds to via `viewport`
523
- const boundViewport = sb.viewport ?? viewports[0]?.id ?? null;
524
- if (!boundViewport) {
525
- pushWarning(warnings, `Scale bar '${sb.id}' is not bound to a viewport.`);
526
- return;
527
- }
528
- const scaleInfo = viewportScales[boundViewport];
529
- if (!scaleInfo) {
530
- pushWarning(warnings, `Viewport '${boundViewport}' missing scale info for scale bar '${sb.id}'.`);
531
- return;
532
- }
533
- const lengthMm = sb.maxLengthMm;
534
- const segments = sb.segments ?? 10;
535
- const segmentMm = lengthMm / segments;
536
- const metersPerMm = 1 / scaleInfo.mmPerModelUnit;
537
- const segmentMeters = segmentMm * metersPerMm;
538
- const labelsEvery = Math.max(1, sb.labelEvery ?? 1);
539
- const unitSuffix = sb.unit ? ` ${sb.unit}` : ' m';
540
- const barParts: string[] = [];
541
- const sbStroke = sb.stroke ?? '#000';
542
- const sbStrokeWidth = sb.strokeWidthMm ?? 0.5;
543
-
544
- for (let i = 0; i <= segments; i++) {
545
- const x = sb.xMm + segmentMm * i;
546
- const isLabel = i % labelsEvery === 0;
547
- const height = isLabel ? 4 : 3;
548
- barParts.push(
549
- `<line x1="${x}" y1="${sb.yMm}" x2="${x}" y2="${sb.yMm - height}" stroke="${escapeAttr(
550
- sbStroke
551
- )}" stroke-width="${sbStrokeWidth}" />`
552
- );
553
- if (isLabel && sb.textStyle) {
554
- const style = template.styles?.[sb.textStyle];
555
- if (style) {
556
- const labelValue = (segmentMeters * i).toFixed(2).replace(/\.?0+$/, '');
557
- barParts.push(
558
- `<text x="${x}" y="${sb.yMm + style.fontSizeMm + 1}" font-family="${escapeAttr(
559
- style.fontFamily
560
- )}" font-size="${style.fontSizeMm}" text-anchor="middle">${escapeText(labelValue + unitSuffix)}</text>`
561
- );
562
- }
563
- }
564
- }
565
-
566
- barParts.push(
567
- `<line x1="${sb.xMm}" y1="${sb.yMm}" x2="${sb.xMm + lengthMm}" y2="${sb.yMm}" stroke="${escapeAttr(
568
- sbStroke
569
- )}" stroke-width="${sbStrokeWidth}" />`
570
- );
571
-
572
- parts.push(barParts.join(''));
573
- } else if (el.kind === ELEMENT_KIND_VIEWPORT) {
574
- const vp = el as ViewportElement;
575
- const model = filteredModelsByViewport[vp.id] ?? models[vp.id] ?? null;
576
- if (!model) {
577
- pushWarning(warnings, `Viewport '${vp.id}' missing model.`);
578
- }
579
- const vpParts = renderViewportElement(
580
- vp,
581
- model,
582
- dataWithViewport,
583
- preferredScales,
584
- presets.scales,
585
- assetMap,
586
- viewportScales,
587
- viewportClipPolygonsLocal,
588
- viewportClipBounds,
589
- viewportClipLabels,
590
- viewportClipStyles,
591
- viewportHighlight,
592
- viewportRenderInfo,
593
- warnings,
594
- legendColorMaps
595
- );
596
- parts.push(...vpParts);
597
- } else if (el.kind === ELEMENT_KIND_LEGEND) {
598
- const legend = el as LegendElement;
599
- const legendItems = getPathValue(data, `viewports.${legend.viewport}.items`);
600
- const rawLegendItems = Array.isArray(legendItems) ? legendItems : [];
601
- const legendDataFiltered = applyLegendFilter(
602
- rawLegendItems,
603
- legend,
604
- viewports,
605
- viewportClipPolygonsLocal,
606
- viewportRenderInfo
607
- );
608
- if (!legendDataFiltered || !legendDataFiltered.length) {
609
- if (!rawLegendItems.length) {
610
- pushWarning(warnings, `Legend '${legend.id}' has no items from viewport '${legend.viewport}'.`);
611
- } else {
612
- pushWarning(
613
- warnings,
614
- `Legend '${legend.id}' filter matched 0 of ${rawLegendItems.length} items (class=${JSON.stringify(legend.class)}, where=${JSON.stringify(legend.where)}).`
615
- );
616
- }
617
- }
618
- const vpEl = viewports.find((v) => v.id === legend.viewport);
619
- // Derive swatch opacity from viewport fill rules (first matching rule's opacity)
620
- const fillOpacity = vpEl?.fills?.length ? (vpEl.fills[0].fillOpacity ?? 0.35) : undefined;
621
- const legendResult = buildLegendGroup(
622
- legend,
623
- template.styles,
624
- page,
625
- page.marginMm,
626
- legendDataFiltered || [],
627
- fillOpacity
628
- );
629
- if (legendResult) {
630
- parts.push(legendResult.svg);
631
- }
632
- }
633
- });
634
-
635
- if (labelElements.length) {
636
- const labelParts = renderLabels(
637
- labelElements,
638
- data,
639
- viewportRenderInfo,
640
- viewportClipLabels,
641
- viewportClipPolygonsLocal,
642
- viewportClipPolygonsWorld,
643
- template.styles ?? {},
644
- dataWithViewport,
645
- warnings
646
- );
647
- parts.push(...labelParts);
648
- }
649
-
650
- if (qrElements.length) {
651
- const qrParts = await renderQrElements(
652
- qrElements,
653
- viewports,
654
- viewportScales,
655
- models,
656
- { qrContext: options?.qrContext },
657
- dataWithViewport,
658
- warnings
659
- );
660
- parts.push(...qrParts);
661
- }
662
-
663
- parts.push('</svg>');
664
-
665
- return { svg: parts.join(''), viewportScales, warnings };
666
- }
667
-
668
- // Register renderLayout with renderDrawing to break the circular dependency.
669
- // This runs at module evaluation time — safe because renderLayout is defined above.
670
- import { setRenderLayoutBinding as _setBinding } from './renderDrawing';
671
- _setBinding(renderLayout as any);
1
+ import type { SectionRenderState } from '@twinfinity/geometry';
2
+ import defaultTemplateJson from '../templates/default-template.json';
3
+ import {
4
+ pointInPolygon,
5
+ filterItemsInPolygons,
6
+ filterEntriesByClip,
7
+ offsetPolygons,
8
+ projectPolygonsToLocal
9
+ } from './clipUtils';
10
+ import { uniquePositiveDenoms } from './utils';
11
+ import { validateLayoutTemplate as validateLayoutTemplateExternal } from './validation';
12
+ import { renderViewportElement } from './viewportRender';
13
+ import { prepareViewports } from './viewportPrep';
14
+ import { renderLabels, ViewportRenderInfo } from './labelsRender';
15
+ import { renderQrElements } from './qrRender';
16
+ import { applyLegendFilter, buildLegendGroup } from './legendRender';
17
+ import { RenderLayoutOptions } from './options';
18
+ import { resolvePresets } from './presets';
19
+ import { pushWarning } from './warnings';
20
+ import { escapeAttr, escapeText, interpolate, getPathValue } from './renderUtils';
21
+ import { validateSectionState } from './sectionValidation';
22
+ import {
23
+ ELEMENT_KIND_VIEWPORT,
24
+ ELEMENT_KIND_TEXT,
25
+ ELEMENT_KIND_IMAGE,
26
+ ELEMENT_KIND_LINE,
27
+ ELEMENT_KIND_RECT,
28
+ ELEMENT_KIND_CIRCLE,
29
+ ELEMENT_KIND_POLYLINE,
30
+ ELEMENT_KIND_POLYGON,
31
+ ELEMENT_KIND_SCALE_BAR,
32
+ ELEMENT_KIND_QR,
33
+ ELEMENT_KIND_LABELS,
34
+ ELEMENT_KIND_LEGEND,
35
+ ELEMENT_KIND_GROUP
36
+ } from './elementKinds';
37
+ import {
38
+ AssetSpec,
39
+ LayoutElement,
40
+ ViewportElement,
41
+ TextElement,
42
+ ImageElement,
43
+ LineElement,
44
+ RectElement,
45
+ CircleElement,
46
+ PolylineElement,
47
+ PolygonElement,
48
+ ScaleBarElement,
49
+ QrElement,
50
+ LabelsElement,
51
+ LegendElement,
52
+ GroupElement,
53
+ LayoutTemplate,
54
+ ModelContext,
55
+ LayoutData,
56
+ ValidationIssue,
57
+ ComputedViewportScale
58
+ } from './types';
59
+
60
+ export type LayoutDataValidationResult =
61
+ | { ok: true }
62
+ | {
63
+ ok: false;
64
+ errors: string[];
65
+ };
66
+
67
+ export type PreflightResult =
68
+ | { ok: true }
69
+ | {
70
+ ok: false;
71
+ templateErrors?: ValidationIssue[];
72
+ dataErrors?: string[];
73
+ sectionErrors?: string[];
74
+ };
75
+
76
+ import { isRecord, getProp } from './typeGuards';
77
+
78
+ /**
79
+ * Validate template, layout data, and section state in a single call.
80
+ * Combines `validateLayoutTemplate`, `validateLayoutData`, and `validateSectionState`.
81
+ */
82
+ export const preflight = (
83
+ template: LayoutTemplate,
84
+ data: unknown,
85
+ expectedViewportIds: string[],
86
+ sectionState?: SectionRenderState
87
+ ): PreflightResult => {
88
+ const templateValidation = validateLayoutTemplate(template);
89
+ const dataValidation = validateLayoutData(data, expectedViewportIds);
90
+ const sectionValidation = sectionState ? validateSectionState(sectionState) : { ok: true as const };
91
+
92
+ const ok = templateValidation.ok && dataValidation.ok && sectionValidation.ok;
93
+ if (ok) return { ok: true };
94
+
95
+ return {
96
+ ok: false,
97
+ templateErrors: templateValidation.ok ? undefined : templateValidation.errors,
98
+ dataErrors: dataValidation.ok ? undefined : dataValidation.errors,
99
+ sectionErrors: sectionValidation.ok ? undefined : sectionValidation.errors
100
+ };
101
+ };
102
+
103
+ /**
104
+ * Validate layout data before rendering to catch missing propertySets or items arrays.
105
+ */
106
+ export const validateLayoutData = (data: unknown, expectedViewportIds: string[]): LayoutDataValidationResult => {
107
+ const errors: string[] = [];
108
+ if (!isRecord(data)) {
109
+ return { ok: false, errors: ['layout data must be an object'] };
110
+ }
111
+ expectedViewportIds.forEach((vpId) => {
112
+ const viewports = getProp(data, 'viewports');
113
+ const viewport = isRecord(viewports) ? getProp(viewports, vpId) : undefined;
114
+ const items = getProp(viewport, 'items');
115
+ if (!Array.isArray(items)) {
116
+ errors.push(`viewports.${vpId}.items is required and must be an array.`);
117
+ return;
118
+ }
119
+ items.forEach((it: unknown, idx: number) => {
120
+ if (!isRecord(it)) {
121
+ errors.push(`viewports.${vpId}.items[${idx}] must be an object.`);
122
+ return;
123
+ }
124
+ const sets = getProp(it, 'propertySets');
125
+ if (!isRecord(sets)) {
126
+ errors.push(`viewports.${vpId}.items[${idx}] is missing propertySets.<Set>.<Prop>.value.`);
127
+ return;
128
+ }
129
+ Object.entries(sets).forEach(([setName, props]) => {
130
+ if (!isRecord(props)) {
131
+ errors.push(`viewports.${vpId}.items[${idx}].propertySets.${setName} must be an object.`);
132
+ return;
133
+ }
134
+ Object.entries(props).forEach(([propName, payload]) => {
135
+ if (!isRecord(payload) || !('value' in payload)) {
136
+ errors.push(
137
+ `viewports.${vpId}.items[${idx}].propertySets.${setName}.${propName} must include a value (propertySets.<Set>.<Prop>.value).`
138
+ );
139
+ }
140
+ });
141
+ });
142
+ });
143
+ });
144
+ return errors.length ? { ok: false, errors } : { ok: true };
145
+ };
146
+
147
+ export type {
148
+ PageSpec,
149
+ TextStyle,
150
+ StyleMap,
151
+ AssetSpec,
152
+ LayoutElement,
153
+ BaseElement,
154
+ ViewportElement,
155
+ TextElement,
156
+ ImageElement,
157
+ LineElement,
158
+ RectElement,
159
+ CircleElement,
160
+ PolylineElement,
161
+ PolygonElement,
162
+ PointMm,
163
+ ScaleBarElement,
164
+ QrElement,
165
+ LabelsElement,
166
+ LegendElement,
167
+ GroupElement,
168
+ LayoutTemplate,
169
+ LegendAnchor,
170
+ LegendColumn,
171
+ SvgModelView,
172
+ ModelContext,
173
+ LayoutData,
174
+ LayoutDataViewports,
175
+ ViewportItemsMap,
176
+ PagePresetKey,
177
+ PageOrientation,
178
+ ValidationOptions,
179
+ ValidationIssue,
180
+ ValidationResult,
181
+ ComputedViewportScale,
182
+ NormalizedProductItem,
183
+ SectionStateValidationResult,
184
+ LabelStyle,
185
+ WhereCondition,
186
+ ClipConfig,
187
+ HighlightConfig,
188
+ ViewportLineStyle,
189
+ ViewportLineStyles,
190
+ SectionResult,
191
+ FillRule,
192
+ RenderDrawingInput,
193
+ RenderDrawingResult,
194
+ IncludeClassesFilter,
195
+ ResolvedIncludeFilter,
196
+ TemplateVariableDef,
197
+ TemplateVariableInfo
198
+ } from './types';
199
+
200
+ export type { RenderLayoutOptions } from './options';
201
+
202
+ const validateLayoutTemplate = validateLayoutTemplateExternal;
203
+
204
+ export { pointInPolygon, filterItemsInPolygons, filterEntriesByClip, offsetPolygons, projectPolygonsToLocal };
205
+ /** Normalize IFC class names to lowercase for consistent matching. */
206
+ export { normalizeClass } from './filters';
207
+ /** Test whether an item matches a class + where filter (used by clip, highlight, labels, legend). */
208
+ export { matchesV2Filter } from './filters';
209
+ /** Metadata utilities: build lookup maps, resolve IFC class includes, and merge metadata sources. */
210
+ export {
211
+ buildMetadataMap,
212
+ normalizePropertySets,
213
+ resolveIfcClassName,
214
+ resolveIfcClassId,
215
+ resolveIncludeClasses,
216
+ resolveAllowedClasses,
217
+ resolveMeta
218
+ } from './metadata';
219
+ /** Validate a layout template JSON structure. Returns `{ ok, errors }` or `{ ok, template }`. */
220
+ export { validateLayoutTemplate as validateLayoutTemplateExternal } from './validation';
221
+ /** Layout utility functions: scale denominators, label sizing, color parsing, and line style application. */
222
+ export { uniquePositiveDenoms, computeLabelFontSize, colorFromString, applyLineStyleToPaths } from './utils';
223
+ /** Property resolution: traverse nested objects with dot-path strings like `"BIP.spacename"`. */
224
+ export { getPathValue, resolveProperty, resolvePath, matchesPropertyFilter } from './renderUtils';
225
+
226
+ const SVG_NS = 'http://www.w3.org/2000/svg';
227
+
228
+ /** Build legend table rows from product items with property formatting. */
229
+ export { buildLegendRows, type LegendRow, type LegendRowOptions } from './legendRows';
230
+ /** Product normalization, viewport item builders, and section-state-to-SVG-model conversion. */
231
+ export type { ProductLike, BuildViewportItemsOptions } from './itemBuilders';
232
+ export { normalizeProductEntry, buildViewportItems, sectionStateToModelView } from './itemBuilders';
233
+ /**
234
+ * High-level facade: renders a complete drawing from a template + section geometry.
235
+ * Internalizes all data wiring (items, colors, labels, metadata) that `renderLayout` requires manually.
236
+ */
237
+ export {
238
+ renderDrawing,
239
+ resolveFillColor,
240
+ BUILTIN_LINE_DEFAULTS,
241
+ BUILTIN_HIDDEN_DEFAULTS,
242
+ BUILTIN_LABEL_DEFAULTS,
243
+ CATEGORY_BUILTIN_OVERRIDES,
244
+ setRenderLayoutBinding
245
+ } from './renderDrawing';
246
+ export type { ResolvedLineStyles } from './renderDrawing';
247
+
248
+ export { EXAMPLE_BUILDING_CLASSES } from './presets';
249
+
250
+ export { analyzeTemplate } from './analyzeTemplate';
251
+ export type { TemplateRequirements } from './analyzeTemplate';
252
+
253
+ /** Drawing export utilities: convert SVG markup to PDF or SVG files. */
254
+ export { exportToPdf, exportToSvg } from './exportDrawing';
255
+ export type { ExportPdfOptions, ExportSvgOptions } from './exportDrawing';
256
+
257
+ /** Template variable utilities: extract, validate, and resolve template parameters. */
258
+ export {
259
+ getTemplateVariables,
260
+ getTemplateDefaults,
261
+ resolveTemplateValues,
262
+ applyTemplateVariables
263
+ } from './templateVariables';
264
+
265
+ /** Result of {@link renderLayout}: the final SVG markup, per-viewport scale info, and any warnings. */
266
+ export interface RenderLayoutResult {
267
+ /** Complete SVG markup string ready for embedding or PDF conversion. */
268
+ svg: string;
269
+ /** Per-viewport computed scale information (denominator, mm-per-model-unit, fit status). */
270
+ viewportScales: Record<string, ComputedViewportScale>;
271
+ /** Non-fatal issues encountered during rendering (missing assets, unresolved styles, etc.). */
272
+ warnings: string[];
273
+ }
274
+
275
+ /** Built-in default layout template (A3 landscape with a single viewport). */
276
+ export const DEFAULT_LAYOUT_TEMPLATE = defaultTemplateJson as LayoutTemplate;
277
+
278
+ const dashArray = (dashed?: boolean): string => (dashed ? ' stroke-dasharray="4 2"' : '');
279
+
280
+ const formatNumber = (value: number): number => {
281
+ if (!Number.isFinite(value)) return 0;
282
+ return Number.parseFloat(value.toFixed(3));
283
+ };
284
+
285
+ /**
286
+ * Render a layout template to SVG.
287
+ *
288
+ * This is the low-level rendering function. For most use cases, prefer {@link renderDrawing}
289
+ * which handles all data wiring automatically.
290
+ *
291
+ * @param template - Validated layout template (page, elements, styles, presets).
292
+ * @param data - Layout data with viewport items, template variables, and legend rows.
293
+ * @param models - Per-viewport SVG model views (geometry + transforms).
294
+ * @param options - Optional rendering settings (remote assets, scale overrides, metadata).
295
+ * @returns SVG markup string, computed viewport scales, and any warnings.
296
+ */
297
+ export async function renderLayout(
298
+ template: LayoutTemplate,
299
+ data: LayoutData,
300
+ models: ModelContext,
301
+ options?: RenderLayoutOptions
302
+ ): Promise<RenderLayoutResult> {
303
+ const presets = resolvePresets(template?.presets);
304
+
305
+ const validation = validateLayoutTemplate(template, {
306
+ allowRemoteAssets: options?.allowRemoteAssets,
307
+ allowDataUrls: options?.allowDataUrls,
308
+ maxViewports: options?.maxViewports
309
+ });
310
+ if (!validation.ok) {
311
+ throw new Error(
312
+ `Template validation failed: ${validation.errors.map((e: ValidationIssue) => `${e.path}: ${e.message}`).join('; ')}`
313
+ );
314
+ }
315
+
316
+ const preferredScales = uniquePositiveDenoms(options?.scaleDenominators);
317
+ const page = template.page;
318
+ const warnings: string[] = [];
319
+
320
+ // Flatten all elements to collect viewports, qrs, labels, legends regardless of grouping
321
+ const allElements: LayoutElement[] = [];
322
+ const collectElements = (elements: LayoutElement[]): void => {
323
+ elements.forEach((el) => {
324
+ allElements.push(el);
325
+ if (el.kind === ELEMENT_KIND_GROUP) {
326
+ collectElements((el as GroupElement).children);
327
+ }
328
+ });
329
+ };
330
+ collectElements(template.elements);
331
+
332
+ const viewports = allElements.filter((el) => el.kind === ELEMENT_KIND_VIEWPORT) as ViewportElement[];
333
+
334
+ const dataValidation = validateLayoutData(
335
+ data,
336
+ viewports.map((v) => v.id)
337
+ );
338
+ if (!dataValidation.ok) {
339
+ throw new Error(`Layout data validation failed: ${dataValidation.errors.join('; ')}`);
340
+ }
341
+
342
+ const {
343
+ viewportScales,
344
+ viewportClipPolygonsLocal,
345
+ viewportClipPolygonsWorld,
346
+ viewportClipBounds,
347
+ viewportClipLabels,
348
+ viewportClipStyles,
349
+ // viewportClipWhere intentionally omitted — not needed for rendering
350
+ viewportHighlight,
351
+ filteredModelsByViewport
352
+ } = prepareViewports(viewports, models, data, preferredScales, presets, {
353
+ metadataById: options?.metadataById,
354
+ warnings
355
+ });
356
+
357
+ const viewportData: Record<
358
+ string,
359
+ {
360
+ scaleDenom: number;
361
+ scaleText: string;
362
+ mmPerModelUnit: number;
363
+ fits: boolean;
364
+ }
365
+ > = {};
366
+
367
+ Object.entries(viewportScales).forEach(([id, scale]) => {
368
+ viewportData[id] = {
369
+ scaleDenom: scale.denom,
370
+ scaleText: `1:${scale.denom}`,
371
+ mmPerModelUnit: scale.mmPerModelUnit,
372
+ fits: scale.fits
373
+ };
374
+ });
375
+
376
+ const dataWithViewport = { ...data, viewport: viewportData };
377
+
378
+ const sorted = [...template.elements].sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
379
+
380
+ const parts: string[] = [];
381
+ parts.push(
382
+ `<svg xmlns="${SVG_NS}" width="${page.widthMm}mm" height="${page.heightMm}mm" viewBox="0 0 ${page.widthMm} ${page.heightMm}">`
383
+ );
384
+
385
+ if (page.backgroundColor) {
386
+ parts.push(
387
+ `<rect x="0" y="0" width="${page.widthMm}" height="${page.heightMm}" fill="${escapeAttr(page.backgroundColor)}"/>`
388
+ );
389
+ }
390
+
391
+ const assetMap = new Map<string, AssetSpec>();
392
+ (template.assets ?? []).forEach((asset) => assetMap.set(asset.id, asset));
393
+
394
+ const qrElements = allElements.filter((el) => el.kind === ELEMENT_KIND_QR) as QrElement[];
395
+ const labelElements = allElements.filter((el) => el.kind === ELEMENT_KIND_LABELS) as LabelsElement[];
396
+ const viewportRenderInfo: Record<string, ViewportRenderInfo> = {};
397
+
398
+ // Pre-process legends to collect color maps (needed before viewport rendering)
399
+ const legendColorMaps = new Map<string, Map<string, string>>(); // viewport ID -> color map
400
+ const legendElements = allElements.filter((el) => el.kind === ELEMENT_KIND_LEGEND) as LegendElement[];
401
+ legendElements.forEach((legend) => {
402
+ if (legend.assignColors?.enabled && legend.groupBy) {
403
+ const legendItems = getPathValue(data, `viewports.${legend.viewport}.items`);
404
+ const rawLegendItems = Array.isArray(legendItems) ? legendItems : [];
405
+ const legendDataFiltered = applyLegendFilter(
406
+ rawLegendItems,
407
+ legend,
408
+ viewports,
409
+ viewportClipPolygonsLocal,
410
+ viewportRenderInfo
411
+ );
412
+ if (legendDataFiltered && legendDataFiltered.length) {
413
+ const vpEl = viewports.find((v) => v.id === legend.viewport);
414
+ const fillOpacity = vpEl?.fills?.length ? (vpEl.fills[0].fillOpacity ?? 0.35) : undefined;
415
+ const result = buildLegendGroup(
416
+ legend,
417
+ template.styles,
418
+ page,
419
+ page.marginMm,
420
+ legendDataFiltered,
421
+ fillOpacity
422
+ );
423
+ if (result?.colorMap) {
424
+ legendColorMaps.set(legend.viewport, result.colorMap);
425
+ console.log(
426
+ `[LEGEND PRE-PROCESS] Stored color map for viewport "${legend.viewport}": ${result.colorMap.size} colors`
427
+ );
428
+ }
429
+ }
430
+ }
431
+ });
432
+
433
+ sorted.forEach((el) => {
434
+ if (el.kind === ELEMENT_KIND_RECT) {
435
+ const r = el as RectElement;
436
+ parts.push(
437
+ `<rect x="${r.xMm}" y="${r.yMm}" width="${r.widthMm}" height="${r.heightMm}"` +
438
+ (r.stroke ? ` stroke="${escapeAttr(r.stroke)}"` : '') +
439
+ (r.strokeWidthMm ? ` stroke-width="${r.strokeWidthMm}"` : '') +
440
+ (r.fill ? ` fill="${escapeAttr(r.fill)}"` : ' fill="none"') +
441
+ dashArray(r.dashed) +
442
+ ` />`
443
+ );
444
+ } else if (el.kind === ELEMENT_KIND_LINE) {
445
+ const l = el as LineElement;
446
+ parts.push(
447
+ `<line x1="${l.x1Mm}" y1="${l.y1Mm}" x2="${l.x2Mm}" y2="${l.y2Mm}"` +
448
+ (l.stroke ? ` stroke="${escapeAttr(l.stroke)}"` : '') +
449
+ (l.strokeWidthMm ? ` stroke-width="${l.strokeWidthMm}"` : '') +
450
+ dashArray(l.dashed) +
451
+ ` />`
452
+ );
453
+ } else if (el.kind === ELEMENT_KIND_CIRCLE) {
454
+ const c = el as CircleElement;
455
+ parts.push(
456
+ `<circle cx="${c.cxMm}" cy="${c.cyMm}" r="${c.rMm}"` +
457
+ (c.stroke ? ` stroke="${escapeAttr(c.stroke)}"` : '') +
458
+ (c.strokeWidthMm ? ` stroke-width="${c.strokeWidthMm}"` : '') +
459
+ (c.fill ? ` fill="${escapeAttr(c.fill)}"` : ' fill="none"') +
460
+ dashArray(c.dashed) +
461
+ ` />`
462
+ );
463
+ } else if (el.kind === ELEMENT_KIND_POLYLINE) {
464
+ const pl = el as PolylineElement;
465
+ const pointsStr = pl.points.map((p) => `${formatNumber(p.xMm)},${formatNumber(p.yMm)}`).join(' ');
466
+ parts.push(
467
+ `<polyline points="${pointsStr}" stroke="${escapeAttr(pl.stroke)}" stroke-width="${pl.strokeWidthMm}" fill="none"${dashArray(pl.dashed)} />`
468
+ );
469
+ } else if (el.kind === ELEMENT_KIND_POLYGON) {
470
+ const pg = el as PolygonElement;
471
+ const pointsStr = pg.points.map((p) => `${formatNumber(p.xMm)},${formatNumber(p.yMm)}`).join(' ');
472
+ parts.push(
473
+ `<polygon points="${pointsStr}"` +
474
+ (pg.stroke ? ` stroke="${escapeAttr(pg.stroke)}"` : '') +
475
+ (pg.strokeWidthMm ? ` stroke-width="${pg.strokeWidthMm}"` : '') +
476
+ (pg.fill ? ` fill="${escapeAttr(pg.fill)}"` : ' fill="none"') +
477
+ dashArray(pg.dashed) +
478
+ ` />`
479
+ );
480
+ } else if (el.kind === ELEMENT_KIND_TEXT) {
481
+ const tEl = el as TextElement;
482
+ const styleName = tEl.textStyle;
483
+ const style = styleName ? template.styles?.[styleName] : undefined;
484
+ if (!style) {
485
+ pushWarning(warnings, `Missing text style '${styleName ?? '(none)'}' for element '${tEl.id}'.`);
486
+ return;
487
+ }
488
+ const text = interpolate(tEl.text, dataWithViewport, warnings);
489
+ const anchor = tEl.align === 'center' ? 'middle' : tEl.align === 'right' ? 'end' : 'start';
490
+ parts.push(
491
+ `<text x="${tEl.xMm}" y="${tEl.yMm}" font-family="${escapeAttr(style.fontFamily)}" font-size="${style.fontSizeMm}"` +
492
+ (style.fontWeight ? ` font-weight="${escapeAttr(style.fontWeight)}"` : '') +
493
+ (style.color ? ` fill="${escapeAttr(style.color)}"` : '') +
494
+ (style.letterSpacingMm !== undefined ? ` letter-spacing="${style.letterSpacingMm}"` : '') +
495
+ (style.lineHeightMm !== undefined ? ` line-height="${style.lineHeightMm}"` : '') +
496
+ ` text-anchor="${anchor}">${escapeText(text)}</text>`
497
+ );
498
+ } else if (el.kind === ELEMENT_KIND_IMAGE) {
499
+ const img = el as ImageElement;
500
+ const asset = assetMap.get(img.assetId);
501
+ if (!asset) {
502
+ pushWarning(warnings, `Missing asset '${img.assetId}' for image '${img.id}'.`);
503
+ return;
504
+ }
505
+ if (asset.src) {
506
+ parts.push(
507
+ `<image href="${escapeAttr(asset.src)}" x="${img.xMm}" y="${img.yMm}" width="${img.widthMm}" height="${img.heightMm}"` +
508
+ (img.fit === 'cover'
509
+ ? ' preserveAspectRatio="xMidYMid slice"'
510
+ : img.fit === 'stretch'
511
+ ? ' preserveAspectRatio="none"'
512
+ : ' preserveAspectRatio="xMidYMid meet"') +
513
+ ` />`
514
+ );
515
+ } else if (asset.content) {
516
+ parts.push(
517
+ `<g transform="translate(${img.xMm} ${img.yMm}) scale(${img.widthMm} ${img.heightMm})">${asset.content}</g>`
518
+ );
519
+ }
520
+ } else if (el.kind === ELEMENT_KIND_SCALE_BAR) {
521
+ const sb = el as ScaleBarElement;
522
+ // Scale bar declares which viewport it binds to via `viewport`
523
+ const boundViewport = sb.viewport ?? viewports[0]?.id ?? null;
524
+ if (!boundViewport) {
525
+ pushWarning(warnings, `Scale bar '${sb.id}' is not bound to a viewport.`);
526
+ return;
527
+ }
528
+ const scaleInfo = viewportScales[boundViewport];
529
+ if (!scaleInfo) {
530
+ pushWarning(warnings, `Viewport '${boundViewport}' missing scale info for scale bar '${sb.id}'.`);
531
+ return;
532
+ }
533
+ const lengthMm = sb.maxLengthMm;
534
+ const segments = sb.segments ?? 10;
535
+ const segmentMm = lengthMm / segments;
536
+ const metersPerMm = 1 / scaleInfo.mmPerModelUnit;
537
+ const segmentMeters = segmentMm * metersPerMm;
538
+ const labelsEvery = Math.max(1, sb.labelEvery ?? 1);
539
+ const unitSuffix = sb.unit ? ` ${sb.unit}` : ' m';
540
+ const barParts: string[] = [];
541
+ const sbStroke = sb.stroke ?? '#000';
542
+ const sbStrokeWidth = sb.strokeWidthMm ?? 0.5;
543
+
544
+ for (let i = 0; i <= segments; i++) {
545
+ const x = sb.xMm + segmentMm * i;
546
+ const isLabel = i % labelsEvery === 0;
547
+ const height = isLabel ? 4 : 3;
548
+ barParts.push(
549
+ `<line x1="${x}" y1="${sb.yMm}" x2="${x}" y2="${sb.yMm - height}" stroke="${escapeAttr(
550
+ sbStroke
551
+ )}" stroke-width="${sbStrokeWidth}" />`
552
+ );
553
+ if (isLabel && sb.textStyle) {
554
+ const style = template.styles?.[sb.textStyle];
555
+ if (style) {
556
+ const labelValue = (segmentMeters * i).toFixed(2).replace(/\.?0+$/, '');
557
+ barParts.push(
558
+ `<text x="${x}" y="${sb.yMm + style.fontSizeMm + 1}" font-family="${escapeAttr(
559
+ style.fontFamily
560
+ )}" font-size="${style.fontSizeMm}" text-anchor="middle">${escapeText(labelValue + unitSuffix)}</text>`
561
+ );
562
+ }
563
+ }
564
+ }
565
+
566
+ barParts.push(
567
+ `<line x1="${sb.xMm}" y1="${sb.yMm}" x2="${sb.xMm + lengthMm}" y2="${sb.yMm}" stroke="${escapeAttr(
568
+ sbStroke
569
+ )}" stroke-width="${sbStrokeWidth}" />`
570
+ );
571
+
572
+ parts.push(barParts.join(''));
573
+ } else if (el.kind === ELEMENT_KIND_VIEWPORT) {
574
+ const vp = el as ViewportElement;
575
+ const model = filteredModelsByViewport[vp.id] ?? models[vp.id] ?? null;
576
+ if (!model) {
577
+ pushWarning(warnings, `Viewport '${vp.id}' missing model.`);
578
+ }
579
+ const vpParts = renderViewportElement(
580
+ vp,
581
+ model,
582
+ dataWithViewport,
583
+ preferredScales,
584
+ presets.scales,
585
+ assetMap,
586
+ viewportScales,
587
+ viewportClipPolygonsLocal,
588
+ viewportClipBounds,
589
+ viewportClipLabels,
590
+ viewportClipStyles,
591
+ viewportHighlight,
592
+ viewportRenderInfo,
593
+ warnings,
594
+ legendColorMaps
595
+ );
596
+ parts.push(...vpParts);
597
+ } else if (el.kind === ELEMENT_KIND_LEGEND) {
598
+ const legend = el as LegendElement;
599
+ const legendItems = getPathValue(data, `viewports.${legend.viewport}.items`);
600
+ const rawLegendItems = Array.isArray(legendItems) ? legendItems : [];
601
+ const legendDataFiltered = applyLegendFilter(
602
+ rawLegendItems,
603
+ legend,
604
+ viewports,
605
+ viewportClipPolygonsLocal,
606
+ viewportRenderInfo
607
+ );
608
+ if (!legendDataFiltered || !legendDataFiltered.length) {
609
+ if (!rawLegendItems.length) {
610
+ pushWarning(warnings, `Legend '${legend.id}' has no items from viewport '${legend.viewport}'.`);
611
+ } else {
612
+ pushWarning(
613
+ warnings,
614
+ `Legend '${legend.id}' filter matched 0 of ${rawLegendItems.length} items (class=${JSON.stringify(legend.class)}, where=${JSON.stringify(legend.where)}).`
615
+ );
616
+ }
617
+ }
618
+ const vpEl = viewports.find((v) => v.id === legend.viewport);
619
+ // Derive swatch opacity from viewport fill rules (first matching rule's opacity)
620
+ const fillOpacity = vpEl?.fills?.length ? (vpEl.fills[0].fillOpacity ?? 0.35) : undefined;
621
+ const legendResult = buildLegendGroup(
622
+ legend,
623
+ template.styles,
624
+ page,
625
+ page.marginMm,
626
+ legendDataFiltered || [],
627
+ fillOpacity
628
+ );
629
+ if (legendResult) {
630
+ parts.push(legendResult.svg);
631
+ }
632
+ }
633
+ });
634
+
635
+ if (labelElements.length) {
636
+ const labelParts = renderLabels(
637
+ labelElements,
638
+ data,
639
+ viewportRenderInfo,
640
+ viewportClipLabels,
641
+ viewportClipPolygonsLocal,
642
+ viewportClipPolygonsWorld,
643
+ template.styles ?? {},
644
+ dataWithViewport,
645
+ warnings
646
+ );
647
+ parts.push(...labelParts);
648
+ }
649
+
650
+ if (qrElements.length) {
651
+ const qrParts = await renderQrElements(
652
+ qrElements,
653
+ viewports,
654
+ viewportScales,
655
+ models,
656
+ { qrContext: options?.qrContext },
657
+ dataWithViewport,
658
+ warnings
659
+ );
660
+ parts.push(...qrParts);
661
+ }
662
+
663
+ parts.push('</svg>');
664
+
665
+ return { svg: parts.join(''), viewportScales, warnings };
666
+ }
667
+
668
+ // Register renderLayout with renderDrawing to break the circular dependency.
669
+ // This runs at module evaluation time — safe because renderLayout is defined above.
670
+ import { setRenderLayoutBinding as _setBinding } from './renderDrawing';
671
+ _setBinding(renderLayout as any);