@twinfinity/printing 6.1.1-beta → 6.1.2-ci.30738-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 (157) hide show
  1. package/dist/BuildInfo.js +3 -3
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +3 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/layout/analyzeTemplate.d.ts +4 -4
  7. package/dist/layout/analyzeTemplate.d.ts.map +1 -1
  8. package/dist/layout/analyzeTemplate.js +18 -16
  9. package/dist/layout/analyzeTemplate.js.map +1 -1
  10. package/dist/layout/classClosure.d.ts +41 -0
  11. package/dist/layout/classClosure.d.ts.map +1 -0
  12. package/dist/layout/classClosure.js +143 -0
  13. package/dist/layout/classClosure.js.map +1 -0
  14. package/dist/layout/clipUtils.d.ts +11 -0
  15. package/dist/layout/clipUtils.d.ts.map +1 -1
  16. package/dist/layout/clipUtils.js +67 -6
  17. package/dist/layout/clipUtils.js.map +1 -1
  18. package/dist/layout/filters.d.ts +14 -8
  19. package/dist/layout/filters.d.ts.map +1 -1
  20. package/dist/layout/filters.js +28 -3
  21. package/dist/layout/filters.js.map +1 -1
  22. package/dist/layout/index.d.ts +24 -57
  23. package/dist/layout/index.d.ts.map +1 -1
  24. package/dist/layout/index.js +20 -337
  25. package/dist/layout/index.js.map +1 -1
  26. package/dist/layout/itemBuilders.d.ts +11 -0
  27. package/dist/layout/itemBuilders.d.ts.map +1 -1
  28. package/dist/layout/itemBuilders.js +17 -3
  29. package/dist/layout/itemBuilders.js.map +1 -1
  30. package/dist/layout/labelsRender.d.ts.map +1 -1
  31. package/dist/layout/labelsRender.js +15 -27
  32. package/dist/layout/labelsRender.js.map +1 -1
  33. package/dist/layout/legendRender.d.ts +18 -6
  34. package/dist/layout/legendRender.d.ts.map +1 -1
  35. package/dist/layout/legendRender.js +49 -79
  36. package/dist/layout/legendRender.js.map +1 -1
  37. package/dist/layout/metadata.d.ts.map +1 -1
  38. package/dist/layout/metadata.js +26 -5
  39. package/dist/layout/metadata.js.map +1 -1
  40. package/dist/layout/model.d.ts +4 -3
  41. package/dist/layout/model.d.ts.map +1 -1
  42. package/dist/layout/model.js +17 -57
  43. package/dist/layout/model.js.map +1 -1
  44. package/dist/layout/options.d.ts +1 -2
  45. package/dist/layout/options.d.ts.map +1 -1
  46. package/dist/layout/options.js.map +1 -1
  47. package/dist/layout/preflight.d.ts +32 -0
  48. package/dist/layout/preflight.d.ts.map +1 -0
  49. package/dist/layout/preflight.js +69 -0
  50. package/dist/layout/preflight.js.map +1 -0
  51. package/dist/layout/qrRender.d.ts.map +1 -1
  52. package/dist/layout/qrRender.js +11 -10
  53. package/dist/layout/qrRender.js.map +1 -1
  54. package/dist/layout/renderDrawing.d.ts +28 -17
  55. package/dist/layout/renderDrawing.d.ts.map +1 -1
  56. package/dist/layout/renderDrawing.js +107 -88
  57. package/dist/layout/renderDrawing.js.map +1 -1
  58. package/dist/layout/renderLayout.d.ts +25 -0
  59. package/dist/layout/renderLayout.d.ts.map +1 -0
  60. package/dist/layout/renderLayout.js +315 -0
  61. package/dist/layout/renderLayout.js.map +1 -0
  62. package/dist/layout/renderTemplateSheet.d.ts +99 -0
  63. package/dist/layout/renderTemplateSheet.d.ts.map +1 -0
  64. package/dist/layout/renderTemplateSheet.js +296 -0
  65. package/dist/layout/renderTemplateSheet.js.map +1 -0
  66. package/dist/layout/renderUtils.d.ts +19 -0
  67. package/dist/layout/renderUtils.d.ts.map +1 -1
  68. package/dist/layout/renderUtils.js +47 -4
  69. package/dist/layout/renderUtils.js.map +1 -1
  70. package/dist/layout/sectionDefs.d.ts +65 -0
  71. package/dist/layout/sectionDefs.d.ts.map +1 -0
  72. package/dist/layout/sectionDefs.js +88 -0
  73. package/dist/layout/sectionDefs.js.map +1 -0
  74. package/dist/layout/slabModelView.d.ts +34 -0
  75. package/dist/layout/slabModelView.d.ts.map +1 -0
  76. package/dist/layout/slabModelView.js +175 -0
  77. package/dist/layout/slabModelView.js.map +1 -0
  78. package/dist/layout/templateVariables.d.ts +1 -1
  79. package/dist/layout/templateVariables.d.ts.map +1 -1
  80. package/dist/layout/templateVariables.js +21 -9
  81. package/dist/layout/templateVariables.js.map +1 -1
  82. package/dist/layout/types.d.ts +164 -83
  83. package/dist/layout/types.d.ts.map +1 -1
  84. package/dist/layout/types.js.map +1 -1
  85. package/dist/layout/validation.d.ts.map +1 -1
  86. package/dist/layout/validation.js +78 -33
  87. package/dist/layout/validation.js.map +1 -1
  88. package/dist/layout/viewportPrep.d.ts +8 -6
  89. package/dist/layout/viewportPrep.d.ts.map +1 -1
  90. package/dist/layout/viewportPrep.js +35 -23
  91. package/dist/layout/viewportPrep.js.map +1 -1
  92. package/dist/layout/viewportRender.d.ts +9 -7
  93. package/dist/layout/viewportRender.d.ts.map +1 -1
  94. package/dist/layout/viewportRender.js +93 -126
  95. package/dist/layout/viewportRender.js.map +1 -1
  96. package/dist/layout/viewportUtils.d.ts +20 -0
  97. package/dist/layout/viewportUtils.d.ts.map +1 -1
  98. package/dist/layout/viewportUtils.js +107 -4
  99. package/dist/layout/viewportUtils.js.map +1 -1
  100. package/dist/printToPdf.d.ts +7 -1
  101. package/dist/printToPdf.d.ts.map +1 -1
  102. package/dist/printToPdf.js +81 -14
  103. package/dist/printToPdf.js.map +1 -1
  104. package/dist/printToSvg.d.ts.map +1 -1
  105. package/dist/printToSvg.js +7 -3
  106. package/dist/printToSvg.js.map +1 -1
  107. package/dist/schemaUtils.d.ts +9 -0
  108. package/dist/schemaUtils.d.ts.map +1 -0
  109. package/dist/schemaUtils.js +14 -0
  110. package/dist/schemaUtils.js.map +1 -0
  111. package/dist/templates/default-template.json +30 -8
  112. package/dist/types.d.ts +2 -1
  113. package/dist/types.d.ts.map +1 -1
  114. package/dist/types.js +10 -1
  115. package/dist/types.js.map +1 -1
  116. package/package.json +8 -3
  117. package/src/index.ts +4 -0
  118. package/src/layout/analyzeTemplate.ts +21 -19
  119. package/src/layout/classClosure.ts +142 -0
  120. package/src/layout/clipUtils.ts +65 -6
  121. package/src/layout/filters.ts +31 -3
  122. package/src/layout/index.ts +61 -571
  123. package/src/layout/itemBuilders.ts +20 -5
  124. package/src/layout/labelsRender.ts +23 -40
  125. package/src/layout/legendRender.ts +64 -84
  126. package/src/layout/metadata.ts +26 -5
  127. package/src/layout/model.ts +21 -66
  128. package/src/layout/options.ts +1 -2
  129. package/src/layout/preflight.ts +96 -0
  130. package/src/layout/qrRender.ts +11 -10
  131. package/src/layout/renderDrawing.ts +132 -110
  132. package/src/layout/renderLayout.ts +483 -0
  133. package/src/layout/renderTemplateSheet.ts +435 -0
  134. package/src/layout/renderUtils.ts +53 -4
  135. package/src/layout/sectionDefs.ts +131 -0
  136. package/src/layout/slabModelView.ts +196 -0
  137. package/src/layout/templateVariables.ts +25 -9
  138. package/src/layout/types.ts +160 -70
  139. package/src/layout/validation.ts +98 -32
  140. package/src/layout/viewportPrep.ts +47 -29
  141. package/src/layout/viewportRender.ts +114 -141
  142. package/src/layout/viewportUtils.ts +118 -5
  143. package/src/printToPdf.ts +84 -15
  144. package/src/printToSvg.ts +7 -3
  145. package/src/schemaUtils.ts +13 -0
  146. package/src/templates/default-template.json +30 -8
  147. package/src/types.ts +12 -2
  148. package/templates/default-a0-landscape.json +171 -66
  149. package/templates/default-a0-portrait.json +159 -60
  150. package/templates/default-a1-landscape.json +135 -48
  151. package/templates/default-a1-portrait.json +123 -42
  152. package/templates/default-a3-landscape.json +87 -24
  153. package/templates/default-a3-portrait.json +99 -30
  154. package/templates/default-a4-landscape.json +75 -18
  155. package/templates/default-a4-portrait.json +75 -18
  156. package/templates/llm-authoring-guide.md +247 -0
  157. package/templates/template-schema.json +295 -120
