partforge 0.66.1 → 0.67.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
CHANGED
|
@@ -2195,8 +2195,10 @@ STEP export always builds on OCCT, so a filleted part gets exact B-rep blends in
|
|
|
2195
2195
|
STEP even though it previews (and STL/3MF-exports) from the mesh blend — the two agree
|
|
2196
2196
|
to within tessellation on the supported edge classes, with one visible exception:
|
|
2197
2197
|
orthogonal box-style corners where three filleted edges meet get a true sphere-octant
|
|
2198
|
-
cap,
|
|
2199
|
-
blend
|
|
2198
|
+
cap, and a planar rim's own corners blend by turn direction (salient corners steer
|
|
2199
|
+
the band around a small arc; reflex/inside corners round into an arc of the blend
|
|
2200
|
+
radius about the vertex — the rolling-ball pivot), but other blend junctions are
|
|
2201
|
+
mitred on the mesh where OCCT builds a vertex blend.
|
|
2200
2202
|
|
|
2201
2203
|
If the mesh backend hits an edge class it can't blend, it signals `NEEDS_OCCT` and the
|
|
2202
2204
|
framework reroutes **just that sub-part** to OCCT for those exact parameters —
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -60,10 +60,15 @@ Its coverage and tolerance band are part of the contract:
|
|
|
60
60
|
guards, all ≤ ~1e-3 mm). Volumes agree with the B-rep result to ~0.1% on covered
|
|
61
61
|
edge classes. **Corners:** where exactly three selected straight convex chains meet
|
|
62
62
|
at a mutually orthogonal vertex (a box corner), the fillet caps it with the
|
|
63
|
-
rolling-ball sphere octant
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
rolling-ball sphere octant. A two-chain corner in a common face plane (a rim
|
|
64
|
+
corner) blends by turn direction: SALIENT corners steer the band around a small
|
|
65
|
+
arc (the silhouette rounds by about the blend radius inside the band), and REFLEX
|
|
66
|
+
corners get the rolling-ball pivot — the face's blend boundary rounds into an arc
|
|
67
|
+
of the blend radius about the vertex, exactly what a ball rolling into an inside
|
|
68
|
+
corner leaves. Every other junction of blended chains is a **mitre** (the blend
|
|
69
|
+
surfaces intersect), where the B-rep class builds a kernel-specific vertex blend
|
|
70
|
+
instead — parity at such corners is approximate, like `roundedBox`'s documented
|
|
71
|
+
corner carve-out.
|
|
67
72
|
- **New surfaces:** like the B-rep op, a mesh blend produces new surfaces — feature
|
|
68
73
|
labels upstream of the call do not survive through it (attribution uses the
|
|
69
74
|
fallback path), and the result shades with the default SMOOTH policy.
|
package/package.json
CHANGED
|
@@ -21,10 +21,14 @@
|
|
|
21
21
|
// block shaves the sharp corner down to band depth, and the band sweeps around with
|
|
22
22
|
// no crease (see roundSalientCorners). The silhouette inside the band rounds by about
|
|
23
23
|
// the blend radius at such corners; the flat shelf the horn leaves at the band's base
|
|
24
|
-
// is the deliberate residue.
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
24
|
+
// is the deliberate residue. A REFLEX corner in a common face plane gets the
|
|
25
|
+
// rolling-ball PIVOT (reflexPivotAt/reflexPivotTool): the ball swings about the
|
|
26
|
+
// corner's face-normal axis, touching the face and the vertical corner edge, and the
|
|
27
|
+
// face's blend boundary rounds into an arc of radius r about the vertex — without it
|
|
28
|
+
// the flush-ended neighbor tools leave a wedge of the original rim uncut and the face
|
|
29
|
+
// keeps a point AT the corner (the label-part "artifacts" bug). Corners keep their
|
|
30
|
+
// mitre only when too tight to host the salient setback (glyph-scale features smaller
|
|
31
|
+
// than ~3× the magnitude), or when three or more chains meet (the spherical
|
|
28
32
|
// cornerPatches below own the orthogonal three-chain case).
|
|
29
33
|
//
|
|
30
34
|
// Known limits (documented, not bugs): radius feasibility is the caller's job (clamp
|
|
@@ -758,7 +762,9 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
758
762
|
// viewer's line threshold and draw across the band. A salient split corner with room
|
|
759
763
|
// for the setback gets a corner ARC (cornerArcAt — the same rounded-corner treatment
|
|
760
764
|
// apply() gives two-chain junctions), the adjoining stretches trimmed to its tangent
|
|
761
|
-
// points; reflex
|
|
765
|
+
// points; a reflex split gets the rolling-ball PIVOT (reflexPivotTool — without it
|
|
766
|
+
// the flush stretch ends leave the corner wedge uncut and the face keeps its point);
|
|
767
|
+
// too-tight salient splits keep the overshoot mitre.
|
|
762
768
|
const reach = magnitude * 1.5;
|
|
763
769
|
const breaks = [];
|
|
764
770
|
for (let i = closed ? 0 : 1; i < (closed ? m : m - 1); i++) {
|
|
@@ -773,6 +779,7 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
773
779
|
// polyline to the ADJACENT break (or chain end) — a single tessellation segment says
|
|
774
780
|
// nothing about the room a whole smooth stretch offers.
|
|
775
781
|
const cornerArcs = new Map(); // break vertex index → { arc, t }
|
|
782
|
+
const pivots = []; // reflex break vertices → rolling-ball pivots
|
|
776
783
|
if (convex && breaks.length) {
|
|
777
784
|
const segSum = (from, to) => {
|
|
778
785
|
let sum = 0;
|
|
@@ -789,7 +796,9 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
789
796
|
: (j + 1 < breaks.length ? breaks[j + 1] : m - 1);
|
|
790
797
|
const got = cornerArcAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i],
|
|
791
798
|
wallNs[iIn], wallNs[i], segSum(prevB, i), segSum(i, nextB), magnitude);
|
|
792
|
-
if (got) cornerArcs.set(i, got);
|
|
799
|
+
if (got) { cornerArcs.set(i, got); continue; }
|
|
800
|
+
const piv = reflexPivotAt(at(i), faceN, scl(segDir[iIn], -1), segDir[i], wallNs[iIn], wallNs[i]);
|
|
801
|
+
if (piv) pivots.push(piv);
|
|
793
802
|
}
|
|
794
803
|
}
|
|
795
804
|
|
|
@@ -860,6 +869,7 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
860
869
|
if (len(sub(got.vertex, got.arc.O)) - got.arc.R > 0.02 * magnitude)
|
|
861
870
|
tools.push(cornerHornTool(k, got, magnitude, segs));
|
|
862
871
|
}
|
|
872
|
+
for (const piv of pivots) tools.push(reflexPivotTool(k, piv, magnitude, mode, segs, pSegs));
|
|
863
873
|
return tools;
|
|
864
874
|
} catch (e) {
|
|
865
875
|
throw new UnsupportedEdgeError(`planar sweep: ${e.message}`);
|
|
@@ -883,10 +893,11 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs) {
|
|
|
883
893
|
// sharper than CORNER_ROUND_MIN_TURN; gentler corners keep their exact silhouette (a
|
|
884
894
|
// mitre under the viewer\'s 35° line threshold draws nothing).
|
|
885
895
|
//
|
|
886
|
-
// REFLEX corners
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
896
|
+
// REFLEX corners take the rolling-ball PIVOT instead (reflexPivotAt below): steering
|
|
897
|
+
// the band path around a reflex corner would ADD material, but the ball itself swings
|
|
898
|
+
// about the corner touching the face and the vertical corner edge — see the reflex
|
|
899
|
+
// pivot section. A salient corner whose neighbors are too short to host the setback
|
|
900
|
+
// (tight glyph features) falls back to the mitre — that fallback is never a failure.
|
|
890
901
|
// Corners gentler than this keep their mitre: the two blends there differ by less than
|
|
891
902
|
// the mitre turn everywhere, far under the viewer's 35° line threshold, and the shallow
|
|
892
903
|
// overlap sliver stays too flat for simplify() to fold into visible creases. Measured:
|
|
@@ -989,6 +1000,95 @@ function cornerHornTool(k, { vertex, f, arc }, magnitude, segs) {
|
|
|
989
1000
|
return tool.translate(O);
|
|
990
1001
|
}
|
|
991
1002
|
|
|
1003
|
+
// ---------------------------------------------------------------------------
|
|
1004
|
+
// Reflex pivots. At a REFLEX corner the neighboring blend tools end flush against the
|
|
1005
|
+
// planes through the vertex (their only overshoot is the 0.05·r anti-graze allowance),
|
|
1006
|
+
// so the wedge of rim material between those end planes — azimuthal extent = the turn
|
|
1007
|
+
// angle — survives both cutters and the face keeps a point essentially AT the vertex.
|
|
1008
|
+
// The rolling ball does not stop there: it pivots about the corner, touching the face
|
|
1009
|
+
// and the vertical corner edge, its center swinging on an arc of radius r about the
|
|
1010
|
+
// face-normal axis through the vertex. The envelope is a horn-torus patch — the
|
|
1011
|
+
// revolve, about that axis, of the blend cross-section running from the axis point
|
|
1012
|
+
// r below the face to the face tangency at radius r — and the face's blend boundary
|
|
1013
|
+
// becomes an arc of radius r about the vertex (the "rounded inside curve"). The pivot
|
|
1014
|
+
// cross-section at each span end coincides exactly with the neighbor tool's own
|
|
1015
|
+
// cross-section at the vertex (same circle, same plane), so the handover is G1; the
|
|
1016
|
+
// small angular overshoot below only re-cuts band the neighbors already cut.
|
|
1017
|
+
|
|
1018
|
+
// Pivot descriptor at one vertex, or null (salient — cornerArcAt's case — or a turn
|
|
1019
|
+
// too gentle to matter). Same argument convention as cornerArcAt: tin1/tin2 point
|
|
1020
|
+
// from the vertex INTO each side, wall1/wall2 are the sides' outward wall normals.
|
|
1021
|
+
function reflexPivotAt(vertex, f, tin1, tin2, wall1, wall2) {
|
|
1022
|
+
const tIn = scl(tin1, -1), tOut = tin2;
|
|
1023
|
+
const turn = Math.acos(clamp1(dot(tIn, tOut)));
|
|
1024
|
+
if (turn < CORNER_ROUND_MIN_TURN) return null; // wedge sagitta sub-micron: keep today's behavior
|
|
1025
|
+
const turnS = dot(cross(tIn, tOut), f);
|
|
1026
|
+
const matLeft = dot(wall1, cross(tIn, f)) > 0;
|
|
1027
|
+
if ((turnS > 0) === matLeft) return null; // salient: roundSalientCorners' territory
|
|
1028
|
+
// inward in-plane wall normals bound the uncut wedge; the pivot sweeps between them
|
|
1029
|
+
const proj = (wl) => { const p = sub(scl(wl, -1), scl(f, -dot(wl, f))); const l = len(p) || 1; return scl(p, 1 / l); };
|
|
1030
|
+
let u0 = proj(wall1), uE = proj(wall2);
|
|
1031
|
+
const span = Math.acos(clamp1(dot(u0, uE)));
|
|
1032
|
+
if (!(span > 1e-4)) return null;
|
|
1033
|
+
if (dot(cross(u0, uE), f) < 0) [u0, uE] = [uE, u0]; // azimuth increases u0 → uE about +f
|
|
1034
|
+
return { vertex, f, u0, span };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// The pivot cutter: revolve of the blend cross-section about the face-normal axis
|
|
1038
|
+
// through the vertex. Cross-section in (ρ, z) with z along f (face at z = 0): the
|
|
1039
|
+
// fillet arc runs from the axis point (0, −r) to the face tangency (r, 0) on the
|
|
1040
|
+
// circle centered (r, −r) — extended past the tangency by revolveTool's arc-tail
|
|
1041
|
+
// recipe, because the blend meets the face tangentially there and a tessellated
|
|
1042
|
+
// tangency grazes — then closes above the face so the corner column goes with it.
|
|
1043
|
+
// A chamfer takes the chord instead (a cone), which meets face and edge transversally
|
|
1044
|
+
// and needs no tail. Interior arc vertices use profile2D's area-exact radius.
|
|
1045
|
+
function reflexPivotTool(k, { vertex, f, u0, span }, magnitude, mode, segs, pSegs) {
|
|
1046
|
+
const r = magnitude;
|
|
1047
|
+
const delta = 0.02 * r;
|
|
1048
|
+
let poly;
|
|
1049
|
+
if (mode === "chamfer") {
|
|
1050
|
+
poly = [[0, -r], [r, 0], [r + delta, delta], [0, delta]];
|
|
1051
|
+
} else {
|
|
1052
|
+
const sag = Math.min(2e-4, 0.02 * r);
|
|
1053
|
+
const ext = Math.min(0.4, Math.max(0.01, Math.acos(Math.max(-1, 1 - sag / r))));
|
|
1054
|
+
const arcSpan = Math.PI / 2 + ext;
|
|
1055
|
+
const nArc = Math.max(2, Math.ceil((arcSpan / (2 * Math.PI)) * pSegs));
|
|
1056
|
+
const th = arcSpan / nArc;
|
|
1057
|
+
const rEq = r * Math.sqrt(th / Math.sin(th));
|
|
1058
|
+
poly = [];
|
|
1059
|
+
for (let i = 0; i <= nArc; i++) {
|
|
1060
|
+
const a = Math.PI - arcSpan * (i / nArc); // 180° (axis) → past the face tangency
|
|
1061
|
+
const ri = i === 0 || i === nArc ? r : rEq;
|
|
1062
|
+
poly.push([r + ri * Math.cos(a), -r + ri * Math.sin(a)]);
|
|
1063
|
+
}
|
|
1064
|
+
poly.push([poly[poly.length - 1][0], delta], [0, delta]);
|
|
1065
|
+
}
|
|
1066
|
+
let area = 0;
|
|
1067
|
+
for (let i = 0; i < poly.length; i++) {
|
|
1068
|
+
const [x1, y1] = poly[i], [x2, y2] = poly[(i + 1) % poly.length];
|
|
1069
|
+
area += x1 * y2 - x2 * y1;
|
|
1070
|
+
}
|
|
1071
|
+
if (area < 0) poly = poly.slice().reverse();
|
|
1072
|
+
// angular overshoot past both span ends: crosses the walls (tangent contacts at the
|
|
1073
|
+
// ends) decisively, re-cutting only band the flush-ended neighbors already cut
|
|
1074
|
+
const ovAng = 0.05;
|
|
1075
|
+
const degrees = ((span + 2 * ovAng) * 180) / Math.PI;
|
|
1076
|
+
let tool = k.revolve(poly, { degrees, segs: cornerArcSegs(segs, 0, magnitude) });
|
|
1077
|
+
// pose: Z → f, then twist the revolve's start azimuth (+X) onto u0 backed off by ovAng
|
|
1078
|
+
const v0 = cross(f, u0);
|
|
1079
|
+
const startDir = add(scl(u0, Math.cos(-ovAng)), scl(v0, Math.sin(-ovAng)));
|
|
1080
|
+
const axisRaw = cross([0, 0, 1], f);
|
|
1081
|
+
const s = len(axisRaw);
|
|
1082
|
+
let axis = null, theta = 0;
|
|
1083
|
+
if (s > 1e-9) { axis = scl(axisRaw, 1 / s); theta = Math.atan2(s, f[2]); }
|
|
1084
|
+
else if (f[2] < 0) { axis = [1, 0, 0]; theta = Math.PI; }
|
|
1085
|
+
if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
|
|
1086
|
+
const xImage = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
|
|
1087
|
+
const twist = Math.atan2(dot(f, cross(xImage, startDir)), dot(xImage, startDir));
|
|
1088
|
+
if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: f, deg: (twist * 180) / Math.PI });
|
|
1089
|
+
return tool.translate(vertex);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
992
1092
|
function chainEndInfo(ch, end) {
|
|
993
1093
|
if (ch.kind === "line") {
|
|
994
1094
|
return end === "start"
|
|
@@ -1003,9 +1103,10 @@ function chainEndInfo(ch, end) {
|
|
|
1003
1103
|
: { v: pts[m - 1], tin: norm(sub(pts[m - 2], pts[m - 1])), flanks: [ch.faceN, ch.wallNs[ch.wallNs.length - 1]], len: plen };
|
|
1004
1104
|
}
|
|
1005
1105
|
|
|
1006
|
-
// Corner
|
|
1007
|
-
//
|
|
1008
|
-
|
|
1106
|
+
// Corner treatment for two chain ends meeting at one vertex: {arc} for a salient
|
|
1107
|
+
// corner with room for the setback, {pivot} for a reflex corner, or null (no common
|
|
1108
|
+
// face plane, gentle turn, or a too-tight salient corner keeping its mitre).
|
|
1109
|
+
function cornerBlendBetween(E1, E2, magnitude) {
|
|
1009
1110
|
let f = null, wall1 = null, wall2 = null;
|
|
1010
1111
|
for (const c1 of E1.flanks) {
|
|
1011
1112
|
for (const c2 of E2.flanks) {
|
|
@@ -1018,7 +1119,10 @@ function cornerArcBetween(E1, E2, magnitude) {
|
|
|
1018
1119
|
}
|
|
1019
1120
|
}
|
|
1020
1121
|
if (!f) return null;
|
|
1021
|
-
|
|
1122
|
+
const corner = cornerArcAt(E1.v, f, E1.tin, E2.tin, wall1, wall2, E1.len, E2.len, magnitude);
|
|
1123
|
+
if (corner) return { corner };
|
|
1124
|
+
const pivot = reflexPivotAt(E1.v, f, E1.tin, E2.tin, wall1, wall2);
|
|
1125
|
+
return pivot ? { pivot } : null;
|
|
1022
1126
|
}
|
|
1023
1127
|
|
|
1024
1128
|
// Convert a FACE-PLANE arc chain to the equivalent planar chain, or return null when
|
|
@@ -1082,8 +1186,9 @@ function trimChain(ch, tStart, tEnd) {
|
|
|
1082
1186
|
return { ...ch, points: pts, wallNs: walls, closed: false };
|
|
1083
1187
|
}
|
|
1084
1188
|
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1189
|
+
// Blend the two-chain corners of a selection: salient corners round (trimmed neighbors
|
|
1190
|
+
// substituted in place, plus the synthetic corner-arc chains and their horns); reflex
|
|
1191
|
+
// corners get rolling-ball pivots (neighbors stay flush — the pivot owns the wedge).
|
|
1087
1192
|
function roundSalientCorners(selected, magnitude) {
|
|
1088
1193
|
const keyOf = (p) => `${Math.round(p[0] * WELD)},${Math.round(p[1] * WELD)},${Math.round(p[2] * WELD)}`;
|
|
1089
1194
|
const ends = new Map();
|
|
@@ -1096,7 +1201,7 @@ function roundSalientCorners(selected, magnitude) {
|
|
|
1096
1201
|
(ends.get(kk) ?? ends.set(kk, []).get(kk)).push({ ch, end, info });
|
|
1097
1202
|
}
|
|
1098
1203
|
}
|
|
1099
|
-
const arcs = [], horns = [], trims = new Map();
|
|
1204
|
+
const arcs = [], horns = [], pivots = [], trims = new Map();
|
|
1100
1205
|
const addTrim = (ch, end, t) => {
|
|
1101
1206
|
const cur = trims.get(ch) ?? { start: 0, end: 0 };
|
|
1102
1207
|
cur[end] = t;
|
|
@@ -1104,8 +1209,10 @@ function roundSalientCorners(selected, magnitude) {
|
|
|
1104
1209
|
};
|
|
1105
1210
|
for (const list of ends.values()) {
|
|
1106
1211
|
if (list.length !== 2 || (list[0].ch === list[1].ch && list[0].end === list[1].end)) continue;
|
|
1107
|
-
const
|
|
1108
|
-
if (!
|
|
1212
|
+
const blend = cornerBlendBetween(list[0].info, list[1].info, magnitude);
|
|
1213
|
+
if (!blend) continue;
|
|
1214
|
+
if (blend.pivot) { pivots.push(blend.pivot); continue; }
|
|
1215
|
+
const got = blend.corner;
|
|
1109
1216
|
arcs.push(got.arc);
|
|
1110
1217
|
// a gentle corner's horn is a sliver — depth ρ·(1/cos(turn/2) − 1), microns at
|
|
1111
1218
|
// small turns — not worth a cutter (and thin cutters are their own noise source)
|
|
@@ -1114,14 +1221,14 @@ function roundSalientCorners(selected, magnitude) {
|
|
|
1114
1221
|
addTrim(list[0].ch, list[0].end, got.t);
|
|
1115
1222
|
addTrim(list[1].ch, list[1].end, got.t);
|
|
1116
1223
|
}
|
|
1117
|
-
if (!arcs.length) return { chains: selected, arcs, horns };
|
|
1224
|
+
if (!arcs.length) return { chains: selected, arcs, horns, pivots };
|
|
1118
1225
|
const chains = [];
|
|
1119
1226
|
for (const ch of selected) {
|
|
1120
1227
|
const tr = trims.get(ch);
|
|
1121
1228
|
const eff = tr ? trimChain(ch, tr.start, tr.end) : ch;
|
|
1122
1229
|
if (eff) chains.push(eff);
|
|
1123
1230
|
}
|
|
1124
|
-
return { chains, arcs, horns };
|
|
1231
|
+
return { chains, arcs, horns, pivots };
|
|
1125
1232
|
}
|
|
1126
1233
|
|
|
1127
1234
|
// ---------------------------------------------------------------------------
|
|
@@ -1199,7 +1306,7 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
|
|
|
1199
1306
|
// converted arc joins its planar neighbors — chainEdges' own stitch pass ran before
|
|
1200
1307
|
// these chains were planar, so their junctions are still open here.
|
|
1201
1308
|
const planarized = stitchPlanarChains(selected.map((ch) => planarizeArc(ch) ?? ch));
|
|
1202
|
-
const { chains: effective, arcs, horns } = roundSalientCorners(planarized, magnitude);
|
|
1309
|
+
const { chains: effective, arcs, horns, pivots } = roundSalientCorners(planarized, magnitude);
|
|
1203
1310
|
const pSegs = blendSegs(segs, magnitude);
|
|
1204
1311
|
const toolsFor = (ch) =>
|
|
1205
1312
|
ch.kind === "planar"
|
|
@@ -1207,6 +1314,7 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
|
|
|
1207
1314
|
: [(ch.kind === "arc" ? revolveTool : prismTool)(k, ch, magnitude, mode, segs, pSegs)];
|
|
1208
1315
|
const cutters = [...effective, ...arcs].filter((ch) => ch.convex).flatMap(toolsFor);
|
|
1209
1316
|
cutters.push(...horns.map((h) => cornerHornTool(k, h, magnitude, segs)));
|
|
1317
|
+
cutters.push(...pivots.map((p) => reflexPivotTool(k, p, magnitude, mode, segs, pSegs)));
|
|
1210
1318
|
const fillers = effective.filter((ch) => !ch.convex).flatMap(toolsFor);
|
|
1211
1319
|
if (mode === "fillet") cutters.push(...cornerPatches(k, effective, magnitude, segs));
|
|
1212
1320
|
let out = solid;
|