partforge 0.113.0 → 0.115.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 +146 -2
- package/docs/ERROR-PATTERNS.md +6 -0
- package/docs/KERNEL-CONTRACT.md +9 -3
- package/package.json +8 -1
- package/src/framework/app.css +50 -2
- package/src/framework/geometry/circle-segs.js +56 -0
- package/src/framework/geometry/manifold-backend.js +44 -18
- package/src/framework/geometry/mesh-fillet.js +30 -15
- package/src/framework/geometry/profile.js +28 -6
- package/src/framework/lint/rules-schema.js +56 -0
- package/src/framework/lint/source-scan.js +116 -2
- package/src/framework/mount.js +21 -2
- package/src/framework/panel/author.js +5 -0
- package/src/framework/panel/json-value.js +55 -0
- package/src/framework/panel/render.js +119 -36
- package/src/framework/panel/scoped-params.js +52 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/custom.js +253 -0
- package/src/framework/panel/widgets/index.js +2 -0
- package/src/panel-values.js +6 -0
- package/types/index.d.ts +66 -0
- package/types/panel-values.d.ts +18 -0
- package/types/part.d.ts +10 -1
|
@@ -670,8 +670,12 @@ function prismTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
670
670
|
// closed-revolve dephase, which are about matching the neighboring tessellation and
|
|
671
671
|
// must not follow the blend cap. `pSegs` is the sagitta-bounded density for the blend
|
|
672
672
|
// cross-section itself (blendSegs above).
|
|
673
|
-
function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
673
|
+
function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs, flankAt = () => segs) {
|
|
674
674
|
const { O, w, u0, v0, R, span, closed, n1, n2, convex } = chain;
|
|
675
|
+
// The count the flank's own circle was built at (see apply): the cap on a flat
|
|
676
|
+
// tier, the per-radius rule on print. Every "matching the neighbouring
|
|
677
|
+
// tessellation" figure below reads this, never `segs`.
|
|
678
|
+
const flankSegs = flankAt(R);
|
|
675
679
|
// Seam-grazing guard. The edge circle passes through the flank tessellation's
|
|
676
680
|
// VERTICES (circumradius) while its facets sit at the apothem, so a revolved
|
|
677
681
|
// tool built exactly at R grazes every facet seam tangentially — Manifold
|
|
@@ -689,7 +693,7 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
689
693
|
// and a radial knife-fin of wall survived both cutters, drawing a line along the
|
|
690
694
|
// band (the label-backing bug). A synthetic corner arc measures nothing — its two
|
|
691
695
|
// points span the whole corner, and its flanks are planes, not a tessellation.
|
|
692
|
-
const kernelSag = (R + magnitude) * (1 - Math.cos(Math.PI /
|
|
696
|
+
const kernelSag = (R + magnitude) * (1 - Math.cos(Math.PI / flankSegs));
|
|
693
697
|
let dip = 0;
|
|
694
698
|
if (!chain.synthetic) {
|
|
695
699
|
const pts = chain.points;
|
|
@@ -732,7 +736,7 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
732
736
|
// with the flank's own tessellation of the same circle (the dephase note below).
|
|
733
737
|
// A SYNTHETIC corner arc (cornerArcAt) is free-standing between planes, so its
|
|
734
738
|
// angular density follows the same sagitta bound as the cross-section.
|
|
735
|
-
const aSegs = chain.synthetic ? cornerArcSegs(segs, R, magnitude) :
|
|
739
|
+
const aSegs = chain.synthetic ? cornerArcSegs(segs, R, magnitude) : flankSegs;
|
|
736
740
|
let tool = k.revolve(poly, { degrees, segs: aSegs });
|
|
737
741
|
// pose: Z → w, then twist so the revolve's start azimuth (+X) lands on the
|
|
738
742
|
// chain's start direction (backed off by the angular overshoot)
|
|
@@ -749,7 +753,7 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
749
753
|
// vertex at every step (the degenerate-needle generator). Half a step lands
|
|
750
754
|
// every crossing mid-facet. Partial arcs have a slightly different pitch
|
|
751
755
|
// (degrees don't divide evenly) and never align in the first place.
|
|
752
|
-
const dephase = closed ? Math.PI /
|
|
756
|
+
const dephase = closed ? Math.PI / flankSegs : 0;
|
|
753
757
|
const twist = Math.atan2(dot(w, cross(xImage, startDir)), dot(xImage, startDir)) + dephase;
|
|
754
758
|
if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: w, deg: (twist * 180) / Math.PI });
|
|
755
759
|
return tool.translate(O);
|
|
@@ -891,7 +895,7 @@ function weldChainPoints(pts, wallNs, closed) {
|
|
|
891
895
|
return { pts: outP, wallNs: outW };
|
|
892
896
|
}
|
|
893
897
|
|
|
894
|
-
function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null) {
|
|
898
|
+
function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null, flankAt = () => segs) {
|
|
895
899
|
const { points, closed, convex, faceN } = chain;
|
|
896
900
|
let { wallNs } = chain;
|
|
897
901
|
let pts = closed ? points.slice(0, -1) : points; // drop the duplicated closure point
|
|
@@ -1095,9 +1099,9 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
|
|
|
1095
1099
|
tools.push(...buildStretch(path, wallNs[s % nSeg]));
|
|
1096
1100
|
}
|
|
1097
1101
|
for (const got of cornerArcs.values()) {
|
|
1098
|
-
tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs));
|
|
1102
|
+
tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs, flankAt));
|
|
1099
1103
|
if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
|
|
1100
|
-
tools.push(cornerHornTool(k, got, magnitude, segs));
|
|
1104
|
+
tools.push(cornerHornTool(k, got, magnitude, segs, flankAt));
|
|
1101
1105
|
}
|
|
1102
1106
|
for (const piv of pivots) tools.push(reflexPivotTool(k, piv, magnitude, mode, segs, pSegs));
|
|
1103
1107
|
return tools;
|
|
@@ -1211,7 +1215,7 @@ function cornerArcAt(vertex, f, tin1, tin2, wall1, wall2, len1, len2, magnitude,
|
|
|
1211
1215
|
// of its step count, so its apothem ≥ R·cos(π/aSegs) > every horn vertex radius. The
|
|
1212
1216
|
// cost is a micron-deep extra bite at the corner base, covered near the tangent lines
|
|
1213
1217
|
// by the neighbors' own overshoot.
|
|
1214
|
-
function cornerHornTool(k, { vertex, f, arc }, magnitude, segs) {
|
|
1218
|
+
function cornerHornTool(k, { vertex, f, arc }, magnitude, segs, flankAt = () => segs) {
|
|
1215
1219
|
const { O, w, u0, R, span } = arc;
|
|
1216
1220
|
const delta = 0.02 * magnitude;
|
|
1217
1221
|
const rH = R * Math.cos(Math.PI / cornerArcSegs(segs, R, magnitude)) - Math.min(1e-3, 0.02 * magnitude);
|
|
@@ -1488,7 +1492,7 @@ function roundSalientCorners(selected, magnitude) {
|
|
|
1488
1492
|
// cube's outer walls land inside the material the edge cutters already remove,
|
|
1489
1493
|
// so the only new surface is the octant. Non-orthogonal corners keep the mitre
|
|
1490
1494
|
// — the safe, documented default.
|
|
1491
|
-
function cornerPatches(k, selected, r, segs) {
|
|
1495
|
+
function cornerPatches(k, selected, r, segs, flankAt = () => segs) {
|
|
1492
1496
|
const byVertex = new Map();
|
|
1493
1497
|
const push = (pt, dirOut) => {
|
|
1494
1498
|
const key = pt.map((v) => Math.round(v * 1e4)).join(",");
|
|
@@ -1527,7 +1531,7 @@ function cornerPatches(k, selected, r, segs) {
|
|
|
1527
1531
|
// is tangent to each flat face at a point and meets the edge-fillet
|
|
1528
1532
|
// cylinders tangentially at the cube walls, and tessellated tangency
|
|
1529
1533
|
// produces the same grazing-noise creases the edge tools guard against.
|
|
1530
|
-
const bury = r * (1 - Math.cos(Math.PI /
|
|
1534
|
+
const bury = r * (1 - Math.cos(Math.PI / flankAt(r))) + 1e-3; // the kernel sphere below is built at flankAt(r)
|
|
1531
1535
|
const inward = norm(add(add(e1, e2), e3));
|
|
1532
1536
|
// corner block: cube spanned by the edge frame, oversized only outward
|
|
1533
1537
|
let block = k.box({ min: [-dOut, -dOut, -dOut], max: [r, r, r] });
|
|
@@ -1554,8 +1558,17 @@ function cornerPatches(k, selected, r, segs) {
|
|
|
1554
1558
|
export function meshFillet(k, solid, opts) { return apply(k, solid, "fillet", opts?.r, opts); }
|
|
1555
1559
|
export function meshChamfer(k, solid, opts) { return apply(k, solid, "chamfer", opts?.d, opts); }
|
|
1556
1560
|
|
|
1557
|
-
|
|
1561
|
+
// `segs` is the kernel's per-circle CAP: it bounds the blend densities (blendSegs)
|
|
1562
|
+
// and is what every circle was built at on a flat tier. `segsAt(r)` is what a circle
|
|
1563
|
+
// of radius r was ACTUALLY built at — the print tier sizes circles by chord tolerance
|
|
1564
|
+
// (circle-segs.js), so a flank's facet pitch is no longer the cap. The three places
|
|
1565
|
+
// that reason about the neighbouring tessellation (revolveTool's seam-grazing sag and
|
|
1566
|
+
// closed-revolve dephase, cornerHornTool's sphere burial) ask it; everything sized
|
|
1567
|
+
// from the blend's own sagitta bound keeps the cap. Absent, it is the cap — the
|
|
1568
|
+
// pre-print-rule behaviour, and byte-identical at preview either way.
|
|
1569
|
+
function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg = 20, segsAt = null } = {}) {
|
|
1558
1570
|
if (!(magnitude > 0)) throw new Error(`mesh ${mode}: magnitude must be > 0`);
|
|
1571
|
+
const flankAt = segsAt ?? (() => segs);
|
|
1559
1572
|
const chains = chainEdges(detectSharpEdges(solid.toIndexedMesh(), { sharpDeg }));
|
|
1560
1573
|
const selected = chains.filter((ch) => matchesSelector(ch, edges));
|
|
1561
1574
|
if (!selected.length) throw new UnsupportedEdgeError(`${mode} selector matched no sharp edges`);
|
|
@@ -1597,13 +1610,15 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
|
|
|
1597
1610
|
const pSegs = blendSegs(segs, magnitude);
|
|
1598
1611
|
const toolsFor = (ch) =>
|
|
1599
1612
|
ch.kind === "planar"
|
|
1600
|
-
? planarTool(k, ch, magnitude, mode, segs, pSegs, endTins)
|
|
1601
|
-
:
|
|
1613
|
+
? planarTool(k, ch, magnitude, mode, segs, pSegs, endTins, flankAt)
|
|
1614
|
+
: ch.kind === "arc"
|
|
1615
|
+
? [revolveTool(k, ch, magnitude, mode, segs, pSegs, flankAt)]
|
|
1616
|
+
: [prismTool(k, ch, magnitude, mode, segs, pSegs)];
|
|
1602
1617
|
const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
|
|
1603
|
-
cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs)));
|
|
1618
|
+
cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs, flankAt)));
|
|
1604
1619
|
cutters.push(...pivots.map((p) => reflexPivotTool(k, p, magnitude, mode, segs, pSegs)));
|
|
1605
1620
|
const fillers = effective.filter((ch) => !ch.convex).flatMap(toolsFor);
|
|
1606
|
-
if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs));
|
|
1621
|
+
if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs, flankAt));
|
|
1607
1622
|
let out = solid;
|
|
1608
1623
|
if (cutters.length) out = out.cutAll(cutters);
|
|
1609
1624
|
if (fillers.length) out = k.union([out, ...fillers]);
|
|
@@ -79,13 +79,16 @@ export function arcGeometry(p0, via, p1) {
|
|
|
79
79
|
// three points; the sweep direction is the one whose arc actually passes through `via`
|
|
80
80
|
// (sign-free, winding-free). Facet count scales with the sweep's fraction of the kernel's
|
|
81
81
|
// full-circle resolution `segs`, matching the piePolygon/circleProfile convention, so an
|
|
82
|
-
// arc and a circleProfile of equal radius facet identically.
|
|
83
|
-
//
|
|
84
|
-
//
|
|
82
|
+
// arc and a circleProfile of equal radius facet identically. `segs` is either that count
|
|
83
|
+
// or a function of the arc's radius returning one — the mesh backend's print tier sizes
|
|
84
|
+
// circles by chord tolerance (circle-segs.js) and hands the samplers the rule rather
|
|
85
|
+
// than a number. A degenerate (collinear) triple falls back to a single straight segment
|
|
86
|
+
// to p1 — the same "plain line" the OCCT side gets when roundedProfile emits no `via`.
|
|
85
87
|
export function sampleArc(p0, via, p1, segs) {
|
|
86
88
|
const g = arcGeometry(p0, via, p1);
|
|
87
89
|
if (!g) return [[p1[0], p1[1]]]; // collinear → straight line
|
|
88
|
-
const
|
|
90
|
+
const n = typeof segs === "function" ? segs(g.r) : segs;
|
|
91
|
+
const steps = Math.max(2, Math.ceil((n * Math.abs(g.dA)) / (2 * Math.PI)));
|
|
89
92
|
const out = [];
|
|
90
93
|
for (let s = 1; s <= steps; s++) {
|
|
91
94
|
const ang = g.a0 + g.dA * (s / steps);
|
|
@@ -102,8 +105,26 @@ export function sampleArc(p0, via, p1, segs) {
|
|
|
102
105
|
// cubic tracing a circular arc facets like the arc primitive at the same segs. Summing
|
|
103
106
|
// |turn| at BOTH interior control points also catches S-curves a pure endpoint-tangent
|
|
104
107
|
// test would miss. Depth cap guarantees termination. Pure in (args, segs).
|
|
108
|
+
//
|
|
109
|
+
// `segs` may be a function of radius (see sampleArc). A cubic has no single radius, so
|
|
110
|
+
// the budget is decided per sub-curve from the radius it traces, recovered from its
|
|
111
|
+
// chord c and turn t as the circle on which a chord c subtends t: r = c / (2·sin(t/2)).
|
|
112
|
+
// That is EXACT for a circular arc. For any other cubic the control polygon's turn t
|
|
113
|
+
// overstates the curve's, so r errs small and the count errs LOW — a slightly coarser
|
|
114
|
+
// budget, not a finer one. The bias is bounded by the ratio of polygon turn to curve
|
|
115
|
+
// turn, which the recursion drives to 1 as the pieces shrink, and at the turns that
|
|
116
|
+
// pass (≤ 2π/116) it is under 0.02 % of the count. A cubic tracing a circle of radius
|
|
117
|
+
// r therefore facets like the arc primitive of radius r under the same rule.
|
|
105
118
|
export function sampleBezier(p0, c1, c2, p1, segs) {
|
|
106
|
-
const
|
|
119
|
+
const segsAt = typeof segs === "function" ? segs : null;
|
|
120
|
+
const flatTurn = segsAt ? null : (2 * Math.PI) / Math.max(3, segs);
|
|
121
|
+
const maxTurnFor = (a, d, t) => {
|
|
122
|
+
if (!segsAt) return flatTurn;
|
|
123
|
+
const chord = Math.hypot(d[0] - a[0], d[1] - a[1]);
|
|
124
|
+
const half = Math.sin(t / 2); // t ≤ 2π here: two turns of ≤ π each
|
|
125
|
+
const r = half > 1e-9 ? chord / (2 * half) : Infinity; // straight (t ≈ 0): the cap, and t ≤ budget anyway
|
|
126
|
+
return (2 * Math.PI) / Math.max(3, segsAt(r));
|
|
127
|
+
};
|
|
107
128
|
const out = [];
|
|
108
129
|
const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
109
130
|
const turn = (u, v) => {
|
|
@@ -117,7 +138,8 @@ export function sampleBezier(p0, c1, c2, p1, segs) {
|
|
|
117
138
|
const ab = [b[0] - a[0], b[1] - a[1]];
|
|
118
139
|
const bc = [c[0] - b[0], c[1] - b[1]];
|
|
119
140
|
const cd = [d[0] - c[0], d[1] - c[1]];
|
|
120
|
-
|
|
141
|
+
const t = turn(ab, bc) + turn(bc, cd);
|
|
142
|
+
if (depth >= 12 || t <= maxTurnFor(a, d, t)) { out.push([d[0], d[1]]); return; }
|
|
121
143
|
const p01 = mid(a, b), p12 = mid(b, c), p23 = mid(c, d);
|
|
122
144
|
const p012 = mid(p01, p12), p123 = mid(p12, p23), m = mid(p012, p123);
|
|
123
145
|
recurse(a, p01, p012, m, depth + 1);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// resolve against `defaults`, which produce a control that silently does nothing.
|
|
5
5
|
import { err, warn } from "./finding.js";
|
|
6
6
|
import { suggest } from "../geometry/op-options.js";
|
|
7
|
+
import { jsonValueProblem } from "../panel/json-value.js";
|
|
7
8
|
import { fieldsFor, authorFieldsFor, WIDGET_TYPES, GROUP_FIELDS, PRESET_FIELDS, SECTION_FIELDS, normalizeOptions } from "../panel/widget-specs.js";
|
|
8
9
|
import { sectionRenders, desugar } from "../panel/legacy.js";
|
|
9
10
|
import { buildTree, WHEN_OPS } from "../panel/model.js";
|
|
@@ -253,6 +254,9 @@ export const SCHEMA_RULES = [
|
|
|
253
254
|
const defaults = part.defaults;
|
|
254
255
|
return collectDescriptors(part)
|
|
255
256
|
.filter(({ container }) => !container)
|
|
257
|
+
// A custom control's OWN key may hold a JSON value; custom-default-not-json
|
|
258
|
+
// (below) is its rule. Its `keys` stay under this one via their own controls.
|
|
259
|
+
.filter(({ d }) => d.type !== "custom")
|
|
256
260
|
.filter(({ d }) => typeof d.key === "string" && Object.hasOwn(defaults, d.key))
|
|
257
261
|
.filter(({ d }) => !isEditableValue(defaults[d.key]))
|
|
258
262
|
.map(({ d, path }) => err("control-default-not-primitive",
|
|
@@ -625,4 +629,56 @@ export const SCHEMA_RULES = [
|
|
|
625
629
|
return out;
|
|
626
630
|
},
|
|
627
631
|
},
|
|
632
|
+
{
|
|
633
|
+
// The whole feature is the function: a missing or non-function `widget`
|
|
634
|
+
// renders the error card in the rail and nothing else, every time.
|
|
635
|
+
id: "custom-control-widget-not-function",
|
|
636
|
+
run: ({ part }) => collectDescriptors(part)
|
|
637
|
+
.filter(({ container, authored, d }) => authored && !container && d.type === "custom" && typeof d.widget !== "function")
|
|
638
|
+
.map(({ d, path }) => err("custom-control-widget-not-function",
|
|
639
|
+
`custom control "${d.key}" has no \`widget\` function`,
|
|
640
|
+
"Set `widget` to a function `(host) => …` — usually imported from a sibling file of the part — that draws into `host.el`. See \"Custom controls\" in AUTHORING-PARTS.md.",
|
|
641
|
+
`${path}.widget`)),
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
// The one relaxation of control-default-not-primitive: a custom control's
|
|
645
|
+
// key may hold a JSON value (json-value.js), and nothing else — a host that
|
|
646
|
+
// persists panel settings writes it back as a JSON literal.
|
|
647
|
+
id: "custom-default-not-json",
|
|
648
|
+
run: ({ part }) => {
|
|
649
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
650
|
+
const defaults = part.defaults;
|
|
651
|
+
return collectDescriptors(part)
|
|
652
|
+
.filter(({ container, authored, d }) => authored && !container && d.type === "custom")
|
|
653
|
+
.filter(({ d }) => typeof d.key === "string" && Object.hasOwn(defaults, d.key))
|
|
654
|
+
.map(({ d }) => ({ d, problem: jsonValueProblem(defaults[d.key]) }))
|
|
655
|
+
.filter(({ problem }) => problem !== null)
|
|
656
|
+
.map(({ d, problem }) => err("custom-default-not-json",
|
|
657
|
+
`\`defaults.${d.key}\` ${problem}`,
|
|
658
|
+
`Give "${d.key}" a JSON value: numbers, strings, booleans, arrays and plain objects of those, at most 16 KB and 8 levels deep, with no null. A custom control stores and persists its value as JSON, so anything else is silently lost on reload.`,
|
|
659
|
+
`defaults.${d.key}`));
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
// `keys` are params the widget writes besides its own; each needs a
|
|
664
|
+
// default or the write lands on a key the build never reads.
|
|
665
|
+
id: "custom-keys-not-in-defaults",
|
|
666
|
+
run: ({ part }) => {
|
|
667
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
668
|
+
const known = defaultKeys(part);
|
|
669
|
+
const out = [];
|
|
670
|
+
for (const { d, path, container, authored } of collectDescriptors(part)) {
|
|
671
|
+
if (!authored || container || d.type !== "custom" || !Array.isArray(d.keys)) continue;
|
|
672
|
+
d.keys.forEach((k, i) => {
|
|
673
|
+
if (typeof k === "string" && known.has(k)) return;
|
|
674
|
+
const hint = typeof k === "string" ? suggest(k, [...known]) : null;
|
|
675
|
+
out.push(err("custom-keys-not-in-defaults",
|
|
676
|
+
`custom control "${d.key}" lists key "${k}", which is not in \`defaults\``,
|
|
677
|
+
`Add "${k}" to \`defaults\`${hint ? `, or correct it to "${hint}"` : ""} — a key the widget writes must exist for the build to read it.`,
|
|
678
|
+
`${path}.keys[${i}]`));
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
},
|
|
683
|
+
},
|
|
628
684
|
];
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// closure (test/lint-purity.test.js) so lintPart keeps running in Node, the
|
|
13
13
|
// browser sandbox iframe, and Deno.
|
|
14
14
|
//
|
|
15
|
+
// The one exception is json-value.js, which itself imports nothing — the
|
|
16
|
+
// shared predicate for a `type: "custom"` control's value.
|
|
17
|
+
//
|
|
15
18
|
// KNOWN BLIND SPOT — regex literals are not tokenized: a `/[/*]/` or `/x\/y/`
|
|
16
19
|
// reads as a comment opener and can blank the rest of the file, and a regex
|
|
17
20
|
// containing a quote can likewise derail string skipping.
|
|
@@ -19,6 +22,8 @@
|
|
|
19
22
|
// FALSE NEGATIVES — a derailed scan finds no defaults literal and no tokens,
|
|
20
23
|
// so a rule says nothing rather than something wrong.
|
|
21
24
|
|
|
25
|
+
import { isJsonValue, CUSTOM_VALUE_MAX_DEPTH } from "../panel/json-value.js";
|
|
26
|
+
|
|
22
27
|
// Span of the object literal after the first `defaults:` key (indices into
|
|
23
28
|
// `source`, end exclusive, covering `{...}`). String- and comment-aware so a
|
|
24
29
|
// "defaults: {" inside a string or comment can't fool the scan.
|
|
@@ -191,8 +196,9 @@ function decodeStringLiteral(raw) {
|
|
|
191
196
|
// into a different spelling of themselves.
|
|
192
197
|
const NUMBER_RE = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
193
198
|
|
|
194
|
-
// One value's source text → { value }, or null when it is not a
|
|
195
|
-
//
|
|
199
|
+
// One value's source text → { value }, or null when it is not a literal this
|
|
200
|
+
// module can read AND write back. Primitives first; then the JSON-literal
|
|
201
|
+
// grammar a `type: "custom"` control's value is written in.
|
|
196
202
|
function readValue(raw) {
|
|
197
203
|
if (raw === "true") return { value: true };
|
|
198
204
|
if (raw === "false") return { value: false };
|
|
@@ -205,6 +211,7 @@ function readValue(raw) {
|
|
|
205
211
|
const s = decodeStringLiteral(raw);
|
|
206
212
|
return s === null ? null : { value: s };
|
|
207
213
|
}
|
|
214
|
+
if (q === "[" || q === "{") return readJsonLiteral(raw);
|
|
208
215
|
return null;
|
|
209
216
|
}
|
|
210
217
|
|
|
@@ -220,6 +227,113 @@ function readKey(text, i, end) {
|
|
|
220
227
|
return m ? { key: m[0], next: i + m[0].length } : null;
|
|
221
228
|
}
|
|
222
229
|
|
|
230
|
+
// --- JSON literals ----------------------------------------------------------
|
|
231
|
+
//
|
|
232
|
+
// The grammar a custom control's owned value is spelled in inside `defaults`:
|
|
233
|
+
// object and array literals of primitives, nested; bare or quoted keys; JS
|
|
234
|
+
// string escapes (the same decoder as above); decimal numbers; true/false;
|
|
235
|
+
// trailing commas; whitespace. NOTHING else — no comments inside the span, no
|
|
236
|
+
// expressions, identifiers, templates, `null`, computed keys, spreads — so
|
|
237
|
+
// every value this reads, writeJsonLiteral can write back and this can read
|
|
238
|
+
// again. A parse failure is null, never a throw: the entry simply stays
|
|
239
|
+
// unreadable, exactly as an expression does.
|
|
240
|
+
//
|
|
241
|
+
// `depth` is threaded through value/array/object and checked BEFORE
|
|
242
|
+
// recursing into a container, so pathological nesting (thousands of `[`)
|
|
243
|
+
// fails fast during parsing instead of blowing the JS call stack — a
|
|
244
|
+
// RangeError would escape readJsonLiteral's try/catch (it only catches
|
|
245
|
+
// JsonLiteralError) and reach a host outside runRules' own try/catch. The
|
|
246
|
+
// threshold matches json-value.js's own depth check exactly (same "depth at
|
|
247
|
+
// which this container sits" numbering), so this is a fast-fail that agrees
|
|
248
|
+
// with the post-parse isJsonValue call below, never a stricter or looser one.
|
|
249
|
+
|
|
250
|
+
class JsonLiteralError extends Error {}
|
|
251
|
+
|
|
252
|
+
function jsonParser(text) {
|
|
253
|
+
let i = 0;
|
|
254
|
+
const fail = () => { throw new JsonLiteralError(); };
|
|
255
|
+
const ws = () => { while (i < text.length && /\s/.test(text[i])) i++; };
|
|
256
|
+
const value = (depth = 0) => {
|
|
257
|
+
ws();
|
|
258
|
+
const c = text[i];
|
|
259
|
+
if (c === "{") return object(depth);
|
|
260
|
+
if (c === "[") return array(depth);
|
|
261
|
+
if (c === '"' || c === "'") {
|
|
262
|
+
const end = skipQuoted(text, i, text.length);
|
|
263
|
+
const s = decodeStringLiteral(text.slice(i, end));
|
|
264
|
+
if (s === null) fail();
|
|
265
|
+
i = end;
|
|
266
|
+
return s;
|
|
267
|
+
}
|
|
268
|
+
const m = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/.exec(text.slice(i));
|
|
269
|
+
if (m) { i += m[0].length; const n = Number(m[0]); if (!Number.isFinite(n)) fail(); return n; }
|
|
270
|
+
if (text.startsWith("true", i)) { i += 4; return true; }
|
|
271
|
+
if (text.startsWith("false", i)) { i += 5; return false; }
|
|
272
|
+
return fail();
|
|
273
|
+
};
|
|
274
|
+
const array = (depth) => {
|
|
275
|
+
if (depth >= CUSTOM_VALUE_MAX_DEPTH) fail();
|
|
276
|
+
i++; // [
|
|
277
|
+
const out = [];
|
|
278
|
+
for (;;) {
|
|
279
|
+
ws();
|
|
280
|
+
if (text[i] === "]") { i++; return out; }
|
|
281
|
+
if (out.length) { if (text[i] !== ",") fail(); i++; ws(); if (text[i] === "]") { i++; return out; } }
|
|
282
|
+
out.push(value(depth + 1));
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const object = (depth) => {
|
|
286
|
+
if (depth >= CUSTOM_VALUE_MAX_DEPTH) fail();
|
|
287
|
+
i++; // {
|
|
288
|
+
const out = {};
|
|
289
|
+
let n = 0;
|
|
290
|
+
for (;;) {
|
|
291
|
+
ws();
|
|
292
|
+
if (text[i] === "}") { i++; return out; }
|
|
293
|
+
if (n) { if (text[i] !== ",") fail(); i++; ws(); if (text[i] === "}") { i++; return out; } }
|
|
294
|
+
const k = readKey(text, i, text.length);
|
|
295
|
+
if (!k) fail();
|
|
296
|
+
i = k.next;
|
|
297
|
+
ws();
|
|
298
|
+
if (text[i] !== ":") fail();
|
|
299
|
+
i++;
|
|
300
|
+
const v = value(depth + 1);
|
|
301
|
+
Object.defineProperty(out, k.key, { value: v, enumerable: true, writable: true, configurable: true });
|
|
302
|
+
n++;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
return { value, done: () => { ws(); return i === text.length; } };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// An array or object literal's source text → { value }, or null. Applies the
|
|
309
|
+
// custom-value caps (json-value.js) so a literal lint accepts is one the panel
|
|
310
|
+
// would accept too.
|
|
311
|
+
export function readJsonLiteral(text) {
|
|
312
|
+
if (typeof text !== "string") return null;
|
|
313
|
+
const first = text.trimStart()[0];
|
|
314
|
+
if (first !== "[" && first !== "{") return null;
|
|
315
|
+
try {
|
|
316
|
+
const p = jsonParser(text);
|
|
317
|
+
const v = p.value();
|
|
318
|
+
if (!p.done()) return null;
|
|
319
|
+
return isJsonValue(v) ? { value: v } : null;
|
|
320
|
+
} catch (e) {
|
|
321
|
+
if (e instanceof JsonLiteralError) return null;
|
|
322
|
+
throw e;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The source text for a JSON value being written into `defaults`: compact
|
|
327
|
+
// when it fits on a line, else indented two spaces deeper than the entry's
|
|
328
|
+
// own indentation (`indent`, the whitespace before the entry's key) so a large
|
|
329
|
+
// layout diffs line by line. Always plain JSON — quoted keys — so the reader
|
|
330
|
+
// above accepts it unchanged.
|
|
331
|
+
export function writeJsonLiteral(value, { indent = "" } = {}) {
|
|
332
|
+
const compact = JSON.stringify(value);
|
|
333
|
+
if (compact.length <= 80) return compact;
|
|
334
|
+
return JSON.stringify(value, null, 2).replace(/\n/g, `\n${indent}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
223
337
|
// Split `{ … }` into entries in source order. `readable` says whether the
|
|
224
338
|
// value was interpreted; `raw` is its exact source text either way, and
|
|
225
339
|
// valueStart/valueEnd are its span (indices into `text`), which is what a
|
package/src/framework/mount.js
CHANGED
|
@@ -63,7 +63,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
63
63
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
64
64
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
65
65
|
|
|
66
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
|
|
66
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors }) {
|
|
67
67
|
return {
|
|
68
68
|
ready, dispose, setParams,
|
|
69
69
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
@@ -101,6 +101,14 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
101
101
|
projection: viewer.getProjection?.() ?? "perspective",
|
|
102
102
|
cutaway: viewer.getCutawayState?.() ?? null,
|
|
103
103
|
}),
|
|
104
|
+
// Every custom control's transient state (selection, a scroll position),
|
|
105
|
+
// keyed by param, as plain JSON — the panel's twin of getViewerState. Hand
|
|
106
|
+
// it back as mount()'s `panelState` and a remount comes up with the same
|
|
107
|
+
// tile selected. {} when the mount resolved no panel.
|
|
108
|
+
getPanelState: getPanelState ?? (() => ({})),
|
|
109
|
+
// What custom controls reported failing this mount ({key, label, phase,
|
|
110
|
+
// message}), for a host to relay to whoever authored the part.
|
|
111
|
+
getPanelErrors: getPanelErrors ?? (() => []),
|
|
104
112
|
// Park/unpark the viewer: stops the render loop and frees the drawing
|
|
105
113
|
// buffer and the cached capture target. For an embedder that hides the
|
|
106
114
|
// canvas without unmounting it — `visibility: hidden`, an off-screen tab —
|
|
@@ -305,6 +313,13 @@ function createCleanupStack() {
|
|
|
305
313
|
// // first mount — the viewer then restores its own persisted
|
|
306
314
|
// // camera as before. Restore is best-effort per field: a pose
|
|
307
315
|
// // this part cannot support is dropped, never fatal.
|
|
316
|
+
// panelState: PanelState // a previous mount's runtime.getPanelState(): the transient
|
|
317
|
+
// // state of the part's custom controls (a selected tile), keyed
|
|
318
|
+
// // by param. Same remount story as viewerState; omit on a first
|
|
319
|
+
// // mount. Never persisted by partforge.
|
|
320
|
+
// files: { [path]: string } // the part's own source tree as text, for custom controls'
|
|
321
|
+
// // host.file(path) — an SVG or a vector document that lives
|
|
322
|
+
// // beside the code. Omit and host.file answers null.
|
|
308
323
|
// annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
|
|
309
324
|
// // Send in the sketch toolbar alongside the other tools.
|
|
310
325
|
// // "host" drops it: the host draws its own send control —
|
|
@@ -331,6 +346,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
331
346
|
imageCatalog,
|
|
332
347
|
onAssetUpload,
|
|
333
348
|
viewerState,
|
|
349
|
+
files, panelState,
|
|
334
350
|
annotateSend = "viewbar",
|
|
335
351
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
336
352
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
@@ -1017,7 +1033,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
1017
1033
|
// showing the bundled default instead of an empty tile. Rebuilt per panel
|
|
1018
1034
|
// build, because the declaration is a function of the current params.
|
|
1019
1035
|
{ fontCatalog, imageCatalog, onAssetUpload,
|
|
1020
|
-
declaredSource: declaredSourceLookup(part, params)
|
|
1036
|
+
declaredSource: declaredSourceLookup(part, params),
|
|
1037
|
+
files, panelState });
|
|
1021
1038
|
cleanup.defer(() => panel.dispose());
|
|
1022
1039
|
panelRef = panel;
|
|
1023
1040
|
const updateRelevance = () => {
|
|
@@ -1194,6 +1211,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
1194
1211
|
getView: view, // () => tabsCtl.current()
|
|
1195
1212
|
setView: (name) => tabsCtl.select(name),
|
|
1196
1213
|
captureView,
|
|
1214
|
+
getPanelState: () => panelRef?.getState() ?? {},
|
|
1215
|
+
getPanelErrors: () => panelRef?.errors() ?? [],
|
|
1197
1216
|
listExportableParts: () =>
|
|
1198
1217
|
exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
|
|
1199
1218
|
exportParts: (opts) => exportCtl.exportParts(opts),
|
|
@@ -44,6 +44,11 @@ function authoredControl(c) {
|
|
|
44
44
|
allow: c.allow,
|
|
45
45
|
preview: c.preview,
|
|
46
46
|
sourceField: c.sourceField,
|
|
47
|
+
// Custom controls (type: "custom"): the author's widget function and the
|
|
48
|
+
// extra scalar keys it may write. Both are on this allow-list for the same
|
|
49
|
+
// reason `allow` is (see above) — a field missing here is silently dropped.
|
|
50
|
+
widget: c.widget,
|
|
51
|
+
keys: c.keys,
|
|
47
52
|
preserveOn: false,
|
|
48
53
|
marksCustom: true,
|
|
49
54
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//
|
|
2
|
+
// The ONE predicate for the value a `type: "custom"` control may own. The panel
|
|
3
|
+
// (host.set, panel state), lint (custom-default-not-json) and partforge-cloud's
|
|
4
|
+
// persistence (through the `partforge/panel-values` export) all ask this module,
|
|
5
|
+
// so "the linter accepts it" and "the save can write it" cannot drift apart.
|
|
6
|
+
//
|
|
7
|
+
// Imports nothing: it sits inside partforge/lint's pure closure
|
|
8
|
+
// (test/lint-purity.test.js) and inside the sandbox iframe.
|
|
9
|
+
|
|
10
|
+
export const CUSTOM_VALUE_MAX_BYTES = 16384;
|
|
11
|
+
export const CUSTOM_VALUE_MAX_DEPTH = 8;
|
|
12
|
+
|
|
13
|
+
// Keys a JSON value may never carry: on a plain object each of these reaches
|
|
14
|
+
// Object.prototype, so a value that round-trips through JSON.parse and a
|
|
15
|
+
// plain-object assignment could change what `params.x.constructor` means.
|
|
16
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
17
|
+
|
|
18
|
+
const at = (path) => (path ? ` at ${path}` : "");
|
|
19
|
+
|
|
20
|
+
function shapeProblem(v, depth, maxDepth, path) {
|
|
21
|
+
if (v === null) return `is null${at(path)}`;
|
|
22
|
+
const t = typeof v;
|
|
23
|
+
if (t === "number") return Number.isFinite(v) ? null : `is a non-finite number${at(path)}`;
|
|
24
|
+
if (t === "string" || t === "boolean") return null;
|
|
25
|
+
if (t !== "object") return `is a ${t}${at(path)}`;
|
|
26
|
+
if (depth >= maxDepth) return `nests deeper than ${maxDepth} levels${at(path)}`;
|
|
27
|
+
if (Array.isArray(v)) {
|
|
28
|
+
for (let i = 0; i < v.length; i++) {
|
|
29
|
+
const p = shapeProblem(v[i], depth + 1, maxDepth, `${path}[${i}]`);
|
|
30
|
+
if (p) return p;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const proto = Object.getPrototypeOf(v);
|
|
35
|
+
if (proto !== Object.prototype && proto !== null) return `is not a plain object${at(path)}`;
|
|
36
|
+
for (const k of Object.keys(v)) {
|
|
37
|
+
if (FORBIDDEN_KEYS.has(k)) return `uses the forbidden key "${k}"${at(path)}`;
|
|
38
|
+
const p = shapeProblem(v[k], depth + 1, maxDepth, path ? `${path}.${k}` : k);
|
|
39
|
+
if (p) return p;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Why `v` is not a JSON value — a sentence fragment that reads after the
|
|
45
|
+
// value's name ("`defaults.tiles` is null at tiles[2].h") — or null when it is.
|
|
46
|
+
export function jsonValueProblem(v, { maxBytes = CUSTOM_VALUE_MAX_BYTES, maxDepth = CUSTOM_VALUE_MAX_DEPTH } = {}) {
|
|
47
|
+
const shape = shapeProblem(v, 0, maxDepth, "");
|
|
48
|
+
if (shape) return shape;
|
|
49
|
+
if (maxBytes === Infinity) return null;
|
|
50
|
+
const bytes = new TextEncoder().encode(JSON.stringify(v)).length;
|
|
51
|
+
if (bytes > maxBytes) return `is ${bytes} bytes serialized; the cap is ${maxBytes}`;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const isJsonValue = (v, opts) => jsonValueProblem(v, opts) === null;
|