Compare commits

...

1 Commits

Author SHA1 Message Date
bherbruck 4031d270c4 solidcore/bench/tess: the mesher bake-off harness
Meshes one corpus of bodies with each of several SolidCraft commits and grades every mesh:
failures, faces left empty, triangles, deviation against the chord, open edges on closed bodies,
slivers and time. The corpus is fetched at run time, never committed.

Changes a default: no

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 11:23:07 -05:00
11 changed files with 887 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
corpus/
Cargo.lock
target/
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "meshbake"
version = "0.0.0"
edition = "2024"
publish = false
# Standalone (own workspace): it sits inside solidcore/, which SolidCraft's workspace excludes, and
# it needs SolidCraft's kernel too. Built by bench/tess/run.sh against whichever tree it is in.
[workspace]
[dependencies]
solidcraft-kernel = { path = "../../../crates/kernel" }
solidcraft-geom = { path = "../../../crates/geom" }
solidcore = { path = "../../crates/solidcore" }
solidcore-record = { path = "../../crates/solidcore-record" }
solidcore-convert = { path = "../../crates/solidcore-convert" }
solidcore-tess = { path = "../../crates/solidcore-tess" }
serde_json = "1"
[profile.release]
opt-level = 3
debug = false
codegen-units = 16
+35
View File
@@ -0,0 +1,35 @@
# Mesher bake-off harness
Meshes one corpus of bodies with one or more SolidCraft commits and grades every mesh: bodies
meshed, failures, faces left without triangles, triangles, deviation from the surface against the
chord tolerance, open edges on closed bodies (true gaps and T-junctions), non-manifold edges,
sliver angles and time. The corpus is never committed: `fetch.sh` builds it at run time from where
each input lives (see its header).
The first run of it (2026-11-07) compared SolidCraft's mesher with solidcore's; it is repeated on
the combined mesher (#14003, #14029, #14033); the results are kept outside the repository.
## Steps
Each commit to measure is its own worktree with its own target folder (a shared one builds stale
code). Copy this folder into it first (an older commit has no `bench/tess/`).
1. `fetch.sh DEST`: the example designs and the 47 STEP files as kernel-case folders in `DEST/cases`.
2. Bodies: `git apply bench/tess/cli-dump.patch` on one commit (a local change to the CLI, never
committed), build `solidcraft-cli`, then run it over `tests/kernel-cases/*/*/` and `DEST/cases/*/*/`
with `MESHBAKE_DUMP=DUMPS`: every body each case ends with is written to `DUMPS/<area>__<case>__<n>.nb`.
Every commit is fed the same `.nb` files. `mkprims` (in `src/bin`) writes the six primitives.
3. `build.sh`: builds `meshbake` and `mkprims` in release against the tree it sits in.
4. `run.sh LIST DUMPS OUT LABEL=BIN[:ONLY]...`: one process per body, setting and mesher, strictly one after
another (body by body, the meshers in turn, so each meets the same load). `ONLY` is `solidcraft`
(`Body::mesh_plan`, the viewport and export path) or `solidcore-tess` (`solidcore_tess::plan`).
For the 2026-11-09 run: `sc-old=OLD/meshbake:solidcraft core-old=OLD/meshbake:solidcore-tess combined=NEW/meshbake:solidcraft`.
5. `agg.py RUNS LIST results.md`: the tables. `RUNS` holds `sc-old/`, `core-old/` and `combined/`.
Tolerances as the app asks for them: display is `MeshTolerance::display_of(size)` (chord
diagonal/1000 and a 20 degree facet angle), export is `export_of(size)` (diagonal/5000, chord only).
Deviation is sampled (up to 60 triangles per face, at the centroid, edge midpoints and interior
points, projected to the face's exact surface with `solidcore::eval::SurfaceEval::project`), so a
miss between samples is not seen. Vertices are welded at 1e-7 of the body's diagonal; a true gap is an
open edge whose midpoint is not within 1e-6 of the diagonal of another open edge.
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Tables of the mesher bake-off: agg.py RUNS LIST OUT.md
RUNS holds one folder per mesher (sc-old, core-old, combined), each the OUT of run.sh; LIST is the
bodies' names. Writes the tables (not the prose) of results.md and prints the regressions of the
last mesher against the best of the other two."""
import json, os, sys, statistics as st
from collections import defaultdict
RUNS, LIST, OUTMD = sys.argv[1], sys.argv[2], sys.argv[3]
LABELS = ['sc-old', 'core-old', 'combined']
NAMES = {'sc-old': "SolidCraft's old mesher", 'core-old': 'solidcore-tess (old)', 'combined': 'combined mesher'}
MODES = ['display', 'export']
bodies = [l.strip() for l in open(LIST) if l.strip()]
bset = set(bodies)
R = {} # (label, mode, body) -> result row
PROC = {} # (label, mode, body) -> rc
KINDS, INFO = {}, {}
for lab in LABELS:
for mode in MODES:
d = os.path.join(RUNS, lab, mode)
if not os.path.isdir(d):
continue
for fn in os.listdir(d):
if not fn.endswith('.jsonl'):
continue
b = fn[:-6]
if b not in bset:
continue
for l in open(os.path.join(d, fn)):
l = l.strip()
if not l.startswith('{'):
continue
x = json.loads(l)
k = x.get('kind')
if k == 'result':
R[(lab, mode, b)] = x
elif k == 'proc':
PROC[(lab, mode, b)] = x['rc']
elif k == 'kinds':
KINDS[b] = x['kinds']
elif k == 'body':
INFO[(b, mode)] = x['info']
elif 'error' in x and 'mesher' not in x:
pass
for (lab, mode, b), x in R.items():
x['chord'] = INFO.get((b, mode), {}).get('chord', float('nan'))
out = []
def w(s=""): out.append(s)
def q(v, p):
v = sorted(v)
return v[int((len(v) - 1) * p)] if v else float('nan')
def fmt(x, n=3):
return f'{x:.{n}f}' if isinstance(x, float) else str(x)
def rows(lab, mode, ok=True):
return [R[(lab, mode, b)] for b in bodies if (lab, mode, b) in R and (not ok or R[(lab, mode, b)].get('ok'))]
def metrics(lab, mode):
allr = [R[(lab, mode, b)] for b in bodies if (lab, mode, b) in R]
nproc = sum(1 for b in bodies if (lab, mode, b) not in R)
ok = [r for r in allr if r.get('ok')]
dv = [r for r in ok if 'dev_max' in r]
ratio = [r['dev_max'] / r['chord'] for r in dv]
closed = [r for r in ok if r['brep_boundary_edges'] == 0]
t = [r['ms'] for r in ok]
m = {
'bodies run': len(bodies), 'meshed ok': len(ok), 'failed': len(bodies) - len(ok),
'triangles': sum(r['tris'] for r in ok),
'bodies with a face missing': sum(1 for r in ok if r['faces_without_tris'] > 0),
'max dev / chord (median body)': st.median(ratio) if ratio else float('nan'),
'(p95)': q(ratio, .95),
'bodies over chord': sum(1 for x in ratio if x > 1.0001),
'sample points over chord': sum(r['dev_over_chord'] for r in dv),
'closed bodies with open edges': sum(1 for r in closed if r['open_edges'] > 0),
'... of which true gaps': sum(1 for r in closed if r['open_unsealed'] > 0),
'nonmanifold-edge bodies': sum(1 for r in ok if r['nonmanifold_edges'] > 0),
'tris with min angle <1 deg': sum(r['tris_lt1deg'] for r in ok),
'<5 deg': sum(r['tris_lt5deg'] for r in ok),
'worst aspect (p99 of bodies, median)': st.median(r['p99_aspect'] for r in ok) if ok else float('nan'),
'time median ms': st.median(t) if t else float('nan'), 'p95 ms': q(t, .95), 'slowest ms': max(t) if t else float('nan'),
}
m['_dev_samples'] = sum(r['dev_samples'] for r in dv)
m['_closed'] = len(closed)
return m
HIGHER_BETTER = {'meshed ok'}
NEUTRAL = {'bodies run', 'triangles'}
def verdict(key, c, a, b):
"""combined c against the better of the two old meshers a, b."""
if key in NEUTRAL:
best = min(a, b)
return f'{(c - best) / max(best, 1) * 100:+.2f}% vs fewest' if key == 'triangles' else '-'
best = max(a, b) if key in HIGHER_BETTER else min(a, b)
d = c - best
if abs(d) <= 1e-9 * max(1, abs(best)) or (isinstance(c, float) and abs(d) < 0.005 * max(abs(best), 1e-9)):
return 'equal'
worse = d < 0 if key in HIGHER_BETTER else d > 0
if isinstance(c, float):
return f'{(d / max(abs(best), 1e-12)) * 100:+.1f}% ' + ('worse' if worse else 'better')
return f'{d:+d} ' + ('worse' if worse else 'better')
w('## 1. Per-mesher totals')
w()
KEYS = ['bodies run', 'meshed ok', 'failed', 'triangles', 'bodies with a face missing', 'max dev / chord (median body)', '(p95)',
'bodies over chord', 'sample points over chord', 'closed bodies with open edges', '... of which true gaps',
'nonmanifold-edge bodies', 'tris with min angle <1 deg', '<5 deg', 'worst aspect (p99 of bodies, median)',
'time median ms', 'p95 ms', 'slowest ms']
M = {}
for mode in MODES:
w(f'### {mode} setting ({"chord = diagonal/1000 and facet angle 20 deg" if mode == "display" else "chord = diagonal/5000, chord only (no angle)"})')
w()
w('| mesher | ' + ' | '.join(KEYS) + ' |')
w('|---|' + '---|' * len(KEYS))
for lab in LABELS:
m = M[(lab, mode)] = metrics(lab, mode)
cells = []
for k in KEYS:
v = m[k]
if k == 'triangles' or k.startswith('tris with') or k == '<5 deg' or k == 'sample points over chord':
v = f'{v:,}'
if k == 'sample points over chord':
v += f' of {m["_dev_samples"]:,}'
elif k == 'closed bodies with open edges':
v = f'{v} of {m["_closed"]}'
elif k in ('time median ms', 'worst aspect (p99 of bodies, median)'):
v = fmt(v, 2)
elif k in ('p95 ms',):
v = fmt(v, 1)
elif k in ('slowest ms',):
v = fmt(v, 0)
elif isinstance(v, float):
v = fmt(v)
cells.append(str(v))
w(f'| {lab} | ' + ' | '.join(cells) + ' |')
cm = [verdict(k, M[('combined', mode)][k], M[('sc-old', mode)][k], M[('core-old', mode)][k]) for k in KEYS]
w('| **combined vs best of the two** | ' + ' | '.join(cm) + ' |')
w()
# ---- by kind
def area(b):
if b.startswith('mesh-bakeoff-stepcases__'): return 'step'
if b.startswith('mesh-bakeoff-excases__'): return 'examples'
if b.startswith('prim-'): return 'primitives'
return b.split('__')[0]
def kindclass(b):
k = set(KINDS.get(b, []))
if not k: return '?'
if k <= {'Plane', 'PlaneThrough'}: return 'planar only'
if k & {'Spline', 'RationalSpline', 'PlacedRevolution', 'BallPatch'}: return 'has spline / revolution / ball patch faces'
return 'analytic curved (cylinder, cone, sphere, torus)'
def cell(mode, lab, sel):
ok = [R[(lab, mode, b)] for b in bodies if sel(b) and (lab, mode, b) in R and R[(lab, mode, b)].get('ok')]
if not ok: return None
dv = [r for r in ok if 'dev_max' in r]
ratio = [r['dev_max'] / r['chord'] for r in dv]
closed = [r for r in ok if r['brep_boundary_edges'] == 0]
gaps = [r for r in closed if r['open_unsealed'] > 0]
tj = [r for r in closed if r['open_edges'] > 0 and r['open_unsealed'] == 0]
return dict(n=len(ok), tris=sum(r['tris'] for r in ok), over=sum(1 for x in ratio if x > 1.0001), gaps=len(gaps), tj=len(tj),
miss=sum(1 for r in ok if r['faces_without_tris'] > 0), tmed=st.median(r['ms'] for r in ok), tsum=sum(r['ms'] for r in ok),
failed=sum(1 for b in bodies if sel(b) and not ((lab, mode, b) in R and R[(lab, mode, b)].get('ok'))))
groups = [('primitives', lambda b: area(b) == 'primitives'),
('planar-only bodies', lambda b: kindclass(b) == 'planar only' and area(b) != 'primitives'),
('analytic curved (cyl/cone/sphere/torus + planes)', lambda b: kindclass(b).startswith('analytic') and area(b) != 'primitives'),
('spline / revolution / ball-patch faces (trimmed NURBS, lofts, sweeps)', lambda b: kindclass(b).startswith('has spline')),
('fillets area', lambda b: area(b) == 'fillets'), ('sweeps area (coils, lofts, sweeps)', lambda b: area(b) == 'sweeps'),
('threads area', lambda b: area(b) == 'threads'), ('sheet-metal area', lambda b: area(b) == 'sheet-metal'),
('booleans area', lambda b: area(b) == 'booleans'), ('crashes area (stress)', lambda b: area(b) == 'crashes'),
('surfaces area', lambda b: area(b) == 'surfaces'), ('shell/offset area', lambda b: area(b) == 'shell'),
('import (kernel-cases) area', lambda b: area(b) == 'import'),
('STEP files (conformance, regressions, NIST, models-corpus)', lambda b: area(b) == 'step'),
('examples/*.json designs', lambda b: area(b) == 'examples'),
('everything else in kernel-cases', lambda b: area(b) in ('assemblies', 'construction', 'continuity', 'frames', 'history', 'inspect', 'ladder', 'plastic', 'solids', 'tessellation'))]
def cmp_cell(c, a, b):
worse = []
for k, lbl in (('failed', 'failed'), ('over', 'over-chord'), ('gaps', 'gap'), ('miss', 'face-missing')):
if c[k] > min(a[k], b[k]): worse.append(f'{lbl} +{c[k] - min(a[k], b[k])}')
if c['tj'] > min(a['tj'], b['tj']): worse.append(f'T-jn +{c["tj"] - min(a["tj"], b["tj"])}')
best = min(a['tmed'], b['tmed'])
if c['tmed'] > best * 1.1 + 0.005: worse.append(f'median ms {c["tmed"] / best:.2f}x')
return 'worse: ' + ', '.join(worse) if worse else 'best or equal'
w('## 2. By kind of body')
w()
w('Per cell: tris / bodies over the chord / closed bodies with a true gap / closed bodies with only T-junctions / bodies with a face missing / median ms. The last column compares the combined mesher with the better of the two old ones on counts (and median time, 10 percent slack).')
w()
for mode in ('export', 'display'):
w(f'### {mode}')
w()
w('| body group | bodies | ' + ' | '.join(f'{l}: tris / over-chord / gap / T-jn / face-missing / med ms' for l in LABELS) + ' | combined vs best of the two |')
w('|---|---|' + '---|' * (len(LABELS) + 1))
for name, sel in groups:
cells = [cell(mode, l, sel) for l in LABELS]
if not all(cells):
continue
w(f'| {name} | {cells[0]["n"]} | ' + ' | '.join(f'{c["tris"]:,} / {c["over"]} / {c["gaps"]} / {c["tj"]} / {c["miss"]} / {c["tmed"]:.2f}' for c in cells) + f' | {cmp_cell(cells[2], cells[0], cells[1])} |')
w()
# ---- failures and regressions
w('## 3. Where one mesher fails or differs from another')
w()
def get(lab, mode, b): return R.get((lab, mode, b))
w('Meshing failures (a mesher returned an error, panicked or was killed), by body:')
w()
w('| body | setting | ' + ' | '.join(LABELS) + ' |')
w('|---|---|' + '---|' * len(LABELS))
nf = 0
for b in bodies:
for mode in MODES:
st_ = []
bad = False
for l in LABELS:
r = get(l, mode, b)
if r is None: st_.append(f'no result (rc {PROC.get((l, mode, b), "?")})'); bad = True
elif not r.get('ok'): st_.append(str(r.get('error'))[:90]); bad = True
else: st_.append('ok')
if bad:
nf += 1; w(f"| {b.split('__', 1)[-1]} | {mode} | " + ' | '.join(st_) + ' |')
if not nf: w('| none | | | | |')
w()
w('Bodies where a mesher leaves a face with no triangles:')
w()
for l in LABELS:
mm = [(b.split('__', 1)[-1], mo, get(l, mo, b)['faces_without_tris'], get(l, mo, b)['faces']) for b in bodies for mo in MODES if get(l, mo, b) and get(l, mo, b).get('ok') and get(l, mo, b)['faces_without_tris'] > 0]
w(f'- **{l}**: {len(mm)} body-settings; ' + '; '.join(f'{b} {mo} ({a}/{f} faces empty)' for b, mo, a, f in mm[:8]))
w()
# regressions of combined vs the best old
def dev_ratio(r): return r['dev_max'] / r['chord'] if r and r.get('ok') and 'dev_max' in r else None
CATS = [
('failed to mesh', lambda r: 0 if r and r.get('ok') else 1, 0),
('faces left empty', lambda r: r['faces_without_tris'] if r and r.get('ok') else 10**9, 0),
('closed body, true gap in the mesh', lambda r: (r['open_unsealed'] if r['brep_boundary_edges'] == 0 else 0) if r and r.get('ok') else 10**9, 0),
('closed body, open edges (gap or T-junction)', lambda r: (r['open_edges'] if r['brep_boundary_edges'] == 0 else 0) if r and r.get('ok') else 10**9, 0),
('non-manifold edges', lambda r: r['nonmanifold_edges'] if r and r.get('ok') else 10**9, 0),
('flipped edges', lambda r: r['flipped_edges'] if r and r.get('ok') else 10**9, 0),
('degenerate triangles', lambda r: r['degenerate_tris'] if r and r.get('ok') else 10**9, 0),
('max deviation over the chord (more than 5 percent worse)', lambda r: max(1.0, dev_ratio(r) or 0) if r and r.get('ok') else 10**9, 0.05),
('triangles under 1 degree (more than 10 percent worse, and 10 more)', lambda r: r['tris_lt1deg'] if r and r.get('ok') else 10**9, 'sliver'),
('triangles (more than 10 percent over the smaller old count, and 100 more)', lambda r: r['tris'] if r and r.get('ok') else 10**9, 'tris'),
]
REG = {}
for cname, f, slack in CATS:
for mode in MODES:
for b in bodies:
a, o, c = get('sc-old', mode, b), get('core-old', mode, b), get('combined', mode, b)
if c is None: continue
fa, fo, fc = f(a), f(o), f(c)
best = min(fa, fo)
if best >= 10**9 and fc >= 10**9: continue
if slack == 0: bad = fc > best
elif slack == 'sliver': bad = fc > best * 1.1 + 10
elif slack == 'tris': bad = fc > best * 1.1 + 100
else: bad = fc > best * (1 + slack)
if bad: REG.setdefault((cname, mode), []).append((b, best, fc))
# time
for mode in MODES:
for b in bodies:
a, o, c = get('sc-old', mode, b), get('core-old', mode, b), get('combined', mode, b)
if a and o and c and a.get('ok') and o.get('ok') and c.get('ok'):
best = min(a['ms'], o['ms'])
if c['ms'] > best * 1.5 and c['ms'] - best > 20: REG.setdefault(('time: over 1.5x the faster old mesher and over 20 ms more', mode), []).append((b, best, c['ms']))
w('### Where the combined mesher is worse than the better of the two old meshers')
w()
w('Per body and setting, combined against the better old value of the same measure (so a body counts where either old mesher did better).')
w()
w('| measure | display: bodies | export: bodies | worst examples (body, setting, best old -> combined) |')
w('|---|---|---|---|')
for cname in [c[0] for c in CATS] + ['time: over 1.5x the faster old mesher and over 20 ms more']:
d, e = REG.get((cname, 'display'), []), REG.get((cname, 'export'), [])
allx = [(x, 'display') for x in d] + [(x, 'export') for x in e]
allx.sort(key=lambda t: -((t[0][2] - t[0][1]) if t[0][2] < 10**9 else 10**12))
ex = '; '.join(f"{b.split('__', 1)[-1]} {mo} ({fmt(a, 3) if isinstance(a, float) else a} -> {fmt(c_, 3) if isinstance(c_, float) else c_})" for (b, a, c_), mo in allx[:4])
w(f'| {cname} | {len(d)} | {len(e)} | {ex} |')
w()
w('Full lists:')
w()
for (cname, mode), lst in sorted(REG.items()):
if len(lst) > 0 and not cname.startswith('triangles ('):
w(f'- {cname}, {mode} ({len(lst)}): ' + '; '.join(f"{b.split('__', 1)[-1]} ({fmt(a, 3) if isinstance(a, float) else a} -> {fmt(c_, 3) if isinstance(c_, float) else c_})" for b, a, c_ in sorted(lst, key=lambda t: -((t[2] - t[1]) if t[2] < 10**9 else 10**12))[:40]))
w()
# time
w('## 4. Time')
w()
w('Wall time of the meshing call (plan + evaluate; median of 5 runs under 150 ms, else one run), one process per body and setting, one mesher at a time, release build.')
w()
w('| setting | mesher | bodies | median ms | p95 ms | p99 ms | slowest ms | sum s |')
w('|---|---|---|---|---|---|---|---|')
for mode in MODES:
for l in LABELS:
t = [r['ms'] for r in rows(l, mode)]
w(f'| {mode} | {l} | {len(t)} | {st.median(t):.2f} | {q(t, .95):.1f} | {q(t, .99):.1f} | {max(t):.0f} | {sum(t) / 1000:.1f} |')
w()
w('Slowest bodies, export setting (ms):')
w()
w('| body | faces | ' + ' | '.join(LABELS) + ' | triangles (combined) |')
w('|---|---|' + '---|' * len(LABELS) + '---|')
rs = [(max(get(l, 'export', b)['ms'] for l in LABELS), b) for b in bodies if all(get(l, 'export', b) and get(l, 'export', b).get('ok') for l in LABELS)]
for _, b in sorted(rs, reverse=True)[:12]:
w(f"| {b.split('__', 1)[-1][:60]} | {get('combined', 'export', b)['faces']} | " + ' | '.join(f"{get(l, 'export', b)['ms']:.0f}" for l in LABELS) + f" | {get('combined', 'export', b)['tris']:,} |")
w()
# cracks
w('Closed bodies (no boundary edge in the B-rep) with open edges in the mesh, one column per mesher: open edges (true gaps).')
w()
w('| body | setting | ' + ' | '.join(LABELS) + ' |')
w('|---|---|' + '---|' * len(LABELS))
seen = 0
for b in bodies:
for mo in MODES:
rr = [get(l, mo, b) for l in LABELS]
if all(r and r.get('ok') for r in rr) and rr[0]['brep_boundary_edges'] == 0 and any(r['open_edges'] > 0 for r in rr):
seen += 1; w(f"| {b.split('__', 1)[-1]} | {mo} | " + ' | '.join(f"{r['open_edges']} ({r['open_unsealed']})" for r in rr) + ' |')
if not seen: w('| none | | | | |')
w()
w('Bodies where any mesher exceeds the chord tolerance by more than 5 percent (max deviation / chord):')
w()
w('| body | setting | ' + ' | '.join(LABELS) + ' |')
w('|---|---|' + '---|' * len(LABELS))
lst = []
for b in bodies:
for mo in MODES:
v = [dev_ratio(get(l, mo, b)) for l in LABELS]
if any(x and x > 1.05 for x in v): lst.append((max(x for x in v if x), b, mo, v))
for _, b, mo, v in sorted(lst, reverse=True)[:20]:
w(f"| {b.split('__', 1)[-1]} | {mo} | " + ' | '.join('n/a' if x is None else f'{x:.2f}' for x in v) + ' |')
w()
open(OUTMD, 'w').write('\n'.join(out))
print(f'wrote {OUTMD}: {len(bodies)} bodies, results ' + ', '.join(f'{l}/{m}: {sum(1 for b in bodies if (l, m, b) in R)}' for l in LABELS for m in MODES))
+8
View File
@@ -0,0 +1,8 @@
// SolidCraft's own mesher lived in crates/geom/src/tess.rs until the one mesher moved to solidcore-tess.
fn main() {
println!("cargo:rustc-check-cfg=cfg(old_geom_tess)");
println!("cargo:rerun-if-changed=../../../crates/geom/src/tess.rs");
if std::path::Path::new("../../../crates/geom/src/tess.rs").exists() {
println!("cargo:rustc-cfg=old_geom_tess");
}
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
#
# Build the harness (meshbake, mkprims) in release against the SolidCraft tree it sits in, with
# that tree's Cargo.lock and its own target folder (never shared with another tree: a shared one
# builds stale code). Prints the folder the binaries are in.
#
# Usage: bench/tess/build.sh (inside a SolidCraft tree; to bench another commit, check it
# out as a worktree and copy bench/tess/ into it first)
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP="$(cd "$HERE/../../.." && pwd)"
cp "$APP/Cargo.lock" "$HERE/Cargo.lock"
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$APP/target}"
cargo build --release --manifest-path "$HERE/Cargo.toml" >&2
echo "$CARGO_TARGET_DIR/release"
+21
View File
@@ -0,0 +1,21 @@
diff --git a/apps/solidcraft-cli/src/kernel_cases.rs b/apps/solidcraft-cli/src/kernel_cases.rs
index d320b3a6e..3af7328c8 100644
--- a/apps/solidcraft-cli/src/kernel_cases.rs
+++ b/apps/solidcraft-cli/src/kernel_cases.rs
@@ -801,6 +801,16 @@ fn check_case_in(
return out;
}
};
+ if let Ok(d) = std::env::var("MESHBAKE_DUMP") {
+ for (i, b) in world.bodies.iter().enumerate() {
+ if let Ok(t) = serde_json::to_string(b.body.record()) {
+ let _ = std::fs::write(format!("{d}/{area}__{name}__{i}.json"), t);
+ }
+ if let Ok(nb) = solidcraft_engine::kernel::encode_native_body(&b.body) {
+ let _ = std::fs::write(format!("{d}/{area}__{name}__{i}.nb"), nb);
+ }
+ }
+ }
let mut bodies = Vec::new();
let mut c = Checks { list: Vec::new(), pass: true };
let want_valid = want["valid"].as_bool().unwrap_or(true);
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
#
# Build the mesher bake-off's case folders in DEST (default bench/tess/corpus, gitignored): one
# kernel-case folder (recipe.json, expected.json, input.step) per example design and per STEP
# file. Every input comes from where it lives, at run time; nothing downloaded is committed:
#
# examples SolidCraft's example designs: examples/*.json, acceptance/, clean-up/, regressions/
# (each is a command script; the recipe is the file itself)
# step-sc SolidCraft's tests/step-conformance and tests/step-regressions
# step-core the step fixtures in solidcore (rod_doubled_edge, discord-2026-10-30-fillet)
# step-conf github.com/dan335/step-conformance at $CONFORMANCE_REV, corpus/ (CC0 1.0)
# step-nist nine NIST PMI files, through scripts/fetch-nist-pmi-corpus.sh (SHA-256 pinned)
# step-mc eight models from github.com/tangency-labs/models (CC0), from the pull requests
# that add them, until those merge
#
# The kernel-case folders (SolidCraft's own tests/kernel-cases) are read in place by dump.sh.
#
# Usage: bench/tess/fetch.sh [DEST] (run inside a SolidCraft tree)
set -euo pipefail
CONFORMANCE_REV=96f30bb
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CORE="$(cd "$HERE/../.." && pwd)"
APP="$(cd "$CORE/.." && pwd)"
DEST="${1:-$HERE/corpus}"
mkdir -p "$DEST"
DEST="$(cd "$DEST" && pwd)"
WORK="$DEST/.downloads"
mkdir -p "$WORK"
EX="$DEST/cases/mesh-bakeoff-excases"
ST="$DEST/cases/mesh-bakeoff-stepcases"
mkdir -p "$EX" "$ST"
example() { # file
local d="$EX/ex-$(basename "$1" .json)"
mkdir -p "$d"; cp "$1" "$d/recipe.json"; echo '{}' >"$d/expected.json"
}
step_case() { # name, file
local d="$ST/$1"
mkdir -p "$d"; cp "$2" "$d/input.step"; echo '{}' >"$d/expected.json"
printf '{"commands":[{"command":"file.insert_step","params":{"path":"$CASE/input.step"}}]}\n' >"$d/recipe.json"
}
for f in "$APP"/examples/*.json "$APP"/examples/{acceptance,clean-up,regressions}/*.json; do example "$f"; done
for f in "$APP"/tests/step-conformance/*.step; do step_case "step-sc-$(basename "$f" .step)" "$f"; done
step_case step-sc-rod_doubled_edge "$APP/tests/step-regressions/rod_doubled_edge.step"
step_case step-core-rod_doubled_edge "$CORE/crates/step-brep/tests/fixtures/rod_doubled_edge.step"
step_case step-core-discord-2026-10-30-fillet "$CORE/crates/step-brep/tests/fixtures/discord-2026-10-30-fillet.step"
if [ ! -d "$WORK/step-conformance" ]; then git clone -q https://github.com/dan335/step-conformance "$WORK/step-conformance"; fi
git -C "$WORK/step-conformance" checkout -q "$CONFORMANCE_REV"
C="$WORK/step-conformance/corpus"
for n in bowl_01 door_handle_guide freeform_panel hinge_jig lego_3_pins lego_4x2_01 rail_guide sled_base; do step_case "step-conf-$n" "$C/$n.step"; done
step_case step-conf-plate_900_holes "$C/generated/plate_900_holes.step"
step_case step-conf-torus_ring "$C/generated/torus_ring.step"
"$CORE/scripts/fetch-nist-pmi-corpus.sh" "$WORK/nist-pmi" >/dev/null
N="$WORK/nist-pmi/NIST-PMI-STEP-Files"
for n in nist_ctc_01_asme1_ap242-e1 nist_ctc_02_asme1_ap242-e2 nist_ctc_03_asme1_ap242-e2 nist_ctc_04_asme1_ap242-e1 \
nist_ctc_05_asme1_ap242-e1 nist_ftc_06_asme1_ap242-e2 nist_ftc_07_asme1_ap242-e2 nist_ftc_10_asme1_ap242-e2; do
step_case "step-nist-$n" "$N/$n.stp"
done
step_case step-nist-nist_ftc_09_asme1_rd "$N/AP203 geometry only/nist_ftc_09_asme1_rd.stp"
if [ ! -d "$WORK/models/.git" ]; then git clone -q --depth 1 https://github.com/tangency-labs/models "$WORK/models"; fi
for p in "9 coil-flush-cylinder" "20 filleted-boss-grid" "11 spline-profile-prism" "18 tessellated-brep-mix" \
"17 toothed-plate-50k-faces" "10 twisted-sweep-tube" "8 untrimmed-surface-body" "15 vertices-off-curves"; do
set -- $p
git -C "$WORK/models" fetch -q --depth 1 origin "pull/$1/head"
git -C "$WORK/models" show "FETCH_HEAD:step/$2/$2.step" >"$WORK/$2.step"
step_case "step-mc-$2" "$WORK/$2.step"
done
echo "fetch: $(ls "$EX" | wc -l) example designs, $(ls "$ST" | wc -l) STEP files in $DEST/cases"
Vendored Executable
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
#
# Mesh every body of LIST with each harness binary in turn, one process per body, setting and
# mesher, strictly one after another, so every mesher meets the same load on a shared machine and
# its timings are clean. Each process's JSON lines are kept in OUT/<label>/<setting>/<body>.jsonl.
#
# TMO=900 seconds a process may take before it is killed
#
# Usage: bench/tess/run.sh LIST DUMPS OUT LABEL=BIN[:ONLY]...
# LIST one body name per line (the body is DUMPS/NAME.nb)
# ONLY solidcraft (SolidCraft's mesher, `Body::mesh_plan`) or solidcore-tess (`solidcore_tess::plan`);
# without it the binary meshes with both
set -uo pipefail
LIST="$1"; DUMPS="$2"; OUT="$3"; shift 3
for mode in display export; do
while read -r n; do
[ -n "$n" ] || continue
for spec in "$@"; do
label="${spec%%=*}"; rest="${spec#*=}"; bin="${rest%%:*}"; only=""
[ "$bin" != "$rest" ] && only="${rest#*:}"
d="$OUT/$label/$mode"; mkdir -p "$d"
MB_ONLY="$only" /usr/bin/timeout "${TMO:-900}" "$bin" "$DUMPS/$n.nb" --mode "$mode" >"$d/$n.jsonl" 2>"$d/$n.err"
rc=$?
if [ $rc -ne 0 ]; then printf '{"kind":"proc","body":"%s","mode":"%s","rc":%d}\n' "$n" "$mode" "$rc" >>"$d/$n.jsonl"; fi
done
done <"$LIST"
done
touch "$OUT/done"
+17
View File
@@ -0,0 +1,17 @@
use solidcore_record::Vec3;
use solidcraft_kernel as k;
fn main() {
let out = std::env::args().nth(1).unwrap();
std::fs::create_dir_all(&out).unwrap();
let bodies: Vec<(&str, k::Body)> = vec![
("prim-box-20x10x5", k::box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)).unwrap()),
("prim-box-40x30x20", k::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap()),
("prim-cube-10", k::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap()),
("prim-cylinder-r5-h20", k::cylinder(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), 5.0, 20.0).unwrap()),
("prim-sphere-r10", k::sphere(Vec3::ZERO, 10.0).unwrap()),
("prim-torus-20-4", k::torus(Vec3::ZERO, 20.0, 4.0).unwrap()),
];
for (n, b) in bodies {
std::fs::write(format!("{out}/{n}.nb"), k::encode_native_body(&b).unwrap()).unwrap();
}
}
+330
View File
@@ -0,0 +1,330 @@
//! meshbake FILE.nb [--mode display|export] [--no-dev] : mesh one body with SolidCraft's mesher (`Body::mesh_plan`) and
//! solidcore-tess's (`solidcore_tess::plan`), grade each, print JSON lines. `MB_ONLY=solidcraft|solidcore-tess` runs one.
use solidcore::eval::SurfaceEval;
use solidcore_record::brep::Brep;
use std::collections::HashMap;
use std::time::Instant;
struct M {
pos: Vec<[f64; 3]>,
tris: Vec<[u32; 3]>,
tri_face: Vec<u32>,
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn len(a: [f64; 3]) -> f64 {
dot(a, a).sqrt()
}
fn pct(v: &mut Vec<f64>, p: f64) -> f64 {
if v.is_empty() {
return 0.0;
}
v.sort_by(|a, b| a.total_cmp(b));
v[((v.len() - 1) as f64 * p) as usize]
}
fn bbox_diag(rec: &Brep) -> f64 {
let mut lo = [f64::MAX; 3];
let mut hi = [f64::MIN; 3];
for v in &rec.vertices {
for k in 0..3 {
lo[k] = lo[k].min(v.point[k]);
hi[k] = hi[k].max(v.point[k]);
}
}
len(sub(hi, lo))
}
/// Grade a mesh: topology, slivers, angle, deviation.
fn grade(m: &M, rec: &Brep, size: f64, chord: f64, dev: bool) -> serde_json::Value {
use serde_json::json;
let eps = size * 1e-7;
// weld
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let mut reps: Vec<[f64; 3]> = Vec::new();
let mut id: Vec<u32> = Vec::with_capacity(m.pos.len());
for p in &m.pos {
let c = [(p[0] / eps).floor() as i64, (p[1] / eps).floor() as i64, (p[2] / eps).floor() as i64];
let mut found = None;
'o: for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
if let Some(l) = cells.get(&(c[0] + dx, c[1] + dy, c[2] + dz)) {
for &r in l {
if len(sub(reps[r as usize], *p)) <= eps {
found = Some(r);
break 'o;
}
}
}
}
}
}
let r = found.unwrap_or_else(|| {
let r = reps.len() as u32;
reps.push(*p);
cells.entry((c[0], c[1], c[2])).or_default().push(r);
r
});
id.push(r);
}
let mut edges: HashMap<(u32, u32), (u32, i32, Vec<usize>)> = HashMap::new();
let mut degenerate = 0usize;
let mut normals: Vec<[f64; 3]> = Vec::with_capacity(m.tris.len());
let (mut minang_all, mut over1, mut over5) = (180.0f64, 0usize, 0usize);
let mut minangs = Vec::new();
let mut aspects = Vec::new();
let mut area = 0.0;
for (ti, t) in m.tris.iter().enumerate() {
let (a, b, c) = (m.pos[t[0] as usize], m.pos[t[1] as usize], m.pos[t[2] as usize]);
let n = cross(sub(b, a), sub(c, a));
let ar = len(n) / 2.0;
area += ar;
let w = [id[t[0] as usize], id[t[1] as usize], id[t[2] as usize]];
if ar <= 1e-12 * size * size || w[0] == w[1] || w[1] == w[2] || w[0] == w[2] {
degenerate += 1;
normals.push([0.0; 3]);
continue;
}
normals.push([n[0] / (2.0 * ar), n[1] / (2.0 * ar), n[2] / (2.0 * ar)]);
let (la, lb, lc) = (len(sub(b, c)), len(sub(a, c)), len(sub(a, b)));
let ang = |x: f64, y: f64, z: f64| ((y * y + z * z - x * x) / (2.0 * y * z)).clamp(-1.0, 1.0).acos().to_degrees();
let ma = ang(la, lb, lc).min(ang(lb, la, lc)).min(ang(lc, la, lb));
minang_all = minang_all.min(ma);
if ma < 1.0 {
over1 += 1;
}
if ma < 5.0 {
over5 += 1;
}
minangs.push(ma);
aspects.push(la.max(lb).max(lc) * (la + lb + lc) / (4.0 * 3f64.sqrt() * ar));
for (x, y) in [(w[0], w[1]), (w[1], w[2]), (w[2], w[0])] {
let (k, s) = if x < y { ((x, y), 1) } else { ((y, x), -1) };
let e = edges.entry(k).or_insert((0, 0, Vec::new()));
e.0 += 1;
e.1 += s;
e.2.push(ti);
}
}
let (mut open, mut nonman, mut flipped) = (0usize, 0usize, 0usize);
let mut max_dihedral = 0.0f64;
for (_, (c, s, ts)) in &edges {
match *c {
1 => open += 1,
2 => {
if *s != 0 {
flipped += 1;
}
let (a, b) = (ts[0], ts[1]);
if m.tri_face[a] == m.tri_face[b] {
let d = dot(normals[a], normals[b]).clamp(-1.0, 1.0).acos().to_degrees();
max_dihedral = max_dihedral.max(d);
}
}
_ => nonman += 1,
}
}
// open edges that are geometrically sealed by another open edge (T-junctions), vs true gaps
let open_list: Vec<(u32, u32)> = edges.iter().filter(|(_, v)| v.0 == 1).map(|(k, _)| *k).collect();
let mut unsealed = -1i64;
if open_list.len() <= 20000 {
unsealed = 0;
let pt = |i: u32| reps[i as usize];
let seg_dist = |p: [f64; 3], a: [f64; 3], b: [f64; 3]| {
let ab = sub(b, a);
let l2 = dot(ab, ab);
let t = if l2 > 0.0 { (dot(sub(p, a), ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
len(sub(p, [a[0] + t * ab[0], a[1] + t * ab[1], a[2] + t * ab[2]]))
};
let tol = size * 1e-6;
for (i, e) in open_list.iter().enumerate() {
let (a, b) = (pt(e.0), pt(e.1));
let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, (a[2] + b[2]) / 2.0];
let sealed = open_list.iter().enumerate().any(|(j, f)| j != i && seg_dist(mid, pt(f.0), pt(f.1)) <= tol);
if !sealed {
unsealed += 1;
}
}
}
let brep_boundary = rec.edges.iter().filter(|e| e.uses.len() == 1).count();
let nfaces = rec.faces.len();
let mut has = vec![false; nfaces];
for f in &m.tri_face {
if let Some(h) = has.get_mut(*f as usize) {
*h = true;
}
}
let empty_faces = has.iter().filter(|h| !**h).count();
let mut out = json!({
"tris": m.tris.len(), "verts": m.pos.len(), "faces": nfaces, "faces_without_tris": empty_faces,
"open_edges": open, "open_unsealed": unsealed, "nonmanifold_edges": nonman, "flipped_edges": flipped, "degenerate_tris": degenerate,
"brep_boundary_edges": brep_boundary, "area": area,
"min_angle_deg": minang_all, "tris_lt1deg": over1, "tris_lt5deg": over5,
"median_min_angle": pct(&mut minangs, 0.5), "p99_aspect": pct(&mut aspects.clone(), 0.99), "max_aspect": aspects.iter().cloned().fold(0.0, f64::max),
"max_dihedral_deg": max_dihedral,
});
if dev {
// deviation: sample up to 40 tris per face, 7 points each, project on the exact surface.
let mut per_face: HashMap<u32, Vec<usize>> = HashMap::new();
for (ti, f) in m.tri_face.iter().enumerate() {
per_face.entry(*f).or_default().push(ti);
}
let mut devs: Vec<f64> = Vec::new();
let mut unproj = 0usize;
let mut faces: Vec<_> = per_face.into_iter().collect();
faces.sort();
let budget = 4000usize / faces.len().max(1);
let per = budget.clamp(8, 60);
for (fi, tl) in faces {
let Some(face) = rec.faces.get(fi as usize) else { continue };
let Ok(ks) = solidcore_convert::surface(&face.surface) else { continue };
let Ok(se) = SurfaceEval::new(&ks) else { continue };
let starts = solidcore::eval::uv::Starts::default();
let step = (tl.len() / per).max(1);
for &ti in tl.iter().step_by(step).take(per) {
let t = m.tris[ti];
let (a, b, c) = (m.pos[t[0] as usize], m.pos[t[1] as usize], m.pos[t[2] as usize]);
let bary = [[1. / 3., 1. / 3., 1. / 3.], [0.5, 0.5, 0.0], [0.0, 0.5, 0.5], [0.5, 0.0, 0.5], [0.6, 0.2, 0.2], [0.2, 0.6, 0.2], [0.2, 0.2, 0.6]];
for w in bary {
let p = [0, 1, 2].map(|k| w[0] * a[k] + w[1] * b[k] + w[2] * c[k]);
let pv = solidcore::Vec3::new(p[0], p[1], p[2]);
let q = se
.project_with(&starts, pv, None, (5.0 * chord).max(size * 1e-3))
.or_else(|| se.project_with(&starts, pv, None, f64::INFINITY));
match q.and_then(|uv| se.point(uv)) {
Some(s) => devs.push(((s.x - p[0]).powi(2) + (s.y - p[1]).powi(2) + (s.z - p[2]).powi(2)).sqrt()),
None => unproj += 1,
}
}
}
}
let n = devs.len();
let mean = devs.iter().sum::<f64>() / n.max(1) as f64;
let maxd = devs.iter().cloned().fold(0.0, f64::max);
let over = devs.iter().filter(|d| **d > chord * 1.0000001).count();
let p99 = pct(&mut devs, 0.99);
out["dev_samples"] = json!(n);
out["dev_unprojected"] = json!(unproj);
out["dev_max"] = json!(maxd);
out["dev_mean"] = json!(mean);
out["dev_p99"] = json!(p99);
out["dev_over_chord"] = json!(over);
}
out
}
fn med(mut v: Vec<f64>) -> f64 {
v.sort_by(|a, b| a.total_cmp(b));
v[v.len() / 2]
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let path = &args[1];
let mode = args.iter().position(|a| a == "--mode").map(|i| args[i + 1].clone()).unwrap_or("display".into());
let dev = !args.iter().any(|a| a == "--no-dev");
let bytes = std::fs::read(path).expect("read");
let name = std::path::Path::new(path).file_stem().unwrap().to_string_lossy().to_string();
let load = || solidcraft_kernel::decode_native_body(&bytes);
let body = match load() {
Ok(b) => b,
Err(e) => {
println!("{}", serde_json::json!({"body": name, "mode": mode, "error": format!("load: {e}")}));
return;
}
};
let rec: Brep = body.record().clone();
let size = body.size();
let (sc_tol, core_tol, chord) = if mode == "display" {
let t = solidcraft_kernel::MeshTolerance::display_of(size);
(t, solidcore_tess::MeshTolerance::display_of(size), t.chord)
} else {
let t = solidcraft_kernel::MeshTolerance::export_of(size);
(t, solidcore_tess::MeshTolerance::export_of(size), t.chord)
};
let _ = bbox_diag(&rec);
let head = serde_json::json!({"body": name, "mode": mode, "size": size, "chord": chord, "faces": rec.faces.len(), "angle_deg": sc_tol.angle.map(|a| a.to_degrees())});
println!("{}", serde_json::json!({"kind": "body", "info": head}));
let mut surf_kinds: Vec<String> = rec.faces.iter().map(|f| format!("{:?}", f.surface).split(|c: char| !c.is_alphanumeric()).next().unwrap_or("").to_string()).collect();
surf_kinds.sort();
surf_kinds.dedup();
println!("{}", serde_json::json!({"kind": "kinds", "body": name, "kinds": surf_kinds}));
// --- mesher: SolidCraft kernel
let only = std::env::var("MB_ONLY").ok().filter(|o| !o.is_empty());
for which in ["solidcraft", "solidcore-tess"] {
if only.as_deref().is_some_and(|o| o != which) {
continue;
}
let run = |which: &str| -> Result<(M, f64, usize), String> {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<(M, usize), String> {
if which == "solidcraft" {
let b = &body;
let plan = b.mesh_plan(&sc_tol).map_err(|e| e.to_string())?;
// Before the one mesher, SolidCraft's plan evaluates to a mesh; after, it is
// solidcore-tess's plan and evaluates to a mesh and its refused faces.
#[cfg(old_geom_tess)]
let mesh = plan.evaluate();
#[cfg(not(old_geom_tess))]
let (mesh, _refused) = plan.evaluate();
let un = b.unmeshed_faces();
Ok((M { pos: mesh.positions.iter().map(|p| [p.x, p.y, p.z]).collect(), tris: mesh.triangles.clone(), tri_face: mesh.tri_face.clone() }, un))
} else {
let (plan, stats) = solidcore_tess::plan(&rec, &core_tol, size).map_err(|e| e.to_string())?;
let (mesh, refused) = plan.evaluate();
let un = stats.unmeshed.len() + refused.len();
Ok((M { pos: mesh.positions.iter().map(|p| [p.x, p.y, p.z]).collect(), tris: mesh.triangles.clone(), tri_face: mesh.tri_face.clone() }, un))
}
}));
match r {
Ok(Ok((m, un))) => Ok((m, 0.0, un)),
Ok(Err(e)) => Err(e),
Err(_) => Err("panic".into()),
}
};
// time
let mut times = Vec::new();
let first = {
let t0 = Instant::now();
let r = run(which);
times.push(t0.elapsed().as_secs_f64() * 1e3);
r
};
match first {
Err(e) => {
println!("{}", serde_json::json!({"kind": "result", "body": name, "mode": mode, "mesher": which, "ok": false, "error": e, "ms": times[0]}));
}
Ok((m, _, un)) => {
if times[0] < 150.0 {
for _ in 0..4 {
let t0 = Instant::now();
let _ = run(which);
times.push(t0.elapsed().as_secs_f64() * 1e3);
}
}
let ms = med(times.clone());
let mut g = grade(&m, &rec, size, chord, dev);
g["kind"] = "result".into();
g["body"] = name.clone().into();
g["mode"] = mode.clone().into();
g["mesher"] = which.into();
g["ok"] = true.into();
g["ms"] = ms.into();
g["ms_first"] = times[0].into();
g["unmeshed_reported"] = un.into();
println!("{}", g);
}
}
}
}