partforge 0.95.0 → 0.97.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/bin/cli.js +14 -1
- package/docs/AUTHORING-PARTS.md +208 -1
- package/docs/ERROR-PATTERNS.md +24 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/package.json +1 -1
- package/src/app-relief.js +16 -0
- package/src/framework/app.css +30 -0
- package/src/framework/backend-select.js +7 -2
- package/src/framework/export-controller.js +25 -1
- package/src/framework/geometry/heightfield.js +129 -0
- package/src/framework/geometry/kernel.js +3 -0
- package/src/framework/geometry/manifold-backend.js +61 -0
- package/src/framework/geometry/occt-backend.js +148 -1
- package/src/framework/geometry/op-options.js +10 -0
- package/src/framework/geometry/png-decode.js +107 -0
- package/src/framework/geometry/solid-hash.js +96 -0
- package/src/framework/image-ingest.js +41 -0
- package/src/framework/image-source.js +72 -0
- package/src/framework/images.js +66 -0
- package/src/framework/jobs.js +62 -0
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-images.js +109 -0
- package/src/framework/measure/measure-mode.js +2 -1
- package/src/framework/mount.js +11 -2
- package/src/framework/oracle/verify.js +6 -1
- package/src/framework/panel/image-picker.js +152 -0
- package/src/framework/panel/render.js +1 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/image.js +164 -0
- package/src/framework/panel/widgets/index.js +9 -5
- package/src/framework/param-deps.js +7 -2
- package/src/framework/worker.js +9 -1
- package/src/index.js +1 -0
- package/src/parts/assets/relief-demo.png +0 -0
- package/src/parts/relief.js +84 -0
- package/src/relief-worker.js +3 -0
- package/src/testing/manifold.js +7 -1
- package/src/testing/occt.js +4 -1
- package/types/index.d.ts +24 -0
- package/types/kernel.d.ts +32 -0
- package/types/part.d.ts +21 -0
|
@@ -19,6 +19,7 @@ import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
|
|
|
19
19
|
import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
|
|
20
20
|
import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
|
|
21
21
|
import { KernelCapabilityError } from "./errors.js";
|
|
22
|
+
import { heightfieldMesh, hashGridData } from "./heightfield.js";
|
|
22
23
|
|
|
23
24
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
24
25
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
@@ -89,6 +90,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
89
90
|
// registers pre-build (ensureImports, Task 8). Kernel-lifetime, NOT tracked/T()'d:
|
|
90
91
|
// these masters must survive cleanup() and be read again on every subsequent build.
|
|
91
92
|
const imports = new Map();
|
|
93
|
+
// name -> { digest, width, height, data } — depth-map grids the framework registers
|
|
94
|
+
// pre-build via `_registerImage` (ensureImages, Task 4). Kernel-lifetime like
|
|
95
|
+
// `imports` above: plain data, not WASM, so there is nothing to T()/dispose.
|
|
96
|
+
const images = new Map();
|
|
92
97
|
// Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
|
|
93
98
|
// tracks the result, and returns the triple the cache needs to pin/dispose it.
|
|
94
99
|
const cached = (hash, computeM) => cache.lookup(hash, () => {
|
|
@@ -614,6 +619,45 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
614
619
|
// X and drives Y to 0, squishing the top to a line). Broadcast for a uniform taper.
|
|
615
620
|
return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
|
|
616
621
|
}),
|
|
622
|
+
// Height-map relief. The grid → triangle conversion is a pure leaf shared with
|
|
623
|
+
// the OCCT backend (heightfield.js), so both kernels build from identical
|
|
624
|
+
// triangle data. `manifoldFromMesh`'s output is NOT self-tracked (see its own
|
|
625
|
+
// header comment — "caller tracks `out`") so, unlike `import`'s master, this
|
|
626
|
+
// build wraps it in T() itself: a heightfield solid is an ordinary per-build
|
|
627
|
+
// result, not a kernel-lifetime master.
|
|
628
|
+
heightfield: (src, opts = {}) => {
|
|
629
|
+
const grid = typeof src === "string" ? images.get(src) : src;
|
|
630
|
+
if (!grid) throw new Error(`heightfield: unknown image "${src}" — declare it in the part's \`images\` field`);
|
|
631
|
+
const build = () => {
|
|
632
|
+
const { positions, indices, warnings } = heightfieldMesh(grid, opts);
|
|
633
|
+
// Only runs on a cache MISS for a registered image (every call for an
|
|
634
|
+
// inline grid, which always takes the bypass below). A repeat build of
|
|
635
|
+
// the same registered image at the same options is a cache HIT and never
|
|
636
|
+
// re-enters this closure, so a pitch-clamp warning is NOT re-emitted on
|
|
637
|
+
// a warm rebuild — intentional dedup (same call, same warning, once),
|
|
638
|
+
// not a missed re-warn.
|
|
639
|
+
for (const w of warnings) recordWarning(w);
|
|
640
|
+
return T(manifoldFromMesh(wasm, positions, indices));
|
|
641
|
+
};
|
|
642
|
+
// Only a registered image carries a content digest to key the cache on. An
|
|
643
|
+
// inline {width,height,data} grid has no identity of its own — keying it on a
|
|
644
|
+
// literal string like "inline" would let two DIFFERENT inline grids at the
|
|
645
|
+
// same options collide on the same cache key, and the second call would
|
|
646
|
+
// silently get back the FIRST call's solid. Skip the cache for those; the
|
|
647
|
+
// inline path is the test/low-level path and isn't performance-sensitive.
|
|
648
|
+
// (The returned solid still gets a real content-fingerprint hash below, so a
|
|
649
|
+
// downstream union/cut composing two different inline heightfields doesn't
|
|
650
|
+
// inherit the same collision risk one level up.)
|
|
651
|
+
if (typeof src !== "string") {
|
|
652
|
+
return wrap(build(), h("heightfield-inline", grid.width, grid.height, hashGridData(grid.data),
|
|
653
|
+
opts.w, opts.d, opts.base, opts.maxZ, opts.pitch, opts.invert, opts.range, opts.origin));
|
|
654
|
+
}
|
|
655
|
+
return cached(
|
|
656
|
+
h("heightfield", grid.digest, opts.w, opts.d, opts.base, opts.maxZ,
|
|
657
|
+
opts.pitch, opts.invert, opts.range, opts.origin),
|
|
658
|
+
build,
|
|
659
|
+
);
|
|
660
|
+
},
|
|
617
661
|
// Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
|
|
618
662
|
// holes regardless of their winding (outer + holes, no per-hole boolean cut).
|
|
619
663
|
// A Shape2D `profile` (curve-native, possibly multi-region) materializes through
|
|
@@ -716,6 +760,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
716
760
|
// Registration memo: undefined for an error entry, so a later registration with
|
|
717
761
|
// the same digest can upgrade it rather than being treated as a no-op repeat.
|
|
718
762
|
_importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
|
|
763
|
+
// Depth-map grids, registered pre-build by the framework via `_registerImage`
|
|
764
|
+
// (ensureImages, Task 4). Unlike imports there is no per-format error entry:
|
|
765
|
+
// every backend can consume a normalized grid, so registration never fails.
|
|
766
|
+
_registerImage: ({ name, digest, width, height, data }) => {
|
|
767
|
+
images.set(name, { digest, width, height, data });
|
|
768
|
+
},
|
|
769
|
+
_imageDigest: (name) => images.get(name)?.digest,
|
|
770
|
+
// Drop every registered name NOT in `keep` (a Set) — the images-map twin of
|
|
771
|
+
// fonts' kernel._fonts prune in jobs.js. `images` is keyed on the part's
|
|
772
|
+
// declared name (e.g. "relief"), not on content, so without this a name
|
|
773
|
+
// the user cleared — or a different part that reuses the same key across a
|
|
774
|
+
// worker-rebind — would silently keep resolving to a prior build's grid.
|
|
775
|
+
// A method rather than exposing `images` itself: jobs.js only ever needs
|
|
776
|
+
// "keep exactly this set", never raw Map access.
|
|
777
|
+
_pruneImages: (keep) => {
|
|
778
|
+
for (const name of [...images.keys()]) if (!keep.has(name)) images.delete(name);
|
|
779
|
+
},
|
|
719
780
|
_acceptsMesh: true,
|
|
720
781
|
shape2d,
|
|
721
782
|
// Backend-internal region adapter: the shared native engine (contour-offset.js)
|
|
@@ -33,8 +33,80 @@ import { createSolidCache } from "./solid-cache.js";
|
|
|
33
33
|
import { composePose, transformPositions, rotateNormals } from "./pose.js";
|
|
34
34
|
import { filterBrepEdges } from "./brep-edges.js";
|
|
35
35
|
import { meshToStl } from "./mesh-stl.js";
|
|
36
|
+
import { heightfieldMesh, hashGridData } from "./heightfield.js";
|
|
36
37
|
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.25 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
37
38
|
|
|
39
|
+
// Heightfield sew-time warning threshold, in triangles. Measured by the plan's
|
|
40
|
+
// Task 1 probe against this exact sequence: 24,182 triangles sewed in 8.2-8.6s
|
|
41
|
+
// (the largest size that stayed under ~10s), 29,750 took 10.4-10.6s. 24000 is a
|
|
42
|
+
// round number just under the last confirmed-under-10s measurement, leaving
|
|
43
|
+
// headroom for slower hardware than the probe ran on.
|
|
44
|
+
const STEP_TRIANGLE_WARN = 24000;
|
|
45
|
+
// STEP bytes per input triangle, from the same probe: 2.3-2.6 KB/triangle across
|
|
46
|
+
// 7,670 -> 81,590 triangles (~37 STEP entities per triangle), linear in triangle
|
|
47
|
+
// count. Only used to put an order-of-magnitude figure in the warning below.
|
|
48
|
+
const STEP_KB_PER_TRIANGLE = 2.4;
|
|
49
|
+
// Monotonic scratch-filename counter for the emscripten FS round trip in
|
|
50
|
+
// stlBufferToShape. A counter, not Date.now()/Math.random(): `build` must be a
|
|
51
|
+
// pure function of (k, p, d), and the name never reaches geometry or a cache key.
|
|
52
|
+
// Collision-free within a process, which is all that matters — the file is
|
|
53
|
+
// written and unlinked inside one synchronous call.
|
|
54
|
+
let stlSeq = 0;
|
|
55
|
+
|
|
56
|
+
// Binary STL ArrayBuffer -> replicad Solid, SYNCHRONOUSLY. This is replicad's own
|
|
57
|
+
// `importSTL` body with the `await blob.arrayBuffer()` removed (we already hold an
|
|
58
|
+
// ArrayBuffer from meshToStl) — verified against replicad 0.23.1 by the plan's
|
|
59
|
+
// Task 1 probe at every grid size up to 81,590 triangles. Synchronous matters:
|
|
60
|
+
// `build(k, p, d)` is a synchronous pure function, so a part author cannot await,
|
|
61
|
+
// and the Manifold backend's `heightfield` is synchronous too.
|
|
62
|
+
//
|
|
63
|
+
// `getOC`, `localGC` and `cast` are all public replicad exports. Every
|
|
64
|
+
// intermediate native object is registered with localGC's `r()` and freed by
|
|
65
|
+
// `gc()` in a `finally` — unconditional, unlike the real importSTL, which frees
|
|
66
|
+
// only on its two known exit paths and would leak if e.g. `Build()` threw.
|
|
67
|
+
// `cast()` hands back a fresh JS handle onto the same reference-counted OCCT
|
|
68
|
+
// TShape, so freeing `asSolid` does not invalidate the returned shape.
|
|
69
|
+
function stlBufferToShape(replicad, arrayBuffer) {
|
|
70
|
+
const oc = replicad.getOC();
|
|
71
|
+
const [r, gc] = replicad.localGC();
|
|
72
|
+
const fileName = `hf-${stlSeq++}.stl`;
|
|
73
|
+
try {
|
|
74
|
+
oc.FS.writeFile(`/${fileName}`, new Uint8Array(arrayBuffer));
|
|
75
|
+
const reader = r(new oc.StlAPI_Reader());
|
|
76
|
+
const readShape = r(new oc.TopoDS_Shell());
|
|
77
|
+
if (!reader.Read(readShape, fileName)) throw new Error("StlAPI_Reader rejected the mesh");
|
|
78
|
+
const shapeUpgrader = r(new oc.ShapeUpgrade_UnifySameDomain_2(readShape, true, true, false));
|
|
79
|
+
shapeUpgrader.Build();
|
|
80
|
+
const upgradedShape = r(shapeUpgrader.Shape());
|
|
81
|
+
// Watertightness gate. MakeSolid will happily build a "solid" from an OPEN
|
|
82
|
+
// shell and report nothing wrong, which is the silent-corruption mode this
|
|
83
|
+
// guards. Two tempting alternatives were probed on the installed OCCT build
|
|
84
|
+
// and BOTH are inadequate — do not swap this for either:
|
|
85
|
+
// - `BRepBuilderAPI_MakeSolid.IsDone()` returns true on a freshly
|
|
86
|
+
// constructed maker before any Add(), and true for a holed shell. It
|
|
87
|
+
// reports "a maker exists", not "the shell closed".
|
|
88
|
+
// - A positive-volume check passes too: a 20,790-triangle shell with 40
|
|
89
|
+
// triangles removed still measured positive volume, only ~3% low.
|
|
90
|
+
// `BRep_Tool.IsClosed_1` is a topological free-edge pass with no geometry —
|
|
91
|
+
// measured at 0 ms (below timer resolution) against a 6.9 s sew. It must be
|
|
92
|
+
// applied to the UPGRADED SHELL, BEFORE MakeSolid: on the resulting solid it
|
|
93
|
+
// falls through to a never-set Closed() flag and answers false even when the
|
|
94
|
+
// shape is watertight. Throwing here lands in heightfield's catch, so the
|
|
95
|
+
// author gets the triangle-count-and-`pitch` message, not a raw OCCT error.
|
|
96
|
+
if (!oc.BRep_Tool.IsClosed_1(upgradedShape)) throw new Error("the sewn shell is not watertight (it has free edges)");
|
|
97
|
+
const solidSTL = r(new oc.BRepBuilderAPI_MakeSolid_1());
|
|
98
|
+
// Shell_1 returns a distinct embind handle with its own .delete(), so it is
|
|
99
|
+
// registered like every other intermediate. Safe: Add() copies the shell into
|
|
100
|
+
// the builder before gc() runs.
|
|
101
|
+
solidSTL.Add(r(oc.TopoDS.Shell_1(upgradedShape)));
|
|
102
|
+
const asSolid = r(solidSTL.Solid());
|
|
103
|
+
return replicad.cast(asSolid);
|
|
104
|
+
} finally {
|
|
105
|
+
try { oc.FS.unlink(`/${fileName}`); } catch { /* writeFile may never have run */ }
|
|
106
|
+
gc();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
38
110
|
export function createOcctKernel(replicad) {
|
|
39
111
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
40
112
|
loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
@@ -53,6 +125,11 @@ export function createOcctKernel(replicad) {
|
|
|
53
125
|
// registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
|
|
54
126
|
// solid cache: imports are the framework's own memo, keyed by name+digest).
|
|
55
127
|
const imports = new Map();
|
|
128
|
+
// name -> { digest, width, height, data } — depth-map grids the framework
|
|
129
|
+
// registers pre-build via `_registerImage` (ensureImages). Kernel-lifetime like
|
|
130
|
+
// `imports` above, and plain data rather than OCCT objects, so there is nothing
|
|
131
|
+
// to free.
|
|
132
|
+
const images = new Map();
|
|
56
133
|
|
|
57
134
|
const cache = createSolidCache();
|
|
58
135
|
// Boundary ops route through cache.lookup. pin is unused here (no cleanup() —
|
|
@@ -486,11 +563,69 @@ export function createOcctKernel(replicad) {
|
|
|
486
563
|
});
|
|
487
564
|
};
|
|
488
565
|
|
|
566
|
+
// Height-map relief. The grid -> triangle conversion is the SAME pure leaf the
|
|
567
|
+
// Manifold backend uses (heightfield.js), so both kernels build from identical
|
|
568
|
+
// triangle data; the difference is only what happens next. Here the triangles go
|
|
569
|
+
// out through meshToStl and back in through OCCT's own mesh->B-rep path
|
|
570
|
+
// (StlAPI_Reader + ShapeUpgrade_UnifySameDomain + MakeSolid — see
|
|
571
|
+
// stlBufferToShape above). The result booleans, fillets and exports to STEP like
|
|
572
|
+
// any other B-rep shape; its surface is triangulated rather than analytic, which
|
|
573
|
+
// is the accepted trade for STEP support on a depth map.
|
|
574
|
+
const heightfield = (src, opts = {}) => {
|
|
575
|
+
const grid = typeof src === "string" ? images.get(src) : src;
|
|
576
|
+
if (!grid) throw new Error(`heightfield: unknown image "${src}" — declare it in the part's \`images\` field`);
|
|
577
|
+
const build = (key) => {
|
|
578
|
+
const { positions, indices, warnings } = heightfieldMesh(grid, opts);
|
|
579
|
+
// Only runs on a cache MISS for a registered image (and on every call for an
|
|
580
|
+
// inline grid, which always takes the bypass below), so a pitch clamp is not
|
|
581
|
+
// re-warned on a warm rebuild — the same intentional dedup as Manifold's.
|
|
582
|
+
for (const w of warnings) recordWarning(w);
|
|
583
|
+
const tris = indices.length / 3;
|
|
584
|
+
// ONE warning carrying both facts, because sew time and STEP size are both
|
|
585
|
+
// linear in triangle count — a second threshold would buy nothing. The size
|
|
586
|
+
// figure is deliberately hedged: ShapeUpgrade_UnifySameDomain merges only
|
|
587
|
+
// genuinely coplanar faces, so a flat relief compresses far better than a
|
|
588
|
+
// high-frequency one at the same triangle count.
|
|
589
|
+
if (tris > STEP_TRIANGLE_WARN) {
|
|
590
|
+
const mb = (tris * STEP_KB_PER_TRIANGLE) / 1024;
|
|
591
|
+
recordWarning(`heightfield: ${tris} triangles on the B-rep backend — sewing is slow above ${STEP_TRIANGLE_WARN} and STEP export will be roughly ${mb.toFixed(0)} MB (a content-dependent estimate: only coplanar faces merge, so a flat relief exports far smaller than a high-frequency one). Raise \`pitch\` to reduce the triangle count.`);
|
|
592
|
+
}
|
|
593
|
+
// Written OUTSIDE the try below: an allocation failure here is a mesh-size
|
|
594
|
+
// problem, not a sewing failure, and must not be reported as one.
|
|
595
|
+
const stl = meshToStl(positions, indices);
|
|
596
|
+
let shape;
|
|
597
|
+
try {
|
|
598
|
+
shape = stlBufferToShape(replicad, stl);
|
|
599
|
+
} catch (e) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
`heightfield: could not sew ${tris} triangles into a B-rep solid (${e.message}). ` +
|
|
602
|
+
"Raise `pitch` to reduce the triangle count, or build this sub-part on the Manifold backend.",
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
return wrap(shape, [], key);
|
|
606
|
+
};
|
|
607
|
+
// Only a REGISTERED image carries a content digest to key the cache on. An
|
|
608
|
+
// inline {width,height,data} grid has no identity of its own, so keying it on
|
|
609
|
+
// a literal would let two DIFFERENT inline grids at the same options collide
|
|
610
|
+
// and hand the second caller the first one's solid. Bypass the cache for those
|
|
611
|
+
// — the inline path is the test/low-level path and isn't performance-sensitive
|
|
612
|
+
// — but still give the solid a real content fingerprint in its `_hash`, since
|
|
613
|
+
// that hash feeds DOWNSTREAM boolean cache keys (cut/union). Matches the
|
|
614
|
+
// Manifold backend exactly.
|
|
615
|
+
if (typeof src !== "string") {
|
|
616
|
+
return build(h("heightfield-inline", grid.width, grid.height, hashGridData(grid.data),
|
|
617
|
+
opts.w, opts.d, opts.base, opts.maxZ, opts.pitch, opts.invert, opts.range, opts.origin));
|
|
618
|
+
}
|
|
619
|
+
const key = h("heightfield", grid.digest, opts.w, opts.d, opts.base, opts.maxZ,
|
|
620
|
+
opts.pitch, opts.invert, opts.range, opts.origin);
|
|
621
|
+
return cached(key, () => build(key));
|
|
622
|
+
};
|
|
623
|
+
|
|
489
624
|
const kernel = finishKernel({
|
|
490
625
|
cylinder, // boredCylinder: the kernel front's default composition is exactly right here
|
|
491
626
|
box: (min, max) => cached(h("box", min, max), () => wrap(makeBox(min, max), [], h("box", min, max))),
|
|
492
627
|
roundedBox,
|
|
493
|
-
prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
|
|
628
|
+
prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube, heightfield,
|
|
494
629
|
sphere: (r) => cached(h("sphere", r), () => wrap(makeSphere(r), [], h("sphere", r))),
|
|
495
630
|
union: (solids) => {
|
|
496
631
|
const key = h("union", solids.map((s) => s._hash));
|
|
@@ -536,6 +671,18 @@ export function createOcctKernel(replicad) {
|
|
|
536
671
|
// Registration memo: undefined for an error entry, so a later registration with
|
|
537
672
|
// the same digest can upgrade it rather than being treated as a no-op repeat.
|
|
538
673
|
_importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
|
|
674
|
+
// Depth-map grids, registered pre-build by the framework via `_registerImage`
|
|
675
|
+
// (ensureImages). Unlike imports there is no per-format error entry: every
|
|
676
|
+
// backend can consume a normalized grid, so registration never fails.
|
|
677
|
+
_registerImage: ({ name, digest, width, height, data }) => {
|
|
678
|
+
images.set(name, { digest, width, height, data });
|
|
679
|
+
},
|
|
680
|
+
_imageDigest: (name) => images.get(name)?.digest,
|
|
681
|
+
// Drop every registered name NOT in `keep` (a Set) — see the identical
|
|
682
|
+
// comment on the Manifold backend's `_pruneImages` for why this exists.
|
|
683
|
+
_pruneImages: (keep) => {
|
|
684
|
+
for (const name of [...images.keys()]) if (!keep.has(name)) images.delete(name);
|
|
685
|
+
},
|
|
539
686
|
_acceptsStep: true,
|
|
540
687
|
beginSubPart: (name) => cache.begin(name),
|
|
541
688
|
endSubPart: () => cache.end(),
|
|
@@ -281,6 +281,16 @@ export const KERNEL_OP_SPECS = {
|
|
|
281
281
|
},
|
|
282
282
|
// loftSmooth: range checks live in loft-smooth.js, next to the defaults they guard.
|
|
283
283
|
loftSmooth: { toArgs: passThrough("loftSmooth", ["sections", "stations", "samples", "shading", "closed"], ["sections"]) },
|
|
284
|
+
// heightfield: (nameOrGrid, opts) is a two-positional call, not the options-object
|
|
285
|
+
// convention — toArgs only normalizes a bare single-arg call (kernel-front.js's
|
|
286
|
+
// wrapper invokes it as `toArgs(a[0], k._recordWarning)`, so the second
|
|
287
|
+
// parameter here is always the kernel's warning recorder, never a real `opts`
|
|
288
|
+
// — do not thread it through as one). The rest of the range/positivity checks
|
|
289
|
+
// (base, pitch) live in heightfield.js next to the defaults they guard, same
|
|
290
|
+
// split as loftSmooth above.
|
|
291
|
+
heightfield: { toArgs: (a) => [a, {}], check: (_src, o = {}) => {
|
|
292
|
+
if (!(o.w > 0) || !(o.d > 0)) throw new Error("heightfield: w and d must be positive");
|
|
293
|
+
} },
|
|
284
294
|
roundedBox: { toArgs: roundedBoxArgs },
|
|
285
295
|
roundedCylinder: { toArgs: roundedCylinderArgs },
|
|
286
296
|
torus: { toArgs: torusArgs },
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Pure-JS PNG → luminance grid. Lives in the worker graph, so it must be DOM-free
|
|
2
|
+
// and node:-free: no createImageBitmap/OffscreenCanvas (browser-only), no pngjs
|
|
3
|
+
// (Node-only). One decoder in one place is what keeps the browser, the CLI and CI
|
|
4
|
+
// from disagreeing about geometry. Inflate comes from fflate, already in this
|
|
5
|
+
// closure via threemf-parse.js.
|
|
6
|
+
import { unzlibSync } from "fflate";
|
|
7
|
+
|
|
8
|
+
const SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
9
|
+
const U16 = 65535;
|
|
10
|
+
|
|
11
|
+
// Rec. 709 luma, the same weighting a viewer would show.
|
|
12
|
+
const luma = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
13
|
+
|
|
14
|
+
function paeth(a, b, c) {
|
|
15
|
+
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
16
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function decodePng(input) {
|
|
20
|
+
const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
|
|
21
|
+
for (let i = 0; i < 8; i++) if (u8[i] !== SIG[i]) throw new Error("decodePng: not a PNG (bad signature)");
|
|
22
|
+
|
|
23
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
24
|
+
let off = 8, width = 0, height = 0, depth = 8, colorType = 6, interlace = 0;
|
|
25
|
+
let palette = null;
|
|
26
|
+
const idat = [];
|
|
27
|
+
|
|
28
|
+
while (off + 8 <= u8.length) {
|
|
29
|
+
const len = dv.getUint32(off);
|
|
30
|
+
const type = String.fromCharCode(u8[off + 4], u8[off + 5], u8[off + 6], u8[off + 7]);
|
|
31
|
+
const body = off + 8;
|
|
32
|
+
if (body + len > u8.length) throw new Error(`decodePng: truncated file (chunk ${type} runs past the end)`);
|
|
33
|
+
if (type === "IHDR") {
|
|
34
|
+
width = dv.getUint32(body); height = dv.getUint32(body + 4);
|
|
35
|
+
depth = u8[body + 8]; colorType = u8[body + 9]; interlace = u8[body + 12];
|
|
36
|
+
} else if (type === "PLTE") palette = u8.subarray(body, body + len);
|
|
37
|
+
// tRNS (alpha) is intentionally not parsed: ignoring alpha is correct for a
|
|
38
|
+
// depth map, and this chunk still falls through to the unconditional
|
|
39
|
+
// `off` advance below like any other chunk type we don't care about.
|
|
40
|
+
else if (type === "IDAT") idat.push(u8.subarray(body, body + len));
|
|
41
|
+
else if (type === "IEND") break;
|
|
42
|
+
off = body + len + 4; // + CRC
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!width || !height) throw new Error("decodePng: truncated file (no IHDR)");
|
|
46
|
+
if (interlace) throw new Error("decodePng: interlaced (Adam7) PNGs are not supported — re-save without interlacing");
|
|
47
|
+
|
|
48
|
+
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
|
|
49
|
+
if (!CHANNELS) throw new Error(`decodePng: unsupported colour type ${colorType}`);
|
|
50
|
+
if (colorType === 3 && !palette) throw new Error("decodePng: palette image with no PLTE chunk");
|
|
51
|
+
|
|
52
|
+
// Concatenate IDAT then inflate.
|
|
53
|
+
let total = 0; for (const c of idat) total += c.length;
|
|
54
|
+
if (!total) throw new Error("decodePng: truncated file (no IDAT)");
|
|
55
|
+
const z = new Uint8Array(total);
|
|
56
|
+
{ let p = 0; for (const c of idat) { z.set(c, p); p += c.length; } }
|
|
57
|
+
const raw = unzlibSync(z);
|
|
58
|
+
|
|
59
|
+
const bpp = Math.max(1, (CHANNELS * depth) >> 3);
|
|
60
|
+
const rowBytes = Math.ceil((CHANNELS * depth * width) / 8);
|
|
61
|
+
if (raw.length < (rowBytes + 1) * height) throw new Error("decodePng: truncated file (short image data)");
|
|
62
|
+
|
|
63
|
+
// Un-filter in place, row by row.
|
|
64
|
+
const img = new Uint8Array(rowBytes * height);
|
|
65
|
+
for (let y = 0; y < height; y++) {
|
|
66
|
+
const ft = raw[y * (rowBytes + 1)];
|
|
67
|
+
const src = y * (rowBytes + 1) + 1;
|
|
68
|
+
const dst = y * rowBytes, up = dst - rowBytes;
|
|
69
|
+
for (let x = 0; x < rowBytes; x++) {
|
|
70
|
+
const v = raw[src + x];
|
|
71
|
+
const a = x >= bpp ? img[dst + x - bpp] : 0;
|
|
72
|
+
const b = y > 0 ? img[up + x] : 0;
|
|
73
|
+
const c = x >= bpp && y > 0 ? img[up + x - bpp] : 0;
|
|
74
|
+
img[dst + x] = (ft === 0 ? v : ft === 1 ? v + a : ft === 2 ? v + b
|
|
75
|
+
: ft === 3 ? v + ((a + b) >> 1) : v + paeth(a, b, c)) & 0xff;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Read samples → luminance, scaled to 0..65535.
|
|
80
|
+
const out = new Uint16Array(width * height);
|
|
81
|
+
const maxIn = depth === 16 ? 65535 : (1 << depth) - 1;
|
|
82
|
+
const readSample = (row, i) => {
|
|
83
|
+
if (depth === 16) return (img[row + i * 2] << 8) | img[row + i * 2 + 1];
|
|
84
|
+
if (depth === 8) return img[row + i];
|
|
85
|
+
const per = 8 / depth, byte = img[row + ((i / per) | 0)];
|
|
86
|
+
const shift = 8 - depth * ((i % per) + 1);
|
|
87
|
+
return (byte >> shift) & maxIn;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (let y = 0; y < height; y++) {
|
|
91
|
+
const row = y * rowBytes;
|
|
92
|
+
for (let x = 0; x < width; x++) {
|
|
93
|
+
let v;
|
|
94
|
+
if (colorType === 3) {
|
|
95
|
+
const idx = readSample(row, x) * 3;
|
|
96
|
+
v = luma(palette[idx], palette[idx + 1], palette[idx + 2]) / 255;
|
|
97
|
+
} else if (colorType === 0 || colorType === 4) {
|
|
98
|
+
v = readSample(row, x * CHANNELS) / maxIn;
|
|
99
|
+
} else {
|
|
100
|
+
const b0 = x * CHANNELS;
|
|
101
|
+
v = luma(readSample(row, b0), readSample(row, b0 + 1), readSample(row, b0 + 2)) / maxIn;
|
|
102
|
+
}
|
|
103
|
+
out[y * width + x] = Math.round(Math.min(Math.max(v, 0), 1) * U16);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { width, height, data: out };
|
|
107
|
+
}
|
|
@@ -19,3 +19,99 @@ function fnv(s) {
|
|
|
19
19
|
for (let i = 0; i < s.length; i++) { hsh ^= s.charCodeAt(i); hsh = Math.imul(hsh, 0x01000193); }
|
|
20
20
|
return (hsh >>> 0).toString(36);
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
// ── byte-aware JSON.stringify support ───────────────────────────────────────
|
|
24
|
+
// A shared home for param-deps.js's relevanceHash, backend-select.js's
|
|
25
|
+
// reroute-latch snapshot, and oracle/verify.js's memo signature — none of
|
|
26
|
+
// which should import one another's concerns. All three wholesale-hash a
|
|
27
|
+
// params object with JSON.stringify, and a byte-valued param — an
|
|
28
|
+
// ArrayBuffer, a SharedArrayBuffer, or a typed-array/DataView view
|
|
29
|
+
// (Buffer included — the shape a param-supplied image takes on the
|
|
30
|
+
// partforge-cloud sandbox path, which cannot fetch URLs and puts PNG bytes
|
|
31
|
+
// straight into a param, and the shape `fs.readFileSync` hands the CLI) —
|
|
32
|
+
// breaks JSON.stringify's default handling two different ways: an
|
|
33
|
+
// ArrayBuffer/SharedArrayBuffer has no enumerable own properties, so it
|
|
34
|
+
// always serializes as the same "{}" regardless of content (two different
|
|
35
|
+
// images collide on one cache/route key); a typed-array VIEW goes the other
|
|
36
|
+
// way, expanding to one JSON number per byte (tens of thousands of
|
|
37
|
+
// characters for a small PNG, on every hash). `byteAwareReplacer` is a
|
|
38
|
+
// JSON.stringify replacer — pass it as the second argument — that
|
|
39
|
+
// substitutes a stable content fingerprint for any of these shapes instead.
|
|
40
|
+
//
|
|
41
|
+
// MUST be a plain function, not an arrow: JSON.stringify calls the replacer
|
|
42
|
+
// with `this` bound to the holder (the object/array currently being
|
|
43
|
+
// serialized), and `this[key]` is how a replacer recovers the RAW value —
|
|
44
|
+
// before `Buffer.prototype.toJSON()` (or any other .toJSON) has already run
|
|
45
|
+
// on it. `value`, the second argument, has ALREADY been through that
|
|
46
|
+
// conversion: for a Node Buffer, `value` here is
|
|
47
|
+
// `{type:"Buffer",data:[...]}`, JSON.stringify's default expansion, one
|
|
48
|
+
// number per byte — exactly the pathology this function exists to avoid,
|
|
49
|
+
// reached through a different door. `this[key]` bypasses toJSON entirely, an
|
|
50
|
+
// ordinary property read of the holder returns whatever object is actually
|
|
51
|
+
// stored there.
|
|
52
|
+
export function byteAwareReplacer(key, value) {
|
|
53
|
+
const raw = this ? this[key] : value;
|
|
54
|
+
return isByteish(raw) ? fingerprintBytes(raw) : value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Object.prototype.toString.call(...), not `instanceof`/typed-array
|
|
58
|
+
// constructor checks: `instanceof ArrayBuffer` is false for an ArrayBuffer
|
|
59
|
+
// from another realm (a different vm context/iframe/worker — Node's `vm`
|
|
60
|
+
// module and Electron both make this reachable), and it silently falls back
|
|
61
|
+
// to the original bug ("{}") rather than throwing. The [[Class]] internal
|
|
62
|
+
// slot Object.prototype.toString reads is realm-independent.
|
|
63
|
+
// `ArrayBuffer.isView` itself IS already realm-independent (it checks for a
|
|
64
|
+
// [[ViewedArrayBuffer]] internal slot, not a prototype chain), so it is kept
|
|
65
|
+
// as-is for the view case — including a Buffer, which is a Uint8Array
|
|
66
|
+
// subclass and so already passes it.
|
|
67
|
+
function isByteish(v) {
|
|
68
|
+
if (v === null || typeof v !== "object") return false;
|
|
69
|
+
if (ArrayBuffer.isView(v)) return true;
|
|
70
|
+
const tag = Object.prototype.toString.call(v);
|
|
71
|
+
return tag === "[object ArrayBuffer]" || tag === "[object SharedArrayBuffer]";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Memoized per buffer/view IDENTITY (not content) in a WeakMap, mirroring
|
|
75
|
+
// kernel-front.js's byteCache: the params object these callers hash is
|
|
76
|
+
// mutated in place and re-hashed on every regen (mesh-cache.js's hashFor, a
|
|
77
|
+
// backend-select reroute check), so re-walking megabytes of image bytes on
|
|
78
|
+
// every slider tick that touches some OTHER param would be its own
|
|
79
|
+
// regression. A given buffer is fingerprinted once and the string reused for
|
|
80
|
+
// as long as that identity survives.
|
|
81
|
+
const byteFingerprints = new WeakMap(); // ArrayBuffer|SharedArrayBuffer|ArrayBufferView -> fingerprint string
|
|
82
|
+
|
|
83
|
+
// Two independent FNV-1a streams over the same bytes (different offset
|
|
84
|
+
// bases, same prime), concatenated — a cheap ~64-bit fold. A single 32-bit
|
|
85
|
+
// fold (what h() uses, and what this used through fix round 1) collides at
|
|
86
|
+
// ~2⁻³² for two same-length buffers, which is fine for h(): that graph is
|
|
87
|
+
// rebuilt fresh every build, nothing accumulates. This fingerprint is
|
|
88
|
+
// different — it is retained in the mesh cache for a whole session — so a
|
|
89
|
+
// collision here means silently stale geometry, the exact failure class
|
|
90
|
+
// fix round 1 existed to eliminate. Widening makes it negligible instead of
|
|
91
|
+
// merely unlikely.
|
|
92
|
+
function fnvBytes64(u8) {
|
|
93
|
+
let h1 = 0x811c9dc5; // FNV-1a's standard 32-bit offset basis
|
|
94
|
+
let h2 = 0x9e3779b9; // a distinct, unrelated 32-bit constant (golden-ratio mix constant)
|
|
95
|
+
for (let i = 0; i < u8.length; i++) {
|
|
96
|
+
const b = u8[i];
|
|
97
|
+
h1 ^= b; h1 = Math.imul(h1, 0x01000193);
|
|
98
|
+
h2 ^= b; h2 = Math.imul(h2, 0x01000193);
|
|
99
|
+
}
|
|
100
|
+
return (h1 >>> 0).toString(36) + (h2 >>> 0).toString(36);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fingerprintBytes(value) {
|
|
104
|
+
let cached = byteFingerprints.get(value);
|
|
105
|
+
if (cached !== undefined) return cached;
|
|
106
|
+
const u8 = ArrayBuffer.isView(value)
|
|
107
|
+
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
|
108
|
+
: new Uint8Array(value); // ArrayBuffer or SharedArrayBuffer
|
|
109
|
+
// "bytes:" prefix plus the raw byte length folded in alongside the ~64-bit
|
|
110
|
+
// digest. No ordinary control value is a string shaped exactly like this
|
|
111
|
+
// (the panel's own control types are numbers/booleans/strings/enums), so
|
|
112
|
+
// this fingerprint only ever competes with OTHER buffers' fingerprints,
|
|
113
|
+
// not a genuine string param's value.
|
|
114
|
+
cached = `bytes:${fnvBytes64(u8)}:${u8.length}`;
|
|
115
|
+
byteFingerprints.set(value, cached);
|
|
116
|
+
return cached;
|
|
117
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// MAIN-THREAD ONLY. Converts any image the browser can decode into a PNG a part's
|
|
2
|
+
// `images` field can consume, downsampling on the way.
|
|
3
|
+
//
|
|
4
|
+
// This file uses createImageBitmap and a canvas, so it must NEVER be reachable
|
|
5
|
+
// from the geometry worker's import closure — test/worker-layering.test.js
|
|
6
|
+
// enforces that. It is exported from src/index.js, the DOM entry documented as
|
|
7
|
+
// one a part's `build` must never import.
|
|
8
|
+
//
|
|
9
|
+
// Why PNG and not the source format: core decodes PNG only, in pure JS, so one
|
|
10
|
+
// decoder produces the geometry in the browser, the CLI and CI alike. Converting
|
|
11
|
+
// once at ingest keeps that single decoder authoritative — the browser's codec
|
|
12
|
+
// output is baked into an immutable PNG rather than racing ours at build time.
|
|
13
|
+
//
|
|
14
|
+
// Why downsample instead of switching to JPEG: pitch caps useful resolution
|
|
15
|
+
// anyway (a 60mm plate at 0.3mm pitch samples 200x200), and JPEG is 8-bit and
|
|
16
|
+
// DCT-ringing — in a depth map those are geometric artifacts, height terracing
|
|
17
|
+
// and 8x8 block bumps, not cosmetic ones.
|
|
18
|
+
|
|
19
|
+
export async function imageToPng(fileOrBlob, { maxSize = 1024 } = {}) {
|
|
20
|
+
const bmp = await createImageBitmap(fileOrBlob);
|
|
21
|
+
try {
|
|
22
|
+
const scale = Math.min(1, maxSize / Math.max(bmp.width, bmp.height));
|
|
23
|
+
const w = Math.max(1, Math.round(bmp.width * scale));
|
|
24
|
+
const h = Math.max(1, Math.round(bmp.height * scale));
|
|
25
|
+
|
|
26
|
+
if (typeof OffscreenCanvas === "function") {
|
|
27
|
+
const c = new OffscreenCanvas(w, h);
|
|
28
|
+
c.getContext("2d").drawImage(bmp, 0, 0, w, h);
|
|
29
|
+
return await c.convertToBlob({ type: "image/png" });
|
|
30
|
+
}
|
|
31
|
+
// Safari and older engines: fall back to a detached <canvas>.
|
|
32
|
+
const c = document.createElement("canvas");
|
|
33
|
+
c.width = w;
|
|
34
|
+
c.height = h;
|
|
35
|
+
c.getContext("2d").drawImage(bmp, 0, 0, w, h);
|
|
36
|
+
return await new Promise((res, rej) =>
|
|
37
|
+
c.toBlob((b) => (b ? res(b) : rej(new Error("imageToPng: canvas encoding failed"))), "image/png"));
|
|
38
|
+
} finally {
|
|
39
|
+
bmp.close?.();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// What a PARAM-supplied image source may be. Author-declared `images` sources are
|
|
2
|
+
// code and get no restriction; this file exists only for the other case — a
|
|
3
|
+
// value that arrived in `params`.
|
|
4
|
+
//
|
|
5
|
+
// This deliberately diverges from font-source.js in one place. That file refuses
|
|
6
|
+
// every non-string on the grounds that "bytes/thunks are never param-supplied".
|
|
7
|
+
// For images they ARE: the partforge-cloud sandbox cannot fetch URLs and puts PNG
|
|
8
|
+
// bytes straight in the param. The replacement rule is sound — an ArrayBuffer in
|
|
9
|
+
// params definitionally did not arrive via a shared link, because a URL cannot
|
|
10
|
+
// carry megabytes, so it can only have been placed there by the host's own panel,
|
|
11
|
+
// which is trusted code. Bytes therefore bypass the allow check entirely, for
|
|
12
|
+
// every allow list. Do NOT copy font-source.js's non-string refusal here.
|
|
13
|
+
//
|
|
14
|
+
// DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
|
|
15
|
+
|
|
16
|
+
export const IMAGE_ALLOW_DEFAULT = ["https"];
|
|
17
|
+
|
|
18
|
+
const ASSET_SCHEME = "pfc-asset:";
|
|
19
|
+
|
|
20
|
+
// The "unset" image source. An empty value declares NO image — the documented
|
|
21
|
+
// way to leave a relief off; the op itself has no fallback, so a build() that
|
|
22
|
+
// still calls k.heightfield for an unset name gets the ordinary unknown-image
|
|
23
|
+
// throw. Never a source to fetch, and never a source to refuse.
|
|
24
|
+
export const isNoImageSource = (v) => v === undefined || v === null || v === "";
|
|
25
|
+
|
|
26
|
+
const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
|
|
27
|
+
|
|
28
|
+
// Parse once; an unparseable string is refused rather than guessed at.
|
|
29
|
+
function parse(source) {
|
|
30
|
+
try { return new URL(source); } catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function imageSourceAllowed(source, allow = IMAGE_ALLOW_DEFAULT) {
|
|
34
|
+
if (isBytes(source)) return true; // see the file header — bytes always bypass the allow check
|
|
35
|
+
if (typeof source !== "string") return false;
|
|
36
|
+
const u = parse(source);
|
|
37
|
+
if (!u) return false;
|
|
38
|
+
for (const kind of allow) {
|
|
39
|
+
// hostname/protocol, never a substring of the raw string: a URL merely
|
|
40
|
+
// CONTAINING "pfc-asset://" (e.g. in its path) must not pass, and neither
|
|
41
|
+
// must a lookalike host like `pfc-asset.evil.test` — see font-source.js's
|
|
42
|
+
// header comment, which explains the two bugs this rule has already
|
|
43
|
+
// caused when a check compared the raw string instead of the parsed URL.
|
|
44
|
+
if (kind === "https" && u.protocol === "https:") return true;
|
|
45
|
+
if (kind === "asset" && u.protocol === ASSET_SCHEME) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// paramKey → allow list, for every `type: "image"` control in the authored tree,
|
|
51
|
+
// new-shape (`controls`, including nested groups) and legacy-shape
|
|
52
|
+
// (`advanced`/`toggles`/`features`, where panel/legacy.js desugars `control:` to
|
|
53
|
+
// `type:`). Missing the legacy arrays would leave such a control silently
|
|
54
|
+
// unrestricted. Tolerant of any array being absent or malformed — it must never
|
|
55
|
+
// throw on an existing part.
|
|
56
|
+
export function imageControlAllows(part) {
|
|
57
|
+
const out = new Map();
|
|
58
|
+
const visit = (nodes) => {
|
|
59
|
+
for (const n of nodes ?? []) {
|
|
60
|
+
if (!n || typeof n !== "object") continue;
|
|
61
|
+
if (Array.isArray(n.controls)) visit(n.controls);
|
|
62
|
+
if (Array.isArray(n.advanced)) visit(n.advanced);
|
|
63
|
+
if (Array.isArray(n.toggles)) visit(n.toggles);
|
|
64
|
+
if (Array.isArray(n.features)) visit(n.features);
|
|
65
|
+
if ((n.type === "image" || n.control === "image") && typeof n.key === "string") {
|
|
66
|
+
out.set(n.key, Array.isArray(n.allow) && n.allow.length ? n.allow : IMAGE_ALLOW_DEFAULT);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
visit(part?.parameters);
|
|
71
|
+
return out;
|
|
72
|
+
}
|