@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
@@ -6,6 +6,9 @@ import {
6
6
  ELEMENT_KIND_IMAGE,
7
7
  ELEMENT_KIND_LINE,
8
8
  ELEMENT_KIND_RECT,
9
+ ELEMENT_KIND_CIRCLE,
10
+ ELEMENT_KIND_POLYLINE,
11
+ ELEMENT_KIND_POLYGON,
9
12
  ELEMENT_KIND_SCALE_BAR,
10
13
  ELEMENT_KIND_QR,
11
14
  ELEMENT_KIND_LABELS,
@@ -20,9 +23,9 @@ export type { ValidationOptions, ValidationIssue, ValidationResult };
20
23
  * Lightweight template validation (structure only) for v2 schema.
21
24
  */
22
25
  export function validateLayoutTemplate(template: unknown, options?: ValidationOptions): ValidationResult {
23
- const allowRemoteAssets = options?.allowRemoteAssets ?? true;
24
26
  const allowDataUrls = options?.allowDataUrls ?? true;
25
- const maxViewports = options?.maxViewports ?? 3;
27
+ const maxViewports = options?.maxViewports ?? 8;
28
+ const strictResourceRefs = options?.strictResourceRefs ?? false;
26
29
  const errors: ValidationIssue[] = [];
27
30
 
28
31
  if (!isRecord(template)) {
@@ -30,6 +33,10 @@ export function validateLayoutTemplate(template: unknown, options?: ValidationOp
30
33
  }
31
34
 
32
35
  const t = template;
36
+ // Remote resources are opt-in (matches the schema default and the
37
+ // renderer's warnings): explicit option wins, else the template's own
38
+ // root flag, else disallowed.
39
+ const allowRemoteResources = options?.allowRemoteResources ?? getProp(t, 'allowRemoteResources') === true;
33
40
 
34
41
  const page = getProp(t, 'page');
35
42
  if (!isRecord(page)) {
@@ -45,39 +52,45 @@ export function validateLayoutTemplate(template: unknown, options?: ValidationOp
45
52
  }
46
53
  }
47
54
 
48
- const assetIds = new Set<string>();
49
- const assets = getProp(t, 'assets');
50
- if (Array.isArray(assets)) {
51
- assets.forEach((asset: unknown, index: number) => {
52
- if (!isRecord(asset)) {
53
- errors.push({ path: `assets[${index}]`, message: 'Asset must be an object.' });
55
+ const resourceIds = new Set<string>();
56
+ const resources = getProp(t, 'resources');
57
+ if (Array.isArray(resources)) {
58
+ resources.forEach((resource: unknown, index: number) => {
59
+ if (!isRecord(resource)) {
60
+ errors.push({ path: `resources[${index}]`, message: 'Resource must be an object.' });
54
61
  return;
55
62
  }
56
- const id = getProp(asset, 'id');
63
+ const id = getProp(resource, 'id');
57
64
  if (!isNonEmptyString(id)) {
58
- errors.push({ path: `assets[${index}].id`, message: 'Asset id is required.' });
59
- } else if (assetIds.has(id as string)) {
60
- errors.push({ path: `assets[${index}].id`, message: 'Duplicate asset id.' });
65
+ errors.push({ path: `resources[${index}].id`, message: 'Resource id is required.' });
66
+ } else if (resourceIds.has(id as string)) {
67
+ errors.push({ path: `resources[${index}].id`, message: 'Duplicate resource id.' });
61
68
  } else {
62
- assetIds.add(id as string);
69
+ resourceIds.add(id as string);
63
70
  }
64
71
 
65
- const src = getProp(asset, 'src');
66
- const content = getProp(asset, 'content');
72
+ const src = getProp(resource, 'src');
73
+ const content = getProp(resource, 'content');
67
74
  if (!isNonEmptyString(src) && !isNonEmptyString(content)) {
68
- errors.push({ path: `assets[${index}]`, message: 'Asset must have either src or content.' });
75
+ errors.push({ path: `resources[${index}]`, message: 'Resource must have either src or content.' });
69
76
  }
70
77
 
71
78
  if (isNonEmptyString(src)) {
72
- if (isRemoteUrl(src as string) && !allowRemoteAssets) {
73
- errors.push({ path: `assets[${index}].src`, message: 'Remote URLs are not allowed for assets.' });
79
+ if (isRemoteUrl(src as string) && !allowRemoteResources) {
80
+ errors.push({
81
+ path: `resources[${index}].src`,
82
+ message: 'Remote URLs are not allowed for resources.'
83
+ });
74
84
  } else if (isDataUrl(src as string) && !allowDataUrls) {
75
- errors.push({ path: `assets[${index}].src`, message: 'Data URLs are not allowed for assets.' });
85
+ errors.push({
86
+ path: `resources[${index}].src`,
87
+ message: 'Data URLs are not allowed for resources.'
88
+ });
76
89
  }
77
90
  }
78
91
  });
79
- } else if (assets !== undefined) {
80
- errors.push({ path: 'assets', message: 'Assets must be an array when provided.' });
92
+ } else if (resources !== undefined) {
93
+ errors.push({ path: 'resources', message: 'Resources must be an array when provided.' });
81
94
  }
82
95
 
83
96
  const styleNames = new Set<string>();
@@ -130,8 +143,8 @@ export function validateLayoutTemplate(template: unknown, options?: ValidationOp
130
143
  ensure(isPositiveNumber(getProp(el, 'heightMm')), `${path}.heightMm`, 'heightMm must be > 0.');
131
144
  const xMm = getProp(el, 'xMm');
132
145
  const yMm = getProp(el, 'yMm');
133
- ensure(isPositiveNumber(xMm) || xMm === 0, `${path}.xMm`, 'xMm is required.');
134
- ensure(isPositiveNumber(yMm) || yMm === 0, `${path}.yMm`, 'yMm is required.');
146
+ ensure(typeof xMm === 'number' && Number.isFinite(xMm), `${path}.xMm`, 'xMm is required.');
147
+ ensure(typeof yMm === 'number' && Number.isFinite(yMm), `${path}.yMm`, 'yMm is required.');
135
148
  const border = getProp(el, 'border');
136
149
  if (border) {
137
150
  // border.stroke and border.strokeWidthMm are optional in v2
@@ -159,8 +172,8 @@ export function validateLayoutTemplate(template: unknown, options?: ValidationOp
159
172
  } else if (kind === ELEMENT_KIND_TEXT) {
160
173
  const xMm = getProp(el, 'xMm');
161
174
  const yMm = getProp(el, 'yMm');
162
- ensure(isPositiveNumber(xMm) || xMm === 0, `${path}.xMm`, 'xMm is required.');
163
- ensure(isPositiveNumber(yMm) || yMm === 0, `${path}.yMm`, 'yMm is required.');
175
+ ensure(typeof xMm === 'number' && Number.isFinite(xMm), `${path}.xMm`, 'xMm is required.');
176
+ ensure(typeof yMm === 'number' && Number.isFinite(yMm), `${path}.yMm`, 'yMm is required.');
164
177
  const textStyle = getProp(el, 'textStyle');
165
178
  if (textStyle !== undefined) {
166
179
  if (typeof textStyle === 'string' && styleNames.size > 0 && !styleNames.has(textStyle)) {
@@ -174,12 +187,47 @@ export function validateLayoutTemplate(template: unknown, options?: ValidationOp
174
187
  } else if (kind === ELEMENT_KIND_IMAGE) {
175
188
  ensure(isPositiveNumber(getProp(el, 'widthMm')), `${path}.widthMm`, 'widthMm must be > 0.');
176
189
  ensure(isPositiveNumber(getProp(el, 'heightMm')), `${path}.heightMm`, 'heightMm must be > 0.');
177
- ensure(isNonEmptyString(getProp(el, 'assetId')), `${path}.assetId`, 'assetId is required.');
190
+ const resourceId = getProp(el, 'resourceId');
191
+ ensure(isNonEmptyString(resourceId), `${path}.resourceId`, 'resourceId is required.');
192
+ // Opt-in only. `renderLayout` throws on any validation error, so treating a dangling
193
+ // reference as one turns a template that used to print without a single logo into a
194
+ // template that does not print at all — the renderer already warns and skips that
195
+ // element, which is the better outcome mid-print. Authoring tools that want the typo
196
+ // caught up front can ask for it.
197
+ if (strictResourceRefs && isNonEmptyString(resourceId) && !resourceIds.has(resourceId)) {
198
+ ensure(false, `${path}.resourceId`, `No resource is declared with id '${resourceId}'.`);
199
+ }
178
200
  } else if (kind === ELEMENT_KIND_LINE) {
179
201
  // stroke and strokeWidthMm optional in v2
180
202
  } else if (kind === ELEMENT_KIND_RECT) {
181
203
  ensure(isPositiveNumber(getProp(el, 'widthMm')), `${path}.widthMm`, 'widthMm must be > 0.');
182
204
  ensure(isPositiveNumber(getProp(el, 'heightMm')), `${path}.heightMm`, 'heightMm must be > 0.');
205
+ } else if (kind === ELEMENT_KIND_CIRCLE) {
206
+ ensure(isPositiveNumber(getProp(el, 'rMm')), `${path}.rMm`, 'rMm must be > 0.');
207
+ const cxMm = getProp(el, 'cxMm');
208
+ const cyMm = getProp(el, 'cyMm');
209
+ ensure(typeof cxMm === 'number' && Number.isFinite(cxMm), `${path}.cxMm`, 'cxMm is required.');
210
+ ensure(typeof cyMm === 'number' && Number.isFinite(cyMm), `${path}.cyMm`, 'cyMm is required.');
211
+ } else if (kind === ELEMENT_KIND_POLYLINE) {
212
+ const points = getProp(el, 'points');
213
+ ensure(
214
+ Array.isArray(points) && points.length >= 2,
215
+ `${path}.points`,
216
+ 'points is required (at least 2 points).'
217
+ );
218
+ ensure(isNonEmptyString(getProp(el, 'stroke')), `${path}.stroke`, 'stroke is required.');
219
+ ensure(
220
+ isPositiveNumber(getProp(el, 'strokeWidthMm')),
221
+ `${path}.strokeWidthMm`,
222
+ 'strokeWidthMm must be > 0.'
223
+ );
224
+ } else if (kind === ELEMENT_KIND_POLYGON) {
225
+ const points = getProp(el, 'points');
226
+ ensure(
227
+ Array.isArray(points) && points.length >= 3,
228
+ `${path}.points`,
229
+ 'points is required (at least 3 points).'
230
+ );
183
231
  } else if (kind === ELEMENT_KIND_SCALE_BAR) {
184
232
  ensure(isPositiveNumber(getProp(el, 'maxLengthMm')), `${path}.maxLengthMm`, 'maxLengthMm must be > 0.');
185
233
  const textStyle = getProp(el, 'textStyle');
@@ -195,8 +243,8 @@ export function validateLayoutTemplate(template: unknown, options?: ValidationOp
195
243
  ensure(isPositiveNumber(getProp(el, 'sizeMm')), `${path}.sizeMm`, 'sizeMm must be > 0.');
196
244
  const xMm = getProp(el, 'xMm');
197
245
  const yMm = getProp(el, 'yMm');
198
- ensure(isPositiveNumber(xMm) || xMm === 0, `${path}.xMm`, 'xMm is required.');
199
- ensure(isPositiveNumber(yMm) || yMm === 0, `${path}.yMm`, 'yMm is required.');
246
+ ensure(typeof xMm === 'number' && Number.isFinite(xMm), `${path}.xMm`, 'xMm is required.');
247
+ ensure(typeof yMm === 'number' && Number.isFinite(yMm), `${path}.yMm`, 'yMm is required.');
200
248
  } else if (kind === ELEMENT_KIND_LABELS) {
201
249
  ensure(isNonEmptyString(getProp(el, 'viewport')), `${path}.viewport`, 'viewport is required.');
202
250
  ensure(isNonEmptyString(getProp(el, 'text')), `${path}.text`, 'text is required.');
@@ -288,8 +336,16 @@ export function validateLayoutTemplate(template: unknown, options?: ValidationOp
288
336
  );
289
337
  const clipXMm = getProp(clip, 'xMm');
290
338
  const clipYMm = getProp(clip, 'yMm');
291
- ensure(isPositiveNumber(clipXMm) || clipXMm === 0, `${path}.clip.xMm`, 'clip.xMm is required.');
292
- ensure(isPositiveNumber(clipYMm) || clipYMm === 0, `${path}.clip.yMm`, 'clip.yMm is required.');
339
+ ensure(
340
+ typeof clipXMm === 'number' && Number.isFinite(clipXMm),
341
+ `${path}.clip.xMm`,
342
+ 'clip.xMm is required.'
343
+ );
344
+ ensure(
345
+ typeof clipYMm === 'number' && Number.isFinite(clipYMm),
346
+ `${path}.clip.yMm`,
347
+ 'clip.yMm is required.'
348
+ );
293
349
  }
294
350
  const children = getProp(el, 'children');
295
351
  if (Array.isArray(children)) {
@@ -305,8 +361,8 @@ export function validateLayoutTemplate(template: unknown, options?: ValidationOp
305
361
  const elements = getProp(t, 'elements');
306
362
  if (Array.isArray(elements)) {
307
363
  elements.forEach((el: unknown, index: number) => validateElement(el, `elements[${index}]`));
308
- } else if (elements !== undefined) {
309
- errors.push({ path: 'elements', message: 'Elements must be an array.' });
364
+ } else {
365
+ errors.push({ path: 'elements', message: 'elements is required and must be an array.' });
310
366
  }
311
367
 
312
368
  // Validate template variables (if present)
@@ -319,6 +375,16 @@ export function validateLayoutTemplate(template: unknown, options?: ValidationOp
319
375
  for (const [varName, varDef] of Object.entries(variables)) {
320
376
  const varPath = `variables.${varName}`;
321
377
 
378
+ // Names are referenced as {{vars.<name>}} — non-identifier
379
+ // names (dashes, dots, spaces) collide with the placeholder
380
+ // syntax and are silently never substituted.
381
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(varName)) {
382
+ errors.push({
383
+ path: varPath,
384
+ message: 'Variable names must be simple identifiers ([A-Za-z_][A-Za-z0-9_]*).'
385
+ });
386
+ }
387
+
322
388
  if (!isRecord(varDef)) {
323
389
  errors.push({ path: varPath, message: 'Variable definition must be an object.' });
324
390
  continue;
@@ -5,8 +5,8 @@ import { pushWarning } from './warnings';
5
5
  import { pickScale } from './viewportUtils';
6
6
  import type { ResolvedPresets } from './presets';
7
7
  import { getPathValue } from './renderUtils';
8
- import { pointInPolygon, offsetPolygons, Polygon } from './clipUtils';
9
- import { matchesV2Filter, normalizeClass } from './filters';
8
+ import { pointInPolygon, offsetPolygons, unionPolygons, Polygon } from './clipUtils';
9
+ import { matchesFilter, normalizeClass } from './filters';
10
10
  import { EPSILON_MIN_DIMENSION } from './geometryConstants';
11
11
 
12
12
  export interface ViewportPrepResult {
@@ -20,7 +20,7 @@ export interface ViewportPrepResult {
20
20
  viewportClipLabels: Record<string, boolean>;
21
21
  viewportClipStyles: Record<
22
22
  string,
23
- { stroke?: string; strokeWidthMm?: number; strokeOpacity?: number; fill?: string; fillOpacity?: number }
23
+ { color?: string; widthMm?: number; opacity?: number; dash?: string; fill?: string; fillOpacity?: number }
24
24
  >;
25
25
  viewportClipWhere: Record<string, WhereCondition | undefined>;
26
26
  viewportHighlight: Record<
@@ -28,9 +28,10 @@ export interface ViewportPrepResult {
28
28
  {
29
29
  gids: Set<string> | null;
30
30
  styles: {
31
- stroke?: string;
32
- strokeWidthMm?: number;
33
- strokeOpacity?: number;
31
+ color?: string;
32
+ widthMm?: number;
33
+ opacity?: number;
34
+ dash?: string;
34
35
  fill?: string;
35
36
  fillOpacity?: number;
36
37
  };
@@ -60,7 +61,7 @@ export const prepareViewports = (
60
61
  const viewportClipLabels: Record<string, boolean> = {};
61
62
  const viewportClipStyles: Record<
62
63
  string,
63
- { stroke?: string; strokeWidthMm?: number; strokeOpacity?: number; fill?: string; fillOpacity?: number }
64
+ { color?: string; widthMm?: number; opacity?: number; dash?: string; fill?: string; fillOpacity?: number }
64
65
  > = {};
65
66
  const viewportClipWhere: Record<string, WhereCondition | undefined> = {};
66
67
  const viewportHighlight: ViewportPrepResult['viewportHighlight'] = {};
@@ -77,7 +78,7 @@ export const prepareViewports = (
77
78
  if (hlCfg && hlCfg.where) {
78
79
  const sourceItems = getPathValue(data, `viewports.${vp.id}.items`);
79
80
  if (Array.isArray(sourceItems) && sourceItems.length) {
80
- const matching = sourceItems.filter((it) => matchesV2Filter(it, hlCfg.class, hlCfg.where));
81
+ const matching = sourceItems.filter((it) => matchesFilter(it, hlCfg.class, hlCfg.where));
81
82
  if (matching.length) {
82
83
  highlightGids = new Set(
83
84
  matching
@@ -130,7 +131,7 @@ export const prepareViewports = (
130
131
  viewportClipWhere[vp.id] = clipCfg.where;
131
132
  const sourceItems = getPathValue(data, `viewports.${vp.id}.items`);
132
133
  if (Array.isArray(sourceItems) && sourceItems.length) {
133
- const matchingItems = sourceItems.filter((it) => matchesV2Filter(it, clipCfg.class, clipCfg.where));
134
+ const matchingItems = sourceItems.filter((it) => matchesFilter(it, clipCfg.class, clipCfg.where));
134
135
  if (!matchingItems.length) {
135
136
  pushWarning(
136
137
  options.warnings,
@@ -167,13 +168,17 @@ export const prepareViewports = (
167
168
  }
168
169
  if (mapped.length >= 3) projectedLocal.push(mapped);
169
170
  }
170
- const expandByM =
171
- typeof clipCfg.expandByM === 'number' &&
172
- Number.isFinite(clipCfg.expandByM) &&
173
- clipCfg.expandByM > 0
174
- ? clipCfg.expandByM
171
+ const expandBy =
172
+ typeof clipCfg.expandBy === 'number' &&
173
+ Number.isFinite(clipCfg.expandBy) &&
174
+ clipCfg.expandBy > 0
175
+ ? clipCfg.expandBy
175
176
  : 0;
176
- const buffered = expandByM > 0 ? offsetPolygons(projectedLocal, expandByM) : projectedLocal;
177
+ const expanded = expandBy > 0 ? offsetPolygons(projectedLocal, expandBy) : projectedLocal;
178
+ // Merge overlapping polygons into single outlines (the
179
+ // result rings can include holes — everything downstream
180
+ // renders them with even-odd rules).
181
+ const buffered = unionPolygons(expanded);
177
182
 
178
183
  if (buffered.length) {
179
184
  // Calculate bounds from EXPANDED polygon for viewport sizing (so everything fits)
@@ -196,9 +201,10 @@ export const prepareViewports = (
196
201
  viewportClipPolygonsLocal[vp.id] = buffered;
197
202
  viewportClipLabels[vp.id] = !!clipCfg.clipLabels;
198
203
  viewportClipStyles[vp.id] = {
199
- stroke: clipCfg.stroke ?? '#000000',
200
- strokeWidthMm: clipCfg.strokeWidthMm ?? 0.5,
201
- strokeOpacity: clipCfg.strokeOpacity ?? 1,
204
+ color: clipCfg.color ?? '#000000',
205
+ widthMm: clipCfg.widthMm ?? 0.5,
206
+ opacity: clipCfg.opacity ?? 1,
207
+ dash: clipCfg.dash,
202
208
  fill: clipCfg.fill ?? 'none',
203
209
  fillOpacity: clipCfg.fillOpacity ?? 0
204
210
  };
@@ -219,6 +225,8 @@ export const prepareViewports = (
219
225
  if (worldPolygons.length) {
220
226
  viewportClipPolygonsWorld[vp.id] = worldPolygons;
221
227
  clipAllowedGids = new Set<string>();
228
+ // pointInPolygon expects y — map polys ONCE, not per item.
229
+ const worldPolygons2D = worldPolygons.map((poly) => poly.map((p) => ({ x: p.x, y: p.z })));
222
230
  for (const item of sourceItems) {
223
231
  // Use item's direct x/z properties (same approach as labels)
224
232
  const ix = getProp(item, 'x');
@@ -227,11 +235,9 @@ export const prepareViewports = (
227
235
  if (!Number.isFinite(ix) || !Number.isFinite(iz)) {
228
236
  continue;
229
237
  }
230
- // Convert polygon {x,z} to {x,y} for pointInPolygon (same as labels do)
231
- const inside = worldPolygons.some((poly) => {
232
- const polyWith2D = poly.map((p) => ({ x: p.x, y: p.z }));
233
- return pointInPolygon(polyWith2D, ix as number, iz as number);
234
- });
238
+ const inside = worldPolygons2D.some((poly) =>
239
+ pointInPolygon(poly, ix as number, iz as number)
240
+ );
235
241
  if (inside) {
236
242
  const gid = getProp(item, 'gid') ?? getProp(item, 'id');
237
243
  if (gid !== null && gid !== undefined) {
@@ -264,16 +270,18 @@ export const prepareViewports = (
264
270
 
265
271
  const hlStyles = hlCfg
266
272
  ? {
267
- stroke: typeof hlCfg.stroke === 'string' ? hlCfg.stroke : '#ff0000',
268
- strokeWidthMm: typeof hlCfg.strokeWidthMm === 'number' ? hlCfg.strokeWidthMm : 0.5,
269
- strokeOpacity: typeof hlCfg.strokeOpacity === 'number' ? hlCfg.strokeOpacity : 1,
273
+ color: typeof hlCfg.color === 'string' ? hlCfg.color : '#ff0000',
274
+ widthMm: typeof hlCfg.widthMm === 'number' ? hlCfg.widthMm : 0.5,
275
+ opacity: typeof hlCfg.opacity === 'number' ? hlCfg.opacity : 1,
276
+ dash: typeof hlCfg.dash === 'string' ? hlCfg.dash : undefined,
270
277
  fill: typeof hlCfg.fill === 'string' ? hlCfg.fill : '#ffff00',
271
278
  fillOpacity: typeof hlCfg.fillOpacity === 'number' ? hlCfg.fillOpacity : 0.5
272
279
  }
273
280
  : {
274
- stroke: undefined,
275
- strokeWidthMm: undefined,
276
- strokeOpacity: undefined,
281
+ color: undefined,
282
+ widthMm: undefined,
283
+ opacity: undefined,
284
+ dash: undefined,
277
285
  fill: undefined,
278
286
  fillOpacity: undefined
279
287
  };
@@ -291,6 +299,16 @@ export const prepareViewports = (
291
299
  addFilterClasses(hlCfg?.class);
292
300
  addFilterClasses(clipCfg?.class);
293
301
 
302
+ // gid-based features (highlight, clip include/exclude, includeClasses)
303
+ // require rawPaths with per-product meta — filterModelForViewport
304
+ // returns the model UNTOUCHED without them, so warn instead of
305
+ // silently rendering as if the config didn't exist.
306
+ if (baseModel && !baseModel.rawPaths?.length && (vp.highlight || vp.clip || vp.includeClasses)) {
307
+ pushWarning(
308
+ options.warnings,
309
+ `Viewport '${vp.id}' declares highlight/clip/includeClasses but its model has no rawPaths with per-product metadata — these settings will have no effect.`
310
+ );
311
+ }
294
312
  const model = baseModel
295
313
  ? filterModelForViewport(
296
314
  baseModel,