@twinfinity/printing 6.0.1-ci.28952-beta → 6.0.2-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.
- package/README.md +3 -3
- package/dist/BuildInfo.js +3 -3
- package/dist/BuildInfo.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout/analyzeTemplate.js.map +1 -1
- package/dist/layout/clipUtils.js.map +1 -1
- package/dist/layout/colorConstants.js.map +1 -1
- package/dist/layout/elementKinds.js.map +1 -1
- package/dist/layout/exportDrawing.js.map +1 -1
- package/dist/layout/filters.js.map +1 -1
- package/dist/layout/geometryConstants.js.map +1 -1
- package/dist/layout/index.js.map +1 -1
- package/dist/layout/itemBuilders.js.map +1 -1
- package/dist/layout/labelsRender.js.map +1 -1
- package/dist/layout/legendConstants.js.map +1 -1
- package/dist/layout/legendRender.js.map +1 -1
- package/dist/layout/legendRows.js.map +1 -1
- package/dist/layout/legendUtils.js.map +1 -1
- package/dist/layout/metadata.js.map +1 -1
- package/dist/layout/model.js.map +1 -1
- package/dist/layout/options.js.map +1 -1
- package/dist/layout/presets.js.map +1 -1
- package/dist/layout/qrRender.js.map +1 -1
- package/dist/layout/renderDrawing.js.map +1 -1
- package/dist/layout/renderUtils.js.map +1 -1
- package/dist/layout/sectionValidation.js.map +1 -1
- package/dist/layout/templateVariables.js.map +1 -1
- package/dist/layout/typeGuards.js.map +1 -1
- package/dist/layout/types.js.map +1 -1
- package/dist/layout/utils.js.map +1 -1
- package/dist/layout/validation.js.map +1 -1
- package/dist/layout/validationHelpers.js.map +1 -1
- package/dist/layout/validationUtils.js.map +1 -1
- package/dist/layout/viewportPrep.js.map +1 -1
- package/dist/layout/viewportRender.js.map +1 -1
- package/dist/layout/viewportUtils.js.map +1 -1
- package/dist/layout/warnings.js.map +1 -1
- package/dist/printToPdf.js.map +1 -1
- package/dist/printToSvg.js.map +1 -1
- package/dist/sections.js.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/BuildInfo.ts +41 -41
- package/src/index.ts +10 -10
- package/src/layout/analyzeTemplate.ts +218 -218
- package/src/layout/clipUtils.ts +193 -193
- package/src/layout/colorConstants.ts +16 -16
- package/src/layout/elementKinds.ts +35 -35
- package/src/layout/exportDrawing.ts +206 -206
- package/src/layout/filters.ts +80 -80
- package/src/layout/geometryConstants.ts +11 -11
- package/src/layout/index.ts +671 -671
- package/src/layout/itemBuilders.ts +159 -159
- package/src/layout/labelsRender.ts +170 -170
- package/src/layout/legendConstants.ts +11 -11
- package/src/layout/legendRender.ts +543 -543
- package/src/layout/legendRows.ts +62 -62
- package/src/layout/legendUtils.ts +144 -144
- package/src/layout/metadata.ts +210 -210
- package/src/layout/model.ts +372 -372
- package/src/layout/options.ts +17 -17
- package/src/layout/presets.ts +51 -51
- package/src/layout/qrRender.ts +126 -126
- package/src/layout/qrcode.d.ts +2 -2
- package/src/layout/renderDrawing.ts +710 -710
- package/src/layout/renderUtils.ts +138 -138
- package/src/layout/sectionValidation.ts +2 -2
- package/src/layout/templateVariables.ts +317 -317
- package/src/layout/typeGuards.ts +9 -9
- package/src/layout/types.ts +835 -835
- package/src/layout/utils.ts +85 -85
- package/src/layout/validation.ts +392 -392
- package/src/layout/validationHelpers.ts +59 -59
- package/src/layout/validationUtils.ts +6 -6
- package/src/layout/viewportPrep.ts +351 -351
- package/src/layout/viewportRender.ts +442 -442
- package/src/layout/viewportUtils.ts +50 -50
- package/src/layout/warnings.ts +3 -3
- package/src/printToPdf.ts +108 -108
- package/src/printToSvg.ts +188 -188
- package/src/sections.ts +1019 -1019
- package/src/types.ts +49 -49
package/src/sections.ts
CHANGED
|
@@ -1,1019 +1,1019 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Vec3,
|
|
3
|
-
SectionLabel,
|
|
4
|
-
SectionPath,
|
|
5
|
-
SectionRenderState,
|
|
6
|
-
TransformInfo,
|
|
7
|
-
ViewWindow
|
|
8
|
-
} from '@twinfinity/geometry';
|
|
9
|
-
|
|
10
|
-
export type { SectionLabel, SectionPath, SectionRenderState, TransformInfo, ViewWindow } from '@twinfinity/geometry';
|
|
11
|
-
|
|
12
|
-
/** Minimal product interface for section fill matching. */
|
|
13
|
-
interface SectionProduct {
|
|
14
|
-
class?: { is(c: unknown): boolean; type?: string; id?: string };
|
|
15
|
-
className?: string;
|
|
16
|
-
propertySets?: Record<string, unknown>;
|
|
17
|
-
properties?: { data?: Record<string, unknown>; sets?: Record<string, unknown> };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Local replacement for PlaneSectionEntry (not available in @twinfinity/geometry). */
|
|
21
|
-
type PlaneSectionEntry<TProduct extends SectionProduct = SectionProduct> = {
|
|
22
|
-
product: TProduct;
|
|
23
|
-
segments: [Vec3, Vec3][];
|
|
24
|
-
polylines: { points: Vec3[]; closed: boolean }[];
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Resolve a dotted property path (e.g. "BIP.spacetype") against a product.
|
|
29
|
-
* Handles both the new T8 API (propertySets → .properties → .value) and
|
|
30
|
-
* legacy BimIfcObject (properties.sets / properties.data).
|
|
31
|
-
*
|
|
32
|
-
* Returns the resolved value, or undefined if not found.
|
|
33
|
-
*/
|
|
34
|
-
export function resolveProductProperty(product: SectionProduct | unknown, path: string): unknown {
|
|
35
|
-
const p = product as SectionProduct;
|
|
36
|
-
const parts = path.split('.');
|
|
37
|
-
if (parts.length < 2) return undefined;
|
|
38
|
-
|
|
39
|
-
const [setName, ...propParts] = parts;
|
|
40
|
-
const propName = propParts.join('.');
|
|
41
|
-
|
|
42
|
-
// New T8 API: propertySets['BIP'].properties['spacetype'].value
|
|
43
|
-
const pset = (p?.propertySets as Record<string, any>)?.[setName];
|
|
44
|
-
if (pset) {
|
|
45
|
-
const prop = pset.properties?.[propName];
|
|
46
|
-
if (prop !== undefined) {
|
|
47
|
-
return prop?.value !== undefined ? prop.value : prop;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Legacy: properties.sets['BIP']['spacetype']
|
|
52
|
-
const legacyPset = (p?.properties?.sets as Record<string, any>)?.[setName];
|
|
53
|
-
if (legacyPset) {
|
|
54
|
-
const val = legacyPset[propName];
|
|
55
|
-
if (val !== undefined) {
|
|
56
|
-
return val?.value !== undefined ? val.value : val;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Fallback: properties.data['BIP']['spacetype'] (old printing code path)
|
|
61
|
-
let value: any = p?.properties?.data;
|
|
62
|
-
if (value) {
|
|
63
|
-
for (const part of parts) {
|
|
64
|
-
value = value?.[part];
|
|
65
|
-
if (value === undefined) break;
|
|
66
|
-
}
|
|
67
|
-
if (value !== undefined) {
|
|
68
|
-
return value?.value !== undefined ? value.value : value;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Sample the actual rendered color of a product's first mesh.
|
|
77
|
-
* Uses BimProductMesh.copyColorTo/getColorComponent for accurate colors
|
|
78
|
-
* (includes overrides, highlights). Falls back to style.surfaceColor/color.
|
|
79
|
-
*/
|
|
80
|
-
const sampleProductColorRgba = (product: unknown): { r: number; g: number; b: number; a: number } | undefined => {
|
|
81
|
-
/** Duck-typed mesh with optional color-access methods. */
|
|
82
|
-
interface DuckMesh {
|
|
83
|
-
copyColorTo?: (out: Uint8Array, offset: number) => void;
|
|
84
|
-
getColorComponent?: (index: number) => number;
|
|
85
|
-
style?: { surfaceColor?: readonly number[]; color?: readonly number[] };
|
|
86
|
-
}
|
|
87
|
-
const p = product as Record<string, unknown>;
|
|
88
|
-
const meshes = (p?.meshes ?? p?.productMeshes) as DuckMesh[] | undefined;
|
|
89
|
-
if (!meshes || !meshes.length) return undefined;
|
|
90
|
-
|
|
91
|
-
const rgba = new Uint8Array(4);
|
|
92
|
-
|
|
93
|
-
for (const mesh of meshes) {
|
|
94
|
-
try {
|
|
95
|
-
// BimProductMesh: read actual rendered color via copyColorTo or getColorComponent
|
|
96
|
-
if (typeof mesh.copyColorTo === 'function') {
|
|
97
|
-
mesh.copyColorTo(rgba, 0);
|
|
98
|
-
} else if (typeof mesh.getColorComponent === 'function') {
|
|
99
|
-
rgba[0] = mesh.getColorComponent(0); // Red
|
|
100
|
-
rgba[1] = mesh.getColorComponent(1); // Green
|
|
101
|
-
rgba[2] = mesh.getColorComponent(2); // Blue
|
|
102
|
-
rgba[3] = mesh.getColorComponent(3); // Alpha
|
|
103
|
-
} else {
|
|
104
|
-
// ModelMesh fallback: read from style
|
|
105
|
-
const style = mesh.style;
|
|
106
|
-
const c = style?.surfaceColor ?? style?.color;
|
|
107
|
-
if (c) return { r: c[0], g: c[1], b: c[2], a: (c[3] ?? 255) / 255 };
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
} catch {
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const [r, g, b, a] = rgba;
|
|
115
|
-
if (a === 0 && r === 0 && g === 0 && b === 0) continue;
|
|
116
|
-
return { r, g, b, a: a / 255 };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return undefined;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
/** Base SVG display width in layout units. */
|
|
123
|
-
export const BASE_WIDTH = 240;
|
|
124
|
-
/** Minimum SVG display height in layout units. */
|
|
125
|
-
export const MIN_HEIGHT = 140;
|
|
126
|
-
/** Margin ratio applied around section bounds (fraction of world extent). */
|
|
127
|
-
export const SECTION_MARGIN_RATIO = 0.05;
|
|
128
|
-
/** Maximum zoom factor for viewport clamping. */
|
|
129
|
-
export const MAX_ZOOM_FACTOR = 12;
|
|
130
|
-
/** Multiplicative step for zooming in. */
|
|
131
|
-
export const ZOOM_IN_STEP = 0.8;
|
|
132
|
-
/** Multiplicative step for zooming out. */
|
|
133
|
-
export const ZOOM_OUT_STEP = 1 / ZOOM_IN_STEP;
|
|
134
|
-
const OUTLINE_THICKNESS_SCALE = 0.5;
|
|
135
|
-
|
|
136
|
-
/** Clamp a number to a min/max range. */
|
|
137
|
-
export function clampValue(value: number, min: number, max: number): number {
|
|
138
|
-
return Math.min(max, Math.max(min, value));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export interface SectionBuildOptions {
|
|
142
|
-
/** If true, attach source product metadata to paths/labels for downstream filtering */
|
|
143
|
-
includeMeta?: boolean;
|
|
144
|
-
/** When true, create fill paths for closed polygons. When false, create only stroke/outline paths. Defaults to true for backward compatibility. */
|
|
145
|
-
createFills?: boolean;
|
|
146
|
-
/** Plane normal vector for determining 2D projection axes. If omitted, defaults to horizontal plan (Y-up). */
|
|
147
|
-
planeNormal?: Vec3;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Extract product metadata for path/label attribution.
|
|
152
|
-
* Casts to `any` once here to avoid scattering `as any` across the codebase.
|
|
153
|
-
* BimIfcObject's runtime shape exposes these fields but the public typings
|
|
154
|
-
* don't always surface them.
|
|
155
|
-
*/
|
|
156
|
-
function extractProductMeta(product: unknown): {
|
|
157
|
-
productId: string;
|
|
158
|
-
ifcClass: string | null;
|
|
159
|
-
ifcClassId: string | null;
|
|
160
|
-
properties: unknown;
|
|
161
|
-
} {
|
|
162
|
-
const p = product as Record<string, any>;
|
|
163
|
-
return {
|
|
164
|
-
productId: p?.gid ?? p?.id ?? String(product),
|
|
165
|
-
ifcClass: p?.class?.type ?? null,
|
|
166
|
-
ifcClassId: p?.class?.id ?? null,
|
|
167
|
-
properties: p?.properties?.data ?? p?.properties ?? null
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Default fill opacity when a fill rule omits `fillOpacity`. */
|
|
172
|
-
export const DEFAULT_FILL_OPACITY = 0.35;
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Fill rule controlling which products get visible fills and at what opacity.
|
|
176
|
-
* Mirrors the template-level FillRule but kept minimal to avoid circular deps.
|
|
177
|
-
*/
|
|
178
|
-
export interface SectionFillRule {
|
|
179
|
-
/** IFC class filter. When omitted, matches all products. */
|
|
180
|
-
class?: string | string[];
|
|
181
|
-
/** Property-based filter (e.g. { "BIP.spacetype": "ROOM" }). */
|
|
182
|
-
where?: Record<string, string | number | boolean | Array<string | number>>;
|
|
183
|
-
/** Fill opacity (0–1). Default: {@link DEFAULT_FILL_OPACITY}. */
|
|
184
|
-
fillOpacity?: number;
|
|
185
|
-
/** When true, only apply fill to products whose centroid is inside the clip polygon. @default false */
|
|
186
|
-
matchClipFilter?: boolean;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export interface BuildSectionStateOptions {
|
|
190
|
-
viewport?: ViewWindow;
|
|
191
|
-
strokeWidth?: number;
|
|
192
|
-
labelFontSize?: number;
|
|
193
|
-
/**
|
|
194
|
-
* Fill rules controlling which products get visible fills and at what opacity.
|
|
195
|
-
* Products matching a fill rule are rendered below non-matching products.
|
|
196
|
-
* When absent or empty, all products render uniformly (no fill-based reordering).
|
|
197
|
-
*/
|
|
198
|
-
fills?: SectionFillRule[];
|
|
199
|
-
includeMeta?: boolean;
|
|
200
|
-
/** Enable labels. Requires buildLabels callback. */
|
|
201
|
-
includeLabels?: boolean;
|
|
202
|
-
/** Callback to build labels for matching products. Required when includeLabels is true. */
|
|
203
|
-
buildLabels?: (products: any[], fontSize: number) => SectionLabel[];
|
|
204
|
-
/** Clip polygon in world coordinates (x, z). Used when fill.matchClipFilter is true for spatial filtering. */
|
|
205
|
-
clipPolygon?: Array<Array<{ x: number; z: number }>>;
|
|
206
|
-
/** When true, create fill paths for closed polygons. When false, create only stroke/outline paths. Defaults to true for backward compatibility. */
|
|
207
|
-
createFills?: boolean;
|
|
208
|
-
/** Plane normal vector for determining 2D projection axes. If omitted, defaults to horizontal plan (Y-up). */
|
|
209
|
-
planeNormal?: Vec3;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** Convert plane section polylines to SVG path data with fill colors and stroke styles. */
|
|
213
|
-
export function createSectionSvg(
|
|
214
|
-
perProduct: PlaneSectionEntry[],
|
|
215
|
-
viewport?: ViewWindow,
|
|
216
|
-
strokeWidthHint?: number,
|
|
217
|
-
labels?: SectionLabel[],
|
|
218
|
-
options?: SectionBuildOptions
|
|
219
|
-
): SectionRenderState | null {
|
|
220
|
-
if (!perProduct.length) {
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const polylines = perProduct.flatMap((entry) => entry.polylines);
|
|
225
|
-
const hasSegments = perProduct.some((entry) => entry.segments && entry.segments.length > 0);
|
|
226
|
-
|
|
227
|
-
if (!polylines.length && !hasSegments) {
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const planeNormal = options?.planeNormal;
|
|
232
|
-
const axes = getProjectionAxes(planeNormal);
|
|
233
|
-
|
|
234
|
-
let viewMinX: number;
|
|
235
|
-
let viewMaxX: number;
|
|
236
|
-
let viewMinZ: number;
|
|
237
|
-
let viewMaxZ: number;
|
|
238
|
-
|
|
239
|
-
if (viewport) {
|
|
240
|
-
viewMinX = viewport.viewMinX;
|
|
241
|
-
viewMaxX = viewport.viewMaxX;
|
|
242
|
-
viewMinZ = viewport.viewMinZ;
|
|
243
|
-
viewMaxZ = viewport.viewMaxZ;
|
|
244
|
-
} else {
|
|
245
|
-
let minU = Number.POSITIVE_INFINITY;
|
|
246
|
-
let maxU = Number.NEGATIVE_INFINITY;
|
|
247
|
-
let minV = Number.POSITIVE_INFINITY;
|
|
248
|
-
let maxV = Number.NEGATIVE_INFINITY;
|
|
249
|
-
|
|
250
|
-
polylines.forEach((polyline: { points: Vec3[]; closed: boolean }) => {
|
|
251
|
-
polyline.points.forEach((point: Vec3) => {
|
|
252
|
-
if (!point) {
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
const u = axes.getU(point);
|
|
256
|
-
const v = axes.getV(point);
|
|
257
|
-
if (!Number.isFinite(u) || !Number.isFinite(v)) {
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
if (u < minU) minU = u;
|
|
261
|
-
if (u > maxU) maxU = u;
|
|
262
|
-
if (v < minV) minV = v;
|
|
263
|
-
if (v > maxV) maxV = v;
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
// Also include segments in bounding box calculation
|
|
268
|
-
perProduct.forEach((entry) => {
|
|
269
|
-
if (entry.segments && entry.segments.length) {
|
|
270
|
-
entry.segments.forEach((segment) => {
|
|
271
|
-
const [p1, p2] = segment;
|
|
272
|
-
const u1 = axes.getU(p1);
|
|
273
|
-
const v1 = axes.getV(p1);
|
|
274
|
-
const u2 = axes.getU(p2);
|
|
275
|
-
const v2 = axes.getV(p2);
|
|
276
|
-
if (Number.isFinite(u1) && Number.isFinite(v1)) {
|
|
277
|
-
if (u1 < minU) minU = u1;
|
|
278
|
-
if (u1 > maxU) maxU = u1;
|
|
279
|
-
if (v1 < minV) minV = v1;
|
|
280
|
-
if (v1 > maxV) maxV = v1;
|
|
281
|
-
}
|
|
282
|
-
if (Number.isFinite(u2) && Number.isFinite(v2)) {
|
|
283
|
-
if (u2 < minU) minU = u2;
|
|
284
|
-
if (u2 > maxU) maxU = u2;
|
|
285
|
-
if (v2 < minV) minV = v2;
|
|
286
|
-
if (v2 > maxV) maxV = v2;
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
if (!Number.isFinite(minU) || !Number.isFinite(maxU) || !Number.isFinite(minV) || !Number.isFinite(maxV)) {
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (maxU - minU < 1e-3) {
|
|
297
|
-
minU -= 0.5;
|
|
298
|
-
maxU += 0.5;
|
|
299
|
-
}
|
|
300
|
-
if (maxV - minV < 1e-3) {
|
|
301
|
-
minV -= 0.5;
|
|
302
|
-
maxV += 0.5;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const worldWidth = maxU - minU;
|
|
306
|
-
const worldHeight = maxV - minV;
|
|
307
|
-
const marginU = Math.max(worldWidth * SECTION_MARGIN_RATIO, 0.5);
|
|
308
|
-
const marginV = Math.max(worldHeight * SECTION_MARGIN_RATIO, 0.5);
|
|
309
|
-
|
|
310
|
-
viewMinX = minU - marginU;
|
|
311
|
-
viewMaxX = maxU + marginU;
|
|
312
|
-
viewMinZ = minV - marginV;
|
|
313
|
-
viewMaxZ = maxV + marginV;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const viewWidth = viewMaxX - viewMinX;
|
|
317
|
-
const viewHeight = viewMaxZ - viewMinZ;
|
|
318
|
-
|
|
319
|
-
if (!Number.isFinite(viewWidth) || !Number.isFinite(viewHeight) || viewWidth <= 1e-6 || viewHeight <= 1e-6) {
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const autoStrokeBase = Math.max(Math.min(viewWidth, viewHeight) * 0.008, 0.08);
|
|
324
|
-
const autoStroke = Math.max(autoStrokeBase * OUTLINE_THICKNESS_SCALE, 0.02);
|
|
325
|
-
const strokeWidth = Math.max(strokeWidthHint ?? autoStroke, 0.02);
|
|
326
|
-
const geometryTransform = buildGeometryTransform(viewMinX, viewMaxX, viewMinZ, viewMaxZ);
|
|
327
|
-
|
|
328
|
-
const includeMeta = !!options?.includeMeta;
|
|
329
|
-
const createFills = options?.createFills !== false; // Default to true for backward compatibility
|
|
330
|
-
|
|
331
|
-
const allPaths: SectionPath[] = [];
|
|
332
|
-
if (includeMeta) {
|
|
333
|
-
perProduct.forEach((entry, idx) => {
|
|
334
|
-
// Skip entries with neither polylines nor segments
|
|
335
|
-
if (!entry.polylines.length && (!entry.segments || !entry.segments.length)) {
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
const colorInfo = sampleProductColorRgba(entry.product);
|
|
339
|
-
const fillColor = colorInfo ? `rgb(${colorInfo.r},${colorInfo.g},${colorInfo.b})` : '#ffffff';
|
|
340
|
-
const fillOpacity = colorInfo ? clampValue(colorInfo.a, 0, 1) : 1;
|
|
341
|
-
const meta = extractProductMeta(entry.product);
|
|
342
|
-
|
|
343
|
-
const closedSubpaths: string[] = [];
|
|
344
|
-
const holeSubpaths: string[] = [];
|
|
345
|
-
const openSubpaths: string[] = [];
|
|
346
|
-
|
|
347
|
-
entry.polylines.forEach((polyline) => {
|
|
348
|
-
const pathData = getCachedPolylinePath(polyline, planeNormal);
|
|
349
|
-
if (!pathData) {
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
if (pathData.closed) {
|
|
353
|
-
if (pathData.hole) {
|
|
354
|
-
holeSubpaths.push(pathData.d);
|
|
355
|
-
} else {
|
|
356
|
-
closedSubpaths.push(pathData.d);
|
|
357
|
-
}
|
|
358
|
-
} else {
|
|
359
|
-
openSubpaths.push(pathData.d);
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
// Only create fill paths if createFills is true
|
|
364
|
-
if (createFills && (closedSubpaths.length || holeSubpaths.length)) {
|
|
365
|
-
const fillSubpaths = closedSubpaths.length ? closedSubpaths.concat(holeSubpaths) : holeSubpaths;
|
|
366
|
-
allPaths.push({
|
|
367
|
-
key: `fill-${idx}`,
|
|
368
|
-
d: fillSubpaths.join(' '),
|
|
369
|
-
fill: fillColor,
|
|
370
|
-
fillOpacity: clampValue(fillOpacity, 0, 1),
|
|
371
|
-
stroke: '#1f2937',
|
|
372
|
-
strokeWidth,
|
|
373
|
-
fillRule: 'evenodd',
|
|
374
|
-
meta: meta ? { ...meta, isFill: true } : undefined
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (openSubpaths.length) {
|
|
379
|
-
allPaths.push({
|
|
380
|
-
key: `outline-${idx}`,
|
|
381
|
-
d: openSubpaths.join(' '),
|
|
382
|
-
fill: 'none',
|
|
383
|
-
fillOpacity: 1,
|
|
384
|
-
stroke: '#1f2937',
|
|
385
|
-
strokeWidth,
|
|
386
|
-
meta: meta ? { ...meta, isOutline: true } : undefined
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Render segments (cut edges) as strokes
|
|
391
|
-
if (entry.segments && entry.segments.length) {
|
|
392
|
-
const segmentPaths: string[] = [];
|
|
393
|
-
for (const segment of entry.segments) {
|
|
394
|
-
const path = segmentToPath(segment, planeNormal);
|
|
395
|
-
if (path) segmentPaths.push(path);
|
|
396
|
-
}
|
|
397
|
-
if (segmentPaths.length) {
|
|
398
|
-
allPaths.push({
|
|
399
|
-
key: `segments-${idx}`,
|
|
400
|
-
d: segmentPaths.join(' '),
|
|
401
|
-
fill: 'none',
|
|
402
|
-
fillOpacity: 1,
|
|
403
|
-
stroke: '#1f2937',
|
|
404
|
-
strokeWidth,
|
|
405
|
-
meta: meta ? { ...meta, isSegment: true } : undefined
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
|
-
} else {
|
|
411
|
-
const fillGroups = new Map<string, FillGroup>();
|
|
412
|
-
const openSubpaths: string[] = [];
|
|
413
|
-
const allSegmentPaths: string[] = [];
|
|
414
|
-
|
|
415
|
-
perProduct.forEach((entry) => {
|
|
416
|
-
// Process segments first (always render, even without polylines)
|
|
417
|
-
if (entry.segments && entry.segments.length) {
|
|
418
|
-
for (const segment of entry.segments) {
|
|
419
|
-
const path = segmentToPath(segment, planeNormal);
|
|
420
|
-
if (path) allSegmentPaths.push(path);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
if (!entry.polylines.length) {
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const colorInfo = sampleProductColorRgba(entry.product);
|
|
429
|
-
const fillColor = colorInfo ? `rgb(${colorInfo.r},${colorInfo.g},${colorInfo.b})` : '#ffffff';
|
|
430
|
-
const fillOpacity = colorInfo ? clampValue(colorInfo.a, 0, 1) : 1;
|
|
431
|
-
const groupKey = `${fillColor}|${Math.round(fillOpacity * 1000)}`;
|
|
432
|
-
let group = fillGroups.get(groupKey);
|
|
433
|
-
if (!group) {
|
|
434
|
-
group = {
|
|
435
|
-
color: fillColor,
|
|
436
|
-
opacity: fillOpacity,
|
|
437
|
-
closed: [],
|
|
438
|
-
holes: []
|
|
439
|
-
};
|
|
440
|
-
fillGroups.set(groupKey, group);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
entry.polylines.forEach((polyline) => {
|
|
444
|
-
const pathData = getCachedPolylinePath(polyline, planeNormal);
|
|
445
|
-
if (!pathData) {
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
if (pathData.closed) {
|
|
449
|
-
if (pathData.hole) {
|
|
450
|
-
group!.holes.push(pathData.d);
|
|
451
|
-
} else {
|
|
452
|
-
group!.closed.push(pathData.d);
|
|
453
|
-
}
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
openSubpaths.push(pathData.d);
|
|
457
|
-
});
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
let groupIndex = 0;
|
|
461
|
-
fillGroups.forEach((group) => {
|
|
462
|
-
const fillSubpaths = group.closed.length ? group.closed.concat(group.holes) : group.holes;
|
|
463
|
-
if (!fillSubpaths.length) {
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
allPaths.push({
|
|
467
|
-
key: `fill-${groupIndex++}`,
|
|
468
|
-
d: fillSubpaths.join(' '),
|
|
469
|
-
fill: group.color,
|
|
470
|
-
fillOpacity: clampValue(group.opacity, 0, 1),
|
|
471
|
-
stroke: '#1f2937',
|
|
472
|
-
strokeWidth,
|
|
473
|
-
fillRule: 'evenodd'
|
|
474
|
-
});
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
if (openSubpaths.length) {
|
|
478
|
-
allPaths.push({
|
|
479
|
-
key: 'outlines',
|
|
480
|
-
d: openSubpaths.join(' '),
|
|
481
|
-
fill: 'none',
|
|
482
|
-
fillOpacity: 1,
|
|
483
|
-
stroke: '#1f2937',
|
|
484
|
-
strokeWidth
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Render all segments (collected above in forEach loop)
|
|
489
|
-
if (allSegmentPaths.length) {
|
|
490
|
-
allPaths.push({
|
|
491
|
-
key: 'segments',
|
|
492
|
-
d: allSegmentPaths.join(' '),
|
|
493
|
-
fill: 'none',
|
|
494
|
-
fillOpacity: 1,
|
|
495
|
-
stroke: '#1f2937',
|
|
496
|
-
strokeWidth
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (!allPaths.length) {
|
|
502
|
-
return null;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const displayWidth = BASE_WIDTH;
|
|
506
|
-
const displayHeight = Math.max(MIN_HEIGHT, (viewHeight / viewWidth) * BASE_WIDTH);
|
|
507
|
-
|
|
508
|
-
return {
|
|
509
|
-
paths: allPaths,
|
|
510
|
-
transform: {
|
|
511
|
-
viewMinX,
|
|
512
|
-
viewMaxX,
|
|
513
|
-
viewMinZ,
|
|
514
|
-
viewMaxZ,
|
|
515
|
-
viewWidth,
|
|
516
|
-
viewHeight,
|
|
517
|
-
displayWidth,
|
|
518
|
-
displayHeight
|
|
519
|
-
},
|
|
520
|
-
strokeWidth,
|
|
521
|
-
geometryTransform,
|
|
522
|
-
labels: labels?.length
|
|
523
|
-
? labels
|
|
524
|
-
.filter((label) => {
|
|
525
|
-
if (!Number.isFinite(label.x) || !Number.isFinite(label.z)) {
|
|
526
|
-
return false;
|
|
527
|
-
}
|
|
528
|
-
return label.x >= viewMinX && label.x <= viewMaxX && label.z >= viewMinZ && label.z <= viewMaxZ;
|
|
529
|
-
})
|
|
530
|
-
.map((label) => ({
|
|
531
|
-
key: label.key,
|
|
532
|
-
text: label.text,
|
|
533
|
-
x: label.x,
|
|
534
|
-
y: label.y,
|
|
535
|
-
z: label.z,
|
|
536
|
-
fontSize: label.fontSize,
|
|
537
|
-
meta: includeMeta ? (label.meta ?? {}) : undefined
|
|
538
|
-
}))
|
|
539
|
-
: []
|
|
540
|
-
};
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* Build a complete SectionRenderState from plane section entries, with optional
|
|
545
|
-
* fill-rule-based separation, ordering, and label generation.
|
|
546
|
-
*
|
|
547
|
-
* When `fills` rules are provided, entries matching a fill rule are rendered
|
|
548
|
-
* below non-matching entries, with their fill opacity set by the matching rule.
|
|
549
|
-
*/
|
|
550
|
-
export function buildSectionStateForEntries(
|
|
551
|
-
perProduct: PlaneSectionEntry[],
|
|
552
|
-
options: BuildSectionStateOptions
|
|
553
|
-
): (SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] }) | null {
|
|
554
|
-
if (!perProduct.length) {
|
|
555
|
-
return null;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
const fills = options.fills;
|
|
559
|
-
const includeMeta = !!options?.includeMeta;
|
|
560
|
-
const includeLabels = options?.includeLabels ?? false;
|
|
561
|
-
const viewport = options?.viewport;
|
|
562
|
-
const strokeWidth = options?.strokeWidth;
|
|
563
|
-
const labelFontSize = includeLabels && typeof options?.labelFontSize === 'number' ? options.labelFontSize : null;
|
|
564
|
-
const buildLabels = options?.buildLabels;
|
|
565
|
-
|
|
566
|
-
// Point-in-polygon test (ray casting algorithm)
|
|
567
|
-
const pointInPolygon = (polygon: Array<{ x: number; z: number }>, x: number, z: number): boolean => {
|
|
568
|
-
let inside = false;
|
|
569
|
-
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
570
|
-
const xi = polygon[i].x;
|
|
571
|
-
const zi = polygon[i].z;
|
|
572
|
-
const xj = polygon[j].x;
|
|
573
|
-
const zj = polygon[j].z;
|
|
574
|
-
const intersect = zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi;
|
|
575
|
-
if (intersect) inside = !inside;
|
|
576
|
-
}
|
|
577
|
-
return inside;
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
// Calculate centroid from entry's polylines
|
|
581
|
-
const getCentroid = (entry: PlaneSectionEntry): { x: number; z: number } | null => {
|
|
582
|
-
const polylines = entry.polylines;
|
|
583
|
-
if (!polylines.length) return null;
|
|
584
|
-
let sumX = 0;
|
|
585
|
-
let sumZ = 0;
|
|
586
|
-
let count = 0;
|
|
587
|
-
for (const polyline of polylines) {
|
|
588
|
-
for (const point of polyline.points) {
|
|
589
|
-
sumX += point[0];
|
|
590
|
-
sumZ += point[2];
|
|
591
|
-
count++;
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
if (count === 0) return null;
|
|
595
|
-
return { x: sumX / count, z: sumZ / count };
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
// Spatial matcher for clip filter - checks if product centroid is inside clip polygon
|
|
599
|
-
const isInsideClipPolygon = (entry: PlaneSectionEntry): boolean => {
|
|
600
|
-
if (!options?.clipPolygon?.length) return true;
|
|
601
|
-
const centroid = getCentroid(entry);
|
|
602
|
-
if (!centroid) return false;
|
|
603
|
-
// Check if centroid is inside any of the clip polygons
|
|
604
|
-
return options.clipPolygon.some((polygon) => pointInPolygon(polygon, centroid.x, centroid.z));
|
|
605
|
-
};
|
|
606
|
-
|
|
607
|
-
// Match an entry against fill rules. Returns the first matching rule, or undefined.
|
|
608
|
-
const matchFillRule = (entry: PlaneSectionEntry): SectionFillRule | undefined => {
|
|
609
|
-
if (!fills?.length) return undefined;
|
|
610
|
-
const product = entry.product;
|
|
611
|
-
for (const rule of fills) {
|
|
612
|
-
// If rule requires clip filter match, check if product centroid is inside clip polygon
|
|
613
|
-
if (rule.matchClipFilter && !isInsideClipPolygon(entry)) {
|
|
614
|
-
continue;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// Check class match
|
|
618
|
-
let classMatches: boolean;
|
|
619
|
-
if (rule.class) {
|
|
620
|
-
const classes = Array.isArray(rule.class) ? rule.class : [rule.class];
|
|
621
|
-
const cls = product.class;
|
|
622
|
-
classMatches = classes.some((c) => {
|
|
623
|
-
if (cls?.is) return cls.is(c as string);
|
|
624
|
-
const pName = (product.className ?? '').toLowerCase();
|
|
625
|
-
return pName === (c as string).toLowerCase() || pName === `ifc${(c as string).toLowerCase()}`;
|
|
626
|
-
});
|
|
627
|
-
if (!classMatches) continue;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// Check where condition (property-based filter)
|
|
631
|
-
if (rule.where && Object.keys(rule.where).length > 0) {
|
|
632
|
-
let whereMatches = true;
|
|
633
|
-
for (const [path, expected] of Object.entries(rule.where)) {
|
|
634
|
-
const actualValue = resolveProductProperty(product, path);
|
|
635
|
-
if (actualValue === undefined) {
|
|
636
|
-
whereMatches = false;
|
|
637
|
-
break;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const normalize = (v: unknown): unknown => (typeof v === 'string' ? v.toLowerCase().trim() : v);
|
|
641
|
-
|
|
642
|
-
if (Array.isArray(expected)) {
|
|
643
|
-
if (!expected.some((exp) => normalize(exp) === normalize(actualValue))) {
|
|
644
|
-
whereMatches = false;
|
|
645
|
-
break;
|
|
646
|
-
}
|
|
647
|
-
} else {
|
|
648
|
-
if (normalize(actualValue) !== normalize(expected)) {
|
|
649
|
-
whereMatches = false;
|
|
650
|
-
break;
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
if (!whereMatches) continue;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
return rule;
|
|
658
|
-
}
|
|
659
|
-
return undefined;
|
|
660
|
-
};
|
|
661
|
-
|
|
662
|
-
const hasFills = !!fills?.length;
|
|
663
|
-
|
|
664
|
-
// Split entries by fill rule match
|
|
665
|
-
const fillEntries: PlaneSectionEntry[] = [];
|
|
666
|
-
const fillOpacities: number[] = []; // parallel to fillEntries
|
|
667
|
-
const nonFillEntries: PlaneSectionEntry[] = [];
|
|
668
|
-
|
|
669
|
-
for (const entry of perProduct) {
|
|
670
|
-
const rule = hasFills ? matchFillRule(entry) : undefined;
|
|
671
|
-
if (rule) {
|
|
672
|
-
const opacity = rule.fillOpacity ?? DEFAULT_FILL_OPACITY;
|
|
673
|
-
// Skip entries with zero opacity - don't render them at all
|
|
674
|
-
if (opacity > 0) {
|
|
675
|
-
fillEntries.push(entry);
|
|
676
|
-
fillOpacities.push(opacity);
|
|
677
|
-
}
|
|
678
|
-
} else {
|
|
679
|
-
nonFillEntries.push(entry);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// Build labels from fill-matched entries (e.g. spaces)
|
|
684
|
-
const fillLabels =
|
|
685
|
-
includeLabels && fillEntries.length && labelFontSize !== null && buildLabels
|
|
686
|
-
? (buildLabels(
|
|
687
|
-
fillEntries.map((entry) => entry.product),
|
|
688
|
-
labelFontSize
|
|
689
|
-
) as SectionLabel[])
|
|
690
|
-
: undefined;
|
|
691
|
-
|
|
692
|
-
// When fill rules are present, render fill and non-fill groups separately
|
|
693
|
-
// so we can adjust opacity and control ordering.
|
|
694
|
-
// When no fill rules, render everything in one pass (unchanged behavior).
|
|
695
|
-
if (hasFills && (fillEntries.length > 0 || nonFillEntries.length > 0)) {
|
|
696
|
-
const nonFillState =
|
|
697
|
-
nonFillEntries.length > 0
|
|
698
|
-
? createSectionSvg(nonFillEntries, viewport, strokeWidth, undefined, {
|
|
699
|
-
includeMeta,
|
|
700
|
-
planeNormal: options.planeNormal
|
|
701
|
-
})
|
|
702
|
-
: null;
|
|
703
|
-
const fillState =
|
|
704
|
-
fillEntries.length > 0
|
|
705
|
-
? createSectionSvg(fillEntries, viewport, strokeWidth, includeLabels ? fillLabels : undefined, {
|
|
706
|
-
includeMeta,
|
|
707
|
-
planeNormal: options.planeNormal
|
|
708
|
-
})
|
|
709
|
-
: null;
|
|
710
|
-
|
|
711
|
-
const baseState = nonFillState ?? fillState ?? null;
|
|
712
|
-
if (!baseState) return null;
|
|
713
|
-
|
|
714
|
-
// Adjust fill opacity per-entry. When includeMeta is true, each entry has
|
|
715
|
-
// its own path, so we can apply per-entry opacity. When false, paths are
|
|
716
|
-
// grouped by color — apply the minimum matching opacity.
|
|
717
|
-
const adjustedFillPaths =
|
|
718
|
-
fillState?.paths.map((path, idx) => {
|
|
719
|
-
// When fill rules match, use the rule opacity directly (don't multiply by material alpha).
|
|
720
|
-
// Material alpha was useful for auto-coloring, but fill rules should have full control.
|
|
721
|
-
const ruleOpacity = idx < fillOpacities.length ? fillOpacities[idx] : (fills![0]?.fillOpacity ?? 0.35);
|
|
722
|
-
const finalOpacity = clampValue(ruleOpacity, 0, 1);
|
|
723
|
-
return {
|
|
724
|
-
...path,
|
|
725
|
-
fillOpacity: finalOpacity
|
|
726
|
-
};
|
|
727
|
-
}) ?? [];
|
|
728
|
-
|
|
729
|
-
const nonFillPaths = nonFillState?.paths ?? [];
|
|
730
|
-
// Fill-matched entries always render below non-fill entries
|
|
731
|
-
const mergedPaths = [...adjustedFillPaths, ...nonFillPaths];
|
|
732
|
-
|
|
733
|
-
const merged: SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] } = {
|
|
734
|
-
...baseState,
|
|
735
|
-
paths: mergedPaths,
|
|
736
|
-
labels: includeLabels ? (fillState?.labels ?? fillLabels ?? []) : []
|
|
737
|
-
};
|
|
738
|
-
|
|
739
|
-
return merged;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
// No fill rules — create paths with fills for highlight support, but set opacity to 0
|
|
743
|
-
const state = createSectionSvg(perProduct, viewport, strokeWidth, includeLabels ? fillLabels : undefined, {
|
|
744
|
-
includeMeta,
|
|
745
|
-
createFills: true,
|
|
746
|
-
planeNormal: options.planeNormal
|
|
747
|
-
});
|
|
748
|
-
if (!state) return null;
|
|
749
|
-
|
|
750
|
-
// Set all fill opacities to 0 when no fill rules exist (but keep paths for highlight support)
|
|
751
|
-
const pathsWithZeroFillOpacity = state.paths.map((path) => ({
|
|
752
|
-
...path,
|
|
753
|
-
fillOpacity: 0
|
|
754
|
-
}));
|
|
755
|
-
|
|
756
|
-
return {
|
|
757
|
-
...state,
|
|
758
|
-
paths: pathsWithZeroFillOpacity,
|
|
759
|
-
labels: includeLabels ? (state.labels ?? []) : []
|
|
760
|
-
};
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/** Extract the viewport bounding box from a TransformInfo. */
|
|
764
|
-
export function extractViewWindow(transform: TransformInfo): ViewWindow {
|
|
765
|
-
return {
|
|
766
|
-
viewMinX: transform.viewMinX,
|
|
767
|
-
viewMaxX: transform.viewMaxX,
|
|
768
|
-
viewMinZ: transform.viewMinZ,
|
|
769
|
-
viewMaxZ: transform.viewMaxZ
|
|
770
|
-
};
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/** Compare two viewport windows for equality within a tolerance. */
|
|
774
|
-
export function windowsEqual(a: ViewWindow, b: ViewWindow, epsilon = 1e-4): boolean {
|
|
775
|
-
return (
|
|
776
|
-
Math.abs(a.viewMinX - b.viewMinX) <= epsilon &&
|
|
777
|
-
Math.abs(a.viewMaxX - b.viewMaxX) <= epsilon &&
|
|
778
|
-
Math.abs(a.viewMinZ - b.viewMinZ) <= epsilon &&
|
|
779
|
-
Math.abs(a.viewMaxZ - b.viewMaxZ) <= epsilon
|
|
780
|
-
);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
/** Constrain a viewport window within base bounds, preserving aspect ratio. */
|
|
784
|
-
export function clampViewWindow(base: TransformInfo, candidate: ViewWindow): ViewWindow {
|
|
785
|
-
const baseWidth = base.viewWidth;
|
|
786
|
-
const baseHeight = base.viewHeight;
|
|
787
|
-
const aspect = baseWidth / baseHeight;
|
|
788
|
-
|
|
789
|
-
let width = candidate.viewMaxX - candidate.viewMinX;
|
|
790
|
-
|
|
791
|
-
if (!Number.isFinite(width) || width <= 1e-6) {
|
|
792
|
-
width = baseWidth;
|
|
793
|
-
}
|
|
794
|
-
width = clampValue(width, baseWidth / MAX_ZOOM_FACTOR, baseWidth);
|
|
795
|
-
const height = width / aspect;
|
|
796
|
-
|
|
797
|
-
let centerX = (candidate.viewMinX + candidate.viewMaxX) * 0.5;
|
|
798
|
-
if (!Number.isFinite(centerX)) {
|
|
799
|
-
centerX = (base.viewMinX + base.viewMaxX) * 0.5;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
let centerZ = (candidate.viewMinZ + candidate.viewMaxZ) * 0.5;
|
|
803
|
-
if (!Number.isFinite(centerZ)) {
|
|
804
|
-
centerZ = (base.viewMinZ + base.viewMaxZ) * 0.5;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
let minX = centerX - width * 0.5;
|
|
808
|
-
let maxX = minX + width;
|
|
809
|
-
if (minX < base.viewMinX) {
|
|
810
|
-
minX = base.viewMinX;
|
|
811
|
-
maxX = minX + width;
|
|
812
|
-
}
|
|
813
|
-
if (maxX > base.viewMaxX) {
|
|
814
|
-
maxX = base.viewMaxX;
|
|
815
|
-
minX = maxX - width;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
let minZ = centerZ - height * 0.5;
|
|
819
|
-
let maxZ = minZ + height;
|
|
820
|
-
if (minZ < base.viewMinZ) {
|
|
821
|
-
minZ = base.viewMinZ;
|
|
822
|
-
maxZ = minZ + height;
|
|
823
|
-
}
|
|
824
|
-
if (maxZ > base.viewMaxZ) {
|
|
825
|
-
maxZ = base.viewMaxZ;
|
|
826
|
-
minZ = maxZ - height;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
return {
|
|
830
|
-
viewMinX: minX,
|
|
831
|
-
viewMaxX: maxX,
|
|
832
|
-
viewMinZ: minZ,
|
|
833
|
-
viewMaxZ: maxZ
|
|
834
|
-
};
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
/** Adjust stroke width proportionally to zoom level. */
|
|
838
|
-
export function strokeWidthForWindow(baseStroke: number, baseTransform: TransformInfo, window: ViewWindow): number {
|
|
839
|
-
const baseWidth = baseTransform.viewWidth;
|
|
840
|
-
const currentWidth = window.viewMaxX - window.viewMinX;
|
|
841
|
-
if (!Number.isFinite(baseWidth) || baseWidth <= 1e-6 || !Number.isFinite(currentWidth) || currentWidth <= 1e-6) {
|
|
842
|
-
return baseStroke;
|
|
843
|
-
}
|
|
844
|
-
const ratio = clampValue(currentWidth / baseWidth, 1 / MAX_ZOOM_FACTOR, 1);
|
|
845
|
-
const scaled = baseStroke * ratio;
|
|
846
|
-
return Math.max(scaled, baseStroke / (MAX_ZOOM_FACTOR + 1));
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
interface SvgPathData {
|
|
850
|
-
d: string;
|
|
851
|
-
closed: boolean;
|
|
852
|
-
hole: boolean;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
interface FillGroup {
|
|
856
|
-
color: string;
|
|
857
|
-
opacity: number;
|
|
858
|
-
closed: string[];
|
|
859
|
-
holes: string[];
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
interface CachedPolylinePath {
|
|
863
|
-
pointsRef: Vec3[];
|
|
864
|
-
signature: number;
|
|
865
|
-
data: SvgPathData;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
const polylinePathCache = new WeakMap<{ points: Vec3[]; closed: boolean }, CachedPolylinePath>();
|
|
869
|
-
|
|
870
|
-
/**
|
|
871
|
-
* Determine which axes to use for 2D projection based on plane normal.
|
|
872
|
-
* Returns accessor functions for horizontal and vertical SVG coordinates.
|
|
873
|
-
*/
|
|
874
|
-
function getProjectionAxes(planeNormal?: Vec3): {
|
|
875
|
-
getU: (p: Vec3) => number;
|
|
876
|
-
getV: (p: Vec3) => number;
|
|
877
|
-
axisNames: string;
|
|
878
|
-
} {
|
|
879
|
-
if (!planeNormal) {
|
|
880
|
-
// Default: horizontal plan (looking down Y axis) -> X,Z
|
|
881
|
-
return {
|
|
882
|
-
getU: (p) => p[0],
|
|
883
|
-
getV: (p) => p[2],
|
|
884
|
-
axisNames: 'XZ'
|
|
885
|
-
};
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// Normalize and find dominant axis
|
|
889
|
-
const absX = Math.abs(planeNormal[0]);
|
|
890
|
-
const absY = Math.abs(planeNormal[1]);
|
|
891
|
-
const absZ = Math.abs(planeNormal[2]);
|
|
892
|
-
|
|
893
|
-
if (absY > absX && absY > absZ) {
|
|
894
|
-
// Horizontal plan (normal ~ Y axis) -> looking down Y, use X,Z
|
|
895
|
-
return {
|
|
896
|
-
getU: (p) => p[0],
|
|
897
|
-
getV: (p) => p[2],
|
|
898
|
-
axisNames: 'XZ'
|
|
899
|
-
};
|
|
900
|
-
} else if (absZ > absX) {
|
|
901
|
-
// Vertical section perpendicular to Z -> looking along Z, use X,Y
|
|
902
|
-
return {
|
|
903
|
-
getU: (p) => p[0],
|
|
904
|
-
getV: (p) => p[1],
|
|
905
|
-
axisNames: 'XY'
|
|
906
|
-
};
|
|
907
|
-
} else {
|
|
908
|
-
// Vertical section perpendicular to X -> looking along X, use Z,Y
|
|
909
|
-
return {
|
|
910
|
-
getU: (p) => p[2],
|
|
911
|
-
getV: (p) => p[1],
|
|
912
|
-
axisNames: 'ZY'
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
/**
|
|
918
|
-
* Convert a segment (pair of points) to an SVG path string.
|
|
919
|
-
*/
|
|
920
|
-
function segmentToPath(segment: [Vec3, Vec3], planeNormal?: Vec3): string {
|
|
921
|
-
const [p1, p2] = segment;
|
|
922
|
-
const axes = getProjectionAxes(planeNormal);
|
|
923
|
-
const u1 = axes.getU(p1);
|
|
924
|
-
const v1 = axes.getV(p1);
|
|
925
|
-
const u2 = axes.getU(p2);
|
|
926
|
-
const v2 = axes.getV(p2);
|
|
927
|
-
|
|
928
|
-
if (!Number.isFinite(u1) || !Number.isFinite(v1) || !Number.isFinite(u2) || !Number.isFinite(v2)) {
|
|
929
|
-
return '';
|
|
930
|
-
}
|
|
931
|
-
return `M${formatNumber(u1)} ${formatNumber(v1)} L${formatNumber(u2)} ${formatNumber(v2)}`;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
function getCachedPolylinePath(polyline: { points: Vec3[]; closed: boolean }, planeNormal?: Vec3): SvgPathData | null {
|
|
935
|
-
if (!polyline.points.length) {
|
|
936
|
-
return null;
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
const signature = computePointsSignature(polyline.points, polyline.closed, planeNormal);
|
|
940
|
-
const cached = polylinePathCache.get(polyline);
|
|
941
|
-
if (cached && cached.pointsRef === polyline.points && cached.signature === signature) {
|
|
942
|
-
return cached.data;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
const axes = getProjectionAxes(planeNormal);
|
|
946
|
-
const commands: string[] = [];
|
|
947
|
-
polyline.points.forEach((point: Vec3, index: number) => {
|
|
948
|
-
const prefix = index === 0 ? 'M' : 'L';
|
|
949
|
-
const u = axes.getU(point);
|
|
950
|
-
const v = axes.getV(point);
|
|
951
|
-
commands.push(`${prefix}${formatNumber(u)} ${formatNumber(v)}`);
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
if (commands.length < 2) {
|
|
955
|
-
return null;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
let closed = false;
|
|
959
|
-
let hole = false;
|
|
960
|
-
|
|
961
|
-
if (polyline.closed && commands.length > 2) {
|
|
962
|
-
const area = signedArea(polyline.points, axes);
|
|
963
|
-
hole = area < 0;
|
|
964
|
-
closed = true;
|
|
965
|
-
commands.push('Z');
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
const data: SvgPathData = { d: commands.join(' '), closed, hole };
|
|
969
|
-
polylinePathCache.set(polyline, { pointsRef: polyline.points, signature, data });
|
|
970
|
-
return data;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
function signedArea(points: Vec3[], axes: { getU: (p: Vec3) => number; getV: (p: Vec3) => number }): number {
|
|
974
|
-
if (points.length < 3) {
|
|
975
|
-
return 0;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
let area = 0;
|
|
979
|
-
for (let i = 0; i < points.length; i++) {
|
|
980
|
-
const current = points[i];
|
|
981
|
-
const next = points[(i + 1) % points.length];
|
|
982
|
-
const u1 = axes.getU(current);
|
|
983
|
-
const v1 = axes.getV(current);
|
|
984
|
-
const u2 = axes.getU(next);
|
|
985
|
-
const v2 = axes.getV(next);
|
|
986
|
-
area += u1 * v2 - u2 * v1;
|
|
987
|
-
}
|
|
988
|
-
return area * 0.5;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
function formatNumber(value: number): string {
|
|
992
|
-
if (!Number.isFinite(value)) {
|
|
993
|
-
return '0';
|
|
994
|
-
}
|
|
995
|
-
const formatted = value.toFixed(3);
|
|
996
|
-
return formatted.replace(/\.?0+$/, '');
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
function buildGeometryTransform(viewMinX: number, viewMaxX: number, viewMinZ: number, viewMaxZ: number): string {
|
|
1000
|
-
const translateX = formatNumber(-viewMinX);
|
|
1001
|
-
const translateY = formatNumber(viewMaxZ);
|
|
1002
|
-
return `matrix(1 0 0 -1 ${translateX} ${translateY})`;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
function computePointsSignature(points: readonly Vec3[], closed: boolean, planeNormal?: Vec3): number {
|
|
1006
|
-
const axes = getProjectionAxes(planeNormal);
|
|
1007
|
-
// FNV-1a-inspired hash — compact fixed-size key instead of concatenating all coordinates
|
|
1008
|
-
let hash = 2166136261 ^ (points.length * 16777619) ^ (closed ? 1 : 0);
|
|
1009
|
-
for (let i = 0; i < points.length; i++) {
|
|
1010
|
-
const point = points[i];
|
|
1011
|
-
if (!point) continue;
|
|
1012
|
-
const u = axes.getU(point);
|
|
1013
|
-
const v = axes.getV(point);
|
|
1014
|
-
// Mix in coordinate bits (multiply by large prime, XOR)
|
|
1015
|
-
hash = Math.imul(hash ^ ((u * 1e4) | 0), 16777619);
|
|
1016
|
-
hash = Math.imul(hash ^ ((v * 1e4) | 0), 16777619);
|
|
1017
|
-
}
|
|
1018
|
-
return hash;
|
|
1019
|
-
}
|
|
1
|
+
import type {
|
|
2
|
+
Vec3,
|
|
3
|
+
SectionLabel,
|
|
4
|
+
SectionPath,
|
|
5
|
+
SectionRenderState,
|
|
6
|
+
TransformInfo,
|
|
7
|
+
ViewWindow
|
|
8
|
+
} from '@twinfinity/geometry';
|
|
9
|
+
|
|
10
|
+
export type { SectionLabel, SectionPath, SectionRenderState, TransformInfo, ViewWindow } from '@twinfinity/geometry';
|
|
11
|
+
|
|
12
|
+
/** Minimal product interface for section fill matching. */
|
|
13
|
+
interface SectionProduct {
|
|
14
|
+
class?: { is(c: unknown): boolean; type?: string; id?: string };
|
|
15
|
+
className?: string;
|
|
16
|
+
propertySets?: Record<string, unknown>;
|
|
17
|
+
properties?: { data?: Record<string, unknown>; sets?: Record<string, unknown> };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Local replacement for PlaneSectionEntry (not available in @twinfinity/geometry). */
|
|
21
|
+
type PlaneSectionEntry<TProduct extends SectionProduct = SectionProduct> = {
|
|
22
|
+
product: TProduct;
|
|
23
|
+
segments: [Vec3, Vec3][];
|
|
24
|
+
polylines: { points: Vec3[]; closed: boolean }[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a dotted property path (e.g. "BIP.spacetype") against a product.
|
|
29
|
+
* Handles both the new T8 API (propertySets → .properties → .value) and
|
|
30
|
+
* legacy BimIfcObject (properties.sets / properties.data).
|
|
31
|
+
*
|
|
32
|
+
* Returns the resolved value, or undefined if not found.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveProductProperty(product: SectionProduct | unknown, path: string): unknown {
|
|
35
|
+
const p = product as SectionProduct;
|
|
36
|
+
const parts = path.split('.');
|
|
37
|
+
if (parts.length < 2) return undefined;
|
|
38
|
+
|
|
39
|
+
const [setName, ...propParts] = parts;
|
|
40
|
+
const propName = propParts.join('.');
|
|
41
|
+
|
|
42
|
+
// New T8 API: propertySets['BIP'].properties['spacetype'].value
|
|
43
|
+
const pset = (p?.propertySets as Record<string, any>)?.[setName];
|
|
44
|
+
if (pset) {
|
|
45
|
+
const prop = pset.properties?.[propName];
|
|
46
|
+
if (prop !== undefined) {
|
|
47
|
+
return prop?.value !== undefined ? prop.value : prop;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Legacy: properties.sets['BIP']['spacetype']
|
|
52
|
+
const legacyPset = (p?.properties?.sets as Record<string, any>)?.[setName];
|
|
53
|
+
if (legacyPset) {
|
|
54
|
+
const val = legacyPset[propName];
|
|
55
|
+
if (val !== undefined) {
|
|
56
|
+
return val?.value !== undefined ? val.value : val;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fallback: properties.data['BIP']['spacetype'] (old printing code path)
|
|
61
|
+
let value: any = p?.properties?.data;
|
|
62
|
+
if (value) {
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
value = value?.[part];
|
|
65
|
+
if (value === undefined) break;
|
|
66
|
+
}
|
|
67
|
+
if (value !== undefined) {
|
|
68
|
+
return value?.value !== undefined ? value.value : value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sample the actual rendered color of a product's first mesh.
|
|
77
|
+
* Uses BimProductMesh.copyColorTo/getColorComponent for accurate colors
|
|
78
|
+
* (includes overrides, highlights). Falls back to style.surfaceColor/color.
|
|
79
|
+
*/
|
|
80
|
+
const sampleProductColorRgba = (product: unknown): { r: number; g: number; b: number; a: number } | undefined => {
|
|
81
|
+
/** Duck-typed mesh with optional color-access methods. */
|
|
82
|
+
interface DuckMesh {
|
|
83
|
+
copyColorTo?: (out: Uint8Array, offset: number) => void;
|
|
84
|
+
getColorComponent?: (index: number) => number;
|
|
85
|
+
style?: { surfaceColor?: readonly number[]; color?: readonly number[] };
|
|
86
|
+
}
|
|
87
|
+
const p = product as Record<string, unknown>;
|
|
88
|
+
const meshes = (p?.meshes ?? p?.productMeshes) as DuckMesh[] | undefined;
|
|
89
|
+
if (!meshes || !meshes.length) return undefined;
|
|
90
|
+
|
|
91
|
+
const rgba = new Uint8Array(4);
|
|
92
|
+
|
|
93
|
+
for (const mesh of meshes) {
|
|
94
|
+
try {
|
|
95
|
+
// BimProductMesh: read actual rendered color via copyColorTo or getColorComponent
|
|
96
|
+
if (typeof mesh.copyColorTo === 'function') {
|
|
97
|
+
mesh.copyColorTo(rgba, 0);
|
|
98
|
+
} else if (typeof mesh.getColorComponent === 'function') {
|
|
99
|
+
rgba[0] = mesh.getColorComponent(0); // Red
|
|
100
|
+
rgba[1] = mesh.getColorComponent(1); // Green
|
|
101
|
+
rgba[2] = mesh.getColorComponent(2); // Blue
|
|
102
|
+
rgba[3] = mesh.getColorComponent(3); // Alpha
|
|
103
|
+
} else {
|
|
104
|
+
// ModelMesh fallback: read from style
|
|
105
|
+
const style = mesh.style;
|
|
106
|
+
const c = style?.surfaceColor ?? style?.color;
|
|
107
|
+
if (c) return { r: c[0], g: c[1], b: c[2], a: (c[3] ?? 255) / 255 };
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [r, g, b, a] = rgba;
|
|
115
|
+
if (a === 0 && r === 0 && g === 0 && b === 0) continue;
|
|
116
|
+
return { r, g, b, a: a / 255 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return undefined;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Base SVG display width in layout units. */
|
|
123
|
+
export const BASE_WIDTH = 240;
|
|
124
|
+
/** Minimum SVG display height in layout units. */
|
|
125
|
+
export const MIN_HEIGHT = 140;
|
|
126
|
+
/** Margin ratio applied around section bounds (fraction of world extent). */
|
|
127
|
+
export const SECTION_MARGIN_RATIO = 0.05;
|
|
128
|
+
/** Maximum zoom factor for viewport clamping. */
|
|
129
|
+
export const MAX_ZOOM_FACTOR = 12;
|
|
130
|
+
/** Multiplicative step for zooming in. */
|
|
131
|
+
export const ZOOM_IN_STEP = 0.8;
|
|
132
|
+
/** Multiplicative step for zooming out. */
|
|
133
|
+
export const ZOOM_OUT_STEP = 1 / ZOOM_IN_STEP;
|
|
134
|
+
const OUTLINE_THICKNESS_SCALE = 0.5;
|
|
135
|
+
|
|
136
|
+
/** Clamp a number to a min/max range. */
|
|
137
|
+
export function clampValue(value: number, min: number, max: number): number {
|
|
138
|
+
return Math.min(max, Math.max(min, value));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface SectionBuildOptions {
|
|
142
|
+
/** If true, attach source product metadata to paths/labels for downstream filtering */
|
|
143
|
+
includeMeta?: boolean;
|
|
144
|
+
/** When true, create fill paths for closed polygons. When false, create only stroke/outline paths. Defaults to true for backward compatibility. */
|
|
145
|
+
createFills?: boolean;
|
|
146
|
+
/** Plane normal vector for determining 2D projection axes. If omitted, defaults to horizontal plan (Y-up). */
|
|
147
|
+
planeNormal?: Vec3;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract product metadata for path/label attribution.
|
|
152
|
+
* Casts to `any` once here to avoid scattering `as any` across the codebase.
|
|
153
|
+
* BimIfcObject's runtime shape exposes these fields but the public typings
|
|
154
|
+
* don't always surface them.
|
|
155
|
+
*/
|
|
156
|
+
function extractProductMeta(product: unknown): {
|
|
157
|
+
productId: string;
|
|
158
|
+
ifcClass: string | null;
|
|
159
|
+
ifcClassId: string | null;
|
|
160
|
+
properties: unknown;
|
|
161
|
+
} {
|
|
162
|
+
const p = product as Record<string, any>;
|
|
163
|
+
return {
|
|
164
|
+
productId: p?.gid ?? p?.id ?? String(product),
|
|
165
|
+
ifcClass: p?.class?.type ?? null,
|
|
166
|
+
ifcClassId: p?.class?.id ?? null,
|
|
167
|
+
properties: p?.properties?.data ?? p?.properties ?? null
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Default fill opacity when a fill rule omits `fillOpacity`. */
|
|
172
|
+
export const DEFAULT_FILL_OPACITY = 0.35;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Fill rule controlling which products get visible fills and at what opacity.
|
|
176
|
+
* Mirrors the template-level FillRule but kept minimal to avoid circular deps.
|
|
177
|
+
*/
|
|
178
|
+
export interface SectionFillRule {
|
|
179
|
+
/** IFC class filter. When omitted, matches all products. */
|
|
180
|
+
class?: string | string[];
|
|
181
|
+
/** Property-based filter (e.g. { "BIP.spacetype": "ROOM" }). */
|
|
182
|
+
where?: Record<string, string | number | boolean | Array<string | number>>;
|
|
183
|
+
/** Fill opacity (0–1). Default: {@link DEFAULT_FILL_OPACITY}. */
|
|
184
|
+
fillOpacity?: number;
|
|
185
|
+
/** When true, only apply fill to products whose centroid is inside the clip polygon. @default false */
|
|
186
|
+
matchClipFilter?: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface BuildSectionStateOptions {
|
|
190
|
+
viewport?: ViewWindow;
|
|
191
|
+
strokeWidth?: number;
|
|
192
|
+
labelFontSize?: number;
|
|
193
|
+
/**
|
|
194
|
+
* Fill rules controlling which products get visible fills and at what opacity.
|
|
195
|
+
* Products matching a fill rule are rendered below non-matching products.
|
|
196
|
+
* When absent or empty, all products render uniformly (no fill-based reordering).
|
|
197
|
+
*/
|
|
198
|
+
fills?: SectionFillRule[];
|
|
199
|
+
includeMeta?: boolean;
|
|
200
|
+
/** Enable labels. Requires buildLabels callback. */
|
|
201
|
+
includeLabels?: boolean;
|
|
202
|
+
/** Callback to build labels for matching products. Required when includeLabels is true. */
|
|
203
|
+
buildLabels?: (products: any[], fontSize: number) => SectionLabel[];
|
|
204
|
+
/** Clip polygon in world coordinates (x, z). Used when fill.matchClipFilter is true for spatial filtering. */
|
|
205
|
+
clipPolygon?: Array<Array<{ x: number; z: number }>>;
|
|
206
|
+
/** When true, create fill paths for closed polygons. When false, create only stroke/outline paths. Defaults to true for backward compatibility. */
|
|
207
|
+
createFills?: boolean;
|
|
208
|
+
/** Plane normal vector for determining 2D projection axes. If omitted, defaults to horizontal plan (Y-up). */
|
|
209
|
+
planeNormal?: Vec3;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Convert plane section polylines to SVG path data with fill colors and stroke styles. */
|
|
213
|
+
export function createSectionSvg(
|
|
214
|
+
perProduct: PlaneSectionEntry[],
|
|
215
|
+
viewport?: ViewWindow,
|
|
216
|
+
strokeWidthHint?: number,
|
|
217
|
+
labels?: SectionLabel[],
|
|
218
|
+
options?: SectionBuildOptions
|
|
219
|
+
): SectionRenderState | null {
|
|
220
|
+
if (!perProduct.length) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const polylines = perProduct.flatMap((entry) => entry.polylines);
|
|
225
|
+
const hasSegments = perProduct.some((entry) => entry.segments && entry.segments.length > 0);
|
|
226
|
+
|
|
227
|
+
if (!polylines.length && !hasSegments) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const planeNormal = options?.planeNormal;
|
|
232
|
+
const axes = getProjectionAxes(planeNormal);
|
|
233
|
+
|
|
234
|
+
let viewMinX: number;
|
|
235
|
+
let viewMaxX: number;
|
|
236
|
+
let viewMinZ: number;
|
|
237
|
+
let viewMaxZ: number;
|
|
238
|
+
|
|
239
|
+
if (viewport) {
|
|
240
|
+
viewMinX = viewport.viewMinX;
|
|
241
|
+
viewMaxX = viewport.viewMaxX;
|
|
242
|
+
viewMinZ = viewport.viewMinZ;
|
|
243
|
+
viewMaxZ = viewport.viewMaxZ;
|
|
244
|
+
} else {
|
|
245
|
+
let minU = Number.POSITIVE_INFINITY;
|
|
246
|
+
let maxU = Number.NEGATIVE_INFINITY;
|
|
247
|
+
let minV = Number.POSITIVE_INFINITY;
|
|
248
|
+
let maxV = Number.NEGATIVE_INFINITY;
|
|
249
|
+
|
|
250
|
+
polylines.forEach((polyline: { points: Vec3[]; closed: boolean }) => {
|
|
251
|
+
polyline.points.forEach((point: Vec3) => {
|
|
252
|
+
if (!point) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const u = axes.getU(point);
|
|
256
|
+
const v = axes.getV(point);
|
|
257
|
+
if (!Number.isFinite(u) || !Number.isFinite(v)) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (u < minU) minU = u;
|
|
261
|
+
if (u > maxU) maxU = u;
|
|
262
|
+
if (v < minV) minV = v;
|
|
263
|
+
if (v > maxV) maxV = v;
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Also include segments in bounding box calculation
|
|
268
|
+
perProduct.forEach((entry) => {
|
|
269
|
+
if (entry.segments && entry.segments.length) {
|
|
270
|
+
entry.segments.forEach((segment) => {
|
|
271
|
+
const [p1, p2] = segment;
|
|
272
|
+
const u1 = axes.getU(p1);
|
|
273
|
+
const v1 = axes.getV(p1);
|
|
274
|
+
const u2 = axes.getU(p2);
|
|
275
|
+
const v2 = axes.getV(p2);
|
|
276
|
+
if (Number.isFinite(u1) && Number.isFinite(v1)) {
|
|
277
|
+
if (u1 < minU) minU = u1;
|
|
278
|
+
if (u1 > maxU) maxU = u1;
|
|
279
|
+
if (v1 < minV) minV = v1;
|
|
280
|
+
if (v1 > maxV) maxV = v1;
|
|
281
|
+
}
|
|
282
|
+
if (Number.isFinite(u2) && Number.isFinite(v2)) {
|
|
283
|
+
if (u2 < minU) minU = u2;
|
|
284
|
+
if (u2 > maxU) maxU = u2;
|
|
285
|
+
if (v2 < minV) minV = v2;
|
|
286
|
+
if (v2 > maxV) maxV = v2;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (!Number.isFinite(minU) || !Number.isFinite(maxU) || !Number.isFinite(minV) || !Number.isFinite(maxV)) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (maxU - minU < 1e-3) {
|
|
297
|
+
minU -= 0.5;
|
|
298
|
+
maxU += 0.5;
|
|
299
|
+
}
|
|
300
|
+
if (maxV - minV < 1e-3) {
|
|
301
|
+
minV -= 0.5;
|
|
302
|
+
maxV += 0.5;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const worldWidth = maxU - minU;
|
|
306
|
+
const worldHeight = maxV - minV;
|
|
307
|
+
const marginU = Math.max(worldWidth * SECTION_MARGIN_RATIO, 0.5);
|
|
308
|
+
const marginV = Math.max(worldHeight * SECTION_MARGIN_RATIO, 0.5);
|
|
309
|
+
|
|
310
|
+
viewMinX = minU - marginU;
|
|
311
|
+
viewMaxX = maxU + marginU;
|
|
312
|
+
viewMinZ = minV - marginV;
|
|
313
|
+
viewMaxZ = maxV + marginV;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const viewWidth = viewMaxX - viewMinX;
|
|
317
|
+
const viewHeight = viewMaxZ - viewMinZ;
|
|
318
|
+
|
|
319
|
+
if (!Number.isFinite(viewWidth) || !Number.isFinite(viewHeight) || viewWidth <= 1e-6 || viewHeight <= 1e-6) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const autoStrokeBase = Math.max(Math.min(viewWidth, viewHeight) * 0.008, 0.08);
|
|
324
|
+
const autoStroke = Math.max(autoStrokeBase * OUTLINE_THICKNESS_SCALE, 0.02);
|
|
325
|
+
const strokeWidth = Math.max(strokeWidthHint ?? autoStroke, 0.02);
|
|
326
|
+
const geometryTransform = buildGeometryTransform(viewMinX, viewMaxX, viewMinZ, viewMaxZ);
|
|
327
|
+
|
|
328
|
+
const includeMeta = !!options?.includeMeta;
|
|
329
|
+
const createFills = options?.createFills !== false; // Default to true for backward compatibility
|
|
330
|
+
|
|
331
|
+
const allPaths: SectionPath[] = [];
|
|
332
|
+
if (includeMeta) {
|
|
333
|
+
perProduct.forEach((entry, idx) => {
|
|
334
|
+
// Skip entries with neither polylines nor segments
|
|
335
|
+
if (!entry.polylines.length && (!entry.segments || !entry.segments.length)) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const colorInfo = sampleProductColorRgba(entry.product);
|
|
339
|
+
const fillColor = colorInfo ? `rgb(${colorInfo.r},${colorInfo.g},${colorInfo.b})` : '#ffffff';
|
|
340
|
+
const fillOpacity = colorInfo ? clampValue(colorInfo.a, 0, 1) : 1;
|
|
341
|
+
const meta = extractProductMeta(entry.product);
|
|
342
|
+
|
|
343
|
+
const closedSubpaths: string[] = [];
|
|
344
|
+
const holeSubpaths: string[] = [];
|
|
345
|
+
const openSubpaths: string[] = [];
|
|
346
|
+
|
|
347
|
+
entry.polylines.forEach((polyline) => {
|
|
348
|
+
const pathData = getCachedPolylinePath(polyline, planeNormal);
|
|
349
|
+
if (!pathData) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (pathData.closed) {
|
|
353
|
+
if (pathData.hole) {
|
|
354
|
+
holeSubpaths.push(pathData.d);
|
|
355
|
+
} else {
|
|
356
|
+
closedSubpaths.push(pathData.d);
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
openSubpaths.push(pathData.d);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Only create fill paths if createFills is true
|
|
364
|
+
if (createFills && (closedSubpaths.length || holeSubpaths.length)) {
|
|
365
|
+
const fillSubpaths = closedSubpaths.length ? closedSubpaths.concat(holeSubpaths) : holeSubpaths;
|
|
366
|
+
allPaths.push({
|
|
367
|
+
key: `fill-${idx}`,
|
|
368
|
+
d: fillSubpaths.join(' '),
|
|
369
|
+
fill: fillColor,
|
|
370
|
+
fillOpacity: clampValue(fillOpacity, 0, 1),
|
|
371
|
+
stroke: '#1f2937',
|
|
372
|
+
strokeWidth,
|
|
373
|
+
fillRule: 'evenodd',
|
|
374
|
+
meta: meta ? { ...meta, isFill: true } : undefined
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (openSubpaths.length) {
|
|
379
|
+
allPaths.push({
|
|
380
|
+
key: `outline-${idx}`,
|
|
381
|
+
d: openSubpaths.join(' '),
|
|
382
|
+
fill: 'none',
|
|
383
|
+
fillOpacity: 1,
|
|
384
|
+
stroke: '#1f2937',
|
|
385
|
+
strokeWidth,
|
|
386
|
+
meta: meta ? { ...meta, isOutline: true } : undefined
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Render segments (cut edges) as strokes
|
|
391
|
+
if (entry.segments && entry.segments.length) {
|
|
392
|
+
const segmentPaths: string[] = [];
|
|
393
|
+
for (const segment of entry.segments) {
|
|
394
|
+
const path = segmentToPath(segment, planeNormal);
|
|
395
|
+
if (path) segmentPaths.push(path);
|
|
396
|
+
}
|
|
397
|
+
if (segmentPaths.length) {
|
|
398
|
+
allPaths.push({
|
|
399
|
+
key: `segments-${idx}`,
|
|
400
|
+
d: segmentPaths.join(' '),
|
|
401
|
+
fill: 'none',
|
|
402
|
+
fillOpacity: 1,
|
|
403
|
+
stroke: '#1f2937',
|
|
404
|
+
strokeWidth,
|
|
405
|
+
meta: meta ? { ...meta, isSegment: true } : undefined
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
} else {
|
|
411
|
+
const fillGroups = new Map<string, FillGroup>();
|
|
412
|
+
const openSubpaths: string[] = [];
|
|
413
|
+
const allSegmentPaths: string[] = [];
|
|
414
|
+
|
|
415
|
+
perProduct.forEach((entry) => {
|
|
416
|
+
// Process segments first (always render, even without polylines)
|
|
417
|
+
if (entry.segments && entry.segments.length) {
|
|
418
|
+
for (const segment of entry.segments) {
|
|
419
|
+
const path = segmentToPath(segment, planeNormal);
|
|
420
|
+
if (path) allSegmentPaths.push(path);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!entry.polylines.length) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const colorInfo = sampleProductColorRgba(entry.product);
|
|
429
|
+
const fillColor = colorInfo ? `rgb(${colorInfo.r},${colorInfo.g},${colorInfo.b})` : '#ffffff';
|
|
430
|
+
const fillOpacity = colorInfo ? clampValue(colorInfo.a, 0, 1) : 1;
|
|
431
|
+
const groupKey = `${fillColor}|${Math.round(fillOpacity * 1000)}`;
|
|
432
|
+
let group = fillGroups.get(groupKey);
|
|
433
|
+
if (!group) {
|
|
434
|
+
group = {
|
|
435
|
+
color: fillColor,
|
|
436
|
+
opacity: fillOpacity,
|
|
437
|
+
closed: [],
|
|
438
|
+
holes: []
|
|
439
|
+
};
|
|
440
|
+
fillGroups.set(groupKey, group);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
entry.polylines.forEach((polyline) => {
|
|
444
|
+
const pathData = getCachedPolylinePath(polyline, planeNormal);
|
|
445
|
+
if (!pathData) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (pathData.closed) {
|
|
449
|
+
if (pathData.hole) {
|
|
450
|
+
group!.holes.push(pathData.d);
|
|
451
|
+
} else {
|
|
452
|
+
group!.closed.push(pathData.d);
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
openSubpaths.push(pathData.d);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
let groupIndex = 0;
|
|
461
|
+
fillGroups.forEach((group) => {
|
|
462
|
+
const fillSubpaths = group.closed.length ? group.closed.concat(group.holes) : group.holes;
|
|
463
|
+
if (!fillSubpaths.length) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
allPaths.push({
|
|
467
|
+
key: `fill-${groupIndex++}`,
|
|
468
|
+
d: fillSubpaths.join(' '),
|
|
469
|
+
fill: group.color,
|
|
470
|
+
fillOpacity: clampValue(group.opacity, 0, 1),
|
|
471
|
+
stroke: '#1f2937',
|
|
472
|
+
strokeWidth,
|
|
473
|
+
fillRule: 'evenodd'
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (openSubpaths.length) {
|
|
478
|
+
allPaths.push({
|
|
479
|
+
key: 'outlines',
|
|
480
|
+
d: openSubpaths.join(' '),
|
|
481
|
+
fill: 'none',
|
|
482
|
+
fillOpacity: 1,
|
|
483
|
+
stroke: '#1f2937',
|
|
484
|
+
strokeWidth
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Render all segments (collected above in forEach loop)
|
|
489
|
+
if (allSegmentPaths.length) {
|
|
490
|
+
allPaths.push({
|
|
491
|
+
key: 'segments',
|
|
492
|
+
d: allSegmentPaths.join(' '),
|
|
493
|
+
fill: 'none',
|
|
494
|
+
fillOpacity: 1,
|
|
495
|
+
stroke: '#1f2937',
|
|
496
|
+
strokeWidth
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (!allPaths.length) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const displayWidth = BASE_WIDTH;
|
|
506
|
+
const displayHeight = Math.max(MIN_HEIGHT, (viewHeight / viewWidth) * BASE_WIDTH);
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
paths: allPaths,
|
|
510
|
+
transform: {
|
|
511
|
+
viewMinX,
|
|
512
|
+
viewMaxX,
|
|
513
|
+
viewMinZ,
|
|
514
|
+
viewMaxZ,
|
|
515
|
+
viewWidth,
|
|
516
|
+
viewHeight,
|
|
517
|
+
displayWidth,
|
|
518
|
+
displayHeight
|
|
519
|
+
},
|
|
520
|
+
strokeWidth,
|
|
521
|
+
geometryTransform,
|
|
522
|
+
labels: labels?.length
|
|
523
|
+
? labels
|
|
524
|
+
.filter((label) => {
|
|
525
|
+
if (!Number.isFinite(label.x) || !Number.isFinite(label.z)) {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
return label.x >= viewMinX && label.x <= viewMaxX && label.z >= viewMinZ && label.z <= viewMaxZ;
|
|
529
|
+
})
|
|
530
|
+
.map((label) => ({
|
|
531
|
+
key: label.key,
|
|
532
|
+
text: label.text,
|
|
533
|
+
x: label.x,
|
|
534
|
+
y: label.y,
|
|
535
|
+
z: label.z,
|
|
536
|
+
fontSize: label.fontSize,
|
|
537
|
+
meta: includeMeta ? (label.meta ?? {}) : undefined
|
|
538
|
+
}))
|
|
539
|
+
: []
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Build a complete SectionRenderState from plane section entries, with optional
|
|
545
|
+
* fill-rule-based separation, ordering, and label generation.
|
|
546
|
+
*
|
|
547
|
+
* When `fills` rules are provided, entries matching a fill rule are rendered
|
|
548
|
+
* below non-matching entries, with their fill opacity set by the matching rule.
|
|
549
|
+
*/
|
|
550
|
+
export function buildSectionStateForEntries(
|
|
551
|
+
perProduct: PlaneSectionEntry[],
|
|
552
|
+
options: BuildSectionStateOptions
|
|
553
|
+
): (SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] }) | null {
|
|
554
|
+
if (!perProduct.length) {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const fills = options.fills;
|
|
559
|
+
const includeMeta = !!options?.includeMeta;
|
|
560
|
+
const includeLabels = options?.includeLabels ?? false;
|
|
561
|
+
const viewport = options?.viewport;
|
|
562
|
+
const strokeWidth = options?.strokeWidth;
|
|
563
|
+
const labelFontSize = includeLabels && typeof options?.labelFontSize === 'number' ? options.labelFontSize : null;
|
|
564
|
+
const buildLabels = options?.buildLabels;
|
|
565
|
+
|
|
566
|
+
// Point-in-polygon test (ray casting algorithm)
|
|
567
|
+
const pointInPolygon = (polygon: Array<{ x: number; z: number }>, x: number, z: number): boolean => {
|
|
568
|
+
let inside = false;
|
|
569
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
570
|
+
const xi = polygon[i].x;
|
|
571
|
+
const zi = polygon[i].z;
|
|
572
|
+
const xj = polygon[j].x;
|
|
573
|
+
const zj = polygon[j].z;
|
|
574
|
+
const intersect = zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi;
|
|
575
|
+
if (intersect) inside = !inside;
|
|
576
|
+
}
|
|
577
|
+
return inside;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// Calculate centroid from entry's polylines
|
|
581
|
+
const getCentroid = (entry: PlaneSectionEntry): { x: number; z: number } | null => {
|
|
582
|
+
const polylines = entry.polylines;
|
|
583
|
+
if (!polylines.length) return null;
|
|
584
|
+
let sumX = 0;
|
|
585
|
+
let sumZ = 0;
|
|
586
|
+
let count = 0;
|
|
587
|
+
for (const polyline of polylines) {
|
|
588
|
+
for (const point of polyline.points) {
|
|
589
|
+
sumX += point[0];
|
|
590
|
+
sumZ += point[2];
|
|
591
|
+
count++;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (count === 0) return null;
|
|
595
|
+
return { x: sumX / count, z: sumZ / count };
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// Spatial matcher for clip filter - checks if product centroid is inside clip polygon
|
|
599
|
+
const isInsideClipPolygon = (entry: PlaneSectionEntry): boolean => {
|
|
600
|
+
if (!options?.clipPolygon?.length) return true;
|
|
601
|
+
const centroid = getCentroid(entry);
|
|
602
|
+
if (!centroid) return false;
|
|
603
|
+
// Check if centroid is inside any of the clip polygons
|
|
604
|
+
return options.clipPolygon.some((polygon) => pointInPolygon(polygon, centroid.x, centroid.z));
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// Match an entry against fill rules. Returns the first matching rule, or undefined.
|
|
608
|
+
const matchFillRule = (entry: PlaneSectionEntry): SectionFillRule | undefined => {
|
|
609
|
+
if (!fills?.length) return undefined;
|
|
610
|
+
const product = entry.product;
|
|
611
|
+
for (const rule of fills) {
|
|
612
|
+
// If rule requires clip filter match, check if product centroid is inside clip polygon
|
|
613
|
+
if (rule.matchClipFilter && !isInsideClipPolygon(entry)) {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Check class match
|
|
618
|
+
let classMatches: boolean;
|
|
619
|
+
if (rule.class) {
|
|
620
|
+
const classes = Array.isArray(rule.class) ? rule.class : [rule.class];
|
|
621
|
+
const cls = product.class;
|
|
622
|
+
classMatches = classes.some((c) => {
|
|
623
|
+
if (cls?.is) return cls.is(c as string);
|
|
624
|
+
const pName = (product.className ?? '').toLowerCase();
|
|
625
|
+
return pName === (c as string).toLowerCase() || pName === `ifc${(c as string).toLowerCase()}`;
|
|
626
|
+
});
|
|
627
|
+
if (!classMatches) continue;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Check where condition (property-based filter)
|
|
631
|
+
if (rule.where && Object.keys(rule.where).length > 0) {
|
|
632
|
+
let whereMatches = true;
|
|
633
|
+
for (const [path, expected] of Object.entries(rule.where)) {
|
|
634
|
+
const actualValue = resolveProductProperty(product, path);
|
|
635
|
+
if (actualValue === undefined) {
|
|
636
|
+
whereMatches = false;
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const normalize = (v: unknown): unknown => (typeof v === 'string' ? v.toLowerCase().trim() : v);
|
|
641
|
+
|
|
642
|
+
if (Array.isArray(expected)) {
|
|
643
|
+
if (!expected.some((exp) => normalize(exp) === normalize(actualValue))) {
|
|
644
|
+
whereMatches = false;
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
} else {
|
|
648
|
+
if (normalize(actualValue) !== normalize(expected)) {
|
|
649
|
+
whereMatches = false;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (!whereMatches) continue;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return rule;
|
|
658
|
+
}
|
|
659
|
+
return undefined;
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const hasFills = !!fills?.length;
|
|
663
|
+
|
|
664
|
+
// Split entries by fill rule match
|
|
665
|
+
const fillEntries: PlaneSectionEntry[] = [];
|
|
666
|
+
const fillOpacities: number[] = []; // parallel to fillEntries
|
|
667
|
+
const nonFillEntries: PlaneSectionEntry[] = [];
|
|
668
|
+
|
|
669
|
+
for (const entry of perProduct) {
|
|
670
|
+
const rule = hasFills ? matchFillRule(entry) : undefined;
|
|
671
|
+
if (rule) {
|
|
672
|
+
const opacity = rule.fillOpacity ?? DEFAULT_FILL_OPACITY;
|
|
673
|
+
// Skip entries with zero opacity - don't render them at all
|
|
674
|
+
if (opacity > 0) {
|
|
675
|
+
fillEntries.push(entry);
|
|
676
|
+
fillOpacities.push(opacity);
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
nonFillEntries.push(entry);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Build labels from fill-matched entries (e.g. spaces)
|
|
684
|
+
const fillLabels =
|
|
685
|
+
includeLabels && fillEntries.length && labelFontSize !== null && buildLabels
|
|
686
|
+
? (buildLabels(
|
|
687
|
+
fillEntries.map((entry) => entry.product),
|
|
688
|
+
labelFontSize
|
|
689
|
+
) as SectionLabel[])
|
|
690
|
+
: undefined;
|
|
691
|
+
|
|
692
|
+
// When fill rules are present, render fill and non-fill groups separately
|
|
693
|
+
// so we can adjust opacity and control ordering.
|
|
694
|
+
// When no fill rules, render everything in one pass (unchanged behavior).
|
|
695
|
+
if (hasFills && (fillEntries.length > 0 || nonFillEntries.length > 0)) {
|
|
696
|
+
const nonFillState =
|
|
697
|
+
nonFillEntries.length > 0
|
|
698
|
+
? createSectionSvg(nonFillEntries, viewport, strokeWidth, undefined, {
|
|
699
|
+
includeMeta,
|
|
700
|
+
planeNormal: options.planeNormal
|
|
701
|
+
})
|
|
702
|
+
: null;
|
|
703
|
+
const fillState =
|
|
704
|
+
fillEntries.length > 0
|
|
705
|
+
? createSectionSvg(fillEntries, viewport, strokeWidth, includeLabels ? fillLabels : undefined, {
|
|
706
|
+
includeMeta,
|
|
707
|
+
planeNormal: options.planeNormal
|
|
708
|
+
})
|
|
709
|
+
: null;
|
|
710
|
+
|
|
711
|
+
const baseState = nonFillState ?? fillState ?? null;
|
|
712
|
+
if (!baseState) return null;
|
|
713
|
+
|
|
714
|
+
// Adjust fill opacity per-entry. When includeMeta is true, each entry has
|
|
715
|
+
// its own path, so we can apply per-entry opacity. When false, paths are
|
|
716
|
+
// grouped by color — apply the minimum matching opacity.
|
|
717
|
+
const adjustedFillPaths =
|
|
718
|
+
fillState?.paths.map((path, idx) => {
|
|
719
|
+
// When fill rules match, use the rule opacity directly (don't multiply by material alpha).
|
|
720
|
+
// Material alpha was useful for auto-coloring, but fill rules should have full control.
|
|
721
|
+
const ruleOpacity = idx < fillOpacities.length ? fillOpacities[idx] : (fills![0]?.fillOpacity ?? 0.35);
|
|
722
|
+
const finalOpacity = clampValue(ruleOpacity, 0, 1);
|
|
723
|
+
return {
|
|
724
|
+
...path,
|
|
725
|
+
fillOpacity: finalOpacity
|
|
726
|
+
};
|
|
727
|
+
}) ?? [];
|
|
728
|
+
|
|
729
|
+
const nonFillPaths = nonFillState?.paths ?? [];
|
|
730
|
+
// Fill-matched entries always render below non-fill entries
|
|
731
|
+
const mergedPaths = [...adjustedFillPaths, ...nonFillPaths];
|
|
732
|
+
|
|
733
|
+
const merged: SectionRenderState & { rawPaths?: SectionPath[]; rawLabels?: SectionLabel[] } = {
|
|
734
|
+
...baseState,
|
|
735
|
+
paths: mergedPaths,
|
|
736
|
+
labels: includeLabels ? (fillState?.labels ?? fillLabels ?? []) : []
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
return merged;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// No fill rules — create paths with fills for highlight support, but set opacity to 0
|
|
743
|
+
const state = createSectionSvg(perProduct, viewport, strokeWidth, includeLabels ? fillLabels : undefined, {
|
|
744
|
+
includeMeta,
|
|
745
|
+
createFills: true,
|
|
746
|
+
planeNormal: options.planeNormal
|
|
747
|
+
});
|
|
748
|
+
if (!state) return null;
|
|
749
|
+
|
|
750
|
+
// Set all fill opacities to 0 when no fill rules exist (but keep paths for highlight support)
|
|
751
|
+
const pathsWithZeroFillOpacity = state.paths.map((path) => ({
|
|
752
|
+
...path,
|
|
753
|
+
fillOpacity: 0
|
|
754
|
+
}));
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
...state,
|
|
758
|
+
paths: pathsWithZeroFillOpacity,
|
|
759
|
+
labels: includeLabels ? (state.labels ?? []) : []
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Extract the viewport bounding box from a TransformInfo. */
|
|
764
|
+
export function extractViewWindow(transform: TransformInfo): ViewWindow {
|
|
765
|
+
return {
|
|
766
|
+
viewMinX: transform.viewMinX,
|
|
767
|
+
viewMaxX: transform.viewMaxX,
|
|
768
|
+
viewMinZ: transform.viewMinZ,
|
|
769
|
+
viewMaxZ: transform.viewMaxZ
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Compare two viewport windows for equality within a tolerance. */
|
|
774
|
+
export function windowsEqual(a: ViewWindow, b: ViewWindow, epsilon = 1e-4): boolean {
|
|
775
|
+
return (
|
|
776
|
+
Math.abs(a.viewMinX - b.viewMinX) <= epsilon &&
|
|
777
|
+
Math.abs(a.viewMaxX - b.viewMaxX) <= epsilon &&
|
|
778
|
+
Math.abs(a.viewMinZ - b.viewMinZ) <= epsilon &&
|
|
779
|
+
Math.abs(a.viewMaxZ - b.viewMaxZ) <= epsilon
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Constrain a viewport window within base bounds, preserving aspect ratio. */
|
|
784
|
+
export function clampViewWindow(base: TransformInfo, candidate: ViewWindow): ViewWindow {
|
|
785
|
+
const baseWidth = base.viewWidth;
|
|
786
|
+
const baseHeight = base.viewHeight;
|
|
787
|
+
const aspect = baseWidth / baseHeight;
|
|
788
|
+
|
|
789
|
+
let width = candidate.viewMaxX - candidate.viewMinX;
|
|
790
|
+
|
|
791
|
+
if (!Number.isFinite(width) || width <= 1e-6) {
|
|
792
|
+
width = baseWidth;
|
|
793
|
+
}
|
|
794
|
+
width = clampValue(width, baseWidth / MAX_ZOOM_FACTOR, baseWidth);
|
|
795
|
+
const height = width / aspect;
|
|
796
|
+
|
|
797
|
+
let centerX = (candidate.viewMinX + candidate.viewMaxX) * 0.5;
|
|
798
|
+
if (!Number.isFinite(centerX)) {
|
|
799
|
+
centerX = (base.viewMinX + base.viewMaxX) * 0.5;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
let centerZ = (candidate.viewMinZ + candidate.viewMaxZ) * 0.5;
|
|
803
|
+
if (!Number.isFinite(centerZ)) {
|
|
804
|
+
centerZ = (base.viewMinZ + base.viewMaxZ) * 0.5;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
let minX = centerX - width * 0.5;
|
|
808
|
+
let maxX = minX + width;
|
|
809
|
+
if (minX < base.viewMinX) {
|
|
810
|
+
minX = base.viewMinX;
|
|
811
|
+
maxX = minX + width;
|
|
812
|
+
}
|
|
813
|
+
if (maxX > base.viewMaxX) {
|
|
814
|
+
maxX = base.viewMaxX;
|
|
815
|
+
minX = maxX - width;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
let minZ = centerZ - height * 0.5;
|
|
819
|
+
let maxZ = minZ + height;
|
|
820
|
+
if (minZ < base.viewMinZ) {
|
|
821
|
+
minZ = base.viewMinZ;
|
|
822
|
+
maxZ = minZ + height;
|
|
823
|
+
}
|
|
824
|
+
if (maxZ > base.viewMaxZ) {
|
|
825
|
+
maxZ = base.viewMaxZ;
|
|
826
|
+
minZ = maxZ - height;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return {
|
|
830
|
+
viewMinX: minX,
|
|
831
|
+
viewMaxX: maxX,
|
|
832
|
+
viewMinZ: minZ,
|
|
833
|
+
viewMaxZ: maxZ
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Adjust stroke width proportionally to zoom level. */
|
|
838
|
+
export function strokeWidthForWindow(baseStroke: number, baseTransform: TransformInfo, window: ViewWindow): number {
|
|
839
|
+
const baseWidth = baseTransform.viewWidth;
|
|
840
|
+
const currentWidth = window.viewMaxX - window.viewMinX;
|
|
841
|
+
if (!Number.isFinite(baseWidth) || baseWidth <= 1e-6 || !Number.isFinite(currentWidth) || currentWidth <= 1e-6) {
|
|
842
|
+
return baseStroke;
|
|
843
|
+
}
|
|
844
|
+
const ratio = clampValue(currentWidth / baseWidth, 1 / MAX_ZOOM_FACTOR, 1);
|
|
845
|
+
const scaled = baseStroke * ratio;
|
|
846
|
+
return Math.max(scaled, baseStroke / (MAX_ZOOM_FACTOR + 1));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
interface SvgPathData {
|
|
850
|
+
d: string;
|
|
851
|
+
closed: boolean;
|
|
852
|
+
hole: boolean;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
interface FillGroup {
|
|
856
|
+
color: string;
|
|
857
|
+
opacity: number;
|
|
858
|
+
closed: string[];
|
|
859
|
+
holes: string[];
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
interface CachedPolylinePath {
|
|
863
|
+
pointsRef: Vec3[];
|
|
864
|
+
signature: number;
|
|
865
|
+
data: SvgPathData;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const polylinePathCache = new WeakMap<{ points: Vec3[]; closed: boolean }, CachedPolylinePath>();
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Determine which axes to use for 2D projection based on plane normal.
|
|
872
|
+
* Returns accessor functions for horizontal and vertical SVG coordinates.
|
|
873
|
+
*/
|
|
874
|
+
function getProjectionAxes(planeNormal?: Vec3): {
|
|
875
|
+
getU: (p: Vec3) => number;
|
|
876
|
+
getV: (p: Vec3) => number;
|
|
877
|
+
axisNames: string;
|
|
878
|
+
} {
|
|
879
|
+
if (!planeNormal) {
|
|
880
|
+
// Default: horizontal plan (looking down Y axis) -> X,Z
|
|
881
|
+
return {
|
|
882
|
+
getU: (p) => p[0],
|
|
883
|
+
getV: (p) => p[2],
|
|
884
|
+
axisNames: 'XZ'
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Normalize and find dominant axis
|
|
889
|
+
const absX = Math.abs(planeNormal[0]);
|
|
890
|
+
const absY = Math.abs(planeNormal[1]);
|
|
891
|
+
const absZ = Math.abs(planeNormal[2]);
|
|
892
|
+
|
|
893
|
+
if (absY > absX && absY > absZ) {
|
|
894
|
+
// Horizontal plan (normal ~ Y axis) -> looking down Y, use X,Z
|
|
895
|
+
return {
|
|
896
|
+
getU: (p) => p[0],
|
|
897
|
+
getV: (p) => p[2],
|
|
898
|
+
axisNames: 'XZ'
|
|
899
|
+
};
|
|
900
|
+
} else if (absZ > absX) {
|
|
901
|
+
// Vertical section perpendicular to Z -> looking along Z, use X,Y
|
|
902
|
+
return {
|
|
903
|
+
getU: (p) => p[0],
|
|
904
|
+
getV: (p) => p[1],
|
|
905
|
+
axisNames: 'XY'
|
|
906
|
+
};
|
|
907
|
+
} else {
|
|
908
|
+
// Vertical section perpendicular to X -> looking along X, use Z,Y
|
|
909
|
+
return {
|
|
910
|
+
getU: (p) => p[2],
|
|
911
|
+
getV: (p) => p[1],
|
|
912
|
+
axisNames: 'ZY'
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Convert a segment (pair of points) to an SVG path string.
|
|
919
|
+
*/
|
|
920
|
+
function segmentToPath(segment: [Vec3, Vec3], planeNormal?: Vec3): string {
|
|
921
|
+
const [p1, p2] = segment;
|
|
922
|
+
const axes = getProjectionAxes(planeNormal);
|
|
923
|
+
const u1 = axes.getU(p1);
|
|
924
|
+
const v1 = axes.getV(p1);
|
|
925
|
+
const u2 = axes.getU(p2);
|
|
926
|
+
const v2 = axes.getV(p2);
|
|
927
|
+
|
|
928
|
+
if (!Number.isFinite(u1) || !Number.isFinite(v1) || !Number.isFinite(u2) || !Number.isFinite(v2)) {
|
|
929
|
+
return '';
|
|
930
|
+
}
|
|
931
|
+
return `M${formatNumber(u1)} ${formatNumber(v1)} L${formatNumber(u2)} ${formatNumber(v2)}`;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function getCachedPolylinePath(polyline: { points: Vec3[]; closed: boolean }, planeNormal?: Vec3): SvgPathData | null {
|
|
935
|
+
if (!polyline.points.length) {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const signature = computePointsSignature(polyline.points, polyline.closed, planeNormal);
|
|
940
|
+
const cached = polylinePathCache.get(polyline);
|
|
941
|
+
if (cached && cached.pointsRef === polyline.points && cached.signature === signature) {
|
|
942
|
+
return cached.data;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const axes = getProjectionAxes(planeNormal);
|
|
946
|
+
const commands: string[] = [];
|
|
947
|
+
polyline.points.forEach((point: Vec3, index: number) => {
|
|
948
|
+
const prefix = index === 0 ? 'M' : 'L';
|
|
949
|
+
const u = axes.getU(point);
|
|
950
|
+
const v = axes.getV(point);
|
|
951
|
+
commands.push(`${prefix}${formatNumber(u)} ${formatNumber(v)}`);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
if (commands.length < 2) {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
let closed = false;
|
|
959
|
+
let hole = false;
|
|
960
|
+
|
|
961
|
+
if (polyline.closed && commands.length > 2) {
|
|
962
|
+
const area = signedArea(polyline.points, axes);
|
|
963
|
+
hole = area < 0;
|
|
964
|
+
closed = true;
|
|
965
|
+
commands.push('Z');
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const data: SvgPathData = { d: commands.join(' '), closed, hole };
|
|
969
|
+
polylinePathCache.set(polyline, { pointsRef: polyline.points, signature, data });
|
|
970
|
+
return data;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function signedArea(points: Vec3[], axes: { getU: (p: Vec3) => number; getV: (p: Vec3) => number }): number {
|
|
974
|
+
if (points.length < 3) {
|
|
975
|
+
return 0;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
let area = 0;
|
|
979
|
+
for (let i = 0; i < points.length; i++) {
|
|
980
|
+
const current = points[i];
|
|
981
|
+
const next = points[(i + 1) % points.length];
|
|
982
|
+
const u1 = axes.getU(current);
|
|
983
|
+
const v1 = axes.getV(current);
|
|
984
|
+
const u2 = axes.getU(next);
|
|
985
|
+
const v2 = axes.getV(next);
|
|
986
|
+
area += u1 * v2 - u2 * v1;
|
|
987
|
+
}
|
|
988
|
+
return area * 0.5;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function formatNumber(value: number): string {
|
|
992
|
+
if (!Number.isFinite(value)) {
|
|
993
|
+
return '0';
|
|
994
|
+
}
|
|
995
|
+
const formatted = value.toFixed(3);
|
|
996
|
+
return formatted.replace(/\.?0+$/, '');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function buildGeometryTransform(viewMinX: number, viewMaxX: number, viewMinZ: number, viewMaxZ: number): string {
|
|
1000
|
+
const translateX = formatNumber(-viewMinX);
|
|
1001
|
+
const translateY = formatNumber(viewMaxZ);
|
|
1002
|
+
return `matrix(1 0 0 -1 ${translateX} ${translateY})`;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function computePointsSignature(points: readonly Vec3[], closed: boolean, planeNormal?: Vec3): number {
|
|
1006
|
+
const axes = getProjectionAxes(planeNormal);
|
|
1007
|
+
// FNV-1a-inspired hash — compact fixed-size key instead of concatenating all coordinates
|
|
1008
|
+
let hash = 2166136261 ^ (points.length * 16777619) ^ (closed ? 1 : 0);
|
|
1009
|
+
for (let i = 0; i < points.length; i++) {
|
|
1010
|
+
const point = points[i];
|
|
1011
|
+
if (!point) continue;
|
|
1012
|
+
const u = axes.getU(point);
|
|
1013
|
+
const v = axes.getV(point);
|
|
1014
|
+
// Mix in coordinate bits (multiply by large prime, XOR)
|
|
1015
|
+
hash = Math.imul(hash ^ ((u * 1e4) | 0), 16777619);
|
|
1016
|
+
hash = Math.imul(hash ^ ((v * 1e4) | 0), 16777619);
|
|
1017
|
+
}
|
|
1018
|
+
return hash;
|
|
1019
|
+
}
|