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
+278
View File
@@ -0,0 +1,278 @@
# OpenSCAD Claude Code Skill
A Claude Code skill for programmatic 3D CAD using [OpenSCAD](https://openscad.org/). Design, preview, iterate, reconstruct from STL, and export 3D-printable models — with AI-driven visual feedback and automated mesh comparison.
## Features
- **Programmatic 3D modeling** — Generate `.scad` files from natural language descriptions
- **AI vision feedback loop** — Render multi-angle PNG previews and analyze them to iteratively refine designs
- **STL-to-SCAD reconstruction** — Reverse-engineer STL meshes into parametric OpenSCAD code using SVG profiling and SDF optimization
- **Image-to-CAD replication** — Reproduce physical objects from reference photos
- **Parametric design** — All dimensions as variables, overridable via CLI `-D` flags
- **Mesh comparison** — Boolean diff rendering and IoU scoring to verify reconstruction accuracy
- **Printability analysis** — Wall thickness, overhang, and manifold validation
- **Reusable module library** — Common 3D printing patterns (counterbores, heat-set bosses, ribs, snap-fits)
- **Eval framework** — Automated testing with 20 binary assertions across 4 scenarios (100% pass rate)
## Prerequisites
- **OpenSCAD** installed and accessible via CLI
```bash
brew install openscad # macOS
```
- **Python packages** (for STL reconstruction):
```bash
pip3 install trimesh numpy scipy rtree shapely
```
- **admesh** (for mesh validation):
```bash
brew install admesh
```
- **Claude Code** CLI installed
## Installation
### Option 1: Clone and symlink (recommended)
```bash
git clone https://github.com/andreahaku/openscad_claude_skill.git ~/Development/Claude/openscad_claude_skill
mkdir -p ~/.claude/skills
ln -sf ~/Development/Claude/openscad_claude_skill ~/.claude/skills/openscad
```
### Option 2: Direct clone into skills
```bash
git clone https://github.com/andreahaku/openscad_claude_skill.git ~/.claude/skills/openscad
```
### Verify installation
```bash
openscad --version
bash ~/.claude/skills/openscad/scripts/openscad-render.sh quick ~/.claude/skills/openscad/templates/bracket.scad
```
## Usage
The skill triggers automatically when you mention 3D modeling, CAD, STL export, or OpenSCAD:
```
/openscad design a phone stand with adjustable angle
```
### Modes
| Mode | Trigger | Description |
|------|---------|-------------|
| **Design** | "design a bracket", "make a box" | Create new 3D models from descriptions |
| **Replicate** | "reproduce this from the photo" | Reverse-engineer objects from reference images |
| **Reconstruct** | "convert this STL to SCAD" | Reverse-engineer STL meshes into parametric code |
| **Refine** | "make it taller", "add fillets" | Iterate on existing designs |
| **Export** | "export STL", "ready for printing" | Generate production files |
| **Analyze** | "check printability" | Validate designs for 3D printing |
### Example Workflows
**Design from scratch:**
```
> Design a parametric enclosure for a Raspberry Pi 4 with ventilation slots
```
**Reconstruct from STL:**
```
> Convert /path/to/model.stl into parametric OpenSCAD code
```
**Export with parameter overrides:**
```
> Export the bracket with width=100 and wall=3
```
## Project Structure
```
openscad_claude_skill/
├── SKILL.md # Skill definition (6 modes, 762 lines)
├── README.md # This file
├── scripts/
│ ├── openscad-render.sh # Core render/export/preview engine (7 commands)
│ ├── openscad-project.sh # Project scaffolding (init/list/clean/info)
│ ├── openscad-validate.sh # Strict validation with error categorization
│ ├── openscad-stl-analyze.sh # STL mesh analysis (bbox, cross-sections, gaps)
│ ├── openscad-stl-reconstruct.sh # Automated reconstruction pipeline (SVG profiling)
│ ├── openscad-stl-compare.sh # Mesh comparison (boolean diff, accuracy %)
│ └── openscad-sdf-optimize.py # SDF parameter optimizer (IoU scoring)
├── references/
│ ├── language-reference.md # Complete OpenSCAD v2021.01 cheat sheet
│ └── reconstruction-guide.md # Best practices for STL-to-SCAD reconstruction
├── templates/
│ ├── enclosure.scad # Parametric electronics box with lid
│ ├── bracket.scad # L-bracket with countersunk holes
│ └── printable-lib.scad # Reusable 3D printing modules
└── eval/
├── eval.json # 4 test scenarios, 20 binary assertions
└── results.jsonl # Test results log
```
## Scripts
### `openscad-render.sh` — Core Rendering Engine
```bash
bash scripts/openscad-render.sh quick <file.scad> # Single isometric preview
bash scripts/openscad-render.sh preview <file.scad> # 4-view (iso, front, right, top)
bash scripts/openscad-render.sh stl <file.scad> [-D ...] # Export STL
bash scripts/openscad-render.sh 3mf <file.scad> # Export 3MF
bash scripts/openscad-render.sh export <file.scad> # Full export (STL + 3MF + PNG)
bash scripts/openscad-render.sh analyze <file.scad> # Printability analysis
bash scripts/openscad-render.sh custom <file.scad> [opts] # Custom render
```
### `openscad-stl-reconstruct.sh` — Automated STL Analysis
```bash
bash scripts/openscad-stl-reconstruct.sh model.stl output_dir/
```
Runs the full analysis pipeline:
1. **trimesh** mesh analysis (volume, watertight, normals, bounding cylinder)
2. **SVG profiling** via OpenSCAD `projection(cut=true)` at 5 Z levels
3. **Primitive detection** (RANSAC axis estimation, normal-based CSG inference)
Output: `mesh-info.json`, `primitives.json`, SVG slice files
### `openscad-stl-compare.sh` — Mesh Comparison
```bash
bash scripts/openscad-stl-compare.sh original.stl reconstruction.stl output_dir/
```
Produces:
- **diff-A-minus-B.png** — Geometry in original but MISSING from reconstruction
- **diff-B-minus-A.png** — EXTRA geometry in reconstruction
- **overlay.png** — Both models overlaid
- **Geometric accuracy %** — Based on volume of boolean differences
### `openscad-sdf-optimize.py` — Parameter Optimizer
```bash
python3 scripts/openscad-sdf-optimize.py model.stl stadium-slot --verbose
```
Uses Signed Distance Fields + IoU scoring to find optimal parameters without invoking OpenSCAD in the loop. Converges in seconds via `scipy.optimize.minimize`.
### `openscad-stl-analyze.sh` — Raw Mesh Analysis
```bash
bash scripts/openscad-stl-analyze.sh model.stl # Full analysis
bash scripts/openscad-stl-analyze.sh model.stl --cross-section z 5.0 # Slice at Z=5
bash scripts/openscad-stl-analyze.sh model.stl --gaps y # Find Y-axis gaps
```
## STL-to-SCAD Reconstruction
The reconstruction pipeline converts triangle meshes into clean, parametric OpenSCAD code:
### The Sculptor Approach
All reconstructions follow the sculptor method — start from a solid block, subtract everything:
```openscad
difference() {
solid_body(); // 1. Full solid first
channels(); // 2. Subtract channels/slots
taper_cuts(); // 3. Subtract wedges
all_holes(); // 4. ALL holes LAST
}
```
### Two-Tier Pipeline
1. **SVG Profile Analysis** (fast) — `openscad-stl-reconstruct.sh` identifies the model topology by slicing at multiple Z levels
2. **SDF Optimization** (precise) — `openscad-sdf-optimize.py` finds exact parameters via IoU scoring
### Proven Results
| Model | Complexity | Accuracy | Iterations |
|-------|-----------|----------|------------|
| Toothpaste Squeezer | Simple (1180 tri) | 96.27% | 2 |
| Interior Bracket | Complex (7576 tri) | 95.63% | 8 |
See `references/reconstruction-guide.md` for the complete best practices guide.
## Templates
### `printable-lib.scad` — Reusable Modules
```openscad
use <printable-lib.scad>
shell_box(outer=[60,40,20], wall=2, floor=2);
rounded_box([50,30,10], r=3);
screw_clearance_hole(d=3, h=10, fit="close");
counterbore_hole(shaft_d=3, head_d=6, head_h=3, h=12);
countersink_hole(d=3, cs_d=6, cs_h=2, h=10);
heatset_boss(insert_d=4.6, insert_h=5, wall=2, h=8);
screw_post(outer_d=7, inner_d=3, h=10);
rib(len=20, height=12, thick=2);
snap_tab(width=8, length=6, thick=1.5, overhang=0.8);
text_label("Hello", size=8, depth=1);
fit_clearance("press"); // 0.15mm
fit_clearance("close"); // 0.25mm
fit_clearance("slide"); // 0.30mm
fit_clearance("loose"); // 0.40mm
```
## 3D Printing Guidelines
- **Wall thickness**: min 1.2mm (FDM with 0.4mm nozzle)
- **Clearance**: 0.2-0.3mm for fitting parts
- **Overhangs**: < 45° from vertical; prefer chamfers on downward faces
- **Epsilon** (`eps = 0.01`) in all boolean operations
- **Flat bottoms** for bed adhesion
- **`assert()`** for self-validating parametric models
- **Counterbore vs countersink**: always check reference images
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENSCAD_BIN` | `$(command -v openscad)` | Path to OpenSCAD binary |
| `OPENSCAD_IMGSIZE` | `800,600` | Default preview image size |
| `OPENSCAD_COLORSCHEME` | `DeepOcean` | Default color scheme |
## Eval Framework
The skill includes automated testing:
```bash
ls eval/
# eval.json — 4 test scenarios, 20 binary assertions
# results.jsonl — Test results log
```
**Baseline: 100% pass rate (20/20)** across:
- Design (simple box + sculptor approach)
- STL reconstruction (96.27% geometric accuracy)
- Parametric export with -D overrides
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make changes
4. Run: `bash scripts/openscad-render.sh quick templates/bracket.scad`
5. Submit a pull request
## License
MIT
## Credits
Built with Claude Code (Anthropic), with architectural input from Gemini (Google) and Codex (OpenAI).
Powered by [OpenSCAD](https://openscad.org/) — The Programmers Solid 3D CAD Modeller.
+917
View File
@@ -0,0 +1,917 @@
---
disable-model-invocation: true
name: openscad
description: >
Programmatic 3D CAD with OpenSCAD. Generate .scad files, render STL for 3D printing,
preview as PNG with AI vision feedback. Triggers on: 3D model, STL, 3D print, parametric
design, openscad, CAD, enclosure, bracket, or any 3D modeling task.
argument-hint: "<description of object to design or path to existing .scad file>"
allowed-tools: "Bash(*),Read,Edit,Write,Glob,Grep,Agent"
metadata:
version: 1.0.0
category: 3d-cad
tags: [openscad, 3d-printing, cad, parametric, stl, modeling, design]
---
# OpenSCAD Skill
Design, render, preview, and export 3D models using OpenSCAD's programmatic CAD engine. Supports iterative AI-driven design refinement via rendered PNG analysis.
## Environment
- **OpenSCAD binary**: `/opt/homebrew/bin/openscad` (v2021.01)
- **Working directory for designs**: `~/openscad-projects/` (create per-project subdirectories)
- **Skill scripts**: `~/.claude/skills/openscad/scripts/`
- **Templates**: `~/.claude/skills/openscad/templates/`
- **Language reference**: `~/.claude/skills/openscad/references/`
## Modes
The skill operates in six modes, auto-detected from the user's request:
- **Design** — Create a new 3D model from a description
- **Replicate** — Reproduce a physical object from reference images
- **Reconstruct** — Reverse-engineer an STL mesh into parametric OpenSCAD code
- **Refine** — Iterate on an existing .scad file (modify, preview, repeat)
- **Export** — Render final STL/3MF for 3D printing
- **Analyze** — Review an existing design for printability or improvements
---
## Workflow: Design Mode
When the user asks to create a new 3D object:
### Step 1: Understand Requirements
Clarify with the user:
- **What** is the object? (enclosure, bracket, gear, container, etc.)
- **Dimensions** — key measurements in mm
- **Purpose** — functional print, aesthetic, mechanical fit?
- **Constraints** — printer bed size, material, wall thickness preferences
- **Parametric?** — which dimensions should be adjustable?
### Step 2: Set Up Project
```bash
bash ~/.claude/skills/openscad/scripts/openscad-project.sh init "<project-name>"
```
This creates `~/openscad-projects/<project-name>/` with subdirectories for source, output, and previews.
### Step 3: Generate the .scad File
Write the OpenSCAD code to `~/openscad-projects/<project-name>/src/main.scad`.
**Mandatory file structure (Feature Tree pattern):**
```openscad
// 1. PARAMETERS (independent variables)
width = 60; height = 30; wall = 2;
// 2. DERIVED DIMENSIONS (calculated from parameters)
inner_width = width - 2 * wall;
// 3. BASE PROFILE (2D sketch — the core shape)
module sketch_base() {
offset(r = corner_r)
square([width - 2*corner_r, depth - 2*corner_r], center=true);
}
// 4. PRIMARY BODY (extrude the sketch)
module body() { linear_extrude(height = height) sketch_base(); }
// 5. ADDITIVE FEATURES (bosses, ribs, tabs)
module features_add() { ... }
// 6. SUBTRACTIVE FEATURES (holes, slots, pockets — ALWAYS LAST)
module features_cut() { ... }
// 7. ASSEMBLY (the Feature Tree)
difference() {
union() { body(); features_add(); }
features_cut();
}
```
**Profile-first design rules:**
- Prefer `polygon()` + `linear_extrude()` over `hull()` of 3D primitives
- Use `offset(r=radius)` for corner rounding instead of `hull()` with cylinders
- Use `rotate_extrude()` for axially symmetric parts (never stack cylinders)
- Define dimensions relative to edges/features, not absolute coordinates: `hole_x = total_length - edge_margin` (not magic numbers)
- Cascade tolerances from a single `fit_clearance` parameter
**Critical rules for generating OpenSCAD code:**
- Read `~/.claude/skills/openscad/references/language-reference.md` if unsure about syntax
- Always define parametric dimensions as variables at the top of the file
- Use `$fn = 64;` for smooth curves (or higher for final renders)
- Add comments explaining each section
- Use modules for reusable parts
- Keep wall thickness >= 1.2mm for FDM printing
- Design with the print orientation in mind (flat bottom, minimal overhangs)
### Step 4: Preview
Render a multi-angle PNG preview:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview ~/openscad-projects/<project-name>/src/main.scad
```
This generates 4 preview images (front, side, top, isometric) in the project's `previews/` directory.
### Step 5: Analyze Preview
Read each preview PNG using the Read tool to see the rendered object. Evaluate:
- Does the shape match the user's description?
- Are proportions correct?
- Are there visible artifacts or unintended geometry?
- Would this print well? (overhangs, bridging, thin walls)
Report findings to the user with the preview images.
### Step 6: Iterate
If changes are needed, edit the .scad file and re-render. Repeat Steps 4-5 until the user is satisfied. Each iteration should be targeted — change one aspect at a time.
### Step 7: Export
When the design is approved:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh export ~/openscad-projects/<project-name>/src/main.scad
```
This produces:
- `output/model.stl` — for slicing and printing
- `output/model.3mf` — alternative format (better metadata)
- `previews/final-preview.png` — high-res final render
---
## Workflow: Replicate Mode
When the user provides reference images of a physical object to reproduce in OpenSCAD:
### Step 1: Analyze Reference Images
Read ALL provided reference images using the Read tool. For each image, extract:
- **Overall shape**: What geometric primitives compose this object?
- **Proportions**: Relative dimensions (height-to-width ratio, etc.)
- **Features**: Holes, fillets, chamfers, textures, slots, lips, threads
- **Symmetry**: Is it symmetric along any axis?
- **Construction**: How would you decompose it into boolean operations?
If dimensions are provided, note them. If not, estimate proportions from the images and ask the user for at least one known measurement to establish scale.
### Step 2: Create Decomposition Plan
Before writing any code, describe the object as a series of OpenSCAD operations:
```
Object: Phone stand
Decomposition:
1. Base: flat rectangle with rounded corners (80x60x5mm)
2. Back support: angled plate (60x3mm, tilted 70 degrees)
3. Front lip: small ridge to hold phone (60x3x8mm)
4. Fillet: smooth transition between base and back support
5. Cable channel: cylinder subtracted from base center
```
Present this plan to the user for confirmation before coding.
### Step 3: Generate Initial .scad File
Write the OpenSCAD code based on the decomposition. Set up the project:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-project.sh init "<object-name>"
```
Write the .scad to the project's `src/main.scad`.
### Step 4: Render and Compare
Generate a preview from the **same angle** as the reference image:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh quick ~/openscad-projects/<name>/src/main.scad
```
Read both the reference image and the rendered preview. Compare them side by side mentally:
- Does the overall silhouette match?
- Are proportions correct?
- Are features (holes, edges, curves) in the right places?
- What's the biggest discrepancy?
### Step 5: Iterative Refinement Loop
For each discrepancy found:
1. Identify which part of the .scad code controls the mismatched feature
2. Make a **single targeted edit** to improve the match
3. Re-render from the same angle
4. Re-compare with the reference
**Refinement priorities** (fix in this order):
1. Overall shape and proportions
2. Major features (holes, cutouts, protrusions)
3. Angles and curves
4. Fillets, chamfers, and surface details
5. Fine details
### Step 6: Multi-Angle Validation
Once the primary angle looks good, render from all angles that have reference images:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview ~/openscad-projects/<name>/src/main.scad
```
Compare each rendered view against its corresponding reference image. Fix any angle-specific discrepancies.
### Step 7: Dimensional Verification
If the user provided measurements, add `echo()` statements to verify:
```openscad
echo("Total width:", width);
echo("Total height:", height);
echo("Wall thickness:", wall);
```
Render with echo capture to verify dimensions match specifications.
### Step 8: Export
When the user confirms the replication is satisfactory, export for printing.
### Tips for Accurate Replication
- **Start simple**: Begin with bounding-box primitives, then refine
- **Use reference dimensions**: If user says "it's about 10cm tall", anchor ALL proportions to that
- **Match camera angle**: Use `--camera` to match the reference photo's perspective
- **Organic shapes**: Approximate with hull(), minkowski(), or rotate_extrude() of a profile
- **Iterate small**: Change one thing per render cycle
- **Ask when unsure**: If a feature is ambiguous from the images, ask the user rather than guessing
---
## Workflow: Reconstruct Mode (STL-to-SCAD)
When the user provides an STL file and wants it converted to parametric OpenSCAD code:
### Overview
STL files are triangle meshes with no semantic information about the original primitives or operations that created them. Reconstruction is the process of analyzing the mesh geometry and re-expressing it as clean, parametric OpenSCAD code. This is valuable because:
- Parametric code can be modified (change dimensions, add features)
- OpenSCAD code is human-readable and version-controllable
- The resulting model can be adapted to different use cases
### Critical Rules for Reconstruction
**Read `references/reconstruction-guide.md` before starting any reconstruction.** It contains the complete best practices guide learned from real reconstructions.
**Rule 1: SCULPTOR APPROACH (mandatory).** Start from a full solid block, subtract ALL features. Never build up from pieces — CSG ordering bugs cause added material to cover previously-cut holes. Structure: `difference() { solid_body(); channels(); tapers(); ALL_holes_LAST(); }`
**Rule 2: NEVER add features based on assumptions.** Always verify with SVG contour data AND reference images. If a feature doesn't appear as a separate contour in the SVG slices, IT DOES NOT EXIST. Known hallucinations to avoid:
- Pyramids/cones from render shadows
- Cylinders from curved wall edges
- Top holes from through-hole exit points
**Rule 3: Bounding box match ≠ correct model.** 0.000mm bbox delta can mean only 70% geometric accuracy. Always use mesh comparison (`openscad-stl-compare.sh`) with boolean diff images.
**Rule 4: ANALYZE FIRST, DECOMPOSE, THEN CHOOSE per-component approach.**
Do NOT jump to code. The analysis phase must answer these questions:
1. **What are the dominant features?** (diagonal arm, clips, channels, holes)
2. **What is the thinnest axis?** That's the likely extrusion direction — NOT necessarily the stability score winner
3. **Can the object be decomposed into simpler sub-objects?** Model each with its best technique
4. **Where are internal channels/gaps?** Slice along Z to find multi-body cross-sections
**Rule 5: Choose the extrusion axis by geometry, not just stability score.**
The profile extractor's stability score finds the axis with the most uniform cross-section. But this is misleading for models with diagonal features — slicing along Z for a diagonal bracket produces staircase artifacts. Instead:
| Model Type | Best Extrusion Axis | Why |
|-----------|-------------------|-----|
| Flat bracket/plate | Thinnest axis (smallest extent) | Profile in the wide plane captures all detail |
| Diagonal/angled arm | Thinnest axis | Diagonals live in the plane of the two longest axes |
| Clean extrusion (stability < 0.1) | Stability-score axis | Profiles are identical → stability is reliable |
| Cylindrical (stability > 0.3) | Object's rotational axis | Use rotate_extrude or parametric primitives |
| Truly complex (no good axis) | Dense multi-axis slabbing | 2mm slabs along thinnest axis |
```bash
# Always run ALL analysis tools before writing any code:
bash ~/.claude/skills/openscad/scripts/openscad-stl-reconstruct.sh model.stl analysis/
python3 ~/.claude/skills/openscad/scripts/openscad-profile-extract.py model.stl --json analysis/profile.json
python3 ~/.claude/skills/openscad/scripts/openscad-adaptive-slice.py model.stl analysis/
```
After analysis, compare: **thinnest axis extent** vs **stability-score axis**. If they differ, the thinnest axis is usually better for models with angled features.
**Rule 6: Choose the right technique for each component:**
| Geometry | Best Approach | Expected Accuracy |
|----------|--------------|-------------------|
| Flat/angular (brackets, plates) | Profile extraction + linear_extrude | 90-96% |
| Diagonal features (angled arms, tapers) | Profile along thinnest axis + linear_extrude | 85-92% |
| Simple known shapes (stadium, box) | Parametric primitives + SDF optimizer | 90-96% |
| Cylindrical features (puzzle tabs, bosses) | Parametric circle() + square() | 85-95% |
| Smooth transitions (convex shapes only) | hull() between boundary profiles | 85-90% |
| Multi-width models (width varies along axis) | Dense X-slab (2mm profiles along thinnest axis) | ~92% |
| Mixed (curves + flats) | Polygon profile (hi-res, tol=0.02) | 70-80% |
| Complex organic shapes | import() original STL + parametric modifications | N/A |
**Rule 7: hull() ONLY for convex profiles.** Hull between two profiles creates the convex hull — it fills in ALL concavities (channels, clips, U-forks, hooks). Only use hull for simple solid zones with 1 contour and no holes. For concave profiles, use linear_extrude of a representative profile instead.
**Rule 8: Dense X-slab approach for complex models.**
When no single extrusion works, slice every 2mm along the thinnest axis:
1. At each X position, extract the full Y-Z cross-section (ALL bodies, not just the largest)
2. Extrude each slab for 2mm width
3. Union all slabs — gaps between bodies are naturally preserved
4. Note: this approach has a ~6% volume overestimate floor from polygon extraction artifacts. Below 6% requires hand-modeled parametric geometry.
**Use the automated reconstruction analysis FIRST — before writing any code:**
```bash
bash ~/.claude/skills/openscad/scripts/openscad-stl-reconstruct.sh model.stl output_dir/
```
This runs the full pipeline: mesh stats (trimesh), 2D profile slices (OpenSCAD projection), primitive detection (RANSAC/normal analysis), and generates SVG profiles at multiple Z levels. The SVG profile analysis is the MOST IMPORTANT output — it reveals the complete cross-section structure at each height level.
**The SVG Profile Method** (preferred over vertex analysis):
1. `projection(cut=true)` slices the STL at a Z height → exports 2D SVG
2. Parse the SVG to count contours: BODY (large area) vs HOLES (small area)
3. Compare contours at different Z levels to understand how the shape changes with height
4. This reveals: channels, slots, holes, wall thickness, taper angles — all from 2D data
**After analysis, verify with mesh comparison:**
```bash
bash ~/.claude/skills/openscad/scripts/openscad-stl-compare.sh original.stl reconstruction.stl output_dir/
```
Target: >95% geometric accuracy. Use diff images to identify remaining discrepancies.
### Step 1: Automated Analysis (run ALL tools)
```bash
# Tool 1: SVG profiling + primitive detection
bash ~/.claude/skills/openscad/scripts/openscad-stl-reconstruct.sh model.stl analysis/
# Tool 2: Profile extraction + extrusion axis detection
python3 ~/.claude/skills/openscad/scripts/openscad-profile-extract.py model.stl \
--output analysis/profile.scad --json analysis/profile.json
# Tool 3: Adaptive multi-axis feature map
python3 ~/.claude/skills/openscad/scripts/openscad-adaptive-slice.py model.stl analysis/
```
**Key outputs to examine:**
- `analysis/slices/*.svg` — 2D profiles at 5 Z levels
- `analysis/profile.json` — extrusion axis, stability score, profile points, hole count
- `analysis/adaptive-slicing.json` — feature zones on all 3 axes, transition locations
- `analysis/primitives.json` — detected cylinders/planes
- `analysis/mesh-info.json` — volume, dimensions, symmetry
### Step 1b: Understand the Object (BEFORE writing code)
After running analysis tools, render multi-angle previews and answer:
1. **What are the main components?** (e.g. "bottom clip + diagonal arm + top clip")
2. **Which axis is thinnest?** Compare extents — the thinnest is likely the extrusion direction
3. **Are there diagonal/angled features?** If yes, the stability-score axis is probably WRONG
4. **Where do cross-sections change?** Check the adaptive slicer's transition zones
5. **Are there multi-body zones?** (channels, rails, gaps between parts)
**Decision tree — axis selection:**
1. If `stability_score < 0.1` AND thinnest axis matches stability axis → clean extrusion, use `profile.scad`
2. If model has **diagonal features** → use the **thinnest axis** regardless of stability score
3. If `stability_score < 0.3` AND no diagonals → stability axis + feature variations
4. If `stability_score > 0.3` → complex shape. Try dense X-slab along thinnest axis, or decompose into sub-objects
**Decision tree — technique per component:**
- Simple extruded body → profile + linear_extrude along extrusion axis
- Diagonal arm/strut → profile along thinnest axis captures it naturally
- Clips, hooks, U-channels → profile extraction (NOT hull — hull fills concavities)
- Cylindrical features → parametric circle() + square(), NOT polygon profiles
- Smooth convex transitions → hull() between boundary profiles (ONLY if convex)
- Complex multi-width → dense 2mm slabs along thinnest axis
**For models with cylindrical features** (stability > 0.3 or SVG shows circular contours):
- Do NOT rely on polygon profiles — they approximate curves poorly
- Identify circle centers and radii from the SVG contour data
- Model with `circle()` + `square()` in 2D, then extrude
Then render multi-angle previews:
```openscad
// Temporary viewer file
import("path/to/model.stl");
```
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview /tmp/stl-viewer.scad
```
Read all preview images to understand the 3D shape from multiple angles.
### Step 1b: Auto-Reconstruction (NEW — recommended for most models)
After running the adaptive slicer, use the auto-reconstructor to generate parametric .scad directly:
```bash
# Option A: With pre-computed analysis
python3 ~/.claude/skills/openscad/scripts/openscad-auto-reconstruct.py model.stl \
--analysis analysis/ --output project/src/main.scad
# Option B: Run analysis + reconstruction in one step
python3 ~/.claude/skills/openscad/scripts/openscad-auto-reconstruct.py model.stl \
--output project/src/main.scad --run-analysis
# Option C: Tighter circle fitting for precision parts
python3 ~/.claude/skills/openscad/scripts/openscad-auto-reconstruct.py model.stl \
--analysis analysis/ --output project/src/main.scad --circle-threshold 0.3
```
This automatically:
1. Parses the feature map JSON into zones
2. Extracts profiles at zone boundaries
3. Fits circles/arcs to replace polygon approximations (Feature 3)
4. Generates hull() blends for transition zones (Feature 2)
5. Emits one OpenSCAD module per zone with sculptor assembly (Feature 1)
The output is a good starting point — review and refine the generated .scad, then verify with mesh comparison. For models with cylindrical features, this typically achieves >85% accuracy automatically (vs 75% with polygon-only).
### Step 2: Detailed Structure Mapping
**For simple models** (5 SVG slices are enough):
Parse the SVG contours to count bodies vs holes at each Z level.
**For complex models** (brackets, enclosures with multiple features):
Use the adaptive multi-axis slicer for efficient feature detection:
```bash
python3 ~/.claude/skills/openscad/scripts/openscad-adaptive-slice.py model.stl analysis/
```
This automatically: scans all 3 axes with coarse pass (5mm) → detects transitions → fine pass (0.5mm) around transitions. Produces a feature map classifying each zone as `solid`, `shell_or_channel`, `multi_body`, or `complex`.
For manual fine-grained slicing at specific heights:
```bash
for z in $(seq 0.5 1 <max_z>); do
echo "projection(cut=true) translate([0,0,-$z]) import(\"model.stl\");" > /tmp/s.scad
openscad -o "slices/z${z}.svg" /tmp/s.scad
done
```
Parse each SVG to build a structural map:
```
Z=0-5: 1 body (full width) + 8 holes → Solid base with screw holes
Z=5-10: 2 bodies + 8 holes → Channel appeared, walls split
Z=10-20: 2 bodies narrowing → Taper zone (measure rate)
Z=20-33: 2 bodies constant width → Top section
Z=25-27: Bodies interrupted → Counterbore pockets at this depth
```
**Hole positions from SVG centroids** — for each hole contour at a given Z, compute the centroid. This gives exact X,Y positions far more reliably than vertex analysis.
**Feature verification rule:** If a feature doesn't appear as a distinct contour in the SVG data, IT DOES NOT EXIST in the model. Never add features based on visual interpretation of 3D renders alone.
### Step 3: Choose Approach and Decompose
Based on the analysis data, choose the reconstruction approach:
**Approach A — Profile Extrusion** (for extruded parts, stability < 0.3):
```bash
# The profile extractor already generated the .scad — use it as a starting point
cat analysis/profile.scad
# Adjust: add cavity with offset(delta=-wall), add floor, add features
```
**Approach B — Parametric Primitives** (for known shapes or cylindrical features):
Create a decomposition plan using measured dimensions from SVG data:
```
Decomposition:
1. Base: square([80, 80]) + circle tabs — from SVG outer contour at Z=mid
2. Cavity: offset(delta=-wall) of base — from SVG inner contour
3. Floor: solid at Z=0 to floor_h — from SVG at Z=0 (1 contour = solid)
4. Holes: cylinder(d=3) at SVG hole centroids
5. Counterbores: cylinder(d=8, h=2) at same positions
```
**Approach C — Hybrid** (for complex shapes with both flat and curved features):
1. Extract polygon profile for the overall outline
2. Identify which curves in the profile are circles (regular spacing, arc-like)
3. Replace those polygon sections with parametric `circle(r)` operations
4. Assemble: `square() + circle()` union for tabs, `difference()` for slots
**Counterbore vs Countersink** — always verify from reference images:
- **Counterbore**: flat cylindrical pocket (`cylinder(d=cb_d, h=cb_depth)`)
- **Countersink**: conical taper (`cylinder(d1=cs_d, d2=hole_d, h=cs_depth)`)
- Most 3D-printed parts use counterbores, not countersinks
### Step 4: Write Parametric .scad Code
Create a new project and write the reconstructed code:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-project.sh init "<name>-reconstructed"
```
**Key principles for reconstruction:**
- Extract ALL dimensions as named variables at the top
- Use meaningful variable names that describe the physical feature
- Add comments linking each section to the original STL features
- Include `echo()` statements for bounding box verification
- Add `assert()` for parameter ranges
### Step 5: Visual Comparison Loop
Render the reconstructed .scad and compare side-by-side with the original STL renders:
1. Render the reconstruction from the same camera angles as Step 1
2. Read both sets of images
3. Compare silhouettes, proportions, and feature placement
4. Identify the biggest discrepancy
5. Fix it and re-render
6. Repeat until the reconstruction matches the original
### Step 6: Overlay Verification
For precise verification, create an overlay .scad file:
```openscad
// Overlay: original STL (transparent) vs reconstruction
%import("path/to/original.stl"); // % = transparent background
color("red", 0.6) reconstructed_model();
```
Render this overlay — any RED areas visible through the transparent original indicate reconstruction errors. Any grey areas not covered by red indicate missing geometry.
### Step 7: Mesh-to-Mesh Comparison
**This is the most important verification step.** Export the reconstruction as STL and compare it against the original using boolean difference:
```bash
# Export reconstruction
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl ~/openscad-projects/<name>/src/main.scad
# Run mesh comparison
bash ~/.claude/skills/openscad/scripts/openscad-stl-compare.sh \
path/to/original.stl \
~/openscad-projects/<name>/output/main.stl \
~/openscad-projects/<name>/previews/comparison
```
This produces:
- **diff-A-minus-B.png** — geometry in original but MISSING from reconstruction (what you need to add)
- **diff-B-minus-A.png** — EXTRA geometry in reconstruction not in original (what you need to remove)
- **overlay.png** — both models overlaid for visual check
- **Geometric accuracy %** — based on volume of boolean differences vs original volume
**Target: >95% geometric accuracy.** If below 95%, examine the diff images to identify which features are wrong, fix them, re-export, and re-compare. Iterate until accuracy is satisfactory.
**Important:** Bounding box delta can be 0.000mm while geometric accuracy is only 78% — internal features matter more than outer dimensions.
### Step 8: Dimensional Verification
Compare echo output from the reconstruction with the STL bounding box:
```openscad
echo(str("Reconstructed BBOX: ", width, " x ", depth, " x ", height));
```
### Step 9: SDF Parameter Optimization (Advanced)
If the SVG profile method doesn't achieve >95% accuracy, use the SDF optimizer for automatic parameter tuning:
```bash
python3 ~/.claude/skills/openscad/scripts/openscad-sdf-optimize.py \
path/to/original.stl \
stadium-slot \
--verbose \
--output analysis/sdf-result.json
```
This works by:
1. Sampling 30,000 random points in the bounding box
2. Computing target occupancy (inside/outside original mesh) via trimesh
3. Defining the reconstruction as a parametric SDF (Signed Distance Field)
4. Using `scipy.optimize.minimize(method="Powell")` to maximize IoU (Intersection over Union)
5. Generating OpenSCAD code with optimized parameters
**When to use**: When you know the correct model topology (e.g., "stadium body with cylindrical slot") but can't find the exact parameters. The optimizer finds them automatically.
**Supported model types**: `stadium-slot`, `box-holes`. Add new types by defining an SDF function in the script.
**Workflow**: Run `openscad-stl-reconstruct.sh` first (to identify the model topology), then `openscad-sdf-optimize.py` (to find exact parameters), then `openscad-stl-compare.sh` (to verify).
**Prerequisites**: `pip3 install trimesh numpy scipy rtree`
### Common Pitfalls
- **Bounding box match ≠ correct model.** A model with completely wrong internal geometry can still have a 0.000mm bounding box delta. Always verify visually from multiple angles.
- **Don't assume features from renders alone.** What looks like a cylinder in a top-down view might just be a curved wall edge. Always verify with vertex analysis.
- **Coincident faces cause Z-fighting.** If a feature touches the body boundary exactly, use `intersection()` to clip it cleanly rather than making it the exact same size.
- **Don't flip between adding and removing features.** If unsure whether a feature exists, run cross-section analysis before deciding. Oscillating between "add bar" and "remove bar" wastes iterations.
- **Offset features are common.** Cylinders, holes, and channels are often NOT centered. Always calculate the actual center from vertex data rather than assuming symmetry.
### Limitations
- **Organic shapes** (sculpted, freeform surfaces) cannot be fully reconstructed as primitives. For these, keep the STL import and wrap it in a module.
- **Very complex models** (1000+ features) should be reconstructed incrementally, starting with the major body and adding features one group at a time.
- **Thread geometry** in STL is extremely difficult to reconstruct. Use `threads.scad` library instead of trying to match individual thread faces.
- **Text/engravings** embedded in STL meshes are very hard to extract. It's better to re-add text using OpenSCAD's `text()` module.
### Hybrid Approach
For complex models, use a hybrid strategy:
```openscad
// Import the complex organic base from STL
module original_base() {
import("base-section.stl");
}
// Reconstruct and parameterize the mechanical features
module mounting_bracket(width=30, hole_d=5) {
difference() {
original_base();
// Add parametric mounting holes
for (pos = hole_positions)
translate(pos) cylinder(d=hole_d, h=50, center=true);
}
}
```
This lets the user modify the parametric parts while keeping the complex geometry intact.
---
## Workflow: Refine Mode
When the user wants to modify an existing design:
### Step 1: Read the Existing File
```bash
# Find .scad files in the project
```
Read the .scad source to understand the current design.
### Step 2: Render Current State
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview /path/to/file.scad
```
Read the preview images to see what currently exists.
### Step 3: Apply Changes
Edit the .scad file with the requested modifications. Use the Edit tool for surgical changes.
### Step 4: Re-render and Compare
Generate new previews and visually compare with the previous version. Report what changed.
### Step 5: Repeat or Export
Continue iterating or export when satisfied.
---
## Workflow: Export Mode
Quick export of an existing .scad file:
```bash
# Single format
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl /path/to/file.scad
# Multiple formats
bash ~/.claude/skills/openscad/scripts/openscad-render.sh export /path/to/file.scad
# With parameter overrides
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl /path/to/file.scad -D 'width=50' -D 'height=30'
```
---
## Workflow: Analyze Mode
Review a design for printability:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh analyze /path/to/file.scad
```
This renders cross-section views and reports:
- Object bounding box dimensions
- Whether the mesh is manifold (watertight)
- Estimated print time indicators (volume, surface area from STL)
- Visual check of overhangs via bottom-up view
---
## Script Reference
All scripts live in `~/.claude/skills/openscad/scripts/`:
| Script | Purpose |
|--------|---------|
| `openscad-render.sh` | Core render/export/preview engine |
| `openscad-project.sh` | Project scaffolding and management |
| `openscad-validate.sh` | Strict validation with categorized error output |
| `openscad-stl-analyze.sh` | STL mesh analysis: bbox, cross-sections, gap detection |
| `openscad-stl-compare.sh` | Mesh comparison: boolean diff, volume delta, accuracy % |
| `openscad-stl-reconstruct.sh` | Automated STL analysis: profiles, primitives, CSG inference |
| `openscad-sdf-optimize.py` | SDF-based parameter optimizer (IoU scoring, no OpenSCAD in loop) |
| `openscad-adaptive-slice.py` | Adaptive multi-axis slicing (coarse→transitions→fine on X,Y,Z) |
| `openscad-auto-reconstruct.py` | Auto-translate feature map → parametric .scad (circle fitting, hull blending) |
### openscad-render.sh Commands
```bash
# Quick single preview (isometric)
openscad-render.sh quick <file.scad>
# Multi-angle preview (4 views)
openscad-render.sh preview <file.scad>
# Export STL only
openscad-render.sh stl <file.scad> [-D 'var=val' ...]
# Export all formats (STL + 3MF + PNG)
openscad-render.sh export <file.scad> [-D 'var=val' ...]
# Analyze printability
openscad-render.sh analyze <file.scad>
# Custom render
openscad-render.sh custom <file.scad> --format png --imgsize 1920,1080 --camera 0,0,0,45,0,30,200
```
### openscad-project.sh Commands
```bash
# Initialize new project
openscad-project.sh init <project-name>
# List projects
openscad-project.sh list
# Clean build artifacts
openscad-project.sh clean <project-name>
```
---
## OpenSCAD Code Guidelines
### File Structure Convention
```openscad
// ============================================
// Project: <name>
// Description: <what this models>
// 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)
eps = 0.01; // epsilon for clean boolean operations
// --- Derived dimensions ---
inner_width = width - 2 * wall;
inner_height = height - 2 * wall;
// --- Main model ---
main_assembly();
// --- Modules ---
module main_assembly() {
// ...
}
```
### 3D Printing Best Practices in OpenSCAD
- **Wall thickness**: minimum 1.2mm for FDM (2-3 perimeters with 0.4mm nozzle)
- **Tolerance**: 0.2-0.3mm clearance for fitting parts together (peg-in-hole, snap fits)
- **Overhangs**: keep below 45 degrees from vertical, or add supports in design
- **Chamfer vs fillet**: prefer chamfers on downward-facing surfaces (avoids supports); use fillets on top surfaces
- **Bridging**: max ~10mm unsupported spans
- **First layer**: design flat bottoms for bed adhesion; largest flat surface on build plate
- **Epsilon constant**: always define `eps = 0.01;` and use it in boolean operations to prevent Z-fighting / coplanar faces
- **Manifold geometry**: always ensure boolean operations produce valid solids; operands must overlap
- **Resolution**: use `$fn = 64` for preview, `$fn = 128` for export
- **Design intent**: Define hole positions relative to edges (`hole_x = length - margin`), never as absolute coordinates
- **Tolerance chains**: Define a single `fit_clearance` parameter and derive all clearances from it
- **Assert validation**: Use `assert()` to validate parameters: `assert(wall >= 1.2)`, `assert(boss_d > hole_d + 2*wall)`
- **Profile-first**: Use `offset(r=corner_r)` on 2D `polygon()` instead of `hull()` with 3D cylinders
### Common Patterns
**Rounded box:**
```openscad
module rounded_box(size, radius) {
minkowski() {
cube([size.x - 2*radius, size.y - 2*radius, size.z - radius]);
cylinder(r=radius, h=radius);
}
}
```
**Shell (hollow object):**
```openscad
module shell(outer_size, wall) {
difference() {
cube(outer_size);
translate([wall, wall, wall])
cube([outer_size.x - 2*wall, outer_size.y - 2*wall, outer_size.z]);
}
}
```
**Screw hole with countersink:**
```openscad
module screw_hole(d=3, h=10, cs_d=6, cs_h=2) {
union() {
cylinder(d=d, h=h);
translate([0, 0, h - cs_h])
cylinder(d1=d, d2=cs_d, h=cs_h);
}
}
```
---
## Available Libraries
Popular libraries that can be installed for advanced features:
| Library | Use Case | Install |
|---------|----------|---------|
| **BOSL2** | Swiss-army knife: attachments, shapes, threading, paths | `git clone https://github.com/BelfrySCAD/BOSL2 ~/.local/share/OpenSCAD/libraries/BOSL2` |
| **NopSCADlib** | Vitamins (screws, nuts, electronics, bearings) | `git clone https://github.com/nophead/NopSCADlib ~/.local/share/OpenSCAD/libraries/NopSCADlib` |
| **threads.scad** | Metric threads, hex bolts, nuts | `git clone https://github.com/rcolyer/threads-scad ~/.local/share/OpenSCAD/libraries/threads` |
| **Round-Anything** | Smooth fillets and rounding | `git clone https://github.com/Irev-Dev/Round-Anything ~/.local/share/OpenSCAD/libraries/Round-Anything` |
| **YAPP_Box** | Parametric project enclosures | `git clone https://github.com/mrWheel/YAPP_Box ~/.local/share/OpenSCAD/libraries/YAPP_Box` |
| **Catch'n'Hole** | Nut catches, screw holes | `git clone https://github.com/mmalecki/catchnhole ~/.local/share/OpenSCAD/libraries/catchnhole` |
Check installed libraries:
```bash
ls ~/.local/share/OpenSCAD/libraries/ 2>/dev/null
ls /opt/homebrew/share/openscad/libraries/ 2>/dev/null
```
When user needs a library, install it and add `use <library/file.scad>` to the .scad source.
---
## Error Handling
When OpenSCAD fails:
1. **Parse errors**`ERROR: Parser error: syntax error in file X, line Y`
- Read the .scad file at the reported line
- Fix syntax (common: missing semicolons, unmatched braces/parens, wrong function names)
- Re-render
2. **Geometry errors**`WARNING: Object may not be a valid 2-manifold`
- Check boolean operations aren't creating degenerate geometry
- Ensure shapes overlap properly for difference/intersection
- Add small epsilon offsets (0.01mm) to prevent coplanar faces
3. **Rendering timeouts** — complex models with high `$fn`
- Lower `$fn` for preview (32), raise for export (128)
- Simplify geometry where possible
- Use `render()` to cache intermediate results
4. **Empty output** — model produces no geometry
- Check that modules are actually called
- Verify boolean operations don't subtract everything
- Use `echo()` statements to debug variable values
Always capture stderr when rendering — it contains warnings and errors:
```bash
openscad -o output.stl input.scad 2>&1
```
---
## Camera Presets for Multi-View
| View | Camera Parameters |
|------|-------------------|
| Front | `--camera 0,0,0,90,0,0,<dist>` |
| Back | `--camera 0,0,0,90,0,180,<dist>` |
| Right | `--camera 0,0,0,90,0,90,<dist>` |
| Left | `--camera 0,0,0,90,0,270,<dist>` |
| Top | `--camera 0,0,0,0,0,0,<dist>` |
| Bottom | `--camera 0,0,0,180,0,0,<dist>` |
| Isometric | `--autocenter --viewall` (default) |
| 3/4 view | `--camera 0,0,0,55,0,25,<dist>` |
Use `--autocenter --viewall` to auto-calculate distance, or specify explicit distance for consistent framing across iterations.
@@ -0,0 +1,188 @@
# OpenSCAD Language Reference (v2021.01)
## Syntax & Declaration
```openscad
var = value; // variable assignment
var = cond ? value_if_true : value_if_false; // ternary
var = function (x) x + x; // function literal
module name(params) { ... } // module definition
function name(params) = expr; // function definition
include <file.scad> // include (executes top-level code)
use <file.scad> // import (only modules/functions)
```
## Constants
- `undef` — undefined value
- `PI` — 3.14159...
## Special Variables
| Variable | Purpose |
|----------|---------|
| `$fn` | Fixed number of segments for circles/spheres |
| `$fa` | Minimum angle per segment |
| `$fs` | Minimum segment size |
| `$t` | Animation step (01) |
| `$vpr` | Viewport rotation |
| `$vpt` | Viewport translation |
| `$vpd` | Viewport distance |
| `$vpf` | Viewport field of view |
| `$children` | Number of child modules |
| `$preview` | `true` in preview (F5), `false` in render (F6) |
## Modifier Characters
- `*` — disable (don't render)
- `!` — show only this
- `#` — highlight/debug (transparent red)
- `%` — transparent/background
## 3D Primitives
```openscad
cube(size, center=false) // cube([w,d,h]) or cube(s)
sphere(r=radius) // sphere(d=diameter)
cylinder(h, r=radius, center=false) // cylinder(h, d=diameter)
cylinder(h, r1, r2, center=false) // cone/tapered cylinder
polyhedron(points, faces, convexity) // custom solid
```
## 2D Primitives
```openscad
circle(r=radius) // circle(d=diameter)
square(size, center=false) // square([w,h])
polygon(points, paths) // 2D polygon
text(t, size=10, font, halign, valign, spacing) // text string
```
## 2D → 3D Extrusion
```openscad
linear_extrude(height, center, twist, slices, scale)
circle(10); // extrude 2D to 3D
rotate_extrude(angle=360, convexity)
translate([20,0]) circle(5); // lathe/revolve
surface(file="heightmap.dat", center) // heightmap to 3D
```
## Transformations
```openscad
translate([x, y, z]) // move
rotate([x, y, z]) // rotate (degrees)
rotate(angle, [x, y, z]) // rotate around axis
scale([x, y, z]) // scale
resize([x, y, z], auto) // resize to exact dimensions
mirror([x, y, z]) // mirror across plane
multmatrix(m) // 4x4 matrix transform
color("name", alpha) // color("red"), color("#ff0000")
color([r, g, b, a]) // color by RGBA (0-1)
offset(r=radius) // 2D offset (round)
offset(delta=dist, chamfer=false) // 2D offset (sharp)
```
## Boolean Operations
```openscad
union() { a(); b(); } // combine (A + B)
difference() { a(); b(); } // subtract (A - B)
intersection() { a(); b(); } // overlap only (A ∩ B)
```
## Advanced Operations
```openscad
hull() { a(); b(); } // convex hull
minkowski() { a(); b(); } // Minkowski sum (slow!)
render(convexity) { ... } // force CGAL render (cache)
projection(cut=false) { ... } // 3D → 2D projection
```
## Import / Export
```openscad
import("file.stl") // import STL, OFF, AMF, 3MF
import("file.dxf") // import 2D DXF
import("file.svg") // import 2D SVG
```
## Flow Control
```openscad
if (condition) { ... } else { ... }
for (i = [0:10]) { ... } // range [start:end]
for (i = [0:2:10]) { ... } // range [start:step:end]
for (i = [1, 5, 7]) { ... } // list iteration
for (i = [...], j = [...]) { ... } // nested loops
intersection_for(i = [...]) { ... } // intersection across iterations
let (a = expr) { ... } // local variable scope
```
## List Comprehensions
```openscad
list = [ for (i = [0:10]) i * 2 ]; // generate
list = [ for (i = [0:10]) if (i % 2 == 0) i ]; // filter
list = [ for (i = [0:10]) let (x = i*i) x ]; // with let
list = [ each sublist ]; // flatten
```
## Math Functions
| Function | Description |
|----------|-------------|
| `abs(x)` | Absolute value |
| `sign(x)` | Sign (-1, 0, 1) |
| `sin(x)`, `cos(x)`, `tan(x)` | Trig (degrees) |
| `asin(x)`, `acos(x)`, `atan(x)` | Inverse trig |
| `atan2(y, x)` | Two-argument arctangent |
| `floor(x)`, `ceil(x)`, `round(x)` | Rounding |
| `ln(x)`, `log(x)` | Natural / base-10 log |
| `pow(base, exp)` | Power |
| `sqrt(x)` | Square root |
| `exp(x)` | e^x |
| `min(a,b,...)`, `max(a,b,...)` | Min/max |
| `norm(v)` | Vector magnitude |
| `cross(v1, v2)` | Cross product |
| `rands(min, max, count)` | Random numbers |
| `len(x)` | Length of list/string |
## String Functions
| Function | Description |
|----------|-------------|
| `str(...)` | Concatenate to string |
| `chr(code)` | ASCII code → character |
| `ord(char)` | Character → ASCII code |
| `search(needle, haystack)` | Search in list/string |
## Type Testing
```openscad
is_undef(x) is_bool(x) is_num(x)
is_string(x) is_list(x) is_function(x)
```
## Other Functions
```openscad
echo("msg", var) // debug output
assert(condition, "message") // assertion
concat(list1, list2) // concatenate lists
lookup(key, [[k,v], ...]) // table lookup
children(idx) // access child N of module
parent_module(idx) // access parent module name
version() // OpenSCAD version string
```
## Operators
| Type | Operators |
|------|-----------|
| Arithmetic | `+` `-` `*` `/` `%` `^` |
| Relational | `<` `<=` `==` `!=` `>=` `>` |
| Logical | `&&` `\|\|` `!` |
| Indexing | `list[i]` `list.x` `list.y` `list.z` |
## Tips for AI-Generated Code
- **Always set `$fn`** for predictable circle resolution
- **Use `center=true`** when building symmetric objects with difference()
- **Add 0.01mm epsilon** to boolean cuts to avoid coplanar Z-fighting:
```openscad
difference() {
cube([10, 10, 10]);
translate([-0.01, 2, 2])
cube([10.02, 6, 6]); // slightly larger to ensure clean cut
}
```
- **Modules for reuse**: parameterize everything, compose with modules
- **Comments**: explain the "why", dimensions in mm
- **Print orientation**: think about which face goes on the build plate
- **Manifold check**: all boolean operands must overlap; no coincident faces
@@ -0,0 +1,241 @@
# STL-to-SCAD Reconstruction Best Practices
## Profile-Based Reconstruction (Preferred Method)
For extruded parts (brackets, plates, channels), extract the 2D profile directly from the mesh and convert it to OpenSCAD `polygon()` + `linear_extrude()`. This is more accurate than fitting known primitives.
### Step 1: Detect Extrusion Axis
Use trimesh to find the axis with the most stable cross-section:
- Slice the mesh along X, Y, and Z at 64 levels each
- For each axis, measure stability: std(area), std(perimeter), std(hole_count)
- The axis with lowest stability score is the extrusion axis
### Step 2: Extract the Dominant Profile
- Find the slice with the largest area (the representative cross-section)
- Simplify the polygon (remove micro-vertices from tessellation)
- Handle holes: inner contours become `paths` in OpenSCAD `polygon()`
### Step 3: Convert to OpenSCAD
```openscad
// Auto-generated from mesh profile extraction
linear_extrude(height = <extrusion_length>)
polygon(
points = [<extracted_points>],
paths = [<outer_boundary>, <hole_1>, <hole_2>]
);
```
### Step 4: Add Secondary Features
Features that vary along the extrusion axis (holes, counterbores) are detected by comparing slice profiles at different heights. Where a slice has more holes than the dominant profile, subtract cylinders.
## The Sculptor Approach (MANDATORY)
Always model as a sculptor: start from a solid block, then subtract material.
```openscad
// CORRECT: sculptor approach
difference() {
solid_body(); // 1. Full solid block first
channel(); // 2. Subtract channels/slots
taper_cuts(); // 3. Subtract wedges/tapers
all_holes(); // 4. Subtract ALL holes LAST
}
// WRONG: additive approach (holes get covered)
union() {
difference() {
base();
some_holes(); // These get covered by wings!
}
left_wing(); // Covers the holes above
right_wing();
}
```
**Why**: In OpenSCAD, `difference()` only applies to its immediate children. If you add material (wings) after cutting holes, the new material covers the holes. The sculptor approach ensures ALL cuts happen after ALL additions.
## Analysis Pipeline
### Step 1: Automated SVG Profile Analysis
```bash
bash openscad-stl-reconstruct.sh model.stl output_dir/
```
This gives you the 30,000-foot view: dimensions, volume, symmetry, primitive hints.
### Step 2: Detailed 1mm Z-Slicing
For complex models, slice at every 1mm (not just 5 levels):
```bash
# Generate slices at every Z level
for z in $(seq 0.5 1 <max_z>); do
echo "projection(cut=true) translate([0,0,-$z]) import(\"model.stl\");" > /tmp/s.scad
openscad -o "slices/z${z}.svg" /tmp/s.scad
done
```
Parse each SVG to extract:
- Number of contours (1 body = solid level, 2+ = channels/features)
- Contour sizes: BODY (>500mm²), FEATURE (50-500mm²), HOLE (<50mm²)
- Hole positions from contour centroids
- How width changes with Z (reveals taper rate)
### Step 3: Identify Structure from Profile Data
```
Z=0-5: 1 body (full width) → Solid base
Z=5-10: 2 bodies + 8 holes → Channel appeared, base holes
Z=10-20: 2 bodies narrowing → Taper zone (measure rate)
Z=20-33: 2 bodies (constant width) → Top section
Z=25-27: bodies interrupted by holes → Upper counterbore holes
```
### Step 4: Write OpenSCAD (Sculptor Method)
1. Create the FULL solid body (base + wings as one block)
2. Subtract the channel
3. Subtract taper wedges (use `hull()` for linear tapers)
4. Subtract ALL holes in separate modules, called LAST
### Step 5: Compare and Iterate
```bash
bash openscad-stl-compare.sh original.stl reconstruction.stl output/
```
- Check accuracy % (target: >95%)
- Read diff images to identify WHAT is wrong
- Fix ONE thing per iteration
- Re-compare
## Hole Patterns
### Counterbore (flat cylindrical pocket)
Most common in 3D-printed brackets. A shallow cylinder + through hole:
```openscad
module counterbore(hole_d, cb_d, cb_depth, total_h) {
cylinder(d=hole_d, h=total_h); // Through hole
cylinder(d=cb_d, h=cb_depth); // Flat pocket
}
```
### Countersink (conical taper)
Less common in 3D prints, used for flat-head screws:
```openscad
module countersink(hole_d, cs_d, cs_depth, total_h) {
cylinder(d=hole_d, h=total_h);
cylinder(d1=cs_d, d2=hole_d, h=cs_depth);
}
```
**Always check reference images** to determine which type is used. Don't assume.
### Hole Orientation Patterns
Complex brackets often have holes on multiple faces:
- **Bottom face**: Vertical (Z-axis) holes
- **Angled faces**: Holes perpendicular to the face (rotate by taper angle)
- **Side walls**: Horizontal (Y-axis) holes
- **Each set may have different spacing and count**
Extract hole positions from SVG centroids at the appropriate Z level.
## Taper/Wedge Subtraction
For a 45° taper that narrows a wing from full width to reduced width:
```openscad
// Wedge: 0 thickness at bottom, full thickness at top
hull() {
translate([0, outer_edge, z_start])
cube([length, eps, z_end - z_start]); // Thin edge
translate([0, outer_edge, z_end - eps])
cube([length, taper_amount, eps]); // Full face
}
// Then remove the rectangular block above the taper
translate([0, outer_edge, z_end])
cube([length, taper_amount, z_top - z_end]);
```
## Common Pitfalls
1. **CSG order**: ALWAYS cut holes after building the full solid
2. **Feature hallucination**: Don't add features you can't verify in the reference
3. **Conical vs cylindrical**: Check if countersinks are tapered or flat
4. **Symmetric assumptions**: Don't assume symmetry — verify from SVG data
5. **Volume match ≠ shape match**: A model can have correct volume but wrong shape
6. **SVG Y-axis is inverted**: OpenSCAD projection flips Y coordinates
## Dependencies
```bash
pip3 install trimesh numpy scipy rtree shapely
brew install admesh
```
## Accuracy Targets
| Level | Accuracy | When to stop |
|-------|----------|-------------|
| Draft | >85% | Initial structure verification |
| Good | >95% | Functional part, ready for test print |
| Excellent | >98% | Production quality |
The 95% threshold is achievable for most mechanical parts in 4-6 iterations using the SVG profiling approach. The remaining 5% is typically tessellation differences and minor feature details.
## When to Use Polygon Profiles vs Parametric Primitives
### Use extracted polygon profiles when:
- The shape has mostly flat/angular surfaces (brackets, plates, channels)
- The curves are gentle and well-approximated by ~200 polygon points
- Speed is more important than last-5% accuracy
- Expected accuracy: 75-96% depending on curve complexity
### Use parametric primitives (circle, cylinder) when:
- The shape has prominent cylindrical features (puzzle tabs, screw holes, bosses)
- The shape can be decomposed into known primitives (square + circles)
- You need >95% accuracy on curved surfaces
- The model has symmetry that can be exploited
### Hybrid approach (best for complex models):
1. Extract the polygon profile for the overall outline
2. Identify which curves are circles/arcs from the profile data
3. Replace polygon approximations with parametric `circle(r)` where possible
4. Use `offset(r)` for rounded corners instead of polygon vertices
### Key lesson: polygon simplification tolerance matters enormously
- 0.3mm tolerance → ~50 points → curves become flat → 52% accuracy
- 0.05mm tolerance → ~150 points → curves approximate → 75% accuracy
- 0.02mm tolerance → ~230 points → curves close but not perfect → 75% accuracy
- Diminishing returns beyond ~200 points for polygon-based approaches
- For >90% on cylindrical surfaces, parametric primitives are required
## Feature Hallucination Prevention
NEVER add features based on visual interpretation of renders alone.
- The toothpaste squeezer "cylinder" was actually a rounded slot floor
- The puzzle tray "pyramid" didn't exist at all — it was a shadow in the render
- ALWAYS verify features with SVG slice data (contour count, area, holes)
- If a feature doesn't show as a separate contour in the SVG slices, IT DOESN'T EXIST
## Adaptive Multi-Axis Slicing
The `openscad-adaptive-slice.py` script scans STL on all 3 axes:
1. Coarse pass (5mm) → detects where cross-section changes
2. Fine pass (0.5mm) only at transition zones
3. Classifies each zone: solid, shell_or_channel, multi_body, complex
### How to interpret the feature map
**Zone types and their OpenSCAD equivalents:**
- `solid` (1 contour, 0 holes) → `linear_extrude()` of the profile
- `shell_or_channel` (2 contours) → walls around a cavity, use `offset(delta=-wall)`
- `solid_with_holes` (1 contour, N holes) → solid body with `difference()` holes
- `multi_body` (N contours) → multiple separate parts or holes
- `complex` → may need `hull()` between profiles or `polyhedron()`
**Detecting specific features from zone evolution:**
- **Chamfer/taper**: contour width decreases progressively across slices
- **Fillet**: smooth curvature in contour centroids between zones
- **Counterbore**: nested circular contours with constant radius for several slices
- **Through-hole**: hole contour appears in ALL slices along that axis
- **Blind hole**: hole contour appears then disappears
**Generating OpenSCAD from zones:**
- Stable zones (many identical slices) → `linear_extrude(height=zone_length)` of representative profile
- Transition zones (gradual change) → `hull()` between two profiles at zone boundaries
- Feature zones (holes, counterbores) → `difference()` with fitted cylinders
### Future: Feature Map → OpenSCAD Translator
The next evolution is automatic translation: parse the JSON feature map, emit one `module zone_N()` per zone, assemble with `difference()/union()` in the correct order. This would close the loop from STL → analysis → parametric .scad automatically.
+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"
+69
View File
@@ -0,0 +1,69 @@
// ============================================
// Template: L-Bracket with Mounting Holes
// Description: Adjustable L-bracket for wall/shelf mounting
// ============================================
// --- Parameters ---
arm_length = 50; // [mm] horizontal arm
leg_length = 40; // [mm] vertical leg
width = 25; // [mm] bracket width
thickness = 4; // [mm] material thickness
fillet_r = 8; // [mm] inner fillet radius
// Mounting holes
hole_d = 5; // [mm] hole diameter
hole_inset = 10; // [mm] hole center from edges
countersink = true; // countersink holes
cs_d = 9; // [mm] countersink diameter
cs_depth = 2; // [mm] countersink depth
// --- Quality ---
$fn = 64;
// --- Render ---
bracket();
// --- Modules ---
module bracket() {
difference() {
union() {
// Horizontal arm
cube([arm_length, width, thickness]);
// Vertical leg
cube([thickness, width, leg_length]);
// Inner fillet for strength
translate([thickness, 0, thickness])
fillet(fillet_r, width);
}
// Arm mounting holes
translate([arm_length - hole_inset, width/2, -0.01])
mounting_hole(hole_d, thickness, countersink, cs_d, cs_depth);
// Leg mounting holes
translate([-0.01, width/2, leg_length - hole_inset])
rotate([0, 90, 0])
mounting_hole(hole_d, thickness, countersink, cs_d, cs_depth);
}
}
module fillet(r, w) {
difference() {
cube([r, w, r]);
translate([r, -0.01, r])
rotate([-90, 0, 0])
cylinder(r=r, h=w + 0.02);
}
}
module mounting_hole(d, h, countersink=false, cs_d=0, cs_depth=0) {
union() {
cylinder(d=d, h=h + 0.02);
if (countersink) {
translate([0, 0, h - cs_depth + 0.01])
cylinder(d1=d, d2=cs_d, h=cs_depth);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
// ============================================
// Template: Parametric Electronics Enclosure
// Description: Box with lid, screw posts, ventilation
// ============================================
// --- Parameters ---
width = 80; // [mm] inner width (X)
depth = 60; // [mm] inner depth (Y)
height = 35; // [mm] inner height (Z)
wall = 2.5; // [mm] wall thickness
corner_r = 3; // [mm] corner radius
lid_height = 8; // [mm] lid inner height
lip = 1.5; // [mm] lid overlap lip
tolerance = 0.3; // [mm] fit tolerance
// Screw posts
screw_d = 3; // [mm] screw hole diameter
post_d = 7; // [mm] post outer diameter
post_inset = 5; // [mm] post inset from inner wall
// Ventilation
vent_slots = 5; // number of vent slots
vent_width = 2; // [mm] slot width
vent_length = 20; // [mm] slot length
// --- Quality ---
$fn = 64;
eps = 0.01; // epsilon for clean booleans
// --- Assertions ---
assert(wall >= 1.2, "wall too thin for FDM (min 1.2mm)");
assert(width > 2 * wall, "inner width must be positive");
assert(depth > 2 * wall, "inner depth must be positive");
assert(height > wall, "inner height must be positive");
// --- Render ---
// Show both parts side by side
box_bottom();
translate([width + wall * 2 + 10, 0, 0]) box_lid();
// --- Modules ---
module box_bottom() {
difference() {
// Outer shell
rounded_box([width + 2*wall, depth + 2*wall, height + wall], corner_r);
// Inner cavity
translate([wall, wall, wall])
rounded_box([width, depth, height + wall + 1], max(corner_r - wall, 0.5));
// Ventilation slots on one side
translate([wall + (width - (vent_slots * (vent_width + 3))) / 2, -1, height/2])
for (i = [0:vent_slots-1])
translate([i * (vent_width + 3), 0, 0])
cube([vent_width, wall + 2, vent_length]);
}
// Screw posts
for (pos = screw_post_positions())
translate([pos.x, pos.y, wall])
screw_post(post_d, screw_d, height - 2);
// Lid lip (inner ridge)
difference() {
translate([wall - lip, wall - lip, height + wall - lip])
rounded_box([width + 2*lip, depth + 2*lip, lip], max(corner_r - wall + lip, 0.5));
translate([wall, wall, height + wall - lip - 0.01])
rounded_box([width, depth, lip + 0.02], max(corner_r - wall, 0.5));
}
}
module box_lid() {
difference() {
// Outer lid
rounded_box([width + 2*wall, depth + 2*wall, lid_height + wall], corner_r);
// Inner cavity
translate([wall, wall, -0.01])
rounded_box([width, depth, lid_height + 0.02], max(corner_r - wall, 0.5));
}
// Lip insert (fits inside bottom lip)
translate([wall + tolerance, wall + tolerance, 0])
difference() {
rounded_box(
[width - 2*tolerance, depth - 2*tolerance, lip],
max(corner_r - wall - tolerance, 0.5)
);
translate([lip, lip, -0.01])
rounded_box(
[width - 2*lip - 2*tolerance, depth - 2*lip - 2*tolerance, lip + 0.02],
max(corner_r - wall - lip - tolerance, 0.5)
);
}
}
module rounded_box(size, r) {
hull() {
for (x = [r, size.x - r])
for (y = [r, size.y - r])
translate([x, y, 0])
cylinder(r=r, h=size.z);
}
}
module screw_post(outer_d, inner_d, h) {
difference() {
cylinder(d=outer_d, h=h);
translate([0, 0, -0.01])
cylinder(d=inner_d, h=h + 0.02);
}
}
function screw_post_positions() = [
[wall + post_inset, wall + post_inset, 0],
[wall + width - post_inset, wall + post_inset, 0],
[wall + post_inset, wall + depth - post_inset, 0],
[wall + width - post_inset, wall + depth - post_inset, 0]
];
@@ -0,0 +1,152 @@
// ============================================
// printable-lib.scad — Reusable modules for 3D printing
// Include with: use <printable-lib.scad>
// ============================================
eps = 0.01; // epsilon for clean boolean operations
// --- Clearance helpers ---
// Returns clearance value for different fit types
function fit_clearance(kind="close") =
kind == "press" ? 0.15 :
kind == "close" ? 0.25 :
kind == "loose" ? 0.40 :
kind == "slide" ? 0.30 : 0.25;
// --- Shell / Hollow box ---
module shell_box(outer=[60,40,20], wall=2, floor=2) {
assert(wall >= 1.2, "wall too thin for FDM (min 1.2mm)");
assert(floor >= 0.8, "floor too thin (min 0.8mm)");
difference() {
cube(outer);
translate([wall, wall, floor])
cube([outer.x - 2*wall, outer.y - 2*wall, outer.z - floor + eps]);
}
}
// --- Rounded box (hull-based) ---
module rounded_box(size, r=2) {
hull() {
for (x = [r, size.x - r])
for (y = [r, size.y - r])
translate([x, y, 0])
cylinder(r=r, h=size.z);
}
}
// --- Screw clearance hole ---
module screw_clearance_hole(d=3, h=10, fit="close") {
cylinder(h=h + 2*eps, d=d + fit_clearance(fit), $fn=48);
}
// --- Counterbore hole (for socket head cap screws) ---
// Head pocket at entry side (top), shaft goes through
module counterbore_hole(shaft_d=3, head_d=6, head_h=3, h=12) {
union() {
screw_clearance_hole(shaft_d, h);
translate([0, 0, h - head_h + eps])
cylinder(h=head_h + eps, d=head_d, $fn=48);
}
}
// --- Countersink hole ---
module countersink_hole(d=3, cs_d=6, cs_h=2, h=10) {
union() {
cylinder(d=d, h=h + 2*eps, $fn=48);
translate([0, 0, h - cs_h + eps])
cylinder(d1=d, d2=cs_d, h=cs_h, $fn=48);
}
}
// --- Heat-set insert boss ---
module heatset_boss(insert_d=4.6, insert_h=5, wall=2, h=8) {
assert(wall >= 1.6, "boss wall too thin for heat-set insert");
difference() {
cylinder(h=h, d=insert_d + 2*wall, $fn=64);
translate([0, 0, -eps])
cylinder(h=insert_h + 2*eps, d=insert_d, $fn=64);
}
}
// --- Screw post (solid post with hole) ---
module screw_post(outer_d=7, inner_d=3, h=10) {
difference() {
cylinder(d=outer_d, h=h, $fn=48);
translate([0, 0, -eps])
cylinder(d=inner_d, h=h + 2*eps, $fn=48);
}
}
// --- Structural rib / gusset ---
module rib(len=20, height=12, thick=2) {
linear_extrude(height=thick)
polygon([[0, 0], [len, 0], [0, height]]);
}
// --- Chamfer edge (for print-friendly overhangs) ---
module chamfer_edge(length=10, size=1) {
translate([0, 0, -eps])
linear_extrude(height=length)
polygon([[0, 0], [size, 0], [0, size]]);
}
// --- Snap-fit tab ---
// Creates a cantilever snap tab extending along Y with a hook at the end
module snap_tab(width=8, length=6, thick=1.5, overhang=0.8) {
union() {
// Cantilever arm
cube([width, length, thick]);
// Hook at the end (rotated extrusion for clean manifold)
translate([0, length - eps, 0])
rotate([90, 0, 90])
linear_extrude(height=width)
polygon([[0, 0], [thick + eps, 0], [thick/2, overhang]]);
}
}
// --- Text emboss/deboss helper ---
// Use with difference() to deboss or union() to emboss
module text_label(txt="Label", size=8, depth=1, font="Liberation Sans:style=Bold",
halign="center", valign="center") {
linear_extrude(height=depth)
text(txt, size=size, font=font, halign=halign, valign=valign);
}
// --- Ventilation grille ---
module vent_grille(area_w=30, area_h=15, slot_w=2, slot_gap=2, depth=2) {
n_slots = floor(area_h / (slot_w + slot_gap));
for (i = [0:n_slots-1])
translate([0, i * (slot_w + slot_gap), 0])
cube([area_w, slot_w, depth + 2*eps]);
}
// --- PCB standoff array ---
module pcb_standoffs(positions, height=5, outer_d=6, hole_d=2.5) {
for (pos = positions)
translate(pos)
difference() {
cylinder(d=outer_d, h=height, $fn=32);
translate([0, 0, -eps])
cylinder(d=hole_d, h=height + 2*eps, $fn=32);
}
}
// --- Profile with rounded corners (2D) ---
// Use with linear_extrude() — preferred over hull() of cylinders
module rounded_rect_2d(size, r=2) {
offset(r=r)
square([size.x - 2*r, size.y - 2*r], center=true);
}
// --- Lid lip (for box closures) ---
module lid_lip(outer_size, wall=2, lip_h=2, lip_w=1.2, tol=0.25) {
difference() {
rounded_box([outer_size.x, outer_size.y, lip_h], r=2);
translate([lip_w + tol, lip_w + tol, -eps])
rounded_box([
outer_size.x - 2*(lip_w + tol),
outer_size.y - 2*(lip_w + tol),
lip_h + 2*eps
], r=max(2 - lip_w, 0.5));
}
}