@@ -0,0 +1,435 @@
1
+ import { buildPlaneBasis, planeFromPointAndNormal, type PlaneBasis, type Vec3 } from '@twinfinity/geometry';
2
+ import {
3
+ computePlaneSectionsForProducts,
4
+ SlabSection,
5
+ type ProductLike,
6
+ type SlabSectionResult
7
+ } from '@twinfinity/product-geometry';
8
+ import { buildSectionStateForEntries } from '../sections';
9
+ import { renderDrawing } from './renderDrawing';
10
+ import { sectionStateToModelView } from './itemBuilders';
11
+ import { slabSectionToModelView, slabPerProductToEntries } from './slabModelView';
12
+ import { resolveViewportSection, sectionDefKey, type ResolvedSectionDef, type SectionDefaults } from './sectionDefs';
13
+ import {
14
+ computeViewportClassClosure,
15
+ filterProductsByClosure,
16
+ collectClassBehavior,
17
+ type ClassBehaviorRule
18
+ } from './classClosure';
19
+ import { buildMetadataMap } from './metadata';
20
+
21
+ /**
22
+ * Plain world AABB from a viewer product's duck-typed `boundingInfo()` (BimIfcObject
23
+ * exposes Babylon BoundingInfo; plain ProductLike adapters usually have none → null).
24
+ */
25
+ function productAabb(product: ProductLike): { min: [number, number, number]; max: [number, number, number] } | null {
26
+ const withBi = product as ProductLike & { boundingInfo?: () => Record<string, unknown> };
27
+ if (typeof withBi.boundingInfo !== 'function') return null;
28
+ const bi = withBi.boundingInfo();
29
+ if (!bi) return null;
30
+ const bb = bi.boundingBox as Record<string, unknown> | undefined;
31
+ const bMin = (bb?.minimumWorld ?? bi.minimum) as (number[] & Record<string, number>) | undefined;
32
+ const bMax = (bb?.maximumWorld ?? bi.maximum) as (number[] & Record<string, number>) | undefined;
33
+ if (!bMin || !bMax) return null;
34
+ return {
35
+ min: [bMin[0] ?? bMin.x ?? 0, bMin[1] ?? bMin.y ?? 0, bMin[2] ?? bMin.z ?? 0],
36
+ max: [bMax[0] ?? bMax.x ?? 0, bMax[1] ?? bMax.y ?? 0, bMax[2] ?? bMax.z ?? 0]
37
+ };
38
+ }
39
+ import type {
40
+ LayoutTemplate,
41
+ SvgModelView,
42
+ ViewportLineStyles,
43
+ ViewportStairArrowOptions,
44
+ ProductPolylineEntry
45
+ } from './types';
46
+
47
+ // Products + template → rendered sheet, in one call. This is the consumer
48
+ // contract for the template's self-describing sections: every viewport's
49
+ // cutHeight/planeNormal/planePosition/depth is resolved (sectionDefs),
50
+ // computed (SlabSection for plans, plane sections for elevations/sections),
51
+ // adapted to SvgModelViews and fed to renderDrawing — so the workbench, the
52
+ // viewer print dialog and any future consumer render a template identically
53
+ // instead of re-implementing this pipeline.
54
+ //
55
+ // Invariants encoded here (learned the hard way — do not "optimize" away):
56
+ // - Sections are computed UNFILTERED: the renderer applies each viewport's
57
+ // includeClasses at render time via rawPaths + metadata, and highlight/clip
58
+ // need the highlight-class geometry present even when includeClasses
59
+ // excludes it from drawing (e.g. highlighting a space in a walls-only view).
60
+ // - The plan's lateral section origin stays at world zero so the projected
61
+ // space equals world x/z — labels/legend items are placed in world x/z, and
62
+ // a shifted origin would displace them.
63
+ // - The plan projection plane sits `above` metres over the cut (matches
64
+ // ExportDrawingPdf/printToSvg), with a (0,0,1) up hint for north-up plans.
65
+
66
+ type SectionEntries = Parameters<typeof buildSectionStateForEntries>[0];
67
+ type SectionStateOptions = Parameters<typeof buildSectionStateForEntries>[1];
68
+
69
+ /**
70
+ * The expensive template-independent half of one viewport's render: only the
71
+ * geometry is cached — the SvgModelView is built per viewport at render time,
72
+ * so template-driven styling (lineStyles incl. dash, fills) applies without
73
+ * re-sectioning.
74
+ */
75
+ export type ComputedSection =
76
+ | {
77
+ kind: 'plan';
78
+ geometry: SlabSectionResult<ProductLike>;
79
+ basis: PlaneBasis;
80
+ segmentCount: number;
81
+ /** Per-product cut polylines (world x/z) — feeds labels/legend items. */
82
+ entries: ProductPolylineEntry[];
83
+ }
84
+ | {
85
+ kind: 'elevation';
86
+ perProduct: SectionEntries;
87
+ normal: Vec3;
88
+ segmentCount: number;
89
+ entries: ProductPolylineEntry[];
90
+ };
91
+
92
+ /**
93
+ * Section-geometry cache, one entry per distinct section definition. Owned by
94
+ * the caller (its lifetime = one loaded model; clear it when the model
95
+ * changes); renderTemplateSheet evicts entries no viewport references any
96
+ * more, keeping it bounded while sharing repeated definitions.
97
+ */
98
+ export class SectionCache {
99
+ private readonly _map = new Map<string, ComputedSection>();
100
+
101
+ get(key: string): ComputedSection | undefined {
102
+ return this._map.get(key);
103
+ }
104
+
105
+ set(key: string, value: ComputedSection): void {
106
+ this._map.set(key, value);
107
+ }
108
+
109
+ evictExcept(used: ReadonlySet<string>): void {
110
+ for (const key of this._map.keys()) {
111
+ if (!used.has(key)) this._map.delete(key);
112
+ }
113
+ }
114
+
115
+ clear(): void {
116
+ this._map.clear();
117
+ }
118
+ }
119
+
120
+ export interface RenderTemplateSheetInput {
121
+ template: LayoutTemplate;
122
+ /** Products with geometry — pass ALL of them (unfiltered; see above). */
123
+ products: ProductLike[];
124
+ /**
125
+ * Fallback plan position (world) for viewports without a section
126
+ * definition — typically the model AABB centre. Without it such
127
+ * viewports are skipped with a warning.
128
+ */
129
+ modelCenter?: [number, number, number];
130
+ /** Section defaults applied where viewports omit cutHeight/depth. */
131
+ defaults?: SectionDefaults;
132
+ /** Reuse section geometry across renders (strongly recommended). */
133
+ cache?: SectionCache;
134
+ /** Context for role:'main'/'anchor' QR elements. */
135
+ qrContext?: {
136
+ host?: string | null;
137
+ containerName?: string | null;
138
+ floorName?: string | null;
139
+ planeHeight?: number | null;
140
+ };
141
+ /** Values for the template's declared variables ({{vars.*}}). */
142
+ variables?: Record<string, unknown>;
143
+ /**
144
+ * Host-side authorisation for fetching `https://` resources, matching the option `renderDrawing`
145
+ * already takes. Without it a caller rendering a sheet had no say at all — only the template
146
+ * could enable remote loading — so a host that wanted to permit it for its own templates could
147
+ * not, and one that wanted to observe the setting had nowhere to look.
148
+ */
149
+ allowRemoteResources?: boolean;
150
+ /**
151
+ * Progress callback: overall fraction 0–1 plus a human-readable phase.
152
+ * Section phases stream from SlabSection per viewport (cached sections
153
+ * skip ahead); the final render step reports as its own phase. Wire this
154
+ * to a busy indicator — large models can take tens of seconds, and
155
+ * without feedback a long compute is indistinguishable from a hang.
156
+ */
157
+ onProgress?: (progress: number, phase: string) => void;
158
+ }
159
+
160
+ export interface RenderedTemplateSheet {
161
+ svg: string;
162
+ warnings: string[];
163
+ /** Sum over viewports — 0 means no cut produced drawable geometry. */
164
+ segmentCount: number;
165
+ }
166
+
167
+ /**
168
+ * Read a viewport's `stairArrows` geometry options into a canonical object:
169
+ * known fields only, fixed order, invalid values dropped — so equal options
170
+ * always serialize to the same cache-key fragment and unknown junk from a
171
+ * hand-written template can't reach the geometry pass.
172
+ */
173
+ export function readViewportStairArrowOptions(vp: Record<string, unknown>): ViewportStairArrowOptions | undefined {
174
+ const raw = vp.stairArrows;
175
+ if (!raw || typeof raw !== 'object') return undefined;
176
+ const r = raw as Record<string, unknown>;
177
+ const out: ViewportStairArrowOptions = {};
178
+ if (r.startMarker === 'filled' || r.startMarker === 'open' || r.startMarker === 'none') {
179
+ out.startMarker = r.startMarker;
180
+ }
181
+ if (r.arrowhead === 'filled' || r.arrowhead === 'outline' || r.arrowhead === 'open') {
182
+ out.arrowhead = r.arrowhead;
183
+ }
184
+ for (const k of ['lengthScale', 'minLength', 'maxLength', 'landingExtension'] as const) {
185
+ const v = r[k];
186
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out[k] = v;
187
+ }
188
+ return Object.keys(out).length > 0 ? out : undefined;
189
+ }
190
+
191
+ async function computeSectionForDef(
192
+ products: ProductLike[],
193
+ def: ResolvedSectionDef,
194
+ onPhase?: (pct: number, phase: string) => void,
195
+ classBehavior?: ClassBehaviorRule[],
196
+ stairArrows?: ViewportStairArrowOptions
197
+ ): Promise<ComputedSection> {
198
+ if (!def.plan) {
199
+ // Elevations/sections: a thin plane section projected by the section-
200
+ // state builder — its planeNormal projection is the canonical
201
+ // orientation (a hand-rolled basis renders elevations mirrored).
202
+ onPhase?.(0, 'Computing plane sections');
203
+ const n = def.normal;
204
+ // AABB gate: a product whose bounds do not straddle the section plane
205
+ // cannot contribute plane sections — skip it before any mesh access.
206
+ const straddling = products.filter((p) => {
207
+ const bi = productAabb(p);
208
+ if (!bi) return true;
209
+ const c0 = (bi.min[0] + bi.max[0]) * 0.5 * n[0];
210
+ const c1 = (bi.min[1] + bi.max[1]) * 0.5 * n[1];
211
+ const c2 = (bi.min[2] + bi.max[2]) * 0.5 * n[2];
212
+ const e =
213
+ ((bi.max[0] - bi.min[0]) / 2) * Math.abs(n[0]) +
214
+ ((bi.max[1] - bi.min[1]) / 2) * Math.abs(n[1]) +
215
+ ((bi.max[2] - bi.min[2]) / 2) * Math.abs(n[2]);
216
+ const center = c0 + c1 + c2;
217
+ return center - e <= def.position && center + e >= def.position;
218
+ });
219
+ const origin: Vec3 = [n[0] * def.position, n[1] * def.position, n[2] * def.position];
220
+ const plane = planeFromPointAndNormal(origin, n as unknown as Vec3);
221
+ const section = await computePlaneSectionsForProducts(straddling, plane);
222
+ onPhase?.(100, 'Plane sections complete');
223
+ return {
224
+ kind: 'elevation',
225
+ perProduct: section.perProduct as unknown as SectionEntries,
226
+ normal: n as unknown as Vec3,
227
+ segmentCount: section.segments.length,
228
+ entries: []
229
+ };
230
+ }
231
+
232
+ // Plans go through SlabSection (cut/visible/hidden lines with occlusion).
233
+ const viewDir: [number, number, number] = [0, -1, 0];
234
+ const above = Math.max(0, def.depth.above);
235
+ const below = Math.max(0, def.depth.below);
236
+ const slab = new SlabSection<ProductLike>({
237
+ cutHeight: def.position,
238
+ viewDir,
239
+ cutOrigin: [0, def.position, 0],
240
+ depth: { above, below },
241
+ // stairArrows: generated only for stair-category products (className fallback in
242
+ // the gather), so enabling it unconditionally costs nothing on stairless slabs —
243
+ // and the stairArrows lineStyles category has been in the template schema all
244
+ // along, styling output this pipeline never produced.
245
+ lines: { cut: true, visible: true, hidden: true, stairArrows: true },
246
+ // Template-driven marker shapes / run sizing; SlabSection merges the
247
+ // partial object with its own defaults.
248
+ ...(stairArrows ? { stairArrows } : {}),
249
+ // Per-product attribution feeds labels/legend items.
250
+ trackProducts: true,
251
+ onProgress: onPhase,
252
+ // Forward-compatible pass-through: the published product-geometry ignores unknown
253
+ // options; the classBehavior contract activates with TWINS-3784's SlabSection.
254
+ ...(classBehavior?.length ? { classBehavior } : {})
255
+ });
256
+ const geometry = await slab.compute(products);
257
+ const segmentCount = geometry.cutLines.length + geometry.visibleLines.length + geometry.hiddenLines.length;
258
+ const entries = slabPerProductToEntries(geometry.perProduct ?? []);
259
+ const basis = buildPlaneBasis([0, def.position + above, 0], viewDir, [0, 0, 1]);
260
+ return { kind: 'plan', geometry, basis, segmentCount, entries };
261
+ }
262
+
263
+ /** Build a viewport's model view from cached geometry + its own lineStyles. */
264
+ function buildViewForViewport(computed: ComputedSection, vp: Record<string, unknown>): SvgModelView | null {
265
+ const lineStyles = vp.lineStyles as ViewportLineStyles | undefined;
266
+ if (computed.kind === 'plan') {
267
+ return slabSectionToModelView(computed.geometry, computed.basis, lineStyles);
268
+ }
269
+ const state = buildSectionStateForEntries(computed.perProduct, {
270
+ strokeWidth: lineStyles?.default?.widthMm ?? 0.25,
271
+ includeMeta: true,
272
+ includeLabels: false,
273
+ fills: vp.fills as SectionStateOptions['fills'],
274
+ planeNormal: computed.normal
275
+ });
276
+ return state ? sectionStateToModelView(state) : null;
277
+ }
278
+
279
+ /**
280
+ * Render a template against a product set: resolve each viewport's section
281
+ * definition, compute (or reuse from `cache`) the section per distinct
282
+ * definition, and feed renderDrawing one model per viewport id.
283
+ */
284
+ export async function renderTemplateSheet(input: RenderTemplateSheetInput): Promise<RenderedTemplateSheet> {
285
+ const warnings: string[] = [];
286
+ const cache = input.cache ?? new SectionCache();
287
+
288
+ // Resolve template variables BEFORE section resolution — variables can
289
+ // drive section placement ("cutHeight": "{{vars.cutHeight}}", typed
290
+ // whole-value substitution), and the geometry cache must key on the
291
+ // resolved values. The resolved template carries no {{vars.*}} left, so
292
+ // renderDrawing gets no variables input.
293
+ let template = input.template;
294
+ if (template.variables) {
295
+ const { resolveTemplateValues, applyTemplateVariables } = await import('./templateVariables');
296
+ const { values, warnings: varWarnings } = resolveTemplateValues(template, input.variables);
297
+ varWarnings.forEach((w) => warnings.push(`[Variables] ${w}`));
298
+ template = applyTemplateVariables(template, values, warnings);
299
+ }
300
+
301
+ const viewports = ((template.elements ?? []) as unknown as Array<Record<string, unknown>>).filter(
302
+ (e): e is Record<string, unknown> & { id: string } => e.kind === 'viewport' && typeof e.id === 'string'
303
+ );
304
+
305
+ if (viewports.length === 0) {
306
+ warnings.push('Template has no viewport elements — nothing to section.');
307
+ }
308
+
309
+ // Per-viewport compute-class closure (classClosure.ts): only the viewport's include
310
+ // classes take part in its drawing generation, hidden lines included — geometry
311
+ // outside the closure neither draws nor OCCLUDES there, and pays no compute cost
312
+ // (the dominant lever on MEP-heavy models). The closure contains the viewport's
313
+ // highlight/clip/fill/label/legend classes too, so the "sections computed unfiltered"
314
+ // invariant above still holds WITHIN it. The closure is part of the section cache
315
+ // key: viewports sharing (sectionDef, closure) share geometry, and editing
316
+ // includeClasses re-sections.
317
+ const productsByClosureKey = new Map<string, ProductLike[]>();
318
+ const productsForClosure = (closure: Set<string> | null): { products: ProductLike[]; closureKey: string } => {
319
+ const closureKey = closure ? [...closure].sort().join(',') : 'all';
320
+ let products = productsByClosureKey.get(closureKey);
321
+ if (!products) {
322
+ products = closure ? filterProductsByClosure(input.products, closure) : input.products;
323
+ productsByClosureKey.set(closureKey, products);
324
+ }
325
+ return { products, closureKey };
326
+ };
327
+
328
+ const models: Record<string, SvgModelView> = {};
329
+ const usedKeys = new Set<string>();
330
+ let segmentCount = 0;
331
+ // Labels/legend items are placed in world x/z (floor-plane convention) and must see ALL products
332
+ // regardless of viewport class filters — so they come from each viewport's unfiltered section.
333
+ //
334
+ // Keyed by viewport id rather than collapsed into one list: a legend declares which viewport it
335
+ // describes (`legend.viewport`, required) and renderLayout reads that viewport's items, so a
336
+ // sheet may carry several legends describing several plans. Passing one flat list handed every
337
+ // legend the first plan's contents no matter what it was bound to.
338
+ // No fallback to another viewport's entries: a legend describing a viewport that yielded nothing
339
+ // renders empty and renderLayout warns by name ("Legend 'x' has no items from viewport 'y'"),
340
+ // which is more use than quietly showing another viewport's contents under its heading.
341
+ const entriesByViewport: Record<string, ProductPolylineEntry[]> = {};
342
+ // gid → class/property metadata for EVERY product in every section. The entries
343
+ // arrays only cover cut-bearing products, but render-time includeClasses filtering
344
+ // must classify products whose geometry never crosses the cut (furniture below a
345
+ // plan cut) — without this map their visible/hidden lines vanish from the drawing.
346
+ const fullMetadata: ReturnType<typeof buildMetadataMap> = {};
347
+
348
+ for (const vp of viewports) {
349
+ let def = resolveViewportSection(vp as never, input.defaults);
350
+ if (!def) {
351
+ if (!input.modelCenter) {
352
+ warnings.push(`Viewport '${vp.id}' has no section definition and no modelCenter fallback — skipped.`);
353
+ continue;
354
+ }
355
+ def = {
356
+ plan: true,
357
+ normal: [0, 1, 0],
358
+ position: input.modelCenter[1],
359
+ depth: { above: 0.5, below: 1.5 }
360
+ };
361
+ warnings.push(
362
+ `Viewport '${vp.id}' has no section definition — using a default plan at ${def.position.toFixed(1)} m.`
363
+ );
364
+ }
365
+ const { products, closureKey } = productsForClosure(computeViewportClassClosure(vp, template));
366
+ const classBehavior = collectClassBehavior(vp, template);
367
+ // Behavior is part of the cache key: same def+closure with different behavior
368
+ // flags must not share geometry.
369
+ const behaviorKey = classBehavior.length ? JSON.stringify(classBehavior) : '';
370
+ // Stair-arrow geometry options are part of the cache key too: the arrows are
371
+ // baked into the section geometry, so viewports with different marker styles
372
+ // must not share it. readViewportStairArrowOptions canonicalizes field order.
373
+ const stairArrows = readViewportStairArrowOptions(vp);
374
+ const stairKey = stairArrows ? JSON.stringify(stairArrows) : '';
375
+ const key = `${sectionDefKey(def)}|${closureKey}|${behaviorKey}|${stairKey}`;
376
+ usedKeys.add(key);
377
+ let computed = cache.get(key);
378
+ if (!computed) {
379
+ // Section compute dominates total time; reserve the last 10% for the
380
+ // render step below. Cached viewports jump ahead without reporting.
381
+ const vpIndex = viewports.indexOf(vp);
382
+ computed = await computeSectionForDef(
383
+ products,
384
+ def,
385
+ (pct, phase) =>
386
+ input.onProgress?.(
387
+ ((vpIndex + pct / 100) / viewports.length) * 0.9,
388
+ `Viewport '${vp.id}': ${phase}`
389
+ ),
390
+ classBehavior,
391
+ stairArrows
392
+ );
393
+ cache.set(key, computed);
394
+ }
395
+ segmentCount += computed.segmentCount;
396
+ if (computed.kind === 'plan') {
397
+ Object.assign(fullMetadata, buildMetadataMap((computed.geometry.perProduct ?? []) as unknown[]));
398
+ } else {
399
+ Object.assign(fullMetadata, buildMetadataMap(computed.perProduct as unknown[]));
400
+ }
401
+ if (computed.entries?.length) entriesByViewport[vp.id] = computed.entries;
402
+ const view = buildViewForViewport(computed, vp);
403
+ if (!view) {
404
+ warnings.push(`Viewport '${vp.id}': section produced no drawable geometry at this cut.`);
405
+ continue;
406
+ }
407
+ models[vp.id] = view;
408
+ }
409
+
410
+ cache.evictExcept(usedKeys);
411
+
412
+ if (Object.keys(models).length === 0) {
413
+ return { svg: '', warnings, segmentCount };
414
+ }
415
+
416
+ input.onProgress?.(0.9, 'Rendering sheet');
417
+ const { svg, warnings: renderWarnings } = await renderDrawing({
418
+ // Already variable-resolved above — pass no variables down.
419
+ template,
420
+ models,
421
+ options: {
422
+ allowRemoteResources:
423
+ (template as { allowRemoteResources?: boolean }).allowRemoteResources ?? input.allowRemoteResources,
424
+ qrContext: input.qrContext,
425
+ metadataById: fullMetadata,
426
+ // Feeds labels/legend items, keyed by viewport so each legend describes the viewport it
427
+ // declares — a sheet may carry several legends over several plans. This used to be one
428
+ // flat list taken from the first plan viewport, which handed every legend that plan's
429
+ // contents no matter what it was bound to.
430
+ productEntries: Object.keys(entriesByViewport).length ? entriesByViewport : undefined
431
+ }
432
+ });
433
+ input.onProgress?.(1, 'Complete');
434
+ return { svg, warnings: [...warnings, ...renderWarnings], segmentCount };
435
+ }
@@ -1,5 +1,10 @@
1
1
  import { isRecord } from './typeGuards';
