@twinfinity/printing 6.1.1-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.
- package/dist/BuildInfo.js +3 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/analyzeTemplate.d.ts +4 -4
- package/dist/layout/analyzeTemplate.d.ts.map +1 -1
- package/dist/layout/analyzeTemplate.js +18 -16
- package/dist/layout/analyzeTemplate.js.map +1 -1
- package/dist/layout/classClosure.d.ts +41 -0
- package/dist/layout/classClosure.d.ts.map +1 -0
- package/dist/layout/classClosure.js +143 -0
- package/dist/layout/classClosure.js.map +1 -0
- package/dist/layout/clipUtils.d.ts +11 -0
- package/dist/layout/clipUtils.d.ts.map +1 -1
- package/dist/layout/clipUtils.js +67 -6
- package/dist/layout/clipUtils.js.map +1 -1
- package/dist/layout/filters.d.ts +14 -8
- package/dist/layout/filters.d.ts.map +1 -1
- package/dist/layout/filters.js +28 -3
- package/dist/layout/filters.js.map +1 -1
- package/dist/layout/index.d.ts +24 -57
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js +20 -337
- package/dist/layout/index.js.map +1 -1
- package/dist/layout/itemBuilders.d.ts +11 -0
- package/dist/layout/itemBuilders.d.ts.map +1 -1
- package/dist/layout/itemBuilders.js +17 -3
- package/dist/layout/itemBuilders.js.map +1 -1
- package/dist/layout/labelsRender.d.ts.map +1 -1
- package/dist/layout/labelsRender.js +15 -27
- package/dist/layout/labelsRender.js.map +1 -1
- package/dist/layout/legendRender.d.ts +18 -6
- package/dist/layout/legendRender.d.ts.map +1 -1
- package/dist/layout/legendRender.js +49 -79
- package/dist/layout/legendRender.js.map +1 -1
- package/dist/layout/metadata.d.ts.map +1 -1
- package/dist/layout/metadata.js +26 -5
- package/dist/layout/metadata.js.map +1 -1
- package/dist/layout/model.d.ts +4 -3
- package/dist/layout/model.d.ts.map +1 -1
- package/dist/layout/model.js +17 -57
- package/dist/layout/model.js.map +1 -1
- package/dist/layout/options.d.ts +1 -2
- package/dist/layout/options.d.ts.map +1 -1
- package/dist/layout/options.js.map +1 -1
- package/dist/layout/preflight.d.ts +32 -0
- package/dist/layout/preflight.d.ts.map +1 -0
- package/dist/layout/preflight.js +69 -0
- package/dist/layout/preflight.js.map +1 -0
- package/dist/layout/qrRender.d.ts.map +1 -1
- package/dist/layout/qrRender.js +11 -10
- package/dist/layout/qrRender.js.map +1 -1
- package/dist/layout/renderDrawing.d.ts +28 -17
- package/dist/layout/renderDrawing.d.ts.map +1 -1
- package/dist/layout/renderDrawing.js +107 -88
- package/dist/layout/renderDrawing.js.map +1 -1
- package/dist/layout/renderLayout.d.ts +25 -0
- package/dist/layout/renderLayout.d.ts.map +1 -0
- package/dist/layout/renderLayout.js +315 -0
- package/dist/layout/renderLayout.js.map +1 -0
- package/dist/layout/renderTemplateSheet.d.ts +99 -0
- package/dist/layout/renderTemplateSheet.d.ts.map +1 -0
- package/dist/layout/renderTemplateSheet.js +296 -0
- package/dist/layout/renderTemplateSheet.js.map +1 -0
- package/dist/layout/renderUtils.d.ts +19 -0
- package/dist/layout/renderUtils.d.ts.map +1 -1
- package/dist/layout/renderUtils.js +47 -4
- package/dist/layout/renderUtils.js.map +1 -1
- package/dist/layout/sectionDefs.d.ts +65 -0
- package/dist/layout/sectionDefs.d.ts.map +1 -0
- package/dist/layout/sectionDefs.js +88 -0
- package/dist/layout/sectionDefs.js.map +1 -0
- package/dist/layout/slabModelView.d.ts +34 -0
- package/dist/layout/slabModelView.d.ts.map +1 -0
- package/dist/layout/slabModelView.js +175 -0
- package/dist/layout/slabModelView.js.map +1 -0
- package/dist/layout/templateVariables.d.ts +1 -1
- package/dist/layout/templateVariables.d.ts.map +1 -1
- package/dist/layout/templateVariables.js +21 -9
- package/dist/layout/templateVariables.js.map +1 -1
- package/dist/layout/types.d.ts +164 -83
- package/dist/layout/types.d.ts.map +1 -1
- package/dist/layout/types.js.map +1 -1
- package/dist/layout/validation.d.ts.map +1 -1
- package/dist/layout/validation.js +78 -33
- package/dist/layout/validation.js.map +1 -1
- package/dist/layout/viewportPrep.d.ts +8 -6
- package/dist/layout/viewportPrep.d.ts.map +1 -1
- package/dist/layout/viewportPrep.js +35 -23
- package/dist/layout/viewportPrep.js.map +1 -1
- package/dist/layout/viewportRender.d.ts +9 -7
- package/dist/layout/viewportRender.d.ts.map +1 -1
- package/dist/layout/viewportRender.js +93 -126
- package/dist/layout/viewportRender.js.map +1 -1
- package/dist/layout/viewportUtils.d.ts +20 -0
- package/dist/layout/viewportUtils.d.ts.map +1 -1
- package/dist/layout/viewportUtils.js +107 -4
- package/dist/layout/viewportUtils.js.map +1 -1
- package/dist/printToPdf.d.ts +7 -1
- package/dist/printToPdf.d.ts.map +1 -1
- package/dist/printToPdf.js +81 -14
- package/dist/printToPdf.js.map +1 -1
- package/dist/printToSvg.d.ts.map +1 -1
- package/dist/printToSvg.js +7 -3
- package/dist/printToSvg.js.map +1 -1
- package/dist/schemaUtils.d.ts +9 -0
- package/dist/schemaUtils.d.ts.map +1 -0
- package/dist/schemaUtils.js +14 -0
- package/dist/schemaUtils.js.map +1 -0
- package/dist/templates/default-template.json +30 -8
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +10 -1
- package/dist/types.js.map +1 -1
- package/package.json +8 -3
- package/src/index.ts +4 -0
- package/src/layout/analyzeTemplate.ts +21 -19
- package/src/layout/classClosure.ts +142 -0
- package/src/layout/clipUtils.ts +65 -6
- package/src/layout/filters.ts +31 -3
- package/src/layout/index.ts +61 -571
- package/src/layout/itemBuilders.ts +20 -5
- package/src/layout/labelsRender.ts +23 -40
- package/src/layout/legendRender.ts +64 -84
- package/src/layout/metadata.ts +26 -5
- package/src/layout/model.ts +21 -66
- package/src/layout/options.ts +1 -2
- package/src/layout/preflight.ts +96 -0
- package/src/layout/qrRender.ts +11 -10
- package/src/layout/renderDrawing.ts +132 -110
- package/src/layout/renderLayout.ts +483 -0
- package/src/layout/renderTemplateSheet.ts +435 -0
- package/src/layout/renderUtils.ts +53 -4
- package/src/layout/sectionDefs.ts +131 -0
- package/src/layout/slabModelView.ts +196 -0
- package/src/layout/templateVariables.ts +25 -9
- package/src/layout/types.ts +160 -70
- package/src/layout/validation.ts +98 -32
- package/src/layout/viewportPrep.ts +47 -29
- package/src/layout/viewportRender.ts +114 -141
- package/src/layout/viewportUtils.ts +118 -5
- package/src/printToPdf.ts +84 -15
- package/src/printToSvg.ts +7 -3
- package/src/schemaUtils.ts +13 -0
- package/src/templates/default-template.json +30 -8
- package/src/types.ts +12 -2
- package/templates/default-a0-landscape.json +171 -66
- package/templates/default-a0-portrait.json +159 -60
- package/templates/default-a1-landscape.json +135 -48
- package/templates/default-a1-portrait.json +123 -42
- package/templates/default-a3-landscape.json +87 -24
- package/templates/default-a3-portrait.json +99 -30
- package/templates/default-a4-landscape.json +75 -18
- package/templates/default-a4-portrait.json +75 -18
- package/templates/llm-authoring-guide.md +247 -0
- package/templates/template-schema.json +295 -120
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { buildPlaneBasis, planeFromPointAndNormal, type PlaneBasis, type Vec3 } from '@twinfinity/geometry';
|
|
2
|
+
import {
|
|
3
|
+
computePlaneSectionsForProducts,
|
|
4
|
+
SlabSection,
|
|
5
|
+
type ProductLike,
|
|
6
|
+
type SlabSectionResult
|
|
7
|
+
} from '@twinfinity/product-geometry';
|
|
8
|
+
import { buildSectionStateForEntries } from '../sections';
|
|
9
|
+
import { renderDrawing } from './renderDrawing';
|
|
10
|
+
import { sectionStateToModelView } from './itemBuilders';
|
|
11
|
+
import { slabSectionToModelView, slabPerProductToEntries } from './slabModelView';
|
|
12
|
+
import { resolveViewportSection, sectionDefKey, type ResolvedSectionDef, type SectionDefaults } from './sectionDefs';
|
|
13
|
+
import {
|
|
14
|
+
computeViewportClassClosure,
|
|
15
|
+
filterProductsByClosure,
|
|
16
|
+
collectClassBehavior,
|
|
17
|
+
type ClassBehaviorRule
|
|
18
|
+
} from './classClosure';
|
|
19
|
+
import { buildMetadataMap } from './metadata';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Plain world AABB from a viewer product's duck-typed `boundingInfo()` (BimIfcObject
|
|
23
|
+
* exposes Babylon BoundingInfo; plain ProductLike adapters usually have none → null).
|
|
24
|
+
*/
|
|
25
|
+
function productAabb(product: ProductLike): { min: [number, number, number]; max: [number, number, number] } | null {
|
|
26
|
+
const withBi = product as ProductLike & { boundingInfo?: () => Record<string, unknown> };
|
|
27
|
+
if (typeof withBi.boundingInfo !== 'function') return null;
|
|
28
|
+
const bi = withBi.boundingInfo();
|
|
29
|
+
if (!bi) return null;
|
|
30
|
+
const bb = bi.boundingBox as Record<string, unknown> | undefined;
|
|
31
|
+
const bMin = (bb?.minimumWorld ?? bi.minimum) as (number[] & Record<string, number>) | undefined;
|
|
32
|
+
const bMax = (bb?.maximumWorld ?? bi.maximum) as (number[] & Record<string, number>) | undefined;
|
|
33
|
+
if (!bMin || !bMax) return null;
|
|
34
|
+
return {
|
|
35
|
+
min: [bMin[0] ?? bMin.x ?? 0, bMin[1] ?? bMin.y ?? 0, bMin[2] ?? bMin.z ?? 0],
|
|
36
|
+
max: [bMax[0] ?? bMax.x ?? 0, bMax[1] ?? bMax.y ?? 0, bMax[2] ?? bMax.z ?? 0]
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
import type {
|
|
40
|
+
LayoutTemplate,
|
|
41
|
+
SvgModelView,
|
|
42
|
+
ViewportLineStyles,
|
|
43
|
+
ViewportStairArrowOptions,
|
|
44
|
+
ProductPolylineEntry
|
|
45
|
+
} from './types';
|
|
46
|
+
|
|
47
|
+
// Products + template → rendered sheet, in one call. This is the consumer
|
|
48
|
+
// contract for the template's self-describing sections: every viewport's
|
|
49
|
+
// cutHeight/planeNormal/planePosition/depth is resolved (sectionDefs),
|
|
50
|
+
// computed (SlabSection for plans, plane sections for elevations/sections),
|
|
51
|
+
// adapted to SvgModelViews and fed to renderDrawing — so the workbench, the
|
|
52
|
+
// viewer print dialog and any future consumer render a template identically
|
|
53
|
+
// instead of re-implementing this pipeline.
|
|
54
|
+
//
|
|
55
|
+
// Invariants encoded here (learned the hard way — do not "optimize" away):
|
|
56
|
+
// - Sections are computed UNFILTERED: the renderer applies each viewport's
|
|
57
|
+
// includeClasses at render time via rawPaths + metadata, and highlight/clip
|
|
58
|
+
// need the highlight-class geometry present even when includeClasses
|
|
59
|
+
// excludes it from drawing (e.g. highlighting a space in a walls-only view).
|
|
60
|
+
// - The plan's lateral section origin stays at world zero so the projected
|
|
61
|
+
// space equals world x/z — labels/legend items are placed in world x/z, and
|
|
62
|
+
// a shifted origin would displace them.
|
|
63
|
+
// - The plan projection plane sits `above` metres over the cut (matches
|
|
64
|
+
// ExportDrawingPdf/printToSvg), with a (0,0,1) up hint for north-up plans.
|
|
65
|
+
|
|
66
|
+
type SectionEntries = Parameters<typeof buildSectionStateForEntries>[0];
|
|
67
|
+
type SectionStateOptions = Parameters<typeof buildSectionStateForEntries>[1];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The expensive template-independent half of one viewport's render: only the
|
|
71
|
+
* geometry is cached — the SvgModelView is built per viewport at render time,
|
|
72
|
+
* so template-driven styling (lineStyles incl. dash, fills) applies without
|
|
73
|
+
* re-sectioning.
|
|
74
|
+
*/
|
|
75
|
+
export type ComputedSection =
|
|
76
|
+
| {
|
|
77
|
+
kind: 'plan';
|
|
78
|
+
geometry: SlabSectionResult<ProductLike>;
|
|
79
|
+
basis: PlaneBasis;
|
|
80
|
+
segmentCount: number;
|
|
81
|
+
/** Per-product cut polylines (world x/z) — feeds labels/legend items. */
|
|
82
|
+
entries: ProductPolylineEntry[];
|
|
83
|
+
}
|
|
84
|
+
| {
|
|
85
|
+
kind: 'elevation';
|
|
86
|
+
perProduct: SectionEntries;
|
|
87
|
+
normal: Vec3;
|
|
88
|
+
segmentCount: number;
|
|
89
|
+
entries: ProductPolylineEntry[];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Section-geometry cache, one entry per distinct section definition. Owned by
|
|
94
|
+
* the caller (its lifetime = one loaded model; clear it when the model
|
|
95
|
+
* changes); renderTemplateSheet evicts entries no viewport references any
|
|
96
|
+
* more, keeping it bounded while sharing repeated definitions.
|
|
97
|
+
*/
|
|
98
|
+
export class SectionCache {
|
|
99
|
+
private readonly _map = new Map<string, ComputedSection>();
|
|
100
|
+
|
|
101
|
+
get(key: string): ComputedSection | undefined {
|
|
102
|
+
return this._map.get(key);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
set(key: string, value: ComputedSection): void {
|
|
106
|
+
this._map.set(key, value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
evictExcept(used: ReadonlySet<string>): void {
|
|
110
|
+
for (const key of this._map.keys()) {
|
|
111
|
+
if (!used.has(key)) this._map.delete(key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
clear(): void {
|
|
116
|
+
this._map.clear();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface RenderTemplateSheetInput {
|
|
121
|
+
template: LayoutTemplate;
|
|
122
|
+
/** Products with geometry — pass ALL of them (unfiltered; see above). */
|
|
123
|
+
products: ProductLike[];
|
|
124
|
+
/**
|
|
125
|
+
* Fallback plan position (world) for viewports without a section
|
|
126
|
+
* definition — typically the model AABB centre. Without it such
|
|
127
|
+
* viewports are skipped with a warning.
|
|
128
|
+
*/
|
|
129
|
+
modelCenter?: [number, number, number];
|
|
130
|
+
/** Section defaults applied where viewports omit cutHeight/depth. */
|
|
131
|
+
defaults?: SectionDefaults;
|
|
132
|
+
/** Reuse section geometry across renders (strongly recommended). */
|
|
133
|
+
cache?: SectionCache;
|
|
134
|
+
/** Context for role:'main'/'anchor' QR elements. */
|
|
135
|
+
qrContext?: {
|
|
136
|
+
host?: string | null;
|
|
137
|
+
containerName?: string | null;
|
|
138
|
+
floorName?: string | null;
|
|
139
|
+
planeHeight?: number | null;
|
|
140
|
+
};
|
|
141
|
+
/** Values for the template's declared variables ({{vars.*}}). */
|
|
142
|
+
variables?: Record<string, unknown>;
|
|
143
|
+
/**
|
|
144
|
+
* Host-side authorisation for fetching `https://` resources, matching the option `renderDrawing`
|
|
145
|
+
* already takes. Without it a caller rendering a sheet had no say at all — only the template
|
|
146
|
+
* could enable remote loading — so a host that wanted to permit it for its own templates could
|
|
147
|
+
* not, and one that wanted to observe the setting had nowhere to look.
|
|
148
|
+
*/
|
|
149
|
+
allowRemoteResources?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Progress callback: overall fraction 0–1 plus a human-readable phase.
|
|
152
|
+
* Section phases stream from SlabSection per viewport (cached sections
|
|
153
|
+
* skip ahead); the final render step reports as its own phase. Wire this
|
|
154
|
+
* to a busy indicator — large models can take tens of seconds, and
|
|
155
|
+
* without feedback a long compute is indistinguishable from a hang.
|
|
156
|
+
*/
|
|
157
|
+
onProgress?: (progress: number, phase: string) => void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface RenderedTemplateSheet {
|
|
161
|
+
svg: string;
|
|
162
|
+
warnings: string[];
|
|
163
|
+
/** Sum over viewports — 0 means no cut produced drawable geometry. */
|
|
164
|
+
segmentCount: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Read a viewport's `stairArrows` geometry options into a canonical object:
|
|
169
|
+
* known fields only, fixed order, invalid values dropped — so equal options
|
|
170
|
+
* always serialize to the same cache-key fragment and unknown junk from a
|
|
171
|
+
* hand-written template can't reach the geometry pass.
|
|
172
|
+
*/
|
|
173
|
+
export function readViewportStairArrowOptions(vp: Record<string, unknown>): ViewportStairArrowOptions | undefined {
|
|
174
|
+
const raw = vp.stairArrows;
|
|
175
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
176
|
+
const r = raw as Record<string, unknown>;
|
|
177
|
+
const out: ViewportStairArrowOptions = {};
|
|
178
|
+
if (r.startMarker === 'filled' || r.startMarker === 'open' || r.startMarker === 'none') {
|
|
179
|
+
out.startMarker = r.startMarker;
|
|
180
|
+
}
|
|
181
|
+
if (r.arrowhead === 'filled' || r.arrowhead === 'outline' || r.arrowhead === 'open') {
|
|
182
|
+
out.arrowhead = r.arrowhead;
|
|
183
|
+
}
|
|
184
|
+
for (const k of ['lengthScale', 'minLength', 'maxLength', 'landingExtension'] as const) {
|
|
185
|
+
const v = r[k];
|
|
186
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out[k] = v;
|
|
187
|
+
}
|
|
188
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function computeSectionForDef(
|
|
192
|
+
products: ProductLike[],
|
|
193
|
+
def: ResolvedSectionDef,
|
|
194
|
+
onPhase?: (pct: number, phase: string) => void,
|
|
195
|
+
classBehavior?: ClassBehaviorRule[],
|
|
196
|
+
stairArrows?: ViewportStairArrowOptions
|
|
197
|
+
): Promise<ComputedSection> {
|
|
198
|
+
if (!def.plan) {
|
|
199
|
+
// Elevations/sections: a thin plane section projected by the section-
|
|
200
|
+
// state builder — its planeNormal projection is the canonical
|
|
201
|
+
// orientation (a hand-rolled basis renders elevations mirrored).
|
|
202
|
+
onPhase?.(0, 'Computing plane sections');
|
|
203
|
+
const n = def.normal;
|
|
204
|
+
// AABB gate: a product whose bounds do not straddle the section plane
|
|
205
|
+
// cannot contribute plane sections — skip it before any mesh access.
|
|
206
|
+
const straddling = products.filter((p) => {
|
|
207
|
+
const bi = productAabb(p);
|
|
208
|
+
if (!bi) return true;
|
|
209
|
+
const c0 = (bi.min[0] + bi.max[0]) * 0.5 * n[0];
|
|
210
|
+
const c1 = (bi.min[1] + bi.max[1]) * 0.5 * n[1];
|
|
211
|
+
const c2 = (bi.min[2] + bi.max[2]) * 0.5 * n[2];
|
|
212
|
+
const e =
|
|
213
|
+
((bi.max[0] - bi.min[0]) / 2) * Math.abs(n[0]) +
|
|
214
|
+
((bi.max[1] - bi.min[1]) / 2) * Math.abs(n[1]) +
|
|
215
|
+
((bi.max[2] - bi.min[2]) / 2) * Math.abs(n[2]);
|
|
216
|
+
const center = c0 + c1 + c2;
|
|
217
|
+
return center - e <= def.position && center + e >= def.position;
|
|
218
|
+
});
|
|
219
|
+
const origin: Vec3 = [n[0] * def.position, n[1] * def.position, n[2] * def.position];
|
|
220
|
+
const plane = planeFromPointAndNormal(origin, n as unknown as Vec3);
|
|
221
|
+
const section = await computePlaneSectionsForProducts(straddling, plane);
|
|
222
|
+
onPhase?.(100, 'Plane sections complete');
|
|
223
|
+
return {
|
|
224
|
+
kind: 'elevation',
|
|
225
|
+
perProduct: section.perProduct as unknown as SectionEntries,
|
|
226
|
+
normal: n as unknown as Vec3,
|
|
227
|
+
segmentCount: section.segments.length,
|
|
228
|
+
entries: []
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Plans go through SlabSection (cut/visible/hidden lines with occlusion).
|
|
233
|
+
const viewDir: [number, number, number] = [0, -1, 0];
|
|
234
|
+
const above = Math.max(0, def.depth.above);
|
|
235
|
+
const below = Math.max(0, def.depth.below);
|
|
236
|
+
const slab = new SlabSection<ProductLike>({
|
|
237
|
+
cutHeight: def.position,
|
|
238
|
+
viewDir,
|
|
239
|
+
cutOrigin: [0, def.position, 0],
|
|
240
|
+
depth: { above, below },
|
|
241
|
+
// stairArrows: generated only for stair-category products (className fallback in
|
|
242
|
+
// the gather), so enabling it unconditionally costs nothing on stairless slabs —
|
|
243
|
+
// and the stairArrows lineStyles category has been in the template schema all
|
|
244
|
+
// along, styling output this pipeline never produced.
|
|
245
|
+
lines: { cut: true, visible: true, hidden: true, stairArrows: true },
|
|
246
|
+
// Template-driven marker shapes / run sizing; SlabSection merges the
|
|
247
|
+
// partial object with its own defaults.
|
|
248
|
+
...(stairArrows ? { stairArrows } : {}),
|
|
249
|
+
// Per-product attribution feeds labels/legend items.
|
|
250
|
+
trackProducts: true,
|
|
251
|
+
onProgress: onPhase,
|
|
252
|
+
// Forward-compatible pass-through: the published product-geometry ignores unknown
|
|
253
|
+
// options; the classBehavior contract activates with TWINS-3784's SlabSection.
|
|
254
|
+
...(classBehavior?.length ? { classBehavior } : {})
|
|
255
|
+
});
|
|
256
|
+
const geometry = await slab.compute(products);
|
|
257
|
+
const segmentCount = geometry.cutLines.length + geometry.visibleLines.length + geometry.hiddenLines.length;
|
|
258
|
+
const entries = slabPerProductToEntries(geometry.perProduct ?? []);
|
|
259
|
+
const basis = buildPlaneBasis([0, def.position + above, 0], viewDir, [0, 0, 1]);
|
|
260
|
+
return { kind: 'plan', geometry, basis, segmentCount, entries };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Build a viewport's model view from cached geometry + its own lineStyles. */
|
|
264
|
+
function buildViewForViewport(computed: ComputedSection, vp: Record<string, unknown>): SvgModelView | null {
|
|
265
|
+
const lineStyles = vp.lineStyles as ViewportLineStyles | undefined;
|
|
266
|
+
if (computed.kind === 'plan') {
|
|
267
|
+
return slabSectionToModelView(computed.geometry, computed.basis, lineStyles);
|
|
268
|
+
}
|
|
269
|
+
const state = buildSectionStateForEntries(computed.perProduct, {
|
|
270
|
+
strokeWidth: lineStyles?.default?.widthMm ?? 0.25,
|
|
271
|
+
includeMeta: true,
|
|
272
|
+
includeLabels: false,
|
|
273
|
+
fills: vp.fills as SectionStateOptions['fills'],
|
|
274
|
+
planeNormal: computed.normal
|
|
275
|
+
});
|
|
276
|
+
return state ? sectionStateToModelView(state) : null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Render a template against a product set: resolve each viewport's section
|
|
281
|
+
* definition, compute (or reuse from `cache`) the section per distinct
|
|
282
|
+
* definition, and feed renderDrawing one model per viewport id.
|
|
283
|
+
*/
|
|
284
|
+
export async function renderTemplateSheet(input: RenderTemplateSheetInput): Promise<RenderedTemplateSheet> {
|
|
285
|
+
const warnings: string[] = [];
|
|
286
|
+
const cache = input.cache ?? new SectionCache();
|
|
287
|
+
|
|
288
|
+
// Resolve template variables BEFORE section resolution — variables can
|
|
289
|
+
// drive section placement ("cutHeight": "{{vars.cutHeight}}", typed
|
|
290
|
+
// whole-value substitution), and the geometry cache must key on the
|
|
291
|
+
// resolved values. The resolved template carries no {{vars.*}} left, so
|
|
292
|
+
// renderDrawing gets no variables input.
|
|
293
|
+
let template = input.template;
|
|
294
|
+
if (template.variables) {
|
|
295
|
+
const { resolveTemplateValues, applyTemplateVariables } = await import('./templateVariables');
|
|
296
|
+
const { values, warnings: varWarnings } = resolveTemplateValues(template, input.variables);
|
|
297
|
+
varWarnings.forEach((w) => warnings.push(`[Variables] ${w}`));
|
|
298
|
+
template = applyTemplateVariables(template, values, warnings);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const viewports = ((template.elements ?? []) as unknown as Array<Record<string, unknown>>).filter(
|
|
302
|
+
(e): e is Record<string, unknown> & { id: string } => e.kind === 'viewport' && typeof e.id === 'string'
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
if (viewports.length === 0) {
|
|
306
|
+
warnings.push('Template has no viewport elements — nothing to section.');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Per-viewport compute-class closure (classClosure.ts): only the viewport's include
|
|
310
|
+
// classes take part in its drawing generation, hidden lines included — geometry
|
|
311
|
+
// outside the closure neither draws nor OCCLUDES there, and pays no compute cost
|
|
312
|
+
// (the dominant lever on MEP-heavy models). The closure contains the viewport's
|
|
313
|
+
// highlight/clip/fill/label/legend classes too, so the "sections computed unfiltered"
|
|
314
|
+
// invariant above still holds WITHIN it. The closure is part of the section cache
|
|
315
|
+
// key: viewports sharing (sectionDef, closure) share geometry, and editing
|
|
316
|
+
// includeClasses re-sections.
|
|
317
|
+
const productsByClosureKey = new Map<string, ProductLike[]>();
|
|
318
|
+
const productsForClosure = (closure: Set<string> | null): { products: ProductLike[]; closureKey: string } => {
|
|
319
|
+
const closureKey = closure ? [...closure].sort().join(',') : 'all';
|
|
320
|
+
let products = productsByClosureKey.get(closureKey);
|
|
321
|
+
if (!products) {
|
|
322
|
+
products = closure ? filterProductsByClosure(input.products, closure) : input.products;
|
|
323
|
+
productsByClosureKey.set(closureKey, products);
|
|
324
|
+
}
|
|
325
|
+
return { products, closureKey };
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const models: Record<string, SvgModelView> = {};
|
|
329
|
+
const usedKeys = new Set<string>();
|
|
330
|
+
let segmentCount = 0;
|
|
331
|
+
// Labels/legend items are placed in world x/z (floor-plane convention) and must see ALL products
|
|
332
|
+
// regardless of viewport class filters — so they come from each viewport's unfiltered section.
|
|
333
|
+
//
|
|
334
|
+
// Keyed by viewport id rather than collapsed into one list: a legend declares which viewport it
|
|
335
|
+
// describes (`legend.viewport`, required) and renderLayout reads that viewport's items, so a
|
|
336
|
+
// sheet may carry several legends describing several plans. Passing one flat list handed every
|
|
337
|
+
// legend the first plan's contents no matter what it was bound to.
|
|
338
|
+
// No fallback to another viewport's entries: a legend describing a viewport that yielded nothing
|
|
339
|
+
// renders empty and renderLayout warns by name ("Legend 'x' has no items from viewport 'y'"),
|
|
340
|
+
// which is more use than quietly showing another viewport's contents under its heading.
|
|
341
|
+
const entriesByViewport: Record<string, ProductPolylineEntry[]> = {};
|
|
342
|
+
// gid → class/property metadata for EVERY product in every section. The entries
|
|
343
|
+
// arrays only cover cut-bearing products, but render-time includeClasses filtering
|
|
344
|
+
// must classify products whose geometry never crosses the cut (furniture below a
|
|
345
|
+
// plan cut) — without this map their visible/hidden lines vanish from the drawing.
|
|
346
|
+
const fullMetadata: ReturnType<typeof buildMetadataMap> = {};
|
|
347
|
+
|
|
348
|
+
for (const vp of viewports) {
|
|
349
|
+
let def = resolveViewportSection(vp as never, input.defaults);
|
|
350
|
+
if (!def) {
|
|
351
|
+
if (!input.modelCenter) {
|
|
352
|
+
warnings.push(`Viewport '${vp.id}' has no section definition and no modelCenter fallback — skipped.`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
def = {
|
|
356
|
+
plan: true,
|
|
357
|
+
normal: [0, 1, 0],
|
|
358
|
+
position: input.modelCenter[1],
|
|
359
|
+
depth: { above: 0.5, below: 1.5 }
|
|
360
|
+
};
|
|
361
|
+
warnings.push(
|
|
362
|
+
`Viewport '${vp.id}' has no section definition — using a default plan at ${def.position.toFixed(1)} m.`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
const { products, closureKey } = productsForClosure(computeViewportClassClosure(vp, template));
|
|
366
|
+
const classBehavior = collectClassBehavior(vp, template);
|
|
367
|
+
// Behavior is part of the cache key: same def+closure with different behavior
|
|
368
|
+
// flags must not share geometry.
|
|
369
|
+
const behaviorKey = classBehavior.length ? JSON.stringify(classBehavior) : '';
|
|
370
|
+
// Stair-arrow geometry options are part of the cache key too: the arrows are
|
|
371
|
+
// baked into the section geometry, so viewports with different marker styles
|
|
372
|
+
// must not share it. readViewportStairArrowOptions canonicalizes field order.
|
|
373
|
+
const stairArrows = readViewportStairArrowOptions(vp);
|
|
374
|
+
const stairKey = stairArrows ? JSON.stringify(stairArrows) : '';
|
|
375
|
+
const key = `${sectionDefKey(def)}|${closureKey}|${behaviorKey}|${stairKey}`;
|
|
376
|
+
usedKeys.add(key);
|
|
377
|
+
let computed = cache.get(key);
|
|
378
|
+
if (!computed) {
|
|
379
|
+
// Section compute dominates total time; reserve the last 10% for the
|
|
380
|
+
// render step below. Cached viewports jump ahead without reporting.
|
|
381
|
+
const vpIndex = viewports.indexOf(vp);
|
|
382
|
+
computed = await computeSectionForDef(
|
|
383
|
+
products,
|
|
384
|
+
def,
|
|
385
|
+
(pct, phase) =>
|
|
386
|
+
input.onProgress?.(
|
|
387
|
+
((vpIndex + pct / 100) / viewports.length) * 0.9,
|
|
388
|
+
`Viewport '${vp.id}': ${phase}`
|
|
389
|
+
),
|
|
390
|
+
classBehavior,
|
|
391
|
+
stairArrows
|
|
392
|
+
);
|
|
393
|
+
cache.set(key, computed);
|
|
394
|
+
}
|
|
395
|
+
segmentCount += computed.segmentCount;
|
|
396
|
+
if (computed.kind === 'plan') {
|
|
397
|
+
Object.assign(fullMetadata, buildMetadataMap((computed.geometry.perProduct ?? []) as unknown[]));
|
|
398
|
+
} else {
|
|
399
|
+
Object.assign(fullMetadata, buildMetadataMap(computed.perProduct as unknown[]));
|
|
400
|
+
}
|
|
401
|
+
if (computed.entries?.length) entriesByViewport[vp.id] = computed.entries;
|
|
402
|
+
const view = buildViewForViewport(computed, vp);
|
|
403
|
+
if (!view) {
|
|
404
|
+
warnings.push(`Viewport '${vp.id}': section produced no drawable geometry at this cut.`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
models[vp.id] = view;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
cache.evictExcept(usedKeys);
|
|
411
|
+
|
|
412
|
+
if (Object.keys(models).length === 0) {
|
|
413
|
+
return { svg: '', warnings, segmentCount };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
input.onProgress?.(0.9, 'Rendering sheet');
|
|
417
|
+
const { svg, warnings: renderWarnings } = await renderDrawing({
|
|
418
|
+
// Already variable-resolved above — pass no variables down.
|
|
419
|
+
template,
|
|
420
|
+
models,
|
|
421
|
+
options: {
|
|
422
|
+
allowRemoteResources:
|
|
423
|
+
(template as { allowRemoteResources?: boolean }).allowRemoteResources ?? input.allowRemoteResources,
|
|
424
|
+
qrContext: input.qrContext,
|
|
425
|
+
metadataById: fullMetadata,
|
|
426
|
+
// Feeds labels/legend items, keyed by viewport so each legend describes the viewport it
|
|
427
|
+
// declares — a sheet may carry several legends over several plans. This used to be one
|
|
428
|
+
// flat list taken from the first plan viewport, which handed every legend that plan's
|
|
429
|
+
// contents no matter what it was bound to.
|
|
430
|
+
productEntries: Object.keys(entriesByViewport).length ? entriesByViewport : undefined
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
input.onProgress?.(1, 'Complete');
|
|
434
|
+
return { svg, warnings: [...warnings, ...renderWarnings], segmentCount };
|
|
435
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { isRecord } from './typeGuards';
|
|
2
2
|
|
|
3
|
+
/** Round a coordinate for SVG output (3 decimals ≈ 1 µm at mm scale) —
|
|
4
|
+
* emitting raw doubles bloats the SVG with 17-digit numbers. */
|
|
5
|
+
export const formatNumber = (value: number): number =>
|
|
6
|
+
Number.isFinite(value) ? Number.parseFloat(value.toFixed(3)) : 0;
|
|
7
|
+
|
|
3
8
|
export const escapeAttr = (value: unknown): string =>
|
|
4
9
|
String(value).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
5
10
|
|
|
@@ -21,8 +26,51 @@ export const getPathValue = (source: unknown, path: string): unknown => {
|
|
|
21
26
|
return current;
|
|
22
27
|
};
|
|
23
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Split a placeholder expression into its value path and optional format
|
|
31
|
+
* filter: "area | round:1" → { path: 'area', filter: 'round:1' }.
|
|
32
|
+
*/
|
|
33
|
+
export const splitPlaceholder = (expr: string): { path: string; filter: string | null } => {
|
|
34
|
+
const pipe = expr.indexOf('|');
|
|
35
|
+
if (pipe < 0) return { path: expr.trim(), filter: null };
|
|
36
|
+
return { path: expr.slice(0, pipe).trim(), filter: expr.slice(pipe + 1).trim() };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply a placeholder format filter to a resolved value:
|
|
41
|
+
* - `round` / `round:N` — round to N decimals (default 0), trailing zeros trimmed
|
|
42
|
+
* - `fixed` / `fixed:N` — exactly N decimals (default 0), for aligned output
|
|
43
|
+
* Non-numeric values pass through unchanged; unknown filters warn and pass
|
|
44
|
+
* through so a typo degrades to the unformatted value, not an empty label.
|
|
45
|
+
*/
|
|
46
|
+
export const applyPlaceholderFilter = (
|
|
47
|
+
value: unknown,
|
|
48
|
+
filter: string | null,
|
|
49
|
+
warnings?: string[],
|
|
50
|
+
context?: string
|
|
51
|
+
): unknown => {
|
|
52
|
+
if (!filter) return value;
|
|
53
|
+
const m = /^(round|fixed)(?:\s*:\s*(\d+))?$/.exec(filter);
|
|
54
|
+
if (!m) {
|
|
55
|
+
warnings?.push(`Unknown placeholder filter '${filter}'${context ? ` in "${context}"` : ''}.`);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
59
|
+
if (typeof value === 'boolean' || value === '' || !Number.isFinite(num)) return value;
|
|
60
|
+
const digits = m[2] ? Number(m[2]) : 0;
|
|
61
|
+
// toFixed throws RangeError outside 0..100, and the digit count comes from the template — a
|
|
62
|
+
// stray `{{ x | fixed:200 }}` would take the whole render down rather than warning about itself.
|
|
63
|
+
if (!Number.isInteger(digits) || digits < 0 || digits > 100) {
|
|
64
|
+
warnings?.push(`Filter '${filter}' needs 0-100 decimals${context ? ` in "${context}"` : ''}.`);
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
const fixed = num.toFixed(digits);
|
|
68
|
+
return m[1] === 'fixed' ? fixed : String(Number(fixed));
|
|
69
|
+
};
|
|
70
|
+
|
|
24
71
|
export const interpolate = (template: string, data: any, warnings?: string[]): string =>
|
|
25
|
-
template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match,
|
|
72
|
+
template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, expr) => {
|
|
73
|
+
const { path, filter } = splitPlaceholder(expr);
|
|
26
74
|
const value = getPathValue(data, path);
|
|
27
75
|
if (value === undefined || value === null) {
|
|
28
76
|
if (warnings) {
|
|
@@ -38,15 +86,16 @@ export const interpolate = (template: string, data: any, warnings?: string[]): s
|
|
|
38
86
|
}
|
|
39
87
|
return '';
|
|
40
88
|
}
|
|
41
|
-
return String(value);
|
|
89
|
+
return String(applyPlaceholderFilter(value, filter, warnings, template));
|
|
42
90
|
});
|
|
43
91
|
|
|
44
92
|
export const interpolateWithSources = (template: string, sources: any[]): string =>
|
|
45
|
-
template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match,
|
|
93
|
+
template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, expr) => {
|
|
94
|
+
const { path, filter } = splitPlaceholder(expr);
|
|
46
95
|
for (const source of sources) {
|
|
47
96
|
const value = getPathValue(source, path);
|
|
48
97
|
if (value !== undefined && value !== null && typeof value !== 'object') {
|
|
49
|
-
return String(value);
|
|
98
|
+
return String(applyPlaceholderFilter(value, filter));
|
|
50
99
|
}
|
|
51
100
|
}
|
|
52
101
|
return '';
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ViewportElement } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The template's per-viewport section contract, resolved to one canonical
|
|
5
|
+
* shape. Templates declare either `cutHeight` (+ `depth`) for horizontal
|
|
6
|
+
* plans or `planeNormal` + `planePosition` for elevations/sections; this
|
|
7
|
+
* module is the single reader/writer of those fields so every consumer
|
|
8
|
+
* (workbench, viewer print dialog, …) interprets them identically.
|
|
9
|
+
*
|
|
10
|
+
* Canonical form: plans always have `normal = [0, 1, 0]` and `position` =
|
|
11
|
+
* world Y (a `planeNormal: [0,-1,0]` declaration is folded into that), so
|
|
12
|
+
* `position` can be used directly as the cut height.
|
|
13
|
+
*/
|
|
14
|
+
export interface ResolvedSectionDef {
|
|
15
|
+
/** True when the cut is a horizontal plan (normal along Y). */
|
|
16
|
+
plan: boolean;
|
|
17
|
+
/** Normalized cut-plane normal (world). `[0, 1, 0]` for plans. */
|
|
18
|
+
normal: [number, number, number];
|
|
19
|
+
/** Position along the normal in world metres — world Y for plans. */
|
|
20
|
+
position: number;
|
|
21
|
+
/** Slab thickness in front of / behind the plane (metres). */
|
|
22
|
+
depth: { above: number; below: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_SECTION_DEPTH = { above: 0.5, below: 1.5 } as const;
|
|
26
|
+
|
|
27
|
+
export interface SectionDefaults {
|
|
28
|
+
cutHeight?: number;
|
|
29
|
+
depth?: { above?: number; below?: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const AXIS_EPSILON = 1e-6;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when a section field carries a whole-value variable binding
|
|
36
|
+
* (`"{{vars.cutHeight}}"`). Bound fields resolve to numbers only after
|
|
37
|
+
* `applyTemplateVariables` — resolveViewportSection treats them as
|
|
38
|
+
* unresolvable rather than guessing.
|
|
39
|
+
*/
|
|
40
|
+
export function isVariableBinding(v: unknown): boolean {
|
|
41
|
+
return typeof v === 'string' && /^\{\{\s*vars\.\w+\s*\}\}$/.test(v);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when any of the viewport's section fields is variable-bound. */
|
|
45
|
+
export function sectionUsesVariables(vp: Pick<ViewportElement, 'cutHeight' | 'planePosition'>): boolean {
|
|
46
|
+
return isVariableBinding(vp.cutHeight) || isVariableBinding(vp.planePosition);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read a viewport element's section definition. `planeNormal` +
|
|
51
|
+
* `planePosition` takes precedence over `cutHeight` (matching the field
|
|
52
|
+
* docs); returns null when the viewport declares nothing (and no default
|
|
53
|
+
* `cutHeight` is provided) or when a section field is still variable-bound
|
|
54
|
+
* (resolve the template's variables first).
|
|
55
|
+
*/
|
|
56
|
+
export function resolveViewportSection(
|
|
57
|
+
vp: Pick<ViewportElement, 'cutHeight' | 'planeNormal' | 'planePosition' | 'depth'>,
|
|
58
|
+
defaults?: SectionDefaults
|
|
59
|
+
): ResolvedSectionDef | null {
|
|
60
|
+
// Unresolved bindings: never guess a position — callers must apply
|
|
61
|
+
// template variables before resolving sections.
|
|
62
|
+
if (sectionUsesVariables(vp)) return null;
|
|
63
|
+
|
|
64
|
+
const above =
|
|
65
|
+
typeof vp.depth?.above === 'number' ? vp.depth.above : (defaults?.depth?.above ?? DEFAULT_SECTION_DEPTH.above);
|
|
66
|
+
const below =
|
|
67
|
+
typeof vp.depth?.below === 'number' ? vp.depth.below : (defaults?.depth?.below ?? DEFAULT_SECTION_DEPTH.below);
|
|
68
|
+
const depth = { above: Math.max(0, above), below: Math.max(0, below) };
|
|
69
|
+
|
|
70
|
+
const n = vp.planeNormal;
|
|
71
|
+
if (Array.isArray(n) && n.length === 3 && n.every((v) => typeof v === 'number')) {
|
|
72
|
+
const len = Math.hypot(n[0], n[1], n[2]);
|
|
73
|
+
if (len > AXIS_EPSILON) {
|
|
74
|
+
let normal: [number, number, number] = [n[0] / len, n[1] / len, n[2] / len];
|
|
75
|
+
let position = typeof vp.planePosition === 'number' ? vp.planePosition : 0;
|
|
76
|
+
const plan = Math.abs(normal[0]) < AXIS_EPSILON && Math.abs(normal[2]) < AXIS_EPSILON;
|
|
77
|
+
if (plan && normal[1] < 0) {
|
|
78
|
+
// Canonicalize plans to +Y so position === world Y.
|
|
79
|
+
normal = [0, 1, 0];
|
|
80
|
+
position = -position;
|
|
81
|
+
} else if (plan) {
|
|
82
|
+
normal = [0, 1, 0];
|
|
83
|
+
}
|
|
84
|
+
return { plan, normal, position, depth };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof vp.cutHeight === 'number') {
|
|
89
|
+
return { plan: true, normal: [0, 1, 0], position: vp.cutHeight, depth };
|
|
90
|
+
}
|
|
91
|
+
if (typeof defaults?.cutHeight === 'number') {
|
|
92
|
+
return { plan: true, normal: [0, 1, 0], position: defaults.cutHeight, depth };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Write a section definition back onto a viewport element in canonical form:
|
|
99
|
+
* plans use the `cutHeight` shorthand (and drop `planeNormal`/`planePosition`),
|
|
100
|
+
* everything else uses `planeNormal` + `planePosition` (and drops
|
|
101
|
+
* `cutHeight`). Keeping reader and writer together prevents the two
|
|
102
|
+
* shorthands from coexisting on one element, where reader precedence would
|
|
103
|
+
* silently decide.
|
|
104
|
+
*/
|
|
105
|
+
export function writeViewportSection(
|
|
106
|
+
vp: Partial<Pick<ViewportElement, 'cutHeight' | 'planeNormal' | 'planePosition' | 'depth'>>,
|
|
107
|
+
def: ResolvedSectionDef
|
|
108
|
+
): void {
|
|
109
|
+
vp.depth = { above: def.depth.above, below: def.depth.below };
|
|
110
|
+
if (def.plan) {
|
|
111
|
+
vp.cutHeight = def.normal[1] >= 0 ? def.position : -def.position;
|
|
112
|
+
delete vp.planeNormal;
|
|
113
|
+
delete vp.planePosition;
|
|
114
|
+
} else {
|
|
115
|
+
vp.planeNormal = [def.normal[0], def.normal[1], def.normal[2]];
|
|
116
|
+
vp.planePosition = def.position;
|
|
117
|
+
delete vp.cutHeight;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fmtNum = (v: number): string => String(Math.round(v * 1e6) / 1e6);
|
|
122
|
+
|
|
123
|
+
/** Stable cache key for a resolved section definition (same cut ⇒ same key). */
|
|
124
|
+
export function sectionDefKey(def: ResolvedSectionDef): string {
|
|
125
|
+
return [
|
|
126
|
+
def.plan ? 'plan' : 'section',
|
|
127
|
+
def.normal.map(fmtNum).join(','),
|
|
128
|
+
fmtNum(def.position),
|
|
129
|
+
`${fmtNum(def.depth.above)}/${fmtNum(def.depth.below)}`
|
|
130
|
+
].join('|');
|
|
131
|
+
}
|