Compare commits

...
20 Commits
Author SHA1 Message Date
martinandClaude Opus 4.8 6c586a753d feat(hooks): Antwort-Regeln per UserPromptSubmit-Hook injizieren
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 07:14:59 +02:00
martinandClaude Opus 4.8 1e55847426 docs: Antwort-Stil um drei Kompaktheits-Regeln ergaenzt
Keine Doppelung Chat/Datei, Analyse als Stichpunkte, kein ungefragter
Recap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:07:44 +02:00
martin 2e42790085 Revert disabling mouse click because there is no marking anymore 2026-07-24 14:19:44 +02:00
martinandClaude Opus 4.8 686b60164f docs: Ausnahme .commit-on-main im Git-Workflow ergaenzen
Marker-Datei im Repo-Root erlaubt Direkt-Commits auf main ohne
Feature-Branch/PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:04:54 +02:00
martinandClaude Opus 4.8 af40546164 chore: Claude-Code-Maus-Klicks per Env-Var deaktivieren
Setzt CLAUDE_CODE_DISABLE_MOUSE_CLICKS=1, damit im TUI wieder die native
Terminal-Textauswahl greift und Yakuake beim Markieren nicht einfaehrt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:00:22 +02:00
martin c6bbebea04 feat(skills): OpenSCAD-Skill hinzufuegen 2026-07-15 14:30:52 +02:00
martin 316b779b06 fix(hooks): Commit-Pruefung bei SSH-gewrappten Befehlen ueberspringen 2026-07-15 14:30:52 +02:00
martin c59baba62f chore: model auf opus setzen, Settings-Reihenfolge angepasst 2026-07-15 14:30:52 +02:00
martin ef77a35e90 fix(hooks): deny statt continue:false, damit Claude selbst korrigiert 2026-07-08 20:45:21 +02:00
martinandClaude Opus 4.8 4b14f21346 docs: Praegnanz-Abschnitt um Hinweis-Konvention geschaerft
Ungefragte Ausblicke (was-passiert-dann, naechster-schritt) explizit
verboten; Zusatzkontext nur als eine Hinweis-Zeile, mehr nur auf Nachfrage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 14:40:49 +02:00
martinandClaude Opus 4.8 39ddcc86ab docs: AsciiDoc als Standard-Dokumentenformat festlegen
Alle Dokumente kuenftig als .adoc statt Markdown. Von Tools erzwungene
Dateien (CLAUDE.md, MEMORY.md, README.md) bleiben Markdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:35:30 +02:00
martin 77eeb2aac7 docs: Regel zu fehlenden Tools als striktes Stopp-Signal schaerfen 2026-07-04 11:51:52 +02:00
martin 9926c50d2a CLAUDE.md angepasst 2026-07-04 11:42:12 +02:00
martinandClaude Opus 4.7 bb91f1ed9f fix: Statusline gegen Format-String-Bug und Zeilenumbruch härten
Variablen wurden bisher in den printf-Format-String interpoliert.
Pfade mit % konnten dadurch als Format-Direktive interpretiert werden
und den TUI-Renderer von claude-code aus dem Tritt bringen.

