feat(skills): OpenSCAD-Skill hinzufuegen

This commit is contained in:
2026-07-15 14:30:52 +02:00
parent 316b779b06
commit c6bbebea04
16 changed files with 4271 additions and 0 deletions
+320
View File
@@ -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
View File
@@ -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()
+187
View File
@@ -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
+397
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ==="
+92
View File
@@ -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"