From c6bbebea041191d071d1ce1b6fcdb9f2cf531dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Tr=C3=B6ger?= Date: Wed, 15 Jul 2026 14:30:52 +0200 Subject: [PATCH] feat(skills): OpenSCAD-Skill hinzufuegen --- skills/openscad/README.md | 278 ++++++ skills/openscad/SKILL.md | 917 ++++++++++++++++++ .../openscad/references/language-reference.md | 188 ++++ .../references/reconstruction-guide.md | 241 +++++ .../scripts/openscad-adaptive-slice.py | 320 ++++++ .../scripts/openscad-profile-extract.py | 320 ++++++ skills/openscad/scripts/openscad-project.sh | 187 ++++ skills/openscad/scripts/openscad-render.sh | 397 ++++++++ .../openscad/scripts/openscad-sdf-optimize.py | 342 +++++++ .../openscad/scripts/openscad-stl-analyze.sh | 214 ++++ .../openscad/scripts/openscad-stl-compare.sh | 220 +++++ .../scripts/openscad-stl-reconstruct.sh | 215 ++++ skills/openscad/scripts/openscad-validate.sh | 92 ++ skills/openscad/templates/bracket.scad | 69 ++ skills/openscad/templates/enclosure.scad | 119 +++ skills/openscad/templates/printable-lib.scad | 152 +++ 16 files changed, 4271 insertions(+) create mode 100644 skills/openscad/README.md create mode 100644 skills/openscad/SKILL.md create mode 100644 skills/openscad/references/language-reference.md create mode 100644 skills/openscad/references/reconstruction-guide.md create mode 100755 skills/openscad/scripts/openscad-adaptive-slice.py create mode 100755 skills/openscad/scripts/openscad-profile-extract.py create mode 100755 skills/openscad/scripts/openscad-project.sh create mode 100755 skills/openscad/scripts/openscad-render.sh create mode 100755 skills/openscad/scripts/openscad-sdf-optimize.py create mode 100755 skills/openscad/scripts/openscad-stl-analyze.sh create mode 100755 skills/openscad/scripts/openscad-stl-compare.sh create mode 100755 skills/openscad/scripts/openscad-stl-reconstruct.sh create mode 100755 skills/openscad/scripts/openscad-validate.sh create mode 100644 skills/openscad/templates/bracket.scad create mode 100644 skills/openscad/templates/enclosure.scad create mode 100644 skills/openscad/templates/printable-lib.scad diff --git a/skills/openscad/README.md b/skills/openscad/README.md new file mode 100644 index 0000000..af487ea --- /dev/null +++ b/skills/openscad/README.md @@ -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 # Single isometric preview +bash scripts/openscad-render.sh preview # 4-view (iso, front, right, top) +bash scripts/openscad-render.sh stl [-D ...] # Export STL +bash scripts/openscad-render.sh 3mf # Export 3MF +bash scripts/openscad-render.sh export # Full export (STL + 3MF + PNG) +bash scripts/openscad-render.sh analyze # Printability analysis +bash scripts/openscad-render.sh custom [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 + +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. diff --git a/skills/openscad/SKILL.md b/skills/openscad/SKILL.md new file mode 100644 index 0000000..b4c5750 --- /dev/null +++ b/skills/openscad/SKILL.md @@ -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: "" +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 "" +``` + +This creates `~/openscad-projects//` with subdirectories for source, output, and previews. + +### Step 3: Generate the .scad File + +Write the OpenSCAD code to `~/openscad-projects//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//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//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 "" +``` + +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//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//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 ); 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 "-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//src/main.scad + +# Run mesh comparison +bash ~/.claude/skills/openscad/scripts/openscad-stl-compare.sh \ + path/to/original.stl \ + ~/openscad-projects//output/main.stl \ + ~/openscad-projects//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 + +# Multi-angle preview (4 views) +openscad-render.sh preview + +# Export STL only +openscad-render.sh stl [-D 'var=val' ...] + +# Export all formats (STL + 3MF + PNG) +openscad-render.sh export [-D 'var=val' ...] + +# Analyze printability +openscad-render.sh analyze + +# Custom render +openscad-render.sh custom --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 + +# List projects +openscad-project.sh list + +# Clean build artifacts +openscad-project.sh clean +``` + +--- + +## OpenSCAD Code Guidelines + +### File Structure Convention + +```openscad +// ============================================ +// Project: +// Description: +// 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 ` 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,` | +| Back | `--camera 0,0,0,90,0,180,` | +| Right | `--camera 0,0,0,90,0,90,` | +| Left | `--camera 0,0,0,90,0,270,` | +| Top | `--camera 0,0,0,0,0,0,` | +| Bottom | `--camera 0,0,0,180,0,0,` | +| Isometric | `--autocenter --viewall` (default) | +| 3/4 view | `--camera 0,0,0,55,0,25,` | + +Use `--autocenter --viewall` to auto-calculate distance, or specify explicit distance for consistent framing across iterations. diff --git a/skills/openscad/references/language-reference.md b/skills/openscad/references/language-reference.md new file mode 100644 index 0000000..bfd4773 --- /dev/null +++ b/skills/openscad/references/language-reference.md @@ -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 // include (executes top-level code) +use // 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 (0–1) | +| `$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 diff --git a/skills/openscad/references/reconstruction-guide.md b/skills/openscad/references/reconstruction-guide.md new file mode 100644 index 0000000..f45cba4 --- /dev/null +++ b/skills/openscad/references/reconstruction-guide.md @@ -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 = ) + polygon( + points = [], + paths = [, , ] + ); +``` + +### 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 ); 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. diff --git a/skills/openscad/scripts/openscad-adaptive-slice.py b/skills/openscad/scripts/openscad-adaptive-slice.py new file mode 100755 index 0000000..3e51d41 --- /dev/null +++ b/skills/openscad/scripts/openscad-adaptive-slice.py @@ -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 [--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() diff --git a/skills/openscad/scripts/openscad-profile-extract.py b/skills/openscad/scripts/openscad-profile-extract.py new file mode 100755 index 0000000..3b19515 --- /dev/null +++ b/skills/openscad/scripts/openscad-profile-extract.py @@ -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 [--output ] [--simplify ] +""" + +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() diff --git a/skills/openscad/scripts/openscad-project.sh b/skills/openscad/scripts/openscad-project.sh new file mode 100755 index 0000000..ab720b6 --- /dev/null +++ b/skills/openscad/scripts/openscad-project.sh @@ -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 [args] + +Commands: + init Create a new project directory + list List all projects + clean Remove build artifacts (keep source) + info 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" < "$project_dir/README.md" </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 diff --git a/skills/openscad/scripts/openscad-render.sh b/skills/openscad/scripts/openscad-render.sh new file mode 100755 index 0000000..b8ca148 --- /dev/null +++ b/skills/openscad/scripts/openscad-render.sh @@ -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 [options] + +Commands: + quick Single isometric preview PNG + preview Multi-angle preview (4 views) + stl [-D ...] Export STL + 3mf [-D ...] Export 3MF + export [-D ...] Export STL + 3MF + final PNG + analyze Render analysis views (cross-sections, bottom) + custom [options] Custom render with full control + +Options (for custom): + --format Output format (png, stl, 3mf, amf, svg, dxf, pdf) + --imgsize Image dimensions + --camera Camera: translate_x,y,z,rot_x,y,z,dist + --colorscheme 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 diff --git a/skills/openscad/scripts/openscad-sdf-optimize.py b/skills/openscad/scripts/openscad-sdf-optimize.py new file mode 100755 index 0000000..18bef27 --- /dev/null +++ b/skills/openscad/scripts/openscad-sdf-optimize.py @@ -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 [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}") diff --git a/skills/openscad/scripts/openscad-stl-analyze.sh b/skills/openscad/scripts/openscad-stl-analyze.sh new file mode 100755 index 0000000..b9d00dd --- /dev/null +++ b/skills/openscad/scripts/openscad-stl-analyze.sh @@ -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 [--cross-section ] [--gaps ] + +Commands: + Full bounding box and triangle count + --cross-section Show vertex distribution at a cross-section + axis: x, y, or z; value: coordinate + --gaps 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(' (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(' 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(' 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 diff --git a/skills/openscad/scripts/openscad-stl-compare.sh b/skills/openscad/scripts/openscad-stl-compare.sh new file mode 100755 index 0000000..9f728b3 --- /dev/null +++ b/skills/openscad/scripts/openscad-stl-compare.sh @@ -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 [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: + /diff-A-minus-B.png — Geometry in original but NOT in reconstruction + /diff-B-minus-A.png — Geometry in reconstruction but NOT in original + /overlay.png — Both overlaid (original=transparent, recon=red) + /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('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('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" diff --git a/skills/openscad/scripts/openscad-stl-reconstruct.sh b/skills/openscad/scripts/openscad-stl-reconstruct.sh new file mode 100755 index 0000000..1fd4c0b --- /dev/null +++ b/skills/openscad/scripts/openscad-stl-reconstruct.sh @@ -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 + +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: + /report.txt Full analysis report + /slices/slice-z*.svg 2D profile SVGs at each Z level + /slices/slice-z*.png Rendered slice images + /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 ===" diff --git a/skills/openscad/scripts/openscad-validate.sh b/skills/openscad/scripts/openscad-validate.sh new file mode 100755 index 0000000..85218bf --- /dev/null +++ b/skills/openscad/scripts/openscad-validate.sh @@ -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 [-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" diff --git a/skills/openscad/templates/bracket.scad b/skills/openscad/templates/bracket.scad new file mode 100644 index 0000000..3f0e91b --- /dev/null +++ b/skills/openscad/templates/bracket.scad @@ -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); + } + } +} diff --git a/skills/openscad/templates/enclosure.scad b/skills/openscad/templates/enclosure.scad new file mode 100644 index 0000000..e95668f --- /dev/null +++ b/skills/openscad/templates/enclosure.scad @@ -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] +]; diff --git a/skills/openscad/templates/printable-lib.scad b/skills/openscad/templates/printable-lib.scad new file mode 100644 index 0000000..84959a6 --- /dev/null +++ b/skills/openscad/templates/printable-lib.scad @@ -0,0 +1,152 @@ +// ============================================ +// printable-lib.scad — Reusable modules for 3D printing +// Include with: use +// ============================================ + +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)); + } +}