mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-11 19:22:01 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4031d270c4 |
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
corpus/
|
||||
Cargo.lock
|
||||
target/
|
||||
Vendored
+23
@@ -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
|
||||
Vendored
+35
@@ -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.
|
||||
Vendored
+331
@@ -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))
|
||||
Vendored
+8
@@ -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
@@ -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"
|
||||
Vendored
+21
@@ -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
@@ -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"
|
||||
+29
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+330
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user