@twinfinity/printing 6.1.0-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,96 @@
1
+ import type { SectionRenderState } from '@twinfinity/geometry';
2
+ import { validateLayoutTemplate } from './validation';
3
+ import { validateSectionState } from './sectionValidation';
4
+ import { isRecord, getProp } from './typeGuards';
5
+ import type { LayoutTemplate, ValidationIssue, ValidationOptions } from './types';
6
+
7
+ export type LayoutDataValidationResult =
8
+ | { ok: true }
9
+ | {
10
+ ok: false;
11
+ errors: string[];
12
+ };
13
+
14
+ export type PreflightResult =
15
+ | { ok: true }
16
+ | {
17
+ ok: false;
18
+ templateErrors?: ValidationIssue[];
19
+ dataErrors?: string[];
20
+ sectionErrors?: string[];
21
+ };
22
+
23
+ /**
24
+ * Validate template, layout data, and section state in a single call.
25
+ * Combines `validateLayoutTemplate`, `validateLayoutData`, and `validateSectionState`.
26
+ */
27
+ export const preflight = (
28
+ template: LayoutTemplate,
29
+ data: unknown,
30
+ expectedViewportIds: string[],
31
+ sectionState?: SectionRenderState,
32
+ /**
33
+ * Forwarded to `validateLayoutTemplate`. Without it preflight always judged the template under
34
+ * default policy, so a caller that permits remote resources or data URLs was told its template
35
+ * was invalid by the very check meant to predict whether rendering would succeed.
36
+ */
37
+ options?: ValidationOptions
38
+ ): PreflightResult => {
39
+ const templateValidation = validateLayoutTemplate(template, options);
40
+ const dataValidation = validateLayoutData(data, expectedViewportIds);
41
+ const sectionValidation = sectionState ? validateSectionState(sectionState) : { ok: true as const };
42
+
43
+ const ok = templateValidation.ok && dataValidation.ok && sectionValidation.ok;
44
+ if (ok) return { ok: true };
45
+
46
+ return {
47
+ ok: false,
48
+ templateErrors: templateValidation.ok ? undefined : templateValidation.errors,
49
+ dataErrors: dataValidation.ok ? undefined : dataValidation.errors,
50
+ sectionErrors: sectionValidation.ok ? undefined : sectionValidation.errors
51
+ };
52
+ };
53
+
54
+ /**
55
+ * Validate layout data before rendering to catch missing propertySets or items arrays.
56
+ */
57
+ export const validateLayoutData = (data: unknown, expectedViewportIds: string[]): LayoutDataValidationResult => {
58
+ const errors: string[] = [];
59
+ if (!isRecord(data)) {
60
+ return { ok: false, errors: ['layout data must be an object'] };
61
+ }
62
+ expectedViewportIds.forEach((vpId) => {
63
+ const viewports = getProp(data, 'viewports');
64
+ const viewport = isRecord(viewports) ? getProp(viewports, vpId) : undefined;
65
+ const items = getProp(viewport, 'items');
66
+ if (!Array.isArray(items)) {
67
+ errors.push(`viewports.${vpId}.items is required and must be an array.`);
68
+ return;
69
+ }
70
+ items.forEach((it: unknown, idx: number) => {
71
+ if (!isRecord(it)) {
72
+ errors.push(`viewports.${vpId}.items[${idx}] must be an object.`);
73
+ return;
74
+ }
75
+ const sets = getProp(it, 'propertySets');
76
+ if (!isRecord(sets)) {
77
+ errors.push(`viewports.${vpId}.items[${idx}] is missing propertySets.<Set>.<Prop>.value.`);
78
+ return;
79
+ }
80
+ Object.entries(sets).forEach(([setName, props]) => {
81
+ if (!isRecord(props)) {
82
+ errors.push(`viewports.${vpId}.items[${idx}].propertySets.${setName} must be an object.`);
83
+ return;
84
+ }
85
+ Object.entries(props).forEach(([propName, payload]) => {
86
+ if (!isRecord(payload) || !('value' in payload)) {
87
+ errors.push(
88
+ `viewports.${vpId}.items[${idx}].propertySets.${setName}.${propName} must include a value (propertySets.<Set>.<Prop>.value).`
89
+ );
90
+ }
91
+ });
92
+ });
93
+ });
94
+ });
95
+ return errors.length ? { ok: false, errors } : { ok: true };
96
+ };
@@ -1,5 +1,6 @@
1
1
  import { escapeAttr } from './renderUtils';
