partforge 0.67.4 → 0.69.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 +19 -0
- package/docs/ERROR-PATTERNS.md +29 -13
- package/docs/KERNEL-CONTRACT.md +43 -13
- package/package.json +1 -1
- package/src/app-demo.js +4 -0
- package/src/framework/annotate/annotate-controls.js +134 -0
- package/src/framework/annotate/annotate-mode.js +165 -0
- package/src/framework/annotate/ink-canvas.js +129 -0
- package/src/framework/annotate/ink.js +124 -0
- package/src/framework/app.css +20 -2
- package/src/framework/chrome.css +18 -0
- package/src/framework/geometry/contour-offset.js +7 -5
- package/src/framework/geometry/contour-ops.js +115 -24
- package/src/framework/geometry/contour-winding.js +15 -1
- package/src/framework/geometry/kernel-front.js +4 -1
- package/src/framework/geometry/kernel.js +7 -0
- package/src/framework/geometry/manifold-backend.js +92 -6
- package/src/framework/geometry/occt-backend.js +15 -2
- package/src/framework/geometry/occt-repair.js +10 -6
- package/src/framework/geometry/occt-roundall.js +3 -3
- package/src/framework/geometry/op-options.js +6 -1
- package/src/framework/geometry/rim-bevel.js +17 -10
- package/src/framework/geometry/shape2d.js +7 -3
- package/src/framework/jobs.js +15 -2
- package/src/framework/mount.js +84 -9
- package/types/index.d.ts +76 -0
- package/types/kernel.d.ts +6 -0
|
@@ -54,6 +54,33 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
54
54
|
const unionRaw = (ms) => (ms.length === 1 ? ms[0] : T(Manifold.union(ms)));
|
|
55
55
|
|
|
56
56
|
const cache = createSolidCache();
|
|
57
|
+
// Feature-skip warnings (the OCCT backend's safeOp policy, adopted here): a
|
|
58
|
+
// fillet/chamfer whose mesh machinery is defeated by the geometry returns its
|
|
59
|
+
// INPUT solid unchanged and records one message here instead of failing the
|
|
60
|
+
// whole build. jobs.js drains this per sub-part (takeBuildWarnings) and ships
|
|
61
|
+
// it out on the meshes message, so a caller — the cloud agent above all — is
|
|
62
|
+
// TOLD the feature was skipped rather than left believing it landed.
|
|
63
|
+
const buildWarnings = [];
|
|
64
|
+
// cache key -> warning message for ops that skipped. The identity result is
|
|
65
|
+
// deliberately NOT cached (a later build should re-attempt the feature after
|
|
66
|
+
// upstream geometry changes — same key means same failure, so re-warning is
|
|
67
|
+
// cheap), but a repeated call in the SAME session must still re-emit the
|
|
68
|
+
// warning: without this map, a no-op re-apply would rebuild from warm caches
|
|
69
|
+
// upstream, hit the recorded skip nowhere, silently re-fail and re-warn — fine
|
|
70
|
+
// — but a memoized wrapper above us could also swallow the retry. Keeping the
|
|
71
|
+
// message per key makes "skipped before, skipped again" deterministic and free.
|
|
72
|
+
const skippedOps = new Map();
|
|
73
|
+
// The one recorder. Shared, backend-neutral degrades (the extrude rim bevel in
|
|
74
|
+
// rim-bevel.js, roundedBox's rim clamp in op-options.js, Shape2D's corner-op
|
|
75
|
+
// clamps in contour-ops.js) reach it through the kernel's `_recordWarning`, so
|
|
76
|
+
// every degrade in the build lands in one drainable list rather than only in
|
|
77
|
+
// the console.
|
|
78
|
+
const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
|
|
79
|
+
const skipFeature = (key, op, magnitude, err) => {
|
|
80
|
+
const msg = `${op} ${magnitude} failed (${String(err?.message || err).slice(0, 200)}) — feature skipped, edges left sharp`;
|
|
81
|
+
skippedOps.set(key, msg);
|
|
82
|
+
recordWarning(msg);
|
|
83
|
+
};
|
|
57
84
|
const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
|
|
58
85
|
const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
|
|
59
86
|
// name -> { m, digest, hash } | { error, digest } — imported geometry the framework
|
|
@@ -80,6 +107,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
80
107
|
segs,
|
|
81
108
|
extrude: (o) => kernel.extrude(o),
|
|
82
109
|
revolve: (o) => kernel.revolve(o),
|
|
110
|
+
recordWarning,
|
|
83
111
|
});
|
|
84
112
|
// Lazy CrossSection materialization, memoized through the solid cache by content
|
|
85
113
|
// hash + LOD: the same shape extruded twice (or extruded and revolved) tessellates
|
|
@@ -237,8 +265,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
237
265
|
let base = T(Manifold.extrude(cur, height));
|
|
238
266
|
if (z0 !== 0) base = T(base.translate([0, 0, z0]));
|
|
239
267
|
const wrapped = wrap(base, h("roundAllPrismBase", mHash, r, quality));
|
|
240
|
-
// selector-free: every sharp edge of the mitered prism gets its radius here
|
|
241
|
-
|
|
268
|
+
// selector-free: every sharp edge of the mitered prism gets its radius here.
|
|
269
|
+
// The THROWING form deliberately: this path's answer to a failed fillet is
|
|
270
|
+
// the `catch` below, which returns null and hands the job to the reference
|
|
271
|
+
// Minkowski roundAll. The degrading public fillet would instead hand back
|
|
272
|
+
// the un-rounded prism, and this function would emit it as a successful
|
|
273
|
+
// roundAll — silently wrong geometry instead of a correct slow result.
|
|
274
|
+
const filleted = wrapped._filletRaw(r);
|
|
242
275
|
// Decouple from the fillet cache's pin: cached() will pin the object this
|
|
243
276
|
// returns under the roundAll hash, and one WASM object must never sit
|
|
244
277
|
// under two cache entries (double-dispose on eviction). The decouple is
|
|
@@ -273,22 +306,66 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
273
306
|
}
|
|
274
307
|
};
|
|
275
308
|
|
|
276
|
-
|
|
309
|
+
// `self` names the wrapper being built so the degrading public fillet/chamfer
|
|
310
|
+
// can delegate to their throwing `_`-prefixed twins above without re-deriving
|
|
311
|
+
// the cache key or the capability checks. Declared as a binding the closures
|
|
312
|
+
// capture: every reference runs after addSugar has returned.
|
|
313
|
+
const wrap = (m, hash) => {
|
|
314
|
+
const self = addSugar({
|
|
277
315
|
_m: m,
|
|
278
316
|
_hash: hash,
|
|
279
317
|
cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
|
|
280
|
-
|
|
318
|
+
// THROWING forms. These are the composition primitives — internal callers
|
|
319
|
+
// that have their own recovery (prismRoundAllFast, which answers a failed
|
|
320
|
+
// fillet by falling back to the reference Minkowski roundAll) must use
|
|
321
|
+
// these, never the degrading public ops below: a skip there would emit an
|
|
322
|
+
// UN-rounded prism as a successful roundAll, which is silently wrong
|
|
323
|
+
// geometry rather than a reported missing feature.
|
|
324
|
+
_filletRaw: (r, selector) => {
|
|
281
325
|
if (typeof selector === "function") throw new KernelCapabilityError("fillet: function selectors need the OCCT backend");
|
|
282
326
|
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
283
327
|
return cached(h("fillet", hash, r, selector ?? null, segs), () =>
|
|
284
328
|
meshCadOp("fillet", m, () => meshFillet(kernel, wrap(m, hash), { r, edges: selector, segs })));
|
|
285
329
|
},
|
|
286
|
-
|
|
330
|
+
_chamferRaw: (d, selector) => {
|
|
287
331
|
if (typeof selector === "function") throw new KernelCapabilityError("chamfer: function selectors need the OCCT backend");
|
|
288
332
|
if (d === 0) return wrap(m, hash);
|
|
289
333
|
return cached(h("chamfer", hash, d, selector ?? null, segs), () =>
|
|
290
334
|
meshCadOp("chamfer", m, () => meshChamfer(kernel, wrap(m, hash), { d, edges: selector, segs })));
|
|
291
335
|
},
|
|
336
|
+
|
|
337
|
+
// The AUTHOR-FACING ops degrade on failure instead of failing the build (the
|
|
338
|
+
// OCCT backend's safeOp policy — see occt-repair.js): a defeated op returns
|
|
339
|
+
// the INPUT solid and records a feature-skip warning. Only NEEDS_OCCT
|
|
340
|
+
// capability errors still propagate — they are the split-backend reroute
|
|
341
|
+
// signal, not a geometry failure, and swallowing one would strand the
|
|
342
|
+
// sub-part on the wrong backend. The skip result is not cached: same key →
|
|
343
|
+
// same failure → same cheap re-warn, while an upstream geometry change mints
|
|
344
|
+
// a new key and genuinely re-attempts the feature.
|
|
345
|
+
fillet: (r, selector) => {
|
|
346
|
+
const key = h("fillet", hash, r, selector ?? null, segs);
|
|
347
|
+
const skipped = skippedOps.get(key);
|
|
348
|
+
if (skipped !== undefined) { buildWarnings.push(skipped); return wrap(m, hash); }
|
|
349
|
+
try {
|
|
350
|
+
return self._filletRaw(r, selector);
|
|
351
|
+
} catch (e) {
|
|
352
|
+
if (e?.code === "NEEDS_OCCT") throw e;
|
|
353
|
+
skipFeature(key, "fillet", r, e);
|
|
354
|
+
return wrap(m, hash);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
chamfer: (d, selector) => {
|
|
358
|
+
const key = h("chamfer", hash, d, selector ?? null, segs);
|
|
359
|
+
const skipped = skippedOps.get(key);
|
|
360
|
+
if (skipped !== undefined) { buildWarnings.push(skipped); return wrap(m, hash); }
|
|
361
|
+
try {
|
|
362
|
+
return self._chamferRaw(d, selector);
|
|
363
|
+
} catch (e) {
|
|
364
|
+
if (e?.code === "NEEDS_OCCT") throw e;
|
|
365
|
+
skipFeature(key, "chamfer", d, e);
|
|
366
|
+
return wrap(m, hash);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
292
369
|
roundAll: (r) => {
|
|
293
370
|
if (r === 0) return wrap(m, hash); // contract: zero magnitude is the identity
|
|
294
371
|
// `quality` in the key is redundant but harmless — the cache lives on a
|
|
@@ -433,7 +510,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
433
510
|
toMesh: () => meshOut(m, false),
|
|
434
511
|
toSTL: () => Promise.resolve(meshOut(m, true)),
|
|
435
512
|
toIndexedMesh: () => indexedMeshOut(m),
|
|
436
|
-
|
|
513
|
+
});
|
|
514
|
+
return self;
|
|
515
|
+
};
|
|
437
516
|
|
|
438
517
|
const kernel = finishKernel({
|
|
439
518
|
cylinder: (rb, rt, h2, { center = false } = {}) =>
|
|
@@ -571,6 +650,13 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
571
650
|
sweepCache: () => cache.sweep(),
|
|
572
651
|
cacheStats: () => cache.stats(),
|
|
573
652
|
resetCacheStats: () => cache.resetStats(),
|
|
653
|
+
// Drain the feature-skip warnings recorded since the last drain (see
|
|
654
|
+
// buildWarnings above). jobs.js calls this per sub-part so a warning is
|
|
655
|
+
// attributed to the sub-part whose build recorded it.
|
|
656
|
+
takeBuildWarnings: () => buildWarnings.splice(0),
|
|
657
|
+
// Internal (underscore = not the contract surface): the recorder shared,
|
|
658
|
+
// backend-neutral helpers report their own degrades through.
|
|
659
|
+
_recordWarning: recordWarning,
|
|
574
660
|
// Free every WASM object created since the last cleanup EXCEPT solids the cache
|
|
575
661
|
// still pins (they must survive for the next build to resume from them).
|
|
576
662
|
cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
|
|
@@ -39,9 +39,15 @@ export function createOcctKernel(replicad) {
|
|
|
39
39
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
40
40
|
loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
41
41
|
|
|
42
|
+
// Feature-skip warnings: everything occt-repair (and roundAll) skips or rescues
|
|
43
|
+
// is recorded here as well as console.warned, and jobs.js drains it per
|
|
44
|
+
// sub-part (takeBuildWarnings) onto the meshes message — same channel as the
|
|
45
|
+
// Manifold backend's fillet/chamfer degradation.
|
|
46
|
+
const buildWarnings = [];
|
|
47
|
+
const recordWarning = (msg) => { buildWarnings.push(msg); console.warn(`partforge: ${msg}`); };
|
|
42
48
|
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
43
49
|
// see occt-repair.js for the policies and why they differ per op.
|
|
44
|
-
const { validChamfer, safeOp } = createOcctRepair(measureVolume);
|
|
50
|
+
const { validChamfer, safeOp } = createOcctRepair(measureVolume, recordWarning);
|
|
45
51
|
|
|
46
52
|
// name -> { shape, digest } | { error, digest } — imported geometry the framework
|
|
47
53
|
// registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
|
|
@@ -234,7 +240,7 @@ export function createOcctKernel(replicad) {
|
|
|
234
240
|
return cached(key, () => {
|
|
235
241
|
const a = mat();
|
|
236
242
|
if (r === 0) return wrap(a._s.clone(), cloneLabels(a._labels), key);
|
|
237
|
-
return wrap(occtRoundAll(replicad, a._s, r), cloneLabels(a._labels), key);
|
|
243
|
+
return wrap(occtRoundAll(replicad, a._s, r, recordWarning), cloneLabels(a._labels), key);
|
|
238
244
|
});
|
|
239
245
|
},
|
|
240
246
|
shell: (thickness, openFaces) => {
|
|
@@ -367,6 +373,7 @@ export function createOcctKernel(replicad) {
|
|
|
367
373
|
segs: SHAPE2D_SEGS,
|
|
368
374
|
extrude: (o) => kernel.extrude(o),
|
|
369
375
|
revolve: (o) => kernel.revolve(o),
|
|
376
|
+
recordWarning,
|
|
370
377
|
});
|
|
371
378
|
// Lazy Drawing materialization for the kernel ops that need one. drawingFromRegions
|
|
372
379
|
// draws a FRESH Drawing on every call, so callers never need to .clone() the result
|
|
@@ -524,6 +531,12 @@ export function createOcctKernel(replicad) {
|
|
|
524
531
|
sweepCache: () => cache.sweep(),
|
|
525
532
|
cacheStats: () => cache.stats(),
|
|
526
533
|
resetCacheStats: () => cache.resetStats(),
|
|
534
|
+
// Drain the feature-skip warnings recorded since the last drain — the
|
|
535
|
+
// Manifold backend's channel, mirrored (see occt-repair.js for the sources).
|
|
536
|
+
takeBuildWarnings: () => buildWarnings.splice(0),
|
|
537
|
+
// Internal: the recorder shared, backend-neutral helpers report through
|
|
538
|
+
// (rim-bevel, roundedBox's clamp, Shape2D corner-op clamps).
|
|
539
|
+
_recordWarning: recordWarning,
|
|
527
540
|
});
|
|
528
541
|
return kernel;
|
|
529
542
|
}
|
|
@@ -33,7 +33,11 @@ export const isClosedSolid = (shape) => {
|
|
|
33
33
|
return true;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// `warn` receives every feature-skip / rescue message. The default keeps the
|
|
37
|
+
// historical console.warn; the OCCT backend injects a recorder that ALSO feeds
|
|
38
|
+
// the kernel's takeBuildWarnings channel, so a skipped feature reaches the
|
|
39
|
+
// build result (and, in the cloud app, the agent) instead of only the console.
|
|
40
|
+
export function createOcctRepair(measureVolume, warn = (msg) => console.warn(`partforge: ${msg}`)) {
|
|
37
41
|
// The true maximum chamfer for an edge depends on local angles and adjacent features,
|
|
38
42
|
// which is hard to predict analytically (and OCCT exposes no max-radius query). So
|
|
39
43
|
// VALIDATE the result instead of guessing: try the requested distance, and if it makes
|
|
@@ -65,8 +69,8 @@ export function createOcctRepair(measureVolume) {
|
|
|
65
69
|
// multiplies an already-expensive op by ~8x, so make the cost loud enough to
|
|
66
70
|
// act on (lower the distance, or bevel profile rims with a loft instead).
|
|
67
71
|
const cost = `${attempts} attempts, ${((performance.now() - t0) / 1000).toFixed(1)}s — see ERROR-PATTERNS.md#chamfer-rescue-bisection`;
|
|
68
|
-
if (best) {
|
|
69
|
-
|
|
72
|
+
if (best) { warn(`chamfer ${distance} over-ran the geometry — reduced to ${bestD.toFixed(2)} (largest valid; ${cost})`); return best; }
|
|
73
|
+
warn(`chamfer ${distance} has no valid distance for this geometry — feature skipped (${cost})`);
|
|
70
74
|
return shape.clone(); // nothing valid — skip the chamfer
|
|
71
75
|
};
|
|
72
76
|
|
|
@@ -90,10 +94,10 @@ export function createOcctRepair(measureVolume) {
|
|
|
90
94
|
const resultVolume = measureVolume(result);
|
|
91
95
|
if (resultVolume > 0 && (!isValid || isValid(resultVolume, beforeVolume))) { backup.delete?.(); return result; }
|
|
92
96
|
result.delete?.();
|
|
93
|
-
if (resultVolume > 0)
|
|
94
|
-
else
|
|
97
|
+
if (resultVolume > 0) warn(`${label} produced invalid geometry — feature skipped`);
|
|
98
|
+
else warn(`${label} produced an empty solid — feature skipped (radius out of range?)`);
|
|
95
99
|
} catch (e) {
|
|
96
|
-
|
|
100
|
+
warn(`${label} failed (${e?.message || e}) — feature skipped`);
|
|
97
101
|
}
|
|
98
102
|
return backup;
|
|
99
103
|
};
|
|
@@ -31,7 +31,7 @@ const VARIANTS = [
|
|
|
31
31
|
{ join: "int", inter: true },
|
|
32
32
|
];
|
|
33
33
|
|
|
34
|
-
export function occtRoundAll(replicad, shape, r) {
|
|
34
|
+
export function occtRoundAll(replicad, shape, r, warn = (msg) => console.warn(`partforge: ${msg}`)) {
|
|
35
35
|
if (!Number.isFinite(r) || r <= 0) throw new Error("roundAll: r must be a finite number > 0 (r = 0 is handled as the identity by the caller)");
|
|
36
36
|
const oc = replicad.getOC();
|
|
37
37
|
const tryOffset = (topo, offset, v) => {
|
|
@@ -60,7 +60,7 @@ export function occtRoundAll(replicad, shape, r) {
|
|
|
60
60
|
vol = replicad.measureVolume(shape);
|
|
61
61
|
} catch (e) {
|
|
62
62
|
// Can't gate what can't be measured — skip rather than run the cascade blind.
|
|
63
|
-
|
|
63
|
+
warn(`roundall-skipped: the input solid's volume could not be measured (${e?.message || e}); returning the un-rounded solid`);
|
|
64
64
|
return shape.clone(); // if the clone throws too, the caller's shape is unusable — let it propagate
|
|
65
65
|
}
|
|
66
66
|
let cur = shape;
|
|
@@ -85,7 +85,7 @@ export function occtRoundAll(replicad, shape, r) {
|
|
|
85
85
|
}
|
|
86
86
|
if (cur !== shape) cur.delete?.(); // superseded intermediate; never the caller's shape
|
|
87
87
|
if (!next) {
|
|
88
|
-
|
|
88
|
+
warn(`roundall-skipped: offset step ${off} produced no valid solid — r=${r} is likely at/above the smallest feature size; returning the un-rounded solid`);
|
|
89
89
|
return shape.clone();
|
|
90
90
|
}
|
|
91
91
|
cur = next;
|
|
@@ -150,7 +150,7 @@ const checkRoundRadius = (op, name, v, max, maxDesc) => {
|
|
|
150
150
|
if (v > max + 1e-9) throw new Error(`${op}: ${name} (${v}) must be ≤ ${maxDesc}`);
|
|
151
151
|
};
|
|
152
152
|
|
|
153
|
-
export function roundedBoxArgs(o) {
|
|
153
|
+
export function roundedBoxArgs(o, record) {
|
|
154
154
|
checkKeys("roundedBox", o, ["size", "center", "round"]);
|
|
155
155
|
const size = req("roundedBox", o, "size");
|
|
156
156
|
if (!Array.isArray(size) || size.length !== 3 || !size.every((v) => Number.isFinite(v) && v > 0))
|
|
@@ -174,6 +174,11 @@ export function roundedBoxArgs(o) {
|
|
|
174
174
|
// message (and a distinct Set entry) on every rebuild, defeating the dedupe.
|
|
175
175
|
const dedupeKey = `roundedBox.${key}|${round.side}`;
|
|
176
176
|
const msg = `roundedBox: round.${key} ${round[key]} clamped to round.side ${round.side} (side must be 0 or ≥ rim radii; use side: 0 for a rim-only round-over)`;
|
|
177
|
+
// The console dedupe stays (a slider sweep would otherwise spam it), but
|
|
178
|
+
// the RECORDER is fed every time: warnings are drained per build, so
|
|
179
|
+
// deduping them across builds would silently drop the clamp from the
|
|
180
|
+
// second and every later build that still clamps.
|
|
181
|
+
record?.(msg);
|
|
177
182
|
if (!warnedClamps.has(dedupeKey)) { warnedClamps.add(dedupeKey); console.warn(msg); }
|
|
178
183
|
round[key] = round.side;
|
|
179
184
|
}
|
|
@@ -67,7 +67,7 @@ const ccw = (r) => {
|
|
|
67
67
|
// loop is deterministic, preserving build purity. `corners: "sharp"` keeps the
|
|
68
68
|
// offset 1:1 with the input points — loft stitching requires every ring to
|
|
69
69
|
// share the profile's exact point count (a mismatch is treated as a failed try).
|
|
70
|
-
const fit = (ring, delta, what) => {
|
|
70
|
+
const fit = (ring, delta, what, record) => {
|
|
71
71
|
const requested = Math.abs(delta), sign = Math.sign(delta);
|
|
72
72
|
let c = requested;
|
|
73
73
|
for (;;) {
|
|
@@ -75,13 +75,13 @@ const fit = (ring, delta, what) => {
|
|
|
75
75
|
const off = offsetPolygon(ring, sign * c, { corners: "sharp" });
|
|
76
76
|
if (off.length === ring.length) {
|
|
77
77
|
if (c < requested)
|
|
78
|
-
|
|
78
|
+
record(`extrude bevel ${requested} exceeds what the ${what} can take — reduced to ${c.toFixed(2)}`);
|
|
79
79
|
return { ring: off, c };
|
|
80
80
|
}
|
|
81
81
|
} catch { /* offset collapsed or self-intersected — try smaller */ }
|
|
82
82
|
c *= 0.85;
|
|
83
83
|
if (c < 0.05) {
|
|
84
|
-
|
|
84
|
+
record(`extrude bevel ${requested} has no valid offset for this ${what} — rim left square`);
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -96,12 +96,12 @@ const outerRings = (outer, h, b, t) => {
|
|
|
96
96
|
return rings;
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
-
const bevelRegion = (k, region, h, bottom, top) => {
|
|
99
|
+
const bevelRegion = (k, region, h, bottom, top, record) => {
|
|
100
100
|
const outer = ccw(region.outer);
|
|
101
101
|
const holes = (region.holes ?? []).map(ccw);
|
|
102
102
|
let s = k.extrude({ profile: holes.length ? { outer, holes } : outer, h });
|
|
103
|
-
const b = bottom > 0 ? fit(outer, -bottom, "profile") : null;
|
|
104
|
-
const t = top > 0 ? fit(outer, -top, "profile") : null;
|
|
103
|
+
const b = bottom > 0 ? fit(outer, -bottom, "profile", record) : null;
|
|
104
|
+
const t = top > 0 ? fit(outer, -top, "profile", record) : null;
|
|
105
105
|
// shading: "smooth" on all three internal lofts — a bevel band inherits the
|
|
106
106
|
// profile's own shading intent (sharp corners at the bevel's start/end
|
|
107
107
|
// rings, as a real chamfer would look), not the loft op's own facet-vs-
|
|
@@ -112,11 +112,11 @@ const bevelRegion = (k, region, h, bottom, top) => {
|
|
|
112
112
|
if (b || t) s = s.intersect(k.loft({ rings: outerRings(outer, h, b, t), shading: "smooth" }));
|
|
113
113
|
const cutters = [];
|
|
114
114
|
for (const hole of holes) {
|
|
115
|
-
const hb = bottom > 0 ? fit(hole, bottom, "hole") : null;
|
|
115
|
+
const hb = bottom > 0 ? fit(hole, bottom, "hole", record) : null;
|
|
116
116
|
if (hb) cutters.push(k.loft({ rings: [
|
|
117
117
|
{ polygon: hb.ring, z: -1 }, { polygon: hb.ring, z: 0 }, { polygon: hole, z: hb.c },
|
|
118
118
|
], shading: "smooth" }));
|
|
119
|
-
const ht = top > 0 ? fit(hole, top, "hole") : null;
|
|
119
|
+
const ht = top > 0 ? fit(hole, top, "hole", record) : null;
|
|
120
120
|
if (ht) cutters.push(k.loft({ rings: [
|
|
121
121
|
{ polygon: hole, z: h - ht.c }, { polygon: ht.ring, z: h }, { polygon: ht.ring, z: h + 1 },
|
|
122
122
|
], shading: "smooth" }));
|
|
@@ -124,7 +124,14 @@ const bevelRegion = (k, region, h, bottom, top) => {
|
|
|
124
124
|
return cutters.length ? s.cutAll(cutters) : s;
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
// `record` reports the two ways a rim bevel degrades — reduced to what the ring
|
|
128
|
+
// can take, or skipped entirely with the rim left square. Both used to reach only
|
|
129
|
+
// console.warn, which meant a build could come back ok:true with a bevel the part
|
|
130
|
+
// asked for and never got, invisible to the caller (and to the cloud agent). The
|
|
131
|
+
// kernel supplies its own recorder; the default keeps the historical console.warn
|
|
132
|
+
// for a direct call with no kernel recorder behind it.
|
|
133
|
+
export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel },
|
|
134
|
+
record = k?._recordWarning ?? ((m) => console.warn(`partforge: ${m}`))) {
|
|
128
135
|
if (twist !== undefined || scaleTop !== undefined)
|
|
129
136
|
throw new Error("extrude: bevel cannot combine with twist or scaleTop");
|
|
130
137
|
const { bottom, top } = resolveBevel(bevel, h);
|
|
@@ -135,5 +142,5 @@ export function beveledExtrude(k, { profile, h, twist, scaleTop, bevel }) {
|
|
|
135
142
|
? profile.toRegions()
|
|
136
143
|
: [tessellateProfile(profile, BEVEL_SEGS)];
|
|
137
144
|
if (regions.length === 0) throw new Error("extrude: bevel profile produced no regions");
|
|
138
|
-
return regions.map((r) => bevelRegion(k, r, h, bottom, top)).reduce((a, x) => a.union(x));
|
|
145
|
+
return regions.map((r) => bevelRegion(k, r, h, bottom, top, record)).reduce((a, x) => a.union(x));
|
|
139
146
|
}
|
|
@@ -45,7 +45,11 @@ const checkProfile = (x) => {
|
|
|
45
45
|
}
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// `recordWarning` is the kernel's build-warning recorder (see manifold-backend /
|
|
49
|
+
// occt-backend). Corner ops CLAMP a magnitude the geometry cannot take rather
|
|
50
|
+
// than throwing, and a clamp that only reached the console would leave a caller
|
|
51
|
+
// believing it got the radius it asked for.
|
|
52
|
+
export function makeShape2dFactory({ segs, extrude, revolve, recordWarning }) {
|
|
49
53
|
// Lift any accepted profile form into stored regions: a live Shape2D is deep-copied out
|
|
50
54
|
// via its own toContours() (value semantics — never alias another shape's storage);
|
|
51
55
|
// anything else goes through liftProfile + per-ring winding normalization.
|
|
@@ -86,8 +90,8 @@ export function makeShape2dFactory({ segs, extrude, revolve }) {
|
|
|
86
90
|
rotate: (deg, center) => viaOps((r) => rotateProfile(r, deg, center)),
|
|
87
91
|
scale: (f, center) => viaOps((r) => scaleProfile(r, f, center)),
|
|
88
92
|
mirror: (axis) => viaOps((r) => mirrorProfile(r, axis)),
|
|
89
|
-
fillet: (r, opts) => viaOps((rg) => filletProfile(rg, r, opts)),
|
|
90
|
-
chamfer: (d, opts) => viaOps((rg) => chamferProfile(rg, d, opts)),
|
|
93
|
+
fillet: (r, opts) => viaOps((rg) => filletProfile(rg, r, opts, recordWarning)),
|
|
94
|
+
chamfer: (d, opts) => viaOps((rg) => chamferProfile(rg, d, opts, recordWarning)),
|
|
91
95
|
simplify: (tol) => viaOps((r) => simplifyProfile(r, tol)),
|
|
92
96
|
corners: () => profileCorners(regions),
|
|
93
97
|
contains: (p) => profileContains(regions, p),
|
package/src/framework/jobs.js
CHANGED
|
@@ -130,6 +130,13 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
130
130
|
const t0 = Date.now();
|
|
131
131
|
const useCache = msg.cache !== false; // ?debug toggle can disable caching (cache:false)
|
|
132
132
|
const meshes = [];
|
|
133
|
+
// Feature-skip warnings (a fillet/chamfer the geometry defeated — see the
|
|
134
|
+
// backends' takeBuildWarnings): drained per sub-part below so each message
|
|
135
|
+
// names the sub-part whose build recorded it, and drained-and-discarded here
|
|
136
|
+
// first so a previous job's stragglers (an oracle build, an export) cannot be
|
|
137
|
+
// misattributed to this build's first sub-part.
|
|
138
|
+
kernel.takeBuildWarnings?.();
|
|
139
|
+
const warnings = [];
|
|
133
140
|
kernel.resetCacheStats?.(); // count hits/misses for just this job
|
|
134
141
|
for (const [i, name] of msg.subparts.entries()) {
|
|
135
142
|
if (useCache) kernel.beginSubPart?.(name); // open the per-sub-part cache round
|
|
@@ -137,6 +144,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
137
144
|
const m = posed(name, "display").toMesh({ quality: "preview" });
|
|
138
145
|
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
|
|
139
146
|
} finally {
|
|
147
|
+
for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
|
|
140
148
|
if (useCache) kernel.endSubPart?.(); // always close the bracket — a throw mid-build must not strand pinned solids
|
|
141
149
|
kernel.cleanup?.(); // free this round's transients (cached/pinned solids survive)
|
|
142
150
|
}
|
|
@@ -152,7 +160,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
152
160
|
}
|
|
153
161
|
const transfer = meshes.flatMap((m) =>
|
|
154
162
|
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
155
|
-
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.()
|
|
163
|
+
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.(),
|
|
164
|
+
...(warnings.length ? { warnings } : {}) }, transfer);
|
|
156
165
|
} else if (msg.type === "capture-generate") {
|
|
157
166
|
// A private, job-correlated one-shot channel for captureView — builds a
|
|
158
167
|
// (possibly non-active) view's meshes off the regen loop, so it can never
|
|
@@ -161,19 +170,23 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
161
170
|
// isStale/superseded polling — there's nothing to supersede a one-shot.
|
|
162
171
|
const useCache = msg.cache !== false;
|
|
163
172
|
const meshes = [];
|
|
173
|
+
kernel.takeBuildWarnings?.(); // discard a previous job's stragglers (same as generate)
|
|
174
|
+
const warnings = [];
|
|
164
175
|
for (const name of msg.subparts) {
|
|
165
176
|
if (useCache) kernel.beginSubPart?.(name);
|
|
166
177
|
try {
|
|
167
178
|
const m = posed(name, "display").toMesh({ quality: "preview" });
|
|
168
179
|
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
|
|
169
180
|
} finally {
|
|
181
|
+
for (const message of kernel.takeBuildWarnings?.() ?? []) warnings.push({ part: name, message });
|
|
170
182
|
if (useCache) kernel.endSubPart?.();
|
|
171
183
|
kernel.cleanup?.();
|
|
172
184
|
}
|
|
173
185
|
}
|
|
174
186
|
const captureTransfer = meshes.flatMap((m) =>
|
|
175
187
|
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
176
|
-
post({ type: "capture-meshes", jobId: msg.jobId, meshes
|
|
188
|
+
post({ type: "capture-meshes", jobId: msg.jobId, meshes,
|
|
189
|
+
...(warnings.length ? { warnings } : {}) }, captureTransfer);
|
|
177
190
|
} else if (msg.type === "export-stl") {
|
|
178
191
|
const names = selected();
|
|
179
192
|
if (names.length === 0) throw new Error("no exportable parts selected");
|
package/src/framework/mount.js
CHANGED
|
@@ -28,6 +28,8 @@ import { attachAnimationControls } from "./animation-controls.js";
|
|
|
28
28
|
import { resolveDefaultView } from "./default-view.js";
|
|
29
29
|
import { createMeasureMode } from "./measure/measure-mode.js";
|
|
30
30
|
import { attachMeasureControls } from "./measure/measure-controls.js";
|
|
31
|
+
import { createAnnotateMode } from "./annotate/annotate-mode.js";
|
|
32
|
+
import { attachAnnotateControls } from "./annotate/annotate-controls.js";
|
|
31
33
|
|
|
32
34
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
33
35
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
@@ -37,6 +39,14 @@ const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {}
|
|
|
37
39
|
// Same no-op-default stance as attachTooltips/setHostPane below, for a
|
|
38
40
|
// makeHandle caller (or a direct test) that doesn't wire measure mode.
|
|
39
41
|
const NOOP_MEASURE = { isEnabled: () => false, setEnabled: () => {}, clearPins: () => {}, pinCount: () => 0 };
|
|
42
|
+
// Same stance as NOOP_MEASURE: the handle's annotate surface exists whether or
|
|
43
|
+
// not this mount wired the mode (it wires only when the host passes
|
|
44
|
+
// onAnnotationSend — without a sink, Send would have nowhere to go).
|
|
45
|
+
const NOOP_ANNOTATE = {
|
|
46
|
+
isEnabled: () => false, setEnabled: () => {}, undo: () => {}, clear: () => {},
|
|
47
|
+
strokeCount: () => 0, send: () => false,
|
|
48
|
+
onInkChange: () => () => {}, onModeChange: () => () => {},
|
|
49
|
+
};
|
|
40
50
|
// The STEP-on-Manifold import crossover's broken-state message (a second
|
|
41
51
|
// needs-import-mesh after the mesh is already primed — see the "needs-import-mesh"
|
|
42
52
|
// case below). One shared string so the status line, onBuild, and the ready
|
|
@@ -49,7 +59,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
49
59
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
50
60
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
51
61
|
|
|
52
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure }) {
|
|
62
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate }) {
|
|
53
63
|
return {
|
|
54
64
|
ready, dispose, setParams,
|
|
55
65
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -91,6 +101,11 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
91
101
|
// button. Dimensions render in the scene, so a dimensioned capture is just
|
|
92
102
|
// captureCurrent() taken while the mode is on.
|
|
93
103
|
measure: measure ?? NOOP_MEASURE,
|
|
104
|
+
// Annotation-mode API (spec 2026-08-18): { isEnabled, setEnabled, clear,
|
|
105
|
+
// strokeCount, send, onModeChange } — an embedder drives the mode without
|
|
106
|
+
// the built-in pencil button. send() delivers to onAnnotationSend and
|
|
107
|
+
// returns false when there is no ink or the capture failed.
|
|
108
|
+
annotate: annotate ?? NOOP_ANNOTATE,
|
|
94
109
|
};
|
|
95
110
|
}
|
|
96
111
|
|
|
@@ -182,6 +197,12 @@ function createCleanupStack() {
|
|
|
182
197
|
// const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
|
|
183
198
|
// // OS gave up — surface it rather than showing a dead
|
|
184
199
|
// // canvas. Returns an unsubscribe.
|
|
200
|
+
// runtime.annotate: { isEnabled, setEnabled, undo, clear, strokeCount, send, onInkChange,
|
|
201
|
+
// onModeChange } — drive annotation mode without the built-in button;
|
|
202
|
+
// // no-op when onAnnotationSend was not supplied. Both
|
|
203
|
+
// // subscribes return an unsubscribe; onInkChange fires on
|
|
204
|
+
// // every stroke/undo/clear, which is what a host driving its
|
|
205
|
+
// // own Send button gates that button on (strokeCount() > 0).
|
|
185
206
|
// runtime.dispose(); // full teardown
|
|
186
207
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
187
208
|
// those are repaired in the viewer and produce no build at all.
|
|
@@ -194,10 +215,27 @@ function createCleanupStack() {
|
|
|
194
215
|
// // is a snapshot copy. Never fired by setParams or
|
|
195
216
|
// // animation playback — hosts call setParams from their
|
|
196
217
|
// // own undo/reset, and firing here would loop.
|
|
218
|
+
// onAnnotationSend(payload) // receive user annotations (freehand ink over the frozen
|
|
219
|
+
// // view). Supplying this reveals the #annotate viewbar
|
|
220
|
+
// // button; omitting it hides the button entirely.
|
|
221
|
+
// // payload.images carries two data URLs (the ink drawing
|
|
222
|
+
// // and the rendered model), each bounded to a 2048px long
|
|
223
|
+
// // edge — a stage bigger than that exports scaled down
|
|
224
|
+
// // rather than at its own hi-DPI size. Still hundreds of
|
|
225
|
+
// // KB of base64 apiece, so a host should not assume this
|
|
226
|
+
// // payload is small, only that it is bounded.
|
|
227
|
+
// annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
|
|
228
|
+
// // Send beside Undo/Clear in the annotate actions row.
|
|
229
|
+
// // "host" drops it and leaves Undo/Clear: the host draws
|
|
230
|
+
// // its own send control — e.g. a composer that pairs the
|
|
231
|
+
// // sketch with a typed message — and calls
|
|
232
|
+
// // runtime.annotate.send() itself. Ignored without
|
|
233
|
+
// // onAnnotationSend (there is no button to place).
|
|
197
234
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
198
235
|
// exactly once here — submodules take element refs and never query the document.
|
|
199
236
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
200
|
-
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit,
|
|
237
|
+
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
|
|
238
|
+
annotateSend = "viewbar",
|
|
201
239
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
202
240
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
203
241
|
const byId = (id) => document.getElementById(id);
|
|
@@ -228,6 +266,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
228
266
|
theme: elements.chrome?.theme ?? byId("theme"),
|
|
229
267
|
cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
|
|
230
268
|
measure: elements.chrome?.measure ?? byId("measure"),
|
|
269
|
+
annotate: elements.chrome?.annotate ?? byId("annotate"),
|
|
231
270
|
railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
|
|
232
271
|
},
|
|
233
272
|
};
|
|
@@ -349,6 +388,30 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
349
388
|
getParamsVersion: () => loop.version(),
|
|
350
389
|
});
|
|
351
390
|
cleanup.defer(() => measureMode.detach());
|
|
391
|
+
// Annotation mode (spec 2026-08-18): freehand ink over the frozen view,
|
|
392
|
+
// delivered to the host via onAnnotationSend. Wired only when the host
|
|
393
|
+
// passes the sink; the chrome hides the button otherwise (mode = null).
|
|
394
|
+
let annotateMode = null;
|
|
395
|
+
if (onAnnotationSend) {
|
|
396
|
+
annotateMode = createAnnotateMode(viewer, {
|
|
397
|
+
stage: els.viewer,
|
|
398
|
+
getContext: () => ({ view: view(), params }),
|
|
399
|
+
onSend: onAnnotationSend,
|
|
400
|
+
});
|
|
401
|
+
cleanup.defer(() => annotateMode.detach());
|
|
402
|
+
// Annotate and measure both claim canvas pointer input — mutually
|
|
403
|
+
// exclusive, whichever turns on turns the other off.
|
|
404
|
+
cleanup.defer(annotateMode.onModeChange(() => {
|
|
405
|
+
if (annotateMode.isEnabled()) measureMode.setEnabled(false);
|
|
406
|
+
}));
|
|
407
|
+
cleanup.defer(measureMode.onModeChange(() => {
|
|
408
|
+
if (measureMode.isEnabled()) annotateMode.setEnabled(false);
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
const annotateChrome = attachAnnotateControls(viewer, annotateMode, {
|
|
412
|
+
annotate: els.chrome.annotate,
|
|
413
|
+
}, { tooltip, escapeScope: els.viewer, send: annotateSend });
|
|
414
|
+
cleanup.defer(() => annotateChrome.detach());
|
|
352
415
|
// escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
|
|
353
416
|
// #viewbar, not descendants of the canvas — attaching Escape to
|
|
354
417
|
// viewer.domElement alone would leave a guarded Escape from those buttons
|
|
@@ -360,13 +423,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
360
423
|
cleanup.defer(() => measureChrome.detach());
|
|
361
424
|
const cutawayChrome = attachCutawayControls(viewer, {
|
|
362
425
|
cutaway: els.chrome.cutaway,
|
|
363
|
-
}, { tooltip, escapeGuard: () => measureMode.isEnabled() });
|
|
426
|
+
}, { tooltip, escapeGuard: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false) });
|
|
364
427
|
cleanup.defer(() => cutawayChrome.detach());
|
|
365
|
-
// Suppress the always-on hover tooltip while measure mode is
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
428
|
+
// Suppress the always-on hover tooltip while measure OR annotate mode is
|
|
429
|
+
// active — measure's highlight + dims take the pointer; annotate's overlay
|
|
430
|
+
// canvas takes it entirely.
|
|
431
|
+
const syncHoverSuppression = () =>
|
|
432
|
+
hover.setSuppressed(measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false));
|
|
433
|
+
cleanup.defer(measureMode.onModeChange(syncHoverSuppression));
|
|
434
|
+
if (annotateMode) cleanup.defer(annotateMode.onModeChange(syncHoverSuppression));
|
|
370
435
|
|
|
371
436
|
// Current selection context for the pickers: the active view + live params +
|
|
372
437
|
// derived values. Shared by every pick mode below.
|
|
@@ -392,7 +457,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
392
457
|
// resync on mode changes. The ?pick/?pickserver harnesses below are
|
|
393
458
|
// deliberately not guarded — one is armed by an explicit dev toggle,
|
|
394
459
|
// the other per agent request.
|
|
395
|
-
suppressed: () => measureMode.isEnabled(),
|
|
460
|
+
suppressed: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
|
|
396
461
|
onPick: (selection) => onPick({
|
|
397
462
|
selection,
|
|
398
463
|
label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
|
|
@@ -869,6 +934,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
869
934
|
clearPins: measureMode.clearPins,
|
|
870
935
|
pinCount: measureMode.pinCount,
|
|
871
936
|
},
|
|
937
|
+
annotate: annotateMode ? {
|
|
938
|
+
isEnabled: annotateMode.isEnabled,
|
|
939
|
+
setEnabled: annotateMode.setEnabled,
|
|
940
|
+
undo: annotateMode.undo,
|
|
941
|
+
clear: annotateMode.clear,
|
|
942
|
+
strokeCount: annotateMode.strokeCount,
|
|
943
|
+
send: annotateMode.send,
|
|
944
|
+
onInkChange: annotateMode.onInkChange,
|
|
945
|
+
onModeChange: annotateMode.onModeChange,
|
|
946
|
+
} : null,
|
|
872
947
|
});
|
|
873
948
|
} catch (error) {
|
|
874
949
|
try {
|