partforge 0.53.0 → 0.55.0
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/docs/AUTHORING-PARTS.md +12 -2
- package/package.json +1 -1
- package/src/framework/app.css +27 -2
- package/src/framework/cutaway-controls.js +4 -27
- package/src/framework/cutaway-gizmo-scene.js +3 -1
- package/src/framework/jobs.js +75 -2
- package/src/framework/measure/dim3-place.js +455 -0
- package/src/framework/measure/dim3-scene.js +438 -0
- package/src/framework/measure/feature-dims.js +258 -0
- package/src/framework/measure/measure-controls.js +110 -0
- package/src/framework/measure/measure-mode.js +546 -0
- package/src/framework/measure/param-link.js +32 -0
- package/src/framework/measure/pins.js +36 -0
- package/src/framework/mount.js +58 -5
- package/src/framework/oracle/match.js +263 -0
- package/src/framework/oracle/measure.js +5 -1
- package/src/framework/oracle/silhouette.js +146 -0
- package/src/framework/panel/render.js +41 -0
- package/src/framework/selection/drag-tracker.js +39 -0
- package/src/framework/selection/feature-highlight.js +99 -0
- package/src/framework/selection/hover.js +14 -90
- package/src/framework/selection/index.js +1 -0
- package/src/framework/selection/pick.js +11 -37
- package/src/framework/teardown.js +29 -0
- package/src/framework/viewer.js +25 -1
- package/src/testing.js +5 -0
- package/types/index.d.ts +16 -0
- package/types/testing.d.ts +116 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// PURE heuristic linking a measured dimension to the schema param driving it.
|
|
2
|
+
// Candidates come from the sub-part's read keys (param-deps.subPartReadKeys,
|
|
3
|
+
// resolved by the caller); a candidate links when its current value matches a
|
|
4
|
+
// measured value within the display quantum (0.01), or at value*2 for
|
|
5
|
+
// radius-style params against measured diameters. Unique match or nothing —
|
|
6
|
+
// never guess between two.
|
|
7
|
+
const QUANTUM = 0.005; // half the 0.01 display quantum: |a-b| < 0.005 rounds equal
|
|
8
|
+
|
|
9
|
+
// Every candidate whose current value matches a measured value (or matches
|
|
10
|
+
// diameter at value*2, the radius-style rule) — the set a measurement click
|
|
11
|
+
// flashes. linkParam keeps the stricter unique-or-nothing rule for focus.
|
|
12
|
+
export function paramMatches(keys, params, values) {
|
|
13
|
+
const measured = Object.entries(values)
|
|
14
|
+
.filter(([k, v]) => typeof v === "number" && k !== "partial")
|
|
15
|
+
.map(([, v]) => v);
|
|
16
|
+
const hasDiameter = "diameter" in values;
|
|
17
|
+
const matches = new Set();
|
|
18
|
+
for (const key of keys) {
|
|
19
|
+
const pv = params[key];
|
|
20
|
+
if (typeof pv !== "number") continue;
|
|
21
|
+
for (const mv of measured) {
|
|
22
|
+
if (Math.abs(pv - mv) < QUANTUM) { matches.add(key); break; }
|
|
23
|
+
if (hasDiameter && Math.abs(pv * 2 - values.diameter) < QUANTUM) { matches.add(key); break; }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...matches];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function linkParam(keys, params, values) {
|
|
30
|
+
const matches = paramMatches(keys, params, values);
|
|
31
|
+
return matches.length === 1 ? matches[0] : null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// PURE pin store for measurement mode. Pins are PER-VIEW and keyed on stable
|
|
2
|
+
// identity — (subPart, featureLabel|null, occurrence) — not on geometry, so a
|
|
3
|
+
// regenerate re-resolves them by label (dormant when the label is gone, revived
|
|
4
|
+
// when it returns). `occurrence` disambiguates duplicate labels: it counts
|
|
5
|
+
// same-label features earlier in the features table.
|
|
6
|
+
const keyString = ({ subPart, featureLabel, occurrence }) =>
|
|
7
|
+
`${subPart}\n${featureLabel}\n${occurrence}`;
|
|
8
|
+
|
|
9
|
+
export function occurrenceOf(features, featureId) {
|
|
10
|
+
const label = features[featureId - 1];
|
|
11
|
+
let n = 0;
|
|
12
|
+
for (let i = 0; i < featureId - 1; i++) if (features[i] === label) n++;
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createPinStore() {
|
|
17
|
+
const byView = new Map(); // view -> Map(keyString -> key)
|
|
18
|
+
const viewMap = (view) => {
|
|
19
|
+
let m = byView.get(view);
|
|
20
|
+
if (!m) { m = new Map(); byView.set(view, m); }
|
|
21
|
+
return m;
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
// -> true when the pin was added, false when it was removed
|
|
25
|
+
toggle(view, key) {
|
|
26
|
+
const m = viewMap(view), ks = keyString(key);
|
|
27
|
+
if (m.has(ks)) { m.delete(ks); return false; }
|
|
28
|
+
m.set(ks, { ...key, occurrence: key.occurrence ?? 0 });
|
|
29
|
+
return true;
|
|
30
|
+
},
|
|
31
|
+
has: (view, key) => viewMap(view).has(keyString(key)),
|
|
32
|
+
list: (view) => [...viewMap(view).values()],
|
|
33
|
+
clear: (view) => { viewMap(view).clear(); },
|
|
34
|
+
count: (view) => viewMap(view).size,
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -26,14 +26,19 @@ import { createExportController, backendForFormat } from "./export-controller.js
|
|
|
26
26
|
import { createCaptureBuild } from "./capture-build.js";
|
|
27
27
|
import { attachAnimationControls } from "./animation-controls.js";
|
|
28
28
|
import { resolveDefaultView } from "./default-view.js";
|
|
29
|
+
import { createMeasureMode } from "./measure/measure-mode.js";
|
|
30
|
+
import { attachMeasureControls } from "./measure/measure-controls.js";
|
|
29
31
|
|
|
30
32
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
31
33
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
32
34
|
// The default no-op tooltip binding, so a host can hold on to whatever
|
|
33
35
|
// attachTooltips returned without caring whether this mount resolved one.
|
|
34
36
|
const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {} };
|
|
37
|
+
// Same no-op-default stance as attachTooltips/setHostPane below, for a
|
|
38
|
+
// makeHandle caller (or a direct test) that doesn't wire measure mode.
|
|
39
|
+
const NOOP_MEASURE = { isEnabled: () => false, setEnabled: () => {}, clearPins: () => {}, pinCount: () => 0 };
|
|
35
40
|
|
|
36
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips }) {
|
|
41
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure }) {
|
|
37
42
|
return {
|
|
38
43
|
ready, dispose, setParams,
|
|
39
44
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -70,6 +75,11 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
70
75
|
// title/aria-label); returns { sync, hide, detach }. Same no-op default
|
|
71
76
|
// stance as setHostPane above.
|
|
72
77
|
attachTooltips: attachTooltips ?? (() => NOOP_TOOLTIP_BINDING),
|
|
78
|
+
// Measurement-mode API (spec Goal 3): { isEnabled, setEnabled, clearPins,
|
|
79
|
+
// pinCount } — an embedder drives the mode without the built-in ruler
|
|
80
|
+
// button. Dimensions render in the scene, so a dimensioned capture is just
|
|
81
|
+
// captureCurrent() taken while the mode is on.
|
|
82
|
+
measure: measure ?? NOOP_MEASURE,
|
|
73
83
|
};
|
|
74
84
|
}
|
|
75
85
|
|
|
@@ -206,6 +216,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
206
216
|
reframe: elements.chrome?.reframe ?? byId("reframe"),
|
|
207
217
|
theme: elements.chrome?.theme ?? byId("theme"),
|
|
208
218
|
cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
|
|
219
|
+
measure: elements.chrome?.measure ?? byId("measure"),
|
|
209
220
|
railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
|
|
210
221
|
},
|
|
211
222
|
};
|
|
@@ -216,10 +227,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
216
227
|
cleanup.defer(() => viewer.dispose());
|
|
217
228
|
const tooltip = createTooltipPresenter({ id: null });
|
|
218
229
|
cleanup.defer(() => tooltip.dispose());
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
230
|
+
// cutawayChrome + measureMode/measureChrome are attached further down (just
|
|
231
|
+
// after `params` exists — measure's getContext needs both `view()` and
|
|
232
|
+
// `params`, and cutaway needs measureMode for its escapeGuard). Nothing
|
|
233
|
+
// between here and there depends on cutawayChrome except the view-tabs
|
|
234
|
+
// onChange closure below, which only runs on a later user/programmatic tab
|
|
235
|
+
// change, never during this synchronous setup.
|
|
223
236
|
// Resizable/collapsible controls rail. No-ops when the host lays out the
|
|
224
237
|
// framework itself (no #panel / no elements.rail).
|
|
225
238
|
const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell, tooltip });
|
|
@@ -283,6 +296,39 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
283
296
|
onViewChange?.(tabsCtl.current());
|
|
284
297
|
const params = { ...part.defaults };
|
|
285
298
|
|
|
299
|
+
// Measurement mode: in-scene dims + pins + dimension->controls reveal
|
|
300
|
+
// (clicking a measurement flashes every control that can drive it). The
|
|
301
|
+
// panel is built later in this function, so revealParams is a late-bound
|
|
302
|
+
// thunk — same idiom for getParamsVersion, which needs `loop` (created
|
|
303
|
+
// further below); readsFor's memo keys on it instead of hashing the whole
|
|
304
|
+
// params object per call, mirroring createMeshCache/createPoseFastPath.
|
|
305
|
+
let panelRef = null;
|
|
306
|
+
const measureMode = createMeasureMode(viewer, {
|
|
307
|
+
part,
|
|
308
|
+
getContext: () => ({ view: view(), params }),
|
|
309
|
+
revealParams: (keys, focusKey) => panelRef?.revealParams(keys, focusKey),
|
|
310
|
+
getParamsVersion: () => loop.version(),
|
|
311
|
+
});
|
|
312
|
+
cleanup.defer(() => measureMode.detach());
|
|
313
|
+
// escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
|
|
314
|
+
// #viewbar, not descendants of the canvas — attaching Escape to
|
|
315
|
+
// viewer.domElement alone would leave a guarded Escape from those buttons
|
|
316
|
+
// dead. els.viewer (the stage) is a shared ancestor of both, so Escape
|
|
317
|
+
// bubbles up from either.
|
|
318
|
+
const measureChrome = attachMeasureControls(viewer, measureMode, {
|
|
319
|
+
measure: els.chrome.measure,
|
|
320
|
+
}, { tooltip, escapeScope: els.viewer });
|
|
321
|
+
cleanup.defer(() => measureChrome.detach());
|
|
322
|
+
const cutawayChrome = attachCutawayControls(viewer, {
|
|
323
|
+
cutaway: els.chrome.cutaway,
|
|
324
|
+
}, { tooltip, escapeGuard: () => measureMode.isEnabled() });
|
|
325
|
+
cleanup.defer(() => cutawayChrome.detach());
|
|
326
|
+
// Suppress the always-on hover tooltip while measure mode is active — its
|
|
327
|
+
// own feature highlight + dims take over the pointer.
|
|
328
|
+
const offMeasureHover = measureMode.onModeChange(() =>
|
|
329
|
+
hover.setSuppressed(measureMode.isEnabled()));
|
|
330
|
+
cleanup.defer(offMeasureHover);
|
|
331
|
+
|
|
286
332
|
// Current selection context for the pickers: the active view + live params +
|
|
287
333
|
// derived values. Shared by every pick mode below.
|
|
288
334
|
const getContext = () => {
|
|
@@ -530,6 +576,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
530
576
|
? (changed) => onParamsCommit({ changed, params: { ...params } })
|
|
531
577
|
: undefined);
|
|
532
578
|
cleanup.defer(() => panel.dispose());
|
|
579
|
+
panelRef = panel;
|
|
533
580
|
const updateRelevance = () => {
|
|
534
581
|
// A throwing derive() must not break every slider drag — mount's pick
|
|
535
582
|
// flow already guards its own resolveDerived call the same way
|
|
@@ -681,6 +728,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
681
728
|
exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
|
|
682
729
|
exportParts: (opts) => exportCtl.exportParts(opts),
|
|
683
730
|
animation: animCtl?.runtime ?? null,
|
|
731
|
+
measure: {
|
|
732
|
+
isEnabled: measureMode.isEnabled,
|
|
733
|
+
setEnabled: measureMode.setEnabled,
|
|
734
|
+
clearPins: measureMode.clearPins,
|
|
735
|
+
pinCount: measureMode.pinCount,
|
|
736
|
+
},
|
|
684
737
|
});
|
|
685
738
|
} catch (error) {
|
|
686
739
|
try {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Mask comparison: score a candidate silhouette against a reference one. Consumes the
|
|
2
|
+
// masks oracle/silhouette.js produces and answers "how close is this shape?" four ways —
|
|
3
|
+
// area overlap (`iou`), rim overlap (`boundaryIoU`), a mean contour separation
|
|
4
|
+
// (`contourDist`), and a per-pixel `delta` map naming what is missing versus extra. No
|
|
5
|
+
// kernel, no DOM, no three, no `node:`, so it runs in the geometry worker like the rest
|
|
6
|
+
// of the oracle.
|
|
7
|
+
//
|
|
8
|
+
// Shape is compared POSE-NORMALIZED: each mask is cropped to its tight foreground bbox,
|
|
9
|
+
// scaled so its longest side fills 0.92 of a 256² frame, and centroid-aligned there. So
|
|
10
|
+
// `iou`, `boundaryIoU`, and `delta` are invariant to where the part sits and how big it
|
|
11
|
+
// is, which is what "does it look like the picture?" means. Absolute size is a separate
|
|
12
|
+
// question and only `{scaleAware: true}` asks it — and only when BOTH masks carry a
|
|
13
|
+
// finite mmPerPx: `iouScale` overlays them on the reference's own mm grid,
|
|
14
|
+
// centroid-aligned and never rescaled, so a part twice too big scores 0.25, and
|
|
15
|
+
// `contourDist` is then a real millimetre distance instead of a fraction of the
|
|
16
|
+
// reference's bbox diagonal.
|
|
17
|
+
//
|
|
18
|
+
// A mask with no foreground pixels — or no mask at all — is UNSCOREABLE, not
|
|
19
|
+
// zero-scoring: matchMasks returns null and matchViews leaves the view out. 0/0 is
|
|
20
|
+
// never a score.
|
|
21
|
+
|
|
22
|
+
import { MATCH_VIEWS } from "./silhouette.js";
|
|
23
|
+
|
|
24
|
+
const S = 256; // internal normalization frame, whatever the input mask sizes
|
|
25
|
+
const FILL = 0.92; // fraction of the frame the longest bbox side occupies
|
|
26
|
+
const BAND_PX = 2; // boundary band thickness, per the Boundary IoU definition
|
|
27
|
+
const BIG = 1e20; // "unreachable" seed for the distance transform's lower envelope
|
|
28
|
+
const MAX_MM_FRAME = 2048; // px ceiling on the scale-aware grid; see mmFrame
|
|
29
|
+
|
|
30
|
+
// candidate/reference: Task 1 masks. opts: {scaleAware}. → null when either is unscoreable.
|
|
31
|
+
export function matchMasks(candidate, reference, opts = {}) {
|
|
32
|
+
const cs = stats(candidate), rs = stats(reference);
|
|
33
|
+
if (!cs || !rs) return null;
|
|
34
|
+
|
|
35
|
+
const nc = normalize(cs), nr = normalize(rs);
|
|
36
|
+
const iou = maskIoU(nc, nr);
|
|
37
|
+
const boundaryIoU = maskIoU(band(nc), band(nr));
|
|
38
|
+
const delta = deltaMap(nc, nr);
|
|
39
|
+
|
|
40
|
+
const scaleAware = opts.scaleAware === true && scaled(candidate) && scaled(reference);
|
|
41
|
+
let contourDist, contourUnit, iouScale;
|
|
42
|
+
if (scaleAware) {
|
|
43
|
+
const [sc, sr, pitch] = mmFrame(cs, candidate.mmPerPx, rs, reference.mmPerPx);
|
|
44
|
+
iouScale = maskIoU(sc, sr);
|
|
45
|
+
contourDist = contourDistance(sc, sr) * pitch;
|
|
46
|
+
contourUnit = "mm";
|
|
47
|
+
} else {
|
|
48
|
+
const diag = Math.hypot(nr.bw, nr.bh);
|
|
49
|
+
contourDist = (contourDistance(nc, nr) / diag) * 100;
|
|
50
|
+
contourUnit = "%bbox-diag";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const out = { iou, boundaryIoU, contourDist, contourUnit, delta };
|
|
54
|
+
if (scaleAware) out.iouScale = iouScale;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// viewMasks: {front: mask|null, ...} → {best: {view, ...scores}|null, views: {view: iou}}.
|
|
59
|
+
// Views are walked in MATCH_VIEWS order, so a tie on `iou` resolves to the earlier view
|
|
60
|
+
// regardless of the object's own key order.
|
|
61
|
+
export function matchViews(viewMasks, reference, opts = {}) {
|
|
62
|
+
const views = {};
|
|
63
|
+
let best = null;
|
|
64
|
+
for (const view of MATCH_VIEWS) {
|
|
65
|
+
const scores = matchMasks(viewMasks?.[view], reference, opts);
|
|
66
|
+
if (!scores) continue;
|
|
67
|
+
views[view] = scores.iou;
|
|
68
|
+
if (!best || scores.iou > best.iou) best = { view, ...scores };
|
|
69
|
+
}
|
|
70
|
+
return { best, views };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Squared-then-rooted Euclidean distance (in px) from every pixel to the nearest non-zero
|
|
74
|
+
// pixel of `data`, by Felzenszwalb–Huttenlocher: a 1D lower-envelope pass over rows, then
|
|
75
|
+
// over columns. With no non-zero pixel at all every entry comes back astronomically large
|
|
76
|
+
// rather than infinite, which keeps the envelope's arithmetic finite.
|
|
77
|
+
export function distanceTransform(data, width, height) {
|
|
78
|
+
const n = width * height;
|
|
79
|
+
const sq = new Float64Array(n);
|
|
80
|
+
for (let i = 0; i < n; i++) sq[i] = data[i] ? 0 : BIG;
|
|
81
|
+
|
|
82
|
+
const m = Math.max(width, height);
|
|
83
|
+
const f = new Float64Array(m), d = new Float64Array(m);
|
|
84
|
+
const v = new Int32Array(m), z = new Float64Array(m + 1);
|
|
85
|
+
for (let r = 0; r < height; r++) {
|
|
86
|
+
for (let c = 0; c < width; c++) f[c] = sq[r * width + c];
|
|
87
|
+
envelope(f, width, d, v, z);
|
|
88
|
+
for (let c = 0; c < width; c++) sq[r * width + c] = d[c];
|
|
89
|
+
}
|
|
90
|
+
for (let c = 0; c < width; c++) {
|
|
91
|
+
for (let r = 0; r < height; r++) f[r] = sq[r * width + c];
|
|
92
|
+
envelope(f, height, d, v, z);
|
|
93
|
+
for (let r = 0; r < height; r++) sq[r * width + c] = d[r];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const out = new Float32Array(n);
|
|
97
|
+
for (let i = 0; i < n; i++) out[i] = Math.sqrt(sq[i]);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Lower envelope of the parabolas (q - x)² + f[q]: `v` holds the parabolas in the
|
|
102
|
+
// envelope and `z` the boundaries between them. d[] comes back as squared distances.
|
|
103
|
+
function envelope(f, n, d, v, z) {
|
|
104
|
+
let k = 0;
|
|
105
|
+
v[0] = 0; z[0] = -Infinity; z[1] = Infinity;
|
|
106
|
+
for (let q = 1; q < n; q++) {
|
|
107
|
+
let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
|
|
108
|
+
while (s <= z[k]) {
|
|
109
|
+
k--;
|
|
110
|
+
s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
|
|
111
|
+
}
|
|
112
|
+
k++; v[k] = q; z[k] = s; z[k + 1] = Infinity;
|
|
113
|
+
}
|
|
114
|
+
k = 0;
|
|
115
|
+
for (let q = 0; q < n; q++) {
|
|
116
|
+
while (z[k + 1] < q) k++;
|
|
117
|
+
d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const scaled = (mask) => Number.isFinite(mask?.mmPerPx) && mask.mmPerPx > 0;
|
|
122
|
+
|
|
123
|
+
// Foreground bbox, centroid, and pixel count — null when there is nothing to score.
|
|
124
|
+
function stats(mask) {
|
|
125
|
+
const data = mask?.data, w = mask?.width | 0, h = mask?.height | 0;
|
|
126
|
+
if (!data || !(w > 0) || !(h > 0) || data.length < w * h) return null;
|
|
127
|
+
let c0 = w, c1 = -1, r0 = h, r1 = -1, n = 0, sc = 0, sr = 0;
|
|
128
|
+
for (let r = 0; r < h; r++) for (let c = 0; c < w; c++) {
|
|
129
|
+
if (!data[r * w + c]) continue;
|
|
130
|
+
n++; sc += c; sr += r;
|
|
131
|
+
if (c < c0) c0 = c;
|
|
132
|
+
if (c > c1) c1 = c;
|
|
133
|
+
if (r < r0) r0 = r;
|
|
134
|
+
if (r > r1) r1 = r;
|
|
135
|
+
}
|
|
136
|
+
return n ? { data, w, h, c0, c1, r0, r1, cx: sc / n, cy: sr / n, count: n } : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Crop to the bbox, scale the longer side to FILL*S, centroid-align into the S² frame,
|
|
140
|
+
// nearest-neighbour. Sampling is done backwards from the output so the two masks share
|
|
141
|
+
// one mapping form and identical shapes at different scales land on identical pixels.
|
|
142
|
+
//
|
|
143
|
+
// The scale is bbox-derived while the alignment is centroid-derived, so a shape whose
|
|
144
|
+
// centroid sits far from its bbox centre (an L) would run past the frame edge and be
|
|
145
|
+
// silently clipped. The guard shrinks such a shape to fit; it is a function of the
|
|
146
|
+
// shape's own proportions, so two copies of it at different scales still normalize
|
|
147
|
+
// identically and still score 1.
|
|
148
|
+
function normalize(st) {
|
|
149
|
+
const bw = st.c1 - st.c0 + 1, bh = st.r1 - st.r0 + 1;
|
|
150
|
+
let s = (FILL * S) / Math.max(bw, bh);
|
|
151
|
+
const reach = Math.max(st.cx - st.c0, st.c1 - st.cx, st.cy - st.r0, st.r1 - st.cy) + 0.5;
|
|
152
|
+
const room = (S - 2) / 2;
|
|
153
|
+
if (reach * s > room) s = room / reach;
|
|
154
|
+
|
|
155
|
+
const mid = (S - 1) / 2;
|
|
156
|
+
const data = new Uint8Array(S * S);
|
|
157
|
+
for (let R = 0; R < S; R++) {
|
|
158
|
+
const r = Math.round((R - mid) / s + st.cy);
|
|
159
|
+
if (r < st.r0 || r > st.r1) continue;
|
|
160
|
+
for (let C = 0; C < S; C++) {
|
|
161
|
+
const c = Math.round((C - mid) / s + st.cx);
|
|
162
|
+
if (c < st.c0 || c > st.c1) continue;
|
|
163
|
+
if (st.data[r * st.w + c]) data[R * S + C] = 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { data, width: S, height: S, bw: bw * s, bh: bh * s };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Both masks resampled onto ONE grid at the reference's mmPerPx, centroid-aligned and
|
|
170
|
+
// never rescaled — the frame where absolute size is the question. Sized to hold both, so
|
|
171
|
+
// an oversized candidate is measured rather than cropped by the reference's own extent.
|
|
172
|
+
// Returns the two masks and the grid's pitch in mm/px, which is the reference's own
|
|
173
|
+
// except when a wildly oversized candidate would need a frame past MAX_MM_FRAME: then the
|
|
174
|
+
// grid COARSENS rather than crops, since a truncated union would flatter the candidate.
|
|
175
|
+
function mmFrame(cs, cmm, rs, rmm) {
|
|
176
|
+
// Half-extents in mm from each mask's own centroid, unioned so neither is cropped.
|
|
177
|
+
const span = (st, mm) => [
|
|
178
|
+
(st.cx - st.c0) * mm + mm / 2, (st.c1 - st.cx) * mm + mm / 2,
|
|
179
|
+
(st.cy - st.r0) * mm + mm / 2, (st.r1 - st.cy) * mm + mm / 2,
|
|
180
|
+
];
|
|
181
|
+
const other = span(rs, rmm);
|
|
182
|
+
const [left, right, up, down] = span(cs, cmm).map((v, i) => Math.max(v, other[i]));
|
|
183
|
+
|
|
184
|
+
const longest = Math.max(left + right, up + down);
|
|
185
|
+
const p = Math.max(rmm, longest / MAX_MM_FRAME);
|
|
186
|
+
const gx = Math.ceil(left / p) + 1, gy = Math.ceil(up / p) + 1;
|
|
187
|
+
const width = gx + Math.ceil(right / p) + 2, height = gy + Math.ceil(down / p) + 2;
|
|
188
|
+
|
|
189
|
+
const draw = (st, mm) => {
|
|
190
|
+
const data = new Uint8Array(width * height);
|
|
191
|
+
for (let R = 0; R < height; R++) {
|
|
192
|
+
const r = Math.round(((R - gy) * p) / mm + st.cy);
|
|
193
|
+
if (r < st.r0 || r > st.r1) continue;
|
|
194
|
+
for (let C = 0; C < width; C++) {
|
|
195
|
+
const c = Math.round(((C - gx) * p) / mm + st.cx);
|
|
196
|
+
if (c < st.c0 || c > st.c1) continue;
|
|
197
|
+
if (st.data[r * st.w + c]) data[R * width + C] = 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { data, width, height };
|
|
201
|
+
};
|
|
202
|
+
return [draw(cs, cmm), draw(rs, rmm), p];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function maskIoU(a, b) {
|
|
206
|
+
let inter = 0, union = 0;
|
|
207
|
+
for (let i = 0; i < a.data.length; i++) {
|
|
208
|
+
const x = a.data[i], y = b.data[i];
|
|
209
|
+
if (x && y) inter++;
|
|
210
|
+
if (x || y) union++;
|
|
211
|
+
}
|
|
212
|
+
return union ? inter / union : 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Boundary IoU's band: the mask minus its own erosion by BAND_PX. Erosion by a disk is
|
|
216
|
+
// the distance transform of the background, so the band is "foreground within BAND_PX of
|
|
217
|
+
// some background pixel".
|
|
218
|
+
function band(m) {
|
|
219
|
+
const inv = new Uint8Array(m.data.length);
|
|
220
|
+
for (let i = 0; i < inv.length; i++) inv[i] = m.data[i] ? 0 : 1;
|
|
221
|
+
const dt = distanceTransform(inv, m.width, m.height);
|
|
222
|
+
const data = new Uint8Array(m.data.length);
|
|
223
|
+
for (let i = 0; i < data.length; i++) if (m.data[i] && dt[i] <= BAND_PX) data[i] = 1;
|
|
224
|
+
return { data, width: m.width, height: m.height };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Contour = foreground with at least one background 4-neighbour; outside the image counts
|
|
228
|
+
// as background, so a shape flush with the edge still has one.
|
|
229
|
+
function contour(m) {
|
|
230
|
+
const { data, width, height } = m;
|
|
231
|
+
const out = new Uint8Array(data.length);
|
|
232
|
+
for (let r = 0; r < height; r++) for (let c = 0; c < width; c++) {
|
|
233
|
+
const i = r * width + c;
|
|
234
|
+
if (!data[i]) continue;
|
|
235
|
+
if (r === 0 || r === height - 1 || c === 0 || c === width - 1
|
|
236
|
+
|| !data[i - width] || !data[i + width] || !data[i - 1] || !data[i + 1]) out[i] = 1;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Symmetric mean contour separation in px: mean distance from each mask's contour to the
|
|
242
|
+
// other's, averaged both directions so neither shape's rim length dominates.
|
|
243
|
+
function contourDistance(a, b) {
|
|
244
|
+
const ca = contour(a), cb = contour(b);
|
|
245
|
+
const da = distanceTransform(ca, a.width, a.height);
|
|
246
|
+
const db = distanceTransform(cb, b.width, b.height);
|
|
247
|
+
const mean = (pts, dt) => {
|
|
248
|
+
let n = 0, sum = 0;
|
|
249
|
+
for (let i = 0; i < pts.length; i++) if (pts[i]) { n++; sum += dt[i]; }
|
|
250
|
+
return n ? sum / n : 0;
|
|
251
|
+
};
|
|
252
|
+
return (mean(cb, da) + mean(ca, db)) / 2;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 0 = background, 1 = overlap, 2 = missing (reference only), 3 = excess (candidate only).
|
|
256
|
+
function deltaMap(c, r) {
|
|
257
|
+
const data = new Uint8Array(c.data.length);
|
|
258
|
+
for (let i = 0; i < data.length; i++) {
|
|
259
|
+
const inC = c.data[i], inR = r.data[i];
|
|
260
|
+
data[i] = inC && inR ? 1 : inR ? 2 : inC ? 3 : 0;
|
|
261
|
+
}
|
|
262
|
+
return { width: c.width, height: c.height, data };
|
|
263
|
+
}
|
|
@@ -19,7 +19,11 @@ const unionBounds = (list) => list.reduce(
|
|
|
19
19
|
// → { part, view, measuredMinWall, subparts[], aggregate, overlaps[], gaps[],
|
|
20
20
|
// nearMisses[], ok }
|
|
21
21
|
export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
|
|
22
|
-
|
|
22
|
+
// `opts.built` is a build of this view the caller already has. The inspect job
|
|
23
|
+
// needs those meshes anyway — it rasterizes them for silhouette match scoring —
|
|
24
|
+
// and a second buildView here would be a whole duplicate build of the part for
|
|
25
|
+
// nothing. Absent, this measures its own build exactly as it always did.
|
|
26
|
+
const built = opts.built ?? buildView(kernel, part, view, params);
|
|
23
27
|
// ONE BVH per sub-part mesh for this call, shared by the two passes that need
|
|
24
28
|
// one: min-wall (inward rays per triangle) and meshGaps (pair distances). They
|
|
25
29
|
// used to index the same mesh objects independently, so every sub-part of a
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Orthographic silhouette masks: project a part (or a set of 2D rings) onto one of the
|
|
2
|
+
// six canonical views and scanline-fill it into a binary image. The foundation of
|
|
3
|
+
// silhouette match scoring — no kernel, no DOM, no three, no `node:`, so it runs in the
|
|
4
|
+
// geometry worker alongside the rest of the oracle.
|
|
5
|
+
//
|
|
6
|
+
// Contract: {data: Uint8Array of 0|255, width, height, mmPerPx, minX, minY}. Row 0 is
|
|
7
|
+
// the TOP of the image; minX/minY are the projected-plane coordinates of the image's
|
|
8
|
+
// BOTTOM-LEFT corner, so a caller maps a pixel back with
|
|
9
|
+
// x = minX + (col + 0.5) * mmPerPx, y = minY + (height - 0.5 - row) * mmPerPx
|
|
10
|
+
// Nothing to draw, or a projection with zero extent, returns null.
|
|
11
|
+
|
|
12
|
+
export const MATCH_VIEWS = ["front", "back", "top", "bottom", "left", "right"];
|
|
13
|
+
|
|
14
|
+
// Image axes per view as [modelAxisIndex, sign] in MODEL space (x,y,z) — the third axis
|
|
15
|
+
// is dropped. Derived from the viewer's single pivot rotation (`pivot.rotation.x =
|
|
16
|
+
// -Math.PI/2` in viewer.js, taking model Z-up to world Y-up) composed with each view's
|
|
17
|
+
// camera basis in view-angles.js. A wrong sign here mirrors every downstream match
|
|
18
|
+
// score without failing anything else, so the table is written out rather than derived.
|
|
19
|
+
const VIEW_AXES = {
|
|
20
|
+
front: { x: [0, 1], y: [2, 1] }, // drops Y
|
|
21
|
+
back: { x: [0, -1], y: [2, 1] }, // drops Y
|
|
22
|
+
right: { x: [1, 1], y: [2, 1] }, // drops X
|
|
23
|
+
left: { x: [1, -1], y: [2, 1] }, // drops X
|
|
24
|
+
top: { x: [0, 1], y: [1, 1] }, // drops Z
|
|
25
|
+
bottom: { x: [0, 1], y: [1, -1] }, // drops Z
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const PAD = 0.04; // blank fraction of the frame on each side
|
|
29
|
+
const FILL = 1 - 2 * PAD; // 0.92 — the fraction the content's longest extent occupies
|
|
30
|
+
|
|
31
|
+
// meshes: [{positions: Float32Array|number[], indices?: Uint32Array|number[]}] — `indices`
|
|
32
|
+
// optional, flat triangle soup when absent (3 verts per triangle), same as oracle/mesh.js.
|
|
33
|
+
export function rasterizeMeshMask(meshes, view, size = 256) {
|
|
34
|
+
const axes = VIEW_AXES[view];
|
|
35
|
+
if (!axes) throw new Error(`unknown match view "${view}"`);
|
|
36
|
+
const [ax, sx] = axes.x, [ay, sy] = axes.y;
|
|
37
|
+
const groups = [];
|
|
38
|
+
for (const mesh of meshes || []) {
|
|
39
|
+
const P = mesh?.positions;
|
|
40
|
+
if (!P || P.length < 9) continue;
|
|
41
|
+
const idx = mesh.indices;
|
|
42
|
+
const n = idx ? idx.length : P.length / 3;
|
|
43
|
+
for (let i = 0; i + 3 <= n; i += 3) {
|
|
44
|
+
const tri = new Float64Array(6);
|
|
45
|
+
let ok = true;
|
|
46
|
+
for (let k = 0; k < 3 && ok; k++) {
|
|
47
|
+
const base = (idx ? idx[i + k] : i + k) * 3;
|
|
48
|
+
const x = P[base + ax] * sx, y = P[base + ay] * sy;
|
|
49
|
+
if (Number.isFinite(x) && Number.isFinite(y)) { tri[k * 2] = x; tri[k * 2 + 1] = y; }
|
|
50
|
+
else ok = false;
|
|
51
|
+
}
|
|
52
|
+
if (ok) groups.push([tri]); // one triangle = one even-odd group; see fillPolygons
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return fillPolygons(groups, size);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// rings: [[[x,y], ...], ...] in mm. All rings share ONE even-odd group, so a ring inside
|
|
59
|
+
// another is a hole.
|
|
60
|
+
export function rasterizeRingsMask(rings, size = 256) {
|
|
61
|
+
const group = [];
|
|
62
|
+
for (const ring of rings || []) {
|
|
63
|
+
if (!ring || ring.length < 3) continue;
|
|
64
|
+
const flat = new Float64Array(ring.length * 2);
|
|
65
|
+
let ok = true;
|
|
66
|
+
for (let i = 0; i < ring.length && ok; i++) {
|
|
67
|
+
const x = ring[i]?.[0], y = ring[i]?.[1];
|
|
68
|
+
if (Number.isFinite(x) && Number.isFinite(y)) { flat[i * 2] = x; flat[i * 2 + 1] = y; }
|
|
69
|
+
else ok = false;
|
|
70
|
+
}
|
|
71
|
+
if (ok) group.push(flat);
|
|
72
|
+
}
|
|
73
|
+
return group.length ? fillPolygons([group], size) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A GROUP is one even-odd polygon set, given as rings of flat [x0,y0,x1,y1,...] pairs.
|
|
77
|
+
// Groups UNION with each other, which is the whole reason for the grouping: a closed mesh
|
|
78
|
+
// projects its front and back faces onto the same pixels, and even-odd across the whole
|
|
79
|
+
// soup would cancel them into background. So each triangle is its own group and a ring
|
|
80
|
+
// set is one group, and holes still subtract.
|
|
81
|
+
function fillPolygons(groups, size) {
|
|
82
|
+
if (!groups.length) return null;
|
|
83
|
+
let loX = Infinity, loY = Infinity, hiX = -Infinity, hiY = -Infinity;
|
|
84
|
+
for (const g of groups) for (const ring of g) for (let i = 0; i < ring.length; i += 2) {
|
|
85
|
+
if (ring[i] < loX) loX = ring[i];
|
|
86
|
+
if (ring[i] > hiX) hiX = ring[i];
|
|
87
|
+
if (ring[i + 1] < loY) loY = ring[i + 1];
|
|
88
|
+
if (ring[i + 1] > hiY) hiY = ring[i + 1];
|
|
89
|
+
}
|
|
90
|
+
const extent = Math.max(hiX - loX, hiY - loY);
|
|
91
|
+
if (!(extent > 0)) return null;
|
|
92
|
+
|
|
93
|
+
// Uniform scale, tight bbox, PAD on each side, centred in a square frame.
|
|
94
|
+
const mmPerPx = extent / (FILL * size);
|
|
95
|
+
const span = size * mmPerPx;
|
|
96
|
+
const minX = (loX + hiX) / 2 - span / 2, minY = (loY + hiY) / 2 - span / 2;
|
|
97
|
+
const rowOf = (y) => size - 0.5 - (y - minY) / mmPerPx; // row 0 = top: y is flipped here
|
|
98
|
+
|
|
99
|
+
// Bucket each group by its first row and drop it once past its last, so a scanline only
|
|
100
|
+
// walks the groups that can cross it (a mesh soup is thousands of tiny groups).
|
|
101
|
+
const starts = Array.from({ length: size }, () => []);
|
|
102
|
+
const lastRow = new Int32Array(groups.length);
|
|
103
|
+
for (let g = 0; g < groups.length; g++) {
|
|
104
|
+
let gLo = Infinity, gHi = -Infinity;
|
|
105
|
+
for (const ring of groups[g]) for (let i = 1; i < ring.length; i += 2) {
|
|
106
|
+
if (ring[i] < gLo) gLo = ring[i];
|
|
107
|
+
if (ring[i] > gHi) gHi = ring[i];
|
|
108
|
+
}
|
|
109
|
+
const r0 = Math.min(size - 1, Math.max(0, Math.floor(rowOf(gHi))));
|
|
110
|
+
lastRow[g] = Math.min(size - 1, Math.max(0, Math.ceil(rowOf(gLo))));
|
|
111
|
+
starts[r0].push(g);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const data = new Uint8Array(size * size);
|
|
115
|
+
const xs = [];
|
|
116
|
+
let active = [];
|
|
117
|
+
for (let r = 0; r < size; r++) {
|
|
118
|
+
if (starts[r].length) active = active.concat(starts[r]);
|
|
119
|
+
if (!active.length) continue;
|
|
120
|
+
active = active.filter((g) => lastRow[g] >= r);
|
|
121
|
+
const y = minY + (size - 0.5 - r) * mmPerPx;
|
|
122
|
+
for (const g of active) {
|
|
123
|
+
xs.length = 0;
|
|
124
|
+
for (const ring of groups[g]) {
|
|
125
|
+
const n = ring.length;
|
|
126
|
+
// Half-open crossing test (y0 <= y) !== (y1 <= y): a vertex sitting exactly on the
|
|
127
|
+
// scanline is counted once, not twice, so parity stays sane.
|
|
128
|
+
for (let i = 0, j = n - 2; i < n; j = i, i += 2) {
|
|
129
|
+
const y0 = ring[j + 1], y1 = ring[i + 1];
|
|
130
|
+
if ((y0 <= y) === (y1 <= y)) continue;
|
|
131
|
+
xs.push(ring[j] + ((y - y0) * (ring[i] - ring[j])) / (y1 - y0));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (xs.length < 2) continue;
|
|
135
|
+
xs.sort((a, b) => a - b);
|
|
136
|
+
for (let k = 0; k + 1 < xs.length; k += 2) {
|
|
137
|
+
let c0 = Math.ceil((xs[k] - minX) / mmPerPx - 0.5); // first pixel CENTRE inside
|
|
138
|
+
let c1 = Math.floor((xs[k + 1] - minX) / mmPerPx - 0.5); // last pixel centre inside
|
|
139
|
+
if (c0 < 0) c0 = 0;
|
|
140
|
+
if (c1 > size - 1) c1 = size - 1;
|
|
141
|
+
for (let c = c0; c <= c1; c++) data[r * size + c] = 255;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { data, width: size, height: size, mmPerPx, minX, minY };
|
|
146
|
+
}
|
|
@@ -37,6 +37,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
|
|
|
37
37
|
const nodeById = new Map(); // id -> node, for the reveal re-sync
|
|
38
38
|
const lastVisible = new Map(); // id -> previous `visible`, to detect a reveal
|
|
39
39
|
const lastDisabled = new Map(); // id -> previous `disabled`, to skip a no-op input pass
|
|
40
|
+
const keyToId = new Map(); // param key -> node id, for revealParam
|
|
40
41
|
// Containers that own a disclosure: sections, and titled inner groups (the
|
|
41
42
|
// legacy "Advanced" fold). Both share the same anatomy — a header row with
|
|
42
43
|
// the aria-carrying button and a text-free chevron span — so `el` (the
|
|
@@ -226,6 +227,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
|
|
|
226
227
|
info,
|
|
227
228
|
});
|
|
228
229
|
nodeEls.set(node.id, widget.el);
|
|
230
|
+
if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
|
|
229
231
|
widgetSyncs.set(node.id, widget.sync);
|
|
230
232
|
container.append(widget.el);
|
|
231
233
|
|
|
@@ -310,6 +312,45 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
|
|
|
310
312
|
for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
|
|
311
313
|
applyState();
|
|
312
314
|
},
|
|
315
|
+
// Measurement mode's dimension->control link: open whatever encloses the
|
|
316
|
+
// control, bring it on screen, hand it keyboard focus, and pulse the flash
|
|
317
|
+
// so the eye lands on it. DOM-containment (not tree walking) finds the
|
|
318
|
+
// enclosing disclosures, so section vs fold nesting needs no special case.
|
|
319
|
+
revealParam(key) {
|
|
320
|
+
return this.revealParams([key], key);
|
|
321
|
+
},
|
|
322
|
+
// Multi-control reveal: a measurement is usually a FUNCTION of several
|
|
323
|
+
// params, so clicking one flashes every control that can drive it.
|
|
324
|
+
// `focusKey` (when given and present) is the one whose value exactly
|
|
325
|
+
// matches the clicked dimension — it gets keyboard focus; otherwise the
|
|
326
|
+
// first flashed control is only scrolled to, no focus steal.
|
|
327
|
+
revealParams(keys, focusKey = null) {
|
|
328
|
+
const targets = [];
|
|
329
|
+
for (const key of keys) {
|
|
330
|
+
const id = keyToId.get(key);
|
|
331
|
+
const el = id && nodeEls.get(id);
|
|
332
|
+
if (el) targets.push([key, el]);
|
|
333
|
+
}
|
|
334
|
+
if (!targets.length) return false;
|
|
335
|
+
for (const [, target] of targets) {
|
|
336
|
+
for (const [, d] of disclosures) {
|
|
337
|
+
if (!d.body.contains(target) || !d.body.classList.contains("hidden")) continue;
|
|
338
|
+
d.body.classList.remove("hidden");
|
|
339
|
+
d.button.setAttribute("aria-expanded", "true");
|
|
340
|
+
d.el.classList.remove("collapsed");
|
|
341
|
+
}
|
|
342
|
+
target.classList.remove("pf-param-flash");
|
|
343
|
+
void target.offsetWidth; // restart the animation on repeat reveals
|
|
344
|
+
target.classList.add("pf-param-flash");
|
|
345
|
+
target.addEventListener("animationend", () => target.classList.remove("pf-param-flash"), { once: true });
|
|
346
|
+
}
|
|
347
|
+
const [primaryKey, primary] = targets.find(([k]) => k === focusKey) ?? targets[0];
|
|
348
|
+
primary.scrollIntoView?.({ block: "center" });
|
|
349
|
+
if (primaryKey === focusKey) {
|
|
350
|
+
primary.querySelector("input, select, textarea, .seg button")?.focus({ preventScroll: true });
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
},
|
|
313
354
|
dispose: () => { info.dispose(); root.replaceChildren(); },
|
|
314
355
|
};
|
|
315
356
|
}
|