2
- import { interpolate } from './renderUtils';
2
+ import { interpolate, formatNumber } from './renderUtils';
3
+ import { resolveViewportSection } from './sectionDefs';
3
4
  import { QrElement, SvgModelView } from './types';
4
5
 
5
6
  async function getQRCode(): Promise<typeof import('qrcode')> {
@@ -58,13 +59,18 @@ export const renderQrElements = async (
58
59
  const t = boundModel.transform;
59
60
  const worldX = t.viewMinX + modelX;
60
61
  const worldZ = t.viewMaxZ - modelY;
61
- const planeHeight = options?.qrContext?.planeHeight ?? 0;
62
- coordinate = { x: formatNumber(worldX), y: formatNumber(planeHeight ?? 0), z: formatNumber(worldZ) };
62
+ // Height: explicit qrContext wins; otherwise the bound viewport's
63
+ // own section definition (the template already knows its cut).
64
+ const sectionDef = resolveViewportSection(boundViewportDef);
65
+ const planeHeight = options?.qrContext?.planeHeight ?? (sectionDef?.plan ? sectionDef.position : 0);
66
+ coordinate = { x: formatNumber(worldX), y: formatNumber(planeHeight), z: formatNumber(worldZ) };
63
67
  }
64
68
 
65
69
  let payload: unknown = qr.dataTemplate ? interpolate(qr.dataTemplate, dataWithViewport) : null;
70
+ // Schema default: a QR with no role and no dataTemplate is role "main".
71
+ const role = qr.role ?? (qr.dataTemplate ? 'custom' : 'main');
66
72
  if (!payload || typeof payload === 'string') {
67
- if (qr.role === 'anchor' && coordinate) {
73
+ if (role === 'anchor' && coordinate) {
68
74
  const corner =
69
75
  qr.anchorCorner === 'bottom-left'
70
76
  ? 'bl'
@@ -76,7 +82,7 @@ export const renderQrElements = async (
76
82
  ? 'tr'
77
83
  : 'na';
78
84
  payload = { v: 1, c: corner, p: coordinate };
79
- } else if (qr.role === 'main') {
85
+ } else if (role === 'main') {
80
86
  const cornerShort =
81
87
  qr.anchorCorner === 'bottom-left'
82
88
  ? 'bl'
@@ -119,8 +125,3 @@ export const renderQrElements = async (
119
125
 
120
126
  return parts;
121
127
  };
122
-
123
- const formatNumber = (value: number): number => {
124
- if (!Number.isFinite(value)) return 0;
125
- return Number.parseFloat(value.toFixed(3));
126
- };
@@ -1,19 +1,21 @@
1
1
  import type { SectionRenderState, SectionPath, SectionLabel } from '@twinfinity/geometry';
2
- import { sectionStateToModelView, computePolylinesCenter } from './itemBuilders';
2
+ import { sectionStateToModelView, computePolylinesCenter, readXZ } from './itemBuilders';
3
3
  import { buildMetadataMap, normalizePropertySets, resolveIfcClassName, resolveIfcClassId } from './metadata';
4
4
  import { applyLineStyleToPaths } from './utils';
5
5
  import { analyzeTemplate } from './analyzeTemplate';
6
- import { matchesV2Filter } from './filters';
6
+ import { matchesFilter, matchesProductClass, normalizeValue } from './filters';
7
7
  import type {
8
8
  LayoutTemplate,
9
9
  ViewportElement,
10
10
  ViewportLineStyle,
11
+ ViewportLineStyles,
11
12
  FillRule,
12
13
  SvgModelView,
13
14
  SectionResult,
14
15
  RenderDrawingInput,
15
16
  RenderDrawingResult,
16
17
  NormalizedProductItem,
18
+ ProductPolylineEntry,
17
19
  LabelStyle
18
20
  } from './types';
19
21
  import { getProp } from './typeGuards';
@@ -133,43 +135,29 @@ function getSampleColor(): (product: unknown) => SampledColor | undefined {
133
135
  return _geometry.sampleProductColorRgba;
134
136
  }
135
137
 
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
- }
138
+ import { renderLayout } from './renderLayout';
161
139
 
162
140
  // ---------------------------------------------------------------------------
163
141
  // Public API
164
142
  // ---------------------------------------------------------------------------
165
143
 
166
144
  /**
167
- * High-level entry point that converts raw plane-section results and a layout
168
- * template into a ready-to-print SVG string.
145
+ * High-level entry point that renders a layout template to a ready-to-print
146
+ * SVG string.
169
147
  *
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.
148
+ * The inputs form a 2×2 matrix geometry × item data, each either raw or
149
+ * pre-built:
150
+ *
151
+ * | input | what it is |
152
+ * |---------------------------|--------------------------------------------------|
153
+ * | `sections` | RAW geometry: plane-section results (one shared or per viewport id). |
154
+ * | `models` | PRE-BUILT geometry: `SvgModelView`s per viewport id (e.g. from `slabSectionToModelView`). Needs `rawPaths` with `meta.gid` for highlight/clip/includeClasses. |
155
+ * | `options.productEntries` | RAW item data: per-product world-x/z polylines (`slabPerProductToEntries`); one flat list shared by all viewports or a per-viewport record. Feeds labels/legend/clip/highlight items. |
156
+ * | `options.viewportItems` | PRE-BUILT item data per viewport id (overrides `productEntries`). |
157
+ *
158
+ * Prerequisite: products must have property sets loaded
159
+ * (`await api.ifc.loadPropertySets()` on core BIM objects) — without them
160
+ * every `where` filter matches nothing.
173
161
  *
174
162
  * ```ts
175
163
  * const section = computePlaneSectionsForProducts(products, plane, { includeInvisible: true });
@@ -177,9 +165,8 @@ function getRenderLayout(): NonNullable<typeof _renderLayout> {
177
165
  * template,
178
166
  * sections: { main: section },
179
167
  * options: {
180
- * allowRemoteAssets: true,
181
- * qrContext: { host, containerName, planeHeight },
182
- * productFields: { idPath: "propertySets.BIP.spacenumber.value", ... },
168
+ * allowRemoteResources: true,
169
+ * qrContext: { host, containerName },
183
170
  * roomLabels: { labelPath: "BIP.spacename", classFilter: "space" },
184
171
  * },
185
172
  * });
@@ -200,11 +187,9 @@ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDr
200
187
  varWarnings.forEach((w) => warnings.push(`[Variables] ${w}`));
201
188
 
202
189
  // Apply variables to template (substitute {{vars.*}} references)
203
- template = applyTemplateVariables(template, values);
190
+ template = applyTemplateVariables(template, values, warnings);
204
191
  }
205
192
 
206
- const renderLayout = getRenderLayout();
207
-
208
193
  // ---- Collect viewport elements from template ----
209
194
  const viewports = template.elements.filter((el) => el.kind === 'viewport') as ViewportElement[];
210
195
  if (!viewports.length) {
@@ -213,6 +198,17 @@ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDr
213
198
 
214
199
  // ---- Fast path: pre-built models provided directly ----
215
200
  if (input.models) {
201
+ if (options?.roomLabels) {
202
+ warnings.push('options.roomLabels is ignored on the models path — use a template labels element instead.');
203
+ }
204
+ // productEntries: one flat list for all viewports, or per-viewport record.
205
+ const entriesInput = options?.productEntries;
206
+ const entriesFor = (vpId: string): ProductPolylineEntry[] =>
207
+ Array.isArray(entriesInput) ? entriesInput : (entriesInput?.[vpId] ?? []);
208
+ const allEntries: ProductPolylineEntry[] = Array.isArray(entriesInput)
209
+ ? entriesInput
210
+ : Object.values(entriesInput ?? {}).flat();
211
+
216
212
  // Build items: explicit viewportItems take priority, then auto-build from productEntries
217
213
  let viewportsData: Record<string, { items: NormalizedProductItem[] }>;
218
214
 
@@ -221,13 +217,13 @@ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDr
221
217
  viewports.map((vp) => [vp.id, { items: [] as NormalizedProductItem[] }])
222
218
  );
223
219
  viewportsData = { ...emptyDefaults, ...options.viewportItems };
224
- } else if (options?.productEntries?.length) {
220
+ } else if (allEntries.length) {
225
221
  // Auto-build items per viewport from raw product entries
226
222
  const sampleColor = getSampleColor();
227
223
  viewportsData = {};
228
224
  for (const vp of viewports) {
229
225
  viewportsData[vp.id] = {
230
- items: buildItemsFromEntries(options.productEntries, vp.fills, sampleColor)
226
+ items: buildItemsFromEntries(entriesFor(vp.id), vp.fills, sampleColor)
231
227
  };
232
228
  }
233
229
  } else {
@@ -258,18 +254,29 @@ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDr
258
254
  warnings.push(`Template references property '${path}' but no items provide it.`);
259
255
  }
260
256
  }
257
+ } else if (
258
+ reqs.propertyPaths.length > 0 ||
259
+ template.elements.some((el) => el.kind === 'labels' || el.kind === 'legend')
260
+ ) {
261
+ // Item-driven features with zero items is almost always a missing
262
+ // productEntries/viewportItems input, not an intentionally empty sheet.
263
+ warnings.push(
264
+ 'Template uses item data (labels/legend/where-filters) but no items were provided — pass options.productEntries or options.viewportItems.'
265
+ );
261
266
  }
262
267
 
263
- if (reqs.usesRemoteAssets && !template.allowRemoteAssets && !options?.allowRemoteAssets) {
264
- warnings.push('Template uses remote assets but allowRemoteAssets is not enabled.');
268
+ if (reqs.usesRemoteResources && !template.allowRemoteResources && !options?.allowRemoteResources) {
269
+ warnings.push('Template uses remote resources but allowRemoteResources is not enabled.');
265
270
  }
266
271
 
267
272
  // Build metadata from product entries (same as standard path)
268
- const metadataEntries = options?.productEntries ?? [];
269
- const metadataById = buildMetadataMap(metadataEntries);
273
+ // Caller-supplied metadata first: entries only cover cut-bearing products, but
274
+ // include filtering must classify products whose geometry never crosses the cut
275
+ // (furniture below a plan cut) - renderTemplateSheet supplies the full map.
276
+ const metadataById = { ...(options?.metadataById ?? {}), ...buildMetadataMap(allEntries) };
270
277
 
271
278
  const result = await renderLayout(template, layoutData, input.models, {
272
- allowRemoteAssets: template.allowRemoteAssets ?? options?.allowRemoteAssets,
279
+ allowRemoteResources: template.allowRemoteResources ?? options?.allowRemoteResources,
273
280
  qrContext: options?.qrContext,
274
281
  metadataById
275
282
  });
@@ -302,7 +309,6 @@ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDr
302
309
  // ---- Build section state + model view per viewport ----
303
310
  const models: Record<string, SvgModelView> = {};
304
311
  const entriesByViewport: Record<string, SectionResult['perProduct']> = {};
305
- const sectionStateCache = new Map<string, SectionRenderState | null>();
306
312
 
307
313
  for (const vp of viewports) {
308
314
  const sectionResult = sections[vp.id];
@@ -313,39 +319,44 @@ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDr
313
319
 
314
320
  entriesByViewport[vp.id] = sectionResult.perProduct;
315
321
 
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
322
  const resolved = resolveLineStyles(vp);
326
323
 
327
324
  // Estimate label font size from template style and section bounds
328
325
  const labelFontSize = estimateLabelFontSize(template, vp, sectionResult.perProduct);
329
326
 
330
- // Extract clip polygon from section results if clip config is present
327
+ // Extract clip polygon from section results if clip config is present.
328
+ // Matching semantics MUST mirror matchesFilter (viewportPrep/labels/
329
+ // legend all use it on the same clip config): class optional,
330
+ // Ifc prefix optional, case-insensitive values, arrays mean any-of.
331
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];
332
+ if (vp.clip?.where) {
333
+ const clipClass = vp.clip.class ? (Array.isArray(vp.clip.class) ? vp.clip.class : [vp.clip.class]) : null;
334
334
  const clipEntries = sectionResult.perProduct.filter((entry: any) => {
335
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
336
+ // Shared helper rather than product.class.is(): that compares with ===, so a clip
337
+ // filter of "SPACE" or "IFCWALL" selected nothing while labels and legend — reading
338
+ // the same clip config through matchesFilter — matched it. The clip silently did
339
+ // not apply, with no warning anywhere.
340
+ if (!matchesProductClass(product, clipClass ?? undefined)) return false;
339
341
  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;
342
+ const actual = normalizeValue(resolveProductProperty(product, path));
343
+ const matches = Array.isArray(expected)
344
+ ? expected.some((v) => normalizeValue(v) === actual)
345
+ : actual === normalizeValue(expected);
346
+ if (!matches) return false;
343
347
  }
344
348
  return true;
345
349
  });
346
350
  if (clipEntries.length) {
347
351
  clipPolygon = clipEntries.flatMap((entry: any) =>
348
- (entry.polylines || []).map((pl: any) => (pl.points || []).map((p: any) => ({ x: p.x, z: p.z })))
352
+ (entry.polylines || []).map((pl: any) =>
353
+ (pl.points || [])
354
+ .map((p: any) => readXZ(p))
355
+ .filter(
356
+ (p: { x: number | null; z: number | null }): p is { x: number; z: number } =>
357
+ p.x !== null && p.z !== null
358
+ )
359
+ )
349
360
  );
350
361
  }
351
362
  }
@@ -374,8 +385,6 @@ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDr
374
385
  planeNormal
375
386
  });
376
387
 
377
- sectionStateCache.set(vp.id, state);
378
-
379
388
  if (!state) {
380
389
  warnings.push(`No geometry for viewport '${vp.id}'.`);
381
390
  continue;
@@ -423,7 +432,10 @@ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDr
423
432
 
424
433
  // ---- Build metadata from all section entries ----
425
434
  const allEntries = Object.values(entriesByViewport).flat();
426
- const metadataById = buildMetadataMap(allEntries);
435
+ // Caller-supplied metadata first: entries only cover cut-bearing products, but
436
+ // include filtering must classify products whose geometry never crosses the cut
437
+ // (furniture below a plan cut) - renderTemplateSheet supplies the full map.
438
+ const metadataById = { ...(options?.metadataById ?? {}), ...buildMetadataMap(allEntries) };
427
439
 
428
440
  // ---- Assemble layout data ----
429
441
  const layoutData = {
@@ -432,7 +444,7 @@ export async function renderDrawing(input: RenderDrawingInput): Promise<RenderDr
432
444
 
433
445
  // ---- Render ----
434
446
  const result = await renderLayout(template, layoutData, models, {
435
- allowRemoteAssets: template.allowRemoteAssets ?? options?.allowRemoteAssets,
447
+ allowRemoteResources: template.allowRemoteResources ?? options?.allowRemoteResources,
436
448
  qrContext: options?.qrContext,
437
449
  metadataById
438
450
  });
@@ -511,13 +523,12 @@ function estimateLabelFontSize(
511
523
  for (const polyline of entry.polylines) {
512
524
  if (!polyline.points) continue;
513
525
  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;
526
+ const { x: px, z: pz } = readXZ(pt);
527
+ if (px !== null && pz !== null) {
528
+ if (px < minX) minX = px;
529
+ if (px > maxX) maxX = px;
530
+ if (pz < minZ) minZ = pz;
531
+ if (pz > maxZ) maxZ = pz;
521
532
  }
522
533
  }
523
534
  }
@@ -571,11 +582,12 @@ export const BUILTIN_HIDDEN_DEFAULTS: Required<ViewportLineStyle> = {
571
582
  enabled: true
572
583
  };
573
584
 
574
- type LineCategory =
585
+ export type LineCategory =
575
586
  | 'cutLines'
576
587
  | 'visibleLines'
577
588
  | 'hiddenLines'
578
- | 'silhouettes'
589
+ | 'silhouettesVisible'
590
+ | 'silhouettesHidden'
579
591
  | 'featureEdges'
580
592
  | 'stairArrows'
581
593
  | 'outlineVisible'
@@ -585,28 +597,59 @@ type LineCategory =
585
597
  export const CATEGORY_BUILTIN_OVERRIDES: Partial<Record<LineCategory, Partial<ViewportLineStyle>>> = {
586
598
  cutLines: { widthMm: 0.02 },
587
599
  hiddenLines: BUILTIN_HIDDEN_DEFAULTS,
600
+ // Occluded silhouettes follow the hidden drafting convention out of the box.
601
+ silhouettesHidden: BUILTIN_HIDDEN_DEFAULTS,
588
602
  outlineHidden: BUILTIN_HIDDEN_DEFAULTS
589
603
  };
590
604
 
605
+ /** Visibility group of each category; keyed to the `defaultVisibleLines`/`defaultHiddenLines` template keys. `stairArrows` is annotation, not geometry, and belongs to neither group. */
606
+ const CATEGORY_VISIBILITY_GROUP: Partial<Record<LineCategory, 'defaultVisibleLines' | 'defaultHiddenLines'>> = {
607
+ cutLines: 'defaultVisibleLines',
608
+ visibleLines: 'defaultVisibleLines',
609
+ silhouettesVisible: 'defaultVisibleLines',
610
+ silhouettesHidden: 'defaultHiddenLines',
611
+ featureEdges: 'defaultVisibleLines',
612
+ outlineVisible: 'defaultVisibleLines',
613
+ hiddenLines: 'defaultHiddenLines',
614
+ outlineHidden: 'defaultHiddenLines'
615
+ };
616
+
617
+ /**
618
+ * Resolve the effective line style for one category — the single source of
619
+ * truth used by both the slab plan path (slabModelView) and the drawing path.
620
+ *
621
+ * Cascade, weakest first: built-in defaults → `default` → built-in category
622
+ * defaults → `defaultVisibleLines`/`defaultHiddenLines` (the category's
623
+ * visibility group) → the category's own key.
624
+ */
625
+ export function resolveCategoryLineStyle(
626
+ category: LineCategory,
627
+ lineStyles?: ViewportLineStyles
628
+ ): Required<ViewportLineStyle> {
629
+ const group = CATEGORY_VISIBILITY_GROUP[category];
630
+ return {
631
+ ...BUILTIN_LINE_DEFAULTS,
632
+ ...(lineStyles?.default ?? {}),
633
+ ...(CATEGORY_BUILTIN_OVERRIDES[category] ?? {}),
634
+ ...((group && lineStyles?.[group]) ?? {}),
635
+ ...(lineStyles?.[category] ?? {})
636
+ } as Required<ViewportLineStyle>;
637
+ }
638
+
591
639
  export interface ResolvedLineStyles {
592
640
  default: Required<ViewportLineStyle>;
593
641
  byCategory: Record<LineCategory, Required<ViewportLineStyle>>;
594
642
  }
595
643
 
596
644
  /**
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.
645
+ * Resolve the effective line style for every category via
646
+ * {@link resolveCategoryLineStyle} (shared with the slab plan path).
601
647
  */
602
648
  function resolveLineStyles(vp: ViewportElement): ResolvedLineStyles {
603
649
  const ls = vp.lineStyles;
604
650
 
605
- // Base default: lineStyles.default → shorthand → built-in
606
651
  const base: Required<ViewportLineStyle> = {
607
652
  ...BUILTIN_LINE_DEFAULTS,
608
- ...(vp.lineWidthMm !== undefined ? { widthMm: vp.lineWidthMm } : {}),
609
- ...(vp.lineColor !== undefined ? { color: vp.lineColor } : {}),
610
653
  ...(ls?.default ?? {})
611
654
  } as Required<ViewportLineStyle>;
612
655
 
@@ -614,38 +657,17 @@ function resolveLineStyles(vp: ViewportElement): ResolvedLineStyles {
614
657
  'cutLines',
615
658
  'visibleLines',
616
659
  'hiddenLines',
617
- 'silhouettes',
660
+ 'silhouettesVisible',
661
+ 'silhouettesHidden',
618
662
  'featureEdges',
619
663
  'stairArrows',
620
664
  'outlineVisible',
621
665
  'outlineHidden'
622
666
  ];
623
667
 
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
668
  const byCategory = {} as Record<LineCategory, Required<ViewportLineStyle>>;
637
669
  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>;
670
+ byCategory[cat] = resolveCategoryLineStyle(cat, ls);
649
671
  }
650
672
 
651
673
  return { default: base, byCategory };
@@ -667,7 +689,7 @@ export function resolveFillColor(
667
689
  ): [number, number, number, number] | undefined {
668
690
  if (!fills?.length) return undefined;
669
691
  for (const rule of fills) {
670
- if (!matchesV2Filter(item, rule.class, rule.where)) continue;
692
+ if (!matchesFilter(item, rule.class, rule.where)) continue;
671
693
  if (rule.fill === 'sample') {
672
694
  const sampled = sampleColor(product);
673
695
  if (sampled) {