2
2
 
3
+ /** Round a coordinate for SVG output (3 decimals ≈ 1 µm at mm scale) —
4
+ * emitting raw doubles bloats the SVG with 17-digit numbers. */
5
+ export const formatNumber = (value: number): number =>
6
+ Number.isFinite(value) ? Number.parseFloat(value.toFixed(3)) : 0;
7
+
3
8
  export const escapeAttr = (value: unknown): string =>
4
9
  String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
5
10
 
@@ -21,8 +26,51 @@ export const getPathValue = (source: unknown, path: string): unknown => {
21
26
  return current;
22
27
  };
23
28
 
29
+ /**
30
+ * Split a placeholder expression into its value path and optional format
31
+ * filter: "area | round:1" → { path: 'area', filter: 'round:1' }.
32
+ */
33
+ export const splitPlaceholder = (expr: string): { path: string; filter: string | null } => {
34
+ const pipe = expr.indexOf('|');
35
+ if (pipe < 0) return { path: expr.trim(), filter: null };
36
+ return { path: expr.slice(0, pipe).trim(), filter: expr.slice(pipe + 1).trim() };
37
+ };
38
+
39
+ /**
40
+ * Apply a placeholder format filter to a resolved value:
41
+ * - `round` / `round:N` — round to N decimals (default 0), trailing zeros trimmed
42
+ * - `fixed` / `fixed:N` — exactly N decimals (default 0), for aligned output
43
+ * Non-numeric values pass through unchanged; unknown filters warn and pass
44
+ * through so a typo degrades to the unformatted value, not an empty label.
45
+ */
46
+ export const applyPlaceholderFilter = (
47
+ value: unknown,
48
+ filter: string | null,
49
+ warnings?: string[],
50
+ context?: string
51
+ ): unknown => {
52
+ if (!filter) return value;
53
+ const m = /^(round|fixed)(?:\s*:\s*(\d+))?$/.exec(filter);
54
+ if (!m) {
55
+ warnings?.push(`Unknown placeholder filter '${filter}'${context ? ` in "${context}"` : ''}.`);
56
+ return value;
57
+ }
58
+ const num = typeof value === 'number' ? value : Number(value);
59
+ if (typeof value === 'boolean' || value === '' || !Number.isFinite(num)) return value;
60
+ const digits = m[2] ? Number(m[2]) : 0;
61
+ // toFixed throws RangeError outside 0..100, and the digit count comes from the template — a
62
+ // stray `{{ x | fixed:200 }}` would take the whole render down rather than warning about itself.
63
+ if (!Number.isInteger(digits) || digits < 0 || digits > 100) {
64
+ warnings?.push(`Filter '${filter}' needs 0-100 decimals${context ? ` in "${context}"` : ''}.`);
65
+ return value;
66
+ }
67
+ const fixed = num.toFixed(digits);
68
+ return m[1] === 'fixed' ? fixed : String(Number(fixed));
69
+ };
70
+
24
71
  export const interpolate = (template: string, data: any, warnings?: string[]): string =>
