Files
claude-workflow/skills/openscad/scripts/openscad-sdf-optimize.py
T

343 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""
openscad-sdf-optimize.py — SDF-based parametric model optimizer
Uses Signed Distance Fields and IoU scoring to find optimal parameters
for an OpenSCAD reconstruction WITHOUT invoking OpenSCAD in the loop.
Usage:
python3 openscad-sdf-optimize.py <original.stl> <model_type> [options]
Model types:
stadium-slot Stadium body with cylindrical slot cut
box-holes Rectangular body with through holes
custom Custom SDF defined in a Python module
Options:
--samples N Number of sample points (default: 30000)
--output FILE Output JSON with optimized parameters
--verbose Print optimization progress
"""
import numpy as np
import trimesh
import json
import sys
import os
from scipy.optimize import minimize, least_squares
# ========== SDF Primitives ==========
def sdf_box(p, size):
"""Signed distance to an axis-aligned box centered at origin."""
half = np.array(size) / 2
q = np.abs(p) - half
return np.linalg.norm(np.maximum(q, 0), axis=1) + np.minimum(np.max(q, axis=1), 0)
def sdf_cylinder_x(p, radius, half_length, center=None):
"""Signed distance to a cylinder along X axis."""
if center is not None:
p = p - np.array(center)
d_yz = np.sqrt(p[:, 1]**2 + p[:, 2]**2) - radius
d_x = np.abs(p[:, 0]) - half_length
return np.minimum(np.maximum(d_yz, d_x), 0) + np.linalg.norm(
np.maximum(np.column_stack([d_yz, d_x]), 0), axis=1)
def sdf_capsule_x(p, radius, half_span, center=None):
"""Signed distance to a capsule (two spheres hulled) along X axis."""
if center is not None:
p = p - np.array(center)
# Clamp X to [-half_span, half_span]
px_clamped = np.clip(p[:, 0], -half_span, half_span)
q = p.copy()
q[:, 0] -= px_clamped
return np.linalg.norm(q, axis=1) - radius
def sdf_stadium_extrude(p, total_len, width, height):
"""SDF for a stadium shape extruded along Z."""
r = width / 2
half_span = total_len / 2 - r
# 2D stadium distance in XY
px_clamped = np.clip(p[:, 0], -half_span, half_span)
dx = p[:, 0] - px_clamped
d_xy = np.sqrt(dx**2 + p[:, 1]**2) - r
# Z bounds
d_z = np.abs(p[:, 2] - height / 2) - height / 2
return np.maximum(d_xy, d_z)
# ========== CSG Operations ==========
def sdf_union(d1, d2):
return np.minimum(d1, d2)
def sdf_difference(d1, d2):
return np.maximum(d1, -d2)
def sdf_intersection(d1, d2):
return np.maximum(d1, d2)
# ========== Model Definitions ==========
def model_stadium_slot(p, params):
"""Stadium body with cylindrical slot carved from top.
params: [length, width, height, slot_total, slot_width, cyl_d, cyl_center_z]
"""
L, W, H, sL, sW, cD, cZ = params
body = sdf_stadium_extrude(p, L, W, H)
# Cylinder slot along X
half_span = sL / 2 - sW / 2
cyl = sdf_capsule_x(p, cD / 2, half_span, center=[0, 0, cZ])
return sdf_difference(body, cyl)
def model_box_with_holes(p, params):
"""Rectangular box with cylindrical through-holes.
params: [sx, sy, sz, hole_d, hole_z, n_holes, hole_spacing, hole_x_start]
"""
sx, sy, sz, hole_d, hole_z, n_holes, spacing, x_start = params
n_holes = int(round(n_holes))
body = sdf_box(p, [sx, sy, sz])
# Offset body center
result = body
for i in range(n_holes):
hx = x_start + i * spacing
hole = sdf_cylinder_x(p, hole_d / 2, sy, center=[hx, 0, hole_z - sz/2])
# Rotate hole to Y axis
p_rot = p.copy()
p_rot[:, 0] = p[:, 1]
p_rot[:, 1] = p[:, 0]
hole = sdf_cylinder_x(p_rot, hole_d / 2, sx, center=[0, hx, hole_z - sz/2])
result = sdf_difference(result, hole)
return result
MODEL_REGISTRY = {
'stadium-slot': {
'sdf': model_stadium_slot,
'param_names': ['length', 'width', 'height', 'slot_total', 'slot_width', 'cyl_d', 'cyl_center_z'],
},
'box-holes': {
'sdf': model_box_with_holes,
'param_names': ['sx', 'sy', 'sz', 'hole_d', 'hole_z', 'n_holes', 'spacing', 'x_start'],
},
}
# ========== Scoring ==========
def compute_iou(target_inside, candidate_inside):
"""Intersection over Union from boolean occupancy arrays."""
intersection = np.count_nonzero(target_inside & candidate_inside)
union = np.count_nonzero(target_inside | candidate_inside)
if union == 0:
return 0.0
return intersection / union
def compute_score(mesh, sdf_func, params, sample_points, target_inside):
"""Score a candidate model against the target mesh."""
candidate_sdf = sdf_func(sample_points, params)
candidate_inside = candidate_sdf <= 0
iou = compute_iou(target_inside, candidate_inside)
return iou
# ========== Initialization from Mesh Analysis ==========
def init_params_from_mesh(mesh, model_type):
"""Extract initial parameters from mesh analysis."""
bounds = mesh.bounds
dims = bounds[1] - bounds[0]
center = mesh.centroid
if model_type == 'stadium-slot':
# Use section analysis to find slot
sections = []
z_min, z_max = bounds[0][2], bounds[1][2]
for pct in [0.01, 0.25, 0.5, 0.75, 0.99]:
z = z_min + (z_max - z_min) * pct
try:
path = mesh.section(plane_origin=[0, 0, z], plane_normal=[0, 0, 1])
if path:
path2d, _ = path.to_2D()
polys = path2d.polygons_full
areas = [p.area for p in polys]
sections.append({'z': z, 'n_contours': len(polys), 'areas': areas,
'total_area': sum(areas)})
except:
pass
# Estimate slot from difference in areas across Z
if sections:
body_area = max(s['total_area'] for s in sections)
# The slot width can be estimated from how the contour changes
# For now, use bounding box as starting point
slot_width_est = dims[1] * 0.33 # ~1/3 of body width
slot_len_est = dims[0] * 0.89 # ~89% of body length
return [
dims[0], # length
dims[1], # width
dims[2], # height
dims[0] * 0.89, # slot_total (slightly shorter than body)
dims[1] * 0.33, # slot_width (1/3 of body width)
dims[2] * 0.8, # cyl_d (80% of height)
dims[2] * 0.6, # cyl_center_z (60% up)
]
return list(dims) + [0] * 4
# ========== Main Optimization ==========
def optimize(stl_path, model_type, n_samples=30000, verbose=False):
"""Main optimization pipeline."""
print(f"Loading mesh: {stl_path}")
mesh = trimesh.load_mesh(stl_path, force='mesh')
trimesh.repair.fix_normals(mesh)
print(f"Mesh: {len(mesh.vertices)} verts, {len(mesh.faces)} faces, vol={mesh.volume:.1f}mm³")
# Sample points in and around the bounding box
margin = 2.0
pts = np.random.uniform(
mesh.bounds[0] - margin,
mesh.bounds[1] + margin,
(n_samples, 3)
)
# Compute target occupancy
print("Computing target occupancy...")
target_sdf = trimesh.proximity.signed_distance(mesh, pts)
target_inside = target_sdf >= 0 # trimesh convention: positive = inside
print(f"Target: {np.sum(target_inside)}/{n_samples} points inside ({np.mean(target_inside)*100:.1f}%)")
# Get model SDF function
model_info = MODEL_REGISTRY[model_type]
sdf_func = model_info['sdf']
param_names = model_info['param_names']
# Initialize parameters from mesh analysis
print("Initializing parameters from mesh analysis...")
x0 = np.array(init_params_from_mesh(mesh, model_type))
print(f"Initial params: {dict(zip(param_names, x0.round(3)))}")
# Score initial
iou0 = compute_score(mesh, sdf_func, x0, pts, target_inside)
print(f"Initial IoU: {iou0:.4f} ({iou0*100:.1f}%)")
# Optimize with Powell method
print("\nOptimizing with Powell method...")
iter_count = [0]
def objective(x):
iter_count[0] += 1
candidate_sdf = sdf_func(pts, x)
candidate_inside = candidate_sdf <= 0
iou = compute_iou(target_inside, candidate_inside)
if verbose and iter_count[0] % 10 == 0:
print(f" iter {iter_count[0]}: IoU={iou:.4f} params={x.round(3)}")
return 1.0 - iou # minimize = maximize IoU
# Set bounds (all positive, reasonable ranges)
dims = mesh.bounds[1] - mesh.bounds[0]
bounds_lo = x0 * 0.5
bounds_hi = x0 * 1.5
# Ensure positive
bounds_lo = np.maximum(bounds_lo, 0.1)
result = minimize(
objective, x0,
method='Powell',
options={'maxiter': 500, 'ftol': 1e-6, 'disp': verbose}
)
final_params = result.x
final_iou = 1.0 - result.fun
print(f"\nOptimization complete ({iter_count[0]} iterations)")
print(f"Final IoU: {final_iou:.4f} ({final_iou*100:.1f}%)")
print(f"Final params:")
for name, val in zip(param_names, final_params):
print(f" {name} = {val:.4f}")
# Generate OpenSCAD code
scad_code = generate_scad(model_type, param_names, final_params)
print(f"\n--- Generated OpenSCAD ---\n{scad_code}")
return {
'model_type': model_type,
'params': dict(zip(param_names, [round(float(v), 4) for v in final_params])),
'iou': round(float(final_iou), 4),
'iterations': iter_count[0],
'volume_target': round(float(mesh.volume), 2),
'scad_code': scad_code,
}
def generate_scad(model_type, param_names, params):
"""Generate OpenSCAD code from optimized parameters."""
p = dict(zip(param_names, params))
if model_type == 'stadium-slot':
return f"""// Auto-generated by SDF optimizer
// IoU-optimized parameters
length = {p['length']:.3f};
width = {p['width']:.3f};
height = {p['height']:.3f};
slot_total = {p['slot_total']:.3f};
slot_width = {p['slot_width']:.3f};
cyl_d = {p['cyl_d']:.3f};
cyl_center_z = {p['cyl_center_z']:.3f};
$fn = 64;
difference() {{
linear_extrude(height = height)
stadium(length, width);
translate([0, 0, cyl_center_z])
hull() {{
translate([-(slot_total/2 - slot_width/2), 0, 0]) sphere(d=cyl_d, $fn=64);
translate([(slot_total/2 - slot_width/2), 0, 0]) sphere(d=cyl_d, $fn=64);
}}
}}
module stadium(l, w) {{
r = w / 2;
hull() {{
translate([-(l/2 - r), 0]) circle(r=r);
translate([(l/2 - r), 0]) circle(r=r);
}}
}}
"""
return f"// No code generator for model_type={model_type}"
# ========== CLI ==========
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='SDF-based OpenSCAD parameter optimizer')
parser.add_argument('stl_file', help='Input STL file')
parser.add_argument('model_type', choices=list(MODEL_REGISTRY.keys()),
help='Model type to fit')
parser.add_argument('--samples', type=int, default=30000, help='Sample points')
parser.add_argument('--output', help='Output JSON file')
parser.add_argument('--verbose', action='store_true', help='Show progress')
args = parser.parse_args()
result = optimize(args.stl_file, args.model_type,
n_samples=args.samples, verbose=args.verbose)
if args.output:
with open(args.output, 'w') as f:
json.dump(result, f, indent=2)
print(f"\nResults saved to {args.output}")