Compare commits
8
Commits
ef77a35e90
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c586a753d | ||
|
|
1e55847426 | ||
|
|
2e42790085 | ||
|
|
686b60164f | ||
|
|
af40546164 | ||
|
|
c6bbebea04 | ||
|
|
316b779b06 | ||
|
|
c59baba62f |
@@ -60,6 +60,7 @@ PRs muessen jedes Akzeptanzkriterium einzeln als Checkbox auflisten und bestaeti
|
|||||||
|
|
||||||
Niemals direkt auf `main` committen. Alle Aenderungen ueber Feature-Branches und PRs.
|
Niemals direkt auf `main` committen. Alle Aenderungen ueber Feature-Branches und PRs.
|
||||||
|
|
||||||
|
- **Ausnahme `.commit-on-main`:** Vor dem Branchen im Repo-Root (`git rev-parse --show-toplevel`) auf die Marker-Datei `.commit-on-main` pruefen. Existiert sie, sind Direkt-Commits auf `main` erlaubt — dann kein Feature-Branch und kein PR, direkt auf `main` committen und pushen.
|
||||||
- **Kleinteilige Commits:** Pro Commit nur ein einziges Thema. Nur die zum Thema gehörenden Dateien committen — keine thematisch gemischten Commits.
|
- **Kleinteilige Commits:** Pro Commit nur ein einziges Thema. Nur die zum Thema gehörenden Dateien committen — keine thematisch gemischten Commits.
|
||||||
- **Commit-Messages:** Conventional Commits, deutsche Beschreibung
|
- **Commit-Messages:** Conventional Commits, deutsche Beschreibung
|
||||||
- Subject: `<type>: <Beschreibung>` — max 72 Zeichen, kein Punkt am Ende
|
- Subject: `<type>: <Beschreibung>` — max 72 Zeichen, kein Punkt am Ende
|
||||||
@@ -124,6 +125,9 @@ Der User ist Informatiker/Techniker und will präzise Kommunikation mit den wese
|
|||||||
- **Umfang an die konkrete Frage koppeln.** „Kann man X?" → ja/nein + Grund. Ein Artefakt (Diagramm, Tabelle, Umbau) nur liefern, wenn es explizit verlangt wurde — dann aber vollständig und **nicht** um ungefragte Zusätze erweitert.
|
- **Umfang an die konkrete Frage koppeln.** „Kann man X?" → ja/nein + Grund. Ein Artefakt (Diagramm, Tabelle, Umbau) nur liefern, wenn es explizit verlangt wurde — dann aber vollständig und **nicht** um ungefragte Zusätze erweitert.
|
||||||
- **`Hinweis:`-Konvention statt Absätzen.** Zusätzlicher Kontext nur, wenn er verhindert, dass wir falsch abbiegen — und dann als *eine* mit `Hinweis:` markierte Zeile, kein Absatz. Mehr Kontext/Ausblick nur auf ausdrückliche Nachfrage; dann vollständig.
|
- **`Hinweis:`-Konvention statt Absätzen.** Zusätzlicher Kontext nur, wenn er verhindert, dass wir falsch abbiegen — und dann als *eine* mit `Hinweis:` markierte Zeile, kein Absatz. Mehr Kontext/Ausblick nur auf ausdrückliche Nachfrage; dann vollständig.
|
||||||
- **Belege statt Ausbreitung.** `datei:zeile` statt den Code nachzuerzählen.
|
- **Belege statt Ausbreitung.** `datei:zeile` statt den Code nachzuerzählen.
|
||||||
|
- **Keine Doppelung Chat/Datei.** Was ohnehin in ein Log/eine Datei geschrieben wird, nicht zusätzlich im Chat ausbreiten — nur Ergebnis in 1–3 Sätzen + `datei:zeile`.
|
||||||
|
- **Analyse als Stichpunkte,** nicht als durchargumentierter Fließtext. Belege/Tabellen nur auf ausdrückliche Nachfrage.
|
||||||
|
- **Kein „nächste Schritte"-/Kontext-Recap,** außer explizit gewünscht.
|
||||||
|
|
||||||
## MCP
|
## MCP
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -62,8 +62,17 @@
|
|||||||
],
|
],
|
||||||
"defaultMode": "default"
|
"defaultMode": "default"
|
||||||
},
|
},
|
||||||
"model": "opus",
|
|
||||||
"hooks": {
|
"hooks": {
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash ~/.claude/hooks/inject-answer-rules.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
{
|
{
|
||||||
"matcher": "Bash",
|
"matcher": "Bash",
|
||||||
@@ -111,7 +120,8 @@
|
|||||||
"skill-creator@claude-plugins-official": true
|
"skill-creator@claude-plugins-official": true
|
||||||
},
|
},
|
||||||
"language": "German",
|
"language": "German",
|
||||||
|
"tui": "fullscreen",
|
||||||
"editorMode": "vim",
|
"editorMode": "vim",
|
||||||
"terminalProgressBarEnabled": false,
|
"terminalProgressBarEnabled": false,
|
||||||
"tui": "fullscreen"
|
"model": "opus"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
Der User ist hochkonzentrierter Programmierer. Jedes nicht-wesentliche Wort kostet ihn Lesezeit und Fokus. Maximale Informationsdichte, minimale Wortzahl. Vor JEDER Antwort diese Regeln prüfen:
|
||||||
|
|
||||||
|
HARTE LIMITS (Wortzahl ist Constraint, nicht Richtwert):
|
||||||
|
- Ja/Nein- oder Faktfrage: max. 2 Sätze. Antwort im ERSTEN Satz.
|
||||||
|
- Erklärung/Konzept: max. 6 Sätze plus optional 1 Belegzeile.
|
||||||
|
- Codeänderung: Diff bzw. datei:zeile + max. 1 Satz. Code nicht in Prosa nacherzählen.
|
||||||
|
- Aufzählbares immer als Bullet/Tabelle, nie als Fließtext. Keine Liste unter 3 Punkten — dann Sätze.
|
||||||
|
|
||||||
|
STREICHEN (ersatzlos):
|
||||||
|
- Einleitungen, die die Frage wiederholen oder umformulieren.
|
||||||
|
- Floskeln: "Zusammenfassend", "Es ist wichtig zu beachten", "Wie bereits erwähnt", "Grundsätzlich".
|
||||||
|
- Abschluss-Anhängsel: "Soll ich ...?", "Lass mich wissen ...", ungefragter Ausblick/nächste Schritte.
|
||||||
|
- Ungefragte Optionen, Alternativen, Empfehlungen, Trade-off-Exkurse.
|
||||||
|
- Hedging: keine abgesicherten Rundum-Varianten. Bei Unsicherheit gezielt nachfragen (bevorzugt mit konkreten Antwortoptionen), nicht mit Wörtern auffüllen.
|
||||||
|
|
||||||
|
STRUKTUR:
|
||||||
|
- Antwort/Ergebnis zuerst. Herleitung und Begründung nur auf ausdrückliche Nachfrage.
|
||||||
|
- Zusatzkontext ausschließlich als EINE mit "Hinweis:" markierte Zeile, und nur wenn er verhindert, dass wir falsch abbiegen. Kein Absatz.
|
||||||
|
- Belegen statt ausbreiten: datei:zeile statt Zitat/Nacherzählung.
|
||||||
|
|
||||||
|
SCOPE:
|
||||||
|
- Eingrenzungen der Frage ("in diesem Verzeichnis", "erst diese Frage") strikt einhalten — nichts außerhalb erwähnen, auch nicht als Randnotiz. Als Constraint für Werkzeugaufrufe übernehmen (z. B. Pfad-Argument), nicht die Ausgabe nachträglich filtern.
|
||||||
|
|
||||||
|
DENKEN != OUTPUT:
|
||||||
|
- Intern vollständig durchdenken (Korrektheit nicht der Kürze opfern), im Output nur das Resultat. Kürze gilt für die Antwort, nicht für die Analyse.
|
||||||
|
|
||||||
|
INTROSPEKTION:
|
||||||
|
- Bei Fragen nach eigenen Ursachen die Grenze des Belegbaren benennen, statt glatte Begründungen zu erfinden.
|
||||||
|
|
||||||
|
KALIBRIERUNG (so sieht eine gute Antwort aus):
|
||||||
|
Frage: "Greift der Hook?"
|
||||||
|
Gut: "Ja. Kommt als system-reminder an, deshalb im Chat unsichtbar für dich."
|
||||||
|
Schlecht: "Das ist eine gute Frage! Lass uns das prüfen. Grundsätzlich werden Hooks ... [5 Sätze Herleitung] ... Zusammenfassend: ja, er greift. Soll ich die Konfiguration zeigen?"
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# UserPromptSubmit-Hook: Injiziert die Antwort-Checkliste in den Kontext,
|
||||||
|
# damit die Prägnanz-Regeln im Moment der Generierung präsent sind.
|
||||||
|
# stdout wird bei UserPromptSubmit dem Modell-Kontext hinzugefügt.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CHECKLIST="$(dirname "$(readlink -f "$0")")/antwort-checkliste.txt"
|
||||||
|
[[ -f "$CHECKLIST" ]] || exit 0
|
||||||
|
|
||||||
|
echo "Vor dem Antworten gegen diese Regeln prüfen:"
|
||||||
|
cat "$CHECKLIST"
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -22,8 +22,10 @@ extract_subject() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Git-Commit-Pruefungen
|
# Git-Commit-Pruefungen (uebersprungen bei SSH-gewrappten Befehlen: die committen
|
||||||
if echo "$cmd" | grep -qE '^\s*git\s+commit\b'; then
|
# auf einem Remote-Repo, dessen Branch lokal nicht ermittelbar ist - Fehlalarm der
|
||||||
|
# main-Pruefung; der Remote-Host hat eigene Guards)
|
||||||
|
if echo "$cmd" | grep -qE '^\s*git\s+commit\b' && ! echo "$cmd" | grep -qE '^\s*ssh\b'; then
|
||||||
# Branch-Schutz: kein direkter Commit auf main (sofern kein Opt-out per Markerdatei)
|
# Branch-Schutz: kein direkter Commit auf main (sofern kein Opt-out per Markerdatei)
|
||||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
||||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
|
repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# OpenSCAD Claude Code Skill
|
||||||
|
|
||||||
|
A Claude Code skill for programmatic 3D CAD using [OpenSCAD](https://openscad.org/). Design, preview, iterate, reconstruct from STL, and export 3D-printable models — with AI-driven visual feedback and automated mesh comparison.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Programmatic 3D modeling** — Generate `.scad` files from natural language descriptions
|
||||||
|
- **AI vision feedback loop** — Render multi-angle PNG previews and analyze them to iteratively refine designs
|
||||||
|
- **STL-to-SCAD reconstruction** — Reverse-engineer STL meshes into parametric OpenSCAD code using SVG profiling and SDF optimization
|
||||||
|
- **Image-to-CAD replication** — Reproduce physical objects from reference photos
|
||||||
|
- **Parametric design** — All dimensions as variables, overridable via CLI `-D` flags
|
||||||
|
- **Mesh comparison** — Boolean diff rendering and IoU scoring to verify reconstruction accuracy
|
||||||
|
- **Printability analysis** — Wall thickness, overhang, and manifold validation
|
||||||
|
- **Reusable module library** — Common 3D printing patterns (counterbores, heat-set bosses, ribs, snap-fits)
|
||||||
|
- **Eval framework** — Automated testing with 20 binary assertions across 4 scenarios (100% pass rate)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **OpenSCAD** installed and accessible via CLI
|
||||||
|
```bash
|
||||||
|
brew install openscad # macOS
|
||||||
|
```
|
||||||
|
- **Python packages** (for STL reconstruction):
|
||||||
|
```bash
|
||||||
|
pip3 install trimesh numpy scipy rtree shapely
|
||||||
|
```
|
||||||
|
- **admesh** (for mesh validation):
|
||||||
|
```bash
|
||||||
|
brew install admesh
|
||||||
|
```
|
||||||
|
- **Claude Code** CLI installed
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Option 1: Clone and symlink (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/andreahaku/openscad_claude_skill.git ~/Development/Claude/openscad_claude_skill
|
||||||
|
|
||||||
|
mkdir -p ~/.claude/skills
|
||||||
|
ln -sf ~/Development/Claude/openscad_claude_skill ~/.claude/skills/openscad
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Direct clone into skills
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/andreahaku/openscad_claude_skill.git ~/.claude/skills/openscad
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openscad --version
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh quick ~/.claude/skills/openscad/templates/bracket.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The skill triggers automatically when you mention 3D modeling, CAD, STL export, or OpenSCAD:
|
||||||
|
|
||||||
|
```
|
||||||
|
/openscad design a phone stand with adjustable angle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modes
|
||||||
|
|
||||||
|
| Mode | Trigger | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| **Design** | "design a bracket", "make a box" | Create new 3D models from descriptions |
|
||||||
|
| **Replicate** | "reproduce this from the photo" | Reverse-engineer objects from reference images |
|
||||||
|
| **Reconstruct** | "convert this STL to SCAD" | Reverse-engineer STL meshes into parametric code |
|
||||||
|
| **Refine** | "make it taller", "add fillets" | Iterate on existing designs |
|
||||||
|
| **Export** | "export STL", "ready for printing" | Generate production files |
|
||||||
|
| **Analyze** | "check printability" | Validate designs for 3D printing |
|
||||||
|
|
||||||
|
### Example Workflows
|
||||||
|
|
||||||
|
**Design from scratch:**
|
||||||
|
```
|
||||||
|
> Design a parametric enclosure for a Raspberry Pi 4 with ventilation slots
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reconstruct from STL:**
|
||||||
|
```
|
||||||
|
> Convert /path/to/model.stl into parametric OpenSCAD code
|
||||||
|
```
|
||||||
|
|
||||||
|
**Export with parameter overrides:**
|
||||||
|
```
|
||||||
|
> Export the bracket with width=100 and wall=3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
openscad_claude_skill/
|
||||||
|
├── SKILL.md # Skill definition (6 modes, 762 lines)
|
||||||
|
├── README.md # This file
|
||||||
|
├── scripts/
|
||||||
|
│ ├── openscad-render.sh # Core render/export/preview engine (7 commands)
|
||||||
|
│ ├── openscad-project.sh # Project scaffolding (init/list/clean/info)
|
||||||
|
│ ├── openscad-validate.sh # Strict validation with error categorization
|
||||||
|
│ ├── openscad-stl-analyze.sh # STL mesh analysis (bbox, cross-sections, gaps)
|
||||||
|
│ ├── openscad-stl-reconstruct.sh # Automated reconstruction pipeline (SVG profiling)
|
||||||
|
│ ├── openscad-stl-compare.sh # Mesh comparison (boolean diff, accuracy %)
|
||||||
|
│ └── openscad-sdf-optimize.py # SDF parameter optimizer (IoU scoring)
|
||||||
|
├── references/
|
||||||
|
│ ├── language-reference.md # Complete OpenSCAD v2021.01 cheat sheet
|
||||||
|
│ └── reconstruction-guide.md # Best practices for STL-to-SCAD reconstruction
|
||||||
|
├── templates/
|
||||||
|
│ ├── enclosure.scad # Parametric electronics box with lid
|
||||||
|
│ ├── bracket.scad # L-bracket with countersunk holes
|
||||||
|
│ └── printable-lib.scad # Reusable 3D printing modules
|
||||||
|
└── eval/
|
||||||
|
├── eval.json # 4 test scenarios, 20 binary assertions
|
||||||
|
└── results.jsonl # Test results log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
### `openscad-render.sh` — Core Rendering Engine
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/openscad-render.sh quick <file.scad> # Single isometric preview
|
||||||
|
bash scripts/openscad-render.sh preview <file.scad> # 4-view (iso, front, right, top)
|
||||||
|
bash scripts/openscad-render.sh stl <file.scad> [-D ...] # Export STL
|
||||||
|
bash scripts/openscad-render.sh 3mf <file.scad> # Export 3MF
|
||||||
|
bash scripts/openscad-render.sh export <file.scad> # Full export (STL + 3MF + PNG)
|
||||||
|
bash scripts/openscad-render.sh analyze <file.scad> # Printability analysis
|
||||||
|
bash scripts/openscad-render.sh custom <file.scad> [opts] # Custom render
|
||||||
|
```
|
||||||
|
|
||||||
|
### `openscad-stl-reconstruct.sh` — Automated STL Analysis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/openscad-stl-reconstruct.sh model.stl output_dir/
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs the full analysis pipeline:
|
||||||
|
1. **trimesh** mesh analysis (volume, watertight, normals, bounding cylinder)
|
||||||
|
2. **SVG profiling** via OpenSCAD `projection(cut=true)` at 5 Z levels
|
||||||
|
3. **Primitive detection** (RANSAC axis estimation, normal-based CSG inference)
|
||||||
|
|
||||||
|
Output: `mesh-info.json`, `primitives.json`, SVG slice files
|
||||||
|
|
||||||
|
### `openscad-stl-compare.sh` — Mesh Comparison
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/openscad-stl-compare.sh original.stl reconstruction.stl output_dir/
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces:
|
||||||
|
- **diff-A-minus-B.png** — Geometry in original but MISSING from reconstruction
|
||||||
|
- **diff-B-minus-A.png** — EXTRA geometry in reconstruction
|
||||||
|
- **overlay.png** — Both models overlaid
|
||||||
|
- **Geometric accuracy %** — Based on volume of boolean differences
|
||||||
|
|
||||||
|
### `openscad-sdf-optimize.py` — Parameter Optimizer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/openscad-sdf-optimize.py model.stl stadium-slot --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses Signed Distance Fields + IoU scoring to find optimal parameters without invoking OpenSCAD in the loop. Converges in seconds via `scipy.optimize.minimize`.
|
||||||
|
|
||||||
|
### `openscad-stl-analyze.sh` — Raw Mesh Analysis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/openscad-stl-analyze.sh model.stl # Full analysis
|
||||||
|
bash scripts/openscad-stl-analyze.sh model.stl --cross-section z 5.0 # Slice at Z=5
|
||||||
|
bash scripts/openscad-stl-analyze.sh model.stl --gaps y # Find Y-axis gaps
|
||||||
|
```
|
||||||
|
|
||||||
|
## STL-to-SCAD Reconstruction
|
||||||
|
|
||||||
|
The reconstruction pipeline converts triangle meshes into clean, parametric OpenSCAD code:
|
||||||
|
|
||||||
|
### The Sculptor Approach
|
||||||
|
|
||||||
|
All reconstructions follow the sculptor method — start from a solid block, subtract everything:
|
||||||
|
|
||||||
|
```openscad
|
||||||
|
difference() {
|
||||||
|
solid_body(); // 1. Full solid first
|
||||||
|
channels(); // 2. Subtract channels/slots
|
||||||
|
taper_cuts(); // 3. Subtract wedges
|
||||||
|
all_holes(); // 4. ALL holes LAST
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Two-Tier Pipeline
|
||||||
|
|
||||||
|
1. **SVG Profile Analysis** (fast) — `openscad-stl-reconstruct.sh` identifies the model topology by slicing at multiple Z levels
|
||||||
|
2. **SDF Optimization** (precise) — `openscad-sdf-optimize.py` finds exact parameters via IoU scoring
|
||||||
|
|
||||||
|
### Proven Results
|
||||||
|
|
||||||
|
| Model | Complexity | Accuracy | Iterations |
|
||||||
|
|-------|-----------|----------|------------|
|
||||||
|
| Toothpaste Squeezer | Simple (1180 tri) | 96.27% | 2 |
|
||||||
|
| Interior Bracket | Complex (7576 tri) | 95.63% | 8 |
|
||||||
|
|
||||||
|
See `references/reconstruction-guide.md` for the complete best practices guide.
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
### `printable-lib.scad` — Reusable Modules
|
||||||
|
|
||||||
|
```openscad
|
||||||
|
use <printable-lib.scad>
|
||||||
|
|
||||||
|
shell_box(outer=[60,40,20], wall=2, floor=2);
|
||||||
|
rounded_box([50,30,10], r=3);
|
||||||
|
screw_clearance_hole(d=3, h=10, fit="close");
|
||||||
|
counterbore_hole(shaft_d=3, head_d=6, head_h=3, h=12);
|
||||||
|
countersink_hole(d=3, cs_d=6, cs_h=2, h=10);
|
||||||
|
heatset_boss(insert_d=4.6, insert_h=5, wall=2, h=8);
|
||||||
|
screw_post(outer_d=7, inner_d=3, h=10);
|
||||||
|
rib(len=20, height=12, thick=2);
|
||||||
|
snap_tab(width=8, length=6, thick=1.5, overhang=0.8);
|
||||||
|
text_label("Hello", size=8, depth=1);
|
||||||
|
|
||||||
|
fit_clearance("press"); // 0.15mm
|
||||||
|
fit_clearance("close"); // 0.25mm
|
||||||
|
fit_clearance("slide"); // 0.30mm
|
||||||
|
fit_clearance("loose"); // 0.40mm
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3D Printing Guidelines
|
||||||
|
|
||||||
|
- **Wall thickness**: min 1.2mm (FDM with 0.4mm nozzle)
|
||||||
|
- **Clearance**: 0.2-0.3mm for fitting parts
|
||||||
|
- **Overhangs**: < 45° from vertical; prefer chamfers on downward faces
|
||||||
|
- **Epsilon** (`eps = 0.01`) in all boolean operations
|
||||||
|
- **Flat bottoms** for bed adhesion
|
||||||
|
- **`assert()`** for self-validating parametric models
|
||||||
|
- **Counterbore vs countersink**: always check reference images
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `OPENSCAD_BIN` | `$(command -v openscad)` | Path to OpenSCAD binary |
|
||||||
|
| `OPENSCAD_IMGSIZE` | `800,600` | Default preview image size |
|
||||||
|
| `OPENSCAD_COLORSCHEME` | `DeepOcean` | Default color scheme |
|
||||||
|
|
||||||
|
## Eval Framework
|
||||||
|
|
||||||
|
The skill includes automated testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls eval/
|
||||||
|
# eval.json — 4 test scenarios, 20 binary assertions
|
||||||
|
# results.jsonl — Test results log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Baseline: 100% pass rate (20/20)** across:
|
||||||
|
- Design (simple box + sculptor approach)
|
||||||
|
- STL reconstruction (96.27% geometric accuracy)
|
||||||
|
- Parametric export with -D overrides
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make changes
|
||||||
|
4. Run: `bash scripts/openscad-render.sh quick templates/bracket.scad`
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Built with Claude Code (Anthropic), with architectural input from Gemini (Google) and Codex (OpenAI).
|
||||||
|
|
||||||
|
Powered by [OpenSCAD](https://openscad.org/) — The Programmers Solid 3D CAD Modeller.
|
||||||
@@ -0,0 +1,917 @@
|
|||||||
|
---
|
||||||
|
disable-model-invocation: true
|
||||||
|
name: openscad
|
||||||
|
description: >
|
||||||
|
Programmatic 3D CAD with OpenSCAD. Generate .scad files, render STL for 3D printing,
|
||||||
|
preview as PNG with AI vision feedback. Triggers on: 3D model, STL, 3D print, parametric
|
||||||
|
design, openscad, CAD, enclosure, bracket, or any 3D modeling task.
|
||||||
|
argument-hint: "<description of object to design or path to existing .scad file>"
|
||||||
|
allowed-tools: "Bash(*),Read,Edit,Write,Glob,Grep,Agent"
|
||||||
|
metadata:
|
||||||
|
version: 1.0.0
|
||||||
|
category: 3d-cad
|
||||||
|
tags: [openscad, 3d-printing, cad, parametric, stl, modeling, design]
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenSCAD Skill
|
||||||
|
|
||||||
|
Design, render, preview, and export 3D models using OpenSCAD's programmatic CAD engine. Supports iterative AI-driven design refinement via rendered PNG analysis.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- **OpenSCAD binary**: `/opt/homebrew/bin/openscad` (v2021.01)
|
||||||
|
- **Working directory for designs**: `~/openscad-projects/` (create per-project subdirectories)
|
||||||
|
- **Skill scripts**: `~/.claude/skills/openscad/scripts/`
|
||||||
|
- **Templates**: `~/.claude/skills/openscad/templates/`
|
||||||
|
- **Language reference**: `~/.claude/skills/openscad/references/`
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
The skill operates in six modes, auto-detected from the user's request:
|
||||||
|
|
||||||
|
- **Design** — Create a new 3D model from a description
|
||||||
|
- **Replicate** — Reproduce a physical object from reference images
|
||||||
|
- **Reconstruct** — Reverse-engineer an STL mesh into parametric OpenSCAD code
|
||||||
|
- **Refine** — Iterate on an existing .scad file (modify, preview, repeat)
|
||||||
|
- **Export** — Render final STL/3MF for 3D printing
|
||||||
|
- **Analyze** — Review an existing design for printability or improvements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Design Mode
|
||||||
|
|
||||||
|
When the user asks to create a new 3D object:
|
||||||
|
|
||||||
|
### Step 1: Understand Requirements
|
||||||
|
|
||||||
|
Clarify with the user:
|
||||||
|
- **What** is the object? (enclosure, bracket, gear, container, etc.)
|
||||||
|
- **Dimensions** — key measurements in mm
|
||||||
|
- **Purpose** — functional print, aesthetic, mechanical fit?
|
||||||
|
- **Constraints** — printer bed size, material, wall thickness preferences
|
||||||
|
- **Parametric?** — which dimensions should be adjustable?
|
||||||
|
|
||||||
|
### Step 2: Set Up Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-project.sh init "<project-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `~/openscad-projects/<project-name>/` with subdirectories for source, output, and previews.
|
||||||
|
|
||||||
|
### Step 3: Generate the .scad File
|
||||||
|
|
||||||
|
Write the OpenSCAD code to `~/openscad-projects/<project-name>/src/main.scad`.
|
||||||
|
|
||||||
|
**Mandatory file structure (Feature Tree pattern):**
|
||||||
|
```openscad
|
||||||
|
// 1. PARAMETERS (independent variables)
|
||||||
|
width = 60; height = 30; wall = 2;
|
||||||
|
|
||||||
|
// 2. DERIVED DIMENSIONS (calculated from parameters)
|
||||||
|
inner_width = width - 2 * wall;
|
||||||
|
|
||||||
|
// 3. BASE PROFILE (2D sketch — the core shape)
|
||||||
|
module sketch_base() {
|
||||||
|
offset(r = corner_r)
|
||||||
|
square([width - 2*corner_r, depth - 2*corner_r], center=true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. PRIMARY BODY (extrude the sketch)
|
||||||
|
module body() { linear_extrude(height = height) sketch_base(); }
|
||||||
|
|
||||||
|
// 5. ADDITIVE FEATURES (bosses, ribs, tabs)
|
||||||
|
module features_add() { ... }
|
||||||
|
|
||||||
|
// 6. SUBTRACTIVE FEATURES (holes, slots, pockets — ALWAYS LAST)
|
||||||
|
module features_cut() { ... }
|
||||||
|
|
||||||
|
// 7. ASSEMBLY (the Feature Tree)
|
||||||
|
difference() {
|
||||||
|
union() { body(); features_add(); }
|
||||||
|
features_cut();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Profile-first design rules:**
|
||||||
|
- Prefer `polygon()` + `linear_extrude()` over `hull()` of 3D primitives
|
||||||
|
- Use `offset(r=radius)` for corner rounding instead of `hull()` with cylinders
|
||||||
|
- Use `rotate_extrude()` for axially symmetric parts (never stack cylinders)
|
||||||
|
- Define dimensions relative to edges/features, not absolute coordinates: `hole_x = total_length - edge_margin` (not magic numbers)
|
||||||
|
- Cascade tolerances from a single `fit_clearance` parameter
|
||||||
|
|
||||||
|
**Critical rules for generating OpenSCAD code:**
|
||||||
|
- Read `~/.claude/skills/openscad/references/language-reference.md` if unsure about syntax
|
||||||
|
- Always define parametric dimensions as variables at the top of the file
|
||||||
|
- Use `$fn = 64;` for smooth curves (or higher for final renders)
|
||||||
|
- Add comments explaining each section
|
||||||
|
- Use modules for reusable parts
|
||||||
|
- Keep wall thickness >= 1.2mm for FDM printing
|
||||||
|
- Design with the print orientation in mind (flat bottom, minimal overhangs)
|
||||||
|
|
||||||
|
### Step 4: Preview
|
||||||
|
|
||||||
|
Render a multi-angle PNG preview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview ~/openscad-projects/<project-name>/src/main.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates 4 preview images (front, side, top, isometric) in the project's `previews/` directory.
|
||||||
|
|
||||||
|
### Step 5: Analyze Preview
|
||||||
|
|
||||||
|
Read each preview PNG using the Read tool to see the rendered object. Evaluate:
|
||||||
|
- Does the shape match the user's description?
|
||||||
|
- Are proportions correct?
|
||||||
|
- Are there visible artifacts or unintended geometry?
|
||||||
|
- Would this print well? (overhangs, bridging, thin walls)
|
||||||
|
|
||||||
|
Report findings to the user with the preview images.
|
||||||
|
|
||||||
|
### Step 6: Iterate
|
||||||
|
|
||||||
|
If changes are needed, edit the .scad file and re-render. Repeat Steps 4-5 until the user is satisfied. Each iteration should be targeted — change one aspect at a time.
|
||||||
|
|
||||||
|
### Step 7: Export
|
||||||
|
|
||||||
|
When the design is approved:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh export ~/openscad-projects/<project-name>/src/main.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces:
|
||||||
|
- `output/model.stl` — for slicing and printing
|
||||||
|
- `output/model.3mf` — alternative format (better metadata)
|
||||||
|
- `previews/final-preview.png` — high-res final render
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Replicate Mode
|
||||||
|
|
||||||
|
When the user provides reference images of a physical object to reproduce in OpenSCAD:
|
||||||
|
|
||||||
|
### Step 1: Analyze Reference Images
|
||||||
|
|
||||||
|
Read ALL provided reference images using the Read tool. For each image, extract:
|
||||||
|
- **Overall shape**: What geometric primitives compose this object?
|
||||||
|
- **Proportions**: Relative dimensions (height-to-width ratio, etc.)
|
||||||
|
- **Features**: Holes, fillets, chamfers, textures, slots, lips, threads
|
||||||
|
- **Symmetry**: Is it symmetric along any axis?
|
||||||
|
- **Construction**: How would you decompose it into boolean operations?
|
||||||
|
|
||||||
|
If dimensions are provided, note them. If not, estimate proportions from the images and ask the user for at least one known measurement to establish scale.
|
||||||
|
|
||||||
|
### Step 2: Create Decomposition Plan
|
||||||
|
|
||||||
|
Before writing any code, describe the object as a series of OpenSCAD operations:
|
||||||
|
|
||||||
|
```
|
||||||
|
Object: Phone stand
|
||||||
|
Decomposition:
|
||||||
|
1. Base: flat rectangle with rounded corners (80x60x5mm)
|
||||||
|
2. Back support: angled plate (60x3mm, tilted 70 degrees)
|
||||||
|
3. Front lip: small ridge to hold phone (60x3x8mm)
|
||||||
|
4. Fillet: smooth transition between base and back support
|
||||||
|
5. Cable channel: cylinder subtracted from base center
|
||||||
|
```
|
||||||
|
|
||||||
|
Present this plan to the user for confirmation before coding.
|
||||||
|
|
||||||
|
### Step 3: Generate Initial .scad File
|
||||||
|
|
||||||
|
Write the OpenSCAD code based on the decomposition. Set up the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-project.sh init "<object-name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Write the .scad to the project's `src/main.scad`.
|
||||||
|
|
||||||
|
### Step 4: Render and Compare
|
||||||
|
|
||||||
|
Generate a preview from the **same angle** as the reference image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh quick ~/openscad-projects/<name>/src/main.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
Read both the reference image and the rendered preview. Compare them side by side mentally:
|
||||||
|
- Does the overall silhouette match?
|
||||||
|
- Are proportions correct?
|
||||||
|
- Are features (holes, edges, curves) in the right places?
|
||||||
|
- What's the biggest discrepancy?
|
||||||
|
|
||||||
|
### Step 5: Iterative Refinement Loop
|
||||||
|
|
||||||
|
For each discrepancy found:
|
||||||
|
1. Identify which part of the .scad code controls the mismatched feature
|
||||||
|
2. Make a **single targeted edit** to improve the match
|
||||||
|
3. Re-render from the same angle
|
||||||
|
4. Re-compare with the reference
|
||||||
|
|
||||||
|
**Refinement priorities** (fix in this order):
|
||||||
|
1. Overall shape and proportions
|
||||||
|
2. Major features (holes, cutouts, protrusions)
|
||||||
|
3. Angles and curves
|
||||||
|
4. Fillets, chamfers, and surface details
|
||||||
|
5. Fine details
|
||||||
|
|
||||||
|
### Step 6: Multi-Angle Validation
|
||||||
|
|
||||||
|
Once the primary angle looks good, render from all angles that have reference images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview ~/openscad-projects/<name>/src/main.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare each rendered view against its corresponding reference image. Fix any angle-specific discrepancies.
|
||||||
|
|
||||||
|
### Step 7: Dimensional Verification
|
||||||
|
|
||||||
|
If the user provided measurements, add `echo()` statements to verify:
|
||||||
|
|
||||||
|
```openscad
|
||||||
|
echo("Total width:", width);
|
||||||
|
echo("Total height:", height);
|
||||||
|
echo("Wall thickness:", wall);
|
||||||
|
```
|
||||||
|
|
||||||
|
Render with echo capture to verify dimensions match specifications.
|
||||||
|
|
||||||
|
### Step 8: Export
|
||||||
|
|
||||||
|
When the user confirms the replication is satisfactory, export for printing.
|
||||||
|
|
||||||
|
### Tips for Accurate Replication
|
||||||
|
|
||||||
|
- **Start simple**: Begin with bounding-box primitives, then refine
|
||||||
|
- **Use reference dimensions**: If user says "it's about 10cm tall", anchor ALL proportions to that
|
||||||
|
- **Match camera angle**: Use `--camera` to match the reference photo's perspective
|
||||||
|
- **Organic shapes**: Approximate with hull(), minkowski(), or rotate_extrude() of a profile
|
||||||
|
- **Iterate small**: Change one thing per render cycle
|
||||||
|
- **Ask when unsure**: If a feature is ambiguous from the images, ask the user rather than guessing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Reconstruct Mode (STL-to-SCAD)
|
||||||
|
|
||||||
|
When the user provides an STL file and wants it converted to parametric OpenSCAD code:
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
STL files are triangle meshes with no semantic information about the original primitives or operations that created them. Reconstruction is the process of analyzing the mesh geometry and re-expressing it as clean, parametric OpenSCAD code. This is valuable because:
|
||||||
|
- Parametric code can be modified (change dimensions, add features)
|
||||||
|
- OpenSCAD code is human-readable and version-controllable
|
||||||
|
- The resulting model can be adapted to different use cases
|
||||||
|
|
||||||
|
### Critical Rules for Reconstruction
|
||||||
|
|
||||||
|
**Read `references/reconstruction-guide.md` before starting any reconstruction.** It contains the complete best practices guide learned from real reconstructions.
|
||||||
|
|
||||||
|
**Rule 1: SCULPTOR APPROACH (mandatory).** Start from a full solid block, subtract ALL features. Never build up from pieces — CSG ordering bugs cause added material to cover previously-cut holes. Structure: `difference() { solid_body(); channels(); tapers(); ALL_holes_LAST(); }`
|
||||||
|
|
||||||
|
**Rule 2: NEVER add features based on assumptions.** Always verify with SVG contour data AND reference images. If a feature doesn't appear as a separate contour in the SVG slices, IT DOES NOT EXIST. Known hallucinations to avoid:
|
||||||
|
- Pyramids/cones from render shadows
|
||||||
|
- Cylinders from curved wall edges
|
||||||
|
- Top holes from through-hole exit points
|
||||||
|
|
||||||
|
**Rule 3: Bounding box match ≠ correct model.** 0.000mm bbox delta can mean only 70% geometric accuracy. Always use mesh comparison (`openscad-stl-compare.sh`) with boolean diff images.
|
||||||
|
|
||||||
|
**Rule 4: ANALYZE FIRST, DECOMPOSE, THEN CHOOSE per-component approach.**
|
||||||
|
Do NOT jump to code. The analysis phase must answer these questions:
|
||||||
|
1. **What are the dominant features?** (diagonal arm, clips, channels, holes)
|
||||||
|
2. **What is the thinnest axis?** That's the likely extrusion direction — NOT necessarily the stability score winner
|
||||||
|
3. **Can the object be decomposed into simpler sub-objects?** Model each with its best technique
|
||||||
|
4. **Where are internal channels/gaps?** Slice along Z to find multi-body cross-sections
|
||||||
|
|
||||||
|
**Rule 5: Choose the extrusion axis by geometry, not just stability score.**
|
||||||
|
|
||||||
|
The profile extractor's stability score finds the axis with the most uniform cross-section. But this is misleading for models with diagonal features — slicing along Z for a diagonal bracket produces staircase artifacts. Instead:
|
||||||
|
|
||||||
|
| Model Type | Best Extrusion Axis | Why |
|
||||||
|
|-----------|-------------------|-----|
|
||||||
|
| Flat bracket/plate | Thinnest axis (smallest extent) | Profile in the wide plane captures all detail |
|
||||||
|
| Diagonal/angled arm | Thinnest axis | Diagonals live in the plane of the two longest axes |
|
||||||
|
| Clean extrusion (stability < 0.1) | Stability-score axis | Profiles are identical → stability is reliable |
|
||||||
|
| Cylindrical (stability > 0.3) | Object's rotational axis | Use rotate_extrude or parametric primitives |
|
||||||
|
| Truly complex (no good axis) | Dense multi-axis slabbing | 2mm slabs along thinnest axis |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Always run ALL analysis tools before writing any code:
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-stl-reconstruct.sh model.stl analysis/
|
||||||
|
python3 ~/.claude/skills/openscad/scripts/openscad-profile-extract.py model.stl --json analysis/profile.json
|
||||||
|
python3 ~/.claude/skills/openscad/scripts/openscad-adaptive-slice.py model.stl analysis/
|
||||||
|
```
|
||||||
|
|
||||||
|
After analysis, compare: **thinnest axis extent** vs **stability-score axis**. If they differ, the thinnest axis is usually better for models with angled features.
|
||||||
|
|
||||||
|
**Rule 6: Choose the right technique for each component:**
|
||||||
|
|
||||||
|
| Geometry | Best Approach | Expected Accuracy |
|
||||||
|
|----------|--------------|-------------------|
|
||||||
|
| Flat/angular (brackets, plates) | Profile extraction + linear_extrude | 90-96% |
|
||||||
|
| Diagonal features (angled arms, tapers) | Profile along thinnest axis + linear_extrude | 85-92% |
|
||||||
|
| Simple known shapes (stadium, box) | Parametric primitives + SDF optimizer | 90-96% |
|
||||||
|
| Cylindrical features (puzzle tabs, bosses) | Parametric circle() + square() | 85-95% |
|
||||||
|
| Smooth transitions (convex shapes only) | hull() between boundary profiles | 85-90% |
|
||||||
|
| Multi-width models (width varies along axis) | Dense X-slab (2mm profiles along thinnest axis) | ~92% |
|
||||||
|
| Mixed (curves + flats) | Polygon profile (hi-res, tol=0.02) | 70-80% |
|
||||||
|
| Complex organic shapes | import() original STL + parametric modifications | N/A |
|
||||||
|
|
||||||
|
**Rule 7: hull() ONLY for convex profiles.** Hull between two profiles creates the convex hull — it fills in ALL concavities (channels, clips, U-forks, hooks). Only use hull for simple solid zones with 1 contour and no holes. For concave profiles, use linear_extrude of a representative profile instead.
|
||||||
|
|
||||||
|
**Rule 8: Dense X-slab approach for complex models.**
|
||||||
|
When no single extrusion works, slice every 2mm along the thinnest axis:
|
||||||
|
1. At each X position, extract the full Y-Z cross-section (ALL bodies, not just the largest)
|
||||||
|
2. Extrude each slab for 2mm width
|
||||||
|
3. Union all slabs — gaps between bodies are naturally preserved
|
||||||
|
4. Note: this approach has a ~6% volume overestimate floor from polygon extraction artifacts. Below 6% requires hand-modeled parametric geometry.
|
||||||
|
|
||||||
|
**Use the automated reconstruction analysis FIRST — before writing any code:**
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-stl-reconstruct.sh model.stl output_dir/
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the full pipeline: mesh stats (trimesh), 2D profile slices (OpenSCAD projection), primitive detection (RANSAC/normal analysis), and generates SVG profiles at multiple Z levels. The SVG profile analysis is the MOST IMPORTANT output — it reveals the complete cross-section structure at each height level.
|
||||||
|
|
||||||
|
**The SVG Profile Method** (preferred over vertex analysis):
|
||||||
|
1. `projection(cut=true)` slices the STL at a Z height → exports 2D SVG
|
||||||
|
2. Parse the SVG to count contours: BODY (large area) vs HOLES (small area)
|
||||||
|
3. Compare contours at different Z levels to understand how the shape changes with height
|
||||||
|
4. This reveals: channels, slots, holes, wall thickness, taper angles — all from 2D data
|
||||||
|
|
||||||
|
**After analysis, verify with mesh comparison:**
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-stl-compare.sh original.stl reconstruction.stl output_dir/
|
||||||
|
```
|
||||||
|
Target: >95% geometric accuracy. Use diff images to identify remaining discrepancies.
|
||||||
|
|
||||||
|
### Step 1: Automated Analysis (run ALL tools)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tool 1: SVG profiling + primitive detection
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-stl-reconstruct.sh model.stl analysis/
|
||||||
|
|
||||||
|
# Tool 2: Profile extraction + extrusion axis detection
|
||||||
|
python3 ~/.claude/skills/openscad/scripts/openscad-profile-extract.py model.stl \
|
||||||
|
--output analysis/profile.scad --json analysis/profile.json
|
||||||
|
|
||||||
|
# Tool 3: Adaptive multi-axis feature map
|
||||||
|
python3 ~/.claude/skills/openscad/scripts/openscad-adaptive-slice.py model.stl analysis/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key outputs to examine:**
|
||||||
|
- `analysis/slices/*.svg` — 2D profiles at 5 Z levels
|
||||||
|
- `analysis/profile.json` — extrusion axis, stability score, profile points, hole count
|
||||||
|
- `analysis/adaptive-slicing.json` — feature zones on all 3 axes, transition locations
|
||||||
|
- `analysis/primitives.json` — detected cylinders/planes
|
||||||
|
- `analysis/mesh-info.json` — volume, dimensions, symmetry
|
||||||
|
|
||||||
|
### Step 1b: Understand the Object (BEFORE writing code)
|
||||||
|
|
||||||
|
After running analysis tools, render multi-angle previews and answer:
|
||||||
|
|
||||||
|
1. **What are the main components?** (e.g. "bottom clip + diagonal arm + top clip")
|
||||||
|
2. **Which axis is thinnest?** Compare extents — the thinnest is likely the extrusion direction
|
||||||
|
3. **Are there diagonal/angled features?** If yes, the stability-score axis is probably WRONG
|
||||||
|
4. **Where do cross-sections change?** Check the adaptive slicer's transition zones
|
||||||
|
5. **Are there multi-body zones?** (channels, rails, gaps between parts)
|
||||||
|
|
||||||
|
**Decision tree — axis selection:**
|
||||||
|
1. If `stability_score < 0.1` AND thinnest axis matches stability axis → clean extrusion, use `profile.scad`
|
||||||
|
2. If model has **diagonal features** → use the **thinnest axis** regardless of stability score
|
||||||
|
3. If `stability_score < 0.3` AND no diagonals → stability axis + feature variations
|
||||||
|
4. If `stability_score > 0.3` → complex shape. Try dense X-slab along thinnest axis, or decompose into sub-objects
|
||||||
|
|
||||||
|
**Decision tree — technique per component:**
|
||||||
|
- Simple extruded body → profile + linear_extrude along extrusion axis
|
||||||
|
- Diagonal arm/strut → profile along thinnest axis captures it naturally
|
||||||
|
- Clips, hooks, U-channels → profile extraction (NOT hull — hull fills concavities)
|
||||||
|
- Cylindrical features → parametric circle() + square(), NOT polygon profiles
|
||||||
|
- Smooth convex transitions → hull() between boundary profiles (ONLY if convex)
|
||||||
|
- Complex multi-width → dense 2mm slabs along thinnest axis
|
||||||
|
|
||||||
|
**For models with cylindrical features** (stability > 0.3 or SVG shows circular contours):
|
||||||
|
- Do NOT rely on polygon profiles — they approximate curves poorly
|
||||||
|
- Identify circle centers and radii from the SVG contour data
|
||||||
|
- Model with `circle()` + `square()` in 2D, then extrude
|
||||||
|
|
||||||
|
Then render multi-angle previews:
|
||||||
|
|
||||||
|
```openscad
|
||||||
|
// Temporary viewer file
|
||||||
|
import("path/to/model.stl");
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview /tmp/stl-viewer.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
Read all preview images to understand the 3D shape from multiple angles.
|
||||||
|
|
||||||
|
### Step 1b: Auto-Reconstruction (NEW — recommended for most models)
|
||||||
|
|
||||||
|
After running the adaptive slicer, use the auto-reconstructor to generate parametric .scad directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A: With pre-computed analysis
|
||||||
|
python3 ~/.claude/skills/openscad/scripts/openscad-auto-reconstruct.py model.stl \
|
||||||
|
--analysis analysis/ --output project/src/main.scad
|
||||||
|
|
||||||
|
# Option B: Run analysis + reconstruction in one step
|
||||||
|
python3 ~/.claude/skills/openscad/scripts/openscad-auto-reconstruct.py model.stl \
|
||||||
|
--output project/src/main.scad --run-analysis
|
||||||
|
|
||||||
|
# Option C: Tighter circle fitting for precision parts
|
||||||
|
python3 ~/.claude/skills/openscad/scripts/openscad-auto-reconstruct.py model.stl \
|
||||||
|
--analysis analysis/ --output project/src/main.scad --circle-threshold 0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically:
|
||||||
|
1. Parses the feature map JSON into zones
|
||||||
|
2. Extracts profiles at zone boundaries
|
||||||
|
3. Fits circles/arcs to replace polygon approximations (Feature 3)
|
||||||
|
4. Generates hull() blends for transition zones (Feature 2)
|
||||||
|
5. Emits one OpenSCAD module per zone with sculptor assembly (Feature 1)
|
||||||
|
|
||||||
|
The output is a good starting point — review and refine the generated .scad, then verify with mesh comparison. For models with cylindrical features, this typically achieves >85% accuracy automatically (vs 75% with polygon-only).
|
||||||
|
|
||||||
|
### Step 2: Detailed Structure Mapping
|
||||||
|
|
||||||
|
**For simple models** (5 SVG slices are enough):
|
||||||
|
Parse the SVG contours to count bodies vs holes at each Z level.
|
||||||
|
|
||||||
|
**For complex models** (brackets, enclosures with multiple features):
|
||||||
|
Use the adaptive multi-axis slicer for efficient feature detection:
|
||||||
|
```bash
|
||||||
|
python3 ~/.claude/skills/openscad/scripts/openscad-adaptive-slice.py model.stl analysis/
|
||||||
|
```
|
||||||
|
This automatically: scans all 3 axes with coarse pass (5mm) → detects transitions → fine pass (0.5mm) around transitions. Produces a feature map classifying each zone as `solid`, `shell_or_channel`, `multi_body`, or `complex`.
|
||||||
|
|
||||||
|
For manual fine-grained slicing at specific heights:
|
||||||
|
```bash
|
||||||
|
for z in $(seq 0.5 1 <max_z>); do
|
||||||
|
echo "projection(cut=true) translate([0,0,-$z]) import(\"model.stl\");" > /tmp/s.scad
|
||||||
|
openscad -o "slices/z${z}.svg" /tmp/s.scad
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse each SVG to build a structural map:
|
||||||
|
```
|
||||||
|
Z=0-5: 1 body (full width) + 8 holes → Solid base with screw holes
|
||||||
|
Z=5-10: 2 bodies + 8 holes → Channel appeared, walls split
|
||||||
|
Z=10-20: 2 bodies narrowing → Taper zone (measure rate)
|
||||||
|
Z=20-33: 2 bodies constant width → Top section
|
||||||
|
Z=25-27: Bodies interrupted → Counterbore pockets at this depth
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hole positions from SVG centroids** — for each hole contour at a given Z, compute the centroid. This gives exact X,Y positions far more reliably than vertex analysis.
|
||||||
|
|
||||||
|
**Feature verification rule:** If a feature doesn't appear as a distinct contour in the SVG data, IT DOES NOT EXIST in the model. Never add features based on visual interpretation of 3D renders alone.
|
||||||
|
|
||||||
|
### Step 3: Choose Approach and Decompose
|
||||||
|
|
||||||
|
Based on the analysis data, choose the reconstruction approach:
|
||||||
|
|
||||||
|
**Approach A — Profile Extrusion** (for extruded parts, stability < 0.3):
|
||||||
|
```bash
|
||||||
|
# The profile extractor already generated the .scad — use it as a starting point
|
||||||
|
cat analysis/profile.scad
|
||||||
|
# Adjust: add cavity with offset(delta=-wall), add floor, add features
|
||||||
|
```
|
||||||
|
|
||||||
|
**Approach B — Parametric Primitives** (for known shapes or cylindrical features):
|
||||||
|
Create a decomposition plan using measured dimensions from SVG data:
|
||||||
|
```
|
||||||
|
Decomposition:
|
||||||
|
1. Base: square([80, 80]) + circle tabs — from SVG outer contour at Z=mid
|
||||||
|
2. Cavity: offset(delta=-wall) of base — from SVG inner contour
|
||||||
|
3. Floor: solid at Z=0 to floor_h — from SVG at Z=0 (1 contour = solid)
|
||||||
|
4. Holes: cylinder(d=3) at SVG hole centroids
|
||||||
|
5. Counterbores: cylinder(d=8, h=2) at same positions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Approach C — Hybrid** (for complex shapes with both flat and curved features):
|
||||||
|
1. Extract polygon profile for the overall outline
|
||||||
|
2. Identify which curves in the profile are circles (regular spacing, arc-like)
|
||||||
|
3. Replace those polygon sections with parametric `circle(r)` operations
|
||||||
|
4. Assemble: `square() + circle()` union for tabs, `difference()` for slots
|
||||||
|
|
||||||
|
**Counterbore vs Countersink** — always verify from reference images:
|
||||||
|
- **Counterbore**: flat cylindrical pocket (`cylinder(d=cb_d, h=cb_depth)`)
|
||||||
|
- **Countersink**: conical taper (`cylinder(d1=cs_d, d2=hole_d, h=cs_depth)`)
|
||||||
|
- Most 3D-printed parts use counterbores, not countersinks
|
||||||
|
|
||||||
|
### Step 4: Write Parametric .scad Code
|
||||||
|
|
||||||
|
Create a new project and write the reconstructed code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-project.sh init "<name>-reconstructed"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key principles for reconstruction:**
|
||||||
|
- Extract ALL dimensions as named variables at the top
|
||||||
|
- Use meaningful variable names that describe the physical feature
|
||||||
|
- Add comments linking each section to the original STL features
|
||||||
|
- Include `echo()` statements for bounding box verification
|
||||||
|
- Add `assert()` for parameter ranges
|
||||||
|
|
||||||
|
### Step 5: Visual Comparison Loop
|
||||||
|
|
||||||
|
Render the reconstructed .scad and compare side-by-side with the original STL renders:
|
||||||
|
|
||||||
|
1. Render the reconstruction from the same camera angles as Step 1
|
||||||
|
2. Read both sets of images
|
||||||
|
3. Compare silhouettes, proportions, and feature placement
|
||||||
|
4. Identify the biggest discrepancy
|
||||||
|
5. Fix it and re-render
|
||||||
|
6. Repeat until the reconstruction matches the original
|
||||||
|
|
||||||
|
### Step 6: Overlay Verification
|
||||||
|
|
||||||
|
For precise verification, create an overlay .scad file:
|
||||||
|
|
||||||
|
```openscad
|
||||||
|
// Overlay: original STL (transparent) vs reconstruction
|
||||||
|
%import("path/to/original.stl"); // % = transparent background
|
||||||
|
color("red", 0.6) reconstructed_model();
|
||||||
|
```
|
||||||
|
|
||||||
|
Render this overlay — any RED areas visible through the transparent original indicate reconstruction errors. Any grey areas not covered by red indicate missing geometry.
|
||||||
|
|
||||||
|
### Step 7: Mesh-to-Mesh Comparison
|
||||||
|
|
||||||
|
**This is the most important verification step.** Export the reconstruction as STL and compare it against the original using boolean difference:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Export reconstruction
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl ~/openscad-projects/<name>/src/main.scad
|
||||||
|
|
||||||
|
# Run mesh comparison
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-stl-compare.sh \
|
||||||
|
path/to/original.stl \
|
||||||
|
~/openscad-projects/<name>/output/main.stl \
|
||||||
|
~/openscad-projects/<name>/previews/comparison
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces:
|
||||||
|
- **diff-A-minus-B.png** — geometry in original but MISSING from reconstruction (what you need to add)
|
||||||
|
- **diff-B-minus-A.png** — EXTRA geometry in reconstruction not in original (what you need to remove)
|
||||||
|
- **overlay.png** — both models overlaid for visual check
|
||||||
|
- **Geometric accuracy %** — based on volume of boolean differences vs original volume
|
||||||
|
|
||||||
|
**Target: >95% geometric accuracy.** If below 95%, examine the diff images to identify which features are wrong, fix them, re-export, and re-compare. Iterate until accuracy is satisfactory.
|
||||||
|
|
||||||
|
**Important:** Bounding box delta can be 0.000mm while geometric accuracy is only 78% — internal features matter more than outer dimensions.
|
||||||
|
|
||||||
|
### Step 8: Dimensional Verification
|
||||||
|
|
||||||
|
Compare echo output from the reconstruction with the STL bounding box:
|
||||||
|
|
||||||
|
```openscad
|
||||||
|
echo(str("Reconstructed BBOX: ", width, " x ", depth, " x ", height));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: SDF Parameter Optimization (Advanced)
|
||||||
|
|
||||||
|
If the SVG profile method doesn't achieve >95% accuracy, use the SDF optimizer for automatic parameter tuning:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ~/.claude/skills/openscad/scripts/openscad-sdf-optimize.py \
|
||||||
|
path/to/original.stl \
|
||||||
|
stadium-slot \
|
||||||
|
--verbose \
|
||||||
|
--output analysis/sdf-result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This works by:
|
||||||
|
1. Sampling 30,000 random points in the bounding box
|
||||||
|
2. Computing target occupancy (inside/outside original mesh) via trimesh
|
||||||
|
3. Defining the reconstruction as a parametric SDF (Signed Distance Field)
|
||||||
|
4. Using `scipy.optimize.minimize(method="Powell")` to maximize IoU (Intersection over Union)
|
||||||
|
5. Generating OpenSCAD code with optimized parameters
|
||||||
|
|
||||||
|
**When to use**: When you know the correct model topology (e.g., "stadium body with cylindrical slot") but can't find the exact parameters. The optimizer finds them automatically.
|
||||||
|
|
||||||
|
**Supported model types**: `stadium-slot`, `box-holes`. Add new types by defining an SDF function in the script.
|
||||||
|
|
||||||
|
**Workflow**: Run `openscad-stl-reconstruct.sh` first (to identify the model topology), then `openscad-sdf-optimize.py` (to find exact parameters), then `openscad-stl-compare.sh` (to verify).
|
||||||
|
|
||||||
|
**Prerequisites**: `pip3 install trimesh numpy scipy rtree`
|
||||||
|
|
||||||
|
### Common Pitfalls
|
||||||
|
|
||||||
|
- **Bounding box match ≠ correct model.** A model with completely wrong internal geometry can still have a 0.000mm bounding box delta. Always verify visually from multiple angles.
|
||||||
|
- **Don't assume features from renders alone.** What looks like a cylinder in a top-down view might just be a curved wall edge. Always verify with vertex analysis.
|
||||||
|
- **Coincident faces cause Z-fighting.** If a feature touches the body boundary exactly, use `intersection()` to clip it cleanly rather than making it the exact same size.
|
||||||
|
- **Don't flip between adding and removing features.** If unsure whether a feature exists, run cross-section analysis before deciding. Oscillating between "add bar" and "remove bar" wastes iterations.
|
||||||
|
- **Offset features are common.** Cylinders, holes, and channels are often NOT centered. Always calculate the actual center from vertex data rather than assuming symmetry.
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
- **Organic shapes** (sculpted, freeform surfaces) cannot be fully reconstructed as primitives. For these, keep the STL import and wrap it in a module.
|
||||||
|
- **Very complex models** (1000+ features) should be reconstructed incrementally, starting with the major body and adding features one group at a time.
|
||||||
|
- **Thread geometry** in STL is extremely difficult to reconstruct. Use `threads.scad` library instead of trying to match individual thread faces.
|
||||||
|
- **Text/engravings** embedded in STL meshes are very hard to extract. It's better to re-add text using OpenSCAD's `text()` module.
|
||||||
|
|
||||||
|
### Hybrid Approach
|
||||||
|
|
||||||
|
For complex models, use a hybrid strategy:
|
||||||
|
```openscad
|
||||||
|
// Import the complex organic base from STL
|
||||||
|
module original_base() {
|
||||||
|
import("base-section.stl");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct and parameterize the mechanical features
|
||||||
|
module mounting_bracket(width=30, hole_d=5) {
|
||||||
|
difference() {
|
||||||
|
original_base();
|
||||||
|
// Add parametric mounting holes
|
||||||
|
for (pos = hole_positions)
|
||||||
|
translate(pos) cylinder(d=hole_d, h=50, center=true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This lets the user modify the parametric parts while keeping the complex geometry intact.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Refine Mode
|
||||||
|
|
||||||
|
When the user wants to modify an existing design:
|
||||||
|
|
||||||
|
### Step 1: Read the Existing File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find .scad files in the project
|
||||||
|
```
|
||||||
|
Read the .scad source to understand the current design.
|
||||||
|
|
||||||
|
### Step 2: Render Current State
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview /path/to/file.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the preview images to see what currently exists.
|
||||||
|
|
||||||
|
### Step 3: Apply Changes
|
||||||
|
|
||||||
|
Edit the .scad file with the requested modifications. Use the Edit tool for surgical changes.
|
||||||
|
|
||||||
|
### Step 4: Re-render and Compare
|
||||||
|
|
||||||
|
Generate new previews and visually compare with the previous version. Report what changed.
|
||||||
|
|
||||||
|
### Step 5: Repeat or Export
|
||||||
|
|
||||||
|
Continue iterating or export when satisfied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Export Mode
|
||||||
|
|
||||||
|
Quick export of an existing .scad file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single format
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl /path/to/file.scad
|
||||||
|
|
||||||
|
# Multiple formats
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh export /path/to/file.scad
|
||||||
|
|
||||||
|
# With parameter overrides
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl /path/to/file.scad -D 'width=50' -D 'height=30'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow: Analyze Mode
|
||||||
|
|
||||||
|
Review a design for printability:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh analyze /path/to/file.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
This renders cross-section views and reports:
|
||||||
|
- Object bounding box dimensions
|
||||||
|
- Whether the mesh is manifold (watertight)
|
||||||
|
- Estimated print time indicators (volume, surface area from STL)
|
||||||
|
- Visual check of overhangs via bottom-up view
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Script Reference
|
||||||
|
|
||||||
|
All scripts live in `~/.claude/skills/openscad/scripts/`:
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `openscad-render.sh` | Core render/export/preview engine |
|
||||||
|
| `openscad-project.sh` | Project scaffolding and management |
|
||||||
|
| `openscad-validate.sh` | Strict validation with categorized error output |
|
||||||
|
| `openscad-stl-analyze.sh` | STL mesh analysis: bbox, cross-sections, gap detection |
|
||||||
|
| `openscad-stl-compare.sh` | Mesh comparison: boolean diff, volume delta, accuracy % |
|
||||||
|
| `openscad-stl-reconstruct.sh` | Automated STL analysis: profiles, primitives, CSG inference |
|
||||||
|
| `openscad-sdf-optimize.py` | SDF-based parameter optimizer (IoU scoring, no OpenSCAD in loop) |
|
||||||
|
| `openscad-adaptive-slice.py` | Adaptive multi-axis slicing (coarse→transitions→fine on X,Y,Z) |
|
||||||
|
| `openscad-auto-reconstruct.py` | Auto-translate feature map → parametric .scad (circle fitting, hull blending) |
|
||||||
|
|
||||||
|
### openscad-render.sh Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick single preview (isometric)
|
||||||
|
openscad-render.sh quick <file.scad>
|
||||||
|
|
||||||
|
# Multi-angle preview (4 views)
|
||||||
|
openscad-render.sh preview <file.scad>
|
||||||
|
|
||||||
|
# Export STL only
|
||||||
|
openscad-render.sh stl <file.scad> [-D 'var=val' ...]
|
||||||
|
|
||||||
|
# Export all formats (STL + 3MF + PNG)
|
||||||
|
openscad-render.sh export <file.scad> [-D 'var=val' ...]
|
||||||
|
|
||||||
|
# Analyze printability
|
||||||
|
openscad-render.sh analyze <file.scad>
|
||||||
|
|
||||||
|
# Custom render
|
||||||
|
openscad-render.sh custom <file.scad> --format png --imgsize 1920,1080 --camera 0,0,0,45,0,30,200
|
||||||
|
```
|
||||||
|
|
||||||
|
### openscad-project.sh Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize new project
|
||||||
|
openscad-project.sh init <project-name>
|
||||||
|
|
||||||
|
# List projects
|
||||||
|
openscad-project.sh list
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
openscad-project.sh clean <project-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenSCAD Code Guidelines
|
||||||
|
|
||||||
|
### File Structure Convention
|
||||||
|
|
||||||
|
```openscad
|
||||||
|
// ============================================
|
||||||
|
// Project: <name>
|
||||||
|
// Description: <what this models>
|
||||||
|
// Author: Claude Code + User
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// --- Parameters (user-configurable) ---
|
||||||
|
width = 50; // [mm] overall width
|
||||||
|
height = 30; // [mm] overall height
|
||||||
|
depth = 20; // [mm] overall depth
|
||||||
|
wall = 2.0; // [mm] wall thickness
|
||||||
|
tolerance = 0.3; // [mm] printer tolerance
|
||||||
|
|
||||||
|
// --- Rendering quality ---
|
||||||
|
$fn = 64; // curve smoothness (use 128+ for final export)
|
||||||
|
eps = 0.01; // epsilon for clean boolean operations
|
||||||
|
|
||||||
|
// --- Derived dimensions ---
|
||||||
|
inner_width = width - 2 * wall;
|
||||||
|
inner_height = height - 2 * wall;
|
||||||
|
|
||||||
|
// --- Main model ---
|
||||||
|
main_assembly();
|
||||||
|
|
||||||
|
// --- Modules ---
|
||||||
|
module main_assembly() {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3D Printing Best Practices in OpenSCAD
|
||||||
|
|
||||||
|
- **Wall thickness**: minimum 1.2mm for FDM (2-3 perimeters with 0.4mm nozzle)
|
||||||
|
- **Tolerance**: 0.2-0.3mm clearance for fitting parts together (peg-in-hole, snap fits)
|
||||||
|
- **Overhangs**: keep below 45 degrees from vertical, or add supports in design
|
||||||
|
- **Chamfer vs fillet**: prefer chamfers on downward-facing surfaces (avoids supports); use fillets on top surfaces
|
||||||
|
- **Bridging**: max ~10mm unsupported spans
|
||||||
|
- **First layer**: design flat bottoms for bed adhesion; largest flat surface on build plate
|
||||||
|
- **Epsilon constant**: always define `eps = 0.01;` and use it in boolean operations to prevent Z-fighting / coplanar faces
|
||||||
|
- **Manifold geometry**: always ensure boolean operations produce valid solids; operands must overlap
|
||||||
|
- **Resolution**: use `$fn = 64` for preview, `$fn = 128` for export
|
||||||
|
- **Design intent**: Define hole positions relative to edges (`hole_x = length - margin`), never as absolute coordinates
|
||||||
|
- **Tolerance chains**: Define a single `fit_clearance` parameter and derive all clearances from it
|
||||||
|
- **Assert validation**: Use `assert()` to validate parameters: `assert(wall >= 1.2)`, `assert(boss_d > hole_d + 2*wall)`
|
||||||
|
- **Profile-first**: Use `offset(r=corner_r)` on 2D `polygon()` instead of `hull()` with 3D cylinders
|
||||||
|
|
||||||
|
### Common Patterns
|
||||||
|
|
||||||
|
**Rounded box:**
|
||||||
|
```openscad
|
||||||
|
module rounded_box(size, radius) {
|
||||||
|
minkowski() {
|
||||||
|
cube([size.x - 2*radius, size.y - 2*radius, size.z - radius]);
|
||||||
|
cylinder(r=radius, h=radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shell (hollow object):**
|
||||||
|
```openscad
|
||||||
|
module shell(outer_size, wall) {
|
||||||
|
difference() {
|
||||||
|
cube(outer_size);
|
||||||
|
translate([wall, wall, wall])
|
||||||
|
cube([outer_size.x - 2*wall, outer_size.y - 2*wall, outer_size.z]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Screw hole with countersink:**
|
||||||
|
```openscad
|
||||||
|
module screw_hole(d=3, h=10, cs_d=6, cs_h=2) {
|
||||||
|
union() {
|
||||||
|
cylinder(d=d, h=h);
|
||||||
|
translate([0, 0, h - cs_h])
|
||||||
|
cylinder(d1=d, d2=cs_d, h=cs_h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Libraries
|
||||||
|
|
||||||
|
Popular libraries that can be installed for advanced features:
|
||||||
|
|
||||||
|
| Library | Use Case | Install |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| **BOSL2** | Swiss-army knife: attachments, shapes, threading, paths | `git clone https://github.com/BelfrySCAD/BOSL2 ~/.local/share/OpenSCAD/libraries/BOSL2` |
|
||||||
|
| **NopSCADlib** | Vitamins (screws, nuts, electronics, bearings) | `git clone https://github.com/nophead/NopSCADlib ~/.local/share/OpenSCAD/libraries/NopSCADlib` |
|
||||||
|
| **threads.scad** | Metric threads, hex bolts, nuts | `git clone https://github.com/rcolyer/threads-scad ~/.local/share/OpenSCAD/libraries/threads` |
|
||||||
|
| **Round-Anything** | Smooth fillets and rounding | `git clone https://github.com/Irev-Dev/Round-Anything ~/.local/share/OpenSCAD/libraries/Round-Anything` |
|
||||||
|
| **YAPP_Box** | Parametric project enclosures | `git clone https://github.com/mrWheel/YAPP_Box ~/.local/share/OpenSCAD/libraries/YAPP_Box` |
|
||||||
|
| **Catch'n'Hole** | Nut catches, screw holes | `git clone https://github.com/mmalecki/catchnhole ~/.local/share/OpenSCAD/libraries/catchnhole` |
|
||||||
|
|
||||||
|
Check installed libraries:
|
||||||
|
```bash
|
||||||
|
ls ~/.local/share/OpenSCAD/libraries/ 2>/dev/null
|
||||||
|
ls /opt/homebrew/share/openscad/libraries/ 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
When user needs a library, install it and add `use <library/file.scad>` to the .scad source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
When OpenSCAD fails:
|
||||||
|
|
||||||
|
1. **Parse errors** — `ERROR: Parser error: syntax error in file X, line Y`
|
||||||
|
- Read the .scad file at the reported line
|
||||||
|
- Fix syntax (common: missing semicolons, unmatched braces/parens, wrong function names)
|
||||||
|
- Re-render
|
||||||
|
|
||||||
|
2. **Geometry errors** — `WARNING: Object may not be a valid 2-manifold`
|
||||||
|
- Check boolean operations aren't creating degenerate geometry
|
||||||
|
- Ensure shapes overlap properly for difference/intersection
|
||||||
|
- Add small epsilon offsets (0.01mm) to prevent coplanar faces
|
||||||
|
|
||||||
|
3. **Rendering timeouts** — complex models with high `$fn`
|
||||||
|
- Lower `$fn` for preview (32), raise for export (128)
|
||||||
|
- Simplify geometry where possible
|
||||||
|
- Use `render()` to cache intermediate results
|
||||||
|
|
||||||
|
4. **Empty output** — model produces no geometry
|
||||||
|
- Check that modules are actually called
|
||||||
|
- Verify boolean operations don't subtract everything
|
||||||
|
- Use `echo()` statements to debug variable values
|
||||||
|
|
||||||
|
Always capture stderr when rendering — it contains warnings and errors:
|
||||||
|
```bash
|
||||||
|
openscad -o output.stl input.scad 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Camera Presets for Multi-View
|
||||||
|
|
||||||
|
| View | Camera Parameters |
|
||||||
|
|------|-------------------|
|
||||||
|
| Front | `--camera 0,0,0,90,0,0,<dist>` |
|
||||||
|
| Back | `--camera 0,0,0,90,0,180,<dist>` |
|
||||||
|
| Right | `--camera 0,0,0,90,0,90,<dist>` |
|
||||||
|
| Left | `--camera 0,0,0,90,0,270,<dist>` |
|
||||||
|
| Top | `--camera 0,0,0,0,0,0,<dist>` |
|
||||||
|
| Bottom | `--camera 0,0,0,180,0,0,<dist>` |
|
||||||
|
| Isometric | `--autocenter --viewall` (default) |
|
||||||
|
| 3/4 view | `--camera 0,0,0,55,0,25,<dist>` |
|
||||||
|
|
||||||
|
Use `--autocenter --viewall` to auto-calculate distance, or specify explicit distance for consistent framing across iterations.
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# OpenSCAD Language Reference (v2021.01)
|
||||||
|
|
||||||
|
## Syntax & Declaration
|
||||||
|
```openscad
|
||||||
|
var = value; // variable assignment
|
||||||
|
var = cond ? value_if_true : value_if_false; // ternary
|
||||||
|
var = function (x) x + x; // function literal
|
||||||
|
module name(params) { ... } // module definition
|
||||||
|
function name(params) = expr; // function definition
|
||||||
|
include <file.scad> // include (executes top-level code)
|
||||||
|
use <file.scad> // import (only modules/functions)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constants
|
||||||
|
- `undef` — undefined value
|
||||||
|
- `PI` — 3.14159...
|
||||||
|
|
||||||
|
## Special Variables
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `$fn` | Fixed number of segments for circles/spheres |
|
||||||
|
| `$fa` | Minimum angle per segment |
|
||||||
|
| `$fs` | Minimum segment size |
|
||||||
|
| `$t` | Animation step (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
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# STL-to-SCAD Reconstruction Best Practices
|
||||||
|
|
||||||
|
## Profile-Based Reconstruction (Preferred Method)
|
||||||
|
|
||||||
|
For extruded parts (brackets, plates, channels), extract the 2D profile directly from the mesh and convert it to OpenSCAD `polygon()` + `linear_extrude()`. This is more accurate than fitting known primitives.
|
||||||
|
|
||||||
|
### Step 1: Detect Extrusion Axis
|
||||||
|
Use trimesh to find the axis with the most stable cross-section:
|
||||||
|
- Slice the mesh along X, Y, and Z at 64 levels each
|
||||||
|
- For each axis, measure stability: std(area), std(perimeter), std(hole_count)
|
||||||
|
- The axis with lowest stability score is the extrusion axis
|
||||||
|
|
||||||
|
### Step 2: Extract the Dominant Profile
|
||||||
|
- Find the slice with the largest area (the representative cross-section)
|
||||||
|
- Simplify the polygon (remove micro-vertices from tessellation)
|
||||||
|
- Handle holes: inner contours become `paths` in OpenSCAD `polygon()`
|
||||||
|
|
||||||
|
### Step 3: Convert to OpenSCAD
|
||||||
|
```openscad
|
||||||
|
// Auto-generated from mesh profile extraction
|
||||||
|
linear_extrude(height = <extrusion_length>)
|
||||||
|
polygon(
|
||||||
|
points = [<extracted_points>],
|
||||||
|
paths = [<outer_boundary>, <hole_1>, <hole_2>]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Add Secondary Features
|
||||||
|
Features that vary along the extrusion axis (holes, counterbores) are detected by comparing slice profiles at different heights. Where a slice has more holes than the dominant profile, subtract cylinders.
|
||||||
|
|
||||||
|
## The Sculptor Approach (MANDATORY)
|
||||||
|
|
||||||
|
Always model as a sculptor: start from a solid block, then subtract material.
|
||||||
|
|
||||||
|
```openscad
|
||||||
|
// CORRECT: sculptor approach
|
||||||
|
difference() {
|
||||||
|
solid_body(); // 1. Full solid block first
|
||||||
|
channel(); // 2. Subtract channels/slots
|
||||||
|
taper_cuts(); // 3. Subtract wedges/tapers
|
||||||
|
all_holes(); // 4. Subtract ALL holes LAST
|
||||||
|
}
|
||||||
|
|
||||||
|
// WRONG: additive approach (holes get covered)
|
||||||
|
union() {
|
||||||
|
difference() {
|
||||||
|
base();
|
||||||
|
some_holes(); // These get covered by wings!
|
||||||
|
}
|
||||||
|
left_wing(); // Covers the holes above
|
||||||
|
right_wing();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why**: In OpenSCAD, `difference()` only applies to its immediate children. If you add material (wings) after cutting holes, the new material covers the holes. The sculptor approach ensures ALL cuts happen after ALL additions.
|
||||||
|
|
||||||
|
## Analysis Pipeline
|
||||||
|
|
||||||
|
### Step 1: Automated SVG Profile Analysis
|
||||||
|
```bash
|
||||||
|
bash openscad-stl-reconstruct.sh model.stl output_dir/
|
||||||
|
```
|
||||||
|
This gives you the 30,000-foot view: dimensions, volume, symmetry, primitive hints.
|
||||||
|
|
||||||
|
### Step 2: Detailed 1mm Z-Slicing
|
||||||
|
For complex models, slice at every 1mm (not just 5 levels):
|
||||||
|
```bash
|
||||||
|
# Generate slices at every Z level
|
||||||
|
for z in $(seq 0.5 1 <max_z>); do
|
||||||
|
echo "projection(cut=true) translate([0,0,-$z]) import(\"model.stl\");" > /tmp/s.scad
|
||||||
|
openscad -o "slices/z${z}.svg" /tmp/s.scad
|
||||||
|
done
|
||||||
|
```
|
||||||
|
Parse each SVG to extract:
|
||||||
|
- Number of contours (1 body = solid level, 2+ = channels/features)
|
||||||
|
- Contour sizes: BODY (>500mm²), FEATURE (50-500mm²), HOLE (<50mm²)
|
||||||
|
- Hole positions from contour centroids
|
||||||
|
- How width changes with Z (reveals taper rate)
|
||||||
|
|
||||||
|
### Step 3: Identify Structure from Profile Data
|
||||||
|
```
|
||||||
|
Z=0-5: 1 body (full width) → Solid base
|
||||||
|
Z=5-10: 2 bodies + 8 holes → Channel appeared, base holes
|
||||||
|
Z=10-20: 2 bodies narrowing → Taper zone (measure rate)
|
||||||
|
Z=20-33: 2 bodies (constant width) → Top section
|
||||||
|
Z=25-27: bodies interrupted by holes → Upper counterbore holes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Write OpenSCAD (Sculptor Method)
|
||||||
|
1. Create the FULL solid body (base + wings as one block)
|
||||||
|
2. Subtract the channel
|
||||||
|
3. Subtract taper wedges (use `hull()` for linear tapers)
|
||||||
|
4. Subtract ALL holes in separate modules, called LAST
|
||||||
|
|
||||||
|
### Step 5: Compare and Iterate
|
||||||
|
```bash
|
||||||
|
bash openscad-stl-compare.sh original.stl reconstruction.stl output/
|
||||||
|
```
|
||||||
|
- Check accuracy % (target: >95%)
|
||||||
|
- Read diff images to identify WHAT is wrong
|
||||||
|
- Fix ONE thing per iteration
|
||||||
|
- Re-compare
|
||||||
|
|
||||||
|
## Hole Patterns
|
||||||
|
|
||||||
|
### Counterbore (flat cylindrical pocket)
|
||||||
|
Most common in 3D-printed brackets. A shallow cylinder + through hole:
|
||||||
|
```openscad
|
||||||
|
module counterbore(hole_d, cb_d, cb_depth, total_h) {
|
||||||
|
cylinder(d=hole_d, h=total_h); // Through hole
|
||||||
|
cylinder(d=cb_d, h=cb_depth); // Flat pocket
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Countersink (conical taper)
|
||||||
|
Less common in 3D prints, used for flat-head screws:
|
||||||
|
```openscad
|
||||||
|
module countersink(hole_d, cs_d, cs_depth, total_h) {
|
||||||
|
cylinder(d=hole_d, h=total_h);
|
||||||
|
cylinder(d1=cs_d, d2=hole_d, h=cs_depth);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always check reference images** to determine which type is used. Don't assume.
|
||||||
|
|
||||||
|
### Hole Orientation Patterns
|
||||||
|
Complex brackets often have holes on multiple faces:
|
||||||
|
- **Bottom face**: Vertical (Z-axis) holes
|
||||||
|
- **Angled faces**: Holes perpendicular to the face (rotate by taper angle)
|
||||||
|
- **Side walls**: Horizontal (Y-axis) holes
|
||||||
|
- **Each set may have different spacing and count**
|
||||||
|
|
||||||
|
Extract hole positions from SVG centroids at the appropriate Z level.
|
||||||
|
|
||||||
|
## Taper/Wedge Subtraction
|
||||||
|
|
||||||
|
For a 45° taper that narrows a wing from full width to reduced width:
|
||||||
|
```openscad
|
||||||
|
// Wedge: 0 thickness at bottom, full thickness at top
|
||||||
|
hull() {
|
||||||
|
translate([0, outer_edge, z_start])
|
||||||
|
cube([length, eps, z_end - z_start]); // Thin edge
|
||||||
|
translate([0, outer_edge, z_end - eps])
|
||||||
|
cube([length, taper_amount, eps]); // Full face
|
||||||
|
}
|
||||||
|
// Then remove the rectangular block above the taper
|
||||||
|
translate([0, outer_edge, z_end])
|
||||||
|
cube([length, taper_amount, z_top - z_end]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **CSG order**: ALWAYS cut holes after building the full solid
|
||||||
|
2. **Feature hallucination**: Don't add features you can't verify in the reference
|
||||||
|
3. **Conical vs cylindrical**: Check if countersinks are tapered or flat
|
||||||
|
4. **Symmetric assumptions**: Don't assume symmetry — verify from SVG data
|
||||||
|
5. **Volume match ≠ shape match**: A model can have correct volume but wrong shape
|
||||||
|
6. **SVG Y-axis is inverted**: OpenSCAD projection flips Y coordinates
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install trimesh numpy scipy rtree shapely
|
||||||
|
brew install admesh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accuracy Targets
|
||||||
|
|
||||||
|
| Level | Accuracy | When to stop |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| Draft | >85% | Initial structure verification |
|
||||||
|
| Good | >95% | Functional part, ready for test print |
|
||||||
|
| Excellent | >98% | Production quality |
|
||||||
|
|
||||||
|
The 95% threshold is achievable for most mechanical parts in 4-6 iterations using the SVG profiling approach. The remaining 5% is typically tessellation differences and minor feature details.
|
||||||
|
|
||||||
|
## When to Use Polygon Profiles vs Parametric Primitives
|
||||||
|
|
||||||
|
### Use extracted polygon profiles when:
|
||||||
|
- The shape has mostly flat/angular surfaces (brackets, plates, channels)
|
||||||
|
- The curves are gentle and well-approximated by ~200 polygon points
|
||||||
|
- Speed is more important than last-5% accuracy
|
||||||
|
- Expected accuracy: 75-96% depending on curve complexity
|
||||||
|
|
||||||
|
### Use parametric primitives (circle, cylinder) when:
|
||||||
|
- The shape has prominent cylindrical features (puzzle tabs, screw holes, bosses)
|
||||||
|
- The shape can be decomposed into known primitives (square + circles)
|
||||||
|
- You need >95% accuracy on curved surfaces
|
||||||
|
- The model has symmetry that can be exploited
|
||||||
|
|
||||||
|
### Hybrid approach (best for complex models):
|
||||||
|
1. Extract the polygon profile for the overall outline
|
||||||
|
2. Identify which curves are circles/arcs from the profile data
|
||||||
|
3. Replace polygon approximations with parametric `circle(r)` where possible
|
||||||
|
4. Use `offset(r)` for rounded corners instead of polygon vertices
|
||||||
|
|
||||||
|
### Key lesson: polygon simplification tolerance matters enormously
|
||||||
|
- 0.3mm tolerance → ~50 points → curves become flat → 52% accuracy
|
||||||
|
- 0.05mm tolerance → ~150 points → curves approximate → 75% accuracy
|
||||||
|
- 0.02mm tolerance → ~230 points → curves close but not perfect → 75% accuracy
|
||||||
|
- Diminishing returns beyond ~200 points for polygon-based approaches
|
||||||
|
- For >90% on cylindrical surfaces, parametric primitives are required
|
||||||
|
|
||||||
|
## Feature Hallucination Prevention
|
||||||
|
|
||||||
|
NEVER add features based on visual interpretation of renders alone.
|
||||||
|
- The toothpaste squeezer "cylinder" was actually a rounded slot floor
|
||||||
|
- The puzzle tray "pyramid" didn't exist at all — it was a shadow in the render
|
||||||
|
- ALWAYS verify features with SVG slice data (contour count, area, holes)
|
||||||
|
- If a feature doesn't show as a separate contour in the SVG slices, IT DOESN'T EXIST
|
||||||
|
|
||||||
|
## Adaptive Multi-Axis Slicing
|
||||||
|
|
||||||
|
The `openscad-adaptive-slice.py` script scans STL on all 3 axes:
|
||||||
|
1. Coarse pass (5mm) → detects where cross-section changes
|
||||||
|
2. Fine pass (0.5mm) only at transition zones
|
||||||
|
3. Classifies each zone: solid, shell_or_channel, multi_body, complex
|
||||||
|
|
||||||
|
### How to interpret the feature map
|
||||||
|
|
||||||
|
**Zone types and their OpenSCAD equivalents:**
|
||||||
|
- `solid` (1 contour, 0 holes) → `linear_extrude()` of the profile
|
||||||
|
- `shell_or_channel` (2 contours) → walls around a cavity, use `offset(delta=-wall)`
|
||||||
|
- `solid_with_holes` (1 contour, N holes) → solid body with `difference()` holes
|
||||||
|
- `multi_body` (N contours) → multiple separate parts or holes
|
||||||
|
- `complex` → may need `hull()` between profiles or `polyhedron()`
|
||||||
|
|
||||||
|
**Detecting specific features from zone evolution:**
|
||||||
|
- **Chamfer/taper**: contour width decreases progressively across slices
|
||||||
|
- **Fillet**: smooth curvature in contour centroids between zones
|
||||||
|
- **Counterbore**: nested circular contours with constant radius for several slices
|
||||||
|
- **Through-hole**: hole contour appears in ALL slices along that axis
|
||||||
|
- **Blind hole**: hole contour appears then disappears
|
||||||
|
|
||||||
|
**Generating OpenSCAD from zones:**
|
||||||
|
- Stable zones (many identical slices) → `linear_extrude(height=zone_length)` of representative profile
|
||||||
|
- Transition zones (gradual change) → `hull()` between two profiles at zone boundaries
|
||||||
|
- Feature zones (holes, counterbores) → `difference()` with fitted cylinders
|
||||||
|
|
||||||
|
### Future: Feature Map → OpenSCAD Translator
|
||||||
|
The next evolution is automatic translation: parse the JSON feature map, emit one `module zone_N()` per zone, assemble with `difference()/union()` in the correct order. This would close the loop from STL → analysis → parametric .scad automatically.
|
||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
openscad-adaptive-slice.py — Adaptive multi-axis SVG slicing
|
||||||
|
|
||||||
|
Slices an STL on all 3 axes with adaptive resolution:
|
||||||
|
1. Coarse pass (every 5mm) → detect transitions
|
||||||
|
2. Fine pass (every 0.5mm) only at transitions
|
||||||
|
3. Outputs a complete feature map of the model
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 openscad-adaptive-slice.py <file.stl> <output_dir> [--coarse 5] [--fine 0.5]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import trimesh
|
||||||
|
from trimesh import intersections
|
||||||
|
from shapely.geometry import MultiLineString
|
||||||
|
from shapely.ops import polygonize, unary_union
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def segments_to_polygons(segments, snap=1e-4):
|
||||||
|
"""Convert mesh slice segments to Shapely polygons."""
|
||||||
|
lines = []
|
||||||
|
for seg in np.asarray(segments):
|
||||||
|
a = tuple(np.round(seg[0] / snap) * snap)
|
||||||
|
b = tuple(np.round(seg[1] / snap) * snap)
|
||||||
|
if a != b:
|
||||||
|
lines.append([a, b])
|
||||||
|
if not lines:
|
||||||
|
return []
|
||||||
|
return [p for p in polygonize(MultiLineString(lines)) if p.area > snap * snap]
|
||||||
|
|
||||||
|
|
||||||
|
def slice_at_height(mesh, axis, height):
|
||||||
|
"""Slice mesh at a given height along an axis. Returns polygon descriptors."""
|
||||||
|
try:
|
||||||
|
normal = np.zeros(3)
|
||||||
|
normal[axis] = 1.0
|
||||||
|
origin = np.zeros(3)
|
||||||
|
origin[axis] = height
|
||||||
|
|
||||||
|
segments, _, _ = intersections.mesh_multiplane(
|
||||||
|
mesh, plane_origin=origin, plane_normal=normal, heights=[0.0])
|
||||||
|
|
||||||
|
if not segments or len(segments[0]) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
polys = segments_to_polygons(segments[0])
|
||||||
|
if not polys:
|
||||||
|
return None
|
||||||
|
|
||||||
|
combined = unary_union(polys)
|
||||||
|
n_holes = sum(len(p.interiors) for p in polys) if hasattr(polys[0], 'interiors') else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'height': round(float(height), 3),
|
||||||
|
'area': round(float(combined.area), 2),
|
||||||
|
'perimeter': round(float(combined.length), 2),
|
||||||
|
'n_contours': len(polys),
|
||||||
|
'n_holes': n_holes,
|
||||||
|
'bounds': [round(float(x), 2) for x in combined.bounds],
|
||||||
|
'width': round(float(combined.bounds[2] - combined.bounds[0]), 2),
|
||||||
|
'height_2d': round(float(combined.bounds[3] - combined.bounds[1]), 2),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_transitions(slices, threshold=0.1):
|
||||||
|
"""Find heights where the cross-section changes significantly."""
|
||||||
|
transitions = []
|
||||||
|
for i in range(1, len(slices)):
|
||||||
|
prev = slices[i - 1]
|
||||||
|
curr = slices[i]
|
||||||
|
if prev is None or curr is None:
|
||||||
|
if prev is not None or curr is not None:
|
||||||
|
transitions.append(i)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Detect changes in area, contour count, or holes
|
||||||
|
area_change = abs(curr['area'] - prev['area']) / max(prev['area'], 1)
|
||||||
|
contour_change = curr['n_contours'] != prev['n_contours']
|
||||||
|
hole_change = curr['n_holes'] != prev['n_holes']
|
||||||
|
width_change = abs(curr['width'] - prev['width']) / max(prev['width'], 1)
|
||||||
|
|
||||||
|
if area_change > threshold or contour_change or hole_change or width_change > threshold:
|
||||||
|
transitions.append(i)
|
||||||
|
|
||||||
|
return transitions
|
||||||
|
|
||||||
|
|
||||||
|
def adaptive_slice_axis(mesh, axis, coarse_step=5.0, fine_step=0.5, fine_range=3.0, threshold=0.1):
|
||||||
|
"""Adaptive slicing along one axis.
|
||||||
|
|
||||||
|
1. Coarse pass at coarse_step intervals
|
||||||
|
2. Detect transitions
|
||||||
|
3. Fine pass around each transition
|
||||||
|
"""
|
||||||
|
axis_names = ['X', 'Y', 'Z']
|
||||||
|
lo, hi = mesh.bounds[0][axis], mesh.bounds[1][axis]
|
||||||
|
extent = hi - lo
|
||||||
|
|
||||||
|
# Pass 1: Coarse
|
||||||
|
coarse_heights = np.arange(lo + coarse_step / 2, hi, coarse_step)
|
||||||
|
coarse_slices = []
|
||||||
|
for h in coarse_heights:
|
||||||
|
s = slice_at_height(mesh, axis, h)
|
||||||
|
coarse_slices.append(s)
|
||||||
|
|
||||||
|
# Detect transitions
|
||||||
|
trans_indices = detect_transitions(coarse_slices, threshold)
|
||||||
|
transition_heights = [float(coarse_heights[i]) for i in trans_indices]
|
||||||
|
|
||||||
|
print(f" {axis_names[axis]}: {len(coarse_heights)} coarse slices, "
|
||||||
|
f"{len(transition_heights)} transitions detected")
|
||||||
|
|
||||||
|
if transition_heights:
|
||||||
|
zones = ", ".join(f"{h:.1f}" for h in transition_heights)
|
||||||
|
print(f" Transition zones: {zones}")
|
||||||
|
|
||||||
|
# Pass 2: Fine slicing around transitions
|
||||||
|
fine_slices = []
|
||||||
|
fine_heights_set = set()
|
||||||
|
for th in transition_heights:
|
||||||
|
fine_lo = max(lo, th - fine_range)
|
||||||
|
fine_hi = min(hi, th + fine_range)
|
||||||
|
for h in np.arange(fine_lo, fine_hi, fine_step):
|
||||||
|
h_round = round(float(h), 3)
|
||||||
|
if h_round not in fine_heights_set:
|
||||||
|
fine_heights_set.add(h_round)
|
||||||
|
s = slice_at_height(mesh, axis, h_round)
|
||||||
|
if s:
|
||||||
|
fine_slices.append(s)
|
||||||
|
|
||||||
|
# Combine coarse + fine, sorted by height
|
||||||
|
all_slices = []
|
||||||
|
all_heights = set()
|
||||||
|
|
||||||
|
for s, h in zip(coarse_slices, coarse_heights):
|
||||||
|
h_round = round(float(h), 3)
|
||||||
|
if s and h_round not in all_heights:
|
||||||
|
all_heights.add(h_round)
|
||||||
|
all_slices.append(s)
|
||||||
|
|
||||||
|
for s in fine_slices:
|
||||||
|
if s['height'] not in all_heights:
|
||||||
|
all_heights.add(s['height'])
|
||||||
|
all_slices.append(s)
|
||||||
|
|
||||||
|
all_slices.sort(key=lambda s: s['height'])
|
||||||
|
|
||||||
|
print(f" Total slices: {len(all_slices)} ({len(coarse_slices)} coarse + {len(fine_slices)} fine)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'axis': axis_names[axis],
|
||||||
|
'extent': round(float(extent), 3),
|
||||||
|
'n_coarse': len(coarse_slices),
|
||||||
|
'n_fine': len(fine_slices),
|
||||||
|
'n_total': len(all_slices),
|
||||||
|
'transitions': [round(h, 3) for h in transition_heights],
|
||||||
|
'slices': all_slices,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_feature_map(axis_results):
|
||||||
|
"""Build a unified feature map from all 3 axes."""
|
||||||
|
features = []
|
||||||
|
|
||||||
|
for result in axis_results:
|
||||||
|
axis = result['axis']
|
||||||
|
slices = result['slices']
|
||||||
|
transitions = result['transitions']
|
||||||
|
|
||||||
|
if not slices:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Identify feature zones (between transitions)
|
||||||
|
zones = []
|
||||||
|
sorted_trans = sorted(transitions)
|
||||||
|
|
||||||
|
# Zone before first transition
|
||||||
|
pre_slices = [s for s in slices if s['height'] < (sorted_trans[0] if sorted_trans else 1e6)]
|
||||||
|
if pre_slices:
|
||||||
|
avg_area = np.mean([s['area'] for s in pre_slices])
|
||||||
|
avg_contours = round(np.mean([s['n_contours'] for s in pre_slices]))
|
||||||
|
avg_holes = round(np.mean([s['n_holes'] for s in pre_slices]))
|
||||||
|
zones.append({
|
||||||
|
'axis': axis,
|
||||||
|
'from': round(float(slices[0]['height']), 2),
|
||||||
|
'to': round(float(sorted_trans[0]), 2) if sorted_trans else round(float(slices[-1]['height']), 2),
|
||||||
|
'avg_area': round(float(avg_area), 1),
|
||||||
|
'contours': int(avg_contours),
|
||||||
|
'holes': int(avg_holes),
|
||||||
|
'type': classify_zone(avg_area, avg_contours, avg_holes),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Zones between transitions
|
||||||
|
for i in range(len(sorted_trans)):
|
||||||
|
t_lo = sorted_trans[i]
|
||||||
|
t_hi = sorted_trans[i + 1] if i + 1 < len(sorted_trans) else slices[-1]['height']
|
||||||
|
zone_slices = [s for s in slices if t_lo <= s['height'] <= t_hi]
|
||||||
|
if zone_slices:
|
||||||
|
avg_area = np.mean([s['area'] for s in zone_slices])
|
||||||
|
avg_contours = round(np.mean([s['n_contours'] for s in zone_slices]))
|
||||||
|
avg_holes = round(np.mean([s['n_holes'] for s in zone_slices]))
|
||||||
|
zones.append({
|
||||||
|
'axis': axis,
|
||||||
|
'from': round(float(t_lo), 2),
|
||||||
|
'to': round(float(t_hi), 2),
|
||||||
|
'avg_area': round(float(avg_area), 1),
|
||||||
|
'contours': int(avg_contours),
|
||||||
|
'holes': int(avg_holes),
|
||||||
|
'type': classify_zone(avg_area, avg_contours, avg_holes),
|
||||||
|
})
|
||||||
|
|
||||||
|
features.extend(zones)
|
||||||
|
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
def classify_zone(area, contours, holes):
|
||||||
|
"""Classify a zone based on its cross-section properties."""
|
||||||
|
if contours == 1 and holes == 0:
|
||||||
|
return 'solid'
|
||||||
|
elif contours == 1 and holes > 0:
|
||||||
|
return 'solid_with_holes'
|
||||||
|
elif contours == 2 and holes == 0:
|
||||||
|
return 'shell_or_channel'
|
||||||
|
elif contours > 2:
|
||||||
|
return 'multi_body'
|
||||||
|
else:
|
||||||
|
return 'complex'
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Adaptive multi-axis STL slicing')
|
||||||
|
parser.add_argument('stl_file', help='Input STL file')
|
||||||
|
parser.add_argument('output_dir', help='Output directory')
|
||||||
|
parser.add_argument('--coarse', type=float, default=5.0, help='Coarse step (mm)')
|
||||||
|
parser.add_argument('--fine', type=float, default=0.5, help='Fine step (mm)')
|
||||||
|
parser.add_argument('--range', type=float, default=3.0, help='Fine range around transitions (mm)')
|
||||||
|
parser.add_argument('--threshold', type=float, default=0.1, help='Change threshold (0-1)')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
os.makedirs(args.output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Loading: {args.stl_file}")
|
||||||
|
mesh = trimesh.load_mesh(args.stl_file, force='mesh')
|
||||||
|
trimesh.repair.fix_normals(mesh)
|
||||||
|
|
||||||
|
dims = mesh.extents
|
||||||
|
print(f"Mesh: {len(mesh.vertices)} verts, {len(mesh.faces)} faces, vol={mesh.volume:.1f}mm³")
|
||||||
|
print(f"Dimensions: {dims[0]:.1f} x {dims[1]:.1f} x {dims[2]:.1f} mm")
|
||||||
|
print(f"Coarse: {args.coarse}mm, Fine: {args.fine}mm, Range: ±{args.range}mm")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Slice on all 3 axes
|
||||||
|
print("=== Adaptive Multi-Axis Slicing ===")
|
||||||
|
axis_results = []
|
||||||
|
for axis in range(3):
|
||||||
|
result = adaptive_slice_axis(
|
||||||
|
mesh, axis,
|
||||||
|
coarse_step=args.coarse,
|
||||||
|
fine_step=args.fine,
|
||||||
|
fine_range=args.range,
|
||||||
|
threshold=args.threshold,
|
||||||
|
)
|
||||||
|
axis_results.append(result)
|
||||||
|
|
||||||
|
# Build feature map
|
||||||
|
print("\n=== Feature Map ===")
|
||||||
|
features = build_feature_map(axis_results)
|
||||||
|
for f in features:
|
||||||
|
print(f" {f['axis']} [{f['from']:.1f} → {f['to']:.1f}]: "
|
||||||
|
f"{f['type']} (area={f['avg_area']:.0f}, contours={f['contours']}, holes={f['holes']})")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print(f"\n=== Summary ===")
|
||||||
|
total_slices = sum(r['n_total'] for r in axis_results)
|
||||||
|
total_coarse = sum(r['n_coarse'] for r in axis_results)
|
||||||
|
total_fine = sum(r['n_fine'] for r in axis_results)
|
||||||
|
total_trans = sum(len(r['transitions']) for r in axis_results)
|
||||||
|
print(f"Total slices: {total_slices} ({total_coarse} coarse + {total_fine} fine)")
|
||||||
|
print(f"Transitions detected: {total_trans}")
|
||||||
|
print(f"Feature zones: {len(features)}")
|
||||||
|
|
||||||
|
# Save results
|
||||||
|
output = {
|
||||||
|
'file': args.stl_file,
|
||||||
|
'dimensions': dims.tolist(),
|
||||||
|
'volume': float(mesh.volume),
|
||||||
|
'settings': {
|
||||||
|
'coarse_step': args.coarse,
|
||||||
|
'fine_step': args.fine,
|
||||||
|
'fine_range': args.range,
|
||||||
|
'threshold': args.threshold,
|
||||||
|
},
|
||||||
|
'axes': [{
|
||||||
|
'axis': r['axis'],
|
||||||
|
'extent': r['extent'],
|
||||||
|
'n_slices': r['n_total'],
|
||||||
|
'transitions': r['transitions'],
|
||||||
|
} for r in axis_results],
|
||||||
|
'features': features,
|
||||||
|
'total_slices': total_slices,
|
||||||
|
'total_transitions': total_trans,
|
||||||
|
}
|
||||||
|
|
||||||
|
output_path = os.path.join(args.output_dir, 'adaptive-slicing.json')
|
||||||
|
with open(output_path, 'w') as f:
|
||||||
|
json.dump(output, f, indent=2)
|
||||||
|
print(f"\nResults saved to: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
openscad-profile-extract.py — Extract 2D profiles from STL meshes
|
||||||
|
|
||||||
|
Detects the extrusion axis, extracts the dominant cross-section profile,
|
||||||
|
and generates OpenSCAD polygon() + linear_extrude() code.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 openscad-profile-extract.py <file.stl> [--output <file.scad>] [--simplify <tolerance>]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import trimesh
|
||||||
|
from trimesh import intersections
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def segments_to_polygons(segments, snap=1e-4):
|
||||||
|
"""Convert line segments from mesh slicing to polygons using Shapely."""
|
||||||
|
from shapely.geometry import MultiLineString
|
||||||
|
from shapely.ops import polygonize
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for seg in np.asarray(segments):
|
||||||
|
a = tuple(np.round(seg[0] / snap) * snap)
|
||||||
|
b = tuple(np.round(seg[1] / snap) * snap)
|
||||||
|
if a != b:
|
||||||
|
lines.append([a, b])
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [p for p in polygonize(MultiLineString(lines)) if p.area > snap * snap]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_extrusion_axis(mesh, n_slices=64):
|
||||||
|
"""Find the best extrusion axis by measuring cross-section stability.
|
||||||
|
|
||||||
|
Returns: (stability_score, axis_index, transform, heights, polygons_per_slice)
|
||||||
|
The axis with the LOWEST stability score is the extrusion direction.
|
||||||
|
"""
|
||||||
|
# Use OBB for canonical alignment
|
||||||
|
try:
|
||||||
|
T = np.linalg.inv(mesh.bounding_box_oriented.primitive.transform)
|
||||||
|
except Exception:
|
||||||
|
T = np.eye(4)
|
||||||
|
|
||||||
|
m = mesh.copy()
|
||||||
|
m.apply_transform(T)
|
||||||
|
|
||||||
|
best = None
|
||||||
|
axis_names = ['X', 'Y', 'Z']
|
||||||
|
|
||||||
|
for axis in range(3):
|
||||||
|
lo, hi = m.bounds[0][axis], m.bounds[1][axis]
|
||||||
|
margin = (hi - lo) * 0.02
|
||||||
|
heights = np.linspace(lo + margin, hi - margin, n_slices)
|
||||||
|
|
||||||
|
try:
|
||||||
|
segments, _, _ = intersections.mesh_multiplane(
|
||||||
|
m,
|
||||||
|
plane_origin=np.zeros(3),
|
||||||
|
plane_normal=np.eye(3)[axis],
|
||||||
|
heights=heights,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
desc = []
|
||||||
|
polys_by_slice = []
|
||||||
|
for segs in segments:
|
||||||
|
polys = segments_to_polygons(segs)
|
||||||
|
polys_by_slice.append(polys)
|
||||||
|
if not polys:
|
||||||
|
desc.append([0, 0, 0, 0])
|
||||||
|
continue
|
||||||
|
from shapely.ops import unary_union
|
||||||
|
u = unary_union(polys)
|
||||||
|
n_holes = sum(len(p.interiors) for p in polys)
|
||||||
|
desc.append([u.area, u.length, n_holes, len(polys)])
|
||||||
|
|
||||||
|
if len([d for d in desc if d[0] > 0]) < n_slices // 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
arr = np.asarray(desc, dtype=float)
|
||||||
|
nonzero = arr[arr[:, 0] > 0]
|
||||||
|
if len(nonzero) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Stability = low variance in area/perimeter/holes across slices
|
||||||
|
means = nonzero.mean(axis=0)
|
||||||
|
means[means < 1e-6] = 1e-6
|
||||||
|
stability = np.mean(np.std(nonzero / means, axis=0))
|
||||||
|
|
||||||
|
extent = hi - lo
|
||||||
|
item = (stability, axis, T, heights, polys_by_slice, extent)
|
||||||
|
if best is None or item[0] < best[0]:
|
||||||
|
best = item
|
||||||
|
|
||||||
|
print(f" Axis {axis_names[axis]}: stability={stability:.4f}, "
|
||||||
|
f"extent={extent:.1f}mm, non-empty={len(nonzero)}/{n_slices}")
|
||||||
|
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def extract_dominant_profile(polys_by_slice, simplify_tol=0.1):
|
||||||
|
"""Find the slice with the largest area and return its simplified polygon."""
|
||||||
|
from shapely.ops import unary_union
|
||||||
|
|
||||||
|
best_area = 0
|
||||||
|
best_poly = None
|
||||||
|
best_idx = 0
|
||||||
|
|
||||||
|
for i, polys in enumerate(polys_by_slice):
|
||||||
|
if not polys:
|
||||||
|
continue
|
||||||
|
u = unary_union(polys)
|
||||||
|
if u.area > best_area:
|
||||||
|
best_area = u.area
|
||||||
|
best_poly = u
|
||||||
|
best_idx = i
|
||||||
|
|
||||||
|
if best_poly is None:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
# Simplify to remove tessellation noise
|
||||||
|
simplified = best_poly.simplify(simplify_tol, preserve_topology=True)
|
||||||
|
return simplified, best_idx
|
||||||
|
|
||||||
|
|
||||||
|
def polygon_to_scad(poly, var_name="profile"):
|
||||||
|
"""Convert a Shapely polygon to OpenSCAD polygon() code."""
|
||||||
|
points = []
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
# Outer boundary
|
||||||
|
exterior_coords = np.asarray(poly.exterior.coords[:-1], dtype=float)
|
||||||
|
start = 0
|
||||||
|
for coord in exterior_coords:
|
||||||
|
points.append(list(np.round(coord, 3)))
|
||||||
|
paths.append(list(range(start, start + len(exterior_coords))))
|
||||||
|
|
||||||
|
# Holes (inner boundaries)
|
||||||
|
for interior in poly.interiors:
|
||||||
|
interior_coords = np.asarray(interior.coords[:-1], dtype=float)
|
||||||
|
start = len(points)
|
||||||
|
for coord in interior_coords:
|
||||||
|
points.append(list(np.round(coord, 3)))
|
||||||
|
# Reverse winding for holes
|
||||||
|
paths.append(list(reversed(range(start, start + len(interior_coords)))))
|
||||||
|
|
||||||
|
# Format as OpenSCAD
|
||||||
|
pts_str = ",\n ".join(f"[{p[0]}, {p[1]}]" for p in points)
|
||||||
|
paths_str = ", ".join(f"{p}" for p in paths)
|
||||||
|
|
||||||
|
return f"""module {var_name}() {{
|
||||||
|
polygon(
|
||||||
|
points = [
|
||||||
|
{pts_str}
|
||||||
|
],
|
||||||
|
paths = [{paths_str}]
|
||||||
|
);
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_hole_variations(polys_by_slice, heights, dominant_idx):
|
||||||
|
"""Detect features that vary along the extrusion axis (holes appearing/disappearing)."""
|
||||||
|
from shapely.ops import unary_union
|
||||||
|
|
||||||
|
dominant_polys = polys_by_slice[dominant_idx]
|
||||||
|
if not dominant_polys:
|
||||||
|
return []
|
||||||
|
|
||||||
|
dom_union = unary_union(dominant_polys)
|
||||||
|
dom_holes = sum(len(p.interiors) for p in dominant_polys)
|
||||||
|
|
||||||
|
variations = []
|
||||||
|
for i, (polys, h) in enumerate(zip(polys_by_slice, heights)):
|
||||||
|
if not polys:
|
||||||
|
continue
|
||||||
|
u = unary_union(polys)
|
||||||
|
n_holes = sum(len(p.interiors) for p in polys)
|
||||||
|
n_parts = len(polys)
|
||||||
|
|
||||||
|
if n_holes != dom_holes or n_parts != len(dominant_polys):
|
||||||
|
variations.append({
|
||||||
|
'height': round(float(h), 3),
|
||||||
|
'slice_idx': i,
|
||||||
|
'holes': n_holes,
|
||||||
|
'parts': n_parts,
|
||||||
|
'area': round(float(u.area), 2),
|
||||||
|
'dom_holes': dom_holes,
|
||||||
|
'dom_parts': len(dominant_polys),
|
||||||
|
})
|
||||||
|
|
||||||
|
return variations
|
||||||
|
|
||||||
|
|
||||||
|
def generate_scad(profile_poly, extrusion_length, axis, variations=None):
|
||||||
|
"""Generate complete OpenSCAD file from extracted profile."""
|
||||||
|
profile_code = polygon_to_scad(profile_poly, "extracted_profile")
|
||||||
|
|
||||||
|
# Determine extrusion direction
|
||||||
|
axis_names = ['X', 'Y', 'Z']
|
||||||
|
|
||||||
|
scad = f"""// ============================================
|
||||||
|
// Auto-generated from STL profile extraction
|
||||||
|
// Extrusion axis: {axis_names[axis]}, length: {extrusion_length:.3f}mm
|
||||||
|
// Profile points: {len(profile_poly.exterior.coords) - 1}
|
||||||
|
// Profile holes: {len(profile_poly.interiors)}
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// --- Parameters ---
|
||||||
|
extrusion_length = {extrusion_length:.3f};
|
||||||
|
|
||||||
|
// --- Quality ---
|
||||||
|
$fn = 64;
|
||||||
|
eps = 0.01;
|
||||||
|
|
||||||
|
// --- Extracted 2D Profile ---
|
||||||
|
{profile_code}
|
||||||
|
|
||||||
|
// --- Main Body ---
|
||||||
|
// Sculptor approach: extrude profile, then subtract features
|
||||||
|
difference() {{
|
||||||
|
// Primary body from extracted profile
|
||||||
|
linear_extrude(height = extrusion_length)
|
||||||
|
extracted_profile();
|
||||||
|
|
||||||
|
// TODO: Add subtractive features (holes, counterbores, chamfers)
|
||||||
|
// detected from cross-section variations
|
||||||
|
"""
|
||||||
|
|
||||||
|
if variations:
|
||||||
|
scad += f"\n // Feature variations detected at {len(variations)} heights:\n"
|
||||||
|
for v in variations[:10]: # Limit output
|
||||||
|
scad += f" // Z={v['height']}: {v['holes']} holes, {v['parts']} parts (dominant: {v['dom_holes']} holes, {v['dom_parts']} parts)\n"
|
||||||
|
|
||||||
|
scad += "}\n"
|
||||||
|
return scad
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Extract 2D profile from STL mesh')
|
||||||
|
parser.add_argument('stl_file', help='Input STL file')
|
||||||
|
parser.add_argument('--output', '-o', help='Output .scad file')
|
||||||
|
parser.add_argument('--simplify', type=float, default=0.1,
|
||||||
|
help='Profile simplification tolerance (mm)')
|
||||||
|
parser.add_argument('--slices', type=int, default=64,
|
||||||
|
help='Number of slices per axis')
|
||||||
|
parser.add_argument('--json', help='Output analysis as JSON')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"Loading: {args.stl_file}")
|
||||||
|
mesh = trimesh.load_mesh(args.stl_file, force='mesh')
|
||||||
|
trimesh.repair.fix_normals(mesh)
|
||||||
|
|
||||||
|
print(f"Mesh: {len(mesh.vertices)} verts, {len(mesh.faces)} faces, "
|
||||||
|
f"vol={mesh.volume:.1f}mm³")
|
||||||
|
print(f"Bounds: {mesh.bounds[0].round(3)} to {mesh.bounds[1].round(3)}")
|
||||||
|
dims = mesh.extents
|
||||||
|
print(f"Dimensions: {dims[0]:.3f} x {dims[1]:.3f} x {dims[2]:.3f} mm")
|
||||||
|
|
||||||
|
print(f"\nDetecting extrusion axis ({args.slices} slices per axis)...")
|
||||||
|
result = detect_extrusion_axis(mesh, n_slices=args.slices)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
print("ERROR: Could not detect extrusion axis")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
stability, axis, T, heights, polys_by_slice, extent = result
|
||||||
|
axis_names = ['X', 'Y', 'Z']
|
||||||
|
print(f"\nBest extrusion axis: {axis_names[axis]} "
|
||||||
|
f"(stability={stability:.4f}, extent={extent:.1f}mm)")
|
||||||
|
|
||||||
|
print("Extracting dominant profile...")
|
||||||
|
profile, dom_idx = extract_dominant_profile(polys_by_slice, args.simplify)
|
||||||
|
|
||||||
|
if profile is None:
|
||||||
|
print("ERROR: Could not extract profile")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
n_pts = len(profile.exterior.coords) - 1
|
||||||
|
n_holes = len(profile.interiors)
|
||||||
|
print(f"Profile: {n_pts} points, {n_holes} holes, area={profile.area:.1f}mm²")
|
||||||
|
|
||||||
|
print("Analyzing feature variations along extrusion axis...")
|
||||||
|
variations = analyze_hole_variations(polys_by_slice, heights, dom_idx)
|
||||||
|
print(f"Found {len(variations)} slices with different features")
|
||||||
|
|
||||||
|
# Generate OpenSCAD
|
||||||
|
scad_code = generate_scad(profile, extent, axis, variations)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, 'w') as f:
|
||||||
|
f.write(scad_code)
|
||||||
|
print(f"\nOpenSCAD saved to: {args.output}")
|
||||||
|
else:
|
||||||
|
print(f"\n--- Generated OpenSCAD ---")
|
||||||
|
print(scad_code)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
analysis = {
|
||||||
|
'extrusion_axis': axis_names[axis],
|
||||||
|
'extrusion_length': round(float(extent), 3),
|
||||||
|
'stability_score': round(float(stability), 4),
|
||||||
|
'profile_points': n_pts,
|
||||||
|
'profile_holes': n_holes,
|
||||||
|
'profile_area': round(float(profile.area), 2),
|
||||||
|
'feature_variations': variations[:20],
|
||||||
|
}
|
||||||
|
with open(args.json, 'w') as f:
|
||||||
|
json.dump(analysis, f, indent=2)
|
||||||
|
print(f"Analysis saved to: {args.json}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Executable
+187
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# openscad-project.sh — Project scaffolding and management for OpenSCAD skill
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECTS_ROOT="$HOME/openscad-projects"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: openscad-project.sh <command> [args]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
init <project-name> Create a new project directory
|
||||||
|
list List all projects
|
||||||
|
clean <project-name> Remove build artifacts (keep source)
|
||||||
|
info <project-name> Show project info and file listing
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_init() {
|
||||||
|
local name="$1"
|
||||||
|
|
||||||
|
# Sanitize project name to prevent directory traversal
|
||||||
|
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||||
|
echo "ERROR: Project name must contain only letters, numbers, hyphens, and underscores." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local project_dir="$PROJECTS_ROOT/$name"
|
||||||
|
|
||||||
|
if [[ -d "$project_dir" ]]; then
|
||||||
|
echo "Project already exists: $project_dir"
|
||||||
|
echo "Use existing project or choose a different name."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$project_dir"/{src,output,previews}
|
||||||
|
|
||||||
|
# Create a starter main.scad (name injected directly, no sed needed)
|
||||||
|
cat > "$project_dir/src/main.scad" <<SCAD
|
||||||
|
// ============================================
|
||||||
|
// Project: $name
|
||||||
|
// Description: TODO
|
||||||
|
// Author: Claude Code + User
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// --- Parameters (user-configurable) ---
|
||||||
|
width = 50; // [mm] overall width
|
||||||
|
height = 30; // [mm] overall height
|
||||||
|
depth = 20; // [mm] overall depth
|
||||||
|
wall = 2.0; // [mm] wall thickness
|
||||||
|
tolerance = 0.3; // [mm] printer tolerance
|
||||||
|
|
||||||
|
// --- Rendering quality ---
|
||||||
|
\$fn = 64; // curve smoothness (use 128+ for final export)
|
||||||
|
|
||||||
|
// --- Derived dimensions ---
|
||||||
|
inner_width = width - 2 * wall;
|
||||||
|
inner_height = height - 2 * wall;
|
||||||
|
inner_depth = depth - 2 * wall;
|
||||||
|
|
||||||
|
// --- Debug output ---
|
||||||
|
echo(str("BBOX: ", width, " x ", depth, " x ", height, " mm"));
|
||||||
|
echo(str("Wall: ", wall, " mm | Tolerance: ", tolerance, " mm"));
|
||||||
|
|
||||||
|
// --- Main Assembly ---
|
||||||
|
// TODO: Replace with your design
|
||||||
|
example();
|
||||||
|
|
||||||
|
module example() {
|
||||||
|
difference() {
|
||||||
|
cube([width, depth, height], center = true);
|
||||||
|
translate([0, 0, wall])
|
||||||
|
cube([inner_width, inner_depth, inner_height + wall], center = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SCAD
|
||||||
|
|
||||||
|
# Create a project README
|
||||||
|
cat > "$project_dir/README.md" <<EOF
|
||||||
|
# $name
|
||||||
|
|
||||||
|
OpenSCAD project created $(date +%Y-%m-%d).
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
- \`src/\` — OpenSCAD source files (.scad)
|
||||||
|
- \`output/\` — Exported STL, 3MF files
|
||||||
|
- \`previews/\` — Rendered PNG previews
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
\`\`\`bash
|
||||||
|
# Preview
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview src/main.scad
|
||||||
|
|
||||||
|
# Export STL
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl src/main.scad
|
||||||
|
|
||||||
|
# With custom parameters
|
||||||
|
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl src/main.scad -D 'width=60' -D 'height=40'
|
||||||
|
\`\`\`
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Project created: $project_dir"
|
||||||
|
echo ""
|
||||||
|
echo "Structure:"
|
||||||
|
echo " $project_dir/"
|
||||||
|
echo " ├── src/"
|
||||||
|
echo " │ └── main.scad (starter template)"
|
||||||
|
echo " ├── output/"
|
||||||
|
echo " ├── previews/"
|
||||||
|
echo " └── README.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
if [[ ! -d "$PROJECTS_ROOT" ]]; then
|
||||||
|
echo "No projects directory found at $PROJECTS_ROOT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OpenSCAD Projects in $PROJECTS_ROOT:"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for dir in "$PROJECTS_ROOT"/*/; do
|
||||||
|
[[ ! -d "$dir" ]] && continue
|
||||||
|
local name
|
||||||
|
name="$(basename "$dir")"
|
||||||
|
local scad_count
|
||||||
|
scad_count=$(find "$dir/src" -name "*.scad" 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
local stl_count
|
||||||
|
stl_count=$(find "$dir/output" -name "*.stl" 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
local preview_count
|
||||||
|
preview_count=$(find "$dir/previews" -name "*.png" 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
|
||||||
|
echo " $name — ${scad_count} .scad, ${stl_count} .stl, ${preview_count} previews"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_clean() {
|
||||||
|
local name="$1"
|
||||||
|
local project_dir="$PROJECTS_ROOT/$name"
|
||||||
|
|
||||||
|
if [[ ! -d "$project_dir" ]]; then
|
||||||
|
echo "Project not found: $name"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "${project_dir:?}/output/"*
|
||||||
|
rm -rf "${project_dir:?}/previews/"*
|
||||||
|
echo "Cleaned build artifacts for: $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_info() {
|
||||||
|
local name="$1"
|
||||||
|
local project_dir="$PROJECTS_ROOT/$name"
|
||||||
|
|
||||||
|
if [[ ! -d "$project_dir" ]]; then
|
||||||
|
echo "Project not found: $name"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Project: $name"
|
||||||
|
echo "Path: $project_dir"
|
||||||
|
echo ""
|
||||||
|
echo "Source files:"
|
||||||
|
find "$project_dir/src" -name "*.scad" -exec echo " {}" \; 2>/dev/null
|
||||||
|
echo ""
|
||||||
|
echo "Exports:"
|
||||||
|
find "$project_dir/output" -type f -exec echo " {}" \; 2>/dev/null || echo " (none)"
|
||||||
|
echo ""
|
||||||
|
echo "Previews:"
|
||||||
|
find "$project_dir/previews" -name "*.png" -exec echo " {}" \; 2>/dev/null || echo " (none)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
command="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
case "$command" in
|
||||||
|
init) [[ $# -lt 1 ]] && usage; cmd_init "$1" ;;
|
||||||
|
list) cmd_list ;;
|
||||||
|
clean) [[ $# -lt 1 ]] && usage; cmd_clean "$1" ;;
|
||||||
|
info) [[ $# -lt 1 ]] && usage; cmd_info "$1" ;;
|
||||||
|
*) echo "Unknown command: $command"; usage ;;
|
||||||
|
esac
|
||||||
Executable
+397
@@ -0,0 +1,397 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# openscad-render.sh — Core render/export/preview engine for OpenSCAD skill
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
|
||||||
|
IMGSIZE_PREVIEW="${OPENSCAD_IMGSIZE:-800,600}"
|
||||||
|
IMGSIZE_HIRES="1600,1200"
|
||||||
|
COLORSCHEME="${OPENSCAD_COLORSCHEME:-DeepOcean}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: openscad-render.sh <command> <file.scad> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
quick <file> Single isometric preview PNG
|
||||||
|
preview <file> Multi-angle preview (4 views)
|
||||||
|
stl <file> [-D ...] Export STL
|
||||||
|
3mf <file> [-D ...] Export 3MF
|
||||||
|
export <file> [-D ...] Export STL + 3MF + final PNG
|
||||||
|
analyze <file> Render analysis views (cross-sections, bottom)
|
||||||
|
custom <file> [options] Custom render with full control
|
||||||
|
|
||||||
|
Options (for custom):
|
||||||
|
--format <ext> Output format (png, stl, 3mf, amf, svg, dxf, pdf)
|
||||||
|
--imgsize <W,H> Image dimensions
|
||||||
|
--camera <params> Camera: translate_x,y,z,rot_x,y,z,dist
|
||||||
|
--colorscheme <name> Color scheme
|
||||||
|
-D 'var=val' Parameter override (repeatable)
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve output directory relative to .scad file
|
||||||
|
get_project_dir() {
|
||||||
|
local scad_file="$1"
|
||||||
|
local scad_dir
|
||||||
|
scad_dir="$(dirname "$(realpath "$scad_file")")"
|
||||||
|
|
||||||
|
# If inside a project structure (has src/ parent), go up
|
||||||
|
if [[ "$(basename "$scad_dir")" == "src" ]]; then
|
||||||
|
echo "$(dirname "$scad_dir")"
|
||||||
|
else
|
||||||
|
echo "$scad_dir"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dirs() {
|
||||||
|
local project_dir="$1"
|
||||||
|
mkdir -p "$project_dir/previews" "$project_dir/output"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render with error handling
|
||||||
|
do_render() {
|
||||||
|
local scad_file="$1"
|
||||||
|
shift
|
||||||
|
local output=""
|
||||||
|
local exit_code=0
|
||||||
|
|
||||||
|
output=$("$OPENSCAD" "$@" "$scad_file" 2>&1) || exit_code=$?
|
||||||
|
|
||||||
|
if [[ $exit_code -ne 0 ]]; then
|
||||||
|
echo "ERROR: OpenSCAD render failed (exit code $exit_code)" >&2
|
||||||
|
echo "$output" >&2
|
||||||
|
# Extract specific error info
|
||||||
|
if echo "$output" | grep -q "Parser error"; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "SYNTAX ERROR detected. Check the .scad file at the reported line." >&2
|
||||||
|
fi
|
||||||
|
return $exit_code
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for warnings
|
||||||
|
if echo "$output" | grep -qi "warning"; then
|
||||||
|
echo "WARNINGS:" >&2
|
||||||
|
echo "$output" | grep -i "warning" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Print render stats
|
||||||
|
if echo "$output" | grep -q "rendering time"; then
|
||||||
|
echo "$output" | grep "rendering time"
|
||||||
|
fi
|
||||||
|
if echo "$output" | grep -q "Facets:"; then
|
||||||
|
echo "$output" | grep -E "(Facets|Vertices):"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$output" | grep -v "^$" | tail -5
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_quick() {
|
||||||
|
local scad_file="$1"
|
||||||
|
shift
|
||||||
|
local project_dir
|
||||||
|
project_dir="$(get_project_dir "$scad_file")"
|
||||||
|
ensure_dirs "$project_dir"
|
||||||
|
|
||||||
|
local out="$project_dir/previews/quick-preview.png"
|
||||||
|
echo "Rendering quick preview..."
|
||||||
|
do_render "$scad_file" \
|
||||||
|
--autocenter --viewall \
|
||||||
|
--imgsize="$IMGSIZE_PREVIEW" \
|
||||||
|
--colorscheme="$COLORSCHEME" \
|
||||||
|
--render \
|
||||||
|
-o "$out" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
echo "Preview saved: $out"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_preview() {
|
||||||
|
local scad_file="$1"
|
||||||
|
shift
|
||||||
|
local project_dir
|
||||||
|
project_dir="$(get_project_dir "$scad_file")"
|
||||||
|
ensure_dirs "$project_dir"
|
||||||
|
|
||||||
|
local timestamp
|
||||||
|
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
local preview_dir="$project_dir/previews"
|
||||||
|
|
||||||
|
# Define camera angles: name, camera_params or flags
|
||||||
|
declare -A views
|
||||||
|
views=(
|
||||||
|
["1-isometric"]="--autocenter --viewall"
|
||||||
|
["2-front"]="--autocenter --viewall --projection o --camera 0,0,0,90,0,0,0"
|
||||||
|
["3-right"]="--autocenter --viewall --projection o --camera 0,0,0,90,0,90,0"
|
||||||
|
["4-top"]="--autocenter --viewall --projection o --camera 0,0,0,0,0,0,0"
|
||||||
|
)
|
||||||
|
|
||||||
|
local failed=0
|
||||||
|
echo "Rendering 4-view preview..."
|
||||||
|
for view_name in $(echo "${!views[@]}" | tr ' ' '\n' | sort); do
|
||||||
|
local out="$preview_dir/${view_name}-${timestamp}.png"
|
||||||
|
local camera_args="${views[$view_name]}"
|
||||||
|
|
||||||
|
echo " Rendering $view_name..."
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
if ! do_render "$scad_file" \
|
||||||
|
$camera_args \
|
||||||
|
--imgsize="$IMGSIZE_PREVIEW" \
|
||||||
|
--colorscheme="$COLORSCHEME" \
|
||||||
|
--render \
|
||||||
|
-o "$out" \
|
||||||
|
"$@"; then
|
||||||
|
echo " FAILED: $view_name" >&2
|
||||||
|
rm -f "$out"
|
||||||
|
failed=1
|
||||||
|
elif [[ ! -s "$out" ]]; then
|
||||||
|
echo " ERROR: Empty render output: $out" >&2
|
||||||
|
rm -f "$out"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Preview images saved in: $preview_dir/"
|
||||||
|
ls -la "$preview_dir"/*-"${timestamp}".png 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_stl() {
|
||||||
|
local scad_file="$1"
|
||||||
|
shift
|
||||||
|
local project_dir
|
||||||
|
project_dir="$(get_project_dir "$scad_file")"
|
||||||
|
ensure_dirs "$project_dir"
|
||||||
|
|
||||||
|
local basename
|
||||||
|
basename="$(basename "$scad_file" .scad)"
|
||||||
|
local out="$project_dir/output/${basename}.stl"
|
||||||
|
|
||||||
|
echo "Exporting STL..."
|
||||||
|
do_render "$scad_file" \
|
||||||
|
--export-format binstl \
|
||||||
|
-o "$out" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
local size
|
||||||
|
size=$(wc -c < "$out" | tr -d ' ')
|
||||||
|
echo "STL saved: $out ($size bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_3mf() {
|
||||||
|
local scad_file="$1"
|
||||||
|
shift
|
||||||
|
local project_dir
|
||||||
|
project_dir="$(get_project_dir "$scad_file")"
|
||||||
|
ensure_dirs "$project_dir"
|
||||||
|
|
||||||
|
local basename
|
||||||
|
basename="$(basename "$scad_file" .scad)"
|
||||||
|
local out="$project_dir/output/${basename}.3mf"
|
||||||
|
|
||||||
|
echo "Exporting 3MF..."
|
||||||
|
do_render "$scad_file" \
|
||||||
|
-o "$out" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
local size
|
||||||
|
size=$(wc -c < "$out" | tr -d ' ')
|
||||||
|
echo "3MF saved: $out ($size bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_export() {
|
||||||
|
local scad_file="$1"
|
||||||
|
shift
|
||||||
|
local project_dir
|
||||||
|
project_dir="$(get_project_dir "$scad_file")"
|
||||||
|
ensure_dirs "$project_dir"
|
||||||
|
|
||||||
|
local basename
|
||||||
|
basename="$(basename "$scad_file" .scad)"
|
||||||
|
|
||||||
|
echo "=== Full Export ==="
|
||||||
|
|
||||||
|
# STL (binary)
|
||||||
|
echo ""
|
||||||
|
echo "--- STL ---"
|
||||||
|
do_render "$scad_file" \
|
||||||
|
--export-format binstl \
|
||||||
|
-o "$project_dir/output/${basename}.stl" \
|
||||||
|
"$@"
|
||||||
|
echo "STL: $project_dir/output/${basename}.stl ($(wc -c < "$project_dir/output/${basename}.stl" | tr -d ' ') bytes)"
|
||||||
|
|
||||||
|
# 3MF
|
||||||
|
echo ""
|
||||||
|
echo "--- 3MF ---"
|
||||||
|
do_render "$scad_file" \
|
||||||
|
-o "$project_dir/output/${basename}.3mf" \
|
||||||
|
"$@" || echo "3MF export failed (may not be supported in this version)"
|
||||||
|
|
||||||
|
# Final high-res preview
|
||||||
|
echo ""
|
||||||
|
echo "--- Final Preview ---"
|
||||||
|
do_render "$scad_file" \
|
||||||
|
--autocenter --viewall \
|
||||||
|
--imgsize="$IMGSIZE_HIRES" \
|
||||||
|
--colorscheme="$COLORSCHEME" \
|
||||||
|
--render \
|
||||||
|
-o "$project_dir/previews/final-preview.png" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Export Complete ==="
|
||||||
|
echo "Output directory: $project_dir/output/"
|
||||||
|
ls -la "$project_dir/output/"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_analyze() {
|
||||||
|
local scad_file="$1"
|
||||||
|
shift
|
||||||
|
local project_dir
|
||||||
|
project_dir="$(get_project_dir "$scad_file")"
|
||||||
|
ensure_dirs "$project_dir"
|
||||||
|
|
||||||
|
local timestamp
|
||||||
|
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
|
||||||
|
echo "=== Design Analysis ==="
|
||||||
|
|
||||||
|
# Render STL to get geometry stats
|
||||||
|
echo ""
|
||||||
|
echo "--- Geometry Stats ---"
|
||||||
|
local stl_output
|
||||||
|
stl_output="$project_dir/output/analysis-temp.stl"
|
||||||
|
do_render "$scad_file" \
|
||||||
|
--export-format binstl \
|
||||||
|
-o "$stl_output" \
|
||||||
|
"$@" 2>&1
|
||||||
|
|
||||||
|
if [[ -f "$stl_output" ]]; then
|
||||||
|
local stl_size
|
||||||
|
stl_size=$(wc -c < "$stl_output" | tr -d ' ')
|
||||||
|
echo "STL file size: $stl_size bytes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Capture echo output for debug info
|
||||||
|
echo ""
|
||||||
|
echo "--- Echo Output ---"
|
||||||
|
do_render "$scad_file" \
|
||||||
|
-o "$project_dir/output/analysis.echo" \
|
||||||
|
"$@" 2>&1 || echo "Echo capture failed (non-critical)" >&2
|
||||||
|
if [[ -f "$project_dir/output/analysis.echo" ]] && [[ -s "$project_dir/output/analysis.echo" ]]; then
|
||||||
|
cat "$project_dir/output/analysis.echo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bottom view (to check overhangs / first layer)
|
||||||
|
local bottom_png="$project_dir/previews/analysis-bottom-${timestamp}.png"
|
||||||
|
echo ""
|
||||||
|
echo "--- Bottom View (check first layer / overhangs) ---"
|
||||||
|
if ! do_render "$scad_file" \
|
||||||
|
--autocenter --viewall \
|
||||||
|
--camera 0,0,0,180,0,0,0 \
|
||||||
|
--imgsize="$IMGSIZE_PREVIEW" \
|
||||||
|
--colorscheme="$COLORSCHEME" \
|
||||||
|
--render \
|
||||||
|
-o "$bottom_png" \
|
||||||
|
"$@"; then
|
||||||
|
echo "WARNING: Bottom view render failed (may need OpenGL context)" >&2
|
||||||
|
rm -f "$bottom_png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Isometric wireframe
|
||||||
|
local wireframe_png="$project_dir/previews/analysis-wireframe-${timestamp}.png"
|
||||||
|
echo ""
|
||||||
|
echo "--- Wireframe View ---"
|
||||||
|
if ! do_render "$scad_file" \
|
||||||
|
--autocenter --viewall \
|
||||||
|
--view edges \
|
||||||
|
--imgsize="$IMGSIZE_PREVIEW" \
|
||||||
|
--colorscheme="$COLORSCHEME" \
|
||||||
|
--render \
|
||||||
|
-o "$wireframe_png" \
|
||||||
|
"$@"; then
|
||||||
|
echo "WARNING: Wireframe render failed (may need OpenGL context)" >&2
|
||||||
|
rm -f "$wireframe_png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Analysis Complete ==="
|
||||||
|
echo "Review images in: $project_dir/previews/"
|
||||||
|
ls -la "$project_dir/previews"/analysis-*-"${timestamp}".png 2>/dev/null
|
||||||
|
|
||||||
|
# Cleanup temp
|
||||||
|
rm -f "$stl_output"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_custom() {
|
||||||
|
local scad_file="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local format="png"
|
||||||
|
local imgsize="$IMGSIZE_PREVIEW"
|
||||||
|
local camera_args=""
|
||||||
|
local colorscheme="$COLORSCHEME"
|
||||||
|
local extra_args=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--format) format="$2"; shift 2 ;;
|
||||||
|
--imgsize) imgsize="$2"; shift 2 ;;
|
||||||
|
--camera) camera_args="--camera $2"; shift 2 ;;
|
||||||
|
--colorscheme) colorscheme="$2"; shift 2 ;;
|
||||||
|
-D) extra_args+=(-D "$2"); shift 2 ;;
|
||||||
|
*) extra_args+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
local project_dir
|
||||||
|
project_dir="$(get_project_dir "$scad_file")"
|
||||||
|
ensure_dirs "$project_dir"
|
||||||
|
|
||||||
|
local basename
|
||||||
|
basename="$(basename "$scad_file" .scad)"
|
||||||
|
local out="$project_dir/output/${basename}-custom.${format}"
|
||||||
|
|
||||||
|
local render_args=(
|
||||||
|
--imgsize="$imgsize"
|
||||||
|
--colorscheme="$colorscheme"
|
||||||
|
--render
|
||||||
|
-o "$out"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -n "$camera_args" ]]; then
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
render_args+=($camera_args)
|
||||||
|
else
|
||||||
|
render_args+=(--autocenter --viewall)
|
||||||
|
fi
|
||||||
|
|
||||||
|
do_render "$scad_file" "${render_args[@]}" "${extra_args[@]}"
|
||||||
|
echo "Output: $out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
[[ $# -lt 2 ]] && usage
|
||||||
|
|
||||||
|
command="$1"
|
||||||
|
scad_file="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
# Validate input file
|
||||||
|
if [[ ! -f "$scad_file" ]]; then
|
||||||
|
echo "ERROR: File not found: $scad_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$command" in
|
||||||
|
quick) cmd_quick "$scad_file" "$@" ;;
|
||||||
|
preview) cmd_preview "$scad_file" "$@" ;;
|
||||||
|
stl) cmd_stl "$scad_file" "$@" ;;
|
||||||
|
3mf) cmd_3mf "$scad_file" "$@" ;;
|
||||||
|
export) cmd_export "$scad_file" "$@" ;;
|
||||||
|
analyze) cmd_analyze "$scad_file" "$@" ;;
|
||||||
|
custom) cmd_custom "$scad_file" "$@" ;;
|
||||||
|
*) echo "Unknown command: $command"; usage ;;
|
||||||
|
esac
|
||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
openscad-sdf-optimize.py — SDF-based parametric model optimizer
|
||||||
|
|
||||||
|
Uses Signed Distance Fields and IoU scoring to find optimal parameters
|
||||||
|
for an OpenSCAD reconstruction WITHOUT invoking OpenSCAD in the loop.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 openscad-sdf-optimize.py <original.stl> <model_type> [options]
|
||||||
|
|
||||||
|
Model types:
|
||||||
|
stadium-slot Stadium body with cylindrical slot cut
|
||||||
|
box-holes Rectangular body with through holes
|
||||||
|
custom Custom SDF defined in a Python module
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--samples N Number of sample points (default: 30000)
|
||||||
|
--output FILE Output JSON with optimized parameters
|
||||||
|
--verbose Print optimization progress
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import trimesh
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from scipy.optimize import minimize, least_squares
|
||||||
|
|
||||||
|
|
||||||
|
# ========== SDF Primitives ==========
|
||||||
|
|
||||||
|
def sdf_box(p, size):
|
||||||
|
"""Signed distance to an axis-aligned box centered at origin."""
|
||||||
|
half = np.array(size) / 2
|
||||||
|
q = np.abs(p) - half
|
||||||
|
return np.linalg.norm(np.maximum(q, 0), axis=1) + np.minimum(np.max(q, axis=1), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def sdf_cylinder_x(p, radius, half_length, center=None):
|
||||||
|
"""Signed distance to a cylinder along X axis."""
|
||||||
|
if center is not None:
|
||||||
|
p = p - np.array(center)
|
||||||
|
d_yz = np.sqrt(p[:, 1]**2 + p[:, 2]**2) - radius
|
||||||
|
d_x = np.abs(p[:, 0]) - half_length
|
||||||
|
return np.minimum(np.maximum(d_yz, d_x), 0) + np.linalg.norm(
|
||||||
|
np.maximum(np.column_stack([d_yz, d_x]), 0), axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def sdf_capsule_x(p, radius, half_span, center=None):
|
||||||
|
"""Signed distance to a capsule (two spheres hulled) along X axis."""
|
||||||
|
if center is not None:
|
||||||
|
p = p - np.array(center)
|
||||||
|
# Clamp X to [-half_span, half_span]
|
||||||
|
px_clamped = np.clip(p[:, 0], -half_span, half_span)
|
||||||
|
q = p.copy()
|
||||||
|
q[:, 0] -= px_clamped
|
||||||
|
return np.linalg.norm(q, axis=1) - radius
|
||||||
|
|
||||||
|
|
||||||
|
def sdf_stadium_extrude(p, total_len, width, height):
|
||||||
|
"""SDF for a stadium shape extruded along Z."""
|
||||||
|
r = width / 2
|
||||||
|
half_span = total_len / 2 - r
|
||||||
|
# 2D stadium distance in XY
|
||||||
|
px_clamped = np.clip(p[:, 0], -half_span, half_span)
|
||||||
|
dx = p[:, 0] - px_clamped
|
||||||
|
d_xy = np.sqrt(dx**2 + p[:, 1]**2) - r
|
||||||
|
# Z bounds
|
||||||
|
d_z = np.abs(p[:, 2] - height / 2) - height / 2
|
||||||
|
return np.maximum(d_xy, d_z)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== CSG Operations ==========
|
||||||
|
|
||||||
|
def sdf_union(d1, d2):
|
||||||
|
return np.minimum(d1, d2)
|
||||||
|
|
||||||
|
def sdf_difference(d1, d2):
|
||||||
|
return np.maximum(d1, -d2)
|
||||||
|
|
||||||
|
def sdf_intersection(d1, d2):
|
||||||
|
return np.maximum(d1, d2)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Model Definitions ==========
|
||||||
|
|
||||||
|
def model_stadium_slot(p, params):
|
||||||
|
"""Stadium body with cylindrical slot carved from top.
|
||||||
|
params: [length, width, height, slot_total, slot_width, cyl_d, cyl_center_z]
|
||||||
|
"""
|
||||||
|
L, W, H, sL, sW, cD, cZ = params
|
||||||
|
body = sdf_stadium_extrude(p, L, W, H)
|
||||||
|
# Cylinder slot along X
|
||||||
|
half_span = sL / 2 - sW / 2
|
||||||
|
cyl = sdf_capsule_x(p, cD / 2, half_span, center=[0, 0, cZ])
|
||||||
|
return sdf_difference(body, cyl)
|
||||||
|
|
||||||
|
|
||||||
|
def model_box_with_holes(p, params):
|
||||||
|
"""Rectangular box with cylindrical through-holes.
|
||||||
|
params: [sx, sy, sz, hole_d, hole_z, n_holes, hole_spacing, hole_x_start]
|
||||||
|
"""
|
||||||
|
sx, sy, sz, hole_d, hole_z, n_holes, spacing, x_start = params
|
||||||
|
n_holes = int(round(n_holes))
|
||||||
|
body = sdf_box(p, [sx, sy, sz])
|
||||||
|
# Offset body center
|
||||||
|
result = body
|
||||||
|
for i in range(n_holes):
|
||||||
|
hx = x_start + i * spacing
|
||||||
|
hole = sdf_cylinder_x(p, hole_d / 2, sy, center=[hx, 0, hole_z - sz/2])
|
||||||
|
# Rotate hole to Y axis
|
||||||
|
p_rot = p.copy()
|
||||||
|
p_rot[:, 0] = p[:, 1]
|
||||||
|
p_rot[:, 1] = p[:, 0]
|
||||||
|
hole = sdf_cylinder_x(p_rot, hole_d / 2, sx, center=[0, hx, hole_z - sz/2])
|
||||||
|
result = sdf_difference(result, hole)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
MODEL_REGISTRY = {
|
||||||
|
'stadium-slot': {
|
||||||
|
'sdf': model_stadium_slot,
|
||||||
|
'param_names': ['length', 'width', 'height', 'slot_total', 'slot_width', 'cyl_d', 'cyl_center_z'],
|
||||||
|
},
|
||||||
|
'box-holes': {
|
||||||
|
'sdf': model_box_with_holes,
|
||||||
|
'param_names': ['sx', 'sy', 'sz', 'hole_d', 'hole_z', 'n_holes', 'spacing', 'x_start'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Scoring ==========
|
||||||
|
|
||||||
|
def compute_iou(target_inside, candidate_inside):
|
||||||
|
"""Intersection over Union from boolean occupancy arrays."""
|
||||||
|
intersection = np.count_nonzero(target_inside & candidate_inside)
|
||||||
|
union = np.count_nonzero(target_inside | candidate_inside)
|
||||||
|
if union == 0:
|
||||||
|
return 0.0
|
||||||
|
return intersection / union
|
||||||
|
|
||||||
|
|
||||||
|
def compute_score(mesh, sdf_func, params, sample_points, target_inside):
|
||||||
|
"""Score a candidate model against the target mesh."""
|
||||||
|
candidate_sdf = sdf_func(sample_points, params)
|
||||||
|
candidate_inside = candidate_sdf <= 0
|
||||||
|
iou = compute_iou(target_inside, candidate_inside)
|
||||||
|
return iou
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Initialization from Mesh Analysis ==========
|
||||||
|
|
||||||
|
def init_params_from_mesh(mesh, model_type):
|
||||||
|
"""Extract initial parameters from mesh analysis."""
|
||||||
|
bounds = mesh.bounds
|
||||||
|
dims = bounds[1] - bounds[0]
|
||||||
|
center = mesh.centroid
|
||||||
|
|
||||||
|
if model_type == 'stadium-slot':
|
||||||
|
# Use section analysis to find slot
|
||||||
|
sections = []
|
||||||
|
z_min, z_max = bounds[0][2], bounds[1][2]
|
||||||
|
for pct in [0.01, 0.25, 0.5, 0.75, 0.99]:
|
||||||
|
z = z_min + (z_max - z_min) * pct
|
||||||
|
try:
|
||||||
|
path = mesh.section(plane_origin=[0, 0, z], plane_normal=[0, 0, 1])
|
||||||
|
if path:
|
||||||
|
path2d, _ = path.to_2D()
|
||||||
|
polys = path2d.polygons_full
|
||||||
|
areas = [p.area for p in polys]
|
||||||
|
sections.append({'z': z, 'n_contours': len(polys), 'areas': areas,
|
||||||
|
'total_area': sum(areas)})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Estimate slot from difference in areas across Z
|
||||||
|
if sections:
|
||||||
|
body_area = max(s['total_area'] for s in sections)
|
||||||
|
# The slot width can be estimated from how the contour changes
|
||||||
|
# For now, use bounding box as starting point
|
||||||
|
slot_width_est = dims[1] * 0.33 # ~1/3 of body width
|
||||||
|
slot_len_est = dims[0] * 0.89 # ~89% of body length
|
||||||
|
|
||||||
|
return [
|
||||||
|
dims[0], # length
|
||||||
|
dims[1], # width
|
||||||
|
dims[2], # height
|
||||||
|
dims[0] * 0.89, # slot_total (slightly shorter than body)
|
||||||
|
dims[1] * 0.33, # slot_width (1/3 of body width)
|
||||||
|
dims[2] * 0.8, # cyl_d (80% of height)
|
||||||
|
dims[2] * 0.6, # cyl_center_z (60% up)
|
||||||
|
]
|
||||||
|
|
||||||
|
return list(dims) + [0] * 4
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Main Optimization ==========
|
||||||
|
|
||||||
|
def optimize(stl_path, model_type, n_samples=30000, verbose=False):
|
||||||
|
"""Main optimization pipeline."""
|
||||||
|
print(f"Loading mesh: {stl_path}")
|
||||||
|
mesh = trimesh.load_mesh(stl_path, force='mesh')
|
||||||
|
trimesh.repair.fix_normals(mesh)
|
||||||
|
|
||||||
|
print(f"Mesh: {len(mesh.vertices)} verts, {len(mesh.faces)} faces, vol={mesh.volume:.1f}mm³")
|
||||||
|
|
||||||
|
# Sample points in and around the bounding box
|
||||||
|
margin = 2.0
|
||||||
|
pts = np.random.uniform(
|
||||||
|
mesh.bounds[0] - margin,
|
||||||
|
mesh.bounds[1] + margin,
|
||||||
|
(n_samples, 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compute target occupancy
|
||||||
|
print("Computing target occupancy...")
|
||||||
|
target_sdf = trimesh.proximity.signed_distance(mesh, pts)
|
||||||
|
target_inside = target_sdf >= 0 # trimesh convention: positive = inside
|
||||||
|
|
||||||
|
print(f"Target: {np.sum(target_inside)}/{n_samples} points inside ({np.mean(target_inside)*100:.1f}%)")
|
||||||
|
|
||||||
|
# Get model SDF function
|
||||||
|
model_info = MODEL_REGISTRY[model_type]
|
||||||
|
sdf_func = model_info['sdf']
|
||||||
|
param_names = model_info['param_names']
|
||||||
|
|
||||||
|
# Initialize parameters from mesh analysis
|
||||||
|
print("Initializing parameters from mesh analysis...")
|
||||||
|
x0 = np.array(init_params_from_mesh(mesh, model_type))
|
||||||
|
print(f"Initial params: {dict(zip(param_names, x0.round(3)))}")
|
||||||
|
|
||||||
|
# Score initial
|
||||||
|
iou0 = compute_score(mesh, sdf_func, x0, pts, target_inside)
|
||||||
|
print(f"Initial IoU: {iou0:.4f} ({iou0*100:.1f}%)")
|
||||||
|
|
||||||
|
# Optimize with Powell method
|
||||||
|
print("\nOptimizing with Powell method...")
|
||||||
|
iter_count = [0]
|
||||||
|
|
||||||
|
def objective(x):
|
||||||
|
iter_count[0] += 1
|
||||||
|
candidate_sdf = sdf_func(pts, x)
|
||||||
|
candidate_inside = candidate_sdf <= 0
|
||||||
|
iou = compute_iou(target_inside, candidate_inside)
|
||||||
|
if verbose and iter_count[0] % 10 == 0:
|
||||||
|
print(f" iter {iter_count[0]}: IoU={iou:.4f} params={x.round(3)}")
|
||||||
|
return 1.0 - iou # minimize = maximize IoU
|
||||||
|
|
||||||
|
# Set bounds (all positive, reasonable ranges)
|
||||||
|
dims = mesh.bounds[1] - mesh.bounds[0]
|
||||||
|
bounds_lo = x0 * 0.5
|
||||||
|
bounds_hi = x0 * 1.5
|
||||||
|
# Ensure positive
|
||||||
|
bounds_lo = np.maximum(bounds_lo, 0.1)
|
||||||
|
|
||||||
|
result = minimize(
|
||||||
|
objective, x0,
|
||||||
|
method='Powell',
|
||||||
|
options={'maxiter': 500, 'ftol': 1e-6, 'disp': verbose}
|
||||||
|
)
|
||||||
|
|
||||||
|
final_params = result.x
|
||||||
|
final_iou = 1.0 - result.fun
|
||||||
|
|
||||||
|
print(f"\nOptimization complete ({iter_count[0]} iterations)")
|
||||||
|
print(f"Final IoU: {final_iou:.4f} ({final_iou*100:.1f}%)")
|
||||||
|
print(f"Final params:")
|
||||||
|
for name, val in zip(param_names, final_params):
|
||||||
|
print(f" {name} = {val:.4f}")
|
||||||
|
|
||||||
|
# Generate OpenSCAD code
|
||||||
|
scad_code = generate_scad(model_type, param_names, final_params)
|
||||||
|
print(f"\n--- Generated OpenSCAD ---\n{scad_code}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'model_type': model_type,
|
||||||
|
'params': dict(zip(param_names, [round(float(v), 4) for v in final_params])),
|
||||||
|
'iou': round(float(final_iou), 4),
|
||||||
|
'iterations': iter_count[0],
|
||||||
|
'volume_target': round(float(mesh.volume), 2),
|
||||||
|
'scad_code': scad_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_scad(model_type, param_names, params):
|
||||||
|
"""Generate OpenSCAD code from optimized parameters."""
|
||||||
|
p = dict(zip(param_names, params))
|
||||||
|
|
||||||
|
if model_type == 'stadium-slot':
|
||||||
|
return f"""// Auto-generated by SDF optimizer
|
||||||
|
// IoU-optimized parameters
|
||||||
|
length = {p['length']:.3f};
|
||||||
|
width = {p['width']:.3f};
|
||||||
|
height = {p['height']:.3f};
|
||||||
|
slot_total = {p['slot_total']:.3f};
|
||||||
|
slot_width = {p['slot_width']:.3f};
|
||||||
|
cyl_d = {p['cyl_d']:.3f};
|
||||||
|
cyl_center_z = {p['cyl_center_z']:.3f};
|
||||||
|
|
||||||
|
$fn = 64;
|
||||||
|
|
||||||
|
difference() {{
|
||||||
|
linear_extrude(height = height)
|
||||||
|
stadium(length, width);
|
||||||
|
translate([0, 0, cyl_center_z])
|
||||||
|
hull() {{
|
||||||
|
translate([-(slot_total/2 - slot_width/2), 0, 0]) sphere(d=cyl_d, $fn=64);
|
||||||
|
translate([(slot_total/2 - slot_width/2), 0, 0]) sphere(d=cyl_d, $fn=64);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
module stadium(l, w) {{
|
||||||
|
r = w / 2;
|
||||||
|
hull() {{
|
||||||
|
translate([-(l/2 - r), 0]) circle(r=r);
|
||||||
|
translate([(l/2 - r), 0]) circle(r=r);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
return f"// No code generator for model_type={model_type}"
|
||||||
|
|
||||||
|
|
||||||
|
# ========== CLI ==========
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser(description='SDF-based OpenSCAD parameter optimizer')
|
||||||
|
parser.add_argument('stl_file', help='Input STL file')
|
||||||
|
parser.add_argument('model_type', choices=list(MODEL_REGISTRY.keys()),
|
||||||
|
help='Model type to fit')
|
||||||
|
parser.add_argument('--samples', type=int, default=30000, help='Sample points')
|
||||||
|
parser.add_argument('--output', help='Output JSON file')
|
||||||
|
parser.add_argument('--verbose', action='store_true', help='Show progress')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result = optimize(args.stl_file, args.model_type,
|
||||||
|
n_samples=args.samples, verbose=args.verbose)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, 'w') as f:
|
||||||
|
json.dump(result, f, indent=2)
|
||||||
|
print(f"\nResults saved to {args.output}")
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# openscad-stl-analyze.sh — Extract geometry data from binary STL files
|
||||||
|
# Outputs bounding box, vertex distributions, and internal structure hints
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: openscad-stl-analyze.sh <file.stl> [--cross-section <axis> <value>] [--gaps <axis>]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
<file.stl> Full bounding box and triangle count
|
||||||
|
--cross-section <axis> <value> Show vertex distribution at a cross-section
|
||||||
|
axis: x, y, or z; value: coordinate
|
||||||
|
--gaps <axis> Find gaps in vertex distribution along axis
|
||||||
|
(useful for finding internal features)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
openscad-stl-analyze.sh model.stl
|
||||||
|
openscad-stl-analyze.sh model.stl --cross-section z 0
|
||||||
|
openscad-stl-analyze.sh model.stl --gaps y
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
STL_FILE="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [[ ! -f "$STL_FILE" ]]; then
|
||||||
|
echo "ERROR: File not found: $STL_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Default: full analysis
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
python3 -c "
|
||||||
|
import struct, math, sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
path = '$STL_FILE'
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
header = f.read(80)
|
||||||
|
n = struct.unpack('<I', f.read(4))[0]
|
||||||
|
verts = set()
|
||||||
|
for _ in range(n):
|
||||||
|
f.read(12) # normal
|
||||||
|
for _ in range(3):
|
||||||
|
v = struct.unpack('<3f', f.read(12))
|
||||||
|
verts.add((round(v[0],4), round(v[1],4), round(v[2],4)))
|
||||||
|
f.read(2)
|
||||||
|
|
||||||
|
xs = [v[0] for v in verts]
|
||||||
|
ys = [v[1] for v in verts]
|
||||||
|
zs = [v[2] for v in verts]
|
||||||
|
|
||||||
|
print('=== STL Analysis ===')
|
||||||
|
print(f'File: {path}')
|
||||||
|
print(f'Triangles: {n}')
|
||||||
|
print(f'Unique vertices: {len(verts)}')
|
||||||
|
print()
|
||||||
|
print('=== Bounding Box ===')
|
||||||
|
print(f'X: {min(xs):.4f} to {max(xs):.4f} = {max(xs)-min(xs):.4f} mm')
|
||||||
|
print(f'Y: {min(ys):.4f} to {max(ys):.4f} = {max(ys)-min(ys):.4f} mm')
|
||||||
|
print(f'Z: {min(zs):.4f} to {max(zs):.4f} = {max(zs)-min(zs):.4f} mm')
|
||||||
|
print(f'Center: ({(min(xs)+max(xs))/2:.4f}, {(min(ys)+max(ys))/2:.4f}, {(min(zs)+max(zs))/2:.4f})')
|
||||||
|
print()
|
||||||
|
print('=== Symmetry Check ===')
|
||||||
|
cx, cy, cz = (min(xs)+max(xs))/2, (min(ys)+max(ys))/2, (min(zs)+max(zs))/2
|
||||||
|
print(f'X symmetric: {abs(cx) < 0.01} (center offset: {cx:.4f})')
|
||||||
|
print(f'Y symmetric: {abs(cy) < 0.01} (center offset: {cy:.4f})')
|
||||||
|
print(f'Z base at 0: {abs(min(zs)) < 0.01}')
|
||||||
|
print()
|
||||||
|
print('=== Distinct Values Per Axis ===')
|
||||||
|
print(f'Distinct X values: {len(set(round(x,3) for x in xs))}')
|
||||||
|
print(f'Distinct Y values: {len(set(round(y,3) for y in ys))}')
|
||||||
|
print(f'Distinct Z values: {len(set(round(z,3) for z in zs))}')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Gap detection on each axis
|
||||||
|
for axis_name, vals in [('X', xs), ('Y', ys), ('Z', zs)]:
|
||||||
|
sorted_unique = sorted(set(round(v,3) for v in vals))
|
||||||
|
gaps = []
|
||||||
|
for i in range(len(sorted_unique)-1):
|
||||||
|
gap = sorted_unique[i+1] - sorted_unique[i]
|
||||||
|
if gap > (max(vals)-min(vals)) * 0.05: # gaps > 5% of range
|
||||||
|
gaps.append((sorted_unique[i], sorted_unique[i+1], gap))
|
||||||
|
if gaps:
|
||||||
|
print(f'{axis_name}-axis gaps (>5% of range):')
|
||||||
|
for v1, v2, g in gaps:
|
||||||
|
print(f' {v1:.3f} to {v2:.3f} (gap={g:.3f} mm) — possible internal feature boundary')
|
||||||
|
print()
|
||||||
|
"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cross-section analysis
|
||||||
|
if [[ "$1" == "--cross-section" ]]; then
|
||||||
|
[[ $# -lt 3 ]] && usage
|
||||||
|
AXIS="$2"
|
||||||
|
VALUE="$3"
|
||||||
|
python3 -c "
|
||||||
|
import struct
|
||||||
|
|
||||||
|
path = '$STL_FILE'
|
||||||
|
axis = '$AXIS'
|
||||||
|
value = float($VALUE)
|
||||||
|
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
f.read(80)
|
||||||
|
n = struct.unpack('<I', f.read(4))[0]
|
||||||
|
verts = set()
|
||||||
|
for _ in range(n):
|
||||||
|
f.read(12)
|
||||||
|
for _ in range(3):
|
||||||
|
v = struct.unpack('<3f', f.read(12))
|
||||||
|
verts.add((round(v[0],4), round(v[1],4), round(v[2],4)))
|
||||||
|
f.read(2)
|
||||||
|
|
||||||
|
axis_idx = {'x':0, 'y':1, 'z':2}[axis]
|
||||||
|
other_axes = [i for i in range(3) if i != axis_idx]
|
||||||
|
axis_names = 'XYZ'
|
||||||
|
|
||||||
|
# Find vertices near the cross-section plane
|
||||||
|
tolerance = 0.02
|
||||||
|
cross_verts = [(v[other_axes[0]], v[other_axes[1]])
|
||||||
|
for v in verts if abs(v[axis_idx] - value) < tolerance]
|
||||||
|
|
||||||
|
if not cross_verts:
|
||||||
|
# Widen tolerance
|
||||||
|
tolerance = 0.2
|
||||||
|
cross_verts = [(v[other_axes[0]], v[other_axes[1]])
|
||||||
|
for v in verts if abs(v[axis_idx] - value) < tolerance]
|
||||||
|
|
||||||
|
print(f'=== Cross-section at {axis_names[axis_idx]}={value:.3f} (tol={tolerance}) ===')
|
||||||
|
print(f'Found {len(cross_verts)} vertices')
|
||||||
|
if cross_verts:
|
||||||
|
a_vals = sorted(set(round(v[0],4) for v in cross_verts))
|
||||||
|
b_vals = sorted(set(round(v[1],4) for v in cross_verts))
|
||||||
|
print(f'{axis_names[other_axes[0]]} range: {min(a_vals):.4f} to {max(a_vals):.4f}')
|
||||||
|
print(f'{axis_names[other_axes[1]]} range: {min(b_vals):.4f} to {max(b_vals):.4f}')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Show |values| distribution for the narrower axis (usually reveals internal features)
|
||||||
|
for oa, name in [(0, axis_names[other_axes[0]]), (1, axis_names[other_axes[1]])]:
|
||||||
|
abs_vals = sorted(set(round(abs(v[oa]),4) for v in cross_verts))
|
||||||
|
if len(abs_vals) < 80:
|
||||||
|
print(f'|{name}| distribution:')
|
||||||
|
for av in abs_vals:
|
||||||
|
bar = '#' * int(av * 8)
|
||||||
|
print(f' |{name}|={av:7.4f} {bar}')
|
||||||
|
print()
|
||||||
|
# Find gaps
|
||||||
|
for i in range(len(abs_vals)-1):
|
||||||
|
gap = abs_vals[i+1] - abs_vals[i]
|
||||||
|
if gap > 0.5:
|
||||||
|
print(f' GAP: |{name}|={abs_vals[i]:.4f} to |{name}|={abs_vals[i+1]:.4f} (width={gap:.4f})')
|
||||||
|
print(f' -> Possible feature boundary at |{name}|={abs_vals[i]:.4f}')
|
||||||
|
"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gap analysis
|
||||||
|
if [[ "$1" == "--gaps" ]]; then
|
||||||
|
[[ $# -lt 2 ]] && usage
|
||||||
|
AXIS="$2"
|
||||||
|
python3 -c "
|
||||||
|
import struct
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
path = '$STL_FILE'
|
||||||
|
axis = '$AXIS'
|
||||||
|
axis_idx = {'x':0, 'y':1, 'z':2}[axis]
|
||||||
|
axis_names = 'XYZ'
|
||||||
|
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
f.read(80)
|
||||||
|
n = struct.unpack('<I', f.read(4))[0]
|
||||||
|
verts = set()
|
||||||
|
for _ in range(n):
|
||||||
|
f.read(12)
|
||||||
|
for _ in range(3):
|
||||||
|
v = struct.unpack('<3f', f.read(12))
|
||||||
|
verts.add((round(v[0],4), round(v[1],4), round(v[2],4)))
|
||||||
|
f.read(2)
|
||||||
|
|
||||||
|
# Get all distinct values for other axes, grouped by the target axis levels
|
||||||
|
other_axes = [i for i in range(3) if i != axis_idx]
|
||||||
|
|
||||||
|
# Find distinct levels of the target axis
|
||||||
|
levels = sorted(set(round(v[axis_idx], 3) for v in verts))
|
||||||
|
print(f'=== {axis_names[axis_idx]}-axis gap analysis ===')
|
||||||
|
print(f'Distinct {axis_names[axis_idx]} levels: {len(levels)}')
|
||||||
|
print()
|
||||||
|
|
||||||
|
for level in levels:
|
||||||
|
level_verts = [v for v in verts if abs(v[axis_idx] - level) < 0.005]
|
||||||
|
for oa in other_axes:
|
||||||
|
abs_vals = sorted(set(round(abs(v[oa]), 4) for v in level_verts))
|
||||||
|
gaps = []
|
||||||
|
for i in range(len(abs_vals)-1):
|
||||||
|
g = abs_vals[i+1] - abs_vals[i]
|
||||||
|
if g > 0.5:
|
||||||
|
gaps.append((abs_vals[i], abs_vals[i+1], g))
|
||||||
|
if gaps:
|
||||||
|
print(f'{axis_names[axis_idx]}={level:6.3f}: |{axis_names[oa]}| gaps:')
|
||||||
|
for v1, v2, g in gaps:
|
||||||
|
print(f' {v1:.4f} to {v2:.4f} (gap={g:.4f}) — feature boundary')
|
||||||
|
"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
usage
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# openscad-stl-compare.sh — Compare two STL files geometrically
|
||||||
|
# Uses OpenSCAD boolean difference + Python mesh distance analysis
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
|
||||||
|
IMGSIZE="${OPENSCAD_IMGSIZE:-800,600}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: openscad-stl-compare.sh <original.stl> <reconstruction.stl> [output_dir]
|
||||||
|
|
||||||
|
Compares two STL files and reports:
|
||||||
|
1. Bounding box comparison
|
||||||
|
2. Volume/triangle count comparison
|
||||||
|
3. Boolean difference renders (what's in A but not B, and vice versa)
|
||||||
|
4. Point-to-mesh distance statistics (Hausdorff, RMS, mean)
|
||||||
|
|
||||||
|
Output:
|
||||||
|
<output_dir>/diff-A-minus-B.png — Geometry in original but NOT in reconstruction
|
||||||
|
<output_dir>/diff-B-minus-A.png — Geometry in reconstruction but NOT in original
|
||||||
|
<output_dir>/overlay.png — Both overlaid (original=transparent, recon=red)
|
||||||
|
<output_dir>/comparison-report.txt — Full numerical report
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -lt 2 ]] && usage
|
||||||
|
|
||||||
|
STL_A="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||||
|
STL_B="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
|
||||||
|
OUTDIR="${3:-/tmp/stl-compare-$(date +%s)}"
|
||||||
|
|
||||||
|
[[ -f "$STL_A" ]] || { echo "ERROR: File not found: $STL_A" >&2; exit 1; }
|
||||||
|
[[ -f "$STL_B" ]] || { echo "ERROR: File not found: $STL_B" >&2; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p "$OUTDIR"
|
||||||
|
TMPDIR=$(mktemp -d /tmp/stl-compare-XXXXXX)
|
||||||
|
trap 'rm -rf "$TMPDIR"' EXIT
|
||||||
|
|
||||||
|
echo "=== STL Mesh Comparison ==="
|
||||||
|
echo "A (original): $STL_A"
|
||||||
|
echo "B (reconstruction): $STL_B"
|
||||||
|
echo "Output: $OUTDIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- Step 1: Bounding box + triangle comparison ---
|
||||||
|
echo "--- Dimensional Comparison ---"
|
||||||
|
python3 -c "
|
||||||
|
import struct, sys
|
||||||
|
|
||||||
|
def parse_stl(path):
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
header = f.read(80)
|
||||||
|
n = struct.unpack('<I', f.read(4))[0]
|
||||||
|
mn = [float('inf')]*3
|
||||||
|
mx = [float('-inf')]*3
|
||||||
|
for _ in range(n):
|
||||||
|
f.read(12)
|
||||||
|
for _ in range(3):
|
||||||
|
v = struct.unpack('<3f', f.read(12))
|
||||||
|
for i in range(3):
|
||||||
|
mn[i] = min(mn[i], v[i])
|
||||||
|
mx[i] = max(mx[i], v[i])
|
||||||
|
f.read(2)
|
||||||
|
return n, mn, mx
|
||||||
|
|
||||||
|
n_a, mn_a, mx_a = parse_stl('$STL_A')
|
||||||
|
n_b, mn_b, mx_b = parse_stl('$STL_B')
|
||||||
|
|
||||||
|
print(f' Original Reconstruction Delta')
|
||||||
|
print(f'Triangles: {n_a:>8} {n_b:>8}')
|
||||||
|
labels = ['X', 'Y', 'Z']
|
||||||
|
total_delta = 0
|
||||||
|
for i in range(3):
|
||||||
|
da = mx_a[i] - mn_a[i]
|
||||||
|
db = mx_b[i] - mn_b[i]
|
||||||
|
delta = abs(db - da)
|
||||||
|
total_delta += delta
|
||||||
|
print(f'{labels[i]} dimension: {da:>12.4f} mm {db:>12.4f} mm {delta:>8.4f} mm')
|
||||||
|
# Also check position offset
|
||||||
|
ca = (mn_a[i] + mx_a[i]) / 2
|
||||||
|
cb = (mn_b[i] + mx_b[i]) / 2
|
||||||
|
if abs(ca - cb) > 0.01:
|
||||||
|
print(f' Center offset: {abs(ca-cb):.4f} mm')
|
||||||
|
|
||||||
|
print(f'Total dim delta: {total_delta:>8.4f} mm')
|
||||||
|
"
|
||||||
|
|
||||||
|
# --- Step 2: Boolean difference renders ---
|
||||||
|
echo ""
|
||||||
|
echo "--- Boolean Difference Renders ---"
|
||||||
|
|
||||||
|
# A - B: what's in original but not reconstruction
|
||||||
|
cat > "$TMPDIR/diff-a-minus-b.scad" << SCAD
|
||||||
|
difference() {
|
||||||
|
import("$STL_A", convexity=10);
|
||||||
|
import("$STL_B", convexity=10);
|
||||||
|
}
|
||||||
|
SCAD
|
||||||
|
|
||||||
|
# B - A: what's in reconstruction but not original
|
||||||
|
cat > "$TMPDIR/diff-b-minus-a.scad" << SCAD
|
||||||
|
difference() {
|
||||||
|
import("$STL_B", convexity=10);
|
||||||
|
import("$STL_A", convexity=10);
|
||||||
|
}
|
||||||
|
SCAD
|
||||||
|
|
||||||
|
# Overlay
|
||||||
|
cat > "$TMPDIR/overlay.scad" << SCAD
|
||||||
|
%import("$STL_A", convexity=10);
|
||||||
|
color("red", 0.5) import("$STL_B", convexity=10);
|
||||||
|
SCAD
|
||||||
|
|
||||||
|
# Render A-B
|
||||||
|
echo " Rendering A-B (in original, missing from reconstruction)..."
|
||||||
|
if "$OPENSCAD" --autocenter --viewall --imgsize="$IMGSIZE" --colorscheme=DeepOcean \
|
||||||
|
--render -o "$OUTDIR/diff-A-minus-B.png" "$TMPDIR/diff-a-minus-b.scad" 2>"$TMPDIR/ab.log"; then
|
||||||
|
# Check if the difference produced any geometry
|
||||||
|
if grep -q "Top level object is a 3D object" "$TMPDIR/ab.log"; then
|
||||||
|
facets=$(grep "Facets:" "$TMPDIR/ab.log" | tail -1 | grep -oE '[0-9]+')
|
||||||
|
echo " Difference has $facets facets"
|
||||||
|
fi
|
||||||
|
# Also export the difference as STL for volume analysis
|
||||||
|
"$OPENSCAD" --render --export-format binstl \
|
||||||
|
-o "$TMPDIR/diff-ab.stl" "$TMPDIR/diff-a-minus-b.scad" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " Render failed" >&2
|
||||||
|
rm -f "$OUTDIR/diff-A-minus-B.png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Render B-A
|
||||||
|
echo " Rendering B-A (in reconstruction, not in original)..."
|
||||||
|
if "$OPENSCAD" --autocenter --viewall --imgsize="$IMGSIZE" --colorscheme=DeepOcean \
|
||||||
|
--render -o "$OUTDIR/diff-B-minus-A.png" "$TMPDIR/diff-b-minus-a.scad" 2>"$TMPDIR/ba.log"; then
|
||||||
|
if grep -q "Top level object is a 3D object" "$TMPDIR/ba.log"; then
|
||||||
|
facets=$(grep "Facets:" "$TMPDIR/ba.log" | tail -1 | grep -oE '[0-9]+')
|
||||||
|
echo " Difference has $facets facets"
|
||||||
|
fi
|
||||||
|
"$OPENSCAD" --render --export-format binstl \
|
||||||
|
-o "$TMPDIR/diff-ba.stl" "$TMPDIR/diff-b-minus-a.scad" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo " Render failed" >&2
|
||||||
|
rm -f "$OUTDIR/diff-B-minus-A.png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Render overlay
|
||||||
|
echo " Rendering overlay..."
|
||||||
|
"$OPENSCAD" --autocenter --viewall --imgsize="$IMGSIZE" --colorscheme=Cornfield \
|
||||||
|
-o "$OUTDIR/overlay.png" "$TMPDIR/overlay.scad" 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- Step 3: Volume analysis of differences ---
|
||||||
|
echo ""
|
||||||
|
echo "--- Difference Volume Analysis ---"
|
||||||
|
python3 -c "
|
||||||
|
import struct, os
|
||||||
|
|
||||||
|
def stl_volume(path):
|
||||||
|
'''Calculate volume of a binary STL using signed tetrahedron method'''
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return 0, 0
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
f.read(80)
|
||||||
|
n = struct.unpack('<I', f.read(4))[0]
|
||||||
|
if n == 0:
|
||||||
|
return 0, 0
|
||||||
|
volume = 0.0
|
||||||
|
for _ in range(n):
|
||||||
|
f.read(12) # normal
|
||||||
|
v1 = struct.unpack('<3f', f.read(12))
|
||||||
|
v2 = struct.unpack('<3f', f.read(12))
|
||||||
|
v3 = struct.unpack('<3f', f.read(12))
|
||||||
|
f.read(2)
|
||||||
|
# Signed volume of tetrahedron with origin
|
||||||
|
volume += (
|
||||||
|
v1[0] * (v2[1]*v3[2] - v2[2]*v3[1]) +
|
||||||
|
v1[1] * (v2[2]*v3[0] - v2[0]*v3[2]) +
|
||||||
|
v1[2] * (v2[0]*v3[1] - v2[1]*v3[0])
|
||||||
|
) / 6.0
|
||||||
|
return n, abs(volume)
|
||||||
|
|
||||||
|
n_a, vol_a = stl_volume('$STL_A')
|
||||||
|
n_b, vol_b = stl_volume('$STL_B')
|
||||||
|
n_ab, vol_ab = stl_volume('$TMPDIR/diff-ab.stl')
|
||||||
|
n_ba, vol_ba = stl_volume('$TMPDIR/diff-ba.stl')
|
||||||
|
|
||||||
|
print(f'Original volume: {vol_a:>12.2f} mm³')
|
||||||
|
print(f'Reconstruction volume: {vol_b:>12.2f} mm³')
|
||||||
|
print(f'Volume delta: {abs(vol_b - vol_a):>12.2f} mm³ ({abs(vol_b-vol_a)/max(vol_a,1)*100:.2f}%)')
|
||||||
|
print()
|
||||||
|
print(f'A-B (missing from recon): {vol_ab:>10.2f} mm³ ({n_ab} triangles)')
|
||||||
|
print(f'B-A (extra in recon): {vol_ba:>10.2f} mm³ ({n_ba} triangles)')
|
||||||
|
print()
|
||||||
|
|
||||||
|
total_error = vol_ab + vol_ba
|
||||||
|
if vol_a > 0:
|
||||||
|
accuracy = (1 - total_error / vol_a) * 100
|
||||||
|
print(f'Total error volume: {total_error:>10.2f} mm³')
|
||||||
|
print(f'Geometric accuracy: {accuracy:>10.2f}%')
|
||||||
|
print()
|
||||||
|
if accuracy > 99:
|
||||||
|
print('Result: EXCELLENT match')
|
||||||
|
elif accuracy > 95:
|
||||||
|
print('Result: Good match (minor differences)')
|
||||||
|
elif accuracy > 90:
|
||||||
|
print('Result: Fair match (visible differences)')
|
||||||
|
else:
|
||||||
|
print('Result: Needs significant refinement')
|
||||||
|
" 2>&1 || echo "Volume analysis failed (non-manifold geometry?)"
|
||||||
|
|
||||||
|
# --- Step 4: Summary ---
|
||||||
|
echo ""
|
||||||
|
echo "--- Output Files ---"
|
||||||
|
ls -la "$OUTDIR"/*.png 2>/dev/null || echo "No PNG files generated"
|
||||||
|
echo ""
|
||||||
|
echo "View difference images to see WHERE the models differ."
|
||||||
|
echo " diff-A-minus-B.png = geometry in original but MISSING from reconstruction"
|
||||||
|
echo " diff-B-minus-A.png = EXTRA geometry in reconstruction not in original"
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# openscad-stl-reconstruct.sh — Automated STL analysis for reconstruction
|
||||||
|
# Uses projection slicing, trimesh analysis, and normal-based CSG inference
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: openscad-stl-reconstruct.sh <file.stl> <output_dir>
|
||||||
|
|
||||||
|
Automated STL analysis pipeline:
|
||||||
|
1. Bounding box + volume + basic mesh stats (via trimesh/admesh)
|
||||||
|
2. 2D profile slices at multiple Z levels (via OpenSCAD projection)
|
||||||
|
3. Normal analysis for CSG inference (cuts vs solid features)
|
||||||
|
4. Primitive detection (RANSAC plane/cylinder fitting)
|
||||||
|
5. Generates a decomposition report with OpenSCAD code suggestions
|
||||||
|
|
||||||
|
Output:
|
||||||
|
<output_dir>/report.txt Full analysis report
|
||||||
|
<output_dir>/slices/slice-z*.svg 2D profile SVGs at each Z level
|
||||||
|
<output_dir>/slices/slice-z*.png Rendered slice images
|
||||||
|
<output_dir>/primitives.json Detected primitives with parameters
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -lt 2 ]] && usage
|
||||||
|
|
||||||
|
STL_FILE="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||||
|
OUTDIR="$2"
|
||||||
|
|
||||||
|
[[ -f "$STL_FILE" ]] || { echo "ERROR: File not found: $STL_FILE" >&2; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p "$OUTDIR/slices"
|
||||||
|
|
||||||
|
echo "=== STL Reconstruction Analysis ==="
|
||||||
|
echo "Input: $STL_FILE"
|
||||||
|
echo "Output: $OUTDIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- Step 1: Basic mesh analysis with trimesh ---
|
||||||
|
echo "--- Step 1: Mesh Analysis ---"
|
||||||
|
python3 << PYEOF
|
||||||
|
import trimesh
|
||||||
|
import numpy as np
|
||||||
|
import json, os
|
||||||
|
|
||||||
|
mesh = trimesh.load_mesh('$STL_FILE', force='mesh')
|
||||||
|
|
||||||
|
print(f"Vertices: {len(mesh.vertices)}")
|
||||||
|
print(f"Faces: {len(mesh.faces)}")
|
||||||
|
print(f"Volume: {mesh.volume:.2f} mm³")
|
||||||
|
print(f"Watertight: {mesh.is_watertight}")
|
||||||
|
print(f"Bounding box: {mesh.bounds[0].round(3)} to {mesh.bounds[1].round(3)}")
|
||||||
|
dims = mesh.bounds[1] - mesh.bounds[0]
|
||||||
|
print(f"Dimensions: {dims[0]:.3f} x {dims[1]:.3f} x {dims[2]:.3f} mm")
|
||||||
|
print(f"Center: {mesh.centroid.round(3)}")
|
||||||
|
|
||||||
|
# Identify the primary axes (longest = extrusion direction)
|
||||||
|
axes = ['X', 'Y', 'Z']
|
||||||
|
sorted_axes = sorted(range(3), key=lambda i: dims[i], reverse=True)
|
||||||
|
print(f"Primary axis (longest): {axes[sorted_axes[0]]} ({dims[sorted_axes[0]]:.1f}mm)")
|
||||||
|
print(f"Likely extrusion axis: {axes[sorted_axes[0]]}")
|
||||||
|
|
||||||
|
# Bounding cylinder
|
||||||
|
try:
|
||||||
|
cyl = trimesh.bounds.minimum_cylinder(mesh)
|
||||||
|
print(f"Bounding cylinder: h={cyl['height']:.2f} r={cyl['radius']:.2f}")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Face normal analysis — detect dominant orientations
|
||||||
|
normals = mesh.face_normals
|
||||||
|
# Cluster normals by major axis alignment
|
||||||
|
for i, axis in enumerate(axes):
|
||||||
|
pos = np.sum(normals[:, i] > 0.9)
|
||||||
|
neg = np.sum(normals[:, i] < -0.9)
|
||||||
|
if pos + neg > 0:
|
||||||
|
print(f"Faces aligned with {axis}: +{pos} / -{neg}")
|
||||||
|
|
||||||
|
# Detect inward-facing curved surfaces (= cuts/holes)
|
||||||
|
# Curved faces have normals NOT aligned with any axis
|
||||||
|
non_planar = np.sum(np.all(np.abs(normals) < 0.9, axis=1))
|
||||||
|
print(f"Curved/non-planar faces: {non_planar} ({non_planar/len(normals)*100:.0f}%)")
|
||||||
|
|
||||||
|
# Save basic info as JSON
|
||||||
|
info = {
|
||||||
|
'file': '$STL_FILE',
|
||||||
|
'vertices': int(len(mesh.vertices)),
|
||||||
|
'faces': int(len(mesh.faces)),
|
||||||
|
'volume': float(mesh.volume),
|
||||||
|
'watertight': bool(mesh.is_watertight),
|
||||||
|
'bounds_min': mesh.bounds[0].tolist(),
|
||||||
|
'bounds_max': mesh.bounds[1].tolist(),
|
||||||
|
'dimensions': dims.tolist(),
|
||||||
|
'center': mesh.centroid.tolist(),
|
||||||
|
'primary_axis': axes[sorted_axes[0]],
|
||||||
|
}
|
||||||
|
with open('$OUTDIR/mesh-info.json', 'w') as f:
|
||||||
|
json.dump(info, f, indent=2)
|
||||||
|
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
# --- Step 2: 2D Profile Slices via OpenSCAD projection ---
|
||||||
|
echo ""
|
||||||
|
echo "--- Step 2: 2D Profile Slices ---"
|
||||||
|
|
||||||
|
# Get Z range from mesh info
|
||||||
|
Z_MIN=$(python3 -c "import json; d=json.load(open('$OUTDIR/mesh-info.json')); print(d['bounds_min'][2])")
|
||||||
|
Z_MAX=$(python3 -c "import json; d=json.load(open('$OUTDIR/mesh-info.json')); print(d['bounds_max'][2])")
|
||||||
|
Z_MID=$(python3 -c "print(($Z_MIN + $Z_MAX) / 2)")
|
||||||
|
|
||||||
|
echo "Z range: $Z_MIN to $Z_MAX (mid=$Z_MID)"
|
||||||
|
|
||||||
|
# Generate slices at key Z levels (bottom, 25%, 50%, 75%, top)
|
||||||
|
for pct in 0.01 0.25 0.50 0.75 0.99; do
|
||||||
|
Z_LEVEL=$(python3 -c "print(round($Z_MIN + ($Z_MAX - $Z_MIN) * $pct, 3))")
|
||||||
|
SLICE_NAME="slice-z${Z_LEVEL}"
|
||||||
|
|
||||||
|
# Create OpenSCAD file for this slice
|
||||||
|
cat > "/tmp/slice-${pct}.scad" << SCADEOF
|
||||||
|
projection(cut=true)
|
||||||
|
translate([0, 0, -${Z_LEVEL}])
|
||||||
|
import("${STL_FILE}", convexity=10);
|
||||||
|
SCADEOF
|
||||||
|
|
||||||
|
# Export as SVG
|
||||||
|
if "$OPENSCAD" -o "$OUTDIR/slices/${SLICE_NAME}.svg" "/tmp/slice-${pct}.scad" 2>/dev/null; then
|
||||||
|
SVG_SIZE=$(wc -c < "$OUTDIR/slices/${SLICE_NAME}.svg" | tr -d ' ')
|
||||||
|
if [[ "$SVG_SIZE" -gt 200 ]]; then
|
||||||
|
echo " Z=$Z_LEVEL (${pct}): SVG exported ($SVG_SIZE bytes)"
|
||||||
|
else
|
||||||
|
echo " Z=$Z_LEVEL (${pct}): Empty slice (no geometry at this Z)"
|
||||||
|
rm -f "$OUTDIR/slices/${SLICE_NAME}.svg"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Z=$Z_LEVEL (${pct}): Export failed"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Step 3: Primitive Detection with trimesh ---
|
||||||
|
echo ""
|
||||||
|
echo "--- Step 3: Primitive Detection ---"
|
||||||
|
python3 -c "
|
||||||
|
import trimesh
|
||||||
|
import numpy as np
|
||||||
|
import json
|
||||||
|
|
||||||
|
mesh = trimesh.load_mesh('$STL_FILE', force='mesh')
|
||||||
|
primitives = []
|
||||||
|
|
||||||
|
# Planar facets
|
||||||
|
try:
|
||||||
|
for i, (facet, area) in enumerate(zip(mesh.facets, mesh.facets_area)):
|
||||||
|
if area > 10:
|
||||||
|
fn = mesh.face_normals[facet]
|
||||||
|
avg_n = fn.mean(axis=0); avg_n /= np.linalg.norm(avg_n)
|
||||||
|
fv = mesh.vertices[mesh.faces[facet].flatten()]
|
||||||
|
primitives.append({'type':'plane','normal':avg_n.round(4).tolist(),'centroid':fv.mean(axis=0).round(3).tolist(),'area':round(float(area),2),'faces':len(facet)})
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Facet error: {e}')
|
||||||
|
|
||||||
|
# Cylindrical surfaces
|
||||||
|
normals = mesh.face_normals
|
||||||
|
curved_mask = np.all(np.abs(normals) < 0.85, axis=1)
|
||||||
|
curved_faces = np.where(curved_mask)[0]
|
||||||
|
print(f'Planar facets: {len([p for p in primitives if p[\"type\"]==\"plane\"])} (>{\"10mm²\"})')
|
||||||
|
print(f'Curved faces: {len(curved_faces)} ({len(curved_faces)/len(normals)*100:.0f}%)')
|
||||||
|
|
||||||
|
if len(curved_faces) > 10:
|
||||||
|
cv = mesh.vertices[np.unique(mesh.faces[curved_faces].flatten())]
|
||||||
|
cn = mesh.face_normals[curved_faces]
|
||||||
|
# Axis from normal cross products
|
||||||
|
axes_c = []
|
||||||
|
for _ in range(min(200, len(cn))):
|
||||||
|
i,j = np.random.choice(len(cn),2,replace=False)
|
||||||
|
cp = np.cross(cn[i],cn[j])
|
||||||
|
if np.linalg.norm(cp) > 0.1:
|
||||||
|
cp /= np.linalg.norm(cp)
|
||||||
|
if cp[0] < 0: cp *= -1
|
||||||
|
axes_c.append(cp)
|
||||||
|
if axes_c:
|
||||||
|
ax = np.mean(axes_c, axis=0); ax /= np.linalg.norm(ax)
|
||||||
|
# Project to 2D for circle fitting
|
||||||
|
if abs(ax[0]) < 0.9: u = np.cross(ax,[1,0,0])
|
||||||
|
else: u = np.cross(ax,[0,1,0])
|
||||||
|
u /= np.linalg.norm(u); v = np.cross(ax,u)
|
||||||
|
c3d = cv.mean(axis=0); centered = cv - c3d
|
||||||
|
pu = centered@u; pv = centered@v; pa = centered@ax
|
||||||
|
r = float(np.median(np.sqrt(pu**2+pv**2)))
|
||||||
|
# Inward/outward
|
||||||
|
fc = mesh.triangles_center[curved_faces]
|
||||||
|
tc = c3d - fc; tc /= (np.linalg.norm(tc,axis=1,keepdims=True)+1e-10)
|
||||||
|
inw = float(np.mean(np.sum(cn*tc,axis=1) > 0))
|
||||||
|
op = 'difference' if inw > 0.5 else 'union'
|
||||||
|
print(f'Cylinder: axis=[{ax[0]:.3f},{ax[1]:.3f},{ax[2]:.3f}] r={r:.3f} d={r*2:.3f} center={c3d.round(2).tolist()} type={op}')
|
||||||
|
print(f' Axis span: {float(pa.min()):.2f} to {float(pa.max()):.2f}')
|
||||||
|
primitives.append({'type':'cylinder','axis':ax.round(4).tolist(),'radius':round(r,3),'diameter':round(r*2,3),'center':c3d.round(3).tolist(),'axis_min':round(float(pa.min()),3),'axis_max':round(float(pa.max()),3),'csg_operation':op,'inward_ratio':round(inw,3),'faces':int(len(curved_faces))})
|
||||||
|
|
||||||
|
with open('$OUTDIR/primitives.json','w') as f:
|
||||||
|
json.dump(primitives,f,indent=2)
|
||||||
|
print(f'Saved {len(primitives)} primitives to $OUTDIR/primitives.json')
|
||||||
|
"
|
||||||
|
|
||||||
|
# --- Step 4: Generate Report ---
|
||||||
|
echo ""
|
||||||
|
echo "--- Step 4: Report ---"
|
||||||
|
echo "Profile slices saved in: $OUTDIR/slices/"
|
||||||
|
ls -la "$OUTDIR/slices/"*.svg 2>/dev/null | wc -l | xargs -I{} echo " {} SVG slices generated"
|
||||||
|
echo "Primitives saved to: $OUTDIR/primitives.json"
|
||||||
|
echo "Mesh info saved to: $OUTDIR/mesh-info.json"
|
||||||
|
echo ""
|
||||||
|
echo "=== Analysis Complete ==="
|
||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# openscad-validate.sh — Validate .scad files with structured error output
|
||||||
|
# Runs OpenSCAD in strict mode and parses errors into actionable categories
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: openscad-validate.sh <file.scad> [-D 'var=val' ...]"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
scad_file="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
if [[ ! -f "$scad_file" ]]; then
|
||||||
|
echo "ERROR: File not found: $scad_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmpdir=$(mktemp -d /tmp/openscad-validate-XXXXXX)
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
|
||||||
|
stl_out="$tmpdir/check.stl"
|
||||||
|
echo_out="$tmpdir/check.echo"
|
||||||
|
|
||||||
|
# Run OpenSCAD in strict mode
|
||||||
|
output=$("$OPENSCAD" \
|
||||||
|
--check-parameters=true \
|
||||||
|
--check-parameter-ranges=true \
|
||||||
|
--hardwarnings \
|
||||||
|
-o "$stl_out" \
|
||||||
|
-o "$echo_out" \
|
||||||
|
"$@" \
|
||||||
|
"$scad_file" 2>&1) || exit_code=$?
|
||||||
|
|
||||||
|
exit_code="${exit_code:-0}"
|
||||||
|
|
||||||
|
echo "=== Validation Report ==="
|
||||||
|
echo "File: $scad_file"
|
||||||
|
echo "Exit code: $exit_code"
|
||||||
|
|
||||||
|
# Categorize errors
|
||||||
|
if echo "$output" | grep -q "Parser error"; then
|
||||||
|
echo "Category: SYNTAX_ERROR"
|
||||||
|
echo "$output" | grep "ERROR:" | head -5
|
||||||
|
echo ""
|
||||||
|
# Extract line number
|
||||||
|
line=$(echo "$output" | sed -n 's/.*line \([0-9]*\).*/\1/p' | head -1)
|
||||||
|
if [[ -n "$line" ]]; then
|
||||||
|
echo "Error at line $line. Context:"
|
||||||
|
sed -n "$((line > 3 ? line - 3 : 1)),${line}p" "$scad_file" 2>/dev/null | cat -n
|
||||||
|
fi
|
||||||
|
elif echo "$output" | grep -q "Current top level object is empty"; then
|
||||||
|
echo "Category: EMPTY_MODEL"
|
||||||
|
echo "The model produces no geometry. Check:"
|
||||||
|
echo " - Are modules being called?"
|
||||||
|
echo " - Did a difference() remove everything?"
|
||||||
|
echo " - Are parameter values valid?"
|
||||||
|
elif echo "$output" | grep -q "NSOpenGLContext\|GLX\|Unable to create"; then
|
||||||
|
echo "Category: HEADLESS_PREVIEW"
|
||||||
|
echo "PNG preview unavailable (no OpenGL context)."
|
||||||
|
echo "STL export should still work."
|
||||||
|
elif echo "$output" | grep -qi "warning"; then
|
||||||
|
echo "Category: WARNING"
|
||||||
|
echo "$output" | grep -i "warning" | head -10
|
||||||
|
else
|
||||||
|
echo "Category: OK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show echo output if present
|
||||||
|
if [[ -f "$echo_out" ]] && [[ -s "$echo_out" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== Echo Output ==="
|
||||||
|
cat "$echo_out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show geometry stats if STL was produced
|
||||||
|
if [[ -f "$stl_out" ]]; then
|
||||||
|
stl_size=$(wc -c < "$stl_out" | tr -d ' ')
|
||||||
|
if [[ "$stl_size" -gt 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== Geometry ==="
|
||||||
|
echo "STL size: $stl_size bytes"
|
||||||
|
# Extract stats from render output
|
||||||
|
echo "$output" | grep -E "(Facets|Vertices|rendering time|Simple):" | head -10
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$exit_code"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// ============================================
|
||||||
|
// Template: Parametric Electronics Enclosure
|
||||||
|
// Description: Box with lid, screw posts, ventilation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// --- Parameters ---
|
||||||
|
width = 80; // [mm] inner width (X)
|
||||||
|
depth = 60; // [mm] inner depth (Y)
|
||||||
|
height = 35; // [mm] inner height (Z)
|
||||||
|
wall = 2.5; // [mm] wall thickness
|
||||||
|
corner_r = 3; // [mm] corner radius
|
||||||
|
lid_height = 8; // [mm] lid inner height
|
||||||
|
lip = 1.5; // [mm] lid overlap lip
|
||||||
|
tolerance = 0.3; // [mm] fit tolerance
|
||||||
|
|
||||||
|
// Screw posts
|
||||||
|
screw_d = 3; // [mm] screw hole diameter
|
||||||
|
post_d = 7; // [mm] post outer diameter
|
||||||
|
post_inset = 5; // [mm] post inset from inner wall
|
||||||
|
|
||||||
|
// Ventilation
|
||||||
|
vent_slots = 5; // number of vent slots
|
||||||
|
vent_width = 2; // [mm] slot width
|
||||||
|
vent_length = 20; // [mm] slot length
|
||||||
|
|
||||||
|
// --- Quality ---
|
||||||
|
$fn = 64;
|
||||||
|
eps = 0.01; // epsilon for clean booleans
|
||||||
|
|
||||||
|
// --- Assertions ---
|
||||||
|
assert(wall >= 1.2, "wall too thin for FDM (min 1.2mm)");
|
||||||
|
assert(width > 2 * wall, "inner width must be positive");
|
||||||
|
assert(depth > 2 * wall, "inner depth must be positive");
|
||||||
|
assert(height > wall, "inner height must be positive");
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
// Show both parts side by side
|
||||||
|
box_bottom();
|
||||||
|
translate([width + wall * 2 + 10, 0, 0]) box_lid();
|
||||||
|
|
||||||
|
// --- Modules ---
|
||||||
|
module box_bottom() {
|
||||||
|
difference() {
|
||||||
|
// Outer shell
|
||||||
|
rounded_box([width + 2*wall, depth + 2*wall, height + wall], corner_r);
|
||||||
|
|
||||||
|
// Inner cavity
|
||||||
|
translate([wall, wall, wall])
|
||||||
|
rounded_box([width, depth, height + wall + 1], max(corner_r - wall, 0.5));
|
||||||
|
|
||||||
|
// Ventilation slots on one side
|
||||||
|
translate([wall + (width - (vent_slots * (vent_width + 3))) / 2, -1, height/2])
|
||||||
|
for (i = [0:vent_slots-1])
|
||||||
|
translate([i * (vent_width + 3), 0, 0])
|
||||||
|
cube([vent_width, wall + 2, vent_length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screw posts
|
||||||
|
for (pos = screw_post_positions())
|
||||||
|
translate([pos.x, pos.y, wall])
|
||||||
|
screw_post(post_d, screw_d, height - 2);
|
||||||
|
|
||||||
|
// Lid lip (inner ridge)
|
||||||
|
difference() {
|
||||||
|
translate([wall - lip, wall - lip, height + wall - lip])
|
||||||
|
rounded_box([width + 2*lip, depth + 2*lip, lip], max(corner_r - wall + lip, 0.5));
|
||||||
|
translate([wall, wall, height + wall - lip - 0.01])
|
||||||
|
rounded_box([width, depth, lip + 0.02], max(corner_r - wall, 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module box_lid() {
|
||||||
|
difference() {
|
||||||
|
// Outer lid
|
||||||
|
rounded_box([width + 2*wall, depth + 2*wall, lid_height + wall], corner_r);
|
||||||
|
|
||||||
|
// Inner cavity
|
||||||
|
translate([wall, wall, -0.01])
|
||||||
|
rounded_box([width, depth, lid_height + 0.02], max(corner_r - wall, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lip insert (fits inside bottom lip)
|
||||||
|
translate([wall + tolerance, wall + tolerance, 0])
|
||||||
|
difference() {
|
||||||
|
rounded_box(
|
||||||
|
[width - 2*tolerance, depth - 2*tolerance, lip],
|
||||||
|
max(corner_r - wall - tolerance, 0.5)
|
||||||
|
);
|
||||||
|
translate([lip, lip, -0.01])
|
||||||
|
rounded_box(
|
||||||
|
[width - 2*lip - 2*tolerance, depth - 2*lip - 2*tolerance, lip + 0.02],
|
||||||
|
max(corner_r - wall - lip - tolerance, 0.5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module rounded_box(size, r) {
|
||||||
|
hull() {
|
||||||
|
for (x = [r, size.x - r])
|
||||||
|
for (y = [r, size.y - r])
|
||||||
|
translate([x, y, 0])
|
||||||
|
cylinder(r=r, h=size.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module screw_post(outer_d, inner_d, h) {
|
||||||
|
difference() {
|
||||||
|
cylinder(d=outer_d, h=h);
|
||||||
|
translate([0, 0, -0.01])
|
||||||
|
cylinder(d=inner_d, h=h + 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function screw_post_positions() = [
|
||||||
|
[wall + post_inset, wall + post_inset, 0],
|
||||||
|
[wall + width - post_inset, wall + post_inset, 0],
|
||||||
|
[wall + post_inset, wall + depth - post_inset, 0],
|
||||||
|
[wall + width - post_inset, wall + depth - post_inset, 0]
|
||||||
|
];
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// ============================================
|
||||||
|
// printable-lib.scad — Reusable modules for 3D printing
|
||||||
|
// Include with: use <printable-lib.scad>
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
eps = 0.01; // epsilon for clean boolean operations
|
||||||
|
|
||||||
|
// --- Clearance helpers ---
|
||||||
|
// Returns clearance value for different fit types
|
||||||
|
function fit_clearance(kind="close") =
|
||||||
|
kind == "press" ? 0.15 :
|
||||||
|
kind == "close" ? 0.25 :
|
||||||
|
kind == "loose" ? 0.40 :
|
||||||
|
kind == "slide" ? 0.30 : 0.25;
|
||||||
|
|
||||||
|
// --- Shell / Hollow box ---
|
||||||
|
module shell_box(outer=[60,40,20], wall=2, floor=2) {
|
||||||
|
assert(wall >= 1.2, "wall too thin for FDM (min 1.2mm)");
|
||||||
|
assert(floor >= 0.8, "floor too thin (min 0.8mm)");
|
||||||
|
difference() {
|
||||||
|
cube(outer);
|
||||||
|
translate([wall, wall, floor])
|
||||||
|
cube([outer.x - 2*wall, outer.y - 2*wall, outer.z - floor + eps]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rounded box (hull-based) ---
|
||||||
|
module rounded_box(size, r=2) {
|
||||||
|
hull() {
|
||||||
|
for (x = [r, size.x - r])
|
||||||
|
for (y = [r, size.y - r])
|
||||||
|
translate([x, y, 0])
|
||||||
|
cylinder(r=r, h=size.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Screw clearance hole ---
|
||||||
|
module screw_clearance_hole(d=3, h=10, fit="close") {
|
||||||
|
cylinder(h=h + 2*eps, d=d + fit_clearance(fit), $fn=48);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Counterbore hole (for socket head cap screws) ---
|
||||||
|
// Head pocket at entry side (top), shaft goes through
|
||||||
|
module counterbore_hole(shaft_d=3, head_d=6, head_h=3, h=12) {
|
||||||
|
union() {
|
||||||
|
screw_clearance_hole(shaft_d, h);
|
||||||
|
translate([0, 0, h - head_h + eps])
|
||||||
|
cylinder(h=head_h + eps, d=head_d, $fn=48);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Countersink hole ---
|
||||||
|
module countersink_hole(d=3, cs_d=6, cs_h=2, h=10) {
|
||||||
|
union() {
|
||||||
|
cylinder(d=d, h=h + 2*eps, $fn=48);
|
||||||
|
translate([0, 0, h - cs_h + eps])
|
||||||
|
cylinder(d1=d, d2=cs_d, h=cs_h, $fn=48);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Heat-set insert boss ---
|
||||||
|
module heatset_boss(insert_d=4.6, insert_h=5, wall=2, h=8) {
|
||||||
|
assert(wall >= 1.6, "boss wall too thin for heat-set insert");
|
||||||
|
difference() {
|
||||||
|
cylinder(h=h, d=insert_d + 2*wall, $fn=64);
|
||||||
|
translate([0, 0, -eps])
|
||||||
|
cylinder(h=insert_h + 2*eps, d=insert_d, $fn=64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Screw post (solid post with hole) ---
|
||||||
|
module screw_post(outer_d=7, inner_d=3, h=10) {
|
||||||
|
difference() {
|
||||||
|
cylinder(d=outer_d, h=h, $fn=48);
|
||||||
|
translate([0, 0, -eps])
|
||||||
|
cylinder(d=inner_d, h=h + 2*eps, $fn=48);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Structural rib / gusset ---
|
||||||
|
module rib(len=20, height=12, thick=2) {
|
||||||
|
linear_extrude(height=thick)
|
||||||
|
polygon([[0, 0], [len, 0], [0, height]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chamfer edge (for print-friendly overhangs) ---
|
||||||
|
module chamfer_edge(length=10, size=1) {
|
||||||
|
translate([0, 0, -eps])
|
||||||
|
linear_extrude(height=length)
|
||||||
|
polygon([[0, 0], [size, 0], [0, size]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Snap-fit tab ---
|
||||||
|
// Creates a cantilever snap tab extending along Y with a hook at the end
|
||||||
|
module snap_tab(width=8, length=6, thick=1.5, overhang=0.8) {
|
||||||
|
union() {
|
||||||
|
// Cantilever arm
|
||||||
|
cube([width, length, thick]);
|
||||||
|
// Hook at the end (rotated extrusion for clean manifold)
|
||||||
|
translate([0, length - eps, 0])
|
||||||
|
rotate([90, 0, 90])
|
||||||
|
linear_extrude(height=width)
|
||||||
|
polygon([[0, 0], [thick + eps, 0], [thick/2, overhang]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Text emboss/deboss helper ---
|
||||||
|
// Use with difference() to deboss or union() to emboss
|
||||||
|
module text_label(txt="Label", size=8, depth=1, font="Liberation Sans:style=Bold",
|
||||||
|
halign="center", valign="center") {
|
||||||
|
linear_extrude(height=depth)
|
||||||
|
text(txt, size=size, font=font, halign=halign, valign=valign);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ventilation grille ---
|
||||||
|
module vent_grille(area_w=30, area_h=15, slot_w=2, slot_gap=2, depth=2) {
|
||||||
|
n_slots = floor(area_h / (slot_w + slot_gap));
|
||||||
|
for (i = [0:n_slots-1])
|
||||||
|
translate([0, i * (slot_w + slot_gap), 0])
|
||||||
|
cube([area_w, slot_w, depth + 2*eps]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PCB standoff array ---
|
||||||
|
module pcb_standoffs(positions, height=5, outer_d=6, hole_d=2.5) {
|
||||||
|
for (pos = positions)
|
||||||
|
translate(pos)
|
||||||
|
difference() {
|
||||||
|
cylinder(d=outer_d, h=height, $fn=32);
|
||||||
|
translate([0, 0, -eps])
|
||||||
|
cylinder(d=hole_d, h=height + 2*eps, $fn=32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Profile with rounded corners (2D) ---
|
||||||
|
// Use with linear_extrude() — preferred over hull() of cylinders
|
||||||
|
module rounded_rect_2d(size, r=2) {
|
||||||
|
offset(r=r)
|
||||||
|
square([size.x - 2*r, size.y - 2*r], center=true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lid lip (for box closures) ---
|
||||||
|
module lid_lip(outer_size, wall=2, lip_h=2, lip_w=1.2, tol=0.25) {
|
||||||
|
difference() {
|
||||||
|
rounded_box([outer_size.x, outer_size.y, lip_h], r=2);
|
||||||
|
translate([lip_w + tol, lip_w + tol, -eps])
|
||||||
|
rounded_box([
|
||||||
|
outer_size.x - 2*(lip_w + tol),
|
||||||
|
outer_size.y - 2*(lip_w + tol),
|
||||||
|
lip_h + 2*eps
|
||||||
|
], r=max(2 - lip_w, 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user