@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,710 +1,710 @@
1
- import type { SectionRenderState, SectionPath, SectionLabel } from '@twinfinity/geometry';
2
- import { sectionStateToModelView, computePolylinesCenter } from './itemBuilders';
3
- import { buildMetadataMap, normalizePropertySets, resolveIfcClassName, resolveIfcClassId } from './metadata';
4
- import { applyLineStyleToPaths } from './utils';
5
- import { analyzeTemplate } from './analyzeTemplate';
6
- import { matchesV2Filter } from './filters';
7
- import type {
8
- LayoutTemplate,
9
- ViewportElement,
10
- ViewportLineStyle,
11
- FillRule,
12
- SvgModelView,
13
- SectionResult,
14
- RenderDrawingInput,
15
- RenderDrawingResult,
16
- NormalizedProductItem,
17
- LabelStyle
18
- } from './types';
19
- import { getProp } from './typeGuards';
20
-
21
- // ---------------------------------------------------------------------------
22
- // Local type declarations for optional peer-dependency APIs.
23
- // Declared inline to avoid pulling @babylonjs/core and @twinfinity/core into
24
- // print-layout's compile-time dependency graph.
25
- // ---------------------------------------------------------------------------
26
-
27
- interface SampledColor {
28
- color: { r: number; g: number; b: number };
29
- alpha: number;
30
- }
31
-
32
- interface ExternalSections {
33
- buildSectionStateForEntries: (
34
- perProduct: unknown[],
35
- options: {
36
- viewport?: { viewMinX: number; viewMaxX: number; viewMinZ: number; viewMaxZ: number };
37
- strokeWidth?: number;
38
- labelFontSize?: number;
39
- fills?: Array<{ class?: string | string[]; fillOpacity?: number; matchClipFilter?: boolean }>;
40
- includeMeta?: boolean;
41
- includeLabels?: boolean;
42
- buildLabels?: (products: unknown[], fontSize: number) => unknown[];
43
- clipPolygon?: Array<Array<{ x: number; z: number }>>;
44
- planeNormal?: unknown;
45
- }
46
- ) => (SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] }) | null;
47
- }
48
-
49
- interface ExternalGeometry {
50
- sampleProductColorRgba: (product: unknown) => SampledColor | undefined;
51
- buildRoomLabels: (
52
- entries: unknown[],
53
- fontSize: number,
54
- options: { labelPath: string; classFilter?: string }
55
- ) => SectionLabel[];
56
- }
57
-
58
- // These were previously lazy-loaded via require() from @measurement-tools/* packages.
59
- // Now they are part of @twinfinity/printing (this package) and @twinfinity/geometry.
60
- import { buildSectionStateForEntries, resolveProductProperty } from '../sections';
61
-
62
- // Wrap into the ExternalSections shape for backward compat with the rest of this file.
63
- const _sections: ExternalSections = {
64
- buildSectionStateForEntries: (perProduct, options) => {
65
- type Entries = Parameters<typeof buildSectionStateForEntries>[0];
66
- type Opts = Parameters<typeof buildSectionStateForEntries>[1];
67
- return buildSectionStateForEntries(perProduct as Entries, {
68
- ...options,
69
- fills: options.fills as Opts['fills'],
70
- buildLabels: options.buildLabels as Opts['buildLabels'],
71
- planeNormal: options.planeNormal as Opts['planeNormal']
72
- });
73
- }
74
- };
75
- function getSections(): ExternalSections {
76
- return _sections;
77
- }
78
-
79
- // sampleProductColorRgba: duck-type access to product mesh style color.
80
- // buildRoomLabels: requires product-geometry which this package can't depend on.
81
- // Consumers must provide buildRoomLabels via options if room labels are needed.
82
- const _geometry: ExternalGeometry = {
83
- sampleProductColorRgba: (product: unknown): SampledColor | undefined => {
84
- /** Duck-typed mesh with optional color-access methods. */
85
- interface DuckMesh {
86
- copyColorTo?: (out: Uint8Array, offset: number) => void;
87
- getColorComponent?: (index: number) => number;
88
- style?: { surfaceColor?: readonly number[]; color?: readonly number[] };
89
- }
90
- const p = product as Record<string, unknown>;
91
- const meshes = (p?.meshes ?? p?.productMeshes) as DuckMesh[] | undefined;
92
- if (!meshes?.length) return undefined;
93
-
94
- const rgba = new Uint8Array(4);
95
- for (const mesh of meshes) {
96
- try {
97
- if (typeof mesh.copyColorTo === 'function') {
98
- mesh.copyColorTo(rgba, 0);
99
- } else if (typeof mesh.getColorComponent === 'function') {
100
- rgba[0] = mesh.getColorComponent(0);
101
- rgba[1] = mesh.getColorComponent(1);
102
- rgba[2] = mesh.getColorComponent(2);
103
- rgba[3] = mesh.getColorComponent(3);
104
- } else {
105
- const style = mesh.style;
106
- const c = style?.surfaceColor ?? style?.color;
107
- if (c) {
108
- // surfaceColor values may be 0–1 or 0–255; normalize to 0–1
109
- const maxVal = Math.max(c[0], c[1], c[2]);
110
- const scale = maxVal > 1 ? 1 / 255 : 1;
111
- return {
112
- color: { r: c[0] * scale, g: c[1] * scale, b: c[2] * scale },
113
- alpha: (c[3] ?? (maxVal > 1 ? 255 : 1)) * scale
114
- };
115
- }
116
- continue;
117
- }
118
- } catch {
119
- continue;
120
- }
121
- const [r, g, b, a] = rgba;
122
- if (a === 0 && r === 0 && g === 0 && b === 0) continue;
123
- return { color: { r: r / 255, g: g / 255, b: b / 255 }, alpha: a / 255 };
124
- }
125
- return undefined;
126
- },
127
- buildRoomLabels: () => [] // Requires product-geometry — consumer provides via options
128
- };
129
- function getGeometry(): ExternalGeometry {
130
- return _geometry;
131
- }
132
- function getSampleColor(): (product: unknown) => SampledColor | undefined {
133
- return _geometry.sampleProductColorRgba;
134
- }
135
-
136
- // Late-binding for renderLayout to break circular dependency.
137
- // renderDrawing is imported by index.ts which defines renderLayout.
138
- // The binding is set by index.ts after definition via setRenderLayoutBinding().
139
- let _renderLayout:
140
- | ((
141
- template: LayoutTemplate,
142
- data: any,
143
- models: Record<string, SvgModelView>,
144
- options?: any
145
- ) => Promise<{ svg: string; viewportScales: any; warnings: string[] }>)
146
- | null = null;
147
-
148
- /** Called by index.ts to register renderLayout without a circular import. */
149
- export function setRenderLayoutBinding(fn: NonNullable<typeof _renderLayout>): void {
150
- _renderLayout = fn;
151
- }
152
-
153
- function getRenderLayout(): NonNullable<typeof _renderLayout> {
154
- if (!_renderLayout) {
155
- throw new Error(
156
- 'renderLayout not initialized. Ensure @twinfinity/printing index is imported before calling renderDrawing.'
157
- );
158
- }
159
- return _renderLayout;
160
- }
161
-
162
- // ---------------------------------------------------------------------------
163
- // Public API
164
- // ---------------------------------------------------------------------------
165
-
166
- /**
167
- * High-level entry point that converts raw plane-section results and a layout
168
- * template into a ready-to-print SVG string.
169
- *
170
- * Internalizes all data wiring (section → SVG, item extraction, color sampling,
171
- * metadata building, legend/label data) that previously required ~350 lines of
172
- * app-side boilerplate.
173
- *
174
- * ```ts
175
- * const section = computePlaneSectionsForProducts(products, plane, { includeInvisible: true });
176
- * const { svg, warnings } = await renderDrawing({
177
- * template,
178
- * sections: { main: section },
179
- * options: {
180
- * allowRemoteAssets: true,
181
- * qrContext: { host, containerName, planeHeight },
182
- * productFields: { idPath: "propertySets.BIP.spacenumber.value", ... },
183
- * roomLabels: { labelPath: "BIP.spacename", classFilter: "space" },
184
- * },
185
- * });
186
- * ```
187
- */
188
- export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDrawingResult> {
189
- const { options } = input;
190
- const warnings: string[] = [];
191
-
192
- // ---- Resolve template variables (if present) ----
193
- // This happens FIRST to ensure all {{vars.*}} references are resolved before validation/rendering
194
- let template = input.template;
195
- if (template.variables) {
196
- const { resolveTemplateValues, applyTemplateVariables } = await import('./templateVariables');
197
- const { values, warnings: varWarnings } = resolveTemplateValues(template, options?.variables);
198
-
199
- // Add variable resolution warnings to output
200
- varWarnings.forEach((w) => warnings.push(`[Variables] ${w}`));
201
-
202
- // Apply variables to template (substitute {{vars.*}} references)
203
- template = applyTemplateVariables(template, values);
204
- }
205
-
206
- const renderLayout = getRenderLayout();
207
-
208
- // ---- Collect viewport elements from template ----
209
- const viewports = template.elements.filter((el) => el.kind === 'viewport') as ViewportElement[];
210
- if (!viewports.length) {
211
- return { svg: '', warnings: ['No viewport elements in template.'] };
212
- }
213
-
214
- // ---- Fast path: pre-built models provided directly ----
215
- if (input.models) {
216
- // Build items: explicit viewportItems take priority, then auto-build from productEntries
217
- let viewportsData: Record<string, { items: NormalizedProductItem[] }>;
218
-
219
- if (options?.viewportItems) {
220
- const emptyDefaults = Object.fromEntries(
221
- viewports.map((vp) => [vp.id, { items: [] as NormalizedProductItem[] }])
222
- );
223
- viewportsData = { ...emptyDefaults, ...options.viewportItems };
224
- } else if (options?.productEntries?.length) {
225
- // Auto-build items per viewport from raw product entries
226
- const sampleColor = getSampleColor();
227
- viewportsData = {};
228
- for (const vp of viewports) {
229
- viewportsData[vp.id] = {
230
- items: buildItemsFromEntries(options.productEntries, vp.fills, sampleColor)
231
- };
232
- }
233
- } else {
234
- viewportsData = Object.fromEntries(
235
- viewports.map((vp) => [vp.id, { items: [] as NormalizedProductItem[] }])
236
- );
237
- }
238
-
239
- const layoutData = { viewports: viewportsData };
240
-
241
- // Early input analysis: warn about mismatches between template and provided data
242
- const reqs = analyzeTemplate(template);
243
-
244
- for (const vpId of reqs.viewportIds) {
245
- if (!input.models[vpId]) {
246
- warnings.push(`Template viewport '${vpId}' has no model — it will render empty.`);
247
- }
248
- }
249
-
250
- const allItems = Object.values(viewportsData).flatMap((v) => v.items);
251
- if (allItems.length > 0) {
252
- for (const path of reqs.propertyPaths) {
253
- const parts = path.split('.');
254
- if (parts.length < 2) continue;
255
- const [setName, propName] = parts;
256
- const hasIt = allItems.some((item) => item.propertySets?.[setName]?.[propName]?.value !== undefined);
257
- if (!hasIt) {
258
- warnings.push(`Template references property '${path}' but no items provide it.`);
259
- }
260
- }
261
- }
262
-
263
- if (reqs.usesRemoteAssets && !template.allowRemoteAssets && !options?.allowRemoteAssets) {
264
- warnings.push('Template uses remote assets but allowRemoteAssets is not enabled.');
265
- }
266
-
267
- // Build metadata from product entries (same as standard path)
268
- const metadataEntries = options?.productEntries ?? [];
269
- const metadataById = buildMetadataMap(metadataEntries);
270
-
271
- const result = await renderLayout(template, layoutData, input.models, {
272
- allowRemoteAssets: template.allowRemoteAssets ?? options?.allowRemoteAssets,
273
- qrContext: options?.qrContext,
274
- metadataById
275
- });
276
-
277
- return {
278
- svg: result.svg,
279
- warnings: [...warnings, ...result.warnings]
280
- };
281
- }
282
-
283
- // ---- Standard path: build models from section results ----
284
- if (!input.sections) {
285
- return { svg: '', warnings: ['Either sections or models must be provided.'] };
286
- }
287
-
288
- const { buildSectionStateForEntries } = getSections();
289
- const { sampleProductColorRgba, buildRoomLabels } = getGeometry();
290
-
291
- // Normalize sections: single SectionResult → map to all viewports
292
- const isSingle = !!(input.sections as SectionResult).perProduct;
293
- const sections: Record<string, SectionResult> = isSingle
294
- ? Object.fromEntries(viewports.map((vp) => [vp.id, input.sections as SectionResult]))
295
- : (input.sections as Record<string, SectionResult>);
296
-
297
- // Auto-detect whether the template has its own labels elements.
298
- // If so, do NOT embed labels in the section SVG (would cause duplicates).
299
- const hasTemplateLabelElements = template.elements.some((el) => el.kind === 'labels');
300
- const roomLabelCfg = hasTemplateLabelElements ? undefined : options?.roomLabels;
301
-
302
- // ---- Build section state + model view per viewport ----
303
- const models: Record<string, SvgModelView> = {};
304
- const entriesByViewport: Record<string, SectionResult['perProduct']> = {};
305
- const sectionStateCache = new Map<string, SectionRenderState | null>();
306
-
307
- for (const vp of viewports) {
308
- const sectionResult = sections[vp.id];
309
- if (!sectionResult?.perProduct?.length) {
310
- warnings.push(`No section data for viewport '${vp.id}'.`);
311
- continue;
312
- }
313
-
314
- entriesByViewport[vp.id] = sectionResult.perProduct;
315
-
316
- // Dedup: skip if already built for this viewport key
317
- if (sectionStateCache.has(vp.id)) {
318
- const cached = sectionStateCache.get(vp.id);
319
- if (cached) {
320
- models[vp.id] = sectionStateToModelView(cached);
321
- }
322
- continue;
323
- }
324
-
325
- const resolved = resolveLineStyles(vp);
326
-
327
- // Estimate label font size from template style and section bounds
328
- const labelFontSize = estimateLabelFontSize(template, vp, sectionResult.perProduct);
329
-
330
- // Extract clip polygon from section results if clip config is present
331
- let clipPolygon: Array<Array<{ x: number; z: number }>> | undefined;
332
- if (vp.clip?.where && vp.clip.class) {
333
- const clipClass = Array.isArray(vp.clip.class) ? vp.clip.class : [vp.clip.class];
334
- const clipEntries = sectionResult.perProduct.filter((entry: any) => {
335
- const product = entry.product;
336
- const matchesClass = clipClass.some((c: string) => product.class?.is?.(c));
337
- if (!matchesClass) return false;
338
- // Match where condition using T8-aware property resolution
339
- for (const [path, expected] of Object.entries(vp.clip!.where!)) {
340
- const actualValue = resolveProductProperty(product, path);
341
- if (actualValue === undefined) return false;
342
- if (actualValue !== expected) return false;
343
- }
344
- return true;
345
- });
346
- if (clipEntries.length) {
347
- clipPolygon = clipEntries.flatMap((entry: any) =>
348
- (entry.polylines || []).map((pl: any) => (pl.points || []).map((p: any) => ({ x: p.x, z: p.z })))
349
- );
350
- }
351
- }
352
-
353
- // Convert planeNormal array to object with x, y, z if present
354
- let planeNormal: unknown = undefined;
355
- if (vp.planeNormal && Array.isArray(vp.planeNormal) && vp.planeNormal.length === 3) {
356
- planeNormal = { x: vp.planeNormal[0], y: vp.planeNormal[1], z: vp.planeNormal[2] };
357
- }
358
-
359
- type SectionStateWithRaw = SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] };
360
- const state: SectionStateWithRaw | null = buildSectionStateForEntries(sectionResult.perProduct, {
361
- strokeWidth: resolved.default.widthMm,
362
- labelFontSize,
363
- fills: vp.fills,
364
- includeMeta: true,
365
- includeLabels: !!roomLabelCfg,
366
- buildLabels: roomLabelCfg
367
- ? (products, fontSize) =>
368
- buildRoomLabels(products, fontSize, {
369
- labelPath: roomLabelCfg.labelPath,
370
- classFilter: roomLabelCfg.classFilter
371
- })
372
- : undefined,
373
- clipPolygon,
374
- planeNormal
375
- });
376
-
377
- sectionStateCache.set(vp.id, state);
378
-
379
- if (!state) {
380
- warnings.push(`No geometry for viewport '${vp.id}'.`);
381
- continue;
382
- }
383
-
384
- // Apply line style overrides from viewport config
385
- let finalState: SectionStateWithRaw = state;
386
- const defaultStyle = resolved.default;
387
- if (defaultStyle.color || defaultStyle.widthMm) {
388
- const style: { stroke?: string; strokeWidth?: number } = {};
389
- if (defaultStyle.color) style.stroke = defaultStyle.color;
390
- if (defaultStyle.widthMm) style.strokeWidth = defaultStyle.widthMm;
391
- finalState = {
392
- ...state,
393
- paths: applyLineStyleToPaths(state.paths, style)
394
- };
395
- if (state.rawPaths) {
396
- finalState.rawPaths = applyLineStyleToPaths(state.rawPaths, style);
397
- }
398
- }
399
-
400
- const labelStyle: LabelStyle | undefined = template.styles?.body
401
- ? {
402
- fill: template.styles.body.color ?? BUILTIN_LABEL_DEFAULTS.fill,
403
- fontFamily: template.styles.body.fontFamily ?? BUILTIN_LABEL_DEFAULTS.fontFamily,
404
- fontWeight: template.styles.body.fontWeight ?? BUILTIN_LABEL_DEFAULTS.fontWeight
405
- }
406
- : undefined;
407
-
408
- models[vp.id] = sectionStateToModelView(finalState, labelStyle);
409
- }
410
-
411
- // ---- Build items per viewport ----
412
- const viewportsData: Record<string, { items: NormalizedProductItem[] }> = {};
413
-
414
- for (const vp of viewports) {
415
- const entries = entriesByViewport[vp.id];
416
- if (!entries) {
417
- viewportsData[vp.id] = { items: [] };
418
- continue;
419
- }
420
-
421
- viewportsData[vp.id] = { items: buildItemsFromEntries(entries, vp.fills, sampleProductColorRgba) };
422
- }
423
-
424
- // ---- Build metadata from all section entries ----
425
- const allEntries = Object.values(entriesByViewport).flat();
426
- const metadataById = buildMetadataMap(allEntries);
427
-
428
- // ---- Assemble layout data ----
429
- const layoutData = {
430
- viewports: viewportsData
431
- };
432
-
433
- // ---- Render ----
434
- const result = await renderLayout(template, layoutData, models, {
435
- allowRemoteAssets: template.allowRemoteAssets ?? options?.allowRemoteAssets,
436
- qrContext: options?.qrContext,
437
- metadataById
438
- });
439
-
440
- return {
441
- svg: result.svg,
442
- warnings: [...warnings, ...result.warnings]
443
- };
444
- }
445
-
446
- // ---------------------------------------------------------------------------
447
- // Helpers
448
- // ---------------------------------------------------------------------------
449
-
450
- /**
451
- * Build NormalizedProductItems from per-product entries.
452
- *
453
- * Shared between the standard path (plane sections) and the models path
454
- * (slab sections / pre-built models with productEntries).
455
- */
456
- function buildItemsFromEntries(
457
- entries: Array<{ product: unknown; polylines: unknown }>,
458
- fills: FillRule[] | undefined,
459
- sampleColor: ((product: unknown) => SampledColor | undefined) | undefined
460
- ): NormalizedProductItem[] {
461
- return entries.map((entry) => {
462
- const product = entry.product;
463
- const propertySets = normalizePropertySets(product);
464
- const gid = getProp(product, 'gid') ?? getProp(product, 'id');
465
- const center = computePolylinesCenter(entry);
466
- const name = getProp(product, 'name') as string | undefined;
467
- const displayName = typeof name === 'string' && name.trim().length ? name.trim() : gid ? String(gid) : '-';
468
- const item: NormalizedProductItem = {
469
- gid: gid ? String(gid) : undefined,
470
- ifcClass: resolveIfcClassName(product),
471
- ifcClassId: resolveIfcClassId(product),
472
- propertySets,
473
- productId: gid ? String(gid) : '-',
474
- productName: displayName,
475
- area: null,
476
- x: center.x,
477
- z: center.z,
478
- text: displayName,
479
- polylines: entry.polylines as Array<{ points?: Array<{ x: number; z: number }> }>
480
- };
481
-
482
- if (fills?.length && sampleColor) {
483
- const color = resolveFillColor(item, product, fills, sampleColor);
484
- if (color) {
485
- item.color = color;
486
- }
487
- }
488
-
489
- return item;
490
- });
491
- }
492
-
493
- /**
494
- * Estimate label font size in model units from the template body style and the
495
- * approximate viewport-to-world scale ratio.
496
- */
497
- function estimateLabelFontSize(
498
- template: LayoutTemplate,
499
- vp: ViewportElement,
500
- perProduct: SectionResult['perProduct']
501
- ): number {
502
- const templateFontSizeMm = template.styles?.body?.fontSizeMm ?? 3.5;
503
-
504
- // Quick world-bounds scan
505
- let minX = Infinity;
506
- let maxX = -Infinity;
507
- let minZ = Infinity;
508
- let maxZ = -Infinity;
509
- for (const entry of perProduct) {
510
- if (!entry.polylines) continue;
511
- for (const polyline of entry.polylines) {
512
- if (!polyline.points) continue;
513
- for (const pt of polyline.points) {
514
- const px = getProp(pt, 'x');
515
- const pz = getProp(pt, 'z');
516
- if (Number.isFinite(px) && Number.isFinite(pz)) {
517
- if ((px as number) < minX) minX = px as number;
518
- if ((px as number) > maxX) maxX = px as number;
519
- if ((pz as number) < minZ) minZ = pz as number;
520
- if ((pz as number) > maxZ) maxZ = pz as number;
521
- }
522
- }
523
- }
524
- }
525
-
526
- const worldWidth = maxX - minX;
527
- const worldHeight = maxZ - minZ;
528
- if (!Number.isFinite(worldWidth) || worldWidth <= 0 || !Number.isFinite(worldHeight) || worldHeight <= 0) {
529
- return templateFontSizeMm; // Fallback: 1:1 estimate
530
- }
531
-
532
- // Approximate mm-per-model-unit from the viewport dimensions
533
- const scaleX = vp.widthMm / worldWidth;
534
- const scaleY = vp.heightMm / worldHeight;
535
- const mmPerModelUnit = Math.min(scaleX, scaleY);
536
-
537
- if (!Number.isFinite(mmPerModelUnit) || mmPerModelUnit <= 0) {
538
- return templateFontSizeMm;
539
- }
540
-
541
- return templateFontSizeMm / mmPerModelUnit;
542
- }
543
-
544
- // ---------------------------------------------------------------------------
545
- // Label style defaults
546
- // ---------------------------------------------------------------------------
547
-
548
- /** Built-in label style defaults when template body styles are partially set. */
549
- export const BUILTIN_LABEL_DEFAULTS = {
550
- fill: '#374151',
551
- fontFamily: '"Segoe UI", "Helvetica Neue", Arial, sans-serif',
552
- fontWeight: '300'
553
- } as const;
554
-
555
- // ---------------------------------------------------------------------------
556
- // Line style defaults & resolution
557
- // ---------------------------------------------------------------------------
558
-
559
- /** Built-in defaults when neither lineStyles nor lineWidthMm/lineColor are set. */
560
- export const BUILTIN_LINE_DEFAULTS: Required<ViewportLineStyle> = {
561
- widthMm: 0.35, // Reasonable default: 0.35mm line width (was 0.01mm - too thin!)
562
- color: '#1f2937',
563
- dash: '',
564
- enabled: true
565
- };
566
-
567
- export const BUILTIN_HIDDEN_DEFAULTS: Required<ViewportLineStyle> = {
568
- widthMm: 0.005,
569
- color: '#9ca3af',
570
- dash: '4 2',
571
- enabled: true
572
- };
573
-
574
- type LineCategory =
575
- | 'cutLines'
576
- | 'visibleLines'
577
- | 'hiddenLines'
578
- | 'silhouettes'
579
- | 'featureEdges'
580
- | 'stairArrows'
581
- | 'outlineVisible'
582
- | 'outlineHidden';
583
-
584
- /** Category-specific built-in defaults (merged over the global default). */
585
- export const CATEGORY_BUILTIN_OVERRIDES: Partial<Record<LineCategory, Partial<ViewportLineStyle>>> = {
586
- cutLines: { widthMm: 0.02 },
587
- hiddenLines: BUILTIN_HIDDEN_DEFAULTS,
588
- outlineHidden: BUILTIN_HIDDEN_DEFAULTS
589
- };
590
-
591
- export interface ResolvedLineStyles {
592
- default: Required<ViewportLineStyle>;
593
- byCategory: Record<LineCategory, Required<ViewportLineStyle>>;
594
- }
595
-
596
- /**
597
- * Resolve the effective line style for every category.
598
- *
599
- * Priority: category entry in `lineStyles` → `lineStyles.default`
600
- * → `lineWidthMm`/`lineColor` shorthand → built-in defaults.
601
- */
602
- function resolveLineStyles(vp: ViewportElement): ResolvedLineStyles {
603
- const ls = vp.lineStyles;
604
-
605
- // Base default: lineStyles.default → shorthand → built-in
606
- const base: Required<ViewportLineStyle> = {
607
- ...BUILTIN_LINE_DEFAULTS,
608
- ...(vp.lineWidthMm !== undefined ? { widthMm: vp.lineWidthMm } : {}),
609
- ...(vp.lineColor !== undefined ? { color: vp.lineColor } : {}),
610
- ...(ls?.default ?? {})
611
- } as Required<ViewportLineStyle>;
612
-
613
- const categories: LineCategory[] = [
614
- 'cutLines',
615
- 'visibleLines',
616
- 'hiddenLines',
617
- 'silhouettes',
618
- 'featureEdges',
619
- 'stairArrows',
620
- 'outlineVisible',
621
- 'outlineHidden'
622
- ];
623
-
624
- // Category group mapping: individual categories inherit from their group style
625
- const categoryGroups: Record<LineCategory, LineCategory> = {
626
- cutLines: 'visibleLines',
627
- visibleLines: 'visibleLines',
628
- silhouettes: 'visibleLines',
629
- featureEdges: 'visibleLines',
630
- outlineVisible: 'visibleLines',
631
- hiddenLines: 'hiddenLines',
632
- outlineHidden: 'hiddenLines',
633
- stairArrows: 'stairArrows'
634
- };
635
-
636
- const byCategory = {} as Record<LineCategory, Required<ViewportLineStyle>>;
637
- for (const cat of categories) {
638
- const builtinOverride = CATEGORY_BUILTIN_OVERRIDES[cat];
639
- const groupName = categoryGroups[cat];
640
- const groupStyle = ls?.[groupName]; // Group style (visibleLines, hiddenLines, stairArrows)
641
- const categoryOverride = ls?.[cat]; // Individual category settings (enable/disable)
642
-
643
- byCategory[cat] = {
644
- ...base,
645
- ...(builtinOverride ?? {}),
646
- ...(groupStyle ?? {}), // Apply group style first
647
- ...(categoryOverride ?? {}) // Then individual overrides (enable/disable)
648
- } as Required<ViewportLineStyle>;
649
- }
650
-
651
- return { default: base, byCategory };
652
- }
653
-
654
- // ---------------------------------------------------------------------------
655
- // Fill rule helpers
656
- // ---------------------------------------------------------------------------
657
-
658
- /**
659
- * Resolve fill color for an item based on viewport fill rules.
660
- * Returns an RGBA tuple [r, g, b, a] (0–255) or undefined if no rule matches.
661
- */
662
- export function resolveFillColor(
663
- item: NormalizedProductItem,
664
- product: unknown,
665
- fills: FillRule[] | undefined,
666
- sampleColor: (product: unknown) => SampledColor | undefined
667
- ): [number, number, number, number] | undefined {
668
- if (!fills?.length) return undefined;
669
- for (const rule of fills) {
670
- if (!matchesV2Filter(item, rule.class, rule.where)) continue;
671
- if (rule.fill === 'sample') {
672
- const sampled = sampleColor(product);
673
- if (sampled) {
674
- return [
675
- Math.round(sampled.color.r * 255),
676
- Math.round(sampled.color.g * 255),
677
- Math.round(sampled.color.b * 255),
678
- Math.round(sampled.alpha * 255)
679
- ];
680
- }
681
- } else {
682
- return parseHexToRgba(rule.fill);
683
- }
684
- break; // first matching rule wins
685
- }
686
- return undefined;
687
- }
688
-
689
- /**
690
- * Parse a CSS hex color string to an RGBA tuple (0–255).
691
- */
692
- function parseHexToRgba(hex: string): [number, number, number, number] {
693
- const h = hex.replace(/^#/, '');
694
- if (h.length === 3) {
695
- return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16), 255];
696
- }
697
- if (h.length === 6) {
698
- return [parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16), 255];
699
- }
700
- if (h.length === 8) {
701
- return [
702
- parseInt(h.substring(0, 2), 16),
703
- parseInt(h.substring(2, 4), 16),
704
- parseInt(h.substring(4, 6), 16),
705
- parseInt(h.substring(6, 8), 16)
706
- ];
707
- }
708
- // Fallback: opaque black
709
- return [0, 0, 0, 255];
710
- }
1
+ import type { SectionRenderState, SectionPath, SectionLabel } from '@twinfinity/geometry';
2
+ import { sectionStateToModelView, computePolylinesCenter } from './itemBuilders';
3
+ import { buildMetadataMap, normalizePropertySets, resolveIfcClassName, resolveIfcClassId } from './metadata';
4
+ import { applyLineStyleToPaths } from './utils';
5
+ import { analyzeTemplate } from './analyzeTemplate';
6
+ import { matchesV2Filter } from './filters';
7
+ import type {
8
+ LayoutTemplate,
9
+ ViewportElement,
10
+ ViewportLineStyle,
11
+ FillRule,
12
+ SvgModelView,
13
+ SectionResult,
14
+ RenderDrawingInput,
15
+ RenderDrawingResult,
16
+ NormalizedProductItem,
17
+ LabelStyle
18
+ } from './types';
19
+ import { getProp } from './typeGuards';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Local type declarations for optional peer-dependency APIs.
23
+ // Declared inline to avoid pulling @babylonjs/core and @twinfinity/core into
24
+ // print-layout's compile-time dependency graph.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ interface SampledColor {
28
+ color: { r: number; g: number; b: number };
29
+ alpha: number;
30
+ }
31
+
32
+ interface ExternalSections {
33
+ buildSectionStateForEntries: (
34
+ perProduct: unknown[],
35
+ options: {
36
+ viewport?: { viewMinX: number; viewMaxX: number; viewMinZ: number; viewMaxZ: number };
37
+ strokeWidth?: number;
38
+ labelFontSize?: number;
39
+ fills?: Array<{ class?: string | string[]; fillOpacity?: number; matchClipFilter?: boolean }>;
40
+ includeMeta?: boolean;
41
+ includeLabels?: boolean;
42
+ buildLabels?: (products: unknown[], fontSize: number) => unknown[];
43
+ clipPolygon?: Array<Array<{ x: number; z: number }>>;
44
+ planeNormal?: unknown;
45
+ }
46
+ ) => (SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] }) | null;
47
+ }
48
+
49
+ interface ExternalGeometry {
50
+ sampleProductColorRgba: (product: unknown) => SampledColor | undefined;
51
+ buildRoomLabels: (
52
+ entries: unknown[],
53
+ fontSize: number,
54
+ options: { labelPath: string; classFilter?: string }
55
+ ) => SectionLabel[];
56
+ }
57
+
58
+ // These were previously lazy-loaded via require() from @measurement-tools/* packages.
59
+ // Now they are part of @twinfinity/printing (this package) and @twinfinity/geometry.
60
+ import { buildSectionStateForEntries, resolveProductProperty } from '../sections';
61
+
62
+ // Wrap into the ExternalSections shape for backward compat with the rest of this file.
63
+ const _sections: ExternalSections = {
64
+ buildSectionStateForEntries: (perProduct, options) => {
65
+ type Entries = Parameters<typeof buildSectionStateForEntries>[0];
66
+ type Opts = Parameters<typeof buildSectionStateForEntries>[1];
67
+ return buildSectionStateForEntries(perProduct as Entries, {
68
+ ...options,
69
+ fills: options.fills as Opts['fills'],
70
+ buildLabels: options.buildLabels as Opts['buildLabels'],
71
+ planeNormal: options.planeNormal as Opts['planeNormal']
72
+ });
73
+ }
74
+ };
75
+ function getSections(): ExternalSections {
76
+ return _sections;
77
+ }
78
+
79
+ // sampleProductColorRgba: duck-type access to product mesh style color.
80
+ // buildRoomLabels: requires product-geometry which this package can't depend on.
81
+ // Consumers must provide buildRoomLabels via options if room labels are needed.
82
+ const _geometry: ExternalGeometry = {
83
+ sampleProductColorRgba: (product: unknown): SampledColor | undefined => {
84
+ /** Duck-typed mesh with optional color-access methods. */
85
+ interface DuckMesh {
86
+ copyColorTo?: (out: Uint8Array, offset: number) => void;
87
+ getColorComponent?: (index: number) => number;
88
+ style?: { surfaceColor?: readonly number[]; color?: readonly number[] };
89
+ }
90
+ const p = product as Record<string, unknown>;
91
+ const meshes = (p?.meshes ?? p?.productMeshes) as DuckMesh[] | undefined;
92
+ if (!meshes?.length) return undefined;
93
+
94
+ const rgba = new Uint8Array(4);
95
+ for (const mesh of meshes) {
96
+ try {
97
+ if (typeof mesh.copyColorTo === 'function') {
98
+ mesh.copyColorTo(rgba, 0);
99
+ } else if (typeof mesh.getColorComponent === 'function') {
100
+ rgba[0] = mesh.getColorComponent(0);
101
+ rgba[1] = mesh.getColorComponent(1);
102
+ rgba[2] = mesh.getColorComponent(2);
103
+ rgba[3] = mesh.getColorComponent(3);
104
+ } else {
105
+ const style = mesh.style;
106
+ const c = style?.surfaceColor ?? style?.color;
107
+ if (c) {
108
+ // surfaceColor values may be 0–1 or 0–255; normalize to 0–1
109
+ const maxVal = Math.max(c[0], c[1], c[2]);
110
+ const scale = maxVal > 1 ? 1 / 255 : 1;
111
+ return {
112
+ color: { r: c[0] * scale, g: c[1] * scale, b: c[2] * scale },
113
+ alpha: (c[3] ?? (maxVal > 1 ? 255 : 1)) * scale
114
+ };
115
+ }
116
+ continue;
117
+ }
118
+ } catch {
119
+ continue;
120
+ }
121
+ const [r, g, b, a] = rgba;
122
+ if (a === 0 && r === 0 && g === 0 && b === 0) continue;
123
+ return { color: { r: r / 255, g: g / 255, b: b / 255 }, alpha: a / 255 };
124
+ }
125
+ return undefined;
126
+ },
127
+ buildRoomLabels: () => [] // Requires product-geometry — consumer provides via options
128
+ };
129
+ function getGeometry(): ExternalGeometry {
130
+ return _geometry;
131
+ }
132
+ function getSampleColor(): (product: unknown) => SampledColor | undefined {
133
+ return _geometry.sampleProductColorRgba;
134
+ }
135
+
136
+ // Late-binding for renderLayout to break circular dependency.
137
+ // renderDrawing is imported by index.ts which defines renderLayout.
138
+ // The binding is set by index.ts after definition via setRenderLayoutBinding().
139
+ let _renderLayout:
140
+ | ((
141
+ template: LayoutTemplate,
142
+ data: any,
143
+ models: Record<string, SvgModelView>,
144
+ options?: any
145
+ ) => Promise<{ svg: string; viewportScales: any; warnings: string[] }>)
146
+ | null = null;
147
+
148
+ /** Called by index.ts to register renderLayout without a circular import. */
149
+ export function setRenderLayoutBinding(fn: NonNullable<typeof _renderLayout>): void {
150
+ _renderLayout = fn;
151
+ }
152
+
153
+ function getRenderLayout(): NonNullable<typeof _renderLayout> {
154
+ if (!_renderLayout) {
155
+ throw new Error(
156
+ 'renderLayout not initialized. Ensure @twinfinity/printing index is imported before calling renderDrawing.'
157
+ );
158
+ }
159
+ return _renderLayout;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Public API
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * High-level entry point that converts raw plane-section results and a layout
168
+ * template into a ready-to-print SVG string.
169
+ *
170
+ * Internalizes all data wiring (section → SVG, item extraction, color sampling,
171
+ * metadata building, legend/label data) that previously required ~350 lines of
172
+ * app-side boilerplate.
173
+ *
174
+ * ```ts
175
+ * const section = computePlaneSectionsForProducts(products, plane, { includeInvisible: true });
176
+ * const { svg, warnings } = await renderDrawing({
177
+ * template,
178
+ * sections: { main: section },
179
+ * options: {
180
+ * allowRemoteAssets: true,
181
+ * qrContext: { host, containerName, planeHeight },
182
+ * productFields: { idPath: "propertySets.BIP.spacenumber.value", ... },
183
+ * roomLabels: { labelPath: "BIP.spacename", classFilter: "space" },
184
+ * },
185
+ * });
186
+ * ```
187
+ */
188
+ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDrawingResult> {
189
+ const { options } = input;
190
+ const warnings: string[] = [];
191
+
192
+ // ---- Resolve template variables (if present) ----
193
+ // This happens FIRST to ensure all {{vars.*}} references are resolved before validation/rendering
194
+ let template = input.template;
195
+ if (template.variables) {
196
+ const { resolveTemplateValues, applyTemplateVariables } = await import('./templateVariables');
197
+ const { values, warnings: varWarnings } = resolveTemplateValues(template, options?.variables);
198
+
199
+ // Add variable resolution warnings to output
200
+ varWarnings.forEach((w) => warnings.push(`[Variables] ${w}`));
201
+
202
+ // Apply variables to template (substitute {{vars.*}} references)
203
+ template = applyTemplateVariables(template, values);
204
+ }
205
+
206
+ const renderLayout = getRenderLayout();
207
+
208
+ // ---- Collect viewport elements from template ----
209
+ const viewports = template.elements.filter((el) => el.kind === 'viewport') as ViewportElement[];
210
+ if (!viewports.length) {
211
+ return { svg: '', warnings: ['No viewport elements in template.'] };
212
+ }
213
+
214
+ // ---- Fast path: pre-built models provided directly ----
215
+ if (input.models) {
216
+ // Build items: explicit viewportItems take priority, then auto-build from productEntries
217
+ let viewportsData: Record<string, { items: NormalizedProductItem[] }>;
218
+
219
+ if (options?.viewportItems) {
220
+ const emptyDefaults = Object.fromEntries(
221
+ viewports.map((vp) => [vp.id, { items: [] as NormalizedProductItem[] }])
222
+ );
223
+ viewportsData = { ...emptyDefaults, ...options.viewportItems };
224
+ } else if (options?.productEntries?.length) {
225
+ // Auto-build items per viewport from raw product entries
226
+ const sampleColor = getSampleColor();
227
+ viewportsData = {};
228
+ for (const vp of viewports) {
229
+ viewportsData[vp.id] = {
230
+ items: buildItemsFromEntries(options.productEntries, vp.fills, sampleColor)
231
+ };
232
+ }
233
+ } else {
234
+ viewportsData = Object.fromEntries(
235
+ viewports.map((vp) => [vp.id, { items: [] as NormalizedProductItem[] }])
236
+ );
237
+ }
238
+
239
+ const layoutData = { viewports: viewportsData };
240
+
241
+ // Early input analysis: warn about mismatches between template and provided data
242
+ const reqs = analyzeTemplate(template);
243
+
244
+ for (const vpId of reqs.viewportIds) {
245
+ if (!input.models[vpId]) {
246
+ warnings.push(`Template viewport '${vpId}' has no model — it will render empty.`);
247
+ }
248
+ }
249
+
250
+ const allItems = Object.values(viewportsData).flatMap((v) => v.items);
251
+ if (allItems.length > 0) {
252
+ for (const path of reqs.propertyPaths) {
253
+ const parts = path.split('.');
254
+ if (parts.length < 2) continue;
255
+ const [setName, propName] = parts;
256
+ const hasIt = allItems.some((item) => item.propertySets?.[setName]?.[propName]?.value !== undefined);
257
+ if (!hasIt) {
258
+ warnings.push(`Template references property '${path}' but no items provide it.`);
259
+ }
260
+ }
261
+ }
262
+
263
+ if (reqs.usesRemoteAssets && !template.allowRemoteAssets && !options?.allowRemoteAssets) {
264
+ warnings.push('Template uses remote assets but allowRemoteAssets is not enabled.');
265
+ }
266
+
267
+ // Build metadata from product entries (same as standard path)
268
+ const metadataEntries = options?.productEntries ?? [];
269
+ const metadataById = buildMetadataMap(metadataEntries);
270
+
271
+ const result = await renderLayout(template, layoutData, input.models, {
272
+ allowRemoteAssets: template.allowRemoteAssets ?? options?.allowRemoteAssets,
273
+ qrContext: options?.qrContext,
274
+ metadataById
275
+ });
276
+
277
+ return {
278
+ svg: result.svg,
279
+ warnings: [...warnings, ...result.warnings]
280
+ };
281
+ }
282
+
283
+ // ---- Standard path: build models from section results ----
284
+ if (!input.sections) {
285
+ return { svg: '', warnings: ['Either sections or models must be provided.'] };
286
+ }
287
+
288
+ const { buildSectionStateForEntries } = getSections();
289
+ const { sampleProductColorRgba, buildRoomLabels } = getGeometry();
290
+
291
+ // Normalize sections: single SectionResult → map to all viewports
292
+ const isSingle = !!(input.sections as SectionResult).perProduct;
293
+ const sections: Record<string, SectionResult> = isSingle
294
+ ? Object.fromEntries(viewports.map((vp) => [vp.id, input.sections as SectionResult]))
295
+ : (input.sections as Record<string, SectionResult>);
296
+
297
+ // Auto-detect whether the template has its own labels elements.
298
+ // If so, do NOT embed labels in the section SVG (would cause duplicates).
299
+ const hasTemplateLabelElements = template.elements.some((el) => el.kind === 'labels');
300
+ const roomLabelCfg = hasTemplateLabelElements ? undefined : options?.roomLabels;
301
+
302
+ // ---- Build section state + model view per viewport ----
303
+ const models: Record<string, SvgModelView> = {};
304
+ const entriesByViewport: Record<string, SectionResult['perProduct']> = {};
305
+ const sectionStateCache = new Map<string, SectionRenderState | null>();
306
+
307
+ for (const vp of viewports) {
308
+ const sectionResult = sections[vp.id];
309
+ if (!sectionResult?.perProduct?.length) {
310
+ warnings.push(`No section data for viewport '${vp.id}'.`);
311
+ continue;
312
+ }
313
+
314
+ entriesByViewport[vp.id] = sectionResult.perProduct;
315
+
316
+ // Dedup: skip if already built for this viewport key
317
+ if (sectionStateCache.has(vp.id)) {
318
+ const cached = sectionStateCache.get(vp.id);
319
+ if (cached) {
320
+ models[vp.id] = sectionStateToModelView(cached);
321
+ }
322
+ continue;
323
+ }
324
+
325
+ const resolved = resolveLineStyles(vp);
326
+
327
+ // Estimate label font size from template style and section bounds
328
+ const labelFontSize = estimateLabelFontSize(template, vp, sectionResult.perProduct);
329
+
330
+ // Extract clip polygon from section results if clip config is present
331
+ let clipPolygon: Array<Array<{ x: number; z: number }>> | undefined;
332
+ if (vp.clip?.where && vp.clip.class) {
333
+ const clipClass = Array.isArray(vp.clip.class) ? vp.clip.class : [vp.clip.class];
334
+ const clipEntries = sectionResult.perProduct.filter((entry: any) => {
335
+ const product = entry.product;
336
+ const matchesClass = clipClass.some((c: string) => product.class?.is?.(c));
337
+ if (!matchesClass) return false;
338
+ // Match where condition using T8-aware property resolution
339
+ for (const [path, expected] of Object.entries(vp.clip!.where!)) {
340
+ const actualValue = resolveProductProperty(product, path);
341
+ if (actualValue === undefined) return false;
342
+ if (actualValue !== expected) return false;
343
+ }
344
+ return true;
345
+ });
346
+ if (clipEntries.length) {
347
+ clipPolygon = clipEntries.flatMap((entry: any) =>
348
+ (entry.polylines || []).map((pl: any) => (pl.points || []).map((p: any) => ({ x: p.x, z: p.z })))
349
+ );
350
+ }
351
+ }
352
+
353
+ // Convert planeNormal array to object with x, y, z if present
354
+ let planeNormal: unknown = undefined;
355
+ if (vp.planeNormal && Array.isArray(vp.planeNormal) && vp.planeNormal.length === 3) {
356
+ planeNormal = { x: vp.planeNormal[0], y: vp.planeNormal[1], z: vp.planeNormal[2] };
357
+ }
358
+
359
+ type SectionStateWithRaw = SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] };
360
+ const state: SectionStateWithRaw | null = buildSectionStateForEntries(sectionResult.perProduct, {
361
+ strokeWidth: resolved.default.widthMm,
362
+ labelFontSize,
363
+ fills: vp.fills,
364
+ includeMeta: true,
365
+ includeLabels: !!roomLabelCfg,
366
+ buildLabels: roomLabelCfg
367
+ ? (products, fontSize) =>
368
+ buildRoomLabels(products, fontSize, {
369
+ labelPath: roomLabelCfg.labelPath,
370
+ classFilter: roomLabelCfg.classFilter
371
+ })
372
+ : undefined,
373
+ clipPolygon,
374
+ planeNormal
375
+ });
376
+
377
+ sectionStateCache.set(vp.id, state);
378
+
379
+ if (!state) {
380
+ warnings.push(`No geometry for viewport '${vp.id}'.`);
381
+ continue;
382
+ }
383
+
384
+ // Apply line style overrides from viewport config
385
+ let finalState: SectionStateWithRaw = state;
386
+ const defaultStyle = resolved.default;
387
+ if (defaultStyle.color || defaultStyle.widthMm) {
388
+ const style: { stroke?: string; strokeWidth?: number } = {};
389
+ if (defaultStyle.color) style.stroke = defaultStyle.color;
390
+ if (defaultStyle.widthMm) style.strokeWidth = defaultStyle.widthMm;
391
+ finalState = {
392
+ ...state,
393
+ paths: applyLineStyleToPaths(state.paths, style)
394
+ };
395
+ if (state.rawPaths) {
396
+ finalState.rawPaths = applyLineStyleToPaths(state.rawPaths, style);
397
+ }
398
+ }
399
+
400
+ const labelStyle: LabelStyle | undefined = template.styles?.body
401
+ ? {
402
+ fill: template.styles.body.color ?? BUILTIN_LABEL_DEFAULTS.fill,
403
+ fontFamily: template.styles.body.fontFamily ?? BUILTIN_LABEL_DEFAULTS.fontFamily,
404
+ fontWeight: template.styles.body.fontWeight ?? BUILTIN_LABEL_DEFAULTS.fontWeight
405
+ }
406
+ : undefined;
407
+
408
+ models[vp.id] = sectionStateToModelView(finalState, labelStyle);
409
+ }
410
+
411
+ // ---- Build items per viewport ----
412
+ const viewportsData: Record<string, { items: NormalizedProductItem[] }> = {};
413
+
414
+ for (const vp of viewports) {
415
+ const entries = entriesByViewport[vp.id];
416
+ if (!entries) {
417
+ viewportsData[vp.id] = { items: [] };
418
+ continue;
419
+ }
420
+
421
+ viewportsData[vp.id] = { items: buildItemsFromEntries(entries, vp.fills, sampleProductColorRgba) };
422
+ }
423
+
424
+ // ---- Build metadata from all section entries ----
425
+ const allEntries = Object.values(entriesByViewport).flat();
426
+ const metadataById = buildMetadataMap(allEntries);
427
+
428
+ // ---- Assemble layout data ----
429
+ const layoutData = {
430
+ viewports: viewportsData
431
+ };
432
+
433
+ // ---- Render ----
434
+ const result = await renderLayout(template, layoutData, models, {
435
+ allowRemoteAssets: template.allowRemoteAssets ?? options?.allowRemoteAssets,
436
+ qrContext: options?.qrContext,
437
+ metadataById
438
+ });
439
+
440
+ return {
441
+ svg: result.svg,
442
+ warnings: [...warnings, ...result.warnings]
443
+ };
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Helpers
448
+ // ---------------------------------------------------------------------------
449
+
450
+ /**
451
+ * Build NormalizedProductItems from per-product entries.
452
+ *
453
+ * Shared between the standard path (plane sections) and the models path
454
+ * (slab sections / pre-built models with productEntries).
455
+ */
456
+ function buildItemsFromEntries(
457
+ entries: Array<{ product: unknown; polylines: unknown }>,
458
+ fills: FillRule[] | undefined,
459
+ sampleColor: ((product: unknown) => SampledColor | undefined) | undefined
460
+ ): NormalizedProductItem[] {
461
+ return entries.map((entry) => {
462
+ const product = entry.product;
463
+ const propertySets = normalizePropertySets(product);
464
+ const gid = getProp(product, 'gid') ?? getProp(product, 'id');
465
+ const center = computePolylinesCenter(entry);
466
+ const name = getProp(product, 'name') as string | undefined;
467
+ const displayName = typeof name === 'string' && name.trim().length ? name.trim() : gid ? String(gid) : '-';
468
+ const item: NormalizedProductItem = {
469
+ gid: gid ? String(gid) : undefined,
470
+ ifcClass: resolveIfcClassName(product),
471
+ ifcClassId: resolveIfcClassId(product),
472
+ propertySets,
473
+ productId: gid ? String(gid) : '-',
474
+ productName: displayName,
475
+ area: null,
476
+ x: center.x,
477
+ z: center.z,
478
+ text: displayName,
479
+ polylines: entry.polylines as Array<{ points?: Array<{ x: number; z: number }> }>
480
+ };
481
+
482
+ if (fills?.length && sampleColor) {
483
+ const color = resolveFillColor(item, product, fills, sampleColor);
484
+ if (color) {
485
+ item.color = color;
486
+ }
487
+ }
488
+
489
+ return item;
490
+ });
491
+ }
492
+
493
+ /**
494
+ * Estimate label font size in model units from the template body style and the
495
+ * approximate viewport-to-world scale ratio.
496
+ */
497
+ function estimateLabelFontSize(
498
+ template: LayoutTemplate,
499
+ vp: ViewportElement,
500
+ perProduct: SectionResult['perProduct']
501
+ ): number {
502
+ const templateFontSizeMm = template.styles?.body?.fontSizeMm ?? 3.5;
503
+
504
+ // Quick world-bounds scan
505
+ let minX = Infinity;
506
+ let maxX = -Infinity;
507
+ let minZ = Infinity;
508
+ let maxZ = -Infinity;
509
+ for (const entry of perProduct) {
510
+ if (!entry.polylines) continue;
511
+ for (const polyline of entry.polylines) {
512
+ if (!polyline.points) continue;
513
+ for (const pt of polyline.points) {
514
+ const px = getProp(pt, 'x');
515
+ const pz = getProp(pt, 'z');
516
+ if (Number.isFinite(px) && Number.isFinite(pz)) {
517
+ if ((px as number) < minX) minX = px as number;
518
+ if ((px as number) > maxX) maxX = px as number;
519
+ if ((pz as number) < minZ) minZ = pz as number;
520
+ if ((pz as number) > maxZ) maxZ = pz as number;
521
+ }
522
+ }
523
+ }
524
+ }
525
+
526
+ const worldWidth = maxX - minX;
527
+ const worldHeight = maxZ - minZ;
528
+ if (!Number.isFinite(worldWidth) || worldWidth <= 0 || !Number.isFinite(worldHeight) || worldHeight <= 0) {
529
+ return templateFontSizeMm; // Fallback: 1:1 estimate
530
+ }
531
+
532
+ // Approximate mm-per-model-unit from the viewport dimensions
533
+ const scaleX = vp.widthMm / worldWidth;
534
+ const scaleY = vp.heightMm / worldHeight;
535
+ const mmPerModelUnit = Math.min(scaleX, scaleY);
536
+
537
+ if (!Number.isFinite(mmPerModelUnit) || mmPerModelUnit <= 0) {
538
+ return templateFontSizeMm;
539
+ }
540
+
541
+ return templateFontSizeMm / mmPerModelUnit;
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Label style defaults
546
+ // ---------------------------------------------------------------------------
547
+
548
+ /** Built-in label style defaults when template body styles are partially set. */
549
+ export const BUILTIN_LABEL_DEFAULTS = {
550
+ fill: '#374151',
551
+ fontFamily: '"Segoe UI", "Helvetica Neue", Arial, sans-serif',
552
+ fontWeight: '300'
553
+ } as const;
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // Line style defaults & resolution
557
+ // ---------------------------------------------------------------------------
558
+
559
+ /** Built-in defaults when neither lineStyles nor lineWidthMm/lineColor are set. */
560
+ export const BUILTIN_LINE_DEFAULTS: Required<ViewportLineStyle> = {
561
+ widthMm: 0.35, // Reasonable default: 0.35mm line width (was 0.01mm - too thin!)
562
+ color: '#1f2937',
563
+ dash: '',
564
+ enabled: true
565
+ };
566
+
567
+ export const BUILTIN_HIDDEN_DEFAULTS: Required<ViewportLineStyle> = {
568
+ widthMm: 0.005,
569
+ color: '#9ca3af',
570
+ dash: '4 2',
571
+ enabled: true
572
+ };
573
+
574
+ type LineCategory =
575
+ | 'cutLines'
576
+ | 'visibleLines'
577
+ | 'hiddenLines'
578
+ | 'silhouettes'
579
+ | 'featureEdges'
580
+ | 'stairArrows'
581
+ | 'outlineVisible'
582
+ | 'outlineHidden';
583
+
584
+ /** Category-specific built-in defaults (merged over the global default). */
585
+ export const CATEGORY_BUILTIN_OVERRIDES: Partial<Record<LineCategory, Partial<ViewportLineStyle>>> = {
586
+ cutLines: { widthMm: 0.02 },
587
+ hiddenLines: BUILTIN_HIDDEN_DEFAULTS,
588
+ outlineHidden: BUILTIN_HIDDEN_DEFAULTS
589
+ };
590
+
591
+ export interface ResolvedLineStyles {
592
+ default: Required<ViewportLineStyle>;
593
+ byCategory: Record<LineCategory, Required<ViewportLineStyle>>;
594
+ }
595
+
596
+ /**
597
+ * Resolve the effective line style for every category.
598
+ *
599
+ * Priority: category entry in `lineStyles` → `lineStyles.default`
600
+ * → `lineWidthMm`/`lineColor` shorthand → built-in defaults.
601
+ */
602
+ function resolveLineStyles(vp: ViewportElement): ResolvedLineStyles {
603
+ const ls = vp.lineStyles;
604
+
605
+ // Base default: lineStyles.default → shorthand → built-in
606
+ const base: Required<ViewportLineStyle> = {
607
+ ...BUILTIN_LINE_DEFAULTS,
608
+ ...(vp.lineWidthMm !== undefined ? { widthMm: vp.lineWidthMm } : {}),
609
+ ...(vp.lineColor !== undefined ? { color: vp.lineColor } : {}),
610
+ ...(ls?.default ?? {})
611
+ } as Required<ViewportLineStyle>;
612
+
613
+ const categories: LineCategory[] = [
614
+ 'cutLines',
615
+ 'visibleLines',
616
+ 'hiddenLines',
617
+ 'silhouettes',
618
+ 'featureEdges',
619
+ 'stairArrows',
620
+ 'outlineVisible',
621
+ 'outlineHidden'
622
+ ];
623
+
624
+ // Category group mapping: individual categories inherit from their group style
625
+ const categoryGroups: Record<LineCategory, LineCategory> = {
626
+ cutLines: 'visibleLines',
627
+ visibleLines: 'visibleLines',
628
+ silhouettes: 'visibleLines',
629
+ featureEdges: 'visibleLines',
630
+ outlineVisible: 'visibleLines',
631
+ hiddenLines: 'hiddenLines',
632
+ outlineHidden: 'hiddenLines',
633
+ stairArrows: 'stairArrows'
634
+ };
635
+
636
+ const byCategory = {} as Record<LineCategory, Required<ViewportLineStyle>>;
637
+ for (const cat of categories) {
638
+ const builtinOverride = CATEGORY_BUILTIN_OVERRIDES[cat];
639
+ const groupName = categoryGroups[cat];
640
+ const groupStyle = ls?.[groupName]; // Group style (visibleLines, hiddenLines, stairArrows)
641
+ const categoryOverride = ls?.[cat]; // Individual category settings (enable/disable)
642
+
643
+ byCategory[cat] = {
644
+ ...base,
645
+ ...(builtinOverride ?? {}),
646
+ ...(groupStyle ?? {}), // Apply group style first
647
+ ...(categoryOverride ?? {}) // Then individual overrides (enable/disable)
648
+ } as Required<ViewportLineStyle>;
649
+ }
650
+
651
+ return { default: base, byCategory };
652
+ }
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // Fill rule helpers
656
+ // ---------------------------------------------------------------------------
657
+
658
+ /**
659
+ * Resolve fill color for an item based on viewport fill rules.
660
+ * Returns an RGBA tuple [r, g, b, a] (0–255) or undefined if no rule matches.
661
+ */
662
+ export function resolveFillColor(
663
+ item: NormalizedProductItem,
664
+ product: unknown,
665
+ fills: FillRule[] | undefined,
666
+ sampleColor: (product: unknown) => SampledColor | undefined
667
+ ): [number, number, number, number] | undefined {
668
+ if (!fills?.length) return undefined;
669
+ for (const rule of fills) {
670
+ if (!matchesV2Filter(item, rule.class, rule.where)) continue;
671
+ if (rule.fill === 'sample') {
672
+ const sampled = sampleColor(product);
673
+ if (sampled) {
674
+ return [
675
+ Math.round(sampled.color.r * 255),
676
+ Math.round(sampled.color.g * 255),
677
+ Math.round(sampled.color.b * 255),
678
+ Math.round(sampled.alpha * 255)
679
+ ];
680
+ }
681
+ } else {
682
+ return parseHexToRgba(rule.fill);
683
+ }
684
+ break; // first matching rule wins
685
+ }
686
+ return undefined;
687
+ }
688
+
689
+ /**
690
+ * Parse a CSS hex color string to an RGBA tuple (0–255).
691
+ */
692
+ function parseHexToRgba(hex: string): [number, number, number, number] {
693
+ const h = hex.replace(/^#/, '');
694
+ if (h.length === 3) {
695
+ return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16), 255];
696
+ }
697
+ if (h.length === 6) {
698
+ return [parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16), 255];
699
+ }
700
+ if (h.length === 8) {
701
+ return [
702
+ parseInt(h.substring(0, 2), 16),
703
+ parseInt(h.substring(2, 4), 16),
704
+ parseInt(h.substring(4, 6), 16),
705
+ parseInt(h.substring(6, 8), 16)
706
+ ];
707
+ }
708
+ // Fallback: opaque black
709
+ return [0, 0, 0, 255];
710
+ }