feat(skills): OpenSCAD-Skill hinzufuegen
This commit is contained in:
+320
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
openscad-adaptive-slice.py — Adaptive multi-axis SVG slicing
|
||||
|
||||
Slices an STL on all 3 axes with adaptive resolution:
|
||||
1. Coarse pass (every 5mm) → detect transitions
|
||||
2. Fine pass (every 0.5mm) only at transitions
|
||||
3. Outputs a complete feature map of the model
|
||||
|
||||
Usage:
|
||||
python3 openscad-adaptive-slice.py <file.stl> <output_dir> [--coarse 5] [--fine 0.5]
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import trimesh
|
||||
from trimesh import intersections
|
||||
from shapely.geometry import MultiLineString
|
||||
from shapely.ops import polygonize, unary_union
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
|
||||
|
||||
def segments_to_polygons(segments, snap=1e-4):
|
||||
"""Convert mesh slice segments to Shapely polygons."""
|
||||
lines = []
|
||||
for seg in np.asarray(segments):
|
||||
a = tuple(np.round(seg[0] / snap) * snap)
|
||||
b = tuple(np.round(seg[1] / snap) * snap)
|
||||
if a != b:
|
||||
lines.append([a, b])
|
||||
if not lines:
|
||||
return []
|
||||
return [p for p in polygonize(MultiLineString(lines)) if p.area > snap * snap]
|
||||
|
||||
|
||||
def slice_at_height(mesh, axis, height):
|
||||
"""Slice mesh at a given height along an axis. Returns polygon descriptors."""
|
||||
try:
|
||||
normal = np.zeros(3)
|
||||
normal[axis] = 1.0
|
||||
origin = np.zeros(3)
|
||||
origin[axis] = height
|
||||
|
||||
segments, _, _ = intersections.mesh_multiplane(
|
||||
mesh, plane_origin=origin, plane_normal=normal, heights=[0.0])
|
||||
|
||||
if not segments or len(segments[0]) == 0:
|
||||
return None
|
||||
|
||||
polys = segments_to_polygons(segments[0])
|
||||
if not polys:
|
||||
return None
|
||||
|
||||
combined = unary_union(polys)
|
||||
n_holes = sum(len(p.interiors) for p in polys) if hasattr(polys[0], 'interiors') else 0
|
||||
|
||||
return {
|
||||
'height': round(float(height), 3),
|
||||
'area': round(float(combined.area), 2),
|
||||
'perimeter': round(float(combined.length), 2),
|
||||
'n_contours': len(polys),
|
||||
'n_holes': n_holes,
|
||||
'bounds': [round(float(x), 2) for x in combined.bounds],
|
||||
'width': round(float(combined.bounds[2] - combined.bounds[0]), 2),
|
||||
'height_2d': round(float(combined.bounds[3] - combined.bounds[1]), 2),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def detect_transitions(slices, threshold=0.1):
|
||||
"""Find heights where the cross-section changes significantly."""
|
||||
transitions = []
|
||||
for i in range(1, len(slices)):
|
||||
prev = slices[i - 1]
|
||||
curr = slices[i]
|
||||
if prev is None or curr is None:
|
||||
if prev is not None or curr is not None:
|
||||
transitions.append(i)
|
||||
continue
|
||||
|
||||
# Detect changes in area, contour count, or holes
|
||||
area_change = abs(curr['area'] - prev['area']) / max(prev['area'], 1)
|
||||
contour_change = curr['n_contours'] != prev['n_contours']
|
||||
hole_change = curr['n_holes'] != prev['n_holes']
|
||||
width_change = abs(curr['width'] - prev['width']) / max(prev['width'], 1)
|
||||
|
||||
if area_change > threshold or contour_change or hole_change or width_change > threshold:
|
||||
transitions.append(i)
|
||||
|
||||
return transitions
|
||||
|
||||
|
||||
def adaptive_slice_axis(mesh, axis, coarse_step=5.0, fine_step=0.5, fine_range=3.0, threshold=0.1):
|
||||
"""Adaptive slicing along one axis.
|
||||
|
||||
1. Coarse pass at coarse_step intervals
|
||||
2. Detect transitions
|
||||
3. Fine pass around each transition
|
||||
"""
|
||||
axis_names = ['X', 'Y', 'Z']
|
||||
lo, hi = mesh.bounds[0][axis], mesh.bounds[1][axis]
|
||||
extent = hi - lo
|
||||
|
||||
# Pass 1: Coarse
|
||||
coarse_heights = np.arange(lo + coarse_step / 2, hi, coarse_step)
|
||||
coarse_slices = []
|
||||
for h in coarse_heights:
|
||||
s = slice_at_height(mesh, axis, h)
|
||||
coarse_slices.append(s)
|
||||
|
||||
# Detect transitions
|
||||
trans_indices = detect_transitions(coarse_slices, threshold)
|
||||
transition_heights = [float(coarse_heights[i]) for i in trans_indices]
|
||||
|
||||
print(f" {axis_names[axis]}: {len(coarse_heights)} coarse slices, "
|
||||
f"{len(transition_heights)} transitions detected")
|
||||
|
||||
if transition_heights:
|
||||
zones = ", ".join(f"{h:.1f}" for h in transition_heights)
|
||||
print(f" Transition zones: {zones}")
|
||||
|
||||
# Pass 2: Fine slicing around transitions
|
||||
fine_slices = []
|
||||
fine_heights_set = set()
|
||||
for th in transition_heights:
|
||||
fine_lo = max(lo, th - fine_range)
|
||||
fine_hi = min(hi, th + fine_range)
|
||||
for h in np.arange(fine_lo, fine_hi, fine_step):
|
||||
h_round = round(float(h), 3)
|
||||
if h_round not in fine_heights_set:
|
||||
fine_heights_set.add(h_round)
|
||||
s = slice_at_height(mesh, axis, h_round)
|
||||
if s:
|
||||
fine_slices.append(s)
|
||||
|
||||
# Combine coarse + fine, sorted by height
|
||||
all_slices = []
|
||||
all_heights = set()
|
||||
|
||||
for s, h in zip(coarse_slices, coarse_heights):
|
||||
h_round = round(float(h), 3)
|
||||
if s and h_round not in all_heights:
|
||||
all_heights.add(h_round)
|
||||
all_slices.append(s)
|
||||
|
||||
for s in fine_slices:
|
||||
if s['height'] not in all_heights:
|
||||
all_heights.add(s['height'])
|
||||
all_slices.append(s)
|
||||
|
||||
all_slices.sort(key=lambda s: s['height'])
|
||||
|
||||
print(f" Total slices: {len(all_slices)} ({len(coarse_slices)} coarse + {len(fine_slices)} fine)")
|
||||
|
||||
return {
|
||||
'axis': axis_names[axis],
|
||||
'extent': round(float(extent), 3),
|
||||
'n_coarse': len(coarse_slices),
|
||||
'n_fine': len(fine_slices),
|
||||
'n_total': len(all_slices),
|
||||
'transitions': [round(h, 3) for h in transition_heights],
|
||||
'slices': all_slices,
|
||||
}
|
||||
|
||||
|
||||
def build_feature_map(axis_results):
|
||||
"""Build a unified feature map from all 3 axes."""
|
||||
features = []
|
||||
|
||||
for result in axis_results:
|
||||
axis = result['axis']
|
||||
slices = result['slices']
|
||||
transitions = result['transitions']
|
||||
|
||||
if not slices:
|
||||
continue
|
||||
|
||||
# Identify feature zones (between transitions)
|
||||
zones = []
|
||||
sorted_trans = sorted(transitions)
|
||||
|
||||
# Zone before first transition
|
||||
pre_slices = [s for s in slices if s['height'] < (sorted_trans[0] if sorted_trans else 1e6)]
|
||||
if pre_slices:
|
||||
avg_area = np.mean([s['area'] for s in pre_slices])
|
||||
avg_contours = round(np.mean([s['n_contours'] for s in pre_slices]))
|
||||
avg_holes = round(np.mean([s['n_holes'] for s in pre_slices]))
|
||||
zones.append({
|
||||
'axis': axis,
|
||||
'from': round(float(slices[0]['height']), 2),
|
||||
'to': round(float(sorted_trans[0]), 2) if sorted_trans else round(float(slices[-1]['height']), 2),
|
||||
'avg_area': round(float(avg_area), 1),
|
||||
'contours': int(avg_contours),
|
||||
'holes': int(avg_holes),
|
||||
'type': classify_zone(avg_area, avg_contours, avg_holes),
|
||||
})
|
||||
|
||||
# Zones between transitions
|
||||
for i in range(len(sorted_trans)):
|
||||
t_lo = sorted_trans[i]
|
||||
t_hi = sorted_trans[i + 1] if i + 1 < len(sorted_trans) else slices[-1]['height']
|
||||
zone_slices = [s for s in slices if t_lo <= s['height'] <= t_hi]
|
||||
if zone_slices:
|
||||
avg_area = np.mean([s['area'] for s in zone_slices])
|
||||
avg_contours = round(np.mean([s['n_contours'] for s in zone_slices]))
|
||||
avg_holes = round(np.mean([s['n_holes'] for s in zone_slices]))
|
||||
zones.append({
|
||||
'axis': axis,
|
||||
'from': round(float(t_lo), 2),
|
||||
'to': round(float(t_hi), 2),
|
||||
'avg_area': round(float(avg_area), 1),
|
||||
'contours': int(avg_contours),
|
||||
'holes': int(avg_holes),
|
||||
'type': classify_zone(avg_area, avg_contours, avg_holes),
|
||||
})
|
||||
|
||||
features.extend(zones)
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def classify_zone(area, contours, holes):
|
||||
"""Classify a zone based on its cross-section properties."""
|
||||
if contours == 1 and holes == 0:
|
||||
return 'solid'
|
||||
elif contours == 1 and holes > 0:
|
||||
return 'solid_with_holes'
|
||||
elif contours == 2 and holes == 0:
|
||||
return 'shell_or_channel'
|
||||
elif contours > 2:
|
||||
return 'multi_body'
|
||||
else:
|
||||
return 'complex'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Adaptive multi-axis STL slicing')
|
||||
parser.add_argument('stl_file', help='Input STL file')
|
||||
parser.add_argument('output_dir', help='Output directory')
|
||||
parser.add_argument('--coarse', type=float, default=5.0, help='Coarse step (mm)')
|
||||
parser.add_argument('--fine', type=float, default=0.5, help='Fine step (mm)')
|
||||
parser.add_argument('--range', type=float, default=3.0, help='Fine range around transitions (mm)')
|
||||
parser.add_argument('--threshold', type=float, default=0.1, help='Change threshold (0-1)')
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
|
||||
print(f"Loading: {args.stl_file}")
|
||||
mesh = trimesh.load_mesh(args.stl_file, force='mesh')
|
||||
trimesh.repair.fix_normals(mesh)
|
||||
|
||||
dims = mesh.extents
|
||||
print(f"Mesh: {len(mesh.vertices)} verts, {len(mesh.faces)} faces, vol={mesh.volume:.1f}mm³")
|
||||
print(f"Dimensions: {dims[0]:.1f} x {dims[1]:.1f} x {dims[2]:.1f} mm")
|
||||
print(f"Coarse: {args.coarse}mm, Fine: {args.fine}mm, Range: ±{args.range}mm")
|
||||
print()
|
||||
|
||||
# Slice on all 3 axes
|
||||
print("=== Adaptive Multi-Axis Slicing ===")
|
||||
axis_results = []
|
||||
for axis in range(3):
|
||||
result = adaptive_slice_axis(
|
||||
mesh, axis,
|
||||
coarse_step=args.coarse,
|
||||
fine_step=args.fine,
|
||||
fine_range=args.range,
|
||||
threshold=args.threshold,
|
||||
)
|
||||
axis_results.append(result)
|
||||
|
||||
# Build feature map
|
||||
print("\n=== Feature Map ===")
|
||||
features = build_feature_map(axis_results)
|
||||
for f in features:
|
||||
print(f" {f['axis']} [{f['from']:.1f} → {f['to']:.1f}]: "
|
||||
f"{f['type']} (area={f['avg_area']:.0f}, contours={f['contours']}, holes={f['holes']})")
|
||||
|
||||
# Summary
|
||||
print(f"\n=== Summary ===")
|
||||
total_slices = sum(r['n_total'] for r in axis_results)
|
||||
total_coarse = sum(r['n_coarse'] for r in axis_results)
|
||||
total_fine = sum(r['n_fine'] for r in axis_results)
|
||||
total_trans = sum(len(r['transitions']) for r in axis_results)
|
||||
print(f"Total slices: {total_slices} ({total_coarse} coarse + {total_fine} fine)")
|
||||
print(f"Transitions detected: {total_trans}")
|
||||
print(f"Feature zones: {len(features)}")
|
||||
|
||||
# Save results
|
||||
output = {
|
||||
'file': args.stl_file,
|
||||
'dimensions': dims.tolist(),
|
||||
'volume': float(mesh.volume),
|
||||
'settings': {
|
||||
'coarse_step': args.coarse,
|
||||
'fine_step': args.fine,
|
||||
'fine_range': args.range,
|
||||
'threshold': args.threshold,
|
||||
},
|
||||
'axes': [{
|
||||
'axis': r['axis'],
|
||||
'extent': r['extent'],
|
||||
'n_slices': r['n_total'],
|
||||
'transitions': r['transitions'],
|
||||
} for r in axis_results],
|
||||
'features': features,
|
||||
'total_slices': total_slices,
|
||||
'total_transitions': total_trans,
|
||||
}
|
||||
|
||||
output_path = os.path.join(args.output_dir, 'adaptive-slicing.json')
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(output, f, indent=2)
|
||||
print(f"\nResults saved to: {output_path}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
openscad-profile-extract.py — Extract 2D profiles from STL meshes
|
||||
|
||||
Detects the extrusion axis, extracts the dominant cross-section profile,
|
||||
and generates OpenSCAD polygon() + linear_extrude() code.
|
||||
|
||||
Usage:
|
||||
python3 openscad-profile-extract.py <file.stl> [--output <file.scad>] [--simplify <tolerance>]
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import trimesh
|
||||
from trimesh import intersections
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
|
||||
def segments_to_polygons(segments, snap=1e-4):
|
||||
"""Convert line segments from mesh slicing to polygons using Shapely."""
|
||||
from shapely.geometry import MultiLineString
|
||||
from shapely.ops import polygonize
|
||||
|
||||
lines = []
|
||||
for seg in np.asarray(segments):
|
||||
a = tuple(np.round(seg[0] / snap) * snap)
|
||||
b = tuple(np.round(seg[1] / snap) * snap)
|
||||
if a != b:
|
||||
lines.append([a, b])
|
||||
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
return [p for p in polygonize(MultiLineString(lines)) if p.area > snap * snap]
|
||||
|
||||
|
||||
def detect_extrusion_axis(mesh, n_slices=64):
|
||||
"""Find the best extrusion axis by measuring cross-section stability.
|
||||
|
||||
Returns: (stability_score, axis_index, transform, heights, polygons_per_slice)
|
||||
The axis with the LOWEST stability score is the extrusion direction.
|
||||
"""
|
||||
# Use OBB for canonical alignment
|
||||
try:
|
||||
T = np.linalg.inv(mesh.bounding_box_oriented.primitive.transform)
|
||||
except Exception:
|
||||
T = np.eye(4)
|
||||
|
||||
m = mesh.copy()
|
||||
m.apply_transform(T)
|
||||
|
||||
best = None
|
||||
axis_names = ['X', 'Y', 'Z']
|
||||
|
||||
for axis in range(3):
|
||||
lo, hi = m.bounds[0][axis], m.bounds[1][axis]
|
||||
margin = (hi - lo) * 0.02
|
||||
heights = np.linspace(lo + margin, hi - margin, n_slices)
|
||||
|
||||
try:
|
||||
segments, _, _ = intersections.mesh_multiplane(
|
||||
m,
|
||||
plane_origin=np.zeros(3),
|
||||
plane_normal=np.eye(3)[axis],
|
||||
heights=heights,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
desc = []
|
||||
polys_by_slice = []
|
||||
for segs in segments:
|
||||
polys = segments_to_polygons(segs)
|
||||
polys_by_slice.append(polys)
|
||||
if not polys:
|
||||
desc.append([0, 0, 0, 0])
|
||||
continue
|
||||
from shapely.ops import unary_union
|
||||
u = unary_union(polys)
|
||||
n_holes = sum(len(p.interiors) for p in polys)
|
||||
desc.append([u.area, u.length, n_holes, len(polys)])
|
||||
|
||||
if len([d for d in desc if d[0] > 0]) < n_slices // 3:
|
||||
continue
|
||||
|
||||
arr = np.asarray(desc, dtype=float)
|
||||
nonzero = arr[arr[:, 0] > 0]
|
||||
if len(nonzero) < 3:
|
||||
continue
|
||||
|
||||
# Stability = low variance in area/perimeter/holes across slices
|
||||
means = nonzero.mean(axis=0)
|
||||
means[means < 1e-6] = 1e-6
|
||||
stability = np.mean(np.std(nonzero / means, axis=0))
|
||||
|
||||
extent = hi - lo
|
||||
item = (stability, axis, T, heights, polys_by_slice, extent)
|
||||
if best is None or item[0] < best[0]:
|
||||
best = item
|
||||
|
||||
print(f" Axis {axis_names[axis]}: stability={stability:.4f}, "
|
||||
f"extent={extent:.1f}mm, non-empty={len(nonzero)}/{n_slices}")
|
||||
|
||||
return best
|
||||
|
||||
|
||||
def extract_dominant_profile(polys_by_slice, simplify_tol=0.1):
|
||||
"""Find the slice with the largest area and return its simplified polygon."""
|
||||
from shapely.ops import unary_union
|
||||
|
||||
best_area = 0
|
||||
best_poly = None
|
||||
best_idx = 0
|
||||
|
||||
for i, polys in enumerate(polys_by_slice):
|
||||
if not polys:
|
||||
continue
|
||||
u = unary_union(polys)
|
||||
if u.area > best_area:
|
||||
best_area = u.area
|
||||
best_poly = u
|
||||
best_idx = i
|
||||
|
||||
if best_poly is None:
|
||||
return None, 0
|
||||
|
||||
# Simplify to remove tessellation noise
|
||||
simplified = best_poly.simplify(simplify_tol, preserve_topology=True)
|
||||
return simplified, best_idx
|
||||
|
||||
|
||||
def polygon_to_scad(poly, var_name="profile"):
|
||||
"""Convert a Shapely polygon to OpenSCAD polygon() code."""
|
||||
points = []
|
||||
paths = []
|
||||
|
||||
# Outer boundary
|
||||
exterior_coords = np.asarray(poly.exterior.coords[:-1], dtype=float)
|
||||
start = 0
|
||||
for coord in exterior_coords:
|
||||
points.append(list(np.round(coord, 3)))
|
||||
paths.append(list(range(start, start + len(exterior_coords))))
|
||||
|
||||
# Holes (inner boundaries)
|
||||
for interior in poly.interiors:
|
||||
interior_coords = np.asarray(interior.coords[:-1], dtype=float)
|
||||
start = len(points)
|
||||
for coord in interior_coords:
|
||||
points.append(list(np.round(coord, 3)))
|
||||
# Reverse winding for holes
|
||||
paths.append(list(reversed(range(start, start + len(interior_coords)))))
|
||||
|
||||
# Format as OpenSCAD
|
||||
pts_str = ",\n ".join(f"[{p[0]}, {p[1]}]" for p in points)
|
||||
paths_str = ", ".join(f"{p}" for p in paths)
|
||||
|
||||
return f"""module {var_name}() {{
|
||||
polygon(
|
||||
points = [
|
||||
{pts_str}
|
||||
],
|
||||
paths = [{paths_str}]
|
||||
);
|
||||
}}"""
|
||||
|
||||
|
||||
def analyze_hole_variations(polys_by_slice, heights, dominant_idx):
|
||||
"""Detect features that vary along the extrusion axis (holes appearing/disappearing)."""
|
||||
from shapely.ops import unary_union
|
||||
|
||||
dominant_polys = polys_by_slice[dominant_idx]
|
||||
if not dominant_polys:
|
||||
return []
|
||||
|
||||
dom_union = unary_union(dominant_polys)
|
||||
dom_holes = sum(len(p.interiors) for p in dominant_polys)
|
||||
|
||||
variations = []
|
||||
for i, (polys, h) in enumerate(zip(polys_by_slice, heights)):
|
||||
if not polys:
|
||||
continue
|
||||
u = unary_union(polys)
|
||||
n_holes = sum(len(p.interiors) for p in polys)
|
||||
n_parts = len(polys)
|
||||
|
||||
if n_holes != dom_holes or n_parts != len(dominant_polys):
|
||||
variations.append({
|
||||
'height': round(float(h), 3),
|
||||
'slice_idx': i,
|
||||
'holes': n_holes,
|
||||
'parts': n_parts,
|
||||
'area': round(float(u.area), 2),
|
||||
'dom_holes': dom_holes,
|
||||
'dom_parts': len(dominant_polys),
|
||||
})
|
||||
|
||||
return variations
|
||||
|
||||
|
||||
def generate_scad(profile_poly, extrusion_length, axis, variations=None):
|
||||
"""Generate complete OpenSCAD file from extracted profile."""
|
||||
profile_code = polygon_to_scad(profile_poly, "extracted_profile")
|
||||
|
||||
# Determine extrusion direction
|
||||
axis_names = ['X', 'Y', 'Z']
|
||||
|
||||
scad = f"""// ============================================
|
||||
// Auto-generated from STL profile extraction
|
||||
// Extrusion axis: {axis_names[axis]}, length: {extrusion_length:.3f}mm
|
||||
// Profile points: {len(profile_poly.exterior.coords) - 1}
|
||||
// Profile holes: {len(profile_poly.interiors)}
|
||||
// ============================================
|
||||
|
||||
// --- Parameters ---
|
||||
extrusion_length = {extrusion_length:.3f};
|
||||
|
||||
// --- Quality ---
|
||||
$fn = 64;
|
||||
eps = 0.01;
|
||||
|
||||
// --- Extracted 2D Profile ---
|
||||
{profile_code}
|
||||
|
||||
// --- Main Body ---
|
||||
// Sculptor approach: extrude profile, then subtract features
|
||||
difference() {{
|
||||
// Primary body from extracted profile
|
||||
linear_extrude(height = extrusion_length)
|
||||
extracted_profile();
|
||||
|
||||
// TODO: Add subtractive features (holes, counterbores, chamfers)
|
||||
// detected from cross-section variations
|
||||
"""
|
||||
|
||||
if variations:
|
||||
scad += f"\n // Feature variations detected at {len(variations)} heights:\n"
|
||||
for v in variations[:10]: # Limit output
|
||||
scad += f" // Z={v['height']}: {v['holes']} holes, {v['parts']} parts (dominant: {v['dom_holes']} holes, {v['dom_parts']} parts)\n"
|
||||
|
||||
scad += "}\n"
|
||||
return scad
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Extract 2D profile from STL mesh')
|
||||
parser.add_argument('stl_file', help='Input STL file')
|
||||
parser.add_argument('--output', '-o', help='Output .scad file')
|
||||
parser.add_argument('--simplify', type=float, default=0.1,
|
||||
help='Profile simplification tolerance (mm)')
|
||||
parser.add_argument('--slices', type=int, default=64,
|
||||
help='Number of slices per axis')
|
||||
parser.add_argument('--json', help='Output analysis as JSON')
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Loading: {args.stl_file}")
|
||||
mesh = trimesh.load_mesh(args.stl_file, force='mesh')
|
||||
trimesh.repair.fix_normals(mesh)
|
||||
|
||||
print(f"Mesh: {len(mesh.vertices)} verts, {len(mesh.faces)} faces, "
|
||||
f"vol={mesh.volume:.1f}mm³")
|
||||
print(f"Bounds: {mesh.bounds[0].round(3)} to {mesh.bounds[1].round(3)}")
|
||||
dims = mesh.extents
|
||||
print(f"Dimensions: {dims[0]:.3f} x {dims[1]:.3f} x {dims[2]:.3f} mm")
|
||||
|
||||
print(f"\nDetecting extrusion axis ({args.slices} slices per axis)...")
|
||||
result = detect_extrusion_axis(mesh, n_slices=args.slices)
|
||||
|
||||
if result is None:
|
||||
print("ERROR: Could not detect extrusion axis")
|
||||
sys.exit(1)
|
||||
|
||||
stability, axis, T, heights, polys_by_slice, extent = result
|
||||
axis_names = ['X', 'Y', 'Z']
|
||||
print(f"\nBest extrusion axis: {axis_names[axis]} "
|
||||
f"(stability={stability:.4f}, extent={extent:.1f}mm)")
|
||||
|
||||
print("Extracting dominant profile...")
|
||||
profile, dom_idx = extract_dominant_profile(polys_by_slice, args.simplify)
|
||||
|
||||
if profile is None:
|
||||
print("ERROR: Could not extract profile")
|
||||
sys.exit(1)
|
||||
|
||||
n_pts = len(profile.exterior.coords) - 1
|
||||
n_holes = len(profile.interiors)
|
||||
print(f"Profile: {n_pts} points, {n_holes} holes, area={profile.area:.1f}mm²")
|
||||
|
||||
print("Analyzing feature variations along extrusion axis...")
|
||||
variations = analyze_hole_variations(polys_by_slice, heights, dom_idx)
|
||||
print(f"Found {len(variations)} slices with different features")
|
||||
|
||||
# Generate OpenSCAD
|
||||
scad_code = generate_scad(profile, extent, axis, variations)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, 'w') as f:
|
||||
f.write(scad_code)
|
||||
print(f"\nOpenSCAD saved to: {args.output}")
|
||||
else:
|
||||
print(f"\n--- Generated OpenSCAD ---")
|
||||
print(scad_code)
|
||||
|
||||
if args.json:
|
||||
analysis = {
|
||||
'extrusion_axis': axis_names[axis],
|
||||
'extrusion_length': round(float(extent), 3),
|
||||
'stability_score': round(float(stability), 4),
|
||||
'profile_points': n_pts,
|
||||
'profile_holes': n_holes,
|
||||
'profile_area': round(float(profile.area), 2),
|
||||
'feature_variations': variations[:20],
|
||||
}
|
||||
with open(args.json, 'w') as f:
|
||||
json.dump(analysis, f, indent=2)
|
||||
print(f"Analysis saved to: {args.json}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
# openscad-project.sh — Project scaffolding and management for OpenSCAD skill
|
||||
set -euo pipefail
|
||||
|
||||
PROJECTS_ROOT="$HOME/openscad-projects"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: openscad-project.sh <command> [args]
|
||||
|
||||
Commands:
|
||||
init <project-name> Create a new project directory
|
||||
list List all projects
|
||||
clean <project-name> Remove build artifacts (keep source)
|
||||
info <project-name> Show project info and file listing
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
cmd_init() {
|
||||
local name="$1"
|
||||
|
||||
# Sanitize project name to prevent directory traversal
|
||||
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
echo "ERROR: Project name must contain only letters, numbers, hyphens, and underscores." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local project_dir="$PROJECTS_ROOT/$name"
|
||||
|
||||
if [[ -d "$project_dir" ]]; then
|
||||
echo "Project already exists: $project_dir"
|
||||
echo "Use existing project or choose a different name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$project_dir"/{src,output,previews}
|
||||
|
||||
# Create a starter main.scad (name injected directly, no sed needed)
|
||||
cat > "$project_dir/src/main.scad" <<SCAD
|
||||
// ============================================
|
||||
// Project: $name
|
||||
// Description: TODO
|
||||
// Author: Claude Code + User
|
||||
// ============================================
|
||||
|
||||
// --- Parameters (user-configurable) ---
|
||||
width = 50; // [mm] overall width
|
||||
height = 30; // [mm] overall height
|
||||
depth = 20; // [mm] overall depth
|
||||
wall = 2.0; // [mm] wall thickness
|
||||
tolerance = 0.3; // [mm] printer tolerance
|
||||
|
||||
// --- Rendering quality ---
|
||||
\$fn = 64; // curve smoothness (use 128+ for final export)
|
||||
|
||||
// --- Derived dimensions ---
|
||||
inner_width = width - 2 * wall;
|
||||
inner_height = height - 2 * wall;
|
||||
inner_depth = depth - 2 * wall;
|
||||
|
||||
// --- Debug output ---
|
||||
echo(str("BBOX: ", width, " x ", depth, " x ", height, " mm"));
|
||||
echo(str("Wall: ", wall, " mm | Tolerance: ", tolerance, " mm"));
|
||||
|
||||
// --- Main Assembly ---
|
||||
// TODO: Replace with your design
|
||||
example();
|
||||
|
||||
module example() {
|
||||
difference() {
|
||||
cube([width, depth, height], center = true);
|
||||
translate([0, 0, wall])
|
||||
cube([inner_width, inner_depth, inner_height + wall], center = true);
|
||||
}
|
||||
}
|
||||
SCAD
|
||||
|
||||
# Create a project README
|
||||
cat > "$project_dir/README.md" <<EOF
|
||||
# $name
|
||||
|
||||
OpenSCAD project created $(date +%Y-%m-%d).
|
||||
|
||||
## Structure
|
||||
- \`src/\` — OpenSCAD source files (.scad)
|
||||
- \`output/\` — Exported STL, 3MF files
|
||||
- \`previews/\` — Rendered PNG previews
|
||||
|
||||
## Quick Commands
|
||||
\`\`\`bash
|
||||
# Preview
|
||||
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview src/main.scad
|
||||
|
||||
# Export STL
|
||||
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl src/main.scad
|
||||
|
||||
# With custom parameters
|
||||
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl src/main.scad -D 'width=60' -D 'height=40'
|
||||
\`\`\`
|
||||
EOF
|
||||
|
||||
echo "Project created: $project_dir"
|
||||
echo ""
|
||||
echo "Structure:"
|
||||
echo " $project_dir/"
|
||||
echo " ├── src/"
|
||||
echo " │ └── main.scad (starter template)"
|
||||
echo " ├── output/"
|
||||
echo " ├── previews/"
|
||||
echo " └── README.md"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
if [[ ! -d "$PROJECTS_ROOT" ]]; then
|
||||
echo "No projects directory found at $PROJECTS_ROOT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "OpenSCAD Projects in $PROJECTS_ROOT:"
|
||||
echo ""
|
||||
|
||||
for dir in "$PROJECTS_ROOT"/*/; do
|
||||
[[ ! -d "$dir" ]] && continue
|
||||
local name
|
||||
name="$(basename "$dir")"
|
||||
local scad_count
|
||||
scad_count=$(find "$dir/src" -name "*.scad" 2>/dev/null | wc -l | tr -d ' ')
|
||||
local stl_count
|
||||
stl_count=$(find "$dir/output" -name "*.stl" 2>/dev/null | wc -l | tr -d ' ')
|
||||
local preview_count
|
||||
preview_count=$(find "$dir/previews" -name "*.png" 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
echo " $name — ${scad_count} .scad, ${stl_count} .stl, ${preview_count} previews"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_clean() {
|
||||
local name="$1"
|
||||
local project_dir="$PROJECTS_ROOT/$name"
|
||||
|
||||
if [[ ! -d "$project_dir" ]]; then
|
||||
echo "Project not found: $name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "${project_dir:?}/output/"*
|
||||
rm -rf "${project_dir:?}/previews/"*
|
||||
echo "Cleaned build artifacts for: $name"
|
||||
}
|
||||
|
||||
cmd_info() {
|
||||
local name="$1"
|
||||
local project_dir="$PROJECTS_ROOT/$name"
|
||||
|
||||
if [[ ! -d "$project_dir" ]]; then
|
||||
echo "Project not found: $name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Project: $name"
|
||||
echo "Path: $project_dir"
|
||||
echo ""
|
||||
echo "Source files:"
|
||||
find "$project_dir/src" -name "*.scad" -exec echo " {}" \; 2>/dev/null
|
||||
echo ""
|
||||
echo "Exports:"
|
||||
find "$project_dir/output" -type f -exec echo " {}" \; 2>/dev/null || echo " (none)"
|
||||
echo ""
|
||||
echo "Previews:"
|
||||
find "$project_dir/previews" -name "*.png" -exec echo " {}" \; 2>/dev/null || echo " (none)"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
command="$1"
|
||||
shift
|
||||
|
||||
case "$command" in
|
||||
init) [[ $# -lt 1 ]] && usage; cmd_init "$1" ;;
|
||||
list) cmd_list ;;
|
||||
clean) [[ $# -lt 1 ]] && usage; cmd_clean "$1" ;;
|
||||
info) [[ $# -lt 1 ]] && usage; cmd_info "$1" ;;
|
||||
*) echo "Unknown command: $command"; usage ;;
|
||||
esac
|
||||
Executable
+397
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env bash
|
||||
# openscad-render.sh — Core render/export/preview engine for OpenSCAD skill
|
||||
set -euo pipefail
|
||||
|
||||
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
|
||||
IMGSIZE_PREVIEW="${OPENSCAD_IMGSIZE:-800,600}"
|
||||
IMGSIZE_HIRES="1600,1200"
|
||||
COLORSCHEME="${OPENSCAD_COLORSCHEME:-DeepOcean}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: openscad-render.sh <command> <file.scad> [options]
|
||||
|
||||
Commands:
|
||||
quick <file> Single isometric preview PNG
|
||||
preview <file> Multi-angle preview (4 views)
|
||||
stl <file> [-D ...] Export STL
|
||||
3mf <file> [-D ...] Export 3MF
|
||||
export <file> [-D ...] Export STL + 3MF + final PNG
|
||||
analyze <file> Render analysis views (cross-sections, bottom)
|
||||
custom <file> [options] Custom render with full control
|
||||
|
||||
Options (for custom):
|
||||
--format <ext> Output format (png, stl, 3mf, amf, svg, dxf, pdf)
|
||||
--imgsize <W,H> Image dimensions
|
||||
--camera <params> Camera: translate_x,y,z,rot_x,y,z,dist
|
||||
--colorscheme <name> Color scheme
|
||||
-D 'var=val' Parameter override (repeatable)
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Resolve output directory relative to .scad file
|
||||
get_project_dir() {
|
||||
local scad_file="$1"
|
||||
local scad_dir
|
||||
scad_dir="$(dirname "$(realpath "$scad_file")")"
|
||||
|
||||
# If inside a project structure (has src/ parent), go up
|
||||
if [[ "$(basename "$scad_dir")" == "src" ]]; then
|
||||
echo "$(dirname "$scad_dir")"
|
||||
else
|
||||
echo "$scad_dir"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
local project_dir="$1"
|
||||
mkdir -p "$project_dir/previews" "$project_dir/output"
|
||||
}
|
||||
|
||||
# Render with error handling
|
||||
do_render() {
|
||||
local scad_file="$1"
|
||||
shift
|
||||
local output=""
|
||||
local exit_code=0
|
||||
|
||||
output=$("$OPENSCAD" "$@" "$scad_file" 2>&1) || exit_code=$?
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo "ERROR: OpenSCAD render failed (exit code $exit_code)" >&2
|
||||
echo "$output" >&2
|
||||
# Extract specific error info
|
||||
if echo "$output" | grep -q "Parser error"; then
|
||||
echo "" >&2
|
||||
echo "SYNTAX ERROR detected. Check the .scad file at the reported line." >&2
|
||||
fi
|
||||
return $exit_code
|
||||
fi
|
||||
|
||||
# Check for warnings
|
||||
if echo "$output" | grep -qi "warning"; then
|
||||
echo "WARNINGS:" >&2
|
||||
echo "$output" | grep -i "warning" >&2
|
||||
fi
|
||||
|
||||
# Print render stats
|
||||
if echo "$output" | grep -q "rendering time"; then
|
||||
echo "$output" | grep "rendering time"
|
||||
fi
|
||||
if echo "$output" | grep -q "Facets:"; then
|
||||
echo "$output" | grep -E "(Facets|Vertices):"
|
||||
fi
|
||||
|
||||
echo "$output" | grep -v "^$" | tail -5
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_quick() {
|
||||
local scad_file="$1"
|
||||
shift
|
||||
local project_dir
|
||||
project_dir="$(get_project_dir "$scad_file")"
|
||||
ensure_dirs "$project_dir"
|
||||
|
||||
local out="$project_dir/previews/quick-preview.png"
|
||||
echo "Rendering quick preview..."
|
||||
do_render "$scad_file" \
|
||||
--autocenter --viewall \
|
||||
--imgsize="$IMGSIZE_PREVIEW" \
|
||||
--colorscheme="$COLORSCHEME" \
|
||||
--render \
|
||||
-o "$out" \
|
||||
"$@"
|
||||
|
||||
echo "Preview saved: $out"
|
||||
}
|
||||
|
||||
cmd_preview() {
|
||||
local scad_file="$1"
|
||||
shift
|
||||
local project_dir
|
||||
project_dir="$(get_project_dir "$scad_file")"
|
||||
ensure_dirs "$project_dir"
|
||||
|
||||
local timestamp
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
local preview_dir="$project_dir/previews"
|
||||
|
||||
# Define camera angles: name, camera_params or flags
|
||||
declare -A views
|
||||
views=(
|
||||
["1-isometric"]="--autocenter --viewall"
|
||||
["2-front"]="--autocenter --viewall --projection o --camera 0,0,0,90,0,0,0"
|
||||
["3-right"]="--autocenter --viewall --projection o --camera 0,0,0,90,0,90,0"
|
||||
["4-top"]="--autocenter --viewall --projection o --camera 0,0,0,0,0,0,0"
|
||||
)
|
||||
|
||||
local failed=0
|
||||
echo "Rendering 4-view preview..."
|
||||
for view_name in $(echo "${!views[@]}" | tr ' ' '\n' | sort); do
|
||||
local out="$preview_dir/${view_name}-${timestamp}.png"
|
||||
local camera_args="${views[$view_name]}"
|
||||
|
||||
echo " Rendering $view_name..."
|
||||
# shellcheck disable=SC2086
|
||||
if ! do_render "$scad_file" \
|
||||
$camera_args \
|
||||
--imgsize="$IMGSIZE_PREVIEW" \
|
||||
--colorscheme="$COLORSCHEME" \
|
||||
--render \
|
||||
-o "$out" \
|
||||
"$@"; then
|
||||
echo " FAILED: $view_name" >&2
|
||||
rm -f "$out"
|
||||
failed=1
|
||||
elif [[ ! -s "$out" ]]; then
|
||||
echo " ERROR: Empty render output: $out" >&2
|
||||
rm -f "$out"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Preview images saved in: $preview_dir/"
|
||||
ls -la "$preview_dir"/*-"${timestamp}".png 2>/dev/null
|
||||
}
|
||||
|
||||
cmd_stl() {
|
||||
local scad_file="$1"
|
||||
shift
|
||||
local project_dir
|
||||
project_dir="$(get_project_dir "$scad_file")"
|
||||
ensure_dirs "$project_dir"
|
||||
|
||||
local basename
|
||||
basename="$(basename "$scad_file" .scad)"
|
||||
local out="$project_dir/output/${basename}.stl"
|
||||
|
||||
echo "Exporting STL..."
|
||||
do_render "$scad_file" \
|
||||
--export-format binstl \
|
||||
-o "$out" \
|
||||
"$@"
|
||||
|
||||
local size
|
||||
size=$(wc -c < "$out" | tr -d ' ')
|
||||
echo "STL saved: $out ($size bytes)"
|
||||
}
|
||||
|
||||
cmd_3mf() {
|
||||
local scad_file="$1"
|
||||
shift
|
||||
local project_dir
|
||||
project_dir="$(get_project_dir "$scad_file")"
|
||||
ensure_dirs "$project_dir"
|
||||
|
||||
local basename
|
||||
basename="$(basename "$scad_file" .scad)"
|
||||
local out="$project_dir/output/${basename}.3mf"
|
||||
|
||||
echo "Exporting 3MF..."
|
||||
do_render "$scad_file" \
|
||||
-o "$out" \
|
||||
"$@"
|
||||
|
||||
local size
|
||||
size=$(wc -c < "$out" | tr -d ' ')
|
||||
echo "3MF saved: $out ($size bytes)"
|
||||
}
|
||||
|
||||
cmd_export() {
|
||||
local scad_file="$1"
|
||||
shift
|
||||
local project_dir
|
||||
project_dir="$(get_project_dir "$scad_file")"
|
||||
ensure_dirs "$project_dir"
|
||||
|
||||
local basename
|
||||
basename="$(basename "$scad_file" .scad)"
|
||||
|
||||
echo "=== Full Export ==="
|
||||
|
||||
# STL (binary)
|
||||
echo ""
|
||||
echo "--- STL ---"
|
||||
do_render "$scad_file" \
|
||||
--export-format binstl \
|
||||
-o "$project_dir/output/${basename}.stl" \
|
||||
"$@"
|
||||
echo "STL: $project_dir/output/${basename}.stl ($(wc -c < "$project_dir/output/${basename}.stl" | tr -d ' ') bytes)"
|
||||
|
||||
# 3MF
|
||||
echo ""
|
||||
echo "--- 3MF ---"
|
||||
do_render "$scad_file" \
|
||||
-o "$project_dir/output/${basename}.3mf" \
|
||||
"$@" || echo "3MF export failed (may not be supported in this version)"
|
||||
|
||||
# Final high-res preview
|
||||
echo ""
|
||||
echo "--- Final Preview ---"
|
||||
do_render "$scad_file" \
|
||||
--autocenter --viewall \
|
||||
--imgsize="$IMGSIZE_HIRES" \
|
||||
--colorscheme="$COLORSCHEME" \
|
||||
--render \
|
||||
-o "$project_dir/previews/final-preview.png" \
|
||||
"$@"
|
||||
|
||||
echo ""
|
||||
echo "=== Export Complete ==="
|
||||
echo "Output directory: $project_dir/output/"
|
||||
ls -la "$project_dir/output/"
|
||||
}
|
||||
|
||||
cmd_analyze() {
|
||||
local scad_file="$1"
|
||||
shift
|
||||
local project_dir
|
||||
project_dir="$(get_project_dir "$scad_file")"
|
||||
ensure_dirs "$project_dir"
|
||||
|
||||
local timestamp
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
echo "=== Design Analysis ==="
|
||||
|
||||
# Render STL to get geometry stats
|
||||
echo ""
|
||||
echo "--- Geometry Stats ---"
|
||||
local stl_output
|
||||
stl_output="$project_dir/output/analysis-temp.stl"
|
||||
do_render "$scad_file" \
|
||||
--export-format binstl \
|
||||
-o "$stl_output" \
|
||||
"$@" 2>&1
|
||||
|
||||
if [[ -f "$stl_output" ]]; then
|
||||
local stl_size
|
||||
stl_size=$(wc -c < "$stl_output" | tr -d ' ')
|
||||
echo "STL file size: $stl_size bytes"
|
||||
fi
|
||||
|
||||
# Capture echo output for debug info
|
||||
echo ""
|
||||
echo "--- Echo Output ---"
|
||||
do_render "$scad_file" \
|
||||
-o "$project_dir/output/analysis.echo" \
|
||||
"$@" 2>&1 || echo "Echo capture failed (non-critical)" >&2
|
||||
if [[ -f "$project_dir/output/analysis.echo" ]] && [[ -s "$project_dir/output/analysis.echo" ]]; then
|
||||
cat "$project_dir/output/analysis.echo"
|
||||
fi
|
||||
|
||||
# Bottom view (to check overhangs / first layer)
|
||||
local bottom_png="$project_dir/previews/analysis-bottom-${timestamp}.png"
|
||||
echo ""
|
||||
echo "--- Bottom View (check first layer / overhangs) ---"
|
||||
if ! do_render "$scad_file" \
|
||||
--autocenter --viewall \
|
||||
--camera 0,0,0,180,0,0,0 \
|
||||
--imgsize="$IMGSIZE_PREVIEW" \
|
||||
--colorscheme="$COLORSCHEME" \
|
||||
--render \
|
||||
-o "$bottom_png" \
|
||||
"$@"; then
|
||||
echo "WARNING: Bottom view render failed (may need OpenGL context)" >&2
|
||||
rm -f "$bottom_png"
|
||||
fi
|
||||
|
||||
# Isometric wireframe
|
||||
local wireframe_png="$project_dir/previews/analysis-wireframe-${timestamp}.png"
|
||||
echo ""
|
||||
echo "--- Wireframe View ---"
|
||||
if ! do_render "$scad_file" \
|
||||
--autocenter --viewall \
|
||||
--view edges \
|
||||
--imgsize="$IMGSIZE_PREVIEW" \
|
||||
--colorscheme="$COLORSCHEME" \
|
||||
--render \
|
||||
-o "$wireframe_png" \
|
||||
"$@"; then
|
||||
echo "WARNING: Wireframe render failed (may need OpenGL context)" >&2
|
||||
rm -f "$wireframe_png"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Analysis Complete ==="
|
||||
echo "Review images in: $project_dir/previews/"
|
||||
ls -la "$project_dir/previews"/analysis-*-"${timestamp}".png 2>/dev/null
|
||||
|
||||
# Cleanup temp
|
||||
rm -f "$stl_output"
|
||||
}
|
||||
|
||||
cmd_custom() {
|
||||
local scad_file="$1"
|
||||
shift
|
||||
|
||||
local format="png"
|
||||
local imgsize="$IMGSIZE_PREVIEW"
|
||||
local camera_args=""
|
||||
local colorscheme="$COLORSCHEME"
|
||||
local extra_args=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--format) format="$2"; shift 2 ;;
|
||||
--imgsize) imgsize="$2"; shift 2 ;;
|
||||
--camera) camera_args="--camera $2"; shift 2 ;;
|
||||
--colorscheme) colorscheme="$2"; shift 2 ;;
|
||||
-D) extra_args+=(-D "$2"); shift 2 ;;
|
||||
*) extra_args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
local project_dir
|
||||
project_dir="$(get_project_dir "$scad_file")"
|
||||
ensure_dirs "$project_dir"
|
||||
|
||||
local basename
|
||||
basename="$(basename "$scad_file" .scad)"
|
||||
local out="$project_dir/output/${basename}-custom.${format}"
|
||||
|
||||
local render_args=(
|
||||
--imgsize="$imgsize"
|
||||
--colorscheme="$colorscheme"
|
||||
--render
|
||||
-o "$out"
|
||||
)
|
||||
|
||||
if [[ -n "$camera_args" ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
render_args+=($camera_args)
|
||||
else
|
||||
render_args+=(--autocenter --viewall)
|
||||
fi
|
||||
|
||||
do_render "$scad_file" "${render_args[@]}" "${extra_args[@]}"
|
||||
echo "Output: $out"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
[[ $# -lt 2 ]] && usage
|
||||
|
||||
command="$1"
|
||||
scad_file="$2"
|
||||
shift 2
|
||||
|
||||
# Validate input file
|
||||
if [[ ! -f "$scad_file" ]]; then
|
||||
echo "ERROR: File not found: $scad_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$command" in
|
||||
quick) cmd_quick "$scad_file" "$@" ;;
|
||||
preview) cmd_preview "$scad_file" "$@" ;;
|
||||
stl) cmd_stl "$scad_file" "$@" ;;
|
||||
3mf) cmd_3mf "$scad_file" "$@" ;;
|
||||
export) cmd_export "$scad_file" "$@" ;;
|
||||
analyze) cmd_analyze "$scad_file" "$@" ;;
|
||||
custom) cmd_custom "$scad_file" "$@" ;;
|
||||
*) echo "Unknown command: $command"; usage ;;
|
||||
esac
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
#!/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}")
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
# openscad-stl-analyze.sh — Extract geometry data from binary STL files
|
||||
# Outputs bounding box, vertex distributions, and internal structure hints
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: openscad-stl-analyze.sh <file.stl> [--cross-section <axis> <value>] [--gaps <axis>]
|
||||
|
||||
Commands:
|
||||
<file.stl> Full bounding box and triangle count
|
||||
--cross-section <axis> <value> Show vertex distribution at a cross-section
|
||||
axis: x, y, or z; value: coordinate
|
||||
--gaps <axis> Find gaps in vertex distribution along axis
|
||||
(useful for finding internal features)
|
||||
|
||||
Examples:
|
||||
openscad-stl-analyze.sh model.stl
|
||||
openscad-stl-analyze.sh model.stl --cross-section z 0
|
||||
openscad-stl-analyze.sh model.stl --gaps y
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
STL_FILE="$1"
|
||||
shift
|
||||
|
||||
if [[ ! -f "$STL_FILE" ]]; then
|
||||
echo "ERROR: File not found: $STL_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Default: full analysis
|
||||
if [[ $# -eq 0 ]]; then
|
||||
python3 -c "
|
||||
import struct, math, sys
|
||||
from collections import defaultdict
|
||||
|
||||
path = '$STL_FILE'
|
||||
with open(path, 'rb') as f:
|
||||
header = f.read(80)
|
||||
n = struct.unpack('<I', f.read(4))[0]
|
||||
verts = set()
|
||||
for _ in range(n):
|
||||
f.read(12) # normal
|
||||
for _ in range(3):
|
||||
v = struct.unpack('<3f', f.read(12))
|
||||
verts.add((round(v[0],4), round(v[1],4), round(v[2],4)))
|
||||
f.read(2)
|
||||
|
||||
xs = [v[0] for v in verts]
|
||||
ys = [v[1] for v in verts]
|
||||
zs = [v[2] for v in verts]
|
||||
|
||||
print('=== STL Analysis ===')
|
||||
print(f'File: {path}')
|
||||
print(f'Triangles: {n}')
|
||||
print(f'Unique vertices: {len(verts)}')
|
||||
print()
|
||||
print('=== Bounding Box ===')
|
||||
print(f'X: {min(xs):.4f} to {max(xs):.4f} = {max(xs)-min(xs):.4f} mm')
|
||||
print(f'Y: {min(ys):.4f} to {max(ys):.4f} = {max(ys)-min(ys):.4f} mm')
|
||||
print(f'Z: {min(zs):.4f} to {max(zs):.4f} = {max(zs)-min(zs):.4f} mm')
|
||||
print(f'Center: ({(min(xs)+max(xs))/2:.4f}, {(min(ys)+max(ys))/2:.4f}, {(min(zs)+max(zs))/2:.4f})')
|
||||
print()
|
||||
print('=== Symmetry Check ===')
|
||||
cx, cy, cz = (min(xs)+max(xs))/2, (min(ys)+max(ys))/2, (min(zs)+max(zs))/2
|
||||
print(f'X symmetric: {abs(cx) < 0.01} (center offset: {cx:.4f})')
|
||||
print(f'Y symmetric: {abs(cy) < 0.01} (center offset: {cy:.4f})')
|
||||
print(f'Z base at 0: {abs(min(zs)) < 0.01}')
|
||||
print()
|
||||
print('=== Distinct Values Per Axis ===')
|
||||
print(f'Distinct X values: {len(set(round(x,3) for x in xs))}')
|
||||
print(f'Distinct Y values: {len(set(round(y,3) for y in ys))}')
|
||||
print(f'Distinct Z values: {len(set(round(z,3) for z in zs))}')
|
||||
print()
|
||||
|
||||
# Gap detection on each axis
|
||||
for axis_name, vals in [('X', xs), ('Y', ys), ('Z', zs)]:
|
||||
sorted_unique = sorted(set(round(v,3) for v in vals))
|
||||
gaps = []
|
||||
for i in range(len(sorted_unique)-1):
|
||||
gap = sorted_unique[i+1] - sorted_unique[i]
|
||||
if gap > (max(vals)-min(vals)) * 0.05: # gaps > 5% of range
|
||||
gaps.append((sorted_unique[i], sorted_unique[i+1], gap))
|
||||
if gaps:
|
||||
print(f'{axis_name}-axis gaps (>5% of range):')
|
||||
for v1, v2, g in gaps:
|
||||
print(f' {v1:.3f} to {v2:.3f} (gap={g:.3f} mm) — possible internal feature boundary')
|
||||
print()
|
||||
"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Cross-section analysis
|
||||
if [[ "$1" == "--cross-section" ]]; then
|
||||
[[ $# -lt 3 ]] && usage
|
||||
AXIS="$2"
|
||||
VALUE="$3"
|
||||
python3 -c "
|
||||
import struct
|
||||
|
||||
path = '$STL_FILE'
|
||||
axis = '$AXIS'
|
||||
value = float($VALUE)
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
f.read(80)
|
||||
n = struct.unpack('<I', f.read(4))[0]
|
||||
verts = set()
|
||||
for _ in range(n):
|
||||
f.read(12)
|
||||
for _ in range(3):
|
||||
v = struct.unpack('<3f', f.read(12))
|
||||
verts.add((round(v[0],4), round(v[1],4), round(v[2],4)))
|
||||
f.read(2)
|
||||
|
||||
axis_idx = {'x':0, 'y':1, 'z':2}[axis]
|
||||
other_axes = [i for i in range(3) if i != axis_idx]
|
||||
axis_names = 'XYZ'
|
||||
|
||||
# Find vertices near the cross-section plane
|
||||
tolerance = 0.02
|
||||
cross_verts = [(v[other_axes[0]], v[other_axes[1]])
|
||||
for v in verts if abs(v[axis_idx] - value) < tolerance]
|
||||
|
||||
if not cross_verts:
|
||||
# Widen tolerance
|
||||
tolerance = 0.2
|
||||
cross_verts = [(v[other_axes[0]], v[other_axes[1]])
|
||||
for v in verts if abs(v[axis_idx] - value) < tolerance]
|
||||
|
||||
print(f'=== Cross-section at {axis_names[axis_idx]}={value:.3f} (tol={tolerance}) ===')
|
||||
print(f'Found {len(cross_verts)} vertices')
|
||||
if cross_verts:
|
||||
a_vals = sorted(set(round(v[0],4) for v in cross_verts))
|
||||
b_vals = sorted(set(round(v[1],4) for v in cross_verts))
|
||||
print(f'{axis_names[other_axes[0]]} range: {min(a_vals):.4f} to {max(a_vals):.4f}')
|
||||
print(f'{axis_names[other_axes[1]]} range: {min(b_vals):.4f} to {max(b_vals):.4f}')
|
||||
print()
|
||||
|
||||
# Show |values| distribution for the narrower axis (usually reveals internal features)
|
||||
for oa, name in [(0, axis_names[other_axes[0]]), (1, axis_names[other_axes[1]])]:
|
||||
abs_vals = sorted(set(round(abs(v[oa]),4) for v in cross_verts))
|
||||
if len(abs_vals) < 80:
|
||||
print(f'|{name}| distribution:')
|
||||
for av in abs_vals:
|
||||
bar = '#' * int(av * 8)
|
||||
print(f' |{name}|={av:7.4f} {bar}')
|
||||
print()
|
||||
# Find gaps
|
||||
for i in range(len(abs_vals)-1):
|
||||
gap = abs_vals[i+1] - abs_vals[i]
|
||||
if gap > 0.5:
|
||||
print(f' GAP: |{name}|={abs_vals[i]:.4f} to |{name}|={abs_vals[i+1]:.4f} (width={gap:.4f})')
|
||||
print(f' -> Possible feature boundary at |{name}|={abs_vals[i]:.4f}')
|
||||
"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Gap analysis
|
||||
if [[ "$1" == "--gaps" ]]; then
|
||||
[[ $# -lt 2 ]] && usage
|
||||
AXIS="$2"
|
||||
python3 -c "
|
||||
import struct
|
||||
from collections import defaultdict
|
||||
|
||||
path = '$STL_FILE'
|
||||
axis = '$AXIS'
|
||||
axis_idx = {'x':0, 'y':1, 'z':2}[axis]
|
||||
axis_names = 'XYZ'
|
||||
|
||||
with open(path, 'rb') as f:
|
||||
f.read(80)
|
||||
n = struct.unpack('<I', f.read(4))[0]
|
||||
verts = set()
|
||||
for _ in range(n):
|
||||
f.read(12)
|
||||
for _ in range(3):
|
||||
v = struct.unpack('<3f', f.read(12))
|
||||
verts.add((round(v[0],4), round(v[1],4), round(v[2],4)))
|
||||
f.read(2)
|
||||
|
||||
# Get all distinct values for other axes, grouped by the target axis levels
|
||||
other_axes = [i for i in range(3) if i != axis_idx]
|
||||
|
||||
# Find distinct levels of the target axis
|
||||
levels = sorted(set(round(v[axis_idx], 3) for v in verts))
|
||||
print(f'=== {axis_names[axis_idx]}-axis gap analysis ===')
|
||||
print(f'Distinct {axis_names[axis_idx]} levels: {len(levels)}')
|
||||
print()
|
||||
|
||||
for level in levels:
|
||||
level_verts = [v for v in verts if abs(v[axis_idx] - level) < 0.005]
|
||||
for oa in other_axes:
|
||||
abs_vals = sorted(set(round(abs(v[oa]), 4) for v in level_verts))
|
||||
gaps = []
|
||||
for i in range(len(abs_vals)-1):
|
||||
g = abs_vals[i+1] - abs_vals[i]
|
||||
if g > 0.5:
|
||||
gaps.append((abs_vals[i], abs_vals[i+1], g))
|
||||
if gaps:
|
||||
print(f'{axis_names[axis_idx]}={level:6.3f}: |{axis_names[oa]}| gaps:')
|
||||
for v1, v2, g in gaps:
|
||||
print(f' {v1:.4f} to {v2:.4f} (gap={g:.4f}) — feature boundary')
|
||||
"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
usage
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env bash
|
||||
# openscad-stl-compare.sh — Compare two STL files geometrically
|
||||
# Uses OpenSCAD boolean difference + Python mesh distance analysis
|
||||
set -euo pipefail
|
||||
|
||||
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
|
||||
IMGSIZE="${OPENSCAD_IMGSIZE:-800,600}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: openscad-stl-compare.sh <original.stl> <reconstruction.stl> [output_dir]
|
||||
|
||||
Compares two STL files and reports:
|
||||
1. Bounding box comparison
|
||||
2. Volume/triangle count comparison
|
||||
3. Boolean difference renders (what's in A but not B, and vice versa)
|
||||
4. Point-to-mesh distance statistics (Hausdorff, RMS, mean)
|
||||
|
||||
Output:
|
||||
<output_dir>/diff-A-minus-B.png — Geometry in original but NOT in reconstruction
|
||||
<output_dir>/diff-B-minus-A.png — Geometry in reconstruction but NOT in original
|
||||
<output_dir>/overlay.png — Both overlaid (original=transparent, recon=red)
|
||||
<output_dir>/comparison-report.txt — Full numerical report
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -lt 2 ]] && usage
|
||||
|
||||
STL_A="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||
STL_B="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
|
||||
OUTDIR="${3:-/tmp/stl-compare-$(date +%s)}"
|
||||
|
||||
[[ -f "$STL_A" ]] || { echo "ERROR: File not found: $STL_A" >&2; exit 1; }
|
||||
[[ -f "$STL_B" ]] || { echo "ERROR: File not found: $STL_B" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
TMPDIR=$(mktemp -d /tmp/stl-compare-XXXXXX)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
echo "=== STL Mesh Comparison ==="
|
||||
echo "A (original): $STL_A"
|
||||
echo "B (reconstruction): $STL_B"
|
||||
echo "Output: $OUTDIR"
|
||||
echo ""
|
||||
|
||||
# --- Step 1: Bounding box + triangle comparison ---
|
||||
echo "--- Dimensional Comparison ---"
|
||||
python3 -c "
|
||||
import struct, sys
|
||||
|
||||
def parse_stl(path):
|
||||
with open(path, 'rb') as f:
|
||||
header = f.read(80)
|
||||
n = struct.unpack('<I', f.read(4))[0]
|
||||
mn = [float('inf')]*3
|
||||
mx = [float('-inf')]*3
|
||||
for _ in range(n):
|
||||
f.read(12)
|
||||
for _ in range(3):
|
||||
v = struct.unpack('<3f', f.read(12))
|
||||
for i in range(3):
|
||||
mn[i] = min(mn[i], v[i])
|
||||
mx[i] = max(mx[i], v[i])
|
||||
f.read(2)
|
||||
return n, mn, mx
|
||||
|
||||
n_a, mn_a, mx_a = parse_stl('$STL_A')
|
||||
n_b, mn_b, mx_b = parse_stl('$STL_B')
|
||||
|
||||
print(f' Original Reconstruction Delta')
|
||||
print(f'Triangles: {n_a:>8} {n_b:>8}')
|
||||
labels = ['X', 'Y', 'Z']
|
||||
total_delta = 0
|
||||
for i in range(3):
|
||||
da = mx_a[i] - mn_a[i]
|
||||
db = mx_b[i] - mn_b[i]
|
||||
delta = abs(db - da)
|
||||
total_delta += delta
|
||||
print(f'{labels[i]} dimension: {da:>12.4f} mm {db:>12.4f} mm {delta:>8.4f} mm')
|
||||
# Also check position offset
|
||||
ca = (mn_a[i] + mx_a[i]) / 2
|
||||
cb = (mn_b[i] + mx_b[i]) / 2
|
||||
if abs(ca - cb) > 0.01:
|
||||
print(f' Center offset: {abs(ca-cb):.4f} mm')
|
||||
|
||||
print(f'Total dim delta: {total_delta:>8.4f} mm')
|
||||
"
|
||||
|
||||
# --- Step 2: Boolean difference renders ---
|
||||
echo ""
|
||||
echo "--- Boolean Difference Renders ---"
|
||||
|
||||
# A - B: what's in original but not reconstruction
|
||||
cat > "$TMPDIR/diff-a-minus-b.scad" << SCAD
|
||||
difference() {
|
||||
import("$STL_A", convexity=10);
|
||||
import("$STL_B", convexity=10);
|
||||
}
|
||||
SCAD
|
||||
|
||||
# B - A: what's in reconstruction but not original
|
||||
cat > "$TMPDIR/diff-b-minus-a.scad" << SCAD
|
||||
difference() {
|
||||
import("$STL_B", convexity=10);
|
||||
import("$STL_A", convexity=10);
|
||||
}
|
||||
SCAD
|
||||
|
||||
# Overlay
|
||||
cat > "$TMPDIR/overlay.scad" << SCAD
|
||||
%import("$STL_A", convexity=10);
|
||||
color("red", 0.5) import("$STL_B", convexity=10);
|
||||
SCAD
|
||||
|
||||
# Render A-B
|
||||
echo " Rendering A-B (in original, missing from reconstruction)..."
|
||||
if "$OPENSCAD" --autocenter --viewall --imgsize="$IMGSIZE" --colorscheme=DeepOcean \
|
||||
--render -o "$OUTDIR/diff-A-minus-B.png" "$TMPDIR/diff-a-minus-b.scad" 2>"$TMPDIR/ab.log"; then
|
||||
# Check if the difference produced any geometry
|
||||
if grep -q "Top level object is a 3D object" "$TMPDIR/ab.log"; then
|
||||
facets=$(grep "Facets:" "$TMPDIR/ab.log" | tail -1 | grep -oE '[0-9]+')
|
||||
echo " Difference has $facets facets"
|
||||
fi
|
||||
# Also export the difference as STL for volume analysis
|
||||
"$OPENSCAD" --render --export-format binstl \
|
||||
-o "$TMPDIR/diff-ab.stl" "$TMPDIR/diff-a-minus-b.scad" 2>/dev/null || true
|
||||
else
|
||||
echo " Render failed" >&2
|
||||
rm -f "$OUTDIR/diff-A-minus-B.png"
|
||||
fi
|
||||
|
||||
# Render B-A
|
||||
echo " Rendering B-A (in reconstruction, not in original)..."
|
||||
if "$OPENSCAD" --autocenter --viewall --imgsize="$IMGSIZE" --colorscheme=DeepOcean \
|
||||
--render -o "$OUTDIR/diff-B-minus-A.png" "$TMPDIR/diff-b-minus-a.scad" 2>"$TMPDIR/ba.log"; then
|
||||
if grep -q "Top level object is a 3D object" "$TMPDIR/ba.log"; then
|
||||
facets=$(grep "Facets:" "$TMPDIR/ba.log" | tail -1 | grep -oE '[0-9]+')
|
||||
echo " Difference has $facets facets"
|
||||
fi
|
||||
"$OPENSCAD" --render --export-format binstl \
|
||||
-o "$TMPDIR/diff-ba.stl" "$TMPDIR/diff-b-minus-a.scad" 2>/dev/null || true
|
||||
else
|
||||
echo " Render failed" >&2
|
||||
rm -f "$OUTDIR/diff-B-minus-A.png"
|
||||
fi
|
||||
|
||||
# Render overlay
|
||||
echo " Rendering overlay..."
|
||||
"$OPENSCAD" --autocenter --viewall --imgsize="$IMGSIZE" --colorscheme=Cornfield \
|
||||
-o "$OUTDIR/overlay.png" "$TMPDIR/overlay.scad" 2>/dev/null || true
|
||||
|
||||
# --- Step 3: Volume analysis of differences ---
|
||||
echo ""
|
||||
echo "--- Difference Volume Analysis ---"
|
||||
python3 -c "
|
||||
import struct, os
|
||||
|
||||
def stl_volume(path):
|
||||
'''Calculate volume of a binary STL using signed tetrahedron method'''
|
||||
if not os.path.exists(path):
|
||||
return 0, 0
|
||||
with open(path, 'rb') as f:
|
||||
f.read(80)
|
||||
n = struct.unpack('<I', f.read(4))[0]
|
||||
if n == 0:
|
||||
return 0, 0
|
||||
volume = 0.0
|
||||
for _ in range(n):
|
||||
f.read(12) # normal
|
||||
v1 = struct.unpack('<3f', f.read(12))
|
||||
v2 = struct.unpack('<3f', f.read(12))
|
||||
v3 = struct.unpack('<3f', f.read(12))
|
||||
f.read(2)
|
||||
# Signed volume of tetrahedron with origin
|
||||
volume += (
|
||||
v1[0] * (v2[1]*v3[2] - v2[2]*v3[1]) +
|
||||
v1[1] * (v2[2]*v3[0] - v2[0]*v3[2]) +
|
||||
v1[2] * (v2[0]*v3[1] - v2[1]*v3[0])
|
||||
) / 6.0
|
||||
return n, abs(volume)
|
||||
|
||||
n_a, vol_a = stl_volume('$STL_A')
|
||||
n_b, vol_b = stl_volume('$STL_B')
|
||||
n_ab, vol_ab = stl_volume('$TMPDIR/diff-ab.stl')
|
||||
n_ba, vol_ba = stl_volume('$TMPDIR/diff-ba.stl')
|
||||
|
||||
print(f'Original volume: {vol_a:>12.2f} mm³')
|
||||
print(f'Reconstruction volume: {vol_b:>12.2f} mm³')
|
||||
print(f'Volume delta: {abs(vol_b - vol_a):>12.2f} mm³ ({abs(vol_b-vol_a)/max(vol_a,1)*100:.2f}%)')
|
||||
print()
|
||||
print(f'A-B (missing from recon): {vol_ab:>10.2f} mm³ ({n_ab} triangles)')
|
||||
print(f'B-A (extra in recon): {vol_ba:>10.2f} mm³ ({n_ba} triangles)')
|
||||
print()
|
||||
|
||||
total_error = vol_ab + vol_ba
|
||||
if vol_a > 0:
|
||||
accuracy = (1 - total_error / vol_a) * 100
|
||||
print(f'Total error volume: {total_error:>10.2f} mm³')
|
||||
print(f'Geometric accuracy: {accuracy:>10.2f}%')
|
||||
print()
|
||||
if accuracy > 99:
|
||||
print('Result: EXCELLENT match')
|
||||
elif accuracy > 95:
|
||||
print('Result: Good match (minor differences)')
|
||||
elif accuracy > 90:
|
||||
print('Result: Fair match (visible differences)')
|
||||
else:
|
||||
print('Result: Needs significant refinement')
|
||||
" 2>&1 || echo "Volume analysis failed (non-manifold geometry?)"
|
||||
|
||||
# --- Step 4: Summary ---
|
||||
echo ""
|
||||
echo "--- Output Files ---"
|
||||
ls -la "$OUTDIR"/*.png 2>/dev/null || echo "No PNG files generated"
|
||||
echo ""
|
||||
echo "View difference images to see WHERE the models differ."
|
||||
echo " diff-A-minus-B.png = geometry in original but MISSING from reconstruction"
|
||||
echo " diff-B-minus-A.png = EXTRA geometry in reconstruction not in original"
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# openscad-stl-reconstruct.sh — Automated STL analysis for reconstruction
|
||||
# Uses projection slicing, trimesh analysis, and normal-based CSG inference
|
||||
set -euo pipefail
|
||||
|
||||
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: openscad-stl-reconstruct.sh <file.stl> <output_dir>
|
||||
|
||||
Automated STL analysis pipeline:
|
||||
1. Bounding box + volume + basic mesh stats (via trimesh/admesh)
|
||||
2. 2D profile slices at multiple Z levels (via OpenSCAD projection)
|
||||
3. Normal analysis for CSG inference (cuts vs solid features)
|
||||
4. Primitive detection (RANSAC plane/cylinder fitting)
|
||||
5. Generates a decomposition report with OpenSCAD code suggestions
|
||||
|
||||
Output:
|
||||
<output_dir>/report.txt Full analysis report
|
||||
<output_dir>/slices/slice-z*.svg 2D profile SVGs at each Z level
|
||||
<output_dir>/slices/slice-z*.png Rendered slice images
|
||||
<output_dir>/primitives.json Detected primitives with parameters
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -lt 2 ]] && usage
|
||||
|
||||
STL_FILE="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||
OUTDIR="$2"
|
||||
|
||||
[[ -f "$STL_FILE" ]] || { echo "ERROR: File not found: $STL_FILE" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$OUTDIR/slices"
|
||||
|
||||
echo "=== STL Reconstruction Analysis ==="
|
||||
echo "Input: $STL_FILE"
|
||||
echo "Output: $OUTDIR"
|
||||
echo ""
|
||||
|
||||
# --- Step 1: Basic mesh analysis with trimesh ---
|
||||
echo "--- Step 1: Mesh Analysis ---"
|
||||
python3 << PYEOF
|
||||
import trimesh
|
||||
import numpy as np
|
||||
import json, os
|
||||
|
||||
mesh = trimesh.load_mesh('$STL_FILE', force='mesh')
|
||||
|
||||
print(f"Vertices: {len(mesh.vertices)}")
|
||||
print(f"Faces: {len(mesh.faces)}")
|
||||
print(f"Volume: {mesh.volume:.2f} mm³")
|
||||
print(f"Watertight: {mesh.is_watertight}")
|
||||
print(f"Bounding box: {mesh.bounds[0].round(3)} to {mesh.bounds[1].round(3)}")
|
||||
dims = mesh.bounds[1] - mesh.bounds[0]
|
||||
print(f"Dimensions: {dims[0]:.3f} x {dims[1]:.3f} x {dims[2]:.3f} mm")
|
||||
print(f"Center: {mesh.centroid.round(3)}")
|
||||
|
||||
# Identify the primary axes (longest = extrusion direction)
|
||||
axes = ['X', 'Y', 'Z']
|
||||
sorted_axes = sorted(range(3), key=lambda i: dims[i], reverse=True)
|
||||
print(f"Primary axis (longest): {axes[sorted_axes[0]]} ({dims[sorted_axes[0]]:.1f}mm)")
|
||||
print(f"Likely extrusion axis: {axes[sorted_axes[0]]}")
|
||||
|
||||
# Bounding cylinder
|
||||
try:
|
||||
cyl = trimesh.bounds.minimum_cylinder(mesh)
|
||||
print(f"Bounding cylinder: h={cyl['height']:.2f} r={cyl['radius']:.2f}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Face normal analysis — detect dominant orientations
|
||||
normals = mesh.face_normals
|
||||
# Cluster normals by major axis alignment
|
||||
for i, axis in enumerate(axes):
|
||||
pos = np.sum(normals[:, i] > 0.9)
|
||||
neg = np.sum(normals[:, i] < -0.9)
|
||||
if pos + neg > 0:
|
||||
print(f"Faces aligned with {axis}: +{pos} / -{neg}")
|
||||
|
||||
# Detect inward-facing curved surfaces (= cuts/holes)
|
||||
# Curved faces have normals NOT aligned with any axis
|
||||
non_planar = np.sum(np.all(np.abs(normals) < 0.9, axis=1))
|
||||
print(f"Curved/non-planar faces: {non_planar} ({non_planar/len(normals)*100:.0f}%)")
|
||||
|
||||
# Save basic info as JSON
|
||||
info = {
|
||||
'file': '$STL_FILE',
|
||||
'vertices': int(len(mesh.vertices)),
|
||||
'faces': int(len(mesh.faces)),
|
||||
'volume': float(mesh.volume),
|
||||
'watertight': bool(mesh.is_watertight),
|
||||
'bounds_min': mesh.bounds[0].tolist(),
|
||||
'bounds_max': mesh.bounds[1].tolist(),
|
||||
'dimensions': dims.tolist(),
|
||||
'center': mesh.centroid.tolist(),
|
||||
'primary_axis': axes[sorted_axes[0]],
|
||||
}
|
||||
with open('$OUTDIR/mesh-info.json', 'w') as f:
|
||||
json.dump(info, f, indent=2)
|
||||
|
||||
PYEOF
|
||||
|
||||
# --- Step 2: 2D Profile Slices via OpenSCAD projection ---
|
||||
echo ""
|
||||
echo "--- Step 2: 2D Profile Slices ---"
|
||||
|
||||
# Get Z range from mesh info
|
||||
Z_MIN=$(python3 -c "import json; d=json.load(open('$OUTDIR/mesh-info.json')); print(d['bounds_min'][2])")
|
||||
Z_MAX=$(python3 -c "import json; d=json.load(open('$OUTDIR/mesh-info.json')); print(d['bounds_max'][2])")
|
||||
Z_MID=$(python3 -c "print(($Z_MIN + $Z_MAX) / 2)")
|
||||
|
||||
echo "Z range: $Z_MIN to $Z_MAX (mid=$Z_MID)"
|
||||
|
||||
# Generate slices at key Z levels (bottom, 25%, 50%, 75%, top)
|
||||
for pct in 0.01 0.25 0.50 0.75 0.99; do
|
||||
Z_LEVEL=$(python3 -c "print(round($Z_MIN + ($Z_MAX - $Z_MIN) * $pct, 3))")
|
||||
SLICE_NAME="slice-z${Z_LEVEL}"
|
||||
|
||||
# Create OpenSCAD file for this slice
|
||||
cat > "/tmp/slice-${pct}.scad" << SCADEOF
|
||||
projection(cut=true)
|
||||
translate([0, 0, -${Z_LEVEL}])
|
||||
import("${STL_FILE}", convexity=10);
|
||||
SCADEOF
|
||||
|
||||
# Export as SVG
|
||||
if "$OPENSCAD" -o "$OUTDIR/slices/${SLICE_NAME}.svg" "/tmp/slice-${pct}.scad" 2>/dev/null; then
|
||||
SVG_SIZE=$(wc -c < "$OUTDIR/slices/${SLICE_NAME}.svg" | tr -d ' ')
|
||||
if [[ "$SVG_SIZE" -gt 200 ]]; then
|
||||
echo " Z=$Z_LEVEL (${pct}): SVG exported ($SVG_SIZE bytes)"
|
||||
else
|
||||
echo " Z=$Z_LEVEL (${pct}): Empty slice (no geometry at this Z)"
|
||||
rm -f "$OUTDIR/slices/${SLICE_NAME}.svg"
|
||||
fi
|
||||
else
|
||||
echo " Z=$Z_LEVEL (${pct}): Export failed"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Step 3: Primitive Detection with trimesh ---
|
||||
echo ""
|
||||
echo "--- Step 3: Primitive Detection ---"
|
||||
python3 -c "
|
||||
import trimesh
|
||||
import numpy as np
|
||||
import json
|
||||
|
||||
mesh = trimesh.load_mesh('$STL_FILE', force='mesh')
|
||||
primitives = []
|
||||
|
||||
# Planar facets
|
||||
try:
|
||||
for i, (facet, area) in enumerate(zip(mesh.facets, mesh.facets_area)):
|
||||
if area > 10:
|
||||
fn = mesh.face_normals[facet]
|
||||
avg_n = fn.mean(axis=0); avg_n /= np.linalg.norm(avg_n)
|
||||
fv = mesh.vertices[mesh.faces[facet].flatten()]
|
||||
primitives.append({'type':'plane','normal':avg_n.round(4).tolist(),'centroid':fv.mean(axis=0).round(3).tolist(),'area':round(float(area),2),'faces':len(facet)})
|
||||
except Exception as e:
|
||||
print(f'Facet error: {e}')
|
||||
|
||||
# Cylindrical surfaces
|
||||
normals = mesh.face_normals
|
||||
curved_mask = np.all(np.abs(normals) < 0.85, axis=1)
|
||||
curved_faces = np.where(curved_mask)[0]
|
||||
print(f'Planar facets: {len([p for p in primitives if p[\"type\"]==\"plane\"])} (>{\"10mm²\"})')
|
||||
print(f'Curved faces: {len(curved_faces)} ({len(curved_faces)/len(normals)*100:.0f}%)')
|
||||
|
||||
if len(curved_faces) > 10:
|
||||
cv = mesh.vertices[np.unique(mesh.faces[curved_faces].flatten())]
|
||||
cn = mesh.face_normals[curved_faces]
|
||||
# Axis from normal cross products
|
||||
axes_c = []
|
||||
for _ in range(min(200, len(cn))):
|
||||
i,j = np.random.choice(len(cn),2,replace=False)
|
||||
cp = np.cross(cn[i],cn[j])
|
||||
if np.linalg.norm(cp) > 0.1:
|
||||
cp /= np.linalg.norm(cp)
|
||||
if cp[0] < 0: cp *= -1
|
||||
axes_c.append(cp)
|
||||
if axes_c:
|
||||
ax = np.mean(axes_c, axis=0); ax /= np.linalg.norm(ax)
|
||||
# Project to 2D for circle fitting
|
||||
if abs(ax[0]) < 0.9: u = np.cross(ax,[1,0,0])
|
||||
else: u = np.cross(ax,[0,1,0])
|
||||
u /= np.linalg.norm(u); v = np.cross(ax,u)
|
||||
c3d = cv.mean(axis=0); centered = cv - c3d
|
||||
pu = centered@u; pv = centered@v; pa = centered@ax
|
||||
r = float(np.median(np.sqrt(pu**2+pv**2)))
|
||||
# Inward/outward
|
||||
fc = mesh.triangles_center[curved_faces]
|
||||
tc = c3d - fc; tc /= (np.linalg.norm(tc,axis=1,keepdims=True)+1e-10)
|
||||
inw = float(np.mean(np.sum(cn*tc,axis=1) > 0))
|
||||
op = 'difference' if inw > 0.5 else 'union'
|
||||
print(f'Cylinder: axis=[{ax[0]:.3f},{ax[1]:.3f},{ax[2]:.3f}] r={r:.3f} d={r*2:.3f} center={c3d.round(2).tolist()} type={op}')
|
||||
print(f' Axis span: {float(pa.min()):.2f} to {float(pa.max()):.2f}')
|
||||
primitives.append({'type':'cylinder','axis':ax.round(4).tolist(),'radius':round(r,3),'diameter':round(r*2,3),'center':c3d.round(3).tolist(),'axis_min':round(float(pa.min()),3),'axis_max':round(float(pa.max()),3),'csg_operation':op,'inward_ratio':round(inw,3),'faces':int(len(curved_faces))})
|
||||
|
||||
with open('$OUTDIR/primitives.json','w') as f:
|
||||
json.dump(primitives,f,indent=2)
|
||||
print(f'Saved {len(primitives)} primitives to $OUTDIR/primitives.json')
|
||||
"
|
||||
|
||||
# --- Step 4: Generate Report ---
|
||||
echo ""
|
||||
echo "--- Step 4: Report ---"
|
||||
echo "Profile slices saved in: $OUTDIR/slices/"
|
||||
ls -la "$OUTDIR/slices/"*.svg 2>/dev/null | wc -l | xargs -I{} echo " {} SVG slices generated"
|
||||
echo "Primitives saved to: $OUTDIR/primitives.json"
|
||||
echo "Mesh info saved to: $OUTDIR/mesh-info.json"
|
||||
echo ""
|
||||
echo "=== Analysis Complete ==="
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# openscad-validate.sh — Validate .scad files with structured error output
|
||||
# Runs OpenSCAD in strict mode and parses errors into actionable categories
|
||||
set -euo pipefail
|
||||
|
||||
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
|
||||
|
||||
usage() {
|
||||
echo "Usage: openscad-validate.sh <file.scad> [-D 'var=val' ...]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
scad_file="$1"
|
||||
shift
|
||||
|
||||
if [[ ! -f "$scad_file" ]]; then
|
||||
echo "ERROR: File not found: $scad_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpdir=$(mktemp -d /tmp/openscad-validate-XXXXXX)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
stl_out="$tmpdir/check.stl"
|
||||
echo_out="$tmpdir/check.echo"
|
||||
|
||||
# Run OpenSCAD in strict mode
|
||||
output=$("$OPENSCAD" \
|
||||
--check-parameters=true \
|
||||
--check-parameter-ranges=true \
|
||||
--hardwarnings \
|
||||
-o "$stl_out" \
|
||||
-o "$echo_out" \
|
||||
"$@" \
|
||||
"$scad_file" 2>&1) || exit_code=$?
|
||||
|
||||
exit_code="${exit_code:-0}"
|
||||
|
||||
echo "=== Validation Report ==="
|
||||
echo "File: $scad_file"
|
||||
echo "Exit code: $exit_code"
|
||||
|
||||
# Categorize errors
|
||||
if echo "$output" | grep -q "Parser error"; then
|
||||
echo "Category: SYNTAX_ERROR"
|
||||
echo "$output" | grep "ERROR:" | head -5
|
||||
echo ""
|
||||
# Extract line number
|
||||
line=$(echo "$output" | sed -n 's/.*line \([0-9]*\).*/\1/p' | head -1)
|
||||
if [[ -n "$line" ]]; then
|
||||
echo "Error at line $line. Context:"
|
||||
sed -n "$((line > 3 ? line - 3 : 1)),${line}p" "$scad_file" 2>/dev/null | cat -n
|
||||
fi
|
||||
elif echo "$output" | grep -q "Current top level object is empty"; then
|
||||
echo "Category: EMPTY_MODEL"
|
||||
echo "The model produces no geometry. Check:"
|
||||
echo " - Are modules being called?"
|
||||
echo " - Did a difference() remove everything?"
|
||||
echo " - Are parameter values valid?"
|
||||
elif echo "$output" | grep -q "NSOpenGLContext\|GLX\|Unable to create"; then
|
||||
echo "Category: HEADLESS_PREVIEW"
|
||||
echo "PNG preview unavailable (no OpenGL context)."
|
||||
echo "STL export should still work."
|
||||
elif echo "$output" | grep -qi "warning"; then
|
||||
echo "Category: WARNING"
|
||||
echo "$output" | grep -i "warning" | head -10
|
||||
else
|
||||
echo "Category: OK"
|
||||
fi
|
||||
|
||||
# Show echo output if present
|
||||
if [[ -f "$echo_out" ]] && [[ -s "$echo_out" ]]; then
|
||||
echo ""
|
||||
echo "=== Echo Output ==="
|
||||
cat "$echo_out"
|
||||
fi
|
||||
|
||||
# Show geometry stats if STL was produced
|
||||
if [[ -f "$stl_out" ]]; then
|
||||
stl_size=$(wc -c < "$stl_out" | tr -d ' ')
|
||||
if [[ "$stl_size" -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "=== Geometry ==="
|
||||
echo "STL size: $stl_size bytes"
|
||||
# Extract stats from render output
|
||||
echo "$output" | grep -E "(Facets|Vertices|rendering time|Simple):" | head -10
|
||||
fi
|
||||
fi
|
||||
|
||||
exit "$exit_code"
|
||||
Reference in New Issue
Block a user