@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
@@ -1,9 +1,10 @@
1
- import { escapeAttr, getPathValue, resolvePath } from './renderUtils';
2
- import { rotateBounds, pickScale } from './viewportUtils';
1
+ import { escapeAttr, getPathValue, resolvePath, formatNumber } from './renderUtils';
2
+ import { rotateBounds, pickScale, rotatedContentExtents } from './viewportUtils';
3
3
  import { ViewportRenderInfo } from './labelsRender';
4
- import { matchesV2Filter } from './filters';
5
- import { ComputedViewportScale, SvgModelView, ViewportElement, AssetSpec } from './types';
4
+ import { matchesFilter } from './filters';
5
+ import { ComputedViewportScale, SvgModelView, ViewportElement, ResourceSpec, ResolvedIncludeFilter } from './types';
6
6
  import { isRecord, getProp } from './typeGuards';
7
+ import type { LegendColorAssignment } from './legendRender';
7
8
 
8
9
  type PolyPoint = { x: number; y: number };
9
10
  // Debug overlay for clip polygons. Keep false for normal builds.
@@ -41,7 +42,7 @@ export const renderViewportElement = (
41
42
  dataWithViewport: any,
42
43
  preferredScales: number[],
43
44
  scales: Record<string, { preferredScales?: number[] }>,
44
- assetMap: Map<string, AssetSpec>,
45
+ resourceMap: Map<string, ResourceSpec>,
45
46
  viewportScales: Record<string, ComputedViewportScale>,
46
47
  viewportClipPolygonsLocal: Record<string, Array<Array<PolyPoint>>>,
47
48
  viewportClipBounds: Record<
@@ -51,12 +52,12 @@ export const renderViewportElement = (
51
52
  viewportClipLabels: Record<string, boolean>,
52
53
  viewportClipStyles: Record<
53
54
  string,
54
- { stroke?: string; strokeWidthMm?: number; strokeOpacity?: number; fill?: string; fillOpacity?: number }
55
+ { color?: string; widthMm?: number; opacity?: number; dash?: string; fill?: string; fillOpacity?: number }
55
56
  >,
56
57
  viewportHighlight: Record<string, { gids: Set<string> | null; styles: any }>,
57
58
  viewportRenderInfo: Record<string, ViewportRenderInfo>,
58
59
  warnings: string[],
59
- legendColorMaps?: Map<string, Map<string, string>>,
60
+ legendColorMaps?: Map<string, LegendColorAssignment>,
60
61
  precomputed?: {
61
62
  effectiveWidthModel?: number;
62
63
  effectiveHeightModel?: number;
@@ -65,7 +66,8 @@ export const renderViewportElement = (
65
66
  clipBounds?: { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number };
66
67
  renderCenterModelX?: number;
67
68
  renderCenterModelY?: number;
68
- }
69
+ },
70
+ includeFilters?: ResolvedIncludeFilter[] | null
69
71
  ): string[] => {
70
72
  const parts: string[] = [];
71
73
  if (!baseModel) {
@@ -101,7 +103,19 @@ export const renderViewportElement = (
101
103
  const mmPerModel = scaleInfo.mmPerModelUnit;
102
104
  const contentWidthMm = renderWidthModel * mmPerModel;
103
105
  const contentHeightMm = renderHeightModel * mmPerModel;
104
- const rotated = rotateBounds(contentWidthMm, contentHeightMm, rotationDeg);
106
+ // Tight bounds of the rotated geometry (see rotatedContentExtents): sizes the
107
+ // content box and re-centers the rotation pivot so diagonally-oriented models
108
+ // straightened via rotationDeg fill the viewport instead of shrinking to the
109
+ // inflated AABB-of-AABB. Clip keeps the AABB path — its bounds are the clip
110
+ // region, not the full geometry.
111
+ const tight = hasClip ? null : rotatedContentExtents(model, rotationDeg);
112
+ if (tight && precomputed?.renderCenterModelX == null && precomputed?.renderCenterModelY == null) {
113
+ renderCenterModelX = tight.centerViewX;
114
+ renderCenterModelY = tight.centerViewY;
115
+ }
116
+ const rotated = tight
117
+ ? { width: tight.width * mmPerModel, height: tight.height * mmPerModel }
118
+ : rotateBounds(contentWidthMm, contentHeightMm, rotationDeg);
105
119
 
106
120
  const centerX = vp.align === 'topLeft' ? vp.xMm + rotated.width / 2 : vp.xMm + vp.widthMm / 2;
107
121
  const centerY = vp.align === 'topLeft' ? vp.yMm + rotated.height / 2 : vp.yMm + vp.heightMm / 2;
@@ -113,37 +127,13 @@ export const renderViewportElement = (
113
127
  let clipId: string | null = null;
114
128
  const clipDebugPolys: string[] = [];
115
129
  const clipStyle = viewportClipStyles[vp.id];
116
- const clipStroke = clipStyle?.stroke;
117
- const clipStrokeWidth = clipStyle?.strokeWidthMm;
118
- const clipStrokeOpacity = clipStyle?.strokeOpacity;
130
+ const clipStroke = clipStyle?.color;
131
+ const clipStrokeWidth = clipStyle?.widthMm;
132
+ const clipStrokeOpacity = clipStyle?.opacity;
133
+ const clipDash = clipStyle?.dash;
119
134
  const clipFill = clipStyle?.fill;
120
135
  const clipFillOpacity = clipStyle?.fillOpacity;
121
- const transformAttr = `translate(${centerX} ${centerY}) rotate(${rotationDeg}) scale(${mmPerModel}) translate(${-renderCenterModelX} ${-renderCenterModelY})`;
122
-
123
- // Debug: log model bounds and path breakdown
124
- // if (model.transform) {
125
- // console.log(`[DEBUG MODEL vp="${vp.id}"] Model bounds: viewMinX=${model.transform.viewMinX.toFixed(2)} viewMaxX=${model.transform.viewMaxX?.toFixed(2)} viewMinZ=${model.transform.viewMinZ.toFixed(2)} viewMaxZ=${model.transform.viewMaxZ.toFixed(2)} viewWidth=${model.transform.viewWidth.toFixed(2)} viewHeight=${model.transform.viewHeight.toFixed(2)}`);
126
-
127
- // if (model.rawPaths) {
128
- // const fills = model.rawPaths.filter(p => p.meta?.isFill);
129
- // const segments = model.rawPaths.filter(p => p.meta?.isSegment);
130
- // const other = model.rawPaths.filter(p => !p.meta?.isFill && !p.meta?.isSegment);
131
- // console.log(`[DEBUG MODEL vp="${vp.id}"] Path breakdown: ${model.rawPaths.length} total (${fills.length} fills, ${segments.length} segments, ${other.length} other)`);
132
-
133
- // // Sample a few segment paths
134
- // const sampleSegments = segments.slice(0, 3);
135
- // sampleSegments.forEach((seg, idx) => {
136
- // console.log(`[DEBUG MODEL vp="${vp.id}"] Segment ${idx}: class=${seg.meta?.class} fill=${seg.fill} stroke=${seg.stroke} strokeWidth=${seg.strokeWidth} d=${seg.d.substring(0, 50)}...`);
137
- // });
138
-
139
- // // Check if geometryTransform is present
140
- // if (model.geometryTransform) {
141
- // console.log(`[DEBUG MODEL vp="${vp.id}"] geometryTransform="${model.geometryTransform}"`);
142
- // } else {
143
- // console.log(`[DEBUG MODEL vp="${vp.id}"] WARNING: geometryTransform is MISSING!`);
144
- // }
145
- // }
146
- // }
136
+ const transformAttr = `translate(${formatNumber(centerX)} ${formatNumber(centerY)}) rotate(${rotationDeg}) scale(${mmPerModel}) translate(${formatNumber(-renderCenterModelX)} ${formatNumber(-renderCenterModelY)})`;
147
137
 
148
138
  // Guard: detect NaN/Infinity in computed transform values
149
139
  if (
@@ -159,14 +149,11 @@ export const renderViewportElement = (
159
149
  return parts;
160
150
  }
161
151
 
162
- // console.log(`[DEBUG CLIP APPLY vp="${vp.id}"] clipPolysLocal exists: ${!!clipPolysLocal}, length: ${clipPolysLocal?.length ?? 0}`);
163
152
  if (clipPolysLocal && clipPolysLocal.length) {
164
- // console.log(`[DEBUG CLIP APPLY vp="${vp.id}"] *** APPLYING CLIP-PATH *** with ${clipPolysLocal.length} polygons`);
165
153
  if (clipPolysLocal.every((poly) => !poly.length)) {
166
154
  warnings.push(`Viewport '${vp.id}' clip has no points after prep; skipping clip.`);
167
155
  }
168
156
  clipId = `clip-${vp.id}-${parts.length}`;
169
- const polyPartsPage: string[] = [];
170
157
  // Clip polygon points are in model-view coordinates (not SVG-internal),
171
158
  // so transform them with outer only (model-view → page). Do NOT apply
172
159
  // geometryTransform — that maps SVG-internal → model-view, which is only
@@ -179,33 +166,21 @@ export const renderViewportElement = (
179
166
  )
180
167
  );
181
168
 
182
- // Debug: log clip polygon bounds
183
- let clipMinX = Infinity,
184
- clipMaxX = -Infinity,
185
- clipMinY = Infinity,
186
- clipMaxY = -Infinity;
187
- clipPolysLocal.forEach((poly) => {
188
- poly.forEach((p) => {
189
- if (p.x < clipMinX) clipMinX = p.x;
190
- if (p.x > clipMaxX) clipMaxX = p.x;
191
- if (p.y < clipMinY) clipMinY = p.y;
192
- if (p.y > clipMaxY) clipMaxY = p.y;
193
- });
194
- });
195
- // console.log(`[DEBUG CLIP vp="${vp.id}"] Local bounds: x=${clipMinX.toFixed(2)}..${clipMaxX.toFixed(2)} y=${clipMinY.toFixed(2)}..${clipMaxY.toFixed(2)} (w=${(clipMaxX - clipMinX).toFixed(2)} h=${(clipMaxY - clipMinY).toFixed(2)})`);
196
-
169
+ // Rings from unioned clips can include holes — a single even-odd path
170
+ // (rather than one polygon per ring) makes holes subtract correctly in
171
+ // both the clip mask and any fill.
172
+ const ringDs: string[] = [];
197
173
  clipPolysLocal.forEach((poly) => {
198
174
  const pagePtsArr = poly.map((p) => matApply(outer, p));
199
- const pagePts = pagePtsArr.map((p) => `${p.x},${p.y}`).join(' ');
200
- polyPartsPage.push(`<polygon points="${pagePts}" />`);
201
- if (clipStroke || clipFill) {
175
+ const pagePts = pagePtsArr.map((p) => `${formatNumber(p.x)},${formatNumber(p.y)}`).join(' ');
176
+ ringDs.push('M ' + pagePtsArr.map((p) => `${formatNumber(p.x)} ${formatNumber(p.y)}`).join(' L ') + ' Z');
177
+ if (clipStroke) {
202
178
  clipDebugPolys.push(
203
- `<polygon points="${pagePts}"` +
204
- (clipStroke ? ` stroke="${escapeAttr(clipStroke)}"` : '') +
179
+ `<polygon points="${pagePts}" fill="none"` +
180
+ ` stroke="${escapeAttr(clipStroke)}"` +
205
181
  (clipStrokeWidth ? ` stroke-width="${clipStrokeWidth}"` : '') +
206
182
  (clipStrokeOpacity !== undefined ? ` stroke-opacity="${clipStrokeOpacity}"` : '') +
207
- (clipFill ? ` fill="${escapeAttr(clipFill)}"` : ' fill="none"') +
208
- (clipFillOpacity !== undefined ? ` fill-opacity="${clipFillOpacity}"` : '') +
183
+ (clipDash ? ` stroke-dasharray="${escapeAttr(clipDash)}"` : '') +
209
184
  ` />`
210
185
  );
211
186
  }
@@ -215,8 +190,15 @@ export const renderViewportElement = (
215
190
  );
216
191
  }
217
192
  });
193
+ if (clipFill && clipFill !== 'none') {
194
+ clipDebugPolys.unshift(
195
+ `<path d="${ringDs.join(' ')}" fill-rule="evenodd" fill="${escapeAttr(clipFill)}"` +
196
+ (clipFillOpacity !== undefined ? ` fill-opacity="${clipFillOpacity}"` : '') +
197
+ ` stroke="none" />`
198
+ );
199
+ }
218
200
  parts.push(
219
- `<defs><clipPath id="${clipId}" clipPathUnits="userSpaceOnUse">${polyPartsPage.join('')}</clipPath></defs>`
201
+ `<defs><clipPath id="${clipId}" clipPathUnits="userSpaceOnUse"><path d="${ringDs.join(' ')}" clip-rule="evenodd" /></clipPath></defs>`
220
202
  );
221
203
  }
222
204
 
@@ -225,35 +207,25 @@ export const renderViewportElement = (
225
207
 
226
208
  // Render fill polygons below structural lines
227
209
  const allowedGids = viewportHighlight[vp.id]?.gids ?? null;
228
- const fillSvg = buildFillSvg(vp, dataWithViewport, model, allowedGids, legendColorMaps);
210
+ const fillSvg = buildFillSvg(vp, dataWithViewport, model, allowedGids, legendColorMaps, warnings, includeFilters);
229
211
  if (fillSvg) parts.push(fillSvg);
230
212
 
231
- // Debug: log sample path coordinates from model.groupSvg
232
- if (clipId && model.rawPaths && model.rawPaths.length > 0) {
233
- const asMeta = (m: unknown): { isSegment?: boolean; isFill?: boolean; class?: string } | undefined =>
234
- m != null && typeof m === 'object'
235
- ? (m as { isSegment?: boolean; isFill?: boolean; class?: string })
236
- : undefined;
237
- const samplePath = model.rawPaths.find((p) => {
238
- const pm = asMeta(p.meta);
239
- return pm?.isSegment || (!pm?.isFill && !pm?.class);
240
- });
241
- if (samplePath) {
242
- const dMatch = samplePath.d.match(/M([\d.-]+)\s+([\d.-]+)/);
243
- if (dMatch) {
244
- // console.log(`[DEBUG PATH vp="${vp.id}"] Sample wall path starts at x=${dMatch[1]} y=${dMatch[2]} (world coords, will be transformed by geometryTransform)`);
245
- }
246
- }
247
- }
248
-
249
- // Compensate stroke-widths for the viewport scale transform.
250
- // Paths have stroke-width in model units; the scale(mmPerModel) transform
251
- // would make them thinner/thicker depending on zoom. Dividing by mmPerModel
252
- // ensures strokes render at their intended mm size regardless of viewport scale.
253
- const compensatedSvg = model.groupSvg.replace(
254
- /stroke-width="([0-9.eE+-]+)"/g,
255
- (_, w) => `stroke-width="${parseFloat(w) / mmPerModel}"`
256
- );
213
+ // Compensate stroke-widths AND dash arrays for the viewport scale
214
+ // transform. Path styling is authored in mm; the scale(mmPerModel)
215
+ // transform would multiply both, so divide them out otherwise
216
+ // stroke-dasharray "4 2" means 4 model units (metres), not 4 mm, and the
217
+ // dash period changes with the viewport scale.
218
+ const compensatedSvg = model.groupSvg
219
+ .replace(/stroke-width="([0-9.eE+-]+)"/g, (_, w) => `stroke-width="${parseFloat(w) / mmPerModel}"`)
220
+ .replace(
221
+ /stroke-dasharray="([0-9.eE+\-, ]+)"/g,
222
+ (_, dashes: string) =>
223
+ `stroke-dasharray="${dashes
224
+ .split(/[\s,]+/)
225
+ .filter(Boolean)
226
+ .map((d) => parseFloat(d) / mmPerModel)
227
+ .join(' ')}"`
228
+ );
257
229
  parts.push(compensatedSvg);
258
230
  parts.push('</g>');
259
231
  parts.push('</g>');
@@ -282,30 +254,30 @@ export const renderViewportElement = (
282
254
  parts.push(
283
255
  `<rect x="${vp.xMm}" y="${vp.yMm}" width="${vp.widthMm}" height="${vp.heightMm}" stroke="${escapeAttr(
284
256
  borderStroke
285
- )}" stroke-width="${borderWidth}" fill="none"${vp.border.dashed ? ' stroke-dasharray="4 2"' : ''} />`
257
+ )}" stroke-width="${borderWidth}" fill="none"${vp.border.dash ? ` stroke-dasharray="${escapeAttr(vp.border.dash)}"` : ''} />`
286
258
  );
287
259
  }
288
260
 
289
261
  if (vp.northArrow) {
290
262
  const arrow = vp.northArrow;
291
- const asset = assetMap.get(arrow.assetId);
263
+ const resource = resourceMap.get(arrow.resourceId);
292
264
  const size = arrow.sizeMm ?? 12;
293
265
  const offsetX = arrow.offsetXMm ?? 8;
294
266
  const offsetY = arrow.offsetYMm ?? 8;
295
267
  const arrowX = vp.xMm + vp.widthMm - offsetX - size;
296
268
  const arrowY = vp.yMm + offsetY;
297
269
  const arrowRotation = arrow.rotationDeg ?? 0;
298
- if (!asset) {
299
- warnings.push(`Missing asset '${arrow.assetId}' for north arrow on viewport '${vp.id}'.`);
300
- } else if (asset.src) {
270
+ if (!resource) {
271
+ warnings.push(`Missing resource '${arrow.resourceId}' for north arrow on viewport '${vp.id}'.`);
272
+ } else if (resource.src) {
301
273
  parts.push(
302
- `<image href="${escapeAttr(asset.src)}" x="${arrowX}" y="${arrowY}" width="${size}" height="${size}" transform="rotate(${arrowRotation}, ${arrowX + size / 2}, ${
274
+ `<image href="${escapeAttr(resource.src)}" x="${arrowX}" y="${arrowY}" width="${size}" height="${size}" transform="rotate(${arrowRotation}, ${arrowX + size / 2}, ${
303
275
  arrowY + size / 2
304
276
  })" />`
305
277
  );
306
- } else if (asset.content) {
278
+ } else if (resource.content) {
307
279
  parts.push(
308
- `<g transform="translate(${arrowX} ${arrowY}) rotate(${arrowRotation} ${size / 2} ${size / 2})">${asset.content}</g>`
280
+ `<g transform="translate(${arrowX} ${arrowY}) rotate(${arrowRotation} ${size / 2} ${size / 2})">${resource.content}</g>`
309
281
  );
310
282
  }
311
283
  }
@@ -327,7 +299,9 @@ function buildFillSvg(
327
299
  data: any,
328
300
  model: SvgModelView,
329
301
  allowedGids: Set<string> | null,
330
- legendColorMaps?: Map<string, Map<string, string>>
302
+ legendColorMaps: Map<string, LegendColorAssignment> | undefined,
303
+ warnings: string[],
304
+ includeFilters?: ResolvedIncludeFilter[] | null
331
305
  ): string | null {
332
306
  const fills = vp.fills;
333
307
  if (!fills?.length || !model.transform) return null;
@@ -342,7 +316,7 @@ function buildFillSvg(
342
316
  const opacity = rule.fillOpacity ?? 0.35;
343
317
  // Find items matching this fill rule
344
318
  let matching = items.filter((item: unknown) =>
345
- isRecord(item) ? matchesV2Filter(item, rule.class, rule.where) : false
319
+ isRecord(item) ? matchesFilter(item, rule.class, rule.where) : false
346
320
  );
347
321
 
348
322
  // Filter by allowed GIDs (from clip polygon center point test)
@@ -352,20 +326,23 @@ function buildFillSvg(
352
326
  const gid = getProp(item, 'gid') ?? getProp(item, 'id');
353
327
  return gid !== null && gid !== undefined && allowedGids.has(String(gid));
354
328
  });
355
- // console.log(`[DEBUG FILL vp="${vp.id}"] Clip filter applied (matchClipFilter=true): ${beforeFilter} → ${matching.length} items (allowedGids.size=${allowedGids.size})`);
356
- } else if (rule.matchClipFilter === true) {
357
- // console.log(`[DEBUG FILL vp="${vp.id}"] Fill rule has matchClipFilter=true but no clip polygon active`);
329
+ } else if (includeFilters?.length) {
330
+ // The viewport's includeClasses gates generic fills exactly like it gates
331
+ // line paths the include list is authoritative for what geometry appears.
332
+ // GID-gated rules (matchClipFilter with an active clip/highlight set, the
333
+ // branch above) are exempt, mirroring highlight/clip classes in
334
+ // filterModelForViewport: a clipped apartment's space fills must render
335
+ // even in a view whose includeClasses excludes spaces.
336
+ matching = matching.filter(
337
+ (item: unknown) =>
338
+ isRecord(item) &&
339
+ includeFilters.some((f) =>
340
+ typeof f === 'string'
341
+ ? matchesFilter(item, f, undefined)
342
+ : matchesFilter(item, f.class, f.where)
343
+ )
344
+ );
358
345
  }
359
- // console.log(`[DEBUG FILL vp="${vp.id}"] Fill rule (class="${rule.class}", where=${JSON.stringify(rule.where)}) matched ${matching.length} items`);
360
- // if (matching.length > 0) {
361
- // console.log(`[DEBUG FILL vp="${vp.id}"] First matched item:`, {
362
- // gid: getProp(matching[0], 'gid'),
363
- // name: getProp(matching[0], 'name'),
364
- // ifcClass: getProp(matching[0], 'ifcClass'),
365
- // propertySets: getProp(matching[0], 'propertySets'),
366
- // polylines: Array.isArray(getProp(matching[0], 'polylines')) ? (getProp(matching[0], 'polylines') as any[]).length : 0
367
- // });
368
- // }
369
346
  if (!matching.length) continue;
370
347
 
371
348
  for (const item of matching) {
@@ -379,40 +356,33 @@ function buildFillSvg(
379
356
  if (!color) continue; // no sampled color available
380
357
  fillColor = `rgb(${color[0]},${color[1]},${color[2]})`;
381
358
  } else if (rule.fill === 'legend') {
382
- // Use color from legend color map
383
- const legendColorMap = legendColorMaps?.get(vp.id);
384
- if (!legendColorMap) {
385
- console.warn(
386
- `[FILL] fill="legend" specified but no legend color map found for viewport "${vp.id}". Available viewports:`,
387
- Array.from(legendColorMaps?.keys() ?? [])
359
+ const legendColors = legendColorMaps?.get(vp.id);
360
+ if (!legendColors) {
361
+ warnings.push(
362
+ `Viewport '${vp.id}' has fill: "legend" but no legend with assignColors targets it (legends exist for: ${Array.from(legendColorMaps?.keys() ?? []).join(', ') || 'none'}).`
388
363
  );
389
- continue;
364
+ break;
365
+ }
366
+ // Resolve the item's colour: by item identity first (covers
367
+ // ungrouped legends — every item its own colour), then by the
368
+ // legend's groupBy property value (shared across a group).
369
+ let resolved: string | undefined;
370
+ const gid = getProp(item, 'gid') ?? getProp(item, 'id');
371
+ if (gid !== null && gid !== undefined) {
372
+ resolved = legendColors.byGid.get(String(gid));
390
373
  }
391
- // Find matching legend entry - need to determine which property to use for lookup
392
- // For now, try common properties (can be made configurable later)
393
- let legendKey: string | undefined;
394
- const possibleKeys = ['spacename', 'BIP.spacename', 'name'];
395
- for (const key of possibleKeys) {
396
- const value = resolvePath(item, key);
374
+ if (!resolved && legendColors.groupBy) {
375
+ const value = resolvePath(item, legendColors.groupBy);
397
376
  if (value !== undefined && value !== null) {
398
- legendKey = String(value);
399
- break;
377
+ resolved = legendColors.byValue.get(String(value));
400
378
  }
401
379
  }
402
- if (!legendKey || !legendColorMap.has(legendKey)) {
403
- // No matching color in legend, skip this item
404
- // if (legendKey) {
405
- // console.log(`[FILL] No color mapping for "${legendKey}" (property: ${matchedProperty}). Available keys:`, Array.from(legendColorMap.keys()));
406
- // }
407
- continue;
408
- }
409
- fillColor = legendColorMap.get(legendKey)!;
410
- // console.log(`[FILL] ✓ Matched "${legendKey}" -> ${fillColor}`);
380
+ if (!resolved) continue; // item not represented in the legend
381
+ fillColor = resolved;
411
382
  } else {
412
383
  fillColor = rule.fill;
413
384
  }
414
385
 
415
- // console.log(`[FILL] Processing ${polylines.length} polyline(s) for item with color ${fillColor}`);
416
386
  for (const polyline of polylines) {
417
387
  const points = isRecord(polyline) ? getProp(polyline, 'points') : null;
418
388
  if (!Array.isArray(points) || points.length < 3) continue;
@@ -426,7 +396,11 @@ function buildFillSvg(
426
396
  if (!Number.isFinite(px) || !Number.isFinite(pz)) continue;
427
397
  const mx = (px as number) - viewMinX;
428
398
  const my = viewMaxZ - (pz as number);
429
- pathParts.push(i === 0 ? `M ${mx} ${my}` : `L ${mx} ${my}`);
399
+ pathParts.push(
400
+ i === 0
401
+ ? `M ${formatNumber(mx)} ${formatNumber(my)}`
402
+ : `L ${formatNumber(mx)} ${formatNumber(my)}`
403
+ );
430
404
  }
431
405
  if (pathParts.length < 3) continue;
432
406
  pathParts.push('Z');
@@ -437,6 +411,5 @@ function buildFillSvg(
437
411
  }
438
412
  }
439
413
 
440
- // console.log(`[FILL] Generated ${parts.length} fill polygon(s) for viewport "${vp.id}"`);
441
414
  return parts.length ? `<g>${parts.join('')}</g>` : null;
442
415
  }
@@ -11,29 +11,142 @@ export const rotateBounds = (width: number, height: number, rotationDeg: number)
11
11
  return { width: rotatedWidth, height: rotatedHeight };
12
12
  };
13
13
 
14
+ export interface RotatedContentExtents {
15
+ /** Tight extents of the rotated content, model units (rotated/sheet axes). */
16
+ width: number;
17
+ height: number;
18
+ /** Tight-bounds center mapped back to view space — the rotation pivot that centers the content. */
19
+ centerViewX: number;
20
+ centerViewY: number;
21
+ }
22
+
23
+ // Absolute move/line sequences only (what our section renderers emit). Anything
24
+ // else — relative commands, curves, arcs — falls back to AABB-based rotation.
25
+ const ABSOLUTE_POLYLINE_D = /^[MLZ\s,0-9eE+\-.]*$/;
26
+ const NUMBER_IN_D = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g;
27
+
28
+ const extentsCache = new WeakMap<object, Map<string, RotatedContentExtents | null>>();
29
+
30
+ /**
31
+ * Tight bounds of the model's actual geometry under viewport rotation.
32
+ *
33
+ * Rotating the model's AABB *rectangle* only ever inflates it — for content that
34
+ * sits diagonally in model space (the main reason to set rotationDeg: straighten
35
+ * a building on the sheet), the true rotated bounds SHRINK instead, and fitting
36
+ * against the inflated rectangle picks a scale several preset steps too small.
37
+ * Returns null when tight bounds can't be derived (no rotation, no rawPaths /
38
+ * transform, or path data that isn't plain absolute polylines) — callers then
39
+ * keep the AABB-based rotateBounds behavior.
40
+ */
41
+ export const rotatedContentExtents = (model: SvgModelView, rotationDeg: number): RotatedContentExtents | null => {
42
+ const norm = ((rotationDeg % 360) + 360) % 360;
43
+ const paths = model.rawPaths;
44
+ const t = model.transform;
45
+ if (!norm || !paths?.length || !t) return null;
46
+ // Keyed on the transform origin as well as the rotation. The extents below are computed through
47
+ // t.viewMinX / t.viewMaxZ, so two models sharing a rawPaths array but placed with different
48
+ // origins have different answers — keying on rotation alone handed the second the first's.
49
+ const cacheKey = `${norm}|${t.viewMinX}|${t.viewMaxZ}`;
50
+ let perRotation = extentsCache.get(paths);
51
+ const cached = perRotation?.get(cacheKey);
52
+ if (cached !== undefined) return cached;
53
+ if (!perRotation) {
54
+ perRotation = new Map();
55
+ extentsCache.set(paths, perRotation);
56
+ }
57
+
58
+ const rad = (norm * Math.PI) / 180;
59
+ const cos = Math.cos(rad);
60
+ const sin = Math.sin(rad);
61
+ let minX = Infinity;
62
+ let minY = Infinity;
63
+ let maxX = -Infinity;
64
+ let maxY = -Infinity;
65
+ for (const p of paths) {
66
+ if (!p.d) continue;
67
+ if (!ABSOLUTE_POLYLINE_D.test(p.d)) {
68
+ perRotation.set(cacheKey, null);
69
+ return null;
70
+ }
71
+ const nums = p.d.match(NUMBER_IN_D);
72
+ if (!nums) continue;
73
+ for (let i = 0; i + 1 < nums.length; i += 2) {
74
+ // Raw coords are projected y-up space; geometryTransform maps them to
75
+ // view space (y-down, origin at the model's min corner) — mirror that
76
+ // here so extents/center land in the space the render transform uses.
77
+ const vx = Number(nums[i]) - t.viewMinX;
78
+ const vy = t.viewMaxZ - Number(nums[i + 1]);
79
+ const rx = vx * cos - vy * sin;
80
+ const ry = vx * sin + vy * cos;
81
+ if (rx < minX) minX = rx;
82
+ if (rx > maxX) maxX = rx;
83
+ if (ry < minY) minY = ry;
84
+ if (ry > maxY) maxY = ry;
85
+ }
86
+ }
87
+ if (!Number.isFinite(minX) || !Number.isFinite(minY) || maxX - minX <= 0 || maxY - minY <= 0) {
88
+ perRotation.set(cacheKey, null);
89
+ return null;
90
+ }
91
+ const cx = (minX + maxX) / 2;
92
+ const cy = (minY + maxY) / 2;
93
+ const result: RotatedContentExtents = {
94
+ width: maxX - minX,
95
+ height: maxY - minY,
96
+ // Inverse-rotate the rotated-space center: using this as the transform
97
+ // pivot makes the tight content box land centered in the viewport.
98
+ centerViewX: cx * cos + cy * sin,
99
+ centerViewY: -cx * sin + cy * cos
100
+ };
101
+ perRotation.set(cacheKey, result);
102
+ return result;
103
+ };
104
+
14
105
  export const pickScale = (
15
106
  viewport: ViewportElement,
16
107
  model: SvgModelView,
17
108
  preferred: number[],
18
109
  scalePresets: Record<string, { preferredScales?: number[] }>
19
110
  ): ComputedViewportScale => {
111
+ const rotationDeg = viewport.rotationDeg ?? 0;
112
+ // Callers substitute clip-region dims into widthModel/heightModel; tight
113
+ // rotated bounds of the FULL geometry would be wrong there, so only use
114
+ // them when the passed dims still match the model's own view bounds.
115
+ const clipAdjusted =
116
+ !!model.transform &&
117
+ (Math.abs(model.widthModel - model.transform.viewWidth) > 1e-9 ||
118
+ Math.abs(model.heightModel - model.transform.viewHeight) > 1e-9);
119
+ const tight = clipAdjusted ? null : rotatedContentExtents(model, rotationDeg);
120
+
121
+ // 'fit': exact fill of the viewport — the denominator is whatever ratio
122
+ // makes the (rotated) model touch the viewport bounds; no rounding to a
123
+ // preferred-scale list, so the scale text can be odd (e.g. 1:73.4).
124
+ if (viewport.scaleMode === 'fit') {
125
+ const rotatedModel = tight ?? rotateBounds(model.widthModel, model.heightModel, rotationDeg);
126
+ const mmPerModel = Math.min(
127
+ viewport.widthMm / (rotatedModel.width || 1),
128
+ viewport.heightMm / (rotatedModel.height || 1)
129
+ );
130
+ if (Number.isFinite(mmPerModel) && mmPerModel > 0) {
131
+ return { denom: Math.round((1000 / mmPerModel) * 10) / 10, mmPerModelUnit: mmPerModel, fits: true };
132
+ }
133
+ }
134
+
20
135
  const scalePresetName = viewport.scalePreset;
21
136
  const scalePreset = scalePresetName && scalePresets[scalePresetName] ? scalePresets[scalePresetName] : null;
22
137
  const denoms =
23
138
  viewport.scaleMode === 'fixed' && viewport.fixedScaleDenom
24
139
  ? [viewport.fixedScaleDenom]
25
140
  : uniquePositiveDenoms(viewport.preferredScales ?? scalePreset?.preferredScales ?? preferred);
26
-
27
- const rotationDeg = viewport.rotationDeg ?? 0;
28
141
  let selectedDenom = denoms[denoms.length - 1];
29
142
  let selectedMmPerModel = 1000 / selectedDenom;
30
143
  let fits = false;
31
144
 
32
145
  for (const denom of denoms) {
33
146
  const mmPerModel = 1000 / denom;
34
- const widthMm = model.widthModel * mmPerModel;
35
- const heightMm = model.heightModel * mmPerModel;
36
- const rotated = rotateBounds(widthMm, heightMm, rotationDeg);
147
+ const rotated = tight
148
+ ? { width: tight.width * mmPerModel, height: tight.height * mmPerModel }
149
+ : rotateBounds(model.widthModel * mmPerModel, model.heightModel * mmPerModel, rotationDeg);
37
150
  if (rotated.width <= viewport.widthMm && rotated.height <= viewport.heightMm) {
38
151
  selectedDenom = denom;
39
152
  selectedMmPerModel = mmPerModel;