GameCult.Geometry.Csg/spikes/boolean-weld/py/run.py
Meta Vens a507760ddc Spike boolean-weld: weld pass, measures, renders and result note
The operator's weld rule automated on the Dexter Quiet hull with boolmesh (identical output to Manifold).
Owner by curvature across the loop (as planned) fails the gun port; along the loop it passes it (slivers
32 to 5, worst angle 0.007 to 5.1 deg, silhouette unchanged). Planar panel splits get nothing from the
rule. Note: docs/research/spike-boolean-weld.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:41:26 +02:00

95 lines
5.3 KiB
Python

"""Run the weld variants and measures over every case and kernel. Usage: run.py <fixtures> <cases.json> <runs-dir> <out-dir>"""
import sys, os, json, numpy as np
from scipy.spatial import cKDTree
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from geo import *
from weld import *
fx, casesf, runs, outd = sys.argv[1:5]
kernels = sys.argv[5].split(',') if len(sys.argv) > 5 else ['boolmesh', 'manifold']
os.makedirs(outd, exist_ok=True)
hull = Hull(fx)
TOL = 1e-7 * hull.diag * 10
cases = {c['name']: c for c in json.load(open(casesf))}
VARIANTS = [('across', 'literal'), ('across', 'looponly'), ('along', 'literal'), ('along', 'looponly'), ('along', 'looponly-snap'), ('across', 'looponly-snap')]
rows, owners = [], {}
def load_run(kernel, name):
V, F = load_obj(f'{runs}/{kernel}_{name}.obj'); T = triangulate(F)
prov = np.array([int(x) for x in open(f'{runs}/{kernel}_{name}.prov').read().split()])
return V, T, prov
def ref_points(c, kernel, V, T, prov):
if c['kind'] == 'cyl':
Vr, Tr, pr = load_run('boolmesh', c['name'] + '_REF')
return densify(Vr, loops_of(Tr, pr)[0], 4)
return densify(V, loops_of(T, prov)[0], 64)
def owner_summary(info, loops):
sw = 0
for L in loops:
o = [info[v]['owner'] for v in L]; sw += sum(o[i] != o[i - 1] for i in range(len(o)))
ks = np.array([[info[v]['kh'], info[v]['kc']] for L in loops for v in L])
fin = np.isfinite(ks[:, 0])
return dict(hull_owned=int(sum(info[v]['owner'] == 0 for v in info)), cutter_owned=int(sum(info[v]['owner'] == 1 for v in info)),
switches=int(sw), crease_loop_verts=int(sum(info[v]['crease'] for v in info)),
kh_abs_median=float(np.median(np.abs(ks[fin, 0]))) if fin.any() else None,
kc_abs_median=float(np.median(np.abs(ks[:, 1]))),
near_tie=int(np.sum(np.abs(np.abs(ks[fin, 0]) - np.abs(ks[fin, 1])) < 0.1 * np.maximum(np.abs(ks[fin, 0]), np.abs(ks[fin, 1])) + 1e-9)),
origins={o: int(sum(info[v]['origin'] == o for v in info)) for o in ('H', 'C', 'HC', 'X')})
def save_for_blender(path, V, T, prov, info=None):
save_obj(path + '.obj', V[np.unique(T)] if False else V, T)
loops, le = loops_of(T, prov)
with open(path + '.loop', 'w') as f:
for a, b in le:
oa = info[a]['owner'] if info and a in info else -1
f.write(f'{a} {b} {oa}\n')
for name, c in cases.items():
for kernel in kernels:
if not os.path.exists(f'{runs}/{kernel}_{name}.obj'):
continue
V, T, prov = load_run(kernel, name)
cut = Cutter(c)
refp = ref_points(c, kernel, V, T, prov)
m = measures(V, T, prov, hull, cut, refp)
rows.append(dict(case=name, kernel=kernel, variant='naive', **m))
for mode in ('across', 'along', 'mean'):
loops, le, info = analyse_loops(V, T, prov, hull, cut, mode, TOL)
owners[f'{name}|{kernel}|{mode}'] = owner_summary(info, loops)
if kernel == 'boolmesh' and mode == 'across':
save_for_blender(f'{outd}/{kernel}_{name}_naive', V, T, prov, info)
for mode, scope in VARIANTS:
T2, P2, done, st, (loops, le, info) = weld(V, T, prov, hull, cut, mode, scope, TOL)
crease_removed = sum(1 for a, b in done if hull.edge_of(a, TOL) in hull.crease or (hull.kd.query(a)[0] < TOL and int(hull.kd.query(a)[1]) in hull.crease_v))
m2 = measures(V, T2, P2, hull, cut, refp, removed=done)
row = dict(case=name, kernel=kernel, variant=f'{mode}-{scope}', collapses=len(done), crease_verts_removed=crease_removed,
stats=st, **m2)
if c['kind'] == 'box' and c['op'] == 'sub':
# split: replay the same collapses on the other piece and compare the two loops
other = name[:-4] + '_int'
Vo, To, po = load_run(kernel, other)
To2, Po2, done_o, st_o, _ = weld(Vo, To, po, hull, cut, mode, scope, TOL, forced=done)
la = densify(V, loops_of(T2, P2)[0], 1); lb = densify(Vo, loops_of(To2, Po2)[0], 1)
gap = max(cKDTree(lb).query(la)[0].max(), cKDTree(la).query(lb)[0].max())
row.update(split_replay=st_o, split_loop_gap=float(gap), split_loop_verts=(len(la), len(lb)))
if mode == 'along' and scope in ('looponly', 'looponly-snap') and kernel == 'boolmesh':
save_for_blender(f'{outd}/{kernel}_{other}_weld-{mode}' + ('-snap' if 'snap' in scope else ''), Vo, To2, Po2)
rows.append(row)
if kernel == 'boolmesh' and scope.startswith('looponly'):
save_for_blender(f'{outd}/{kernel}_{name}_weld-{mode}' + ('-snap' if 'snap' in scope else ''), V, T2, P2, info)
if c['kind'] == 'box' and c['op'] == 'sub':
Vo, To, po = load_run(kernel, name[:-4] + '_int')
la = densify(V, loops_of(T, prov)[0], 1); lb = densify(Vo, loops_of(To, po)[0], 1)
rows.append(dict(case=name, kernel=kernel, variant='naive-split-gap',
split_loop_gap=float(max(cKDTree(lb).query(la)[0].max(), cKDTree(la).query(lb)[0].max())), split_loop_verts=(len(la), len(lb))))
print('done', name, kernel, flush=True)
import weld as W
json.dump(dict(rows=rows, owners=owners, flips=W.FLIPS), open(f'{outd}/measures.json', 'w'), indent=1, default=float)