@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
@@ -93,6 +93,22 @@ const getBBoxCenter = (product: unknown): { x: number | null; z: number | null }
93
93
  return { x: null, z: null };
94
94
  };
95
95
 
96
+ /**
97
+ * Read the plan-projection X/Z of a polyline point, tolerating both shapes the
98
+ * section pipeline emits: `Vec3` arrays `[x, y, z]` (from
99
+ * `computePlaneSectionsForProducts`) and `{ x, z }` objects. Coords are `null`
100
+ * when the point is neither / non-finite. Shared by every consumer so the dual
101
+ * contract is handled in exactly one place.
102
+ */
103
+ export const readXZ = (pt: unknown): { x: number | null; z: number | null } => {
104
+ const rawX = Array.isArray(pt) ? pt[0] : getProp(pt, 'x');
105
+ const rawZ = Array.isArray(pt) ? pt[2] : getProp(pt, 'z');
106
+ return {
107
+ x: Number.isFinite(rawX) ? (rawX as number) : null,
108
+ z: Number.isFinite(rawZ) ? (rawZ as number) : null
109
+ };
110
+ };
111
+
96
112
  export const computePolylinesCenter = (entry: unknown): { x: number | null; z: number | null } => {
97
113
  try {
98
114
  const polylines = getProp(entry, 'polylines');
@@ -106,11 +122,10 @@ export const computePolylinesCenter = (entry: unknown): { x: number | null; z: n
106
122
  const points = getProp(polyline, 'points');
107
123
  if (!Array.isArray(points)) continue;
108
124
  for (const pt of points) {
109
- const px = getProp(pt, 'x');
110
- const pz = getProp(pt, 'z');
111
- if (Number.isFinite(px) && Number.isFinite(pz)) {
112
- sumX += px as number;
113
- sumZ += pz as number;
125
+ const { x: px, z: pz } = readXZ(pt);
126
+ if (px !== null && pz !== null) {
127
+ sumX += px;
128
+ sumZ += pz;
114
129
  count++;
115
130
  }
116
131
  }
@@ -1,7 +1,15 @@
1
1
  import { LabelsElement, LayoutData } from './types';
2
- import { matchesV2Filter } from './filters';
2
+ import { matchesFilter } from './filters';
3
3
  import { pointInPolygon } from './clipUtils';
4
- import { getPathValue, resolvePath, escapeAttr, escapeText } from './renderUtils';
4
+ import {
5
+ getPathValue,
6
+ resolvePath,
7
+ escapeAttr,
8
+ escapeText,
9
+ formatNumber,
10
+ splitPlaceholder,
11
+ applyPlaceholderFilter
12
+ } from './renderUtils';
5
13
 
6
14
  import { isRecord, getProp } from './typeGuards';
7
15
 
@@ -60,41 +68,22 @@ export const renderLabels = (
60
68
  }
61
69
  // Filter by class + where
62
70
  let filteredItems =
63
- dl.class || dl.where ? items.filter((item) => matchesV2Filter(item, dl.class, dl.where)) : items;
64
-
65
- console.log(`[Labels ${dl.id}] Total items:`, items.length, 'After class+where filter:', filteredItems.length);
71
+ dl.class || dl.where ? items.filter((item) => matchesFilter(item, dl.class, dl.where)) : items;
66
72
 
67
73
  // If matchClipFilter is true, filter by spatial position inside clip polygon
68
74
  if (dl.matchClipFilter && viewportClipPolygonsWorld[dl.viewport]?.length) {
69
- const clipPolygons = viewportClipPolygonsWorld[dl.viewport];
70
- console.log(`[Labels ${dl.id}] Applying matchClipFilter (spatial) with ${clipPolygons.length} polygons`);
71
- const beforeClipFilter = filteredItems.length;
75
+ // pointInPolygon expects y — world polys are {x, z}; map ONCE, not per item.
76
+ const polys2D = viewportClipPolygonsWorld[dl.viewport].map((poly) => poly.map((p) => ({ x: p.x, y: p.z })));
72
77
  filteredItems = filteredItems.filter((item) => {
73
78
  const ix = getProp(item, 'x');
74
79
  const iz = getProp(item, 'z');
75
80
  if (!Number.isFinite(ix) || !Number.isFinite(iz)) return false;
76
- return clipPolygons.some((poly) => {
77
- // pointInPolygon expects y coordinate, but we use z for world space
78
- const polyWith2D = poly.map((p) => ({ x: p.x, y: p.z }));
79
- return pointInPolygon(polyWith2D, ix as number, iz as number);
80
- });
81
+ return polys2D.some((poly) => pointInPolygon(poly, ix as number, iz as number));
81
82
  });
82
- console.log(
83
- `[Labels ${dl.id}] After matchClipFilter (spatial):`,
84
- filteredItems.length,
85
- '(was:',
86
- beforeClipFilter,
87
- ')'
88
- );
89
-
90
- // Debug: log first few items to see their properties
91
- if (filteredItems.length === 0 && beforeClipFilter > 0) {
92
- console.log('[Labels] Sample item that was filtered out:', items[0]);
93
- }
94
83
  }
95
84
 
96
85
  if (!filteredItems.length) {
97
- console.log(`[Labels ${dl.id}] No items left after filtering, skipping.`);
86
+ warnings.push(`Labels '${dl.id}' matched 0 of ${items.length} items after filtering.`);
98
87
  continue;
99
88
  }
100
89
  const styleName = dl.textStyle;
@@ -103,12 +92,10 @@ export const renderLabels = (
103
92
  warnings.push(`Labels '${dl.id}' missing textStyle '${styleName ?? '(none)'}'.`);
104
93
  continue;
105
94
  }
106
- const anchor = dl.align === 'center' ? 'middle' : dl.align === 'right' ? 'end' : 'start';
95
+ // Schema default is center labels sit on item centroids.
96
+ const anchor = dl.align === 'left' ? 'start' : dl.align === 'right' ? 'end' : 'middle';
107
97
  const t = vpInfo.transform;
108
98
 
109
- let renderedCount = 0;
110
- let spatiallyFilteredCount = 0;
111
-
112
99
  filteredItems.forEach((item) => {
113
100
  // Hardcoded x/z for BIM floor plane convention
114
101
  const xVal = getProp(item, 'x');
@@ -128,18 +115,18 @@ export const renderLabels = (
128
115
  pointInPolygon(poly, viewX, viewY)
129
116
  );
130
117
  if (!inside) {
131
- spatiallyFilteredCount++;
132
118
  return;
133
119
  }
134
120
  }
135
121
 
136
- renderedCount++;
137
-
138
122
  const text = escapeText(
139
123
  dl.text && typeof dl.text === 'string'
140
- ? dl.text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_m, path) => {
124
+ ? dl.text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_m, expr) => {
125
+ const { path, filter } = splitPlaceholder(expr);
141
126
  const val = resolvePath(item, path) ?? getPathValue(dataWithViewport, path);
142
- return val === undefined || val === null || typeof val === 'object' ? '' : String(val);
127
+ return val === undefined || val === null || typeof val === 'object'
128
+ ? ''
129
+ : String(applyPlaceholderFilter(val, filter));
143
130
  })
144
131
  : ''
145
132
  );
@@ -152,7 +139,7 @@ export const renderLabels = (
152
139
  const lineHeightMm = getProp(style, 'lineHeightMm');
153
140
 
154
141
  parts.push(
155
- `<text x="${xMm}" y="${yMm}" font-family="${escapeAttr(String(fontFamily))}" font-size="${fontSizeMm}"` +
142
+ `<text x="${formatNumber(xMm)}" y="${formatNumber(yMm)}" font-family="${escapeAttr(String(fontFamily))}" font-size="${fontSizeMm}"` +
156
143
  (fontWeight ? ` font-weight="${escapeAttr(String(fontWeight))}"` : '') +
157
144
  (color ? ` fill="${escapeAttr(String(color))}"` : '') +
158
145
  (letterSpacingMm !== undefined ? ` letter-spacing="${letterSpacingMm}"` : '') +
@@ -160,10 +147,6 @@ export const renderLabels = (
160
147
  ` text-anchor="${anchor}">${text}</text>`
161
148
  );
162
149
  });
163
-
164
- console.log(
165
- `[Labels ${dl.id}] Rendered ${renderedCount} labels. Spatially filtered out: ${spatiallyFilteredCount}`
166
- );
167
150
  }
168
151
 
169
152
  return parts;
@@ -14,8 +14,8 @@ import {
14
14
  parseLegendColor,
15
15
  formatLegendValue
16
16
  } from './legendUtils';
17
- import { resolvePath } from './renderUtils';
18
- import { matchesV2Filter } from './filters';
17
+ import { resolvePath, escapeText, escapeAttr } from './renderUtils';
18
+ import { matchesFilter } from './filters';
19
19
  import { pointInPolygon } from './clipUtils';
20
20
 
21
21
  /**
@@ -80,43 +80,52 @@ import { isRecord, getProp } from './typeGuards';
80
80
  export const applyLegendFilter = (
81
81
  legendData: unknown[],
82
82
  legend: LegendElement,
83
- viewports: unknown[],
84
- viewportClipPolygonsLocal: Record<string, Array<Array<{ x: number; y: number }>>>,
85
- viewportRenderInfo: Record<string, { transform?: unknown }>
83
+ viewportClipPolygonsWorld: Record<string, Array<Array<{ x: number; z: number }>>>
86
84
  ): unknown[] => {
87
85
  // Filter by class + where
88
86
  let rows = Array.isArray(legendData)
89
- ? legendData.filter((row) => matchesV2Filter(row, legend.class, legend.where))
87
+ ? legendData.filter((row) => matchesFilter(row, legend.class, legend.where))
90
88
  : [];
91
89
  if (!rows.length) return rows;
92
90
 
91
+ // clipToViewport filters in WORLD space (same convention as labels and
92
+ // clipAllowedGids) so the result is available before viewports render —
93
+ // legend color maps must exist by then for fill: "legend".
93
94
  if (legend.clipToViewport) {
94
95
  const targetViewportId = legend.viewport;
95
- const polys = targetViewportId ? viewportClipPolygonsLocal[targetViewportId] : null;
96
- const vpInfo = targetViewportId ? viewportRenderInfo[targetViewportId] : null;
97
- const t = isRecord(vpInfo) ? getProp(vpInfo, 'transform') : null;
98
- if (polys && polys.length && isRecord(t)) {
99
- const viewMinX = getProp(t, 'viewMinX');
100
- const viewMaxZ = getProp(t, 'viewMaxZ');
101
- if (typeof viewMinX === 'number' && typeof viewMaxZ === 'number') {
102
- rows = rows.filter((row) => {
103
- const rowX = getProp(row, 'x');
104
- const rowZ = getProp(row, 'z');
105
- if (!Number.isFinite(rowX) || !Number.isFinite(rowZ)) return false;
106
- const viewX = Number(rowX) - viewMinX;
107
- const viewY = viewMaxZ - Number(rowZ);
108
- return polys.some((poly) => pointInPolygon(poly, viewX, viewY));
109
- });
110
- }
96
+ const polys = targetViewportId ? viewportClipPolygonsWorld[targetViewportId] : null;
97
+ if (polys && polys.length) {
98
+ const polys2D = polys.map((poly) => poly.map((p) => ({ x: p.x, y: p.z })));
99
+ rows = rows.filter((row) => {
100
+ const rowX = getProp(row, 'x');
101
+ const rowZ = getProp(row, 'z');
102
+ if (!Number.isFinite(rowX) || !Number.isFinite(rowZ)) return false;
103
+ return polys2D.some((poly) => pointInPolygon(poly, Number(rowX), Number(rowZ)));
104
+ });
111
105
  }
112
106
  }
113
107
 
114
108
  return rows;
115
109
  };
116
110
 
111
+ /**
112
+ * Colors a legend assigned to its rows (assignColors), for consumption by
113
+ * viewport fill rules with `fill: "legend"`. Lookup order for an item:
114
+ * its gid (per-row identity — works without groupBy), then the value of the
115
+ * legend's groupBy property (shared across a group).
116
+ */
117
+ export interface LegendColorAssignment {
118
+ /** The legend's groupBy property path, when set. */
119
+ groupBy?: string;
120
+ /** Row color by item gid/id. */
121
+ byGid: Map<string, string>;
122
+ /** Row color by groupBy property value (only populated with groupBy). */
123
+ byValue: Map<string, string>;
124
+ }
125
+
117
126
  export interface LegendResult {
118
127
  svg: string;
119
- colorMap?: Map<string, string>; // Maps property value to color
128
+ colors?: LegendColorAssignment;
120
129
  }
121
130
 
122
131
  export const buildLegendGroup = (
@@ -129,36 +138,25 @@ export const buildLegendGroup = (
129
138
  ): LegendResult | null => {
130
139
  if (!legend || !legendData || !legendData.length) return null;
131
140
 
132
- // Debug: Log raw input data
133
- // console.log(`[LEGEND INPUT] Received ${legendData.length} items for legend "${legend.id}"`);
134
- // if (legend.groupBy) {
135
- // // Count duplicates by groupBy property
136
- // const counts = new Map<string, number>();
137
- // for (const item of legendData) {
138
- // const key = String(resolvePath(item, legend.groupBy) ?? '');
139
- // counts.set(key, (counts.get(key) || 0) + 1);
140
- // }
141
- // console.log('[LEGEND INPUT] Items per group (before grouping):', Array.from(counts.entries()).map(([k, v]) => `${k}: ${v}`).join(', '));
142
- // }
143
-
144
141
  const pageMargin = typeof marginMm === 'number' ? marginMm : 0;
145
142
  const fontStyle = (legend.textStyle && styles?.[legend.textStyle]) || styles?.['body'];
146
143
  const fontSizeMm = typeof fontStyle?.fontSizeMm === 'number' ? fontStyle.fontSizeMm : 5;
147
- const fontFamily = fontStyle?.fontFamily ?? 'Arial, sans-serif';
148
- const fontColor = isRecord(fontStyle) ? (getProp(fontStyle, 'color') ?? '#000') : '#000';
144
+ // Escaped once here every emitted <text>/<rect> below interpolates these
145
+ // (and cell text via escapeText) into XML.
146
+ const fontFamily = escapeAttr(String(fontStyle?.fontFamily ?? 'Arial, sans-serif'));
147
+ const fontColor = escapeAttr(String(isRecord(fontStyle) ? (getProp(fontStyle, 'color') ?? '#000') : '#000'));
149
148
 
150
149
  // Show color column if enabled (but swatches will be empty if no fills configured)
151
150
  const showColorColumn = legend.showColorColumn !== false;
152
151
  const colorColumnWidthMm = showColorColumn ? (legend.colorColumnWidthMm ?? DEFAULT_LEGEND_SWATCH_WIDTH_MM) : 0;
153
152
  const hasFills = fillOpacity !== undefined;
154
- // console.log(`[LEGEND RENDER] fillOpacity=${fillOpacity}, hasFills=${hasFills}, showColorColumn=${showColorColumn}`);
155
153
  const cellPaddingMm = legend.cellPaddingMm ?? DEFAULT_LEGEND_CELL_PADDING_MM;
156
- const borderStrokeWidthMm = legend.borderStrokeWidthMm ?? DEFAULT_LEGEND_BORDER_WIDTH_MM;
157
- const borderStrokeColor = legend.borderStrokeColor ?? '#000000';
158
- const gridStrokeWidthMm = legend.gridStrokeWidthMm ?? DEFAULT_LEGEND_GRID_WIDTH_MM;
159
- const gridStrokeColor = legend.gridStrokeColor ?? '#000000';
160
- const headerStrokeWidthMm = legend.headerStrokeWidthMm ?? DEFAULT_LEGEND_HEADER_WIDTH_MM;
161
- const headerStrokeColor = legend.headerStrokeColor ?? '#000000';
154
+ const borderStrokeWidthMm = legend.border?.strokeWidthMm ?? DEFAULT_LEGEND_BORDER_WIDTH_MM;
155
+ const borderStrokeColor = escapeAttr(legend.border?.stroke ?? '#000000');
156
+ const gridStrokeWidthMm = legend.grid?.strokeWidthMm ?? DEFAULT_LEGEND_GRID_WIDTH_MM;
157
+ const gridStrokeColor = escapeAttr(legend.grid?.stroke ?? '#000000');
158
+ const headerStrokeWidthMm = legend.header?.strokeWidthMm ?? DEFAULT_LEGEND_HEADER_WIDTH_MM;
159
+ const headerStrokeColor = escapeAttr(legend.header?.stroke ?? '#000000');
162
160
  const anchor = legend.anchor ?? 'top-right';
163
161
  const legendMarginMm = legend.marginMm ?? DEFAULT_LEGEND_OFFSET_MM;
164
162
 
@@ -176,13 +174,6 @@ export const buildLegendGroup = (
176
174
  groups.get(groupKey)!.push(row);
177
175
  }
178
176
 
179
- // console.log(`[LEGEND GROUPING] Total input rows: ${legendData.length}, Groups created: ${groups.size}`);
180
- // for (const [key, rows] of groups.entries()) {
181
- // if (rows.length > 1) {
182
- // console.log(`[LEGEND GROUPING] Group "${key}": ${rows.length} rows`);
183
- // }
184
- // }
185
-
186
177
  // Aggregate each group
187
178
  processedData = Array.from(groups.values()).map((groupRows) => {
188
179
  if (groupRows.length === 0) return groupRows[0];
@@ -217,16 +208,6 @@ export const buildLegendGroup = (
217
208
  return acc + num;
218
209
  }, 0);
219
210
 
220
- // Debug logging for aggregation
221
- // if (groupRows.length > 1 && legend.groupBy) {
222
- // const groupKey = resolvePath(groupRows[0], legend.groupBy);
223
- // console.log(`[LEGEND AGGREGATION] Group "${groupKey}", Column "${col.property}":`, {
224
- // itemCount: values.length,
225
- // values: values,
226
- // sum: sum
227
- // });
228
- // }
229
-
230
211
  // Set the aggregated value in the nested property structure
231
212
  const pathParts = col.property.split('.');
232
213
  let target = aggregated;
@@ -245,29 +226,28 @@ export const buildLegendGroup = (
245
226
  });
246
227
  }
247
228
 
248
- // Generate colors if assignColors is enabled
229
+ // Generate colors if assignColors is enabled — consumed by viewport fill
230
+ // rules with fill: "legend" (byGid works without groupBy: every row gets
231
+ // its own color).
249
232
  let generatedColors: string[] | null = null;
250
- let colorMap: Map<string, string> | undefined;
233
+ let colors: LegendColorAssignment | undefined;
251
234
 
252
235
  if (legend.assignColors?.enabled) {
253
236
  const palette = legend.assignColors.palette ?? 'auto';
254
237
  generatedColors = generateLegendColors(processedData.length, palette);
255
- // console.log(`[LEGEND COLORS] assignColors enabled, generated ${generatedColors.length} colors`);
256
-
257
- // Build color map based on groupBy property
258
- if (legend.groupBy) {
259
- colorMap = new Map();
260
- for (let i = 0; i < processedData.length; i++) {
261
- const row = processedData[i];
238
+ colors = { groupBy: legend.groupBy || undefined, byGid: new Map(), byValue: new Map() };
239
+ for (let i = 0; i < processedData.length; i++) {
240
+ const row = processedData[i];
241
+ if (legend.groupBy) {
262
242
  const groupValue = String(resolvePath(row, legend.groupBy) ?? '');
263
- if (!colorMap.has(groupValue)) {
264
- colorMap.set(groupValue, generatedColors[i]);
265
- // console.log(`[LEGEND COLORS] Mapped "${groupValue}" -> ${generatedColors[i]}`);
243
+ if (!colors.byValue.has(groupValue)) {
244
+ colors.byValue.set(groupValue, generatedColors[i]);
266
245
  }
267
246
  }
268
- // console.log(`[LEGEND COLORS] Generated ${colorMap.size} colors for groupBy="${legend.groupBy}"`);
269
- } else {
270
- console.warn(`[LEGEND COLORS] assignColors enabled but no groupBy property set`);
247
+ const gid = getProp(row, 'gid') ?? getProp(row, 'id');
248
+ if (gid !== null && gid !== undefined) {
249
+ colors.byGid.set(String(gid), generatedColors[i]);
250
+ }
271
251
  }
272
252
  }
273
253
 
@@ -405,7 +385,7 @@ export const buildLegendGroup = (
405
385
  for (let i = 2; i < columnCount; i++) {
406
386
  const x = originXmm + colXmm[i];
407
387
  lines.push(
408
- `<line x1="${x}" y1="${originYmm}" x2="${x}" y2="${originYmm + tableHeightMm}" stroke="${gridStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
388
+ `<line x1="${x}" y1="${originYmm}" x2="${x}" y2="${originYmm + tableHeightMm}" stroke="${gridStrokeColor}" stroke-width="${gridStrokeWidthMm}" />`
409
389
  );
410
390
  }
411
391
 
@@ -415,7 +395,7 @@ export const buildLegendGroup = (
415
395
 
416
396
  const y = originYmm + headerHeightMm + r * rowHeightMm;
417
397
  lines.push(
418
- `<line x1="${originXmm}" y1="${y}" x2="${originXmm + tableWidthMm}" y2="${y}" stroke="${gridStrokeColor}" stroke-width="${borderStrokeWidthMm}" />`
398
+ `<line x1="${originXmm}" y1="${y}" x2="${originXmm + tableWidthMm}" y2="${y}" stroke="${gridStrokeColor}" stroke-width="${gridStrokeWidthMm}" />`
419
399
  );
420
400
  }
421
401
 
@@ -433,7 +413,7 @@ export const buildLegendGroup = (
433
413
  const y = originYmm + headerHeightMm / 2 + textYOffsetMm;
434
414
  const anchorText = col.align === 'center' ? 'middle' : col.align === 'right' ? 'end' : 'start';
435
415
  texts.push(
436
- `<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" dominant-baseline="middle" text-anchor="${anchorText}">${col.label}</text>`
416
+ `<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" dominant-baseline="middle" text-anchor="${anchorText}">${escapeText(col.label)}</text>`
437
417
  );
438
418
  });
439
419
 
@@ -449,7 +429,7 @@ export const buildLegendGroup = (
449
429
  const swatchFill = row.color; // Already a CSS color string
450
430
  const opacityAttr = swatchOpacity < 1 ? ` fill-opacity="${swatchOpacity.toFixed(3)}"` : '';
451
431
  rects.push(
452
- `<rect x="${originXmm + colXmm[0]}" y="${yTop}" width="${colWidthsMm[0]}" height="${rowHeightMm}" fill="${swatchFill}"${opacityAttr} stroke="none" />`
432
+ `<rect x="${originXmm + colXmm[0]}" y="${yTop}" width="${colWidthsMm[0]}" height="${rowHeightMm}" fill="${escapeAttr(String(swatchFill))}"${opacityAttr} stroke="none" />`
453
433
  );
454
434
  } else {
455
435
  // Empty swatch when no fills configured - just the border cell
@@ -474,7 +454,7 @@ export const buildLegendGroup = (
474
454
  const y = yTop + rowHeightMm / 2 + textYOffsetMm;
475
455
  const anchorText = col.align === 'center' ? 'middle' : col.align === 'right' ? 'end' : 'start';
476
456
  texts.push(
477
- `<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" dominant-baseline="middle" text-anchor="${anchorText}">${text}</text>`
457
+ `<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" dominant-baseline="middle" text-anchor="${anchorText}">${escapeText(text)}</text>`
478
458
  );
479
459
  });
480
460
  });
@@ -514,7 +494,7 @@ export const buildLegendGroup = (
514
494
  const labelX = contentOriginXmm + innerPad;
515
495
  const labelY = yTop + rowHeightMm / 2 + textYOffsetMm;
516
496
  texts.push(
517
- `<text x="${labelX}" y="${labelY}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" font-weight="bold" dominant-baseline="middle" text-anchor="start">${summaryLabel}</text>`
497
+ `<text x="${labelX}" y="${labelY}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" font-weight="bold" dominant-baseline="middle" text-anchor="start">${escapeText(summaryLabel)}</text>`
518
498
  );
519
499
 
520
500
  // Render summary values for other columns
@@ -533,11 +513,11 @@ export const buildLegendGroup = (
533
513
  const y = yTop + rowHeightMm / 2 + textYOffsetMm;
534
514
  const anchorText = col.align === 'center' ? 'middle' : col.align === 'right' ? 'end' : 'start';
535
515
  texts.push(
536
- `<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" font-weight="bold" dominant-baseline="middle" text-anchor="${anchorText}">${summaryText}</text>`
516
+ `<text x="${x}" y="${y}" font-family="${fontFamily}" font-size="${fontSizeMm}" fill="${fontColor}" font-weight="bold" dominant-baseline="middle" text-anchor="${anchorText}">${escapeText(summaryText)}</text>`
537
517
  );
538
518
  });
539
519
  }
540
520
 
541
521
  const svg = [`<g id="legend">`, ...rects, ...lines, ...texts, `</g>`].join('');
542
- return { svg, colorMap };
522
+ return { svg, colors };
543
523
  };
@@ -56,10 +56,15 @@ export const normalizePropertySets = (
56
56
  const source = isRecord(innerProps) ? innerProps : setValue;
57
57
  const props: Record<string, { value: unknown; unit?: string; type?: string }> = {};
58
58
  Object.entries(source).forEach(([propName, propVal]) => {
59
- if (isRecord(propVal) && 'value' in propVal) {
60
- props[propName] = propVal as { value: unknown; unit?: string; type?: string };
59
+ // T8 conflict arrays: T8PropertySet.properties values are
60
+ // T8Property | T8Property[] multiple source files disagreed on
61
+ // the property. Use the first entry so the value stays scalar and
62
+ // filterable rather than leaking an array into `value`.
63
+ const single = Array.isArray(propVal) ? propVal[0] : propVal;
64
+ if (isRecord(single) && 'value' in single) {
65
+ props[propName] = single as { value: unknown; unit?: string; type?: string };
61
66
  } else {
62
- props[propName] = { value: propVal };
67
+ props[propName] = { value: single };
63
68
  }
64
69
  });
65
70
  propertySets[setName] = props;
@@ -131,9 +136,16 @@ export const resolveIncludeClasses = (
131
136
  } else if (isRecord(item)) {
132
137
  const cls = getProp(item, 'class');
133
138
  const where = getProp(item, 'where');
139
+ const occludes = getProp(item, 'occludes');
140
+ const classify = getProp(item, 'classify');
134
141
  // Must have either class or where (or both)
135
142
  if (cls !== undefined || (where && isRecord(where))) {
136
- result.push({ class: cls as string | string[] | undefined, where: where as WhereCondition });
143
+ result.push({
144
+ class: cls as string | string[] | undefined,
145
+ where: where as WhereCondition,
146
+ ...(typeof occludes === 'boolean' ? { occludes } : {}),
147
+ ...(typeof classify === 'boolean' ? { classify } : {})
148
+ });
137
149
  }
138
150
  }
139
151
  }
@@ -164,7 +176,16 @@ export const resolveIncludeClasses = (
164
176
 
165
177
  // Filter object: { class: "space", where: {...} }
166
178
  if (cls !== undefined || (where && isRecord(where))) {
167
- return [{ class: cls as string | string[] | undefined, where: where as WhereCondition }];
179
+ const occludes = getProp(includeClasses, 'occludes');
180
+ const classify = getProp(includeClasses, 'classify');
181
+ return [
182
+ {
183
+ class: cls as string | string[] | undefined,
184
+ where: where as WhereCondition,
185
+ ...(typeof occludes === 'boolean' ? { occludes } : {}),
186
+ ...(typeof classify === 'boolean' ? { classify } : {})
187
+ }
188
+ ];
168
189
  }
169
190
  }
170
191
 
@@ -2,7 +2,7 @@ import type { SectionPath, SectionLabel } from '@twinfinity/geometry';
2
2
  import type { SvgModelView, ViewportElement, LabelStyle, ResolvedIncludeFilter } from './types';
3
3
  import type { ModelMetadata } from './metadata';
4
4
  import { resolveIncludeClasses, resolveMeta } from './metadata';
5
- import { normalizeClass, matchesWhere } from './filters';
5
+ import { normalizeClass, classEquals, matchesWhere } from './filters';
6
6
  import { escapeAttr, escapeText } from './renderUtils';
7
7
  import { BUILTIN_LABEL_DEFAULTS } from './renderDrawing';
8
8
 
@@ -33,6 +33,7 @@ export const buildModelViewFromRaw = (
33
33
  (path.stroke ? ` stroke="${escapeAttr(path.stroke)}"` : '') +
34
34
  (path.strokeWidth ? ` stroke-width="${path.strokeWidth}"` : '') +
35
35
  (path.strokeOpacity !== undefined ? ` stroke-opacity="${path.strokeOpacity}"` : '') +
36
+ (path.dash ? ` stroke-dasharray="${escapeAttr(path.dash)}"` : '') +
36
37
  (path.fillRule ? ` fill-rule="${escapeAttr(path.fillRule)}"` : '') +
37
38
  ` stroke-linejoin="round" stroke-linecap="round"` +
38
39
  ` />`
@@ -102,13 +103,13 @@ const matchesIncludeFilter = (
102
103
  meta: unknown
103
104
  ): boolean => {
104
105
  for (const filter of includeFilters) {
105
- // String filter: class name only
106
+ // String filter: class name only. classEquals treats the Ifc prefix
107
+ // as optional — same rule as every other class filter in the format.
106
108
  if (typeof filter === 'string') {
107
109
  const normalizedFilter = normalizeClass(filter);
108
- if (cls && normalizedFilter === cls) return true;
110
+ if (cls && classEquals(normalizedFilter, cls)) return true;
109
111
  if (clsId && typeof clsId === 'string') {
110
- const normalizedId = normalizeClass(clsId);
111
- if (normalizedId === normalizedFilter) return true;
112
+ if (classEquals(normalizeClass(clsId), normalizedFilter)) return true;
112
113
  }
113
114
  continue;
114
115
  }
@@ -125,13 +126,12 @@ const matchesIncludeFilter = (
125
126
  classMatches = false;
126
127
  for (const c of classes) {
127
128
  const normalizedC = normalizeClass(c);
128
- if (normalizedC && cls === normalizedC) {
129
+ if (normalizedC && classEquals(cls, normalizedC)) {
129
130
  classMatches = true;
130
131
  break;
131
132
  }
132
133
  if (clsId && typeof clsId === 'string') {
133
- const normalizedId = normalizeClass(clsId);
134
- if (normalizedId === normalizedC) {
134
+ if (classEquals(normalizeClass(clsId), normalizedC)) {
135
135
  classMatches = true;
136
136
  break;
137
137
  }
@@ -161,9 +161,10 @@ export const filterModelForViewport = (
161
161
  highlight?: {
162
162
  gids: Set<string> | null;
163
163
  styles?: {
164
- stroke?: string;
165
- strokeWidthMm?: number;
166
- strokeOpacity?: number;
164
+ color?: string;
165
+ widthMm?: number;
166
+ opacity?: number;
167
+ dash?: string;
167
168
  fill?: string;
168
169
  fillOpacity?: number;
169
170
  };
@@ -248,14 +249,17 @@ export const filterModelForViewport = (
248
249
  // Apply highlight styling when requested
249
250
  if (shouldHighlight) {
250
251
  const styledPath: SectionPath = { ...p };
251
- if (highlight?.styles?.stroke !== undefined) {
252
- styledPath.stroke = highlight.styles.stroke;
252
+ if (highlight?.styles?.color !== undefined) {
253
+ styledPath.stroke = highlight.styles.color;
253
254
  }
254
- if (highlight?.styles?.strokeWidthMm !== undefined) {
255
- styledPath.strokeWidth = highlight.styles.strokeWidthMm;
255
+ if (highlight?.styles?.widthMm !== undefined) {
256
+ styledPath.strokeWidth = highlight.styles.widthMm;
256
257
  }
257
- if (highlight?.styles?.strokeOpacity !== undefined) {
258
- styledPath.strokeOpacity = highlight.styles.strokeOpacity;
258
+ if (highlight?.styles?.opacity !== undefined) {
259
+ styledPath.strokeOpacity = highlight.styles.opacity;
260
+ }
261
+ if (highlight?.styles?.dash !== undefined) {
262
+ styledPath.dash = highlight.styles.dash;
259
263
  }
260
264
  if (highlight?.styles?.fill !== undefined) {
261
265
  styledPath.fill = highlight.styles.fill;
@@ -304,55 +308,6 @@ export const filterModelForViewport = (
304
308
  filteredLabels.push(l);
305
309
  });
306
310
 
307
- // Count paths by IFC class before and after filtering
308
- const classCountsBefore = new Map<string, number>();
309
- const classCountsAfter = new Map<string, number>();
310
- const excluded: Array<{ cls: string | null; clsId: unknown }> = [];
311
-
312
- model.rawPaths.forEach((p: SectionPath) => {
313
- const rawMeta = getProp(p, 'meta');
314
- const meta = resolveMeta(rawMeta, metadataById);
315
- const metaFromMap = (() => {
316
- const pid =
317
- getProp(meta, 'productId') ??
318
- getProp(meta, 'gid') ??
319
- getProp(meta, 'id') ??
320
- getProp(rawMeta, 'gid') ??
321
- getProp(rawMeta, 'id');
322
- return pid && metadataById ? metadataById[String(pid)] : null;
323
- })();
324
- const cls = normalizeClass(getProp(meta, 'ifcClass') ?? getProp(rawMeta, 'ifcClass') ?? metaFromMap?.ifcClass);
325
- const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? metaFromMap?.ifcClassId ?? null;
326
- const key = cls ?? String(clsId) ?? 'unknown';
327
- classCountsBefore.set(key, (classCountsBefore.get(key) ?? 0) + 1);
328
- });
329
-
330
- filteredPaths.forEach((p: SectionPath) => {
331
- const rawMeta = getProp(p, 'meta');
332
- const meta = resolveMeta(rawMeta, metadataById);
333
- const metaFromMap = (() => {
334
- const pid =
335
- getProp(meta, 'productId') ??
336
- getProp(meta, 'gid') ??
337
- getProp(meta, 'id') ??
338
- getProp(rawMeta, 'gid') ??
339
- getProp(rawMeta, 'id');
340
- return pid && metadataById ? metadataById[String(pid)] : null;
341
- })();
342
- const cls = normalizeClass(getProp(meta, 'ifcClass') ?? getProp(rawMeta, 'ifcClass') ?? metaFromMap?.ifcClass);
343
- const clsId = getProp(meta, 'ifcClassId') ?? getProp(rawMeta, 'ifcClassId') ?? metaFromMap?.ifcClassId ?? null;
344
- const key = cls ?? String(clsId) ?? 'unknown';
345
- classCountsAfter.set(key, (classCountsAfter.get(key) ?? 0) + 1);
346
- });
347
-
348
- // Find excluded classes
349
- classCountsBefore.forEach((countBefore, cls) => {
350
- const countAfter = classCountsAfter.get(cls) ?? 0;
351
- if (countAfter < countBefore) {
352
- excluded.push({ cls, clsId: `${countBefore - countAfter} paths removed` });
353
- }
354
- });
355
-
356
311
  if (
357
312
  !styleMutated &&
358
313
  filteredPaths.length === model.rawPaths.length &&
@@ -3,7 +3,7 @@ import { ModelMetadata } from './metadata';
3
3
  export interface RenderLayoutOptions {
4
4
  scaleDenominators?: number[];
5
5
  maxViewports?: number;
6
- allowRemoteAssets?: boolean;
6
+ allowRemoteResources?: boolean;
7
7
  allowDataUrls?: boolean;
8
8
  qrContext?: {
9
9
  host?: string | null;
@@ -11,7 +11,6 @@ export interface RenderLayoutOptions {
11
11
  floorName?: string | null;
12
12
  planeHeight?: number | null;
13
13
  };
14
- legendSources?: Record<string, any[]>;
15
14
  /** Optional metadata map keyed by productId referenced from path/label meta */
16
15
  metadataById?: ModelMetadata;
17
16
  }