Zusätzlich werden Pfade über 25 Zeichen gekürzt, damit die Statusline
auf schmalen Terminals nicht umbricht — die zusätzliche Zeile war
der wahrscheinliche Auslöser des Render-Drifts ("Ausgabe scrollt nach
oben aus dem sichtbaren Bereich").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-19 08:22:03 +02:00
martin dbd833ae5b Add explanation rules 2026-06-18 21:09:31 +02:00
martinandClaude Opus 4.7 58d6ed0b1c refactor: INSTALL.md nur mit stack-spezifischen Inhalten
Die starre 8-Sektionen-Vorlage entfernt — sie enthielt viel Boilerplate
(Docker-Basics, generische compose-Befehle), der jeder Leser ohnehin kennt.
Neuer Ansatz: INSTALL.md listet nur das, was der Leser aus allgemeiner
Docker-Kenntnis nicht ableiten kann — konkrete Netze, Pfade, UIDs,
Pflicht-Env-Vars, Initial-Zugang, Reverse-Proxy-Setup, Major-Upgrade-Schritte,
stack-spezifisches Troubleshooting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 08:06:32 +02:00
martinandClaude Opus 4.7 2eb4277aaa feat: INSTALL.md pro Stack im /docker-compose Skill
Phase 3 erweitert um einen Pflichtschritt 3e: INSTALL.md wird neben der
docker-compose.yaml im Stack-Verzeichnis erzeugt. Sie enthält
Voraussetzungen, Verzeichnis-Setup, .env-Befüllung, Start, Erst-Konfiguration,
Backup, Update inkl. Breaking-Change-Hinweisen aus Phase 0.5 sowie
Troubleshooting. Bei Updates wird die existierende INSTALL.md ergänzt,
nicht überschrieben.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 08:04:29 +02:00
martinandClaude Opus 4.7 c640c5a575 feat: Container-Recherche-Phase im /docker-compose Skill
Vor der Generierung wird für jedes Image die aktuelle Doku via Context7,
Docker Hub und GitHub-Releases konsultiert: Pflicht-Env-Vars der aktuellen
Major-Version, Breaking Changes, deprecated Variablen, Default-Ports und
Volume-Pfade. Ergebnis landet als Header-Kommentar in der Compose-Datei.

Bei kritischen Stacks (DBs, Auth) wird die Major-Version gepinnt statt
:latest, um unkontrollierte Major-Upgrades zu vermeiden.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 08:02:57 +02:00
martinandClaude Opus 4.7 1c39f1fbb8 chore: Default-Modell auf claude-opus-4-7 anheben
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 07:54:07 +02:00
martinandClaude Opus 4.7 a93cfef13b feat: /docker-compose Skill für Compose-Stacks nach Hausregeln
Skill fragt vier Zielhosts ab (automation, controller, webserver, localhost),
prüft Port-Belegung gegen bestehende Compose-Files und aktive Listener und
generiert eine docker-compose.yaml plus .env nach den Hausregeln (5 MB Logging,
TZ Berlin, relative Bind-Mounts, Secrets in .env). Auf webserver werden interne
Ports an die VPN-IP 10.7.0.1 gebunden.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 07:53:58 +02:00
24 changed files with 4754 additions and 25 deletions
+1
View File
@@ -104,6 +104,7 @@ Kurzbefehl für den gesamten Ablauf: `/story`
| `/workflow-review` | `skills/workflow-review/SKILL.md` | Opus reviewt alle Skills/Konfig → Umsetzungsplan für Sonnet | | `/workflow-review` | `skills/workflow-review/SKILL.md` | Opus reviewt alle Skills/Konfig → Umsetzungsplan für Sonnet |
| `/implement` | `skills/implement/SKILL.md` | Implementierung direkt aus bestehendem Plan (überspringt Research/Planung) | | `/implement` | `skills/implement/SKILL.md` | Implementierung direkt aus bestehendem Plan (überspringt Research/Planung) |
| `/project-review` | `skills/project-review/SKILL.md` | Domänenunabhängiges Review für Nicht-Software-Dokumente (Bau, Hardware, Analysen) | | `/project-review` | `skills/project-review/SKILL.md` | Domänenunabhängiges Review für Nicht-Software-Dokumente (Bau, Hardware, Analysen) |
| `/docker-compose` | `skills/docker-compose/SKILL.md` | Erstellt/aktualisiert eine docker-compose.yaml nach Hausregeln (4 Zielhosts: automation/controller/webserver/localhost, Container-Doku-Recherche, Port-Belegungs-Check, 5 MB Logging, TZ Berlin, Bind-Mounts, .env-Secrets, VPN-Binding für Webserver) |
## Agenten ## Agenten
+38 -1
View File
@@ -4,6 +4,10 @@
Alle Texte, Kommentare und Kommunikation auf Deutsch. Technische Begriffe und Code-Bezeichner bleiben im Original. Alle Texte, Kommentare und Kommunikation auf Deutsch. Technische Begriffe und Code-Bezeichner bleiben im Original.
## Dokumentenformat
**Immer AsciiDoc (`.adoc`) statt Markdown** für alle Dokumente (Analysen, Pläne, Berichte, Anfragen usw.). Neue Dokumente als `.adoc` anlegen. Bestehende `.md`-Dateien nur auf ausdrücklichen Wunsch konvertieren. Ausnahmen: von Tools erzwungene Dateien (`CLAUDE.md`, `MEMORY.md`, `README.md` wo vom Workflow verlangt) bleiben Markdown.
## Entwicklungsprinzipien ## Entwicklungsprinzipien
- **Single Responsibility:** Jede Datei, jede Funktion hat genau eine Aufgabe - **Single Responsibility:** Jede Datei, jede Funktion hat genau eine Aufgabe
@@ -56,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
@@ -64,7 +69,16 @@ Niemals direkt auf `main` committen. Alle Aenderungen ueber Feature-Branches und
## Fehlende Tools ## Fehlende Tools
Wenn ein benötigtes CLI-Tool fehlt: **nicht** mit Workarounds (curl, rohe API-Calls) umgehen. Stattdessen: **Sobald die saubere Lösung ein Standard-CLI-Tool braucht, das fehlt: STOPP.**
Zuerst die Installation vorschlagen und auf den User warten — **bevor** irgendeine
Alternative gebaut wird. Eine fehlende Abhängigkeit ist ein Stopp-Signal, keine
Designfrage. Der User installiert lieber ein Tool, als dass komplizierte
Eigenlösungen entstehen.
Kein Ausweichen auf curl/rohe API-Calls, nachgebaute Login-Flows, HTML-/Token-
Parsing oder mehrstufige Eigenkonstruktionen — auch nicht „nur kurz zum Testen".
Solche Konstrukte gar nicht erst anfangen; das fehlende Tool ist immer der erste
Lösungsweg.
1. User informieren welches Tool fehlt und den Installations-Befehl nennen 1. User informieren welches Tool fehlt und den Installations-Befehl nennen
2. User führt den Befehl in einer separaten Konsole aus (sudo funktioniert dort) 2. User führt den Befehl in einer separaten Konsole aus (sudo funktioniert dort)
@@ -92,6 +106,29 @@ Bei User-Korrekturen oder neuen Erkenntnissen zuerst klassifizieren, dann am ric
**Niemals Arbeitsregeln im Memory-System ablegen.** Memory ist für Kontext, nicht für Regeln. Wenn eine User-Korrektur eine wiederkehrende Arbeitsregel beschreibt → CLAUDE.md. Immer. **Niemals Arbeitsregeln im Memory-System ablegen.** Memory ist für Kontext, nicht für Regeln. Wenn eine User-Korrektur eine wiederkehrende Arbeitsregel beschreibt → CLAUDE.md. Immer.
## Erläuterungs-Stil
Wenn der User um eine Erklärung auf **architektonischer, logischer, konzeptioneller, abstrakter oder prinzipieller Ebene** bittet (oder Varianten wie "vom Konzept her", "rein architektonisch"):
- Den **Mechanismus der zugrundeliegenden Technik in Fachsprache** erklären (Framework-Konzepte, Lifecycle, Semantik) — keine Alltagsmetaphern (Bibliothekar, Postbote, Restaurant).
- Das **Prinzip generisch** darlegen, **nicht am konkreten Code festmachen**. Am Ende kurz auf die Anwendung im Projekt zurückbeziehen.
- Technische Begriffe (z.B. "detached entity", "managed", "flush", "snapshot", "dirty check") frei verwenden, kurz einführen falls neu im Kontext, aber nicht durch Umschreibungen ersetzen.
Im Zweifel lieber technischer als metaphorischer. Der User hat solides Fachwissen über die Domäne (z.B. relationale Datenbanken, HTTP, Linux) und möchte framework-/tool-spezifische Konzepte tatsächlich verstehen, nicht durch Metaphern davor abgeschirmt werden.
## Antwort-Stil (Prägnanz)
Der User ist Informatiker/Techniker und will präzise Kommunikation mit den wesentlichen Fakten — nichts Ungefragtes.
- **Antwort zuerst, keine Herleitung.** Ergebnis in 13 Sätzen; den Weg dahin nur auf Nachfrage.
- **Nur das Gefragte.** Keine ungefragten Optionen, Empfehlungen, Mockups, „wo es hakt"-, „was-passiert-dann"- oder „für-den-nächsten-Schritt"-Exkurse. Kein Ausblick, den der User nicht verlangt hat.
- **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.
- **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 13 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
Bei Frameworks/Bibliotheken immer Context-7-Dokumentation abrufen (resolve-library-id / query-docs). Bei Frameworks/Bibliotheken immer Context-7-Dokumentation abrufen (resolve-library-id / query-docs).
+13 -2
View File
@@ -62,8 +62,17 @@
], ],
"defaultMode": "default" "defaultMode": "default"
}, },
"model": "claude-opus-4-6",
"hooks": { "hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/inject-answer-rules.sh"
}
]
}
],
"PreToolUse": [ "PreToolUse": [
{ {
"matcher": "Bash", "matcher": "Bash",
@@ -111,6 +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,
"model": "opus"
} }
+16 -10
View File
@@ -18,19 +18,26 @@ IFS=$'\t' read -r current_dir model total_input total_output context_size used_p
total_tokens=$(( (total_input + total_output) / 1000 )) total_tokens=$(( (total_input + total_output) / 1000 ))
context_limit_k=$(( context_size / 1000 )) context_limit_k=$(( context_size / 1000 ))
cost_formatted=$(LC_NUMERIC=C awk "BEGIN { cost_formatted=$(LC_NUMERIC=C awk -v cost="$cost_usd" 'BEGIN {
cents = int($cost_usd * 100 + 0.5) cents = int(cost * 100 + 0.5)
if (cents < 100) printf \"%d¢\", cents if (cents < 100) printf "%d¢", cents
else printf \"\$%.2f\", $cost_usd else printf "$%.2f", cost
}") }')
user=$(whoami) user=$(whoami)
host=$(hostname -s) host=$(hostname -s)
# Lange Pfade kürzen, damit die Statusline nie umbricht (sonst gerät der
# claude-code-Renderer aus dem Tritt, weil er 1 Zeile am unteren Rand erwartet).
display_dir=$current_dir
if [ ${#display_dir} -gt 25 ]; then
display_dir=".../$(basename "$(dirname "$current_dir")")/$(basename "$current_dir")"
fi
git_branch="" git_branch=""
if [ -n "$current_dir" ] && git -C "$current_dir" rev-parse --git-dir > /dev/null 2>&1; then if [ -n "$current_dir" ] && git -C "$current_dir" rev-parse --git-dir > /dev/null 2>&1; then
branch=$(git -C "$current_dir" branch --show-current 2>/dev/null) branch=$(git -C "$current_dir" branch --show-current 2>/dev/null)
[ -n "$branch" ] && git_branch=" \033[33m($branch)\033[00m" [ -n "$branch" ] && printf -v git_branch ' \033[33m(%s)\033[00m' "$branch"
fi fi
chroot_prefix="" chroot_prefix=""
@@ -38,7 +45,6 @@ if [ -r /etc/debian_chroot ]; then
chroot_prefix="($(cat /etc/debian_chroot))" chroot_prefix="($(cat /etc/debian_chroot))"
fi fi
printf "${chroot_prefix}\033[32m${user}@${host}\033[00m:\033[34m${current_dir}\033[00m${git_branch}" printf '%s\033[32m%s@%s\033[00m:\033[34m%s\033[00m%s \033[90m|\033[00m \033[36m%s\033[00m \033[90m|\033[00m \033[35m%dK/%dK (%d%%)\033[00m \033[90m|\033[00m \033[33m%s\033[00m' \
printf " \033[90m|\033[00m \033[36m${model}\033[00m" "$chroot_prefix" "$user" "$host" "$display_dir" "$git_branch" \
printf " \033[90m|\033[00m \033[35m${total_tokens}K/${context_limit_k}K (${used_percent}%%)\033[00m" "$model" "$total_tokens" "$context_limit_k" "$used_percent" "$cost_formatted"
printf " \033[90m|\033[00m \033[33m${cost_formatted}\033[00m"
+33
View File
@@ -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?"
+14
View File
@@ -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
+15 -12
View File
@@ -5,6 +5,12 @@ cmd=$(jq -r '.tool_input.command // ""')
VALID_TYPES="feat|fix|docs|refactor|test|chore|perf|ci|build|style" VALID_TYPES="feat|fix|docs|refactor|test|chore|perf|ci|build|style"
deny() {
jq -cn --arg reason "$1" \
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason}}'
exit 0
}
extract_subject() { extract_subject() {
local cmd="$1" local cmd="$1"
if echo "$cmd" | grep -qE "cat\s+<<"; then if echo "$cmd" | grep -qE "cat\s+<<"; then
@@ -16,14 +22,15 @@ 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 "")
if [ "$branch" = "main" ] && [ ! -f "${repo_root}/.commit-on-main" ]; then if [ "$branch" = "main" ] && [ ! -f "${repo_root}/.commit-on-main" ]; then
printf '{"continue":false,"stopReason":"BLOCKIERT: Direkter Commit auf main verboten (globale Regel). Bitte zuerst Feature-Branch erstellen: git checkout -b session/YYYY-MM-DD-titel"}\n' deny "BLOCKIERT: Direkter Commit auf main verboten (globale Regel). Bitte zuerst Feature-Branch erstellen: git checkout -b session/YYYY-MM-DD-titel"
exit 0
fi fi
# Commit-Message: Conventional Commits Format pruefen # Commit-Message: Conventional Commits Format pruefen
@@ -31,26 +38,22 @@ if echo "$cmd" | grep -qE '^\s*git\s+commit\b'; then
if [ -n "$subject" ]; then if [ -n "$subject" ]; then
if ! echo "$subject" | grep -qP "^(${VALID_TYPES})(\\(.+\\))?\\!?:\\s.+"; then if ! echo "$subject" | grep -qP "^(${VALID_TYPES})(\\(.+\\))?\\!?:\\s.+"; then
printf '{"continue":false,"stopReason":"BLOCKIERT: Commit-Message folgt nicht dem Conventional-Commits-Format. Erwartet: <type>: <Beschreibung>. Erlaubte Typen: feat, fix, docs, refactor, test, chore, perf, ci, build, style"}\n' deny "BLOCKIERT: Commit-Message folgt nicht dem Conventional-Commits-Format. Erwartet: <type>: <Beschreibung>. Erlaubte Typen: feat, fix, docs, refactor, test, chore, perf, ci, build, style"
exit 0
fi fi
if [ ${#subject} -gt 72 ]; then if [ ${#subject} -gt 72 ]; then
printf '{"continue":false,"stopReason":"BLOCKIERT: Commit-Message Subject zu lang (%d Zeichen, max 72)."}\n' "${#subject}" deny "BLOCKIERT: Commit-Message Subject zu lang (${#subject} Zeichen, max 72)."
exit 0
fi fi
if echo "$subject" | grep -qP '\.\s*$'; then if echo "$subject" | grep -qP '\.\s*$'; then
printf '{"continue":false,"stopReason":"BLOCKIERT: Commit-Message Subject darf nicht mit einem Punkt enden."}\n' deny "BLOCKIERT: Commit-Message Subject darf nicht mit einem Punkt enden."
exit 0
fi fi
fi fi
fi fi
# apt -y verboten # apt -y verboten
if echo "$cmd" | grep -qE '\bapt(-get)?\b' && echo "$cmd" | grep -qE '\bapt(-get)?\b.*\s-y\b'; then if echo "$cmd" | grep -qE '\bapt(-get)?\b' && echo "$cmd" | grep -qE '\bapt(-get)?\b.*\s-y\b'; then
printf '{"continue":false,"stopReason":"BLOCKIERT: apt -y ist verboten. Bitte ohne -y ausfuehren, damit du die Zusammenfassung selbst bestaetigen kannst."}\n' deny "BLOCKIERT: apt -y ist verboten. Bitte ohne -y ausfuehren, damit du die Zusammenfassung selbst bestaetigen kannst."
exit 0
fi fi
exit 0 exit 0
+353
View File
@@ -0,0 +1,353 @@
---
description: Erstellt oder aktualisiert eine docker-compose.yaml nach den Best Practices dieses Workflows — aktuelle Compose-Syntax, explizite Konfiguration, Secrets in .env, optionales VPN-Binding für den Webserver. Aktivieren wenn ein neuer Container-Stack aufgesetzt oder ein bestehender überarbeitet werden soll. Nicht verwenden für reine Image-Updates, einzelne docker-Befehle oder Dockerfile-Änderungen.
model: claude-opus-4-6
---
# /docker-compose — Compose-Stack nach Hausregeln
Zielort: ein Verzeichnis unter `/docker/<name>/` auf einem der Hosts oder lokal. Der Skill liefert eine `docker-compose.yaml` plus passende `.env`-Vorlage.
## Phase 0: Aufgabenklärung
Stelle dem User folgende Fragen, sofern nicht schon im Request beantwortet:
1. **Name des Stacks** (Verzeichnisname unter `/docker/`)
2. **Welche Services** sind enthalten (Image + Zweck)?
3. **Zielhost** — eine der folgenden vier Optionen, per `AskUserQuestion`:
| Ziel | SSH-Pfad | Netzwerk-Kontext |
|---|---|---|
| `automation` | `ssh automation:/docker/<name>/` | internes LAN, kein VPN-Binding |
| `controller` | `ssh controller:/docker/<name>/` | internes LAN, kein VPN-Binding |
| `webserver` | `ssh waldperle:/docker/<name>/` | öffentlich, interne Ports an VPN `10.7.0.1` binden |
| `localhost` | aktuelles Arbeitsverzeichnis | kein Bind-Prefix |
4. **Reverse-Proxy** — soll der Stack über `nginx-proxy-manager` ans Web? Dann `reverse-proxy`-Netz nötig.
5. **Persistente Daten** — welche Verzeichnisse müssen über Restarts/Recreates überleben (Datenbankdaten, Configs, Uploads, Logs)?
6. **Gewünschte Host-Ports** — welche Ports sollen extern erreichbar sein? (werden in Phase 1.5 gegen Belegung geprüft)
Bei `webserver` zusätzlich:
> "Sollen die Ports nur über das VPN (`10.7.0.1`) erreichbar sein oder auch öffentlich (z.B. `80`, `443`)?"
Standardregel: **alle Ports an `10.7.0.1` binden**, nur explizit öffentliche (HTTP/HTTPS für reverse-proxy) bleiben ohne Bind-IP.
## Phase 0.5: Container-Recherche (Pflicht vor Generierung)
Für **jedes** Image im Stack die aktuelle Dokumentation konsultieren, bevor die Compose-Datei geschrieben wird. Ziel: korrekte aktuelle Konfiguration, bekannte Breaking Changes berücksichtigen, deprecated Variablen vermeiden.
### Quellen (in dieser Reihenfolge)
1. **Context7** (bevorzugt für offizielle Images):
```
mcp__plugin_context7_context7__resolve-library-id → Library-ID finden
mcp__plugin_context7_context7__query-docs → gezielte Frage stellen
```
2. **Offizielle Docker-Hub-Seite** via `WebFetch`:
```
https://hub.docker.com/_/<image> # Official Images
https://hub.docker.com/r/<vendor>/<image> # Community Images
```
3. **GitHub-Releases / CHANGELOG** via `WebFetch` — für Breaking Changes der aktuellen Major-Version.
4. **WebSearch** als Fallback, wenn die obigen Quellen leer bleiben.
### Was muss konkret geprüft werden?
Pro Image diese Punkte abklären:
- **Aktuelle Major-Version** (`:latest` zeigt auf welche?)
- **Breaking Changes** seit der vorherigen Major-Version (z.B. PostgreSQL-Daten-Migration zwischen Major-Versionen, Keycloak `start-dev` vs `start --optimized`, MySQL→MariaDB-Replacements)
- **Pflicht-Env-Vars** der aktuellen Version (z.B. Auth-Defaults wurden in vielen Images entfernt — `POSTGRES_PASSWORD` ist Pflicht, kein Standard mehr)
- **Deprecated Env-Vars** — alte Namen vermeiden
- **Default-Ports** und ob sie sich geändert haben
- **Volume-Pfade** des Images — wo erwartet das Image persistente Daten?
- **Health-Check-Kommando** — was empfiehlt der Maintainer?
- **User/UID-Verhalten** — läuft das Image als Root, gibt es ein `PUID/PGID`-Pattern, oder `user:`-Override möglich?
### Output dieser Phase
Bevor weitergegangen wird, dem User eine **Zusammenfassung pro Image** zeigen:
```
postgres:latest → aktuell 17.x
Breaking: pg_dump-Format zwischen Major-Versionen inkompatibel — Major-Upgrade
erfordert pg_dumpall/restore (siehe Release-Notes)
Pflicht: POSTGRES_PASSWORD
Empfohlen: POSTGRES_DB, POSTGRES_USER, Healthcheck pg_isready
Volume: /var/lib/postgresql/data
```
Bei Major-Sprung gegenüber einem bestehenden Stack: User explizit warnen und Migrationsschritt vorschlagen, bevor `:latest` gepinnt bleibt.
### Im Compose-Header dokumentieren
Header-Kommentar in der `docker-compose.yaml`:
```yaml
# Stack: <name>
# Recherchiert: <YYYY-MM-DD>
# Images:
# postgres:latest → 17.x (Breaking: Major-Upgrade braucht pg_dumpall)
# <image>:latest → x.y (Hinweise/Breaking)
```
So bleibt nachvollziehbar, gegen welchen Wissensstand der Stack designed wurde.
## Phase 1.5: Port-Belegungs-Analyse (Pflicht vor Generierung)
Bevor irgendein Port in die `docker-compose.yaml` geschrieben wird, **beide** Prüfungen auf dem Zielhost ausführen:
### Prüfung 1: Alle bestehenden Compose-Files
```bash
# Bei automation/controller/webserver via SSH:
ssh <host> "grep -rhE '^\s*-\s*\"?([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:)?[0-9]+:[0-9]+' /docker/ 2>/dev/null \
| sed -E 's/.*\"?([0-9.]+:)?([0-9]+):.*/\2/' | sort -u"
# Bei localhost: lokal ausführen (Pfad ggf. anpassen)
grep -rhE '^\s*-\s*\"?([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:)?[0-9]+:[0-9]+' . 2>/dev/null \
| sed -E 's/.*\"?([0-9.]+:)?([0-9]+):.*/\2/' | sort -u
```
### Prüfung 2: Aktive Listener auf dem Host
```bash
ssh <host> "ss -tlnp 2>/dev/null | awk 'NR>1 {split(\$4, a, \":\"); print a[length(a)]}' | sort -u"
# Bei localhost:
ss -tlnp 2>/dev/null | awk 'NR>1 {split($4, a, ":"); print a[length(a)]}' | sort -u
```
### Konfliktauflösung
Vergleiche die in Phase 0 gewünschten Host-Ports mit beiden Listen:
- **Frei in beiden:** Port übernehmen.
- **Belegt in Prüfung 1 (Compose):** zeigen, welcher Stack/Service belegt — User fragen, ob Konflikt akzeptiert ist (selten korrekt) oder ein anderer Port gewählt werden soll.
- **Belegt in Prüfung 2 (Listener):** zeigen welcher Prozess — alternativen Port vorschlagen.
- **Vorschlag bei Konflikt:** nächste freie Port-Nummer aus passendem Bereich (Web-UIs `80008999`, DBs `5400+`, Custom `30003999`).
Erst wenn alle gewünschten Ports konfliktfrei sind, weiter zu Phase 3.
**Hinweis für `webserver`:** das VPN-IP-Prefix `10.7.0.1` ändert nichts an der Belegungsprüfung — ein bereits gebundener Port ist gebunden, egal an welche IP.
## Phase 1: Pflichtregeln (nicht verhandelbar)
Diese Regeln gelten für jeden generierten Stack:
### Compose-Syntax
- **Kein `version:`-Key.** Compose v2 ignoriert ihn — nicht aufnehmen.
- **`name: <stack>`** als oberstes Feld (Project-Name explizit setzen).
- **YAML-Mapping-Stil konsistent** pro Datei (entweder Listen-Stil `- KEY=VAL` oder Map-Stil `KEY: VAL` — nicht mischen innerhalb eines Services).
### Pro Service
- **`container_name: <name>`** — explizit, stabile Namen für Logs/Scripts.
- **`hostname: <name>`** — für interne Service-Adressierung in geteilten Netzwerken.
- **`restart: unless-stopped`** als Default. `always` nur wenn der Container nach einem manuellen `docker stop` von alleine wieder hochkommen soll.
- **`environment:`**
- `TZ=Europe/Berlin` ist Pflicht.
- Alle relevanten Env-Variablen explizit auflisten (keine impliziten Defaults aus dem Image — der Image-Default kann sich ändern).
- Sensible Werte (Passwörter, Tokens, Secrets, API-Keys) ausschließlich als `${VARIABLE}` referenzieren — Werte in `.env`.
- Keine Inline-Defaults vom Typ `${VAR:-default}` für Secrets. Für nicht-sensible Werte erlaubt, aber sparsam.
- **`logging:`**
```yaml
logging:
driver: "json-file"
options:
max-size: "5M"
max-file: "3"
```
5 MB pro Datei × 3 Dateien = max. 15 MB pro Container.
- **`labels:`**
```yaml
labels:
- "com.centurylinklabs.watchtower.enable=false"
```
Default `false` (entspricht globaler CLAUDE.md). Auf `true` setzen, wenn der Container über `:latest` läuft und automatische Updates erwünscht sind.
- **`user: "1000:1000"`** als Default. Ausnahmen nur, wenn das Image Root erfordert (z.B. NPM Port 80/443) — dann mit Kommentar warum.
- **Volumes als relative Bind-Mounts** (`./data:/data`). Niemals absolute Pfade ausserhalb von `./`, niemals named volumes für Datenpersistenz (Backup-Strategie hängt am Bind-Mount).
- **Read-only mounten** wo möglich (`./config:/etc/app/config:ro`).
### Ports
- **Immer als gequoteter String:** `"8080:80"`. Unquoted YAML kann `5060:5060` als Sexagesimalzahl interpretieren.
- **Pro Zielhost:**
- `automation`, `controller`, `localhost`: kein IP-Prefix (LAN-intern erreichbar).
- `webserver` (waldperle): alle internen Ports an `10.7.0.1` binden — `"10.7.0.1:8080:80"`. Nur explizit öffentliche Ports (NPM 80/443) ohne IP-Prefix.
- **Belegung muss in Phase 1.5 geprüft sein** — kein Port ohne vorherige Konfliktprüfung.
### Netzwerke
- **Externe Standard-Netze:**
- `reverse-proxy` — für alle Container, die über NPM ans Web sollen
- `public` — für Container mit direktem Public-Exposure
- `openhab` — Smart-Home-Stack (nur `automation`)
- Externe Netze in der `networks:`-Sektion immer mit `external: true` markieren.
- Stack-interne Netze ohne `external` deklarieren (oder weglassen — Compose erzeugt dann `<name>_default`).
### Multi-Service-Stacks
- **Healthchecks für DB/Cache:**
```yaml
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
```
- **`depends_on` mit `condition: service_healthy`** wo Healthchecks existieren — verhindert Race-Conditions beim Start.
### Image-Versionen
- **`:latest`** ist der Default (User-Wunsch: immer neueste Version) — aber nur, wenn die in Phase 0.5 dokumentierte Major-Version mit der Stack-Konfiguration kompatibel ist.
- Bei `:latest` + Watchtower-Enable=`true` → automatische Updates. Bei Datenbanken nur, wenn das Image rolling-kompatibel ist (oft nicht — siehe PostgreSQL-Hinweis aus Phase 0.5).
- Bei kritischen Stacks (Datenbanken, Auth) Major-Version pinnen (z.B. `postgres:17`) und Major-Upgrades bewusst durchführen.
### Security-Hardening (für exposed Services empfohlen)
```yaml
cap_drop:
- ALL
security_opt:
- no-new-privileges=true
```
Aufnehmen für: Web-UIs, REST-APIs, alles was Ports öffnet. Nicht für DBs/Caches im internen Netz (würde dort oft brechen).
### `.env`-Datei
- Liegt im selben Verzeichnis wie die `docker-compose.yaml`.
- Wird per `.gitignore` ausgeschlossen.
- Eine `.env.example` ohne Werte mitliefern — als Vorlage.
- Format:
```
# Pflicht
DB_USER=
DB_PASSWORD=
# ...
```
## Phase 2: VPN-Binding für Webserver-Stacks
Wenn der User in Phase 0 als Zielhost `webserver` (waldperle) gewählt hat:
1. **Bind-Adresse abfragen** falls nicht bekannt:
> "Welche VPN-Interface-IP soll gebunden werden? (Standard `waldperle`: `10.7.0.1`)"
2. **Pro Service-Port entscheiden:**
- Public (im Internet erreichbar, typisch Reverse-Proxy 80/443): ohne IP-Prefix.
- Alle anderen Ports (Web-UIs, DBs, Admin-Interfaces, Metrics): an die VPN-IP binden.
3. **Begründung im Kopf des Compose-Files als Kommentar dokumentieren:**
```yaml
# Webserver-Stack — interne Ports nur via VPN (10.7.0.1) erreichbar.
# Nur 80/443 (Reverse-Proxy) sind öffentlich exponiert.
```
## Phase 3: Generierung
### 3a: Verzeichnis-Layout
```
<stack-name>/
├── docker-compose.yaml
├── INSTALL.md (Schritt-für-Schritt-Anleitung)
├── .env (lokal, nicht in Git)
├── .env.example (Vorlage in Git)
├── .gitignore (mind. .env, dazu Daten-Verzeichnisse)
└── <persistente Verzeichnisse, z.B. ./data, ./db, ./config>
```
### 3b: Datei erzeugen
Generiere die `docker-compose.yaml` nach den Regeln aus Phase 1 + 2. Vor dem Schreiben prüfen, ob die Datei schon existiert — bei Update nur die nötigen Felder anfassen, vorhandene Kommentare/Strukturen erhalten.
### 3c: Persistente Verzeichnisse anlegen
Nur als Hinweis an den User — nicht selbst per `mkdir` ausführen. Die konkreten Befehle landen in der `INSTALL.md` (3e).
### 3d: `.env.example` erzeugen
Liste alle in der Compose-Datei referenzierten `${VARIABLE}` mit leerem Wert. Sortierung: erst Pflicht, dann optional. Jede Variable mit Einzeiler-Kommentar, wozu sie dient.
### 3e: `INSTALL.md` erzeugen (Pflicht)
Die `INSTALL.md` enthält **nur stack-spezifische Inhalte** — alles, was der Leser nicht aus der allgemeinen Docker-Compose-Kenntnis weiss. Allgemeines (was Docker ist, dass `docker compose up -d` startet, dass `.env` aus `.env.example` kopiert wird) **nicht aufnehmen**.
#### Was rein muss (sofern für den Stack zutreffend)
- **Header:** Stack-Name, Zielhost, Recherche-Datum (aus Phase 0/0.5)
- **Externe Netzwerke** dieses Stacks: konkrete Namen, die existieren müssen (`reverse-proxy`, `public`, `openhab`, …)
- **Persistente Verzeichnisse** dieses Stacks: exakte Pfade, erforderliche UID/GID, Sonderfälle (z.B. PostgreSQL braucht UID des Postgres-Users)
- **Pflicht-Env-Vars** dieses Stacks: was der Wert bedeutet, Format-Constraints, Generierungs-Hinweis (z.B. "32-Byte-Hex", "openssl rand -hex 32")
- **Initial-Zugang**: Default-Admin-Login, Setup-Wizard-URL, erstes Token aus den Logs ziehen — was dieses Image konkret macht
- **Reverse-Proxy-Einstellungen** für DIESEN Service: Ziel-Container + Port, Pfad-Besonderheiten, WebSocket-Pflicht, Trust-Proxy-Hops, spezielle Header
- **Ports und Erreichbarkeit** dieses Stacks: konkrete URLs, bei `webserver` mit `10.7.0.1` und Public-Variante
- **Backup-relevante Pfade** und stack-spezifische Pre-Backup-Schritte (z.B. `pg_dumpall` vor dem Volume-Backup)
- **Update-Besonderheiten** dieses Images (aus Phase 0.5): Major-Migrations-Skripte, deprecated Env-Vars die ersetzt werden müssen, Reihenfolge bei Multi-Service-Updates
- **Stack-spezifische Healthcheck-/Troubleshooting-Hinweise**: bekannte Fehler des Images, übliche Logmeldungen die Warnung sind aber keine Probleme, container-spezifische Reset-Schritte
#### Was raus bleibt
- Wie man Docker installiert
- Generische `docker compose`-Befehle ohne stack-spezifischen Kontext
- "cp .env.example .env" und ähnliche Standardschritte
- Allgemeine Compose-Theorie
#### Quellen für den Inhalt
- Phase 0.5 (Container-Recherche) — Breaking Changes, Pflicht-Env-Vars, Volume-Pfade
- Phase 1.5 / 2 — Ports, VPN-Binding
- Compose-Datei selbst — Bind-Mounts, Netzwerke, depends_on-Reihenfolge
- Bei Lücken: erneut Doku des Images via Context7/WebFetch konsultieren, nicht raten
Bei Update eines bestehenden Stacks: existierende `INSTALL.md` lesen, gezielt ergänzen — nicht überschreiben, keine Sektionen ohne neuen Inhalt einfügen.
## Phase 4: Validierung
```bash
# Auf dem Zielhost (automation/controller/webserver via SSH, oder lokal):
docker compose -f <pfad>/docker-compose.yaml config
```
Erfolgreich heisst: keine Warnings, kein "version is obsolete", alle `${VAR}` auflösbar (mit gefüllter `.env`).
Bei Fehlern: korrigieren, erneut validieren.
## Phase 5: Test-Empfehlung
```bash
# Trockenlauf
docker compose -f <pfad>/docker-compose.yaml config
# Start
cd <pfad> && docker compose up -d
# Logs prüfen
docker compose logs -f <service>
# Status
docker compose ps
```
Warte auf User-Rückmeldung, ob der Stack erfolgreich gestartet ist.
## Phase 6: Abschluss via /ship
Wenn die Compose-Datei in einem Git-Repo liegt: `/ship` aufrufen.
Liegt sie nur lokal auf dem Host (z.B. direkt unter `/docker/<name>/` auf `automation`/`waldperle`, ohne Git): User erinnern, die Konfiguration manuell ins Backup aufzunehmen.
## Pitfalls — explizite Verbote
- Kein `version:`-Key (obsolet).
- Keine impliziten Image-Defaults für relevante Konfiguration.
- Keine ungequoteten Port-Mappings.
- Keine absoluten Pfade in Bind-Mounts.
- Keine Secrets im Klartext in der Compose-Datei.
- Keine named Volumes für persistente Daten.
- Auf `webserver` (waldperle): kein ungebundener interner Port — sonst public.
- Keine Port-Angabe ohne vorherige Belegungs-Analyse aus Phase 1.5.
+278
View File
@@ -0,0 +1,278 @@
# OpenSCAD Claude Code Skill
A Claude Code skill for programmatic 3D CAD using [OpenSCAD](https://openscad.org/). Design, preview, iterate, reconstruct from STL, and export 3D-printable models — with AI-driven visual feedback and automated mesh comparison.
## Features
- **Programmatic 3D modeling** — Generate `.scad` files from natural language descriptions
- **AI vision feedback loop** — Render multi-angle PNG previews and analyze them to iteratively refine designs
- **STL-to-SCAD reconstruction** — Reverse-engineer STL meshes into parametric OpenSCAD code using SVG profiling and SDF optimization
- **Image-to-CAD replication** — Reproduce physical objects from reference photos
- **Parametric design** — All dimensions as variables, overridable via CLI `-D` flags
- **Mesh comparison** — Boolean diff rendering and IoU scoring to verify reconstruction accuracy
- **Printability analysis** — Wall thickness, overhang, and manifold validation
- **Reusable module library** — Common 3D printing patterns (counterbores, heat-set bosses, ribs, snap-fits)
- **Eval framework** — Automated testing with 20 binary assertions across 4 scenarios (100% pass rate)
## Prerequisites
- **OpenSCAD** installed and accessible via CLI
```bash
brew install openscad # macOS
```
- **Python packages** (for STL reconstruction):
```bash
pip3 install trimesh numpy scipy rtree shapely
```
- **admesh** (for mesh validation):
```bash
brew install admesh
```
- **Claude Code** CLI installed
## Installation
### Option 1: Clone and symlink (recommended)
```bash
git clone https://github.com/andreahaku/openscad_claude_skill.git ~/Development/Claude/openscad_claude_skill
mkdir -p ~/.claude/skills
ln -sf ~/Development/Claude/openscad_claude_skill ~/.claude/skills/openscad
```
### Option 2: Direct clone into skills
```bash
git clone https://github.com/andreahaku/openscad_claude_skill.git ~/.claude/skills/openscad
```
### Verify installation
```bash
openscad --version
bash ~/.claude/skills/openscad/scripts/openscad-render.sh quick ~/.claude/skills/openscad/templates/bracket.scad
```
## Usage
The skill triggers automatically when you mention 3D modeling, CAD, STL export, or OpenSCAD:
```
/openscad design a phone stand with adjustable angle
```
### Modes
| Mode | Trigger | Description |
|------|---------|-------------|
| **Design** | "design a bracket", "make a box" | Create new 3D models from descriptions |
| **Replicate** | "reproduce this from the photo" | Reverse-engineer objects from reference images |
| **Reconstruct** | "convert this STL to SCAD" | Reverse-engineer STL meshes into parametric code |
| **Refine** | "make it taller", "add fillets" | Iterate on existing designs |
| **Export** | "export STL", "ready for printing" | Generate production files |
| **Analyze** | "check printability" | Validate designs for 3D printing |
### Example Workflows
**Design from scratch:**
```
> Design a parametric enclosure for a Raspberry Pi 4 with ventilation slots
```
**Reconstruct from STL:**
```
> Convert /path/to/model.stl into parametric OpenSCAD code
```
**Export with parameter overrides:**
```
> Export the bracket with width=100 and wall=3
```
## Project Structure
```
openscad_claude_skill/
├── SKILL.md # Skill definition (6 modes, 762 lines)
├── README.md # This file
├── scripts/
│ ├── openscad-render.sh # Core render/export/preview engine (7 commands)
│ ├── openscad-project.sh # Project scaffolding (init/list/clean/info)
│ ├── openscad-validate.sh # Strict validation with error categorization
│ ├── openscad-stl-analyze.sh # STL mesh analysis (bbox, cross-sections, gaps)
│ ├── openscad-stl-reconstruct.sh # Automated reconstruction pipeline (SVG profiling)
│ ├── openscad-stl-compare.sh # Mesh comparison (boolean diff, accuracy %)
│ └── openscad-sdf-optimize.py # SDF parameter optimizer (IoU scoring)
├── references/
│ ├── language-reference.md # Complete OpenSCAD v2021.01 cheat sheet
│ └── reconstruction-guide.md # Best practices for STL-to-SCAD reconstruction
├── templates/
│ ├── enclosure.scad # Parametric electronics box with lid
│ ├── bracket.scad # L-bracket with countersunk holes
│ └── printable-lib.scad # Reusable 3D printing modules
└── eval/
├── eval.json # 4 test scenarios, 20 binary assertions
└── results.jsonl # Test results log
```
## Scripts
### `openscad-render.sh` — Core Rendering Engine
```bash
bash scripts/openscad-render.sh quick <file.scad> # Single isometric preview
bash scripts/openscad-render.sh preview <file.scad> # 4-view (iso, front, right, top)
bash scripts/openscad-render.sh stl <file.scad> [-D ...] # Export STL
bash scripts/openscad-render.sh 3mf <file.scad> # Export 3MF
bash scripts/openscad-render.sh export <file.scad> # Full export (STL + 3MF + PNG)
bash scripts/openscad-render.sh analyze <file.scad> # Printability analysis
bash scripts/openscad-render.sh custom <file.scad> [opts] # Custom render
```
### `openscad-stl-reconstruct.sh` — Automated STL Analysis
```bash
bash scripts/openscad-stl-reconstruct.sh model.stl output_dir/
```
Runs the full analysis pipeline:
1. **trimesh** mesh analysis (volume, watertight, normals, bounding cylinder)
2. **SVG profiling** via OpenSCAD `projection(cut=true)` at 5 Z levels
3. **Primitive detection** (RANSAC axis estimation, normal-based CSG inference)
Output: `mesh-info.json`, `primitives.json`, SVG slice files
### `openscad-stl-compare.sh` — Mesh Comparison
```bash
bash scripts/openscad-stl-compare.sh original.stl reconstruction.stl output_dir/
```
Produces:
- **diff-A-minus-B.png** — Geometry in original but MISSING from reconstruction
- **diff-B-minus-A.png** — EXTRA geometry in reconstruction
- **overlay.png** — Both models overlaid
- **Geometric accuracy %** — Based on volume of boolean differences
### `openscad-sdf-optimize.py` — Parameter Optimizer
```bash
python3 scripts/openscad-sdf-optimize.py model.stl stadium-slot --verbose
```
Uses Signed Distance Fields + IoU scoring to find optimal parameters without invoking OpenSCAD in the loop. Converges in seconds via `scipy.optimize.minimize`.
### `openscad-stl-analyze.sh` — Raw Mesh Analysis
```bash
bash scripts/openscad-stl-analyze.sh model.stl # Full analysis
bash scripts/openscad-stl-analyze.sh model.stl --cross-section z 5.0 # Slice at Z=5
bash scripts/openscad-stl-analyze.sh model.stl --gaps y # Find Y-axis gaps
```
## STL-to-SCAD Reconstruction
The reconstruction pipeline converts triangle meshes into clean, parametric OpenSCAD code:
### The Sculptor Approach
All reconstructions follow the sculptor method — start from a solid block, subtract everything:
```openscad
difference() {
solid_body(); // 1. Full solid first
channels(); // 2. Subtract channels/slots
taper_cuts(); // 3. Subtract wedges
all_holes(); // 4. ALL holes LAST
}
```
### Two-Tier Pipeline
1. **SVG Profile Analysis** (fast) — `openscad-stl-reconstruct.sh` identifies the model topology by slicing at multiple Z levels
2. **SDF Optimization** (precise) — `openscad-sdf-optimize.py` finds exact parameters via IoU scoring
### Proven Results
| Model | Complexity | Accuracy | Iterations |
|-------|-----------|----------|------------|
| Toothpaste Squeezer | Simple (1180 tri) | 96.27% | 2 |
| Interior Bracket | Complex (7576 tri) | 95.63% | 8 |
See `references/reconstruction-guide.md` for the complete best practices guide.
## Templates
### `printable-lib.scad` — Reusable Modules
```openscad
use <printable-lib.scad>
shell_box(outer=[60,40,20], wall=2, floor=2);
rounded_box([50,30,10], r=3);
screw_clearance_hole(d=3, h=10, fit="close");
counterbore_hole(shaft_d=3, head_d=6, head_h=3, h=12);
countersink_hole(d=3, cs_d=6, cs_h=2, h=10);
heatset_boss(insert_d=4.6, insert_h=5, wall=2, h=8);
screw_post(outer_d=7, inner_d=3, h=10);
rib(len=20, height=12, thick=2);
snap_tab(width=8, length=6, thick=1.5, overhang=0.8);
text_label("Hello", size=8, depth=1);
fit_clearance("press"); // 0.15mm
fit_clearance("close"); // 0.25mm
fit_clearance("slide"); // 0.30mm
fit_clearance("loose"); // 0.40mm
```
## 3D Printing Guidelines
- **Wall thickness**: min 1.2mm (FDM with 0.4mm nozzle)
- **Clearance**: 0.2-0.3mm for fitting parts
- **Overhangs**: < 45° from vertical; prefer chamfers on downward faces
- **Epsilon** (`eps = 0.01`) in all boolean operations
- **Flat bottoms** for bed adhesion
- **`assert()`** for self-validating parametric models
- **Counterbore vs countersink**: always check reference images
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENSCAD_BIN` | `$(command -v openscad)` | Path to OpenSCAD binary |
| `OPENSCAD_IMGSIZE` | `800,600` | Default preview image size |
| `OPENSCAD_COLORSCHEME` | `DeepOcean` | Default color scheme |
## Eval Framework
The skill includes automated testing:
```bash
ls eval/
# eval.json — 4 test scenarios, 20 binary assertions
# results.jsonl — Test results log
```
**Baseline: 100% pass rate (20/20)** across:
- Design (simple box + sculptor approach)
- STL reconstruction (96.27% geometric accuracy)
- Parametric export with -D overrides
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make changes
4. Run: `bash scripts/openscad-render.sh quick templates/bracket.scad`
5. Submit a pull request
## License
MIT
## Credits
Built with Claude Code (Anthropic), with architectural input from Gemini (Google) and Codex (OpenAI).
Powered by [OpenSCAD](https://openscad.org/) — The Programmers Solid 3D CAD Modeller.
+917
View File
@@ -0,0 +1,917 @@
---
disable-model-invocation: true
name: openscad
description: >
Programmatic 3D CAD with OpenSCAD. Generate .scad files, render STL for 3D printing,
preview as PNG with AI vision feedback. Triggers on: 3D model, STL, 3D print, parametric
design, openscad, CAD, enclosure, bracket, or any 3D modeling task.
argument-hint: "<description of object to design or path to existing .scad file>"
allowed-tools: "Bash(*),Read,Edit,Write,Glob,Grep,Agent"
metadata:
version: 1.0.0
category: 3d-cad
tags: [openscad, 3d-printing, cad, parametric, stl, modeling, design]
---
# OpenSCAD Skill
Design, render, preview, and export 3D models using OpenSCAD's programmatic CAD engine. Supports iterative AI-driven design refinement via rendered PNG analysis.
## Environment
- **OpenSCAD binary**: `/opt/homebrew/bin/openscad` (v2021.01)
- **Working directory for designs**: `~/openscad-projects/` (create per-project subdirectories)
- **Skill scripts**: `~/.claude/skills/openscad/scripts/`
- **Templates**: `~/.claude/skills/openscad/templates/`
- **Language reference**: `~/.claude/skills/openscad/references/`
## Modes
The skill operates in six modes, auto-detected from the user's request:
- **Design** — Create a new 3D model from a description
- **Replicate** — Reproduce a physical object from reference images
- **Reconstruct** — Reverse-engineer an STL mesh into parametric OpenSCAD code
- **Refine** — Iterate on an existing .scad file (modify, preview, repeat)
- **Export** — Render final STL/3MF for 3D printing
- **Analyze** — Review an existing design for printability or improvements
---
## Workflow: Design Mode
When the user asks to create a new 3D object:
### Step 1: Understand Requirements
Clarify with the user:
- **What** is the object? (enclosure, bracket, gear, container, etc.)
- **Dimensions** — key measurements in mm
- **Purpose** — functional print, aesthetic, mechanical fit?
- **Constraints** — printer bed size, material, wall thickness preferences
- **Parametric?** — which dimensions should be adjustable?
### Step 2: Set Up Project
```bash
bash ~/.claude/skills/openscad/scripts/openscad-project.sh init "<project-name>"
```
This creates `~/openscad-projects/<project-name>/` with subdirectories for source, output, and previews.
### Step 3: Generate the .scad File
Write the OpenSCAD code to `~/openscad-projects/<project-name>/src/main.scad`.
**Mandatory file structure (Feature Tree pattern):**
```openscad
// 1. PARAMETERS (independent variables)
width = 60; height = 30; wall = 2;
// 2. DERIVED DIMENSIONS (calculated from parameters)
inner_width = width - 2 * wall;
// 3. BASE PROFILE (2D sketch — the core shape)
module sketch_base() {
offset(r = corner_r)
square([width - 2*corner_r, depth - 2*corner_r], center=true);
}
// 4. PRIMARY BODY (extrude the sketch)
module body() { linear_extrude(height = height) sketch_base(); }
// 5. ADDITIVE FEATURES (bosses, ribs, tabs)
module features_add() { ... }
// 6. SUBTRACTIVE FEATURES (holes, slots, pockets — ALWAYS LAST)
module features_cut() { ... }
// 7. ASSEMBLY (the Feature Tree)
difference() {
union() { body(); features_add(); }
features_cut();
}
```
**Profile-first design rules:**
- Prefer `polygon()` + `linear_extrude()` over `hull()` of 3D primitives
- Use `offset(r=radius)` for corner rounding instead of `hull()` with cylinders
- Use `rotate_extrude()` for axially symmetric parts (never stack cylinders)
- Define dimensions relative to edges/features, not absolute coordinates: `hole_x = total_length - edge_margin` (not magic numbers)
- Cascade tolerances from a single `fit_clearance` parameter
**Critical rules for generating OpenSCAD code:**
- Read `~/.claude/skills/openscad/references/language-reference.md` if unsure about syntax
- Always define parametric dimensions as variables at the top of the file
- Use `$fn = 64;` for smooth curves (or higher for final renders)
- Add comments explaining each section
- Use modules for reusable parts
- Keep wall thickness >= 1.2mm for FDM printing
- Design with the print orientation in mind (flat bottom, minimal overhangs)
### Step 4: Preview
Render a multi-angle PNG preview:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview ~/openscad-projects/<project-name>/src/main.scad
```
This generates 4 preview images (front, side, top, isometric) in the project's `previews/` directory.
### Step 5: Analyze Preview
Read each preview PNG using the Read tool to see the rendered object. Evaluate:
- Does the shape match the user's description?
- Are proportions correct?
- Are there visible artifacts or unintended geometry?
- Would this print well? (overhangs, bridging, thin walls)
Report findings to the user with the preview images.
### Step 6: Iterate
If changes are needed, edit the .scad file and re-render. Repeat Steps 4-5 until the user is satisfied. Each iteration should be targeted — change one aspect at a time.
### Step 7: Export
When the design is approved:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh export ~/openscad-projects/<project-name>/src/main.scad
```
This produces:
- `output/model.stl` — for slicing and printing
- `output/model.3mf` — alternative format (better metadata)
- `previews/final-preview.png` — high-res final render
---
## Workflow: Replicate Mode
When the user provides reference images of a physical object to reproduce in OpenSCAD:
### Step 1: Analyze Reference Images
Read ALL provided reference images using the Read tool. For each image, extract:
- **Overall shape**: What geometric primitives compose this object?
- **Proportions**: Relative dimensions (height-to-width ratio, etc.)
- **Features**: Holes, fillets, chamfers, textures, slots, lips, threads
- **Symmetry**: Is it symmetric along any axis?
- **Construction**: How would you decompose it into boolean operations?
If dimensions are provided, note them. If not, estimate proportions from the images and ask the user for at least one known measurement to establish scale.
### Step 2: Create Decomposition Plan
Before writing any code, describe the object as a series of OpenSCAD operations:
```
Object: Phone stand
Decomposition:
1. Base: flat rectangle with rounded corners (80x60x5mm)
2. Back support: angled plate (60x3mm, tilted 70 degrees)
3. Front lip: small ridge to hold phone (60x3x8mm)
4. Fillet: smooth transition between base and back support
5. Cable channel: cylinder subtracted from base center
```
Present this plan to the user for confirmation before coding.
### Step 3: Generate Initial .scad File
Write the OpenSCAD code based on the decomposition. Set up the project:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-project.sh init "<object-name>"
```
Write the .scad to the project's `src/main.scad`.
### Step 4: Render and Compare
Generate a preview from the **same angle** as the reference image:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh quick ~/openscad-projects/<name>/src/main.scad
```
Read both the reference image and the rendered preview. Compare them side by side mentally:
- Does the overall silhouette match?
- Are proportions correct?
- Are features (holes, edges, curves) in the right places?
- What's the biggest discrepancy?
### Step 5: Iterative Refinement Loop
For each discrepancy found:
1. Identify which part of the .scad code controls the mismatched feature
2. Make a **single targeted edit** to improve the match
3. Re-render from the same angle
4. Re-compare with the reference
**Refinement priorities** (fix in this order):
1. Overall shape and proportions
2. Major features (holes, cutouts, protrusions)
3. Angles and curves
4. Fillets, chamfers, and surface details
5. Fine details
### Step 6: Multi-Angle Validation
Once the primary angle looks good, render from all angles that have reference images:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview ~/openscad-projects/<name>/src/main.scad
```
Compare each rendered view against its corresponding reference image. Fix any angle-specific discrepancies.
### Step 7: Dimensional Verification
If the user provided measurements, add `echo()` statements to verify:
```openscad
echo("Total width:", width);
echo("Total height:", height);
echo("Wall thickness:", wall);
```
Render with echo capture to verify dimensions match specifications.
### Step 8: Export
When the user confirms the replication is satisfactory, export for printing.
### Tips for Accurate Replication
- **Start simple**: Begin with bounding-box primitives, then refine
- **Use reference dimensions**: If user says "it's about 10cm tall", anchor ALL proportions to that
- **Match camera angle**: Use `--camera` to match the reference photo's perspective
- **Organic shapes**: Approximate with hull(), minkowski(), or rotate_extrude() of a profile
- **Iterate small**: Change one thing per render cycle
- **Ask when unsure**: If a feature is ambiguous from the images, ask the user rather than guessing
---
## Workflow: Reconstruct Mode (STL-to-SCAD)
When the user provides an STL file and wants it converted to parametric OpenSCAD code:
### Overview
STL files are triangle meshes with no semantic information about the original primitives or operations that created them. Reconstruction is the process of analyzing the mesh geometry and re-expressing it as clean, parametric OpenSCAD code. This is valuable because:
- Parametric code can be modified (change dimensions, add features)
- OpenSCAD code is human-readable and version-controllable
- The resulting model can be adapted to different use cases
### Critical Rules for Reconstruction
**Read `references/reconstruction-guide.md` before starting any reconstruction.** It contains the complete best practices guide learned from real reconstructions.
**Rule 1: SCULPTOR APPROACH (mandatory).** Start from a full solid block, subtract ALL features. Never build up from pieces — CSG ordering bugs cause added material to cover previously-cut holes. Structure: `difference() { solid_body(); channels(); tapers(); ALL_holes_LAST(); }`
**Rule 2: NEVER add features based on assumptions.** Always verify with SVG contour data AND reference images. If a feature doesn't appear as a separate contour in the SVG slices, IT DOES NOT EXIST. Known hallucinations to avoid:
- Pyramids/cones from render shadows
- Cylinders from curved wall edges
- Top holes from through-hole exit points
**Rule 3: Bounding box match ≠ correct model.** 0.000mm bbox delta can mean only 70% geometric accuracy. Always use mesh comparison (`openscad-stl-compare.sh`) with boolean diff images.
**Rule 4: ANALYZE FIRST, DECOMPOSE, THEN CHOOSE per-component approach.**
Do NOT jump to code. The analysis phase must answer these questions:
1. **What are the dominant features?** (diagonal arm, clips, channels, holes)
2. **What is the thinnest axis?** That's the likely extrusion direction — NOT necessarily the stability score winner
3. **Can the object be decomposed into simpler sub-objects?** Model each with its best technique
4. **Where are internal channels/gaps?** Slice along Z to find multi-body cross-sections
**Rule 5: Choose the extrusion axis by geometry, not just stability score.**
The profile extractor's stability score finds the axis with the most uniform cross-section. But this is misleading for models with diagonal features — slicing along Z for a diagonal bracket produces staircase artifacts. Instead:
| Model Type | Best Extrusion Axis | Why |
|-----------|-------------------|-----|
| Flat bracket/plate | Thinnest axis (smallest extent) | Profile in the wide plane captures all detail |
| Diagonal/angled arm | Thinnest axis | Diagonals live in the plane of the two longest axes |
| Clean extrusion (stability < 0.1) | Stability-score axis | Profiles are identical → stability is reliable |
| Cylindrical (stability > 0.3) | Object's rotational axis | Use rotate_extrude or parametric primitives |
| Truly complex (no good axis) | Dense multi-axis slabbing | 2mm slabs along thinnest axis |
```bash
# Always run ALL analysis tools before writing any code:
bash ~/.claude/skills/openscad/scripts/openscad-stl-reconstruct.sh model.stl analysis/
python3 ~/.claude/skills/openscad/scripts/openscad-profile-extract.py model.stl --json analysis/profile.json
python3 ~/.claude/skills/openscad/scripts/openscad-adaptive-slice.py model.stl analysis/
```
After analysis, compare: **thinnest axis extent** vs **stability-score axis**. If they differ, the thinnest axis is usually better for models with angled features.
**Rule 6: Choose the right technique for each component:**
| Geometry | Best Approach | Expected Accuracy |
|----------|--------------|-------------------|
| Flat/angular (brackets, plates) | Profile extraction + linear_extrude | 90-96% |
| Diagonal features (angled arms, tapers) | Profile along thinnest axis + linear_extrude | 85-92% |
| Simple known shapes (stadium, box) | Parametric primitives + SDF optimizer | 90-96% |
| Cylindrical features (puzzle tabs, bosses) | Parametric circle() + square() | 85-95% |
| Smooth transitions (convex shapes only) | hull() between boundary profiles | 85-90% |
| Multi-width models (width varies along axis) | Dense X-slab (2mm profiles along thinnest axis) | ~92% |
| Mixed (curves + flats) | Polygon profile (hi-res, tol=0.02) | 70-80% |
| Complex organic shapes | import() original STL + parametric modifications | N/A |
**Rule 7: hull() ONLY for convex profiles.** Hull between two profiles creates the convex hull — it fills in ALL concavities (channels, clips, U-forks, hooks). Only use hull for simple solid zones with 1 contour and no holes. For concave profiles, use linear_extrude of a representative profile instead.
**Rule 8: Dense X-slab approach for complex models.**
When no single extrusion works, slice every 2mm along the thinnest axis:
1. At each X position, extract the full Y-Z cross-section (ALL bodies, not just the largest)
2. Extrude each slab for 2mm width
3. Union all slabs — gaps between bodies are naturally preserved
4. Note: this approach has a ~6% volume overestimate floor from polygon extraction artifacts. Below 6% requires hand-modeled parametric geometry.
**Use the automated reconstruction analysis FIRST — before writing any code:**
```bash
bash ~/.claude/skills/openscad/scripts/openscad-stl-reconstruct.sh model.stl output_dir/
```
This runs the full pipeline: mesh stats (trimesh), 2D profile slices (OpenSCAD projection), primitive detection (RANSAC/normal analysis), and generates SVG profiles at multiple Z levels. The SVG profile analysis is the MOST IMPORTANT output — it reveals the complete cross-section structure at each height level.
**The SVG Profile Method** (preferred over vertex analysis):
1. `projection(cut=true)` slices the STL at a Z height → exports 2D SVG
2. Parse the SVG to count contours: BODY (large area) vs HOLES (small area)
3. Compare contours at different Z levels to understand how the shape changes with height
4. This reveals: channels, slots, holes, wall thickness, taper angles — all from 2D data
**After analysis, verify with mesh comparison:**
```bash
bash ~/.claude/skills/openscad/scripts/openscad-stl-compare.sh original.stl reconstruction.stl output_dir/
```
Target: >95% geometric accuracy. Use diff images to identify remaining discrepancies.
### Step 1: Automated Analysis (run ALL tools)
```bash
# Tool 1: SVG profiling + primitive detection
bash ~/.claude/skills/openscad/scripts/openscad-stl-reconstruct.sh model.stl analysis/
# Tool 2: Profile extraction + extrusion axis detection
python3 ~/.claude/skills/openscad/scripts/openscad-profile-extract.py model.stl \
--output analysis/profile.scad --json analysis/profile.json
# Tool 3: Adaptive multi-axis feature map
python3 ~/.claude/skills/openscad/scripts/openscad-adaptive-slice.py model.stl analysis/
```
**Key outputs to examine:**
- `analysis/slices/*.svg` — 2D profiles at 5 Z levels
- `analysis/profile.json` — extrusion axis, stability score, profile points, hole count
- `analysis/adaptive-slicing.json` — feature zones on all 3 axes, transition locations
- `analysis/primitives.json` — detected cylinders/planes
- `analysis/mesh-info.json` — volume, dimensions, symmetry
### Step 1b: Understand the Object (BEFORE writing code)
After running analysis tools, render multi-angle previews and answer:
1. **What are the main components?** (e.g. "bottom clip + diagonal arm + top clip")
2. **Which axis is thinnest?** Compare extents — the thinnest is likely the extrusion direction
3. **Are there diagonal/angled features?** If yes, the stability-score axis is probably WRONG
4. **Where do cross-sections change?** Check the adaptive slicer's transition zones
5. **Are there multi-body zones?** (channels, rails, gaps between parts)
**Decision tree — axis selection:**
1. If `stability_score < 0.1` AND thinnest axis matches stability axis → clean extrusion, use `profile.scad`
2. If model has **diagonal features** → use the **thinnest axis** regardless of stability score
3. If `stability_score < 0.3` AND no diagonals → stability axis + feature variations
4. If `stability_score > 0.3` → complex shape. Try dense X-slab along thinnest axis, or decompose into sub-objects
**Decision tree — technique per component:**
- Simple extruded body → profile + linear_extrude along extrusion axis
- Diagonal arm/strut → profile along thinnest axis captures it naturally
- Clips, hooks, U-channels → profile extraction (NOT hull — hull fills concavities)
- Cylindrical features → parametric circle() + square(), NOT polygon profiles
- Smooth convex transitions → hull() between boundary profiles (ONLY if convex)
- Complex multi-width → dense 2mm slabs along thinnest axis
**For models with cylindrical features** (stability > 0.3 or SVG shows circular contours):
- Do NOT rely on polygon profiles — they approximate curves poorly
- Identify circle centers and radii from the SVG contour data
- Model with `circle()` + `square()` in 2D, then extrude
Then render multi-angle previews:
```openscad
// Temporary viewer file
import("path/to/model.stl");
```
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview /tmp/stl-viewer.scad
```
Read all preview images to understand the 3D shape from multiple angles.
### Step 1b: Auto-Reconstruction (NEW — recommended for most models)
After running the adaptive slicer, use the auto-reconstructor to generate parametric .scad directly:
```bash
# Option A: With pre-computed analysis
python3 ~/.claude/skills/openscad/scripts/openscad-auto-reconstruct.py model.stl \
--analysis analysis/ --output project/src/main.scad
# Option B: Run analysis + reconstruction in one step
python3 ~/.claude/skills/openscad/scripts/openscad-auto-reconstruct.py model.stl \
--output project/src/main.scad --run-analysis
# Option C: Tighter circle fitting for precision parts
python3 ~/.claude/skills/openscad/scripts/openscad-auto-reconstruct.py model.stl \
--analysis analysis/ --output project/src/main.scad --circle-threshold 0.3
```
This automatically:
1. Parses the feature map JSON into zones
2. Extracts profiles at zone boundaries
3. Fits circles/arcs to replace polygon approximations (Feature 3)
4. Generates hull() blends for transition zones (Feature 2)
5. Emits one OpenSCAD module per zone with sculptor assembly (Feature 1)
The output is a good starting point — review and refine the generated .scad, then verify with mesh comparison. For models with cylindrical features, this typically achieves >85% accuracy automatically (vs 75% with polygon-only).
### Step 2: Detailed Structure Mapping
**For simple models** (5 SVG slices are enough):
Parse the SVG contours to count bodies vs holes at each Z level.
**For complex models** (brackets, enclosures with multiple features):
Use the adaptive multi-axis slicer for efficient feature detection:
```bash
python3 ~/.claude/skills/openscad/scripts/openscad-adaptive-slice.py model.stl analysis/
```
This automatically: scans all 3 axes with coarse pass (5mm) → detects transitions → fine pass (0.5mm) around transitions. Produces a feature map classifying each zone as `solid`, `shell_or_channel`, `multi_body`, or `complex`.
For manual fine-grained slicing at specific heights:
```bash
for z in $(seq 0.5 1 <max_z>); do
echo "projection(cut=true) translate([0,0,-$z]) import(\"model.stl\");" > /tmp/s.scad
openscad -o "slices/z${z}.svg" /tmp/s.scad
done
```
Parse each SVG to build a structural map:
```
Z=0-5: 1 body (full width) + 8 holes → Solid base with screw holes
Z=5-10: 2 bodies + 8 holes → Channel appeared, walls split
Z=10-20: 2 bodies narrowing → Taper zone (measure rate)
Z=20-33: 2 bodies constant width → Top section
Z=25-27: Bodies interrupted → Counterbore pockets at this depth
```
**Hole positions from SVG centroids** — for each hole contour at a given Z, compute the centroid. This gives exact X,Y positions far more reliably than vertex analysis.
**Feature verification rule:** If a feature doesn't appear as a distinct contour in the SVG data, IT DOES NOT EXIST in the model. Never add features based on visual interpretation of 3D renders alone.
### Step 3: Choose Approach and Decompose
Based on the analysis data, choose the reconstruction approach:
**Approach A — Profile Extrusion** (for extruded parts, stability < 0.3):
```bash
# The profile extractor already generated the .scad — use it as a starting point
cat analysis/profile.scad
# Adjust: add cavity with offset(delta=-wall), add floor, add features
```
**Approach B — Parametric Primitives** (for known shapes or cylindrical features):
Create a decomposition plan using measured dimensions from SVG data:
```
Decomposition:
1. Base: square([80, 80]) + circle tabs — from SVG outer contour at Z=mid
2. Cavity: offset(delta=-wall) of base — from SVG inner contour
3. Floor: solid at Z=0 to floor_h — from SVG at Z=0 (1 contour = solid)
4. Holes: cylinder(d=3) at SVG hole centroids
5. Counterbores: cylinder(d=8, h=2) at same positions
```
**Approach C — Hybrid** (for complex shapes with both flat and curved features):
1. Extract polygon profile for the overall outline
2. Identify which curves in the profile are circles (regular spacing, arc-like)
3. Replace those polygon sections with parametric `circle(r)` operations
4. Assemble: `square() + circle()` union for tabs, `difference()` for slots
**Counterbore vs Countersink** — always verify from reference images:
- **Counterbore**: flat cylindrical pocket (`cylinder(d=cb_d, h=cb_depth)`)
- **Countersink**: conical taper (`cylinder(d1=cs_d, d2=hole_d, h=cs_depth)`)
- Most 3D-printed parts use counterbores, not countersinks
### Step 4: Write Parametric .scad Code
Create a new project and write the reconstructed code:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-project.sh init "<name>-reconstructed"
```
**Key principles for reconstruction:**
- Extract ALL dimensions as named variables at the top
- Use meaningful variable names that describe the physical feature
- Add comments linking each section to the original STL features
- Include `echo()` statements for bounding box verification
- Add `assert()` for parameter ranges
### Step 5: Visual Comparison Loop
Render the reconstructed .scad and compare side-by-side with the original STL renders:
1. Render the reconstruction from the same camera angles as Step 1
2. Read both sets of images
3. Compare silhouettes, proportions, and feature placement
4. Identify the biggest discrepancy
5. Fix it and re-render
6. Repeat until the reconstruction matches the original
### Step 6: Overlay Verification
For precise verification, create an overlay .scad file:
```openscad
// Overlay: original STL (transparent) vs reconstruction
%import("path/to/original.stl"); // % = transparent background
color("red", 0.6) reconstructed_model();
```
Render this overlay — any RED areas visible through the transparent original indicate reconstruction errors. Any grey areas not covered by red indicate missing geometry.
### Step 7: Mesh-to-Mesh Comparison
**This is the most important verification step.** Export the reconstruction as STL and compare it against the original using boolean difference:
```bash
# Export reconstruction
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl ~/openscad-projects/<name>/src/main.scad
# Run mesh comparison
bash ~/.claude/skills/openscad/scripts/openscad-stl-compare.sh \
path/to/original.stl \
~/openscad-projects/<name>/output/main.stl \
~/openscad-projects/<name>/previews/comparison
```
This produces:
- **diff-A-minus-B.png** — geometry in original but MISSING from reconstruction (what you need to add)
- **diff-B-minus-A.png** — EXTRA geometry in reconstruction not in original (what you need to remove)
- **overlay.png** — both models overlaid for visual check
- **Geometric accuracy %** — based on volume of boolean differences vs original volume
**Target: >95% geometric accuracy.** If below 95%, examine the diff images to identify which features are wrong, fix them, re-export, and re-compare. Iterate until accuracy is satisfactory.
**Important:** Bounding box delta can be 0.000mm while geometric accuracy is only 78% — internal features matter more than outer dimensions.
### Step 8: Dimensional Verification
Compare echo output from the reconstruction with the STL bounding box:
```openscad
echo(str("Reconstructed BBOX: ", width, " x ", depth, " x ", height));
```
### Step 9: SDF Parameter Optimization (Advanced)
If the SVG profile method doesn't achieve >95% accuracy, use the SDF optimizer for automatic parameter tuning:
```bash
python3 ~/.claude/skills/openscad/scripts/openscad-sdf-optimize.py \
path/to/original.stl \
stadium-slot \
--verbose \
--output analysis/sdf-result.json
```
This works by:
1. Sampling 30,000 random points in the bounding box
2. Computing target occupancy (inside/outside original mesh) via trimesh
3. Defining the reconstruction as a parametric SDF (Signed Distance Field)
4. Using `scipy.optimize.minimize(method="Powell")` to maximize IoU (Intersection over Union)
5. Generating OpenSCAD code with optimized parameters
**When to use**: When you know the correct model topology (e.g., "stadium body with cylindrical slot") but can't find the exact parameters. The optimizer finds them automatically.
**Supported model types**: `stadium-slot`, `box-holes`. Add new types by defining an SDF function in the script.
**Workflow**: Run `openscad-stl-reconstruct.sh` first (to identify the model topology), then `openscad-sdf-optimize.py` (to find exact parameters), then `openscad-stl-compare.sh` (to verify).
**Prerequisites**: `pip3 install trimesh numpy scipy rtree`
### Common Pitfalls
- **Bounding box match ≠ correct model.** A model with completely wrong internal geometry can still have a 0.000mm bounding box delta. Always verify visually from multiple angles.
- **Don't assume features from renders alone.** What looks like a cylinder in a top-down view might just be a curved wall edge. Always verify with vertex analysis.
- **Coincident faces cause Z-fighting.** If a feature touches the body boundary exactly, use `intersection()` to clip it cleanly rather than making it the exact same size.
- **Don't flip between adding and removing features.** If unsure whether a feature exists, run cross-section analysis before deciding. Oscillating between "add bar" and "remove bar" wastes iterations.
- **Offset features are common.** Cylinders, holes, and channels are often NOT centered. Always calculate the actual center from vertex data rather than assuming symmetry.
### Limitations
- **Organic shapes** (sculpted, freeform surfaces) cannot be fully reconstructed as primitives. For these, keep the STL import and wrap it in a module.
- **Very complex models** (1000+ features) should be reconstructed incrementally, starting with the major body and adding features one group at a time.
- **Thread geometry** in STL is extremely difficult to reconstruct. Use `threads.scad` library instead of trying to match individual thread faces.
- **Text/engravings** embedded in STL meshes are very hard to extract. It's better to re-add text using OpenSCAD's `text()` module.
### Hybrid Approach
For complex models, use a hybrid strategy:
```openscad
// Import the complex organic base from STL
module original_base() {
import("base-section.stl");
}
// Reconstruct and parameterize the mechanical features
module mounting_bracket(width=30, hole_d=5) {
difference() {
original_base();
// Add parametric mounting holes
for (pos = hole_positions)
translate(pos) cylinder(d=hole_d, h=50, center=true);
}
}
```
This lets the user modify the parametric parts while keeping the complex geometry intact.
---
## Workflow: Refine Mode
When the user wants to modify an existing design:
### Step 1: Read the Existing File
```bash
# Find .scad files in the project
```
Read the .scad source to understand the current design.
### Step 2: Render Current State
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview /path/to/file.scad
```
Read the preview images to see what currently exists.
### Step 3: Apply Changes
Edit the .scad file with the requested modifications. Use the Edit tool for surgical changes.
### Step 4: Re-render and Compare
Generate new previews and visually compare with the previous version. Report what changed.
### Step 5: Repeat or Export
Continue iterating or export when satisfied.
---
## Workflow: Export Mode
Quick export of an existing .scad file:
```bash
# Single format
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl /path/to/file.scad
# Multiple formats
bash ~/.claude/skills/openscad/scripts/openscad-render.sh export /path/to/file.scad
# With parameter overrides
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl /path/to/file.scad -D 'width=50' -D 'height=30'
```
---
## Workflow: Analyze Mode
Review a design for printability:
```bash
bash ~/.claude/skills/openscad/scripts/openscad-render.sh analyze /path/to/file.scad
```
This renders cross-section views and reports:
- Object bounding box dimensions
- Whether the mesh is manifold (watertight)
- Estimated print time indicators (volume, surface area from STL)
- Visual check of overhangs via bottom-up view
---
## Script Reference
All scripts live in `~/.claude/skills/openscad/scripts/`:
| Script | Purpose |
|--------|---------|
| `openscad-render.sh` | Core render/export/preview engine |
| `openscad-project.sh` | Project scaffolding and management |
| `openscad-validate.sh` | Strict validation with categorized error output |
| `openscad-stl-analyze.sh` | STL mesh analysis: bbox, cross-sections, gap detection |
| `openscad-stl-compare.sh` | Mesh comparison: boolean diff, volume delta, accuracy % |
| `openscad-stl-reconstruct.sh` | Automated STL analysis: profiles, primitives, CSG inference |
| `openscad-sdf-optimize.py` | SDF-based parameter optimizer (IoU scoring, no OpenSCAD in loop) |
| `openscad-adaptive-slice.py` | Adaptive multi-axis slicing (coarse→transitions→fine on X,Y,Z) |
| `openscad-auto-reconstruct.py` | Auto-translate feature map → parametric .scad (circle fitting, hull blending) |
### openscad-render.sh Commands
```bash
# Quick single preview (isometric)
openscad-render.sh quick <file.scad>
# Multi-angle preview (4 views)
openscad-render.sh preview <file.scad>
# Export STL only
openscad-render.sh stl <file.scad> [-D 'var=val' ...]
# Export all formats (STL + 3MF + PNG)
openscad-render.sh export <file.scad> [-D 'var=val' ...]
# Analyze printability
openscad-render.sh analyze <file.scad>
# Custom render
openscad-render.sh custom <file.scad> --format png --imgsize 1920,1080 --camera 0,0,0,45,0,30,200
```
### openscad-project.sh Commands
```bash
# Initialize new project
openscad-project.sh init <project-name>
# List projects
openscad-project.sh list
# Clean build artifacts
openscad-project.sh clean <project-name>
```
---
## OpenSCAD Code Guidelines
### File Structure Convention
```openscad
// ============================================
// Project: <name>
// Description: <what this models>
// Author: Claude Code + User
// ============================================
// --- Parameters (user-configurable) ---
width = 50; // [mm] overall width
height = 30; // [mm] overall height
depth = 20; // [mm] overall depth
wall = 2.0; // [mm] wall thickness
tolerance = 0.3; // [mm] printer tolerance
// --- Rendering quality ---
$fn = 64; // curve smoothness (use 128+ for final export)
eps = 0.01; // epsilon for clean boolean operations
// --- Derived dimensions ---
inner_width = width - 2 * wall;
inner_height = height - 2 * wall;
// --- Main model ---
main_assembly();
// --- Modules ---
module main_assembly() {
// ...
}
```
### 3D Printing Best Practices in OpenSCAD
- **Wall thickness**: minimum 1.2mm for FDM (2-3 perimeters with 0.4mm nozzle)
- **Tolerance**: 0.2-0.3mm clearance for fitting parts together (peg-in-hole, snap fits)
- **Overhangs**: keep below 45 degrees from vertical, or add supports in design
- **Chamfer vs fillet**: prefer chamfers on downward-facing surfaces (avoids supports); use fillets on top surfaces
- **Bridging**: max ~10mm unsupported spans
- **First layer**: design flat bottoms for bed adhesion; largest flat surface on build plate
- **Epsilon constant**: always define `eps = 0.01;` and use it in boolean operations to prevent Z-fighting / coplanar faces
- **Manifold geometry**: always ensure boolean operations produce valid solids; operands must overlap
- **Resolution**: use `$fn = 64` for preview, `$fn = 128` for export
- **Design intent**: Define hole positions relative to edges (`hole_x = length - margin`), never as absolute coordinates
- **Tolerance chains**: Define a single `fit_clearance` parameter and derive all clearances from it
- **Assert validation**: Use `assert()` to validate parameters: `assert(wall >= 1.2)`, `assert(boss_d > hole_d + 2*wall)`
- **Profile-first**: Use `offset(r=corner_r)` on 2D `polygon()` instead of `hull()` with 3D cylinders
### Common Patterns
**Rounded box:**
```openscad
module rounded_box(size, radius) {
minkowski() {
cube([size.x - 2*radius, size.y - 2*radius, size.z - radius]);
cylinder(r=radius, h=radius);
}
}
```
**Shell (hollow object):**
```openscad
module shell(outer_size, wall) {
difference() {
cube(outer_size);
translate([wall, wall, wall])
cube([outer_size.x - 2*wall, outer_size.y - 2*wall, outer_size.z]);
}
}
```
**Screw hole with countersink:**
```openscad
module screw_hole(d=3, h=10, cs_d=6, cs_h=2) {
union() {
cylinder(d=d, h=h);
translate([0, 0, h - cs_h])
cylinder(d1=d, d2=cs_d, h=cs_h);
}
}
```
---
## Available Libraries
Popular libraries that can be installed for advanced features:
| Library | Use Case | Install |
|---------|----------|---------|
| **BOSL2** | Swiss-army knife: attachments, shapes, threading, paths | `git clone https://github.com/BelfrySCAD/BOSL2 ~/.local/share/OpenSCAD/libraries/BOSL2` |
| **NopSCADlib** | Vitamins (screws, nuts, electronics, bearings) | `git clone https://github.com/nophead/NopSCADlib ~/.local/share/OpenSCAD/libraries/NopSCADlib` |
| **threads.scad** | Metric threads, hex bolts, nuts | `git clone https://github.com/rcolyer/threads-scad ~/.local/share/OpenSCAD/libraries/threads` |
| **Round-Anything** | Smooth fillets and rounding | `git clone https://github.com/Irev-Dev/Round-Anything ~/.local/share/OpenSCAD/libraries/Round-Anything` |
| **YAPP_Box** | Parametric project enclosures | `git clone https://github.com/mrWheel/YAPP_Box ~/.local/share/OpenSCAD/libraries/YAPP_Box` |
| **Catch'n'Hole** | Nut catches, screw holes | `git clone https://github.com/mmalecki/catchnhole ~/.local/share/OpenSCAD/libraries/catchnhole` |
Check installed libraries:
```bash
ls ~/.local/share/OpenSCAD/libraries/ 2>/dev/null
ls /opt/homebrew/share/openscad/libraries/ 2>/dev/null
```
When user needs a library, install it and add `use <library/file.scad>` to the .scad source.
---
## Error Handling
When OpenSCAD fails:
1. **Parse errors**`ERROR: Parser error: syntax error in file X, line Y`
- Read the .scad file at the reported line
- Fix syntax (common: missing semicolons, unmatched braces/parens, wrong function names)
- Re-render
2. **Geometry errors**`WARNING: Object may not be a valid 2-manifold`
- Check boolean operations aren't creating degenerate geometry
- Ensure shapes overlap properly for difference/intersection
- Add small epsilon offsets (0.01mm) to prevent coplanar faces
3. **Rendering timeouts** — complex models with high `$fn`
- Lower `$fn` for preview (32), raise for export (128)
- Simplify geometry where possible
- Use `render()` to cache intermediate results
4. **Empty output** — model produces no geometry
- Check that modules are actually called
- Verify boolean operations don't subtract everything
- Use `echo()` statements to debug variable values
Always capture stderr when rendering — it contains warnings and errors:
```bash
openscad -o output.stl input.scad 2>&1
```
---
## Camera Presets for Multi-View
| View | Camera Parameters |
|------|-------------------|
| Front | `--camera 0,0,0,90,0,0,<dist>` |
| Back | `--camera 0,0,0,90,0,180,<dist>` |
| Right | `--camera 0,0,0,90,0,90,<dist>` |
| Left | `--camera 0,0,0,90,0,270,<dist>` |
| Top | `--camera 0,0,0,0,0,0,<dist>` |
| Bottom | `--camera 0,0,0,180,0,0,<dist>` |
| Isometric | `--autocenter --viewall` (default) |
| 3/4 view | `--camera 0,0,0,55,0,25,<dist>` |
Use `--autocenter --viewall` to auto-calculate distance, or specify explicit distance for consistent framing across iterations.
@@ -0,0 +1,188 @@
# OpenSCAD Language Reference (v2021.01)
## Syntax & Declaration
```openscad
var = value; // variable assignment
var = cond ? value_if_true : value_if_false; // ternary
var = function (x) x + x; // function literal
module name(params) { ... } // module definition
function name(params) = expr; // function definition
include <file.scad> // include (executes top-level code)
use <file.scad> // import (only modules/functions)
```
## Constants
- `undef` — undefined value
- `PI` — 3.14159...
## Special Variables
| Variable | Purpose |
|----------|---------|
| `$fn` | Fixed number of segments for circles/spheres |
| `$fa` | Minimum angle per segment |
| `$fs` | Minimum segment size |
| `$t` | Animation step (01) |
| `$vpr` | Viewport rotation |
| `$vpt` | Viewport translation |
| `$vpd` | Viewport distance |
| `$vpf` | Viewport field of view |
| `$children` | Number of child modules |
| `$preview` | `true` in preview (F5), `false` in render (F6) |
## Modifier Characters
- `*` — disable (don't render)
- `!` — show only this
- `#` — highlight/debug (transparent red)
- `%` — transparent/background
## 3D Primitives
```openscad
cube(size, center=false) // cube([w,d,h]) or cube(s)
sphere(r=radius) // sphere(d=diameter)
cylinder(h, r=radius, center=false) // cylinder(h, d=diameter)
cylinder(h, r1, r2, center=false) // cone/tapered cylinder
polyhedron(points, faces, convexity) // custom solid
```
## 2D Primitives
```openscad
circle(r=radius) // circle(d=diameter)
square(size, center=false) // square([w,h])
polygon(points, paths) // 2D polygon
text(t, size=10, font, halign, valign, spacing) // text string
```
## 2D → 3D Extrusion
```openscad
linear_extrude(height, center, twist, slices, scale)
circle(10); // extrude 2D to 3D
rotate_extrude(angle=360, convexity)
translate([20,0]) circle(5); // lathe/revolve
surface(file="heightmap.dat", center) // heightmap to 3D
```
## Transformations
```openscad
translate([x, y, z]) // move
rotate([x, y, z]) // rotate (degrees)
rotate(angle, [x, y, z]) // rotate around axis
scale([x, y, z]) // scale
resize([x, y, z], auto) // resize to exact dimensions
mirror([x, y, z]) // mirror across plane
multmatrix(m) // 4x4 matrix transform
color("name", alpha) // color("red"), color("#ff0000")
color([r, g, b, a]) // color by RGBA (0-1)
offset(r=radius) // 2D offset (round)
offset(delta=dist, chamfer=false) // 2D offset (sharp)
```
## Boolean Operations
```openscad
union() { a(); b(); } // combine (A + B)
difference() { a(); b(); } // subtract (A - B)
intersection() { a(); b(); } // overlap only (A ∩ B)
```
## Advanced Operations
```openscad
hull() { a(); b(); } // convex hull
minkowski() { a(); b(); } // Minkowski sum (slow!)
render(convexity) { ... } // force CGAL render (cache)
projection(cut=false) { ... } // 3D → 2D projection
```
## Import / Export
```openscad
import("file.stl") // import STL, OFF, AMF, 3MF
import("file.dxf") // import 2D DXF
import("file.svg") // import 2D SVG
```
## Flow Control
```openscad
if (condition) { ... } else { ... }
for (i = [0:10]) { ... } // range [start:end]
for (i = [0:2:10]) { ... } // range [start:step:end]
for (i = [1, 5, 7]) { ... } // list iteration
for (i = [...], j = [...]) { ... } // nested loops
intersection_for(i = [...]) { ... } // intersection across iterations
let (a = expr) { ... } // local variable scope
```
## List Comprehensions
```openscad
list = [ for (i = [0:10]) i * 2 ]; // generate
list = [ for (i = [0:10]) if (i % 2 == 0) i ]; // filter
list = [ for (i = [0:10]) let (x = i*i) x ]; // with let
list = [ each sublist ]; // flatten
```
## Math Functions
| Function | Description |
|----------|-------------|
| `abs(x)` | Absolute value |
| `sign(x)` | Sign (-1, 0, 1) |
| `sin(x)`, `cos(x)`, `tan(x)` | Trig (degrees) |
| `asin(x)`, `acos(x)`, `atan(x)` | Inverse trig |
| `atan2(y, x)` | Two-argument arctangent |
| `floor(x)`, `ceil(x)`, `round(x)` | Rounding |
| `ln(x)`, `log(x)` | Natural / base-10 log |
| `pow(base, exp)` | Power |
| `sqrt(x)` | Square root |
| `exp(x)` | e^x |
| `min(a,b,...)`, `max(a,b,...)` | Min/max |
| `norm(v)` | Vector magnitude |
| `cross(v1, v2)` | Cross product |
| `rands(min, max, count)` | Random numbers |
| `len(x)` | Length of list/string |
## String Functions
| Function | Description |
|----------|-------------|
| `str(...)` | Concatenate to string |
| `chr(code)` | ASCII code → character |
| `ord(char)` | Character → ASCII code |
| `search(needle, haystack)` | Search in list/string |
## Type Testing
```openscad
is_undef(x) is_bool(x) is_num(x)
is_string(x) is_list(x) is_function(x)
```
## Other Functions
```openscad
echo("msg", var) // debug output
assert(condition, "message") // assertion
concat(list1, list2) // concatenate lists
lookup(key, [[k,v], ...]) // table lookup
children(idx) // access child N of module
parent_module(idx) // access parent module name
version() // OpenSCAD version string
```
## Operators
| Type | Operators |
|------|-----------|
| Arithmetic | `+` `-` `*` `/` `%` `^` |
| Relational | `<` `<=` `==` `!=` `>=` `>` |
| Logical | `&&` `\|\|` `!` |
| Indexing | `list[i]` `list.x` `list.y` `list.z` |
## Tips for AI-Generated Code
- **Always set `$fn`** for predictable circle resolution
- **Use `center=true`** when building symmetric objects with difference()
- **Add 0.01mm epsilon** to boolean cuts to avoid coplanar Z-fighting:
```openscad
difference() {
cube([10, 10, 10]);
translate([-0.01, 2, 2])
cube([10.02, 6, 6]); // slightly larger to ensure clean cut
}
```
- **Modules for reuse**: parameterize everything, compose with modules
- **Comments**: explain the "why", dimensions in mm
- **Print orientation**: think about which face goes on the build plate
- **Manifold check**: all boolean operands must overlap; no coincident faces
@@ -0,0 +1,241 @@
# STL-to-SCAD Reconstruction Best Practices
## Profile-Based Reconstruction (Preferred Method)
For extruded parts (brackets, plates, channels), extract the 2D profile directly from the mesh and convert it to OpenSCAD `polygon()` + `linear_extrude()`. This is more accurate than fitting known primitives.
### Step 1: Detect Extrusion Axis
Use trimesh to find the axis with the most stable cross-section:
- Slice the mesh along X, Y, and Z at 64 levels each
- For each axis, measure stability: std(area), std(perimeter), std(hole_count)
- The axis with lowest stability score is the extrusion axis
### Step 2: Extract the Dominant Profile
- Find the slice with the largest area (the representative cross-section)
- Simplify the polygon (remove micro-vertices from tessellation)
- Handle holes: inner contours become `paths` in OpenSCAD `polygon()`
### Step 3: Convert to OpenSCAD
```openscad
// Auto-generated from mesh profile extraction
linear_extrude(height = <extrusion_length>)
polygon(
points = [<extracted_points>],
paths = [<outer_boundary>, <hole_1>, <hole_2>]
);
```
### Step 4: Add Secondary Features
Features that vary along the extrusion axis (holes, counterbores) are detected by comparing slice profiles at different heights. Where a slice has more holes than the dominant profile, subtract cylinders.
## The Sculptor Approach (MANDATORY)
Always model as a sculptor: start from a solid block, then subtract material.
```openscad
// CORRECT: sculptor approach
difference() {
solid_body(); // 1. Full solid block first
channel(); // 2. Subtract channels/slots
taper_cuts(); // 3. Subtract wedges/tapers
all_holes(); // 4. Subtract ALL holes LAST
}
// WRONG: additive approach (holes get covered)
union() {
difference() {
base();
some_holes(); // These get covered by wings!
}
left_wing(); // Covers the holes above
right_wing();
}
```
**Why**: In OpenSCAD, `difference()` only applies to its immediate children. If you add material (wings) after cutting holes, the new material covers the holes. The sculptor approach ensures ALL cuts happen after ALL additions.
## Analysis Pipeline
### Step 1: Automated SVG Profile Analysis
```bash
bash openscad-stl-reconstruct.sh model.stl output_dir/
```
This gives you the 30,000-foot view: dimensions, volume, symmetry, primitive hints.
### Step 2: Detailed 1mm Z-Slicing
For complex models, slice at every 1mm (not just 5 levels):
```bash
# Generate slices at every Z level
for z in $(seq 0.5 1 <max_z>); do
echo "projection(cut=true) translate([0,0,-$z]) import(\"model.stl\");" > /tmp/s.scad
openscad -o "slices/z${z}.svg" /tmp/s.scad
done
```
Parse each SVG to extract:
- Number of contours (1 body = solid level, 2+ = channels/features)
- Contour sizes: BODY (>500mm²), FEATURE (50-500mm²), HOLE (<50mm²)
- Hole positions from contour centroids
- How width changes with Z (reveals taper rate)
### Step 3: Identify Structure from Profile Data
```
Z=0-5: 1 body (full width) → Solid base
Z=5-10: 2 bodies + 8 holes → Channel appeared, base holes
Z=10-20: 2 bodies narrowing → Taper zone (measure rate)
Z=20-33: 2 bodies (constant width) → Top section
Z=25-27: bodies interrupted by holes → Upper counterbore holes
```
### Step 4: Write OpenSCAD (Sculptor Method)
1. Create the FULL solid body (base + wings as one block)
2. Subtract the channel
3. Subtract taper wedges (use `hull()` for linear tapers)
4. Subtract ALL holes in separate modules, called LAST
### Step 5: Compare and Iterate
```bash
bash openscad-stl-compare.sh original.stl reconstruction.stl output/
```
- Check accuracy % (target: >95%)
- Read diff images to identify WHAT is wrong
- Fix ONE thing per iteration
- Re-compare
## Hole Patterns
### Counterbore (flat cylindrical pocket)
Most common in 3D-printed brackets. A shallow cylinder + through hole:
```openscad
module counterbore(hole_d, cb_d, cb_depth, total_h) {
cylinder(d=hole_d, h=total_h); // Through hole
cylinder(d=cb_d, h=cb_depth); // Flat pocket
}
```
### Countersink (conical taper)
Less common in 3D prints, used for flat-head screws:
```openscad
module countersink(hole_d, cs_d, cs_depth, total_h) {
cylinder(d=hole_d, h=total_h);
cylinder(d1=cs_d, d2=hole_d, h=cs_depth);
}
```
**Always check reference images** to determine which type is used. Don't assume.
### Hole Orientation Patterns
Complex brackets often have holes on multiple faces:
- **Bottom face**: Vertical (Z-axis) holes
- **Angled faces**: Holes perpendicular to the face (rotate by taper angle)
- **Side walls**: Horizontal (Y-axis) holes
- **Each set may have different spacing and count**
Extract hole positions from SVG centroids at the appropriate Z level.
## Taper/Wedge Subtraction
For a 45° taper that narrows a wing from full width to reduced width:
```openscad
// Wedge: 0 thickness at bottom, full thickness at top
hull() {
translate([0, outer_edge, z_start])
cube([length, eps, z_end - z_start]); // Thin edge
translate([0, outer_edge, z_end - eps])
cube([length, taper_amount, eps]); // Full face
}
// Then remove the rectangular block above the taper
translate([0, outer_edge, z_end])
cube([length, taper_amount, z_top - z_end]);
```
## Common Pitfalls
1. **CSG order**: ALWAYS cut holes after building the full solid
2. **Feature hallucination**: Don't add features you can't verify in the reference
3. **Conical vs cylindrical**: Check if countersinks are tapered or flat
4. **Symmetric assumptions**: Don't assume symmetry — verify from SVG data
5. **Volume match ≠ shape match**: A model can have correct volume but wrong shape
6. **SVG Y-axis is inverted**: OpenSCAD projection flips Y coordinates
## Dependencies
```bash
pip3 install trimesh numpy scipy rtree shapely
brew install admesh
```
## Accuracy Targets
| Level | Accuracy | When to stop |
|-------|----------|-------------|
| Draft | >85% | Initial structure verification |
| Good | >95% | Functional part, ready for test print |
| Excellent | >98% | Production quality |
The 95% threshold is achievable for most mechanical parts in 4-6 iterations using the SVG profiling approach. The remaining 5% is typically tessellation differences and minor feature details.
## When to Use Polygon Profiles vs Parametric Primitives
### Use extracted polygon profiles when:
- The shape has mostly flat/angular surfaces (brackets, plates, channels)
- The curves are gentle and well-approximated by ~200 polygon points
- Speed is more important than last-5% accuracy
- Expected accuracy: 75-96% depending on curve complexity
### Use parametric primitives (circle, cylinder) when:
- The shape has prominent cylindrical features (puzzle tabs, screw holes, bosses)
- The shape can be decomposed into known primitives (square + circles)
- You need >95% accuracy on curved surfaces
- The model has symmetry that can be exploited
### Hybrid approach (best for complex models):
1. Extract the polygon profile for the overall outline
2. Identify which curves are circles/arcs from the profile data
3. Replace polygon approximations with parametric `circle(r)` where possible
4. Use `offset(r)` for rounded corners instead of polygon vertices
### Key lesson: polygon simplification tolerance matters enormously
- 0.3mm tolerance → ~50 points → curves become flat → 52% accuracy
- 0.05mm tolerance → ~150 points → curves approximate → 75% accuracy
- 0.02mm tolerance → ~230 points → curves close but not perfect → 75% accuracy
- Diminishing returns beyond ~200 points for polygon-based approaches
- For >90% on cylindrical surfaces, parametric primitives are required
## Feature Hallucination Prevention
NEVER add features based on visual interpretation of renders alone.
- The toothpaste squeezer "cylinder" was actually a rounded slot floor
- The puzzle tray "pyramid" didn't exist at all — it was a shadow in the render
- ALWAYS verify features with SVG slice data (contour count, area, holes)
- If a feature doesn't show as a separate contour in the SVG slices, IT DOESN'T EXIST
## Adaptive Multi-Axis Slicing
The `openscad-adaptive-slice.py` script scans STL on all 3 axes:
1. Coarse pass (5mm) → detects where cross-section changes
2. Fine pass (0.5mm) only at transition zones
3. Classifies each zone: solid, shell_or_channel, multi_body, complex
### How to interpret the feature map
**Zone types and their OpenSCAD equivalents:**
- `solid` (1 contour, 0 holes) → `linear_extrude()` of the profile
- `shell_or_channel` (2 contours) → walls around a cavity, use `offset(delta=-wall)`
- `solid_with_holes` (1 contour, N holes) → solid body with `difference()` holes
- `multi_body` (N contours) → multiple separate parts or holes
- `complex` → may need `hull()` between profiles or `polyhedron()`
**Detecting specific features from zone evolution:**
- **Chamfer/taper**: contour width decreases progressively across slices
- **Fillet**: smooth curvature in contour centroids between zones
- **Counterbore**: nested circular contours with constant radius for several slices
- **Through-hole**: hole contour appears in ALL slices along that axis
- **Blind hole**: hole contour appears then disappears
**Generating OpenSCAD from zones:**
- Stable zones (many identical slices) → `linear_extrude(height=zone_length)` of representative profile
- Transition zones (gradual change) → `hull()` between two profiles at zone boundaries
- Feature zones (holes, counterbores) → `difference()` with fitted cylinders
### Future: Feature Map → OpenSCAD Translator
The next evolution is automatic translation: parse the JSON feature map, emit one `module zone_N()` per zone, assemble with `difference()/union()` in the correct order. This would close the loop from STL → analysis → parametric .scad automatically.
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
openscad-adaptive-slice.py — Adaptive multi-axis SVG slicing
Slices an STL on all 3 axes with adaptive resolution:
1. Coarse pass (every 5mm) → detect transitions
2. Fine pass (every 0.5mm) only at transitions
3. Outputs a complete feature map of the model
Usage:
python3 openscad-adaptive-slice.py <file.stl> <output_dir> [--coarse 5] [--fine 0.5]
"""
import numpy as np
import trimesh
from trimesh import intersections
from shapely.geometry import MultiLineString
from shapely.ops import polygonize, unary_union
import json
import sys
import os
import argparse
def segments_to_polygons(segments, snap=1e-4):
"""Convert mesh slice segments to Shapely polygons."""
lines = []
for seg in np.asarray(segments):
a = tuple(np.round(seg[0] / snap) * snap)
b = tuple(np.round(seg[1] / snap) * snap)
if a != b:
lines.append([a, b])
if not lines:
return []
return [p for p in polygonize(MultiLineString(lines)) if p.area > snap * snap]
def slice_at_height(mesh, axis, height):
"""Slice mesh at a given height along an axis. Returns polygon descriptors."""
try:
normal = np.zeros(3)
normal[axis] = 1.0
origin = np.zeros(3)
origin[axis] = height
segments, _, _ = intersections.mesh_multiplane(
mesh, plane_origin=origin, plane_normal=normal, heights=[0.0])
if not segments or len(segments[0]) == 0:
return None
polys = segments_to_polygons(segments[0])
if not polys:
return None
combined = unary_union(polys)
n_holes = sum(len(p.interiors) for p in polys) if hasattr(polys[0], 'interiors') else 0
return {
'height': round(float(height), 3),
'area': round(float(combined.area), 2),
'perimeter': round(float(combined.length), 2),
'n_contours': len(polys),
'n_holes': n_holes,
'bounds': [round(float(x), 2) for x in combined.bounds],
'width': round(float(combined.bounds[2] - combined.bounds[0]), 2),
'height_2d': round(float(combined.bounds[3] - combined.bounds[1]), 2),
}
except Exception:
return None
def detect_transitions(slices, threshold=0.1):
"""Find heights where the cross-section changes significantly."""
transitions = []
for i in range(1, len(slices)):
prev = slices[i - 1]
curr = slices[i]
if prev is None or curr is None:
if prev is not None or curr is not None:
transitions.append(i)
continue
# Detect changes in area, contour count, or holes
area_change = abs(curr['area'] - prev['area']) / max(prev['area'], 1)
contour_change = curr['n_contours'] != prev['n_contours']
hole_change = curr['n_holes'] != prev['n_holes']
width_change = abs(curr['width'] - prev['width']) / max(prev['width'], 1)
if area_change > threshold or contour_change or hole_change or width_change > threshold:
transitions.append(i)
return transitions
def adaptive_slice_axis(mesh, axis, coarse_step=5.0, fine_step=0.5, fine_range=3.0, threshold=0.1):
"""Adaptive slicing along one axis.
1. Coarse pass at coarse_step intervals
2. Detect transitions
3. Fine pass around each transition
"""
axis_names = ['X', 'Y', 'Z']
lo, hi = mesh.bounds[0][axis], mesh.bounds[1][axis]
extent = hi - lo
# Pass 1: Coarse
coarse_heights = np.arange(lo + coarse_step / 2, hi, coarse_step)
coarse_slices = []
for h in coarse_heights:
s = slice_at_height(mesh, axis, h)
coarse_slices.append(s)
# Detect transitions
trans_indices = detect_transitions(coarse_slices, threshold)
transition_heights = [float(coarse_heights[i]) for i in trans_indices]
print(f" {axis_names[axis]}: {len(coarse_heights)} coarse slices, "
f"{len(transition_heights)} transitions detected")
if transition_heights:
zones = ", ".join(f"{h:.1f}" for h in transition_heights)
print(f" Transition zones: {zones}")
# Pass 2: Fine slicing around transitions
fine_slices = []
fine_heights_set = set()
for th in transition_heights:
fine_lo = max(lo, th - fine_range)
fine_hi = min(hi, th + fine_range)
for h in np.arange(fine_lo, fine_hi, fine_step):
h_round = round(float(h), 3)
if h_round not in fine_heights_set:
fine_heights_set.add(h_round)
s = slice_at_height(mesh, axis, h_round)
if s:
fine_slices.append(s)
# Combine coarse + fine, sorted by height
all_slices = []
all_heights = set()
for s, h in zip(coarse_slices, coarse_heights):
h_round = round(float(h), 3)
if s and h_round not in all_heights:
all_heights.add(h_round)
all_slices.append(s)
for s in fine_slices:
if s['height'] not in all_heights:
all_heights.add(s['height'])
all_slices.append(s)
all_slices.sort(key=lambda s: s['height'])
print(f" Total slices: {len(all_slices)} ({len(coarse_slices)} coarse + {len(fine_slices)} fine)")
return {
'axis': axis_names[axis],
'extent': round(float(extent), 3),
'n_coarse': len(coarse_slices),
'n_fine': len(fine_slices),
'n_total': len(all_slices),
'transitions': [round(h, 3) for h in transition_heights],
'slices': all_slices,
}
def build_feature_map(axis_results):
"""Build a unified feature map from all 3 axes."""
features = []
for result in axis_results:
axis = result['axis']
slices = result['slices']
transitions = result['transitions']
if not slices:
continue
# Identify feature zones (between transitions)
zones = []
sorted_trans = sorted(transitions)
# Zone before first transition
pre_slices = [s for s in slices if s['height'] < (sorted_trans[0] if sorted_trans else 1e6)]
if pre_slices:
avg_area = np.mean([s['area'] for s in pre_slices])
avg_contours = round(np.mean([s['n_contours'] for s in pre_slices]))
avg_holes = round(np.mean([s['n_holes'] for s in pre_slices]))
zones.append({
'axis': axis,
'from': round(float(slices[0]['height']), 2),
'to': round(float(sorted_trans[0]), 2) if sorted_trans else round(float(slices[-1]['height']), 2),
'avg_area': round(float(avg_area), 1),
'contours': int(avg_contours),
'holes': int(avg_holes),
'type': classify_zone(avg_area, avg_contours, avg_holes),
})
# Zones between transitions
for i in range(len(sorted_trans)):
t_lo = sorted_trans[i]
t_hi = sorted_trans[i + 1] if i + 1 < len(sorted_trans) else slices[-1]['height']
zone_slices = [s for s in slices if t_lo <= s['height'] <= t_hi]
if zone_slices:
avg_area = np.mean([s['area'] for s in zone_slices])
avg_contours = round(np.mean([s['n_contours'] for s in zone_slices]))
avg_holes = round(np.mean([s['n_holes'] for s in zone_slices]))
zones.append({
'axis': axis,
'from': round(float(t_lo), 2),
'to': round(float(t_hi), 2),
'avg_area': round(float(avg_area), 1),
'contours': int(avg_contours),
'holes': int(avg_holes),
'type': classify_zone(avg_area, avg_contours, avg_holes),
})
features.extend(zones)
return features
def classify_zone(area, contours, holes):
"""Classify a zone based on its cross-section properties."""
if contours == 1 and holes == 0:
return 'solid'
elif contours == 1 and holes > 0:
return 'solid_with_holes'
elif contours == 2 and holes == 0:
return 'shell_or_channel'
elif contours > 2:
return 'multi_body'
else:
return 'complex'
def main():
parser = argparse.ArgumentParser(description='Adaptive multi-axis STL slicing')
parser.add_argument('stl_file', help='Input STL file')
parser.add_argument('output_dir', help='Output directory')
parser.add_argument('--coarse', type=float, default=5.0, help='Coarse step (mm)')
parser.add_argument('--fine', type=float, default=0.5, help='Fine step (mm)')
parser.add_argument('--range', type=float, default=3.0, help='Fine range around transitions (mm)')
parser.add_argument('--threshold', type=float, default=0.1, help='Change threshold (0-1)')
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
print(f"Loading: {args.stl_file}")
mesh = trimesh.load_mesh(args.stl_file, force='mesh')
trimesh.repair.fix_normals(mesh)
dims = mesh.extents
print(f"Mesh: {len(mesh.vertices)} verts, {len(mesh.faces)} faces, vol={mesh.volume:.1f}mm³")
print(f"Dimensions: {dims[0]:.1f} x {dims[1]:.1f} x {dims[2]:.1f} mm")
print(f"Coarse: {args.coarse}mm, Fine: {args.fine}mm, Range: ±{args.range}mm")
print()
# Slice on all 3 axes
print("=== Adaptive Multi-Axis Slicing ===")
axis_results = []
for axis in range(3):
result = adaptive_slice_axis(
mesh, axis,
coarse_step=args.coarse,
fine_step=args.fine,
fine_range=args.range,
threshold=args.threshold,
)
axis_results.append(result)
# Build feature map
print("\n=== Feature Map ===")
features = build_feature_map(axis_results)
for f in features:
print(f" {f['axis']} [{f['from']:.1f}{f['to']:.1f}]: "
f"{f['type']} (area={f['avg_area']:.0f}, contours={f['contours']}, holes={f['holes']})")
# Summary
print(f"\n=== Summary ===")
total_slices = sum(r['n_total'] for r in axis_results)
total_coarse = sum(r['n_coarse'] for r in axis_results)
total_fine = sum(r['n_fine'] for r in axis_results)
total_trans = sum(len(r['transitions']) for r in axis_results)
print(f"Total slices: {total_slices} ({total_coarse} coarse + {total_fine} fine)")
print(f"Transitions detected: {total_trans}")
print(f"Feature zones: {len(features)}")
# Save results
output = {
'file': args.stl_file,
'dimensions': dims.tolist(),
'volume': float(mesh.volume),
'settings': {
'coarse_step': args.coarse,
'fine_step': args.fine,
'fine_range': args.range,
'threshold': args.threshold,
},
'axes': [{
'axis': r['axis'],
'extent': r['extent'],
'n_slices': r['n_total'],
'transitions': r['transitions'],
} for r in axis_results],
'features': features,
'total_slices': total_slices,
'total_transitions': total_trans,
}
output_path = os.path.join(args.output_dir, 'adaptive-slicing.json')
with open(output_path, 'w') as f:
json.dump(output, f, indent=2)
print(f"\nResults saved to: {output_path}")
if __name__ == '__main__':
main()
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
openscad-profile-extract.py — Extract 2D profiles from STL meshes
Detects the extrusion axis, extracts the dominant cross-section profile,
and generates OpenSCAD polygon() + linear_extrude() code.
Usage:
python3 openscad-profile-extract.py <file.stl> [--output <file.scad>] [--simplify <tolerance>]
"""
import numpy as np
import trimesh
from trimesh import intersections
import json
import sys
import argparse
def segments_to_polygons(segments, snap=1e-4):
"""Convert line segments from mesh slicing to polygons using Shapely."""
from shapely.geometry import MultiLineString
from shapely.ops import polygonize
lines = []
for seg in np.asarray(segments):
a = tuple(np.round(seg[0] / snap) * snap)
b = tuple(np.round(seg[1] / snap) * snap)
if a != b:
lines.append([a, b])
if not lines:
return []
return [p for p in polygonize(MultiLineString(lines)) if p.area > snap * snap]
def detect_extrusion_axis(mesh, n_slices=64):
"""Find the best extrusion axis by measuring cross-section stability.
Returns: (stability_score, axis_index, transform, heights, polygons_per_slice)
The axis with the LOWEST stability score is the extrusion direction.
"""
# Use OBB for canonical alignment
try:
T = np.linalg.inv(mesh.bounding_box_oriented.primitive.transform)
except Exception:
T = np.eye(4)
m = mesh.copy()
m.apply_transform(T)
best = None
axis_names = ['X', 'Y', 'Z']
for axis in range(3):
lo, hi = m.bounds[0][axis], m.bounds[1][axis]
margin = (hi - lo) * 0.02
heights = np.linspace(lo + margin, hi - margin, n_slices)
try:
segments, _, _ = intersections.mesh_multiplane(
m,
plane_origin=np.zeros(3),
plane_normal=np.eye(3)[axis],
heights=heights,
)
except Exception:
continue
desc = []
polys_by_slice = []
for segs in segments:
polys = segments_to_polygons(segs)
polys_by_slice.append(polys)
if not polys:
desc.append([0, 0, 0, 0])
continue
from shapely.ops import unary_union
u = unary_union(polys)
n_holes = sum(len(p.interiors) for p in polys)
desc.append([u.area, u.length, n_holes, len(polys)])
if len([d for d in desc if d[0] > 0]) < n_slices // 3:
continue
arr = np.asarray(desc, dtype=float)
nonzero = arr[arr[:, 0] > 0]
if len(nonzero) < 3:
continue
# Stability = low variance in area/perimeter/holes across slices
means = nonzero.mean(axis=0)
means[means < 1e-6] = 1e-6
stability = np.mean(np.std(nonzero / means, axis=0))
extent = hi - lo
item = (stability, axis, T, heights, polys_by_slice, extent)
if best is None or item[0] < best[0]:
best = item
print(f" Axis {axis_names[axis]}: stability={stability:.4f}, "
f"extent={extent:.1f}mm, non-empty={len(nonzero)}/{n_slices}")
return best
def extract_dominant_profile(polys_by_slice, simplify_tol=0.1):
"""Find the slice with the largest area and return its simplified polygon."""
from shapely.ops import unary_union
best_area = 0
best_poly = None
best_idx = 0
for i, polys in enumerate(polys_by_slice):
if not polys:
continue
u = unary_union(polys)
if u.area > best_area:
best_area = u.area
best_poly = u
best_idx = i
if best_poly is None:
return None, 0
# Simplify to remove tessellation noise
simplified = best_poly.simplify(simplify_tol, preserve_topology=True)
return simplified, best_idx
def polygon_to_scad(poly, var_name="profile"):
"""Convert a Shapely polygon to OpenSCAD polygon() code."""
points = []
paths = []
# Outer boundary
exterior_coords = np.asarray(poly.exterior.coords[:-1], dtype=float)
start = 0
for coord in exterior_coords:
points.append(list(np.round(coord, 3)))
paths.append(list(range(start, start + len(exterior_coords))))
# Holes (inner boundaries)
for interior in poly.interiors:
interior_coords = np.asarray(interior.coords[:-1], dtype=float)
start = len(points)
for coord in interior_coords:
points.append(list(np.round(coord, 3)))
# Reverse winding for holes
paths.append(list(reversed(range(start, start + len(interior_coords)))))
# Format as OpenSCAD
pts_str = ",\n ".join(f"[{p[0]}, {p[1]}]" for p in points)
paths_str = ", ".join(f"{p}" for p in paths)
return f"""module {var_name}() {{
polygon(
points = [
{pts_str}
],
paths = [{paths_str}]
);
}}"""
def analyze_hole_variations(polys_by_slice, heights, dominant_idx):
"""Detect features that vary along the extrusion axis (holes appearing/disappearing)."""
from shapely.ops import unary_union
dominant_polys = polys_by_slice[dominant_idx]
if not dominant_polys:
return []
dom_union = unary_union(dominant_polys)
dom_holes = sum(len(p.interiors) for p in dominant_polys)
variations = []
for i, (polys, h) in enumerate(zip(polys_by_slice, heights)):
if not polys:
continue
u = unary_union(polys)
n_holes = sum(len(p.interiors) for p in polys)
n_parts = len(polys)
if n_holes != dom_holes or n_parts != len(dominant_polys):
variations.append({
'height': round(float(h), 3),
'slice_idx': i,
'holes': n_holes,
'parts': n_parts,
'area': round(float(u.area), 2),
'dom_holes': dom_holes,
'dom_parts': len(dominant_polys),
})
return variations
def generate_scad(profile_poly, extrusion_length, axis, variations=None):
"""Generate complete OpenSCAD file from extracted profile."""
profile_code = polygon_to_scad(profile_poly, "extracted_profile")
# Determine extrusion direction
axis_names = ['X', 'Y', 'Z']
scad = f"""// ============================================
// Auto-generated from STL profile extraction
// Extrusion axis: {axis_names[axis]}, length: {extrusion_length:.3f}mm
// Profile points: {len(profile_poly.exterior.coords) - 1}
// Profile holes: {len(profile_poly.interiors)}
// ============================================
// --- Parameters ---
extrusion_length = {extrusion_length:.3f};
// --- Quality ---
$fn = 64;
eps = 0.01;
// --- Extracted 2D Profile ---
{profile_code}
// --- Main Body ---
// Sculptor approach: extrude profile, then subtract features
difference() {{
// Primary body from extracted profile
linear_extrude(height = extrusion_length)
extracted_profile();
// TODO: Add subtractive features (holes, counterbores, chamfers)
// detected from cross-section variations
"""
if variations:
scad += f"\n // Feature variations detected at {len(variations)} heights:\n"
for v in variations[:10]: # Limit output
scad += f" // Z={v['height']}: {v['holes']} holes, {v['parts']} parts (dominant: {v['dom_holes']} holes, {v['dom_parts']} parts)\n"
scad += "}\n"
return scad
def main():
parser = argparse.ArgumentParser(description='Extract 2D profile from STL mesh')
parser.add_argument('stl_file', help='Input STL file')
parser.add_argument('--output', '-o', help='Output .scad file')
parser.add_argument('--simplify', type=float, default=0.1,
help='Profile simplification tolerance (mm)')
parser.add_argument('--slices', type=int, default=64,
help='Number of slices per axis')
parser.add_argument('--json', help='Output analysis as JSON')
args = parser.parse_args()
print(f"Loading: {args.stl_file}")
mesh = trimesh.load_mesh(args.stl_file, force='mesh')
trimesh.repair.fix_normals(mesh)
print(f"Mesh: {len(mesh.vertices)} verts, {len(mesh.faces)} faces, "
f"vol={mesh.volume:.1f}mm³")
print(f"Bounds: {mesh.bounds[0].round(3)} to {mesh.bounds[1].round(3)}")
dims = mesh.extents
print(f"Dimensions: {dims[0]:.3f} x {dims[1]:.3f} x {dims[2]:.3f} mm")
print(f"\nDetecting extrusion axis ({args.slices} slices per axis)...")
result = detect_extrusion_axis(mesh, n_slices=args.slices)
if result is None:
print("ERROR: Could not detect extrusion axis")
sys.exit(1)
stability, axis, T, heights, polys_by_slice, extent = result
axis_names = ['X', 'Y', 'Z']
print(f"\nBest extrusion axis: {axis_names[axis]} "
f"(stability={stability:.4f}, extent={extent:.1f}mm)")
print("Extracting dominant profile...")
profile, dom_idx = extract_dominant_profile(polys_by_slice, args.simplify)
if profile is None:
print("ERROR: Could not extract profile")
sys.exit(1)
n_pts = len(profile.exterior.coords) - 1
n_holes = len(profile.interiors)
print(f"Profile: {n_pts} points, {n_holes} holes, area={profile.area:.1f}mm²")
print("Analyzing feature variations along extrusion axis...")
variations = analyze_hole_variations(polys_by_slice, heights, dom_idx)
print(f"Found {len(variations)} slices with different features")
# Generate OpenSCAD
scad_code = generate_scad(profile, extent, axis, variations)
if args.output:
with open(args.output, 'w') as f:
f.write(scad_code)
print(f"\nOpenSCAD saved to: {args.output}")
else:
print(f"\n--- Generated OpenSCAD ---")
print(scad_code)
if args.json:
analysis = {
'extrusion_axis': axis_names[axis],
'extrusion_length': round(float(extent), 3),
'stability_score': round(float(stability), 4),
'profile_points': n_pts,
'profile_holes': n_holes,
'profile_area': round(float(profile.area), 2),
'feature_variations': variations[:20],
}
with open(args.json, 'w') as f:
json.dump(analysis, f, indent=2)
print(f"Analysis saved to: {args.json}")
if __name__ == '__main__':
main()
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# openscad-project.sh — Project scaffolding and management for OpenSCAD skill
set -euo pipefail
PROJECTS_ROOT="$HOME/openscad-projects"
usage() {
cat <<'EOF'
Usage: openscad-project.sh <command> [args]
Commands:
init <project-name> Create a new project directory
list List all projects
clean <project-name> Remove build artifacts (keep source)
info <project-name> Show project info and file listing
EOF
exit 1
}
cmd_init() {
local name="$1"
# Sanitize project name to prevent directory traversal
if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then
echo "ERROR: Project name must contain only letters, numbers, hyphens, and underscores." >&2
exit 1
fi
local project_dir="$PROJECTS_ROOT/$name"
if [[ -d "$project_dir" ]]; then
echo "Project already exists: $project_dir"
echo "Use existing project or choose a different name."
exit 1
fi
mkdir -p "$project_dir"/{src,output,previews}
# Create a starter main.scad (name injected directly, no sed needed)
cat > "$project_dir/src/main.scad" <<SCAD
// ============================================
// Project: $name
// Description: TODO
// Author: Claude Code + User
// ============================================
// --- Parameters (user-configurable) ---
width = 50; // [mm] overall width
height = 30; // [mm] overall height
depth = 20; // [mm] overall depth
wall = 2.0; // [mm] wall thickness
tolerance = 0.3; // [mm] printer tolerance
// --- Rendering quality ---
\$fn = 64; // curve smoothness (use 128+ for final export)
// --- Derived dimensions ---
inner_width = width - 2 * wall;
inner_height = height - 2 * wall;
inner_depth = depth - 2 * wall;
// --- Debug output ---
echo(str("BBOX: ", width, " x ", depth, " x ", height, " mm"));
echo(str("Wall: ", wall, " mm | Tolerance: ", tolerance, " mm"));
// --- Main Assembly ---
// TODO: Replace with your design
example();
module example() {
difference() {
cube([width, depth, height], center = true);
translate([0, 0, wall])
cube([inner_width, inner_depth, inner_height + wall], center = true);
}
}
SCAD
# Create a project README
cat > "$project_dir/README.md" <<EOF
# $name
OpenSCAD project created $(date +%Y-%m-%d).
## Structure
- \`src/\` — OpenSCAD source files (.scad)
- \`output/\` — Exported STL, 3MF files
- \`previews/\` — Rendered PNG previews
## Quick Commands
\`\`\`bash
# Preview
bash ~/.claude/skills/openscad/scripts/openscad-render.sh preview src/main.scad
# Export STL
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl src/main.scad
# With custom parameters
bash ~/.claude/skills/openscad/scripts/openscad-render.sh stl src/main.scad -D 'width=60' -D 'height=40'
\`\`\`
EOF
echo "Project created: $project_dir"
echo ""
echo "Structure:"
echo " $project_dir/"
echo " ├── src/"
echo " │ └── main.scad (starter template)"
echo " ├── output/"
echo " ├── previews/"
echo " └── README.md"
}
cmd_list() {
if [[ ! -d "$PROJECTS_ROOT" ]]; then
echo "No projects directory found at $PROJECTS_ROOT"
exit 0
fi
echo "OpenSCAD Projects in $PROJECTS_ROOT:"
echo ""
for dir in "$PROJECTS_ROOT"/*/; do
[[ ! -d "$dir" ]] && continue
local name
name="$(basename "$dir")"
local scad_count
scad_count=$(find "$dir/src" -name "*.scad" 2>/dev/null | wc -l | tr -d ' ')
local stl_count
stl_count=$(find "$dir/output" -name "*.stl" 2>/dev/null | wc -l | tr -d ' ')
local preview_count
preview_count=$(find "$dir/previews" -name "*.png" 2>/dev/null | wc -l | tr -d ' ')
echo " $name${scad_count} .scad, ${stl_count} .stl, ${preview_count} previews"
done
}
cmd_clean() {
local name="$1"
local project_dir="$PROJECTS_ROOT/$name"
if [[ ! -d "$project_dir" ]]; then
echo "Project not found: $name"
exit 1
fi
rm -rf "${project_dir:?}/output/"*
rm -rf "${project_dir:?}/previews/"*
echo "Cleaned build artifacts for: $name"
}
cmd_info() {
local name="$1"
local project_dir="$PROJECTS_ROOT/$name"
if [[ ! -d "$project_dir" ]]; then
echo "Project not found: $name"
exit 1
fi
echo "Project: $name"
echo "Path: $project_dir"
echo ""
echo "Source files:"
find "$project_dir/src" -name "*.scad" -exec echo " {}" \; 2>/dev/null
echo ""
echo "Exports:"
find "$project_dir/output" -type f -exec echo " {}" \; 2>/dev/null || echo " (none)"
echo ""
echo "Previews:"
find "$project_dir/previews" -name "*.png" -exec echo " {}" \; 2>/dev/null || echo " (none)"
}
# --- Main ---
[[ $# -lt 1 ]] && usage
command="$1"
shift
case "$command" in
init) [[ $# -lt 1 ]] && usage; cmd_init "$1" ;;
list) cmd_list ;;
clean) [[ $# -lt 1 ]] && usage; cmd_clean "$1" ;;
info) [[ $# -lt 1 ]] && usage; cmd_info "$1" ;;
*) echo "Unknown command: $command"; usage ;;
esac
+397
View File
@@ -0,0 +1,397 @@
#!/usr/bin/env bash
# openscad-render.sh — Core render/export/preview engine for OpenSCAD skill
set -euo pipefail
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
IMGSIZE_PREVIEW="${OPENSCAD_IMGSIZE:-800,600}"
IMGSIZE_HIRES="1600,1200"
COLORSCHEME="${OPENSCAD_COLORSCHEME:-DeepOcean}"
usage() {
cat <<'EOF'
Usage: openscad-render.sh <command> <file.scad> [options]
Commands:
quick <file> Single isometric preview PNG
preview <file> Multi-angle preview (4 views)
stl <file> [-D ...] Export STL
3mf <file> [-D ...] Export 3MF
export <file> [-D ...] Export STL + 3MF + final PNG
analyze <file> Render analysis views (cross-sections, bottom)
custom <file> [options] Custom render with full control
Options (for custom):
--format <ext> Output format (png, stl, 3mf, amf, svg, dxf, pdf)
--imgsize <W,H> Image dimensions
--camera <params> Camera: translate_x,y,z,rot_x,y,z,dist
--colorscheme <name> Color scheme
-D 'var=val' Parameter override (repeatable)
EOF
exit 1
}
# Resolve output directory relative to .scad file
get_project_dir() {
local scad_file="$1"
local scad_dir
scad_dir="$(dirname "$(realpath "$scad_file")")"
# If inside a project structure (has src/ parent), go up
if [[ "$(basename "$scad_dir")" == "src" ]]; then
echo "$(dirname "$scad_dir")"
else
echo "$scad_dir"
fi
}
ensure_dirs() {
local project_dir="$1"
mkdir -p "$project_dir/previews" "$project_dir/output"
}
# Render with error handling
do_render() {
local scad_file="$1"
shift
local output=""
local exit_code=0
output=$("$OPENSCAD" "$@" "$scad_file" 2>&1) || exit_code=$?
if [[ $exit_code -ne 0 ]]; then
echo "ERROR: OpenSCAD render failed (exit code $exit_code)" >&2
echo "$output" >&2
# Extract specific error info
if echo "$output" | grep -q "Parser error"; then
echo "" >&2
echo "SYNTAX ERROR detected. Check the .scad file at the reported line." >&2
fi
return $exit_code
fi
# Check for warnings
if echo "$output" | grep -qi "warning"; then
echo "WARNINGS:" >&2
echo "$output" | grep -i "warning" >&2
fi
# Print render stats
if echo "$output" | grep -q "rendering time"; then
echo "$output" | grep "rendering time"
fi
if echo "$output" | grep -q "Facets:"; then
echo "$output" | grep -E "(Facets|Vertices):"
fi
echo "$output" | grep -v "^$" | tail -5
return 0
}
cmd_quick() {
local scad_file="$1"
shift
local project_dir
project_dir="$(get_project_dir "$scad_file")"
ensure_dirs "$project_dir"
local out="$project_dir/previews/quick-preview.png"
echo "Rendering quick preview..."
do_render "$scad_file" \
--autocenter --viewall \
--imgsize="$IMGSIZE_PREVIEW" \
--colorscheme="$COLORSCHEME" \
--render \
-o "$out" \
"$@"
echo "Preview saved: $out"
}
cmd_preview() {
local scad_file="$1"
shift
local project_dir
project_dir="$(get_project_dir "$scad_file")"
ensure_dirs "$project_dir"
local timestamp
timestamp="$(date +%Y%m%d-%H%M%S)"
local preview_dir="$project_dir/previews"
# Define camera angles: name, camera_params or flags
declare -A views
views=(
["1-isometric"]="--autocenter --viewall"
["2-front"]="--autocenter --viewall --projection o --camera 0,0,0,90,0,0,0"
["3-right"]="--autocenter --viewall --projection o --camera 0,0,0,90,0,90,0"
["4-top"]="--autocenter --viewall --projection o --camera 0,0,0,0,0,0,0"
)
local failed=0
echo "Rendering 4-view preview..."
for view_name in $(echo "${!views[@]}" | tr ' ' '\n' | sort); do
local out="$preview_dir/${view_name}-${timestamp}.png"
local camera_args="${views[$view_name]}"
echo " Rendering $view_name..."
# shellcheck disable=SC2086
if ! do_render "$scad_file" \
$camera_args \
--imgsize="$IMGSIZE_PREVIEW" \
--colorscheme="$COLORSCHEME" \
--render \
-o "$out" \
"$@"; then
echo " FAILED: $view_name" >&2
rm -f "$out"
failed=1
elif [[ ! -s "$out" ]]; then
echo " ERROR: Empty render output: $out" >&2
rm -f "$out"
failed=1
fi
done
echo ""
echo "Preview images saved in: $preview_dir/"
ls -la "$preview_dir"/*-"${timestamp}".png 2>/dev/null
}
cmd_stl() {
local scad_file="$1"
shift
local project_dir
project_dir="$(get_project_dir "$scad_file")"
ensure_dirs "$project_dir"
local basename
basename="$(basename "$scad_file" .scad)"
local out="$project_dir/output/${basename}.stl"
echo "Exporting STL..."
do_render "$scad_file" \
--export-format binstl \
-o "$out" \
"$@"
local size
size=$(wc -c < "$out" | tr -d ' ')
echo "STL saved: $out ($size bytes)"
}
cmd_3mf() {
local scad_file="$1"
shift
local project_dir
project_dir="$(get_project_dir "$scad_file")"
ensure_dirs "$project_dir"
local basename
basename="$(basename "$scad_file" .scad)"
local out="$project_dir/output/${basename}.3mf"
echo "Exporting 3MF..."
do_render "$scad_file" \
-o "$out" \
"$@"
local size
size=$(wc -c < "$out" | tr -d ' ')
echo "3MF saved: $out ($size bytes)"
}
cmd_export() {
local scad_file="$1"
shift
local project_dir
project_dir="$(get_project_dir "$scad_file")"
ensure_dirs "$project_dir"
local basename
basename="$(basename "$scad_file" .scad)"
echo "=== Full Export ==="
# STL (binary)
echo ""
echo "--- STL ---"
do_render "$scad_file" \
--export-format binstl \
-o "$project_dir/output/${basename}.stl" \
"$@"
echo "STL: $project_dir/output/${basename}.stl ($(wc -c < "$project_dir/output/${basename}.stl" | tr -d ' ') bytes)"
# 3MF
echo ""
echo "--- 3MF ---"
do_render "$scad_file" \
-o "$project_dir/output/${basename}.3mf" \
"$@" || echo "3MF export failed (may not be supported in this version)"
# Final high-res preview
echo ""
echo "--- Final Preview ---"
do_render "$scad_file" \
--autocenter --viewall \
--imgsize="$IMGSIZE_HIRES" \
--colorscheme="$COLORSCHEME" \
--render \
-o "$project_dir/previews/final-preview.png" \
"$@"
echo ""
echo "=== Export Complete ==="
echo "Output directory: $project_dir/output/"
ls -la "$project_dir/output/"
}
cmd_analyze() {
local scad_file="$1"
shift
local project_dir
project_dir="$(get_project_dir "$scad_file")"
ensure_dirs "$project_dir"
local timestamp
timestamp="$(date +%Y%m%d-%H%M%S)"
echo "=== Design Analysis ==="
# Render STL to get geometry stats
echo ""
echo "--- Geometry Stats ---"
local stl_output
stl_output="$project_dir/output/analysis-temp.stl"
do_render "$scad_file" \
--export-format binstl \
-o "$stl_output" \
"$@" 2>&1
if [[ -f "$stl_output" ]]; then
local stl_size
stl_size=$(wc -c < "$stl_output" | tr -d ' ')
echo "STL file size: $stl_size bytes"
fi
# Capture echo output for debug info
echo ""
echo "--- Echo Output ---"
do_render "$scad_file" \
-o "$project_dir/output/analysis.echo" \
"$@" 2>&1 || echo "Echo capture failed (non-critical)" >&2
if [[ -f "$project_dir/output/analysis.echo" ]] && [[ -s "$project_dir/output/analysis.echo" ]]; then
cat "$project_dir/output/analysis.echo"
fi
# Bottom view (to check overhangs / first layer)
local bottom_png="$project_dir/previews/analysis-bottom-${timestamp}.png"
echo ""
echo "--- Bottom View (check first layer / overhangs) ---"
if ! do_render "$scad_file" \
--autocenter --viewall \
--camera 0,0,0,180,0,0,0 \
--imgsize="$IMGSIZE_PREVIEW" \
--colorscheme="$COLORSCHEME" \
--render \
-o "$bottom_png" \
"$@"; then
echo "WARNING: Bottom view render failed (may need OpenGL context)" >&2
rm -f "$bottom_png"
fi
# Isometric wireframe
local wireframe_png="$project_dir/previews/analysis-wireframe-${timestamp}.png"
echo ""
echo "--- Wireframe View ---"
if ! do_render "$scad_file" \
--autocenter --viewall \
--view edges \
--imgsize="$IMGSIZE_PREVIEW" \
--colorscheme="$COLORSCHEME" \
--render \
-o "$wireframe_png" \
"$@"; then
echo "WARNING: Wireframe render failed (may need OpenGL context)" >&2
rm -f "$wireframe_png"
fi
echo ""
echo "=== Analysis Complete ==="
echo "Review images in: $project_dir/previews/"
ls -la "$project_dir/previews"/analysis-*-"${timestamp}".png 2>/dev/null
# Cleanup temp
rm -f "$stl_output"
}
cmd_custom() {
local scad_file="$1"
shift
local format="png"
local imgsize="$IMGSIZE_PREVIEW"
local camera_args=""
local colorscheme="$COLORSCHEME"
local extra_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--format) format="$2"; shift 2 ;;
--imgsize) imgsize="$2"; shift 2 ;;
--camera) camera_args="--camera $2"; shift 2 ;;
--colorscheme) colorscheme="$2"; shift 2 ;;
-D) extra_args+=(-D "$2"); shift 2 ;;
*) extra_args+=("$1"); shift ;;
esac
done
local project_dir
project_dir="$(get_project_dir "$scad_file")"
ensure_dirs "$project_dir"
local basename
basename="$(basename "$scad_file" .scad)"
local out="$project_dir/output/${basename}-custom.${format}"
local render_args=(
--imgsize="$imgsize"
--colorscheme="$colorscheme"
--render
-o "$out"
)
if [[ -n "$camera_args" ]]; then
# shellcheck disable=SC2086
render_args+=($camera_args)
else
render_args+=(--autocenter --viewall)
fi
do_render "$scad_file" "${render_args[@]}" "${extra_args[@]}"
echo "Output: $out"
}
# --- Main ---
[[ $# -lt 2 ]] && usage
command="$1"
scad_file="$2"
shift 2
# Validate input file
if [[ ! -f "$scad_file" ]]; then
echo "ERROR: File not found: $scad_file" >&2
exit 1
fi
case "$command" in
quick) cmd_quick "$scad_file" "$@" ;;
preview) cmd_preview "$scad_file" "$@" ;;
stl) cmd_stl "$scad_file" "$@" ;;
3mf) cmd_3mf "$scad_file" "$@" ;;
export) cmd_export "$scad_file" "$@" ;;
analyze) cmd_analyze "$scad_file" "$@" ;;
custom) cmd_custom "$scad_file" "$@" ;;
*) echo "Unknown command: $command"; usage ;;
esac
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
openscad-sdf-optimize.py — SDF-based parametric model optimizer
Uses Signed Distance Fields and IoU scoring to find optimal parameters
for an OpenSCAD reconstruction WITHOUT invoking OpenSCAD in the loop.
Usage:
python3 openscad-sdf-optimize.py <original.stl> <model_type> [options]
Model types:
stadium-slot Stadium body with cylindrical slot cut
box-holes Rectangular body with through holes
custom Custom SDF defined in a Python module
Options:
--samples N Number of sample points (default: 30000)
--output FILE Output JSON with optimized parameters
--verbose Print optimization progress
"""
import numpy as np
import trimesh
import json
import sys
import os
from scipy.optimize import minimize, least_squares
# ========== SDF Primitives ==========
def sdf_box(p, size):
"""Signed distance to an axis-aligned box centered at origin."""
half = np.array(size) / 2
q = np.abs(p) - half
return np.linalg.norm(np.maximum(q, 0), axis=1) + np.minimum(np.max(q, axis=1), 0)
def sdf_cylinder_x(p, radius, half_length, center=None):
"""Signed distance to a cylinder along X axis."""
if center is not None:
p = p - np.array(center)
d_yz = np.sqrt(p[:, 1]**2 + p[:, 2]**2) - radius
d_x = np.abs(p[:, 0]) - half_length
return np.minimum(np.maximum(d_yz, d_x), 0) + np.linalg.norm(
np.maximum(np.column_stack([d_yz, d_x]), 0), axis=1)
def sdf_capsule_x(p, radius, half_span, center=None):
"""Signed distance to a capsule (two spheres hulled) along X axis."""
if center is not None:
p = p - np.array(center)
# Clamp X to [-half_span, half_span]
px_clamped = np.clip(p[:, 0], -half_span, half_span)
q = p.copy()
q[:, 0] -= px_clamped
return np.linalg.norm(q, axis=1) - radius
def sdf_stadium_extrude(p, total_len, width, height):
"""SDF for a stadium shape extruded along Z."""
r = width / 2
half_span = total_len / 2 - r
# 2D stadium distance in XY
px_clamped = np.clip(p[:, 0], -half_span, half_span)
dx = p[:, 0] - px_clamped
d_xy = np.sqrt(dx**2 + p[:, 1]**2) - r
# Z bounds
d_z = np.abs(p[:, 2] - height / 2) - height / 2
return np.maximum(d_xy, d_z)
# ========== CSG Operations ==========
def sdf_union(d1, d2):
return np.minimum(d1, d2)
def sdf_difference(d1, d2):
return np.maximum(d1, -d2)
def sdf_intersection(d1, d2):
return np.maximum(d1, d2)
# ========== Model Definitions ==========
def model_stadium_slot(p, params):
"""Stadium body with cylindrical slot carved from top.
params: [length, width, height, slot_total, slot_width, cyl_d, cyl_center_z]
"""
L, W, H, sL, sW, cD, cZ = params
body = sdf_stadium_extrude(p, L, W, H)
# Cylinder slot along X
half_span = sL / 2 - sW / 2
cyl = sdf_capsule_x(p, cD / 2, half_span, center=[0, 0, cZ])
return sdf_difference(body, cyl)
def model_box_with_holes(p, params):
"""Rectangular box with cylindrical through-holes.
params: [sx, sy, sz, hole_d, hole_z, n_holes, hole_spacing, hole_x_start]
"""
sx, sy, sz, hole_d, hole_z, n_holes, spacing, x_start = params
n_holes = int(round(n_holes))
body = sdf_box(p, [sx, sy, sz])
# Offset body center
result = body
for i in range(n_holes):
hx = x_start + i * spacing
hole = sdf_cylinder_x(p, hole_d / 2, sy, center=[hx, 0, hole_z - sz/2])
# Rotate hole to Y axis
p_rot = p.copy()
p_rot[:, 0] = p[:, 1]
p_rot[:, 1] = p[:, 0]
hole = sdf_cylinder_x(p_rot, hole_d / 2, sx, center=[0, hx, hole_z - sz/2])
result = sdf_difference(result, hole)
return result
MODEL_REGISTRY = {
'stadium-slot': {
'sdf': model_stadium_slot,
'param_names': ['length', 'width', 'height', 'slot_total', 'slot_width', 'cyl_d', 'cyl_center_z'],
},
'box-holes': {
'sdf': model_box_with_holes,
'param_names': ['sx', 'sy', 'sz', 'hole_d', 'hole_z', 'n_holes', 'spacing', 'x_start'],
},
}
# ========== Scoring ==========
def compute_iou(target_inside, candidate_inside):
"""Intersection over Union from boolean occupancy arrays."""
intersection = np.count_nonzero(target_inside & candidate_inside)
union = np.count_nonzero(target_inside | candidate_inside)
if union == 0:
return 0.0
return intersection / union
def compute_score(mesh, sdf_func, params, sample_points, target_inside):
"""Score a candidate model against the target mesh."""
candidate_sdf = sdf_func(sample_points, params)
candidate_inside = candidate_sdf <= 0
iou = compute_iou(target_inside, candidate_inside)
return iou
# ========== Initialization from Mesh Analysis ==========
def init_params_from_mesh(mesh, model_type):
"""Extract initial parameters from mesh analysis."""
bounds = mesh.bounds
dims = bounds[1] - bounds[0]
center = mesh.centroid
if model_type == 'stadium-slot':
# Use section analysis to find slot
sections = []
z_min, z_max = bounds[0][2], bounds[1][2]
for pct in [0.01, 0.25, 0.5, 0.75, 0.99]:
z = z_min + (z_max - z_min) * pct
try:
path = mesh.section(plane_origin=[0, 0, z], plane_normal=[0, 0, 1])
if path:
path2d, _ = path.to_2D()
polys = path2d.polygons_full
areas = [p.area for p in polys]
sections.append({'z': z, 'n_contours': len(polys), 'areas': areas,
'total_area': sum(areas)})
except:
pass
# Estimate slot from difference in areas across Z
if sections:
body_area = max(s['total_area'] for s in sections)
# The slot width can be estimated from how the contour changes
# For now, use bounding box as starting point
slot_width_est = dims[1] * 0.33 # ~1/3 of body width
slot_len_est = dims[0] * 0.89 # ~89% of body length
return [
dims[0], # length
dims[1], # width
dims[2], # height
dims[0] * 0.89, # slot_total (slightly shorter than body)
dims[1] * 0.33, # slot_width (1/3 of body width)
dims[2] * 0.8, # cyl_d (80% of height)
dims[2] * 0.6, # cyl_center_z (60% up)
]
return list(dims) + [0] * 4
# ========== Main Optimization ==========
def optimize(stl_path, model_type, n_samples=30000, verbose=False):
"""Main optimization pipeline."""
print(f"Loading mesh: {stl_path}")
mesh = trimesh.load_mesh(stl_path, force='mesh')
trimesh.repair.fix_normals(mesh)
print(f"Mesh: {len(mesh.vertices)} verts, {len(mesh.faces)} faces, vol={mesh.volume:.1f}mm³")
# Sample points in and around the bounding box
margin = 2.0
pts = np.random.uniform(
mesh.bounds[0] - margin,
mesh.bounds[1] + margin,
(n_samples, 3)
)
# Compute target occupancy
print("Computing target occupancy...")
target_sdf = trimesh.proximity.signed_distance(mesh, pts)
target_inside = target_sdf >= 0 # trimesh convention: positive = inside
print(f"Target: {np.sum(target_inside)}/{n_samples} points inside ({np.mean(target_inside)*100:.1f}%)")
# Get model SDF function
model_info = MODEL_REGISTRY[model_type]
sdf_func = model_info['sdf']
param_names = model_info['param_names']
# Initialize parameters from mesh analysis
print("Initializing parameters from mesh analysis...")
x0 = np.array(init_params_from_mesh(mesh, model_type))
print(f"Initial params: {dict(zip(param_names, x0.round(3)))}")
# Score initial
iou0 = compute_score(mesh, sdf_func, x0, pts, target_inside)
print(f"Initial IoU: {iou0:.4f} ({iou0*100:.1f}%)")
# Optimize with Powell method
print("\nOptimizing with Powell method...")
iter_count = [0]
def objective(x):
iter_count[0] += 1
candidate_sdf = sdf_func(pts, x)
candidate_inside = candidate_sdf <= 0
iou = compute_iou(target_inside, candidate_inside)
if verbose and iter_count[0] % 10 == 0:
print(f" iter {iter_count[0]}: IoU={iou:.4f} params={x.round(3)}")
return 1.0 - iou # minimize = maximize IoU
# Set bounds (all positive, reasonable ranges)
dims = mesh.bounds[1] - mesh.bounds[0]
bounds_lo = x0 * 0.5
bounds_hi = x0 * 1.5
# Ensure positive
bounds_lo = np.maximum(bounds_lo, 0.1)
result = minimize(
objective, x0,
method='Powell',
options={'maxiter': 500, 'ftol': 1e-6, 'disp': verbose}
)
final_params = result.x
final_iou = 1.0 - result.fun
print(f"\nOptimization complete ({iter_count[0]} iterations)")
print(f"Final IoU: {final_iou:.4f} ({final_iou*100:.1f}%)")
print(f"Final params:")
for name, val in zip(param_names, final_params):
print(f" {name} = {val:.4f}")
# Generate OpenSCAD code
scad_code = generate_scad(model_type, param_names, final_params)
print(f"\n--- Generated OpenSCAD ---\n{scad_code}")
return {
'model_type': model_type,
'params': dict(zip(param_names, [round(float(v), 4) for v in final_params])),
'iou': round(float(final_iou), 4),
'iterations': iter_count[0],
'volume_target': round(float(mesh.volume), 2),
'scad_code': scad_code,
}
def generate_scad(model_type, param_names, params):
"""Generate OpenSCAD code from optimized parameters."""
p = dict(zip(param_names, params))
if model_type == 'stadium-slot':
return f"""// Auto-generated by SDF optimizer
// IoU-optimized parameters
length = {p['length']:.3f};
width = {p['width']:.3f};
height = {p['height']:.3f};
slot_total = {p['slot_total']:.3f};
slot_width = {p['slot_width']:.3f};
cyl_d = {p['cyl_d']:.3f};
cyl_center_z = {p['cyl_center_z']:.3f};
$fn = 64;
difference() {{
linear_extrude(height = height)
stadium(length, width);
translate([0, 0, cyl_center_z])
hull() {{
translate([-(slot_total/2 - slot_width/2), 0, 0]) sphere(d=cyl_d, $fn=64);
translate([(slot_total/2 - slot_width/2), 0, 0]) sphere(d=cyl_d, $fn=64);
}}
}}
module stadium(l, w) {{
r = w / 2;
hull() {{
translate([-(l/2 - r), 0]) circle(r=r);
translate([(l/2 - r), 0]) circle(r=r);
}}
}}
"""
return f"// No code generator for model_type={model_type}"
# ========== CLI ==========
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='SDF-based OpenSCAD parameter optimizer')
parser.add_argument('stl_file', help='Input STL file')
parser.add_argument('model_type', choices=list(MODEL_REGISTRY.keys()),
help='Model type to fit')
parser.add_argument('--samples', type=int, default=30000, help='Sample points')
parser.add_argument('--output', help='Output JSON file')
parser.add_argument('--verbose', action='store_true', help='Show progress')
args = parser.parse_args()
result = optimize(args.stl_file, args.model_type,
n_samples=args.samples, verbose=args.verbose)
if args.output:
with open(args.output, 'w') as f:
json.dump(result, f, indent=2)
print(f"\nResults saved to {args.output}")
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# openscad-stl-analyze.sh — Extract geometry data from binary STL files
# Outputs bounding box, vertex distributions, and internal structure hints
set -euo pipefail
usage() {
cat <<'EOF'
Usage: openscad-stl-analyze.sh <file.stl> [--cross-section <axis> <value>] [--gaps <axis>]
Commands:
<file.stl> Full bounding box and triangle count
--cross-section <axis> <value> Show vertex distribution at a cross-section
axis: x, y, or z; value: coordinate
--gaps <axis> Find gaps in vertex distribution along axis
(useful for finding internal features)
Examples:
openscad-stl-analyze.sh model.stl
openscad-stl-analyze.sh model.stl --cross-section z 0
openscad-stl-analyze.sh model.stl --gaps y
EOF
exit 1
}
[[ $# -lt 1 ]] && usage
STL_FILE="$1"
shift
if [[ ! -f "$STL_FILE" ]]; then
echo "ERROR: File not found: $STL_FILE" >&2
exit 1
fi
# Default: full analysis
if [[ $# -eq 0 ]]; then
python3 -c "
import struct, math, sys
from collections import defaultdict
path = '$STL_FILE'
with open(path, 'rb') as f:
header = f.read(80)
n = struct.unpack('<I', f.read(4))[0]
verts = set()
for _ in range(n):
f.read(12) # normal
for _ in range(3):
v = struct.unpack('<3f', f.read(12))
verts.add((round(v[0],4), round(v[1],4), round(v[2],4)))
f.read(2)
xs = [v[0] for v in verts]
ys = [v[1] for v in verts]
zs = [v[2] for v in verts]
print('=== STL Analysis ===')
print(f'File: {path}')
print(f'Triangles: {n}')
print(f'Unique vertices: {len(verts)}')
print()
print('=== Bounding Box ===')
print(f'X: {min(xs):.4f} to {max(xs):.4f} = {max(xs)-min(xs):.4f} mm')
print(f'Y: {min(ys):.4f} to {max(ys):.4f} = {max(ys)-min(ys):.4f} mm')
print(f'Z: {min(zs):.4f} to {max(zs):.4f} = {max(zs)-min(zs):.4f} mm')
print(f'Center: ({(min(xs)+max(xs))/2:.4f}, {(min(ys)+max(ys))/2:.4f}, {(min(zs)+max(zs))/2:.4f})')
print()
print('=== Symmetry Check ===')
cx, cy, cz = (min(xs)+max(xs))/2, (min(ys)+max(ys))/2, (min(zs)+max(zs))/2
print(f'X symmetric: {abs(cx) < 0.01} (center offset: {cx:.4f})')
print(f'Y symmetric: {abs(cy) < 0.01} (center offset: {cy:.4f})')
print(f'Z base at 0: {abs(min(zs)) < 0.01}')
print()
print('=== Distinct Values Per Axis ===')
print(f'Distinct X values: {len(set(round(x,3) for x in xs))}')
print(f'Distinct Y values: {len(set(round(y,3) for y in ys))}')
print(f'Distinct Z values: {len(set(round(z,3) for z in zs))}')
print()
# Gap detection on each axis
for axis_name, vals in [('X', xs), ('Y', ys), ('Z', zs)]:
sorted_unique = sorted(set(round(v,3) for v in vals))
gaps = []
for i in range(len(sorted_unique)-1):
gap = sorted_unique[i+1] - sorted_unique[i]
if gap > (max(vals)-min(vals)) * 0.05: # gaps > 5% of range
gaps.append((sorted_unique[i], sorted_unique[i+1], gap))
if gaps:
print(f'{axis_name}-axis gaps (>5% of range):')
for v1, v2, g in gaps:
print(f' {v1:.3f} to {v2:.3f} (gap={g:.3f} mm) — possible internal feature boundary')
print()
"
exit 0
fi
# Cross-section analysis
if [[ "$1" == "--cross-section" ]]; then
[[ $# -lt 3 ]] && usage
AXIS="$2"
VALUE="$3"
python3 -c "
import struct
path = '$STL_FILE'
axis = '$AXIS'
value = float($VALUE)
with open(path, 'rb') as f:
f.read(80)
n = struct.unpack('<I', f.read(4))[0]
verts = set()
for _ in range(n):
f.read(12)
for _ in range(3):
v = struct.unpack('<3f', f.read(12))
verts.add((round(v[0],4), round(v[1],4), round(v[2],4)))
f.read(2)
axis_idx = {'x':0, 'y':1, 'z':2}[axis]
other_axes = [i for i in range(3) if i != axis_idx]
axis_names = 'XYZ'
# Find vertices near the cross-section plane
tolerance = 0.02
cross_verts = [(v[other_axes[0]], v[other_axes[1]])
for v in verts if abs(v[axis_idx] - value) < tolerance]
if not cross_verts:
# Widen tolerance
tolerance = 0.2
cross_verts = [(v[other_axes[0]], v[other_axes[1]])
for v in verts if abs(v[axis_idx] - value) < tolerance]
print(f'=== Cross-section at {axis_names[axis_idx]}={value:.3f} (tol={tolerance}) ===')
print(f'Found {len(cross_verts)} vertices')
if cross_verts:
a_vals = sorted(set(round(v[0],4) for v in cross_verts))
b_vals = sorted(set(round(v[1],4) for v in cross_verts))
print(f'{axis_names[other_axes[0]]} range: {min(a_vals):.4f} to {max(a_vals):.4f}')
print(f'{axis_names[other_axes[1]]} range: {min(b_vals):.4f} to {max(b_vals):.4f}')
print()
# Show |values| distribution for the narrower axis (usually reveals internal features)
for oa, name in [(0, axis_names[other_axes[0]]), (1, axis_names[other_axes[1]])]:
abs_vals = sorted(set(round(abs(v[oa]),4) for v in cross_verts))
if len(abs_vals) < 80:
print(f'|{name}| distribution:')
for av in abs_vals:
bar = '#' * int(av * 8)
print(f' |{name}|={av:7.4f} {bar}')
print()
# Find gaps
for i in range(len(abs_vals)-1):
gap = abs_vals[i+1] - abs_vals[i]
if gap > 0.5:
print(f' GAP: |{name}|={abs_vals[i]:.4f} to |{name}|={abs_vals[i+1]:.4f} (width={gap:.4f})')
print(f' -> Possible feature boundary at |{name}|={abs_vals[i]:.4f}')
"
exit 0
fi
# Gap analysis
if [[ "$1" == "--gaps" ]]; then
[[ $# -lt 2 ]] && usage
AXIS="$2"
python3 -c "
import struct
from collections import defaultdict
path = '$STL_FILE'
axis = '$AXIS'
axis_idx = {'x':0, 'y':1, 'z':2}[axis]
axis_names = 'XYZ'
with open(path, 'rb') as f:
f.read(80)
n = struct.unpack('<I', f.read(4))[0]
verts = set()
for _ in range(n):
f.read(12)
for _ in range(3):
v = struct.unpack('<3f', f.read(12))
verts.add((round(v[0],4), round(v[1],4), round(v[2],4)))
f.read(2)
# Get all distinct values for other axes, grouped by the target axis levels
other_axes = [i for i in range(3) if i != axis_idx]
# Find distinct levels of the target axis
levels = sorted(set(round(v[axis_idx], 3) for v in verts))
print(f'=== {axis_names[axis_idx]}-axis gap analysis ===')
print(f'Distinct {axis_names[axis_idx]} levels: {len(levels)}')
print()
for level in levels:
level_verts = [v for v in verts if abs(v[axis_idx] - level) < 0.005]
for oa in other_axes:
abs_vals = sorted(set(round(abs(v[oa]), 4) for v in level_verts))
gaps = []
for i in range(len(abs_vals)-1):
g = abs_vals[i+1] - abs_vals[i]
if g > 0.5:
gaps.append((abs_vals[i], abs_vals[i+1], g))
if gaps:
print(f'{axis_names[axis_idx]}={level:6.3f}: |{axis_names[oa]}| gaps:')
for v1, v2, g in gaps:
print(f' {v1:.4f} to {v2:.4f} (gap={g:.4f}) — feature boundary')
"
exit 0
fi
usage
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env bash
# openscad-stl-compare.sh — Compare two STL files geometrically
# Uses OpenSCAD boolean difference + Python mesh distance analysis
set -euo pipefail
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
IMGSIZE="${OPENSCAD_IMGSIZE:-800,600}"
usage() {
cat <<'EOF'
Usage: openscad-stl-compare.sh <original.stl> <reconstruction.stl> [output_dir]
Compares two STL files and reports:
1. Bounding box comparison
2. Volume/triangle count comparison
3. Boolean difference renders (what's in A but not B, and vice versa)
4. Point-to-mesh distance statistics (Hausdorff, RMS, mean)
Output:
<output_dir>/diff-A-minus-B.png — Geometry in original but NOT in reconstruction
<output_dir>/diff-B-minus-A.png — Geometry in reconstruction but NOT in original
<output_dir>/overlay.png — Both overlaid (original=transparent, recon=red)
<output_dir>/comparison-report.txt — Full numerical report
EOF
exit 1
}
[[ $# -lt 2 ]] && usage
STL_A="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
STL_B="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
OUTDIR="${3:-/tmp/stl-compare-$(date +%s)}"
[[ -f "$STL_A" ]] || { echo "ERROR: File not found: $STL_A" >&2; exit 1; }
[[ -f "$STL_B" ]] || { echo "ERROR: File not found: $STL_B" >&2; exit 1; }
mkdir -p "$OUTDIR"
TMPDIR=$(mktemp -d /tmp/stl-compare-XXXXXX)
trap 'rm -rf "$TMPDIR"' EXIT
echo "=== STL Mesh Comparison ==="
echo "A (original): $STL_A"
echo "B (reconstruction): $STL_B"
echo "Output: $OUTDIR"
echo ""
# --- Step 1: Bounding box + triangle comparison ---
echo "--- Dimensional Comparison ---"
python3 -c "
import struct, sys
def parse_stl(path):
with open(path, 'rb') as f:
header = f.read(80)
n = struct.unpack('<I', f.read(4))[0]
mn = [float('inf')]*3
mx = [float('-inf')]*3
for _ in range(n):
f.read(12)
for _ in range(3):
v = struct.unpack('<3f', f.read(12))
for i in range(3):
mn[i] = min(mn[i], v[i])
mx[i] = max(mx[i], v[i])
f.read(2)
return n, mn, mx
n_a, mn_a, mx_a = parse_stl('$STL_A')
n_b, mn_b, mx_b = parse_stl('$STL_B')
print(f' Original Reconstruction Delta')
print(f'Triangles: {n_a:>8} {n_b:>8}')
labels = ['X', 'Y', 'Z']
total_delta = 0
for i in range(3):
da = mx_a[i] - mn_a[i]
db = mx_b[i] - mn_b[i]
delta = abs(db - da)
total_delta += delta
print(f'{labels[i]} dimension: {da:>12.4f} mm {db:>12.4f} mm {delta:>8.4f} mm')
# Also check position offset
ca = (mn_a[i] + mx_a[i]) / 2
cb = (mn_b[i] + mx_b[i]) / 2
if abs(ca - cb) > 0.01:
print(f' Center offset: {abs(ca-cb):.4f} mm')
print(f'Total dim delta: {total_delta:>8.4f} mm')
"
# --- Step 2: Boolean difference renders ---
echo ""
echo "--- Boolean Difference Renders ---"
# A - B: what's in original but not reconstruction
cat > "$TMPDIR/diff-a-minus-b.scad" << SCAD
difference() {
import("$STL_A", convexity=10);
import("$STL_B", convexity=10);
}
SCAD
# B - A: what's in reconstruction but not original
cat > "$TMPDIR/diff-b-minus-a.scad" << SCAD
difference() {
import("$STL_B", convexity=10);
import("$STL_A", convexity=10);
}
SCAD
# Overlay
cat > "$TMPDIR/overlay.scad" << SCAD
%import("$STL_A", convexity=10);
color("red", 0.5) import("$STL_B", convexity=10);
SCAD
# Render A-B
echo " Rendering A-B (in original, missing from reconstruction)..."
if "$OPENSCAD" --autocenter --viewall --imgsize="$IMGSIZE" --colorscheme=DeepOcean \
--render -o "$OUTDIR/diff-A-minus-B.png" "$TMPDIR/diff-a-minus-b.scad" 2>"$TMPDIR/ab.log"; then
# Check if the difference produced any geometry
if grep -q "Top level object is a 3D object" "$TMPDIR/ab.log"; then
facets=$(grep "Facets:" "$TMPDIR/ab.log" | tail -1 | grep -oE '[0-9]+')
echo " Difference has $facets facets"
fi
# Also export the difference as STL for volume analysis
"$OPENSCAD" --render --export-format binstl \
-o "$TMPDIR/diff-ab.stl" "$TMPDIR/diff-a-minus-b.scad" 2>/dev/null || true
else
echo " Render failed" >&2
rm -f "$OUTDIR/diff-A-minus-B.png"
fi
# Render B-A
echo " Rendering B-A (in reconstruction, not in original)..."
if "$OPENSCAD" --autocenter --viewall --imgsize="$IMGSIZE" --colorscheme=DeepOcean \
--render -o "$OUTDIR/diff-B-minus-A.png" "$TMPDIR/diff-b-minus-a.scad" 2>"$TMPDIR/ba.log"; then
if grep -q "Top level object is a 3D object" "$TMPDIR/ba.log"; then
facets=$(grep "Facets:" "$TMPDIR/ba.log" | tail -1 | grep -oE '[0-9]+')
echo " Difference has $facets facets"
fi
"$OPENSCAD" --render --export-format binstl \
-o "$TMPDIR/diff-ba.stl" "$TMPDIR/diff-b-minus-a.scad" 2>/dev/null || true
else
echo " Render failed" >&2
rm -f "$OUTDIR/diff-B-minus-A.png"
fi
# Render overlay
echo " Rendering overlay..."
"$OPENSCAD" --autocenter --viewall --imgsize="$IMGSIZE" --colorscheme=Cornfield \
-o "$OUTDIR/overlay.png" "$TMPDIR/overlay.scad" 2>/dev/null || true
# --- Step 3: Volume analysis of differences ---
echo ""
echo "--- Difference Volume Analysis ---"
python3 -c "
import struct, os
def stl_volume(path):
'''Calculate volume of a binary STL using signed tetrahedron method'''
if not os.path.exists(path):
return 0, 0
with open(path, 'rb') as f:
f.read(80)
n = struct.unpack('<I', f.read(4))[0]
if n == 0:
return 0, 0
volume = 0.0
for _ in range(n):
f.read(12) # normal
v1 = struct.unpack('<3f', f.read(12))
v2 = struct.unpack('<3f', f.read(12))
v3 = struct.unpack('<3f', f.read(12))
f.read(2)
# Signed volume of tetrahedron with origin
volume += (
v1[0] * (v2[1]*v3[2] - v2[2]*v3[1]) +
v1[1] * (v2[2]*v3[0] - v2[0]*v3[2]) +
v1[2] * (v2[0]*v3[1] - v2[1]*v3[0])
) / 6.0
return n, abs(volume)
n_a, vol_a = stl_volume('$STL_A')
n_b, vol_b = stl_volume('$STL_B')
n_ab, vol_ab = stl_volume('$TMPDIR/diff-ab.stl')
n_ba, vol_ba = stl_volume('$TMPDIR/diff-ba.stl')
print(f'Original volume: {vol_a:>12.2f} mm³')
print(f'Reconstruction volume: {vol_b:>12.2f} mm³')
print(f'Volume delta: {abs(vol_b - vol_a):>12.2f} mm³ ({abs(vol_b-vol_a)/max(vol_a,1)*100:.2f}%)')
print()
print(f'A-B (missing from recon): {vol_ab:>10.2f} mm³ ({n_ab} triangles)')
print(f'B-A (extra in recon): {vol_ba:>10.2f} mm³ ({n_ba} triangles)')
print()
total_error = vol_ab + vol_ba
if vol_a > 0:
accuracy = (1 - total_error / vol_a) * 100
print(f'Total error volume: {total_error:>10.2f} mm³')
print(f'Geometric accuracy: {accuracy:>10.2f}%')
print()
if accuracy > 99:
print('Result: EXCELLENT match')
elif accuracy > 95:
print('Result: Good match (minor differences)')
elif accuracy > 90:
print('Result: Fair match (visible differences)')
else:
print('Result: Needs significant refinement')
" 2>&1 || echo "Volume analysis failed (non-manifold geometry?)"
# --- Step 4: Summary ---
echo ""
echo "--- Output Files ---"
ls -la "$OUTDIR"/*.png 2>/dev/null || echo "No PNG files generated"
echo ""
echo "View difference images to see WHERE the models differ."
echo " diff-A-minus-B.png = geometry in original but MISSING from reconstruction"
echo " diff-B-minus-A.png = EXTRA geometry in reconstruction not in original"
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# openscad-stl-reconstruct.sh — Automated STL analysis for reconstruction
# Uses projection slicing, trimesh analysis, and normal-based CSG inference
set -euo pipefail
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
usage() {
cat <<'EOF'
Usage: openscad-stl-reconstruct.sh <file.stl> <output_dir>
Automated STL analysis pipeline:
1. Bounding box + volume + basic mesh stats (via trimesh/admesh)
2. 2D profile slices at multiple Z levels (via OpenSCAD projection)
3. Normal analysis for CSG inference (cuts vs solid features)
4. Primitive detection (RANSAC plane/cylinder fitting)
5. Generates a decomposition report with OpenSCAD code suggestions
Output:
<output_dir>/report.txt Full analysis report
<output_dir>/slices/slice-z*.svg 2D profile SVGs at each Z level
<output_dir>/slices/slice-z*.png Rendered slice images
<output_dir>/primitives.json Detected primitives with parameters
EOF
exit 1
}
[[ $# -lt 2 ]] && usage
STL_FILE="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
OUTDIR="$2"
[[ -f "$STL_FILE" ]] || { echo "ERROR: File not found: $STL_FILE" >&2; exit 1; }
mkdir -p "$OUTDIR/slices"
echo "=== STL Reconstruction Analysis ==="
echo "Input: $STL_FILE"
echo "Output: $OUTDIR"
echo ""
# --- Step 1: Basic mesh analysis with trimesh ---
echo "--- Step 1: Mesh Analysis ---"
python3 << PYEOF
import trimesh
import numpy as np
import json, os
mesh = trimesh.load_mesh('$STL_FILE', force='mesh')
print(f"Vertices: {len(mesh.vertices)}")
print(f"Faces: {len(mesh.faces)}")
print(f"Volume: {mesh.volume:.2f} mm³")
print(f"Watertight: {mesh.is_watertight}")
print(f"Bounding box: {mesh.bounds[0].round(3)} to {mesh.bounds[1].round(3)}")
dims = mesh.bounds[1] - mesh.bounds[0]
print(f"Dimensions: {dims[0]:.3f} x {dims[1]:.3f} x {dims[2]:.3f} mm")
print(f"Center: {mesh.centroid.round(3)}")
# Identify the primary axes (longest = extrusion direction)
axes = ['X', 'Y', 'Z']
sorted_axes = sorted(range(3), key=lambda i: dims[i], reverse=True)
print(f"Primary axis (longest): {axes[sorted_axes[0]]} ({dims[sorted_axes[0]]:.1f}mm)")
print(f"Likely extrusion axis: {axes[sorted_axes[0]]}")
# Bounding cylinder
try:
cyl = trimesh.bounds.minimum_cylinder(mesh)
print(f"Bounding cylinder: h={cyl['height']:.2f} r={cyl['radius']:.2f}")
except:
pass
# Face normal analysis — detect dominant orientations
normals = mesh.face_normals
# Cluster normals by major axis alignment
for i, axis in enumerate(axes):
pos = np.sum(normals[:, i] > 0.9)
neg = np.sum(normals[:, i] < -0.9)
if pos + neg > 0:
print(f"Faces aligned with {axis}: +{pos} / -{neg}")
# Detect inward-facing curved surfaces (= cuts/holes)
# Curved faces have normals NOT aligned with any axis
non_planar = np.sum(np.all(np.abs(normals) < 0.9, axis=1))
print(f"Curved/non-planar faces: {non_planar} ({non_planar/len(normals)*100:.0f}%)")
# Save basic info as JSON
info = {
'file': '$STL_FILE',
'vertices': int(len(mesh.vertices)),
'faces': int(len(mesh.faces)),
'volume': float(mesh.volume),
'watertight': bool(mesh.is_watertight),
'bounds_min': mesh.bounds[0].tolist(),
'bounds_max': mesh.bounds[1].tolist(),
'dimensions': dims.tolist(),
'center': mesh.centroid.tolist(),
'primary_axis': axes[sorted_axes[0]],
}
with open('$OUTDIR/mesh-info.json', 'w') as f:
json.dump(info, f, indent=2)
PYEOF
# --- Step 2: 2D Profile Slices via OpenSCAD projection ---
echo ""
echo "--- Step 2: 2D Profile Slices ---"
# Get Z range from mesh info
Z_MIN=$(python3 -c "import json; d=json.load(open('$OUTDIR/mesh-info.json')); print(d['bounds_min'][2])")
Z_MAX=$(python3 -c "import json; d=json.load(open('$OUTDIR/mesh-info.json')); print(d['bounds_max'][2])")
Z_MID=$(python3 -c "print(($Z_MIN + $Z_MAX) / 2)")
echo "Z range: $Z_MIN to $Z_MAX (mid=$Z_MID)"
# Generate slices at key Z levels (bottom, 25%, 50%, 75%, top)
for pct in 0.01 0.25 0.50 0.75 0.99; do
Z_LEVEL=$(python3 -c "print(round($Z_MIN + ($Z_MAX - $Z_MIN) * $pct, 3))")
SLICE_NAME="slice-z${Z_LEVEL}"
# Create OpenSCAD file for this slice
cat > "/tmp/slice-${pct}.scad" << SCADEOF
projection(cut=true)
translate([0, 0, -${Z_LEVEL}])
import("${STL_FILE}", convexity=10);
SCADEOF
# Export as SVG
if "$OPENSCAD" -o "$OUTDIR/slices/${SLICE_NAME}.svg" "/tmp/slice-${pct}.scad" 2>/dev/null; then
SVG_SIZE=$(wc -c < "$OUTDIR/slices/${SLICE_NAME}.svg" | tr -d ' ')
if [[ "$SVG_SIZE" -gt 200 ]]; then
echo " Z=$Z_LEVEL (${pct}): SVG exported ($SVG_SIZE bytes)"
else
echo " Z=$Z_LEVEL (${pct}): Empty slice (no geometry at this Z)"
rm -f "$OUTDIR/slices/${SLICE_NAME}.svg"
fi
else
echo " Z=$Z_LEVEL (${pct}): Export failed"
fi
done
# --- Step 3: Primitive Detection with trimesh ---
echo ""
echo "--- Step 3: Primitive Detection ---"
python3 -c "
import trimesh
import numpy as np
import json
mesh = trimesh.load_mesh('$STL_FILE', force='mesh')
primitives = []
# Planar facets
try:
for i, (facet, area) in enumerate(zip(mesh.facets, mesh.facets_area)):
if area > 10:
fn = mesh.face_normals[facet]
avg_n = fn.mean(axis=0); avg_n /= np.linalg.norm(avg_n)
fv = mesh.vertices[mesh.faces[facet].flatten()]
primitives.append({'type':'plane','normal':avg_n.round(4).tolist(),'centroid':fv.mean(axis=0).round(3).tolist(),'area':round(float(area),2),'faces':len(facet)})
except Exception as e:
print(f'Facet error: {e}')
# Cylindrical surfaces
normals = mesh.face_normals
curved_mask = np.all(np.abs(normals) < 0.85, axis=1)
curved_faces = np.where(curved_mask)[0]
print(f'Planar facets: {len([p for p in primitives if p[\"type\"]==\"plane\"])} (>{\"10mm²\"})')
print(f'Curved faces: {len(curved_faces)} ({len(curved_faces)/len(normals)*100:.0f}%)')
if len(curved_faces) > 10:
cv = mesh.vertices[np.unique(mesh.faces[curved_faces].flatten())]
cn = mesh.face_normals[curved_faces]
# Axis from normal cross products
axes_c = []
for _ in range(min(200, len(cn))):
i,j = np.random.choice(len(cn),2,replace=False)
cp = np.cross(cn[i],cn[j])
if np.linalg.norm(cp) > 0.1:
cp /= np.linalg.norm(cp)
if cp[0] < 0: cp *= -1
axes_c.append(cp)
if axes_c:
ax = np.mean(axes_c, axis=0); ax /= np.linalg.norm(ax)
# Project to 2D for circle fitting
if abs(ax[0]) < 0.9: u = np.cross(ax,[1,0,0])
else: u = np.cross(ax,[0,1,0])
u /= np.linalg.norm(u); v = np.cross(ax,u)
c3d = cv.mean(axis=0); centered = cv - c3d
pu = centered@u; pv = centered@v; pa = centered@ax
r = float(np.median(np.sqrt(pu**2+pv**2)))
# Inward/outward
fc = mesh.triangles_center[curved_faces]
tc = c3d - fc; tc /= (np.linalg.norm(tc,axis=1,keepdims=True)+1e-10)
inw = float(np.mean(np.sum(cn*tc,axis=1) > 0))
op = 'difference' if inw > 0.5 else 'union'
print(f'Cylinder: axis=[{ax[0]:.3f},{ax[1]:.3f},{ax[2]:.3f}] r={r:.3f} d={r*2:.3f} center={c3d.round(2).tolist()} type={op}')
print(f' Axis span: {float(pa.min()):.2f} to {float(pa.max()):.2f}')
primitives.append({'type':'cylinder','axis':ax.round(4).tolist(),'radius':round(r,3),'diameter':round(r*2,3),'center':c3d.round(3).tolist(),'axis_min':round(float(pa.min()),3),'axis_max':round(float(pa.max()),3),'csg_operation':op,'inward_ratio':round(inw,3),'faces':int(len(curved_faces))})
with open('$OUTDIR/primitives.json','w') as f:
json.dump(primitives,f,indent=2)
print(f'Saved {len(primitives)} primitives to $OUTDIR/primitives.json')
"
# --- Step 4: Generate Report ---
echo ""
echo "--- Step 4: Report ---"
echo "Profile slices saved in: $OUTDIR/slices/"
ls -la "$OUTDIR/slices/"*.svg 2>/dev/null | wc -l | xargs -I{} echo " {} SVG slices generated"
echo "Primitives saved to: $OUTDIR/primitives.json"
echo "Mesh info saved to: $OUTDIR/mesh-info.json"
echo ""
echo "=== Analysis Complete ==="
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# openscad-validate.sh — Validate .scad files with structured error output
# Runs OpenSCAD in strict mode and parses errors into actionable categories
set -euo pipefail
OPENSCAD="${OPENSCAD_BIN:-$(command -v openscad || echo /opt/homebrew/bin/openscad)}"
usage() {
echo "Usage: openscad-validate.sh <file.scad> [-D 'var=val' ...]"
exit 1
}
[[ $# -lt 1 ]] && usage
scad_file="$1"
shift
if [[ ! -f "$scad_file" ]]; then
echo "ERROR: File not found: $scad_file" >&2
exit 1
fi
tmpdir=$(mktemp -d /tmp/openscad-validate-XXXXXX)
trap 'rm -rf "$tmpdir"' EXIT
stl_out="$tmpdir/check.stl"
echo_out="$tmpdir/check.echo"
# Run OpenSCAD in strict mode
output=$("$OPENSCAD" \
--check-parameters=true \
--check-parameter-ranges=true \
--hardwarnings \
-o "$stl_out" \
-o "$echo_out" \
"$@" \
"$scad_file" 2>&1) || exit_code=$?
exit_code="${exit_code:-0}"
echo "=== Validation Report ==="
echo "File: $scad_file"
echo "Exit code: $exit_code"
# Categorize errors
if echo "$output" | grep -q "Parser error"; then
echo "Category: SYNTAX_ERROR"
echo "$output" | grep "ERROR:" | head -5
echo ""
# Extract line number
line=$(echo "$output" | sed -n 's/.*line \([0-9]*\).*/\1/p' | head -1)
if [[ -n "$line" ]]; then
echo "Error at line $line. Context:"
sed -n "$((line > 3 ? line - 3 : 1)),${line}p" "$scad_file" 2>/dev/null | cat -n
fi
elif echo "$output" | grep -q "Current top level object is empty"; then
echo "Category: EMPTY_MODEL"
echo "The model produces no geometry. Check:"
echo " - Are modules being called?"
echo " - Did a difference() remove everything?"
echo " - Are parameter values valid?"
elif echo "$output" | grep -q "NSOpenGLContext\|GLX\|Unable to create"; then
echo "Category: HEADLESS_PREVIEW"
echo "PNG preview unavailable (no OpenGL context)."
echo "STL export should still work."
elif echo "$output" | grep -qi "warning"; then
echo "Category: WARNING"
echo "$output" | grep -i "warning" | head -10
else
echo "Category: OK"
fi
# Show echo output if present
if [[ -f "$echo_out" ]] && [[ -s "$echo_out" ]]; then
echo ""
echo "=== Echo Output ==="
cat "$echo_out"
fi
# Show geometry stats if STL was produced
if [[ -f "$stl_out" ]]; then
stl_size=$(wc -c < "$stl_out" | tr -d ' ')
if [[ "$stl_size" -gt 0 ]]; then
echo ""
echo "=== Geometry ==="
echo "STL size: $stl_size bytes"
# Extract stats from render output
echo "$output" | grep -E "(Facets|Vertices|rendering time|Simple):" | head -10
fi
fi
exit "$exit_code"
+69
View File
@@ -0,0 +1,69 @@
// ============================================
// Template: L-Bracket with Mounting Holes
// Description: Adjustable L-bracket for wall/shelf mounting
// ============================================
// --- Parameters ---
arm_length = 50; // [mm] horizontal arm
leg_length = 40; // [mm] vertical leg
width = 25; // [mm] bracket width
thickness = 4; // [mm] material thickness
fillet_r = 8; // [mm] inner fillet radius
// Mounting holes
hole_d = 5; // [mm] hole diameter
hole_inset = 10; // [mm] hole center from edges
countersink = true; // countersink holes
cs_d = 9; // [mm] countersink diameter
cs_depth = 2; // [mm] countersink depth
// --- Quality ---
$fn = 64;
// --- Render ---
bracket();
// --- Modules ---
module bracket() {
difference() {
union() {
// Horizontal arm
cube([arm_length, width, thickness]);
// Vertical leg
cube([thickness, width, leg_length]);
// Inner fillet for strength
translate([thickness, 0, thickness])
fillet(fillet_r, width);
}
// Arm mounting holes
translate([arm_length - hole_inset, width/2, -0.01])
mounting_hole(hole_d, thickness, countersink, cs_d, cs_depth);
// Leg mounting holes
translate([-0.01, width/2, leg_length - hole_inset])
rotate([0, 90, 0])
mounting_hole(hole_d, thickness, countersink, cs_d, cs_depth);
}
}
module fillet(r, w) {
difference() {
cube([r, w, r]);
translate([r, -0.01, r])
rotate([-90, 0, 0])
cylinder(r=r, h=w + 0.02);
}
}
module mounting_hole(d, h, countersink=false, cs_d=0, cs_depth=0) {
union() {
cylinder(d=d, h=h + 0.02);
if (countersink) {
translate([0, 0, h - cs_depth + 0.01])
cylinder(d1=d, d2=cs_d, h=cs_depth);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
// ============================================
// Template: Parametric Electronics Enclosure
// Description: Box with lid, screw posts, ventilation
// ============================================
// --- Parameters ---
width = 80; // [mm] inner width (X)
depth = 60; // [mm] inner depth (Y)
height = 35; // [mm] inner height (Z)
wall = 2.5; // [mm] wall thickness
corner_r = 3; // [mm] corner radius
lid_height = 8; // [mm] lid inner height
lip = 1.5; // [mm] lid overlap lip
tolerance = 0.3; // [mm] fit tolerance
// Screw posts
screw_d = 3; // [mm] screw hole diameter
post_d = 7; // [mm] post outer diameter
post_inset = 5; // [mm] post inset from inner wall
// Ventilation
vent_slots = 5; // number of vent slots
vent_width = 2; // [mm] slot width
vent_length = 20; // [mm] slot length
// --- Quality ---
$fn = 64;
eps = 0.01; // epsilon for clean booleans
// --- Assertions ---
assert(wall >= 1.2, "wall too thin for FDM (min 1.2mm)");
assert(width > 2 * wall, "inner width must be positive");
assert(depth > 2 * wall, "inner depth must be positive");
assert(height > wall, "inner height must be positive");
// --- Render ---
// Show both parts side by side
box_bottom();
translate([width + wall * 2 + 10, 0, 0]) box_lid();
// --- Modules ---
module box_bottom() {
difference() {
// Outer shell
rounded_box([width + 2*wall, depth + 2*wall, height + wall], corner_r);
// Inner cavity
translate([wall, wall, wall])
rounded_box([width, depth, height + wall + 1], max(corner_r - wall, 0.5));
// Ventilation slots on one side
translate([wall + (width - (vent_slots * (vent_width + 3))) / 2, -1, height/2])
for (i = [0:vent_slots-1])
translate([i * (vent_width + 3), 0, 0])
cube([vent_width, wall + 2, vent_length]);
}
// Screw posts
for (pos = screw_post_positions())
translate([pos.x, pos.y, wall])
screw_post(post_d, screw_d, height - 2);
// Lid lip (inner ridge)
difference() {
translate([wall - lip, wall - lip, height + wall - lip])
rounded_box([width + 2*lip, depth + 2*lip, lip], max(corner_r - wall + lip, 0.5));
translate([wall, wall, height + wall - lip - 0.01])
rounded_box([width, depth, lip + 0.02], max(corner_r - wall, 0.5));
}
}
module box_lid() {
difference() {
// Outer lid
rounded_box([width + 2*wall, depth + 2*wall, lid_height + wall], corner_r);
// Inner cavity
translate([wall, wall, -0.01])
rounded_box([width, depth, lid_height + 0.02], max(corner_r - wall, 0.5));
}
// Lip insert (fits inside bottom lip)
translate([wall + tolerance, wall + tolerance, 0])
difference() {
rounded_box(
[width - 2*tolerance, depth - 2*tolerance, lip],
max(corner_r - wall - tolerance, 0.5)
);
translate([lip, lip, -0.01])
rounded_box(
[width - 2*lip - 2*tolerance, depth - 2*lip - 2*tolerance, lip + 0.02],
max(corner_r - wall - lip - tolerance, 0.5)
);
}
}
module rounded_box(size, r) {
hull() {
for (x = [r, size.x - r])
for (y = [r, size.y - r])
translate([x, y, 0])
cylinder(r=r, h=size.z);
}
}
module screw_post(outer_d, inner_d, h) {
difference() {
cylinder(d=outer_d, h=h);
translate([0, 0, -0.01])
cylinder(d=inner_d, h=h + 0.02);
}
}
function screw_post_positions() = [
[wall + post_inset, wall + post_inset, 0],
[wall + width - post_inset, wall + post_inset, 0],
[wall + post_inset, wall + depth - post_inset, 0],
[wall + width - post_inset, wall + depth - post_inset, 0]
];
@@ -0,0 +1,152 @@
// ============================================
// printable-lib.scad — Reusable modules for 3D printing
// Include with: use <printable-lib.scad>
// ============================================
eps = 0.01; // epsilon for clean boolean operations
// --- Clearance helpers ---
// Returns clearance value for different fit types
function fit_clearance(kind="close") =
kind == "press" ? 0.15 :
kind == "close" ? 0.25 :
kind == "loose" ? 0.40 :
kind == "slide" ? 0.30 : 0.25;
// --- Shell / Hollow box ---
module shell_box(outer=[60,40,20], wall=2, floor=2) {
assert(wall >= 1.2, "wall too thin for FDM (min 1.2mm)");
assert(floor >= 0.8, "floor too thin (min 0.8mm)");
difference() {
cube(outer);
translate([wall, wall, floor])
cube([outer.x - 2*wall, outer.y - 2*wall, outer.z - floor + eps]);
}
}
// --- Rounded box (hull-based) ---
module rounded_box(size, r=2) {
hull() {
for (x = [r, size.x - r])
for (y = [r, size.y - r])
translate([x, y, 0])
cylinder(r=r, h=size.z);
}
}
// --- Screw clearance hole ---
module screw_clearance_hole(d=3, h=10, fit="close") {
cylinder(h=h + 2*eps, d=d + fit_clearance(fit), $fn=48);
}
// --- Counterbore hole (for socket head cap screws) ---
// Head pocket at entry side (top), shaft goes through
module counterbore_hole(shaft_d=3, head_d=6, head_h=3, h=12) {
union() {
screw_clearance_hole(shaft_d, h);
translate([0, 0, h - head_h + eps])
cylinder(h=head_h + eps, d=head_d, $fn=48);
}
}
// --- Countersink hole ---
module countersink_hole(d=3, cs_d=6, cs_h=2, h=10) {
union() {
cylinder(d=d, h=h + 2*eps, $fn=48);
translate([0, 0, h - cs_h + eps])
cylinder(d1=d, d2=cs_d, h=cs_h, $fn=48);
}
}
// --- Heat-set insert boss ---
module heatset_boss(insert_d=4.6, insert_h=5, wall=2, h=8) {
assert(wall >= 1.6, "boss wall too thin for heat-set insert");
difference() {
cylinder(h=h, d=insert_d + 2*wall, $fn=64);
translate([0, 0, -eps])
cylinder(h=insert_h + 2*eps, d=insert_d, $fn=64);
}
}
// --- Screw post (solid post with hole) ---
module screw_post(outer_d=7, inner_d=3, h=10) {
difference() {
cylinder(d=outer_d, h=h, $fn=48);
translate([0, 0, -eps])
cylinder(d=inner_d, h=h + 2*eps, $fn=48);
}
}
// --- Structural rib / gusset ---
module rib(len=20, height=12, thick=2) {
linear_extrude(height=thick)
polygon([[0, 0], [len, 0], [0, height]]);
}
// --- Chamfer edge (for print-friendly overhangs) ---
module chamfer_edge(length=10, size=1) {
translate([0, 0, -eps])
linear_extrude(height=length)
polygon([[0, 0], [size, 0], [0, size]]);
}
// --- Snap-fit tab ---
// Creates a cantilever snap tab extending along Y with a hook at the end
module snap_tab(width=8, length=6, thick=1.5, overhang=0.8) {
union() {
// Cantilever arm
cube([width, length, thick]);
// Hook at the end (rotated extrusion for clean manifold)
translate([0, length - eps, 0])
rotate([90, 0, 90])
linear_extrude(height=width)
polygon([[0, 0], [thick + eps, 0], [thick/2, overhang]]);
}
}
// --- Text emboss/deboss helper ---
// Use with difference() to deboss or union() to emboss
module text_label(txt="Label", size=8, depth=1, font="Liberation Sans:style=Bold",
halign="center", valign="center") {
linear_extrude(height=depth)
text(txt, size=size, font=font, halign=halign, valign=valign);
}
// --- Ventilation grille ---
module vent_grille(area_w=30, area_h=15, slot_w=2, slot_gap=2, depth=2) {
n_slots = floor(area_h / (slot_w + slot_gap));
for (i = [0:n_slots-1])
translate([0, i * (slot_w + slot_gap), 0])
cube([area_w, slot_w, depth + 2*eps]);
}
// --- PCB standoff array ---
module pcb_standoffs(positions, height=5, outer_d=6, hole_d=2.5) {
for (pos = positions)
translate(pos)
difference() {
cylinder(d=outer_d, h=height, $fn=32);
translate([0, 0, -eps])
cylinder(d=hole_d, h=height + 2*eps, $fn=32);
}
}
// --- Profile with rounded corners (2D) ---
// Use with linear_extrude() — preferred over hull() of cylinders
module rounded_rect_2d(size, r=2) {
offset(r=r)
square([size.x - 2*r, size.y - 2*r], center=true);
}
// --- Lid lip (for box closures) ---
module lid_lip(outer_size, wall=2, lip_h=2, lip_w=1.2, tol=0.25) {
difference() {
rounded_box([outer_size.x, outer_size.y, lip_h], r=2);
translate([lip_w + tol, lip_w + tol, -eps])
rounded_box([
outer_size.x - 2*(lip_w + tol),
outer_size.y - 2*(lip_w + tol),
lip_h + 2*eps
], r=max(2 - lip_w, 0.5));
}
}