25
- template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, path) => {
72
+ template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, expr) => {
73
+ const { path, filter } = splitPlaceholder(expr);
26
74
  const value = getPathValue(data, path);
27
75
  if (value === undefined || value === null) {
28
76
  if (warnings) {
@@ -38,15 +86,16 @@ export const interpolate = (template: string, data: any, warnings?: string[]): s
38
86
  }
39
87
  return '';
40
88
  }
41
- return String(value);
89
+ return String(applyPlaceholderFilter(value, filter, warnings, template));
42
90
  });
43
91
 
44
92
  export const interpolateWithSources = (template: string, sources: any[]): string =>
45
- template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, path) => {
93
+ template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, expr) => {
94
+ const { path, filter } = splitPlaceholder(expr);
46
95
  for (const source of sources) {
47
96
  const value = getPathValue(source, path);
48
97
  if (value !== undefined && value !== null && typeof value !== 'object') {
49
- return String(value);
98
+ return String(applyPlaceholderFilter(value, filter));
50
99
  }
51
100
  }
52
101
  return '';
@@ -0,0 +1,131 @@
1
+ import type { ViewportElement } from './types';
2
+
3
+ /**
4
+ * The template's per-viewport section contract, resolved to one canonical
5
+ * shape. Templates declare either `cutHeight` (+ `depth`) for horizontal
6
+ * plans or `planeNormal` + `planePosition` for elevations/sections; this
7
+ * module is the single reader/writer of those fields so every consumer
8
+ * (workbench, viewer print dialog, …) interprets them identically.
9
+ *
10
+ * Canonical form: plans always have `normal = [0, 1, 0]` and `position` =
11
+ * world Y (a `planeNormal: [0,-1,0]` declaration is folded into that), so
12
+ * `position` can be used directly as the cut height.
13
+ */
14
+ export interface ResolvedSectionDef {
15
+ /** True when the cut is a horizontal plan (normal along Y). */
16
+ plan: boolean;
17
+ /** Normalized cut-plane normal (world). `[0, 1, 0]` for plans. */
18
+ normal: [number, number, number];
19
+ /** Position along the normal in world metres — world Y for plans. */
20
+ position: number;
21
+ /** Slab thickness in front of / behind the plane (metres). */
22
+ depth: { above: number; below: number };
23
+ }
24
+
25
+ export const DEFAULT_SECTION_DEPTH = { above: 0.5, below: 1.5 } as const;
26
+
27
+ export interface SectionDefaults {
28
+ cutHeight?: number;
29
+ depth?: { above?: number; below?: number };
30
+ }
31
+
32
+ const AXIS_EPSILON = 1e-6;
33
+
34
+ /**
35
+ * True when a section field carries a whole-value variable binding
36
+ * (`"{{vars.cutHeight}}"`). Bound fields resolve to numbers only after
37
+ * `applyTemplateVariables` — resolveViewportSection treats them as
38
+ * unresolvable rather than guessing.
39
+ */
40
+ export function isVariableBinding(v: unknown): boolean {
41
+ return typeof v === 'string' && /^\{\{\s*vars\.\w+\s*\}\}$/.test(v);
42
+ }
43
+
44
+ /** True when any of the viewport's section fields is variable-bound. */
45
+ export function sectionUsesVariables(vp: Pick<ViewportElement, 'cutHeight' | 'planePosition'>): boolean {
46
+ return isVariableBinding(vp.cutHeight) || isVariableBinding(vp.planePosition);
47
+ }
48
+
49
+ /**
50
+ * Read a viewport element's section definition. `planeNormal` +
51
+ * `planePosition` takes precedence over `cutHeight` (matching the field
52
+ * docs); returns null when the viewport declares nothing (and no default
53
+ * `cutHeight` is provided) or when a section field is still variable-bound
54
+ * (resolve the template's variables first).
55
+ */
56
+ export function resolveViewportSection(
57
+ vp: Pick<ViewportElement, 'cutHeight' | 'planeNormal' | 'planePosition' | 'depth'>,
58
+ defaults?: SectionDefaults
59
+ ): ResolvedSectionDef | null {
60
+ // Unresolved bindings: never guess a position — callers must apply
61
+ // template variables before resolving sections.
62
+ if (sectionUsesVariables(vp)) return null;
63
+
64
+ const above =
65
+ typeof vp.depth?.above === 'number' ? vp.depth.above : (defaults?.depth?.above ?? DEFAULT_SECTION_DEPTH.above);
66
+ const below =
67
+ typeof vp.depth?.below === 'number' ? vp.depth.below : (defaults?.depth?.below ?? DEFAULT_SECTION_DEPTH.below);
68
+ const depth = { above: Math.max(0, above), below: Math.max(0, below) };
69
+
70
+ const n = vp.planeNormal;
71
+ if (Array.isArray(n) && n.length === 3 && n.every((v) => typeof v === 'number')) {
72
+ const len = Math.hypot(n[0], n[1], n[2]);
73
+ if (len > AXIS_EPSILON) {
74
+ let normal: [number, number, number] = [n[0] / len, n[1] / len, n[2] / len];
75
+ let position = typeof vp.planePosition === 'number' ? vp.planePosition : 0;
76
+ const plan = Math.abs(normal[0]) < AXIS_EPSILON && Math.abs(normal[2]) < AXIS_EPSILON;
77
+ if (plan && normal[1] < 0) {
78
+ // Canonicalize plans to +Y so position === world Y.
79
+ normal = [0, 1, 0];
80
+ position = -position;
81
+ } else if (plan) {
82
+ normal = [0, 1, 0];
83
+ }
84
+ return { plan, normal, position, depth };
85
+ }
86
+ }
87
+
88
+ if (typeof vp.cutHeight === 'number') {
89
+ return { plan: true, normal: [0, 1, 0], position: vp.cutHeight, depth };
90
+ }
91
+ if (typeof defaults?.cutHeight === 'number') {
92
+ return { plan: true, normal: [0, 1, 0], position: defaults.cutHeight, depth };
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Write a section definition back onto a viewport element in canonical form:
99
+ * plans use the `cutHeight` shorthand (and drop `planeNormal`/`planePosition`),
100
+ * everything else uses `planeNormal` + `planePosition` (and drops
101
+ * `cutHeight`). Keeping reader and writer together prevents the two
102
+ * shorthands from coexisting on one element, where reader precedence would
103
+ * silently decide.
104
+ */
105
+ export function writeViewportSection(
106
+ vp: Partial<Pick<ViewportElement, 'cutHeight' | 'planeNormal' | 'planePosition' | 'depth'>>,
107
+ def: ResolvedSectionDef
108
+ ): void {
109
+ vp.depth = { above: def.depth.above, below: def.depth.below };
110
+ if (def.plan) {
111
+ vp.cutHeight = def.normal[1] >= 0 ? def.position : -def.position;
112
+ delete vp.planeNormal;
113
+ delete vp.planePosition;
114
+ } else {
115
+ vp.planeNormal = [def.normal[0], def.normal[1], def.normal[2]];
116
+ vp.planePosition = def.position;
117
+ delete vp.cutHeight;
118
+ }
119
+ }
120
+
121
+ const fmtNum = (v: number): string => String(Math.round(v * 1e6) / 1e6);
122
+
123
+ /** Stable cache key for a resolved section definition (same cut ⇒ same key). */
124
+ export function sectionDefKey(def: ResolvedSectionDef): string {
125
+ return [
126
+ def.plan ? 'plan' : 'section',
127
+ def.normal.map(fmtNum).join(','),
128
+ fmtNum(def.position),
129
+ `${fmtNum(def.depth.above)}/${fmtNum(def.depth.below)}`
130
+ ].join('|');
131
+ }