phase 0
@@ -0,0 +1,276 @@
|
||||
# STEP Processor Skill — Project Context
|
||||
### CoWork Memory Document · MPMedia Engineering
|
||||
**Updated:** June 2026 · **Status:** ✅ FULLY TESTED — Smoke-tested on 3 Chinese-sourced STEP files
|
||||
|
||||
---
|
||||
|
||||
## What This Is
|
||||
|
||||
A Python-based CAD processing skill for MPMedia's engineering workflow. Reads STEP/STP files (display enclosures, mounting kits, hardware assemblies) and automates: thumbnail generation, parts BOM export (MPM-branded Excel), Chinese-to-English label translation, STEP file rewriting with English labels, geometric queries, and external dimensional diagram creation.
|
||||
|
||||
Lives as a CoWork skill folder with a `SKILL.md`. Callable from Claude or directly from CLI.
|
||||
|
||||
---
|
||||
|
||||
## Current Working State (as of June 2026)
|
||||
|
||||
### Smoke Test Results
|
||||
All three production STEP files tested end-to-end:
|
||||
|
||||
| File | Faces | Parts (STEP parser) | Translations | _EN.step |
|
||||
|------|-------|---------------------|--------------|----------|
|
||||
| MR16s Gen1 | 5,655 | 28 | 16/16 | ✅ All English |
|
||||
| MR27s Gen1 | 6,732 | 31 | 18/18 | ✅ All English |
|
||||
| MR28uws Gen1 | 5,891 | 31 | 20/20 | ✅ All English |
|
||||
|
||||
### What Works
|
||||
- **STEP loading**: build123d primary, FreeCAD headless fallback
|
||||
- **GBK encoding**: Chinese CAD files fully decoded (see Architecture Notes below)
|
||||
- **BOM extraction**: STEP text parser as primary source — correctly reads Chinese names
|
||||
- **Translation**: Claude API (`claude-haiku-4-5-20251001`) — batched, single call per file
|
||||
- **STEP rewriter**: `_EN.step` produced with English labels in both PRODUCT name fields
|
||||
- **BOM export**: MPM-branded `.xlsx` (Montserrat/Open Sans, Dark Shade header, Gold border, alternating rows)
|
||||
- **Thumbnails**: 6 PNG views via pyrender (front, rear, left, right, iso_left, iso_right)
|
||||
|
||||
### BOM Output Format
|
||||
Columns in Excel output order:
|
||||
|
||||
| Column | Header in Excel |
|
||||
|--------|----------------|
|
||||
| part_number | Part # |
|
||||
| part_description | Part Description |
|
||||
| quantity | Qty |
|
||||
| level | Level |
|
||||
| parent | Parent |
|
||||
| bbox_x_mm | X (mm) |
|
||||
| bbox_y_mm | Y (mm) |
|
||||
| bbox_z_mm | Z (mm) |
|
||||
| notes | Notes |
|
||||
| part_name_supplier | Supplier Part Name |
|
||||
|
||||
`part_description` = translated English name (was `part_name_english` internally).
|
||||
`part_name_supplier` = original Chinese name from supplier file (last column, was `part_name_original`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes (Critical — Read Before Debugging)
|
||||
|
||||
### GBK Encoding Fix
|
||||
STEP files from Chinese CAD tools (SolidWorks CN, etc.) embed raw GBK bytes in `PRODUCT` entity name strings. This caused two separate problems, each with a different fix:
|
||||
|
||||
**Problem 1: BOM shows garbled Chinese (mojibake)**
|
||||
OpenCASCADE (OCC) / build123d's STEP reader applies an internal codec that maps each 2-byte GBK sequence to incorrect Unicode codepoints. This is NOT reversible via latin-1 → GBK round-trip because OCC's codec is not latin-1.
|
||||
|
||||
Fix: Bypass OCC entirely for part name extraction. `bom.py` reads the raw STEP file text directly with encoding detection (UTF-8 → GBK → latin-1 fallback) and parses `PRODUCT` entities via regex. This is the primary name source; OCC assembly walk is fallback only.
|
||||
|
||||
**Problem 2: _EN.step still shows Chinese in CAD viewer**
|
||||
The rewriter (`rewriter.py`) was reading the file as UTF-8, turning GBK bytes into replacement chars (U+FFFD). Chinese names became `???` and never matched the translation map.
|
||||
|
||||
Fix: `rewriter.py` uses the same encoding-detection reader as `bom.py`. File is read as GBK when UTF-8 produces replacement chars.
|
||||
|
||||
**Problem 3: Only first PRODUCT name field was replaced**
|
||||
ISO 10303-21 `PRODUCT` entity format: `#N = PRODUCT('id', 'name', 'description', (#...))`. Both the first and second quoted strings carry the part name. CAD viewers (including OpenCASCADE CAD Assistant) display the second field.
|
||||
|
||||
Fix: Updated `PRODUCT_PATTERN` regex to capture both fields with 5 groups. Replacement writes the translated name into both positions.
|
||||
|
||||
### Translation API Model
|
||||
Current model: `claude-haiku-4-5-20251001`
|
||||
Previous value `claude-sonnet-4-20250514` was returning 404 — updated in `translator.py`.
|
||||
|
||||
### BOM Excel Library
|
||||
`openpyxl` must be installed in the venv. The skill falls back to CSV silently if it's missing — don't rely on this fallback, install it explicitly.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites for a New macOS Computer
|
||||
|
||||
### Hardware Requirements
|
||||
- Apple Silicon Mac (M1/M2/M3/M4) — all wheels are native arm64
|
||||
- **No Rosetta required**
|
||||
- macOS 13 Sequoia or later recommended
|
||||
|
||||
### Software Stack
|
||||
```
|
||||
Python 3.10–3.12 (3.13 works but less tested; 3.9 too old)
|
||||
Homebrew (for cairo system libs)
|
||||
~/step-processor-env (Python venv — all pip packages go here)
|
||||
```
|
||||
|
||||
### Python Packages (pip install in venv)
|
||||
```bash
|
||||
pip install cadquery-ocp # OCC kernel, native arm64, ~300MB
|
||||
pip install build123d # STEP loader, primary
|
||||
pip install trimesh pyrender # thumbnail rendering pipeline
|
||||
pip install Pillow numpy pandas # image and data processing
|
||||
pip install anthropic # Claude API client
|
||||
pip install openpyxl # Excel BOM output — REQUIRED
|
||||
pip install cairosvg # SVG→PNG/PDF for diagrams (optional, diagrams only)
|
||||
```
|
||||
|
||||
### System Libraries (Homebrew)
|
||||
```bash
|
||||
brew install cairo pango gdk-pixbuf libffi # required for cairosvg
|
||||
```
|
||||
|
||||
### FreeCAD Fallback (Optional)
|
||||
Only needed if build123d fails on a specific file.
|
||||
- Download official arm64 `FreeCAD.app` from https://github.com/FreeCAD/FreeCAD/releases/latest
|
||||
- Drag to `/Applications`, launch once so macOS approves it
|
||||
- Verify: `/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd --version`
|
||||
|
||||
> **macOS 15 Sequoia:** conda-forge FreeCAD is killed by Gatekeeper. Use the official signed `.app` only.
|
||||
|
||||
### Anthropic API Key
|
||||
The key must be available in the environment where the processor runs.
|
||||
|
||||
**For interactive terminal use:**
|
||||
```bash
|
||||
echo 'export ANTHROPIC_API_KEY="sk-ant-YOUR-KEY"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
**For Desktop Commander / CoWork (non-interactive shell):**
|
||||
```bash
|
||||
echo 'export ANTHROPIC_API_KEY="sk-ant-YOUR-KEY"' >> ~/.zshenv
|
||||
```
|
||||
`~/.zshrc` is only sourced in interactive shells. Desktop Commander spawns non-interactive zsh — it reads `~/.zshenv` instead. **Both files should have the key.**
|
||||
|
||||
---
|
||||
|
||||
## Setup — Fast Path (New Mac)
|
||||
|
||||
```bash
|
||||
# 1. Create venv
|
||||
python3 -m venv ~/step-processor-env
|
||||
source ~/step-processor-env/bin/activate
|
||||
|
||||
# 2. Install all packages
|
||||
pip install --upgrade pip
|
||||
pip install cadquery-ocp build123d trimesh pyrender Pillow numpy pandas anthropic openpyxl
|
||||
|
||||
# 3. System libs for diagram export
|
||||
brew install cairo pango gdk-pixbuf libffi
|
||||
pip install cairosvg
|
||||
|
||||
# 4. API key (do BOTH for interactive + Desktop Commander)
|
||||
echo 'export ANTHROPIC_API_KEY="sk-ant-YOUR-KEY"' >> ~/.zshrc
|
||||
echo 'export ANTHROPIC_API_KEY="sk-ant-YOUR-KEY"' >> ~/.zshenv
|
||||
source ~/.zshrc
|
||||
|
||||
# 5. Verify
|
||||
python -c "import build123d, openpyxl, anthropic, trimesh; print('ALL OK')"
|
||||
|
||||
# 6. Test run
|
||||
cd /path/to/step-skill-folder
|
||||
python step_processor.py yourfile.step --no-thumbnails --verbose
|
||||
```
|
||||
|
||||
Expected output for Chinese STEP file:
|
||||
```
|
||||
INFO [build123d] Loaded: yourfile.step | NNNN faces | NN parts
|
||||
INFO STEP text parser found NN unique part names
|
||||
INFO BOM extracted: NN parts
|
||||
INFO BOM XLSX → yourfile_bom.xlsx
|
||||
INFO Chinese part names detected — auto-translating
|
||||
INFO API returned NN translations
|
||||
INFO _EN.step written: yourfile_EN.step (NN labels replaced)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
STEP File Skill/
|
||||
step_processor.py ← CLI entry point
|
||||
modules/
|
||||
__init__.py
|
||||
loader.py ← build123d load + GBK mojibake helper
|
||||
bom.py ← STEP text parser + MPM-branded xlsx output
|
||||
renderer.py ← 6-view PNG thumbnails (pyrender)
|
||||
translator.py ← Claude API translation (claude-haiku-4-5-20251001)
|
||||
rewriter.py ← _EN.step writer (GBK-aware, both PRODUCT fields)
|
||||
query_engine.py ← Natural language geometry queries + REPL
|
||||
external_diagram.py ← Dimensional diagram generator
|
||||
schemas/
|
||||
external_diagram_schema.json
|
||||
parts_mapping_schema.json
|
||||
templates/
|
||||
datablock_template.md
|
||||
SKILL.md ← Claude skill instructions
|
||||
INSTALL.md ← Library setup details
|
||||
SETUP_CHECKLIST.md ← Step-by-step setup + progressive tests
|
||||
COWORK_CONTEXT.md ← This file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```bash
|
||||
# Activate environment first (every new terminal session)
|
||||
source ~/step-processor-env/bin/activate
|
||||
cd /path/to/STEP\ File\ Skill
|
||||
|
||||
# Default: thumbnails + BOM + auto-translate if Chinese detected
|
||||
python step_processor.py enclosure.step
|
||||
|
||||
# BOM only (no thumbnails, no translate)
|
||||
python step_processor.py enclosure.step --no-thumbnails --no-translate
|
||||
|
||||
# Force translation even if auto-detect is off
|
||||
python step_processor.py enclosure.step --translate
|
||||
|
||||
# Single geometric query and exit
|
||||
python step_processor.py enclosure.step --query "list all mounting holes"
|
||||
|
||||
# Interactive geometry REPL
|
||||
python step_processor.py enclosure.step --repl
|
||||
|
||||
# External dimensional diagram
|
||||
python step_processor.py enclosure.step --diagram
|
||||
python step_processor.py enclosure.step --diagram --diagram-pdf
|
||||
python step_processor.py enclosure.step --diagram --diagram-mode enclosure_plus_mounting
|
||||
|
||||
# Verbose (shows backend selection, timing, fallback notices)
|
||||
python step_processor.py enclosure.step --verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues and Pending Work
|
||||
|
||||
### BOM bbox Enrichment (partial)
|
||||
Rows beyond the first only get 5.0 × 5.0 × 5.0mm placeholder bounding boxes. The OCC child enumeration only retrieves the top-level shape's children correctly. Root cause: build123d's `.children` on compound shapes doesn't walk sub-assemblies for bbox. Fix: map part names from STEP text parser back to OCC children by label — non-trivial due to OCC label mangling on CJK files.
|
||||
|
||||
### Mounting Hole Filter — Minimum Diameter
|
||||
The query engine's mounting hole detector returns PCB vias (0.4mm diameter) alongside actual mounting holes. Needs a minimum diameter threshold (recommend 2.0mm floor). Pending update to `query_engine.py`.
|
||||
|
||||
### Phase 7 Full Test
|
||||
The SETUP_CHECKLIST.md Phase 7 tests have been validated for Tests 1, 2, 4 (BOM, translation, rewrite). Tests 3 (thumbnails), 5 (REPL), 6/7 (diagrams) not yet re-run post GBK fix. Thumbnails were verified in an earlier session; diagram code is scaffolded but output quality against production files not fully validated.
|
||||
|
||||
### Conda freecad_env Cleanup
|
||||
From a prior session: `conda activate freecad_env && conda deactivate && conda env remove -n freecad_env`. The conda FreeCAD approach was abandoned in favor of the signed FreeCAD.app. This env is dead weight on the local machine.
|
||||
|
||||
---
|
||||
|
||||
## Library Stack Decision Log
|
||||
|
||||
| Decision | Choice | Reason |
|
||||
|----------|--------|--------|
|
||||
| CAD kernel | build123d | Native arm64 arm; clean API; same OCC as existing viewer |
|
||||
| Fallback CAD | FreeCAD.app (signed) | conda-forge builds killed by Gatekeeper on Sequoia |
|
||||
| Translation | Claude Haiku API | Batched, manufacturing-context prompted, flags ambiguity |
|
||||
| Diagrams | SVG-first + cairosvg | No GUI dependency; vector quality; cairosvg handles PNG/PDF |
|
||||
| Excel | openpyxl | MPM brand formatting, column control, frozen panes |
|
||||
| Rejected | pythonocc | Rosetta/x64 conda dependency — non-starter on Apple Silicon |
|
||||
| Rejected | conda-forge FreeCAD | Unsigned binaries killed by Gatekeeper on macOS 15 |
|
||||
|
||||
---
|
||||
|
||||
## Planned Integration Points
|
||||
|
||||
- **Odoo V18 MRP**: Model number from `meta.json` triggers lookup of weight, BOM cost, stock status
|
||||
- **CoWork product docs**: `meta.json` feeds product data cards; diagram PNGs embed in Knowledge Base articles
|
||||
- **RDMC** (`rdmc.messagepoint.tv`): Thumbnail PNGs for display inventory visual reference
|
||||
- **OnSign.tv**: Enclosure dimensions for content sizing reference
|
||||
@@ -0,0 +1,133 @@
|
||||
# INSTALL.md — STEP Processor Setup (Apple Silicon / macOS arm64)
|
||||
|
||||
No Rosetta. No x64 sub-environments. Both libraries run natively.
|
||||
|
||||
---
|
||||
|
||||
## 1. build123d (Primary — native arm64, pip-based)
|
||||
|
||||
build123d depends on `cadquery-ocp`, which ships native arm64 wheels
|
||||
via GitHub Releases (not yet on PyPI, but stable).
|
||||
|
||||
### Step 1 — Install the arm64 OCP wheel
|
||||
|
||||
Go to: https://github.com/CadQuery/OCP/releases
|
||||
|
||||
Download the wheel matching your Python version, e.g.:
|
||||
`cadquery_ocp-7.x.x-cp311-cp311-macosx_12_0_arm64.whl`
|
||||
|
||||
Install it:
|
||||
```bash
|
||||
pip install /path/to/cadquery_ocp-*.whl
|
||||
```
|
||||
|
||||
### Step 2 — Install build123d and rendering stack
|
||||
|
||||
```bash
|
||||
pip install build123d
|
||||
pip install trimesh pyrender Pillow numpy pandas anthropic
|
||||
```
|
||||
|
||||
### Step 3 — Set API key
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
# Add to ~/.zshrc to persist
|
||||
```
|
||||
|
||||
### Step 4 — Verify
|
||||
|
||||
```bash
|
||||
python -c "import build123d; print('build123d OK')"
|
||||
python -c "import trimesh; print('trimesh OK')"
|
||||
python -c "import pyrender; print('pyrender OK')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. FreeCAD Headless (Fallback — native arm64 via conda-forge)
|
||||
|
||||
FreeCAD has been available as a native macOS-arm64 conda-forge package
|
||||
since late 2024. Install it in its own conda environment to avoid
|
||||
dependency conflicts with build123d.
|
||||
|
||||
### Step 1 — Install Miniforge (arm64) if not already present
|
||||
|
||||
Download from: https://github.com/conda-forge/miniforge/releases
|
||||
Select: `Miniforge3-MacOSX-arm64.sh`
|
||||
|
||||
```bash
|
||||
bash Miniforge3-MacOSX-arm64.sh
|
||||
```
|
||||
|
||||
### Step 2 — Create FreeCAD environment
|
||||
|
||||
```bash
|
||||
conda create -n freecad_env python=3.11
|
||||
conda activate freecad_env
|
||||
conda install -c conda-forge freecad
|
||||
```
|
||||
|
||||
### Step 3 — Verify headless operation
|
||||
|
||||
```bash
|
||||
FreeCADCmd --version
|
||||
# or
|
||||
python -c "import FreeCAD; import Part; print('FreeCAD headless OK')"
|
||||
```
|
||||
|
||||
**Important:** Never import `FreeCADGui` in headless scripts — it will crash.
|
||||
|
||||
### Step 4 — Make FreeCAD importable from your build123d environment
|
||||
|
||||
Add the FreeCAD conda env's site-packages to your PATH, or run scripts
|
||||
via the conda freecad_env directly:
|
||||
```bash
|
||||
conda run -n freecad_env python step_processor.py myfile.step
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Test
|
||||
|
||||
Once either library is installed:
|
||||
|
||||
```bash
|
||||
# Test with a sample STEP file
|
||||
python step_processor.py sample.step --verbose
|
||||
|
||||
# Test query REPL
|
||||
python step_processor.py sample.step --repl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Summary
|
||||
|
||||
| Package | Purpose | Required for |
|
||||
|---------------|--------------------------------|---------------------|
|
||||
| build123d | STEP loading + geometry | Primary path |
|
||||
| cadquery-ocp | OCC kernel (arm64 wheel) | build123d |
|
||||
| trimesh | Mesh handling for rendering | PNG thumbnails |
|
||||
| pyrender | Offscreen rendering | PNG thumbnails |
|
||||
| Pillow | PNG file writing | PNG thumbnails |
|
||||
| numpy | Geometry math | Both paths |
|
||||
| pandas | BOM CSV output | BOM export |
|
||||
| anthropic | Claude API translation | Translation |
|
||||
| FreeCAD | Fallback STEP loading | Fallback path only |
|
||||
|
||||
---
|
||||
|
||||
## 3. cairosvg (required for PNG and PDF export from diagrams)
|
||||
|
||||
```bash
|
||||
pip install cairosvg
|
||||
|
||||
# On macOS, may also need:
|
||||
brew install cairo pango gdk-pixbuf libffi
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
python -c "import cairosvg; print('cairosvg OK')"
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
part_number,part_name_original,part_name_english,quantity,level,parent,bbox_x_mm,bbox_y_mm,bbox_z_mm,notes
|
||||
001,"15.6"" Rear Cover","15.6"" Rear Cover",1,0,,248.6,459.2,40.0,parsed from STEP text
|
||||
002,Remote Control + Light Sensor Board,Remote Control + Light Sensor Board,1,0,,242.3,392.9,6.0,parsed from STEP text
|
||||
003,J5~HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,J5~HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,1,0,,215.9,363.8,8.8,parsed from STEP text
|
||||
004,"15.6"" Tempered Glass 3+3 Laminated","15.6"" Tempered Glass 3+3 Laminated",1,0,,241.2,451.8,21.5,parsed from STEP text
|
||||
005,M3x6 Countersunk Screw,M3x6 Countersunk Screw,1,0,,238.2,448.8,21.0,parsed from STEP text
|
||||
006,DZ-LP0632 Light Sensor Control Board,DZ-LP0632 Light Sensor Control Board,1,0,,130.5,10.0,20.0,parsed from STEP text
|
||||
007,J4~BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,J4~BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,2,0,,33.82,28.2,19.0,parsed from STEP text
|
||||
008,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07,1,0,,48.37,103.79,16.25,parsed from STEP text
|
||||
009,TOSN-DY398P-EMC Sub-board,TOSN-DY398P-EMC Sub-board,1,0,,92.0,21.0,17.7,parsed from STEP text
|
||||
010,TOSN-AD120P12V10A-120W,TOSN-AD120P12V10A-120W,1,0,,33.0,73.0,14.7,parsed from STEP text
|
||||
011,2P Phoenix Terminal,2P Phoenix Terminal,1,0,,33.0,73.0,14.7,parsed from STEP text
|
||||
012,HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,1,0,,30.0,120.0,11.6,parsed from STEP text
|
||||
013,G156HAN02.0--20221031_G156HAN02.0_PSpec_DBEST,G156HAN02.0--20221031_G156HAN02.0_PSpec_DBEST,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
014,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07.step,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
015,Small Glass Lens,Small Glass Lens,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
016,Board~a9jc.step,Board~a9jc.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
017,J7~XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,J7~XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
018,"15.6"" Terminal Board","15.6"" Terminal Board",1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
019,XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
020,Lower Aluminum Plate,Lower Aluminum Plate,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
021,Upper Aluminum Plate,Upper Aluminum Plate,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
022,BSCZ-TX3361,BSCZ-TX3361,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
023,BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
024,"15.6"" Mounting Plate","15.6"" Mounting Plate",1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
025,"15.6"" Light Sensor Bracket","15.6"" Light Sensor Bracket",1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
026,"15.6"" High-Speed Rail Display Assembly Drawing","15.6"" High-Speed Rail Display Assembly Drawing",1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
027,"15.6"" Aluminum Frame","15.6"" Aluminum Frame",1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
028,8Ω 5W Speaker (Model 3070),8Ω 5W Speaker (Model 3070),1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
|
@@ -0,0 +1,29 @@
|
||||
part_number,part_name_original,part_name_english,quantity,level,parent,bbox_x_mm,bbox_y_mm,bbox_z_mm,notes
|
||||
001,15.6寸后盖,"15.6"" Rear Cover",1,0,,248.6,459.2,40.0,parsed from STEP text; machine-translated
|
||||
002,遥控+光感板,Remote Control + Light Sensor Board,1,0,,242.3,392.9,6.0,parsed from STEP text; machine-translated
|
||||
003,J5~HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,J5~HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,1,0,,215.9,363.8,8.8,parsed from STEP text
|
||||
004,15.6寸钢化玻璃3+3夹胶,"15.6"" Tempered Glass 3+3 Laminated",1,0,,241.2,451.8,21.5,parsed from STEP text; machine-translated
|
||||
005,M3x6沉头螺丝,M3x6 Countersunk Screw,1,0,,238.2,448.8,21.0,parsed from STEP text; machine-translated
|
||||
006,DZ-LP0632感光控制板,DZ-LP0632 Light Sensor Control Board,1,0,,130.5,10.0,20.0,parsed from STEP text; machine-translated
|
||||
007,J4~BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,J4~BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,2,0,,33.82,28.2,19.0,parsed from STEP text
|
||||
008,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07,1,0,,48.37,103.79,16.25,parsed from STEP text
|
||||
009,TOSN-DY398P-EMC小板,TOSN-DY398P-EMC Sub-board,1,0,,92.0,21.0,17.7,parsed from STEP text; machine-translated
|
||||
010,TOSN-AD120P12V10A-120W,TOSN-AD120P12V10A-120W,1,0,,33.0,73.0,14.7,parsed from STEP text
|
||||
011,2P凤凰端子,2P Phoenix Terminal,1,0,,33.0,73.0,14.7,parsed from STEP text; machine-translated
|
||||
012,HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,1,0,,30.0,120.0,11.6,parsed from STEP text
|
||||
013,G156HAN02.0--20221031_G156HAN02.0_PSpec_DBEST,G156HAN02.0--20221031_G156HAN02.0_PSpec_DBEST,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
014,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07.step,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
015,小玻璃镜片,Small Glass Lens,1,0,,5.0,5.0,5.0,parsed from STEP text; machine-translated
|
||||
016,Board~a9jc.step,Board~a9jc.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
017,J7~XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,J7~XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
018,15.6寸端子板,"15.6"" Terminal Board",1,0,,5.0,5.0,5.0,parsed from STEP text; machine-translated
|
||||
019,XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
020,下铝板,Lower Aluminum Plate,1,0,,5.0,5.0,5.0,parsed from STEP text; machine-translated
|
||||
021,上铝板,Upper Aluminum Plate,1,0,,5.0,5.0,5.0,parsed from STEP text; machine-translated
|
||||
022,BSCZ-TX3361,BSCZ-TX3361,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
023,BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,1,0,,5.0,5.0,5.0,parsed from STEP text
|
||||
024,15.6寸安装板,"15.6"" Mounting Plate",1,0,,5.0,5.0,5.0,parsed from STEP text; machine-translated
|
||||
025,15.6寸光感支架,"15.6"" Light Sensor Bracket",1,0,,5.0,5.0,5.0,parsed from STEP text; machine-translated
|
||||
026,15.6寸高铁显示屏总装图,"15.6"" High-Speed Rail Display Assembly Drawing",1,0,,5.0,5.0,5.0,parsed from STEP text; machine-translated
|
||||
027,15.6寸铝框,"15.6"" Aluminum Frame",1,0,,5.0,5.0,5.0,parsed from STEP text; machine-translated
|
||||
028,8欧5W喇叭(3070款),8Ω 5W Speaker (Model 3070),1,0,,5.0,5.0,5.0,parsed from STEP text; machine-translated
|
||||
|
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,32 @@
|
||||
part_number,part_name_original,part_name_english,quantity,level,parent,bbox_x_mm,bbox_y_mm,bbox_z_mm,notes
|
||||
001,TOSN-DY398P-EMC小板,TOSN-DY398P-EMC Small PCB Board,1,0,,397.2,718.6,40.0,parsed from STEP text; machine-translated
|
||||
002,BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,1,0,,390.9,652.3,6.0,parsed from STEP text
|
||||
003,光感支架,Light Sensor Bracket,1,0,,362.1,623.7,12.1,parsed from STEP text; machine-translated
|
||||
004,2P凤凰端子,2P Phoenix Connector Terminal,1,0,,389.8,711.2,23.1,parsed from STEP text; machine-translated
|
||||
005,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07.step,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07.step,1,0,,33.82,28.2,19.0,parsed from STEP text
|
||||
006,J7~XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,J7~XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,1,0,,48.37,103.79,16.25,parsed from STEP text
|
||||
007,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07,1,0,,110.0,49.0,8.6,parsed from STEP text
|
||||
008,DZ-LP6608 REV1.0,DZ-LP6608 REV1.0,1,0,,102.0,50.0,16.0,parsed from STEP text
|
||||
009,P270HVN03.0,P270HVN03.0,1,0,,386.0,483.0,22.6,parsed from STEP text
|
||||
010,Board~a9jc.step,Board~a9jc.step,1,0,,152.0,10.0,21.6,parsed from STEP text
|
||||
011,BSCZ-TX3361,BSCZ-TX3361,1,0,,33.0,73.0,14.7,parsed from STEP text
|
||||
012,J4~BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,J4~BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,2,0,,33.0,73.0,14.7,parsed from STEP text
|
||||
013,27寸高铁显示屏总装图,27-inch High-speed Rail Display Screen Assembly Drawing,1,0,,45.0,160.0,11.6,parsed from STEP text; machine-translated
|
||||
014,HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,1,0,,92.0,21.0,17.1,parsed from STEP text
|
||||
015,8欧5W喇叭(3070款),8Ω 5W Speaker (Model 3070),1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
016,27寸屏压件1,27-inch Screen Clamp 1,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
017,27寸上铝板,27-inch Upper Aluminum Panel,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
018,小玻璃镜片,Small Glass Mirror Lens,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
019,钢化玻璃3+3夹胶,Tempered Glass 3+3 Laminated,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
020,27寸屏压件2,27-inch Screen Clamp 2,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
021,遥控+光感板,Remote Control + Light Sensor Board,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
022,屏压件,Screen Clamp,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
023,27寸下铝板,27-inch Lower Aluminum Panel,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
024,后盖,Rear Cover,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
025,安装板,Mounting Plate,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
026,M3x6沉头螺丝,M3x6 Countersunk Screw,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
027,J5~HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,J5~HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,1,0,,6.0,6.0,5.0,parsed from STEP text
|
||||
028,端子板,Terminal Board,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
029,27寸铝框,27-inch Aluminum Frame,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
030,XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,1,0,,6.0,6.0,5.0,parsed from STEP text
|
||||
031,TOSN-AD120P12V10A-120W,TOSN-AD120P12V10A-120W,1,0,,6.0,6.0,5.0,parsed from STEP text
|
||||
|
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,32 @@
|
||||
part_number,part_name_original,part_name_english,quantity,level,parent,bbox_x_mm,bbox_y_mm,bbox_z_mm,notes
|
||||
001,J4~BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,J4~BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,2,0,,826.0,192.0,48.0,parsed from STEP text
|
||||
002,小玻璃镜片,Small Glass Lens,1,0,,39.85,185.7,9.0,parsed from STEP text; machine-translated
|
||||
003,8欧5W喇叭(3070款),8Ω 5W Speaker (Model 3070),1,0,,39.85,185.7,9.0,parsed from STEP text; machine-translated
|
||||
004,TOSN-AD120P12V10A-120W,TOSN-AD120P12V10A-120W,1,0,,759.6,185.7,6.0,parsed from STEP text
|
||||
005,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07,1,0,,821.4,187.4,23.1,parsed from STEP text
|
||||
006,28寸屏压件,"28"" Screen Pressure Clip",1,0,,12.0,28.0,1.1,parsed from STEP text; machine-translated
|
||||
007,28寸端子板,"28"" Terminal Board",1,0,,618.0,183.6,24.1,parsed from STEP text; machine-translated
|
||||
008,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07.step,3D_YZ-006-V3 HDMI TO 2SDI_2025-11-07.step,1,0,,12.3,65.0,14.0,parsed from STEP text
|
||||
009,XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,1,0,,21.0,92.0,19.2,parsed from STEP text
|
||||
010,TOSN-DY398P-EMC小板,TOSN-DY398P-EMC Subboard,1,0,,10.0,152.0,21.6,parsed from STEP text; machine-translated
|
||||
011,HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,1,0,,28.2,33.82,19.0,parsed from STEP text
|
||||
012,玻璃3+3,Glass 3+3,1,0,,103.79,48.37,16.25,parsed from STEP text; machine-translated
|
||||
013,BSCZ-TX3361,BSCZ-TX3361,1,0,,49.0,110.0,8.6,parsed from STEP text
|
||||
014,28寸后盖,"28"" Rear Cover",1,0,,102.0,50.0,16.0,parsed from STEP text; machine-translated
|
||||
015,28寸下铝板,"28"" Lower Aluminum Plate",1,0,,50.0,40.0,12.6,parsed from STEP text; machine-translated
|
||||
016,28寸高铁显示屏总装图,"28"" High-speed Rail Display Screen Assembly Drawing",1,0,,73.0,33.0,14.7,parsed from STEP text; machine-translated
|
||||
017,Board~a9jc.step,Board~a9jc.step,1,0,,73.0,33.0,14.7,parsed from STEP text
|
||||
018,jgj-hy0280HD03模组,jgj-hy0280HD03 Module,1,0,,95.0,42.0,8.5,parsed from STEP text; machine-translated
|
||||
019,遥控+光感板,Remote Control + Light Sensor Board,1,0,,59.0,79.0,39.2,parsed from STEP text; machine-translated
|
||||
020,2P凤凰端子,2P Phoenix Terminal,1,0,,29.0,21.0,29.7,parsed from STEP text; machine-translated
|
||||
021,BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,BNC-1~BNC-TH_BNC-50KWYE~a9jc.step,1,0,,29.0,21.0,24.9,parsed from STEP text
|
||||
022,J7~XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,J7~XH4X1-2.54~CONN-TH_4P-P2.50_MEGASTAR_ZX-XH2.54-4PZZ~a9jc.step,1,0,,722.0,154.6,16.8,parsed from STEP text
|
||||
023,28寸恒流板,"28"" Constant Current Board",1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
024,28寸条屏铝框,"28"" Strip Screen Aluminum Frame",1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
025,28寸屏压件2,"28"" Screen Pressure Clip 2",1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
026,M3x6沉头螺丝,M3x6 Countersunk Screw,1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
027,28寸上铝板,"28"" Upper Aluminum Plate",1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
028,28寸屏压件1,"28"" Screen Pressure Clip 1",1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
029,28寸光感支架,"28"" Light Sensor Bracket",1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
030,28寸安装板,"28"" Mounting Plate",1,0,,6.0,6.0,5.0,parsed from STEP text; machine-translated
|
||||
031,J5~HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,J5~HDMI_A_F_SMD_19P~HDMI-SMD_HDMI-HX-19D~a9jc.step,1,0,,6.0,6.0,5.0,parsed from STEP text
|
||||
|
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,392 @@
|
||||
# STEP Processor — macOS Setup Checklist
|
||||
### Apple Silicon (Mac Studio M-series) · No Rosetta Required
|
||||
|
||||
Work through this top to bottom. Each section has a ✅ verification command
|
||||
so you know it worked before moving on.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 — Prerequisites
|
||||
|
||||
### 1.1 — Confirm Python version
|
||||
|
||||
```bash
|
||||
python3 --version
|
||||
```
|
||||
|
||||
**You need Python 3.10, 3.11, or 3.12.**
|
||||
3.13 will work but is less tested. 3.9 is too old.
|
||||
|
||||
If your version is wrong, install via Homebrew:
|
||||
```bash
|
||||
brew install python@3.11
|
||||
```
|
||||
Then use `python3.11` explicitly in all commands below.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 — Confirm Homebrew is installed
|
||||
|
||||
```bash
|
||||
brew --version
|
||||
```
|
||||
|
||||
If missing:
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 — Create a dedicated virtual environment
|
||||
|
||||
Keep everything isolated from your system Python.
|
||||
Pick a folder — e.g. your CoWork dev directory.
|
||||
|
||||
```bash
|
||||
python3 -m venv ~/step-processor-env
|
||||
source ~/step-processor-env/bin/activate
|
||||
```
|
||||
|
||||
You should see `(step-processor-env)` in your terminal prompt.
|
||||
**All pip commands below assume this environment is active.**
|
||||
|
||||
✅ Verify:
|
||||
```bash
|
||||
which python # should point inside step-processor-env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 — Primary Library: build123d
|
||||
|
||||
### 2.1 — Upgrade pip first
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 — Install cadquery-ocp (the OCC kernel, native arm64)
|
||||
|
||||
This is now on PyPI directly — no manual wheel download needed.
|
||||
|
||||
```bash
|
||||
pip install cadquery-ocp
|
||||
```
|
||||
|
||||
This is a large download (~300MB). Let it run.
|
||||
|
||||
✅ Verify:
|
||||
```bash
|
||||
python -c "import OCP; print('OCP kernel OK')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 — Install build123d
|
||||
|
||||
```bash
|
||||
pip install build123d
|
||||
```
|
||||
|
||||
✅ Verify:
|
||||
```bash
|
||||
python -c "import build123d; print('build123d OK')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 — Install the rendering stack
|
||||
|
||||
```bash
|
||||
pip install trimesh pyrender Pillow numpy pandas anthropic openpyxl
|
||||
```
|
||||
|
||||
> **Note:** `openpyxl` is required for Excel BOM output. If missing, the skill silently
|
||||
> falls back to CSV. Install it here to get MPM-branded `.xlsx` output.
|
||||
|
||||
✅ Verify each:
|
||||
```bash
|
||||
python -c "import trimesh; print('trimesh OK')"
|
||||
python -c "import pyrender; print('pyrender OK')"
|
||||
python -c "import PIL; print('Pillow OK')"
|
||||
python -c "import pandas; print('pandas OK')"
|
||||
python -c "import anthropic; print('anthropic OK')"
|
||||
python -c "import openpyxl; print('openpyxl OK')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 — Diagram Export: cairosvg
|
||||
|
||||
Required for converting SVG diagrams to PNG and PDF.
|
||||
|
||||
### 3.1 — Install system libraries via Homebrew
|
||||
|
||||
```bash
|
||||
brew install cairo pango gdk-pixbuf libffi
|
||||
```
|
||||
|
||||
### 3.2 — Install cairosvg
|
||||
|
||||
```bash
|
||||
pip install cairosvg
|
||||
```
|
||||
|
||||
✅ Verify:
|
||||
```bash
|
||||
python -c "import cairosvg; print('cairosvg OK')"
|
||||
```
|
||||
|
||||
If you get a library not found error, run this and retry:
|
||||
```bash
|
||||
export PKG_CONFIG_PATH="/opt/homebrew/lib/pkgconfig"
|
||||
pip install cairosvg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4 — Fallback Library: FreeCAD Headless
|
||||
|
||||
Only needed if build123d fails on a specific file.
|
||||
|
||||
> **macOS 15 Sequoia note:** The conda-forge FreeCAD package is killed silently
|
||||
> by Gatekeeper on Sequoia due to unsigned binaries. Use the official signed
|
||||
> FreeCAD.app instead (approach below). Conda approach will NOT work on Sequoia.
|
||||
|
||||
### 4.1 — Install official FreeCAD.app (signed & notarized)
|
||||
|
||||
Download the latest arm64 release from:
|
||||
```
|
||||
https://github.com/FreeCAD/FreeCAD/releases/latest
|
||||
```
|
||||
|
||||
Open the .dmg and drag FreeCAD.app to /Applications as normal.
|
||||
Launch it once from /Applications so macOS registers the security approval.
|
||||
|
||||
### 4.2 — Verify headless via app bundle
|
||||
|
||||
The binary is `freecadcmd` (all lowercase) inside the bundle:
|
||||
|
||||
```bash
|
||||
/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd --version
|
||||
# Should print: FreeCAD 1.x.x Revision: ...
|
||||
```
|
||||
|
||||
✅ Verify Python import:
|
||||
```bash
|
||||
/Applications/FreeCAD.app/Contents/Resources/bin/python -c \
|
||||
"import sys; sys.path.insert(0, '/Applications/FreeCAD.app/Contents/Resources/lib'); \
|
||||
import FreeCAD; print('FreeCAD', FreeCAD.Version())"
|
||||
```
|
||||
|
||||
**Key paths for loader.py:**
|
||||
- CLI binary: `/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd`
|
||||
- Python interpreter: `/Applications/FreeCAD.app/Contents/Resources/bin/python`
|
||||
- Module path (add to sys.path): `/Applications/FreeCAD.app/Contents/Resources/lib`
|
||||
|
||||
The code invokes FreeCAD using the bundle's own Python — no conda env needed.
|
||||
|
||||
---
|
||||
|
||||
## PHASE 5 — API Key
|
||||
|
||||
### 5.1 — Set your Anthropic API key
|
||||
|
||||
```bash
|
||||
echo 'export ANTHROPIC_API_KEY="sk-ant-YOUR-KEY-HERE"' >> ~/.zshrc
|
||||
echo 'export ANTHROPIC_API_KEY="sk-ant-YOUR-KEY-HERE"' >> ~/.zshenv
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
> **Important:** Add the key to **both** `~/.zshrc` AND `~/.zshenv`.
|
||||
> `~/.zshrc` is only sourced in interactive terminal sessions.
|
||||
> `~/.zshenv` is sourced for all zsh invocations including Desktop Commander's
|
||||
> non-interactive shells — without it, translation will silently fail when
|
||||
> running via CoWork/Desktop Commander.
|
||||
|
||||
✅ Verify:
|
||||
```bash
|
||||
echo $ANTHROPIC_API_KEY # should print your key (or at least sk-ant-...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 6 — Place the Skill Files
|
||||
|
||||
### 6.1 — Copy the step-processor folder to your working location
|
||||
|
||||
Recommended: somewhere in your CoWork project or a dedicated tools directory.
|
||||
|
||||
```
|
||||
~/your-project/
|
||||
step-processor/
|
||||
step_processor.py
|
||||
modules/
|
||||
__init__.py
|
||||
loader.py
|
||||
bom.py
|
||||
renderer.py
|
||||
translator.py
|
||||
rewriter.py
|
||||
query_engine.py
|
||||
external_diagram.py
|
||||
schemas/
|
||||
external_diagram_schema.json
|
||||
parts_mapping_schema.json
|
||||
templates/
|
||||
datablock_template.md
|
||||
SKILL.md
|
||||
INSTALL.md
|
||||
```
|
||||
|
||||
### 6.2 — Activate your environment before running
|
||||
|
||||
```bash
|
||||
source ~/step-processor-env/bin/activate
|
||||
cd ~/your-project/step-processor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 7 — Test Run (do this when you have a STEP file ready)
|
||||
|
||||
Work through these tests in order. Stop at the first failure and fix it
|
||||
before continuing.
|
||||
|
||||
---
|
||||
|
||||
### TEST 1 — Confirm the skill loads without errors
|
||||
|
||||
```bash
|
||||
python step_processor.py --help
|
||||
```
|
||||
|
||||
Expected: prints the CLI help/usage block with no ImportError or traceback.
|
||||
|
||||
---
|
||||
|
||||
### TEST 2 — Load a STEP file and extract BOM
|
||||
|
||||
Replace `yourfile.step` with your actual file path.
|
||||
|
||||
```bash
|
||||
python step_processor.py /path/to/yourfile.step --no-thumbnails --verbose
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
INFO Loading: yourfile.step
|
||||
INFO [build123d] Loaded: yourfile.step
|
||||
INFO BOM extracted: N parts
|
||||
INFO BOM XLSX → /path/to/yourfile_bom.xlsx
|
||||
```
|
||||
|
||||
Open the `.xlsx` file to confirm part names look right.
|
||||
If you see Chinese characters in the `Supplier Part Name` column — that's expected and correct.
|
||||
The `Part Description` column will be populated with English in Test 4.
|
||||
If you see a CSV instead of xlsx, `openpyxl` is not installed — run `pip install openpyxl`.
|
||||
|
||||
---
|
||||
|
||||
### TEST 3 — Generate thumbnails
|
||||
|
||||
```bash
|
||||
python step_processor.py /path/to/yourfile.step --no-bom --verbose
|
||||
```
|
||||
|
||||
Expected: 6 PNG files appear next to the STEP file:
|
||||
`yourfile_front.png`, `yourfile_bottom.png`, `yourfile_left.png`,
|
||||
`yourfile_right.png`, `yourfile_iso_left.png`, `yourfile_iso_right.png`
|
||||
|
||||
Open them in Preview to confirm they look like your enclosure from different angles.
|
||||
|
||||
---
|
||||
|
||||
### TEST 4 — Translation (requires API key)
|
||||
|
||||
Only run this if the BOM from Test 2 has Chinese part names.
|
||||
|
||||
```bash
|
||||
python step_processor.py /path/to/yourfile.step --no-thumbnails --translate --verbose
|
||||
```
|
||||
|
||||
Expected: `part_name_english` column in the CSV is now populated with English names.
|
||||
A `yourfile_EN.step` should appear if Chinese labels were found in the STEP file itself.
|
||||
|
||||
---
|
||||
|
||||
### TEST 5 — Geometry query REPL
|
||||
|
||||
```bash
|
||||
python step_processor.py /path/to/yourfile.step --no-thumbnails --no-bom --repl
|
||||
```
|
||||
|
||||
At the `>` prompt, try:
|
||||
```
|
||||
> bounding box
|
||||
> list all holes
|
||||
> all parts
|
||||
> help
|
||||
> exit
|
||||
```
|
||||
|
||||
Confirm the output tables look reasonable for your file.
|
||||
|
||||
---
|
||||
|
||||
### TEST 6 — External dimensional diagram
|
||||
|
||||
```bash
|
||||
python step_processor.py /path/to/yourfile.step --diagram --verbose
|
||||
```
|
||||
|
||||
Expected files next to the STEP:
|
||||
- `yourfile__external-diagram.svg`
|
||||
- `yourfile__external-diagram.png`
|
||||
- `yourfile__meta.json`
|
||||
|
||||
Open the SVG in a browser (drag and drop into Safari or Chrome) to inspect the diagram.
|
||||
Open the PNG for the rasterized version.
|
||||
Open the JSON to confirm the metadata block populated correctly.
|
||||
|
||||
---
|
||||
|
||||
### TEST 7 — Diagram with PDF output
|
||||
|
||||
```bash
|
||||
python step_processor.py /path/to/yourfile.step --diagram --diagram-pdf --verbose
|
||||
```
|
||||
|
||||
Expected: adds `yourfile__external-diagram.pdf` to the outputs.
|
||||
Open in Preview to confirm it renders cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference — Re-activating After Restart
|
||||
|
||||
Every time you open a new terminal session:
|
||||
```bash
|
||||
source ~/step-processor-env/bin/activate
|
||||
cd ~/your-project/step-processor
|
||||
export ANTHROPIC_API_KEY="sk-ant-..." # only if not already in ~/.zshrc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Quick Reference
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
| `ModuleNotFoundError: build123d` | `pip install build123d` (check env is activated) |
|
||||
| `ModuleNotFoundError: OCP` | `pip install cadquery-ocp` |
|
||||
| `ModuleNotFoundError: cairosvg` | `brew install cairo && pip install cairosvg` |
|
||||
| `pyrender` offscreen crash | Expected on some macOS setups — thumbnails will warn but diagram still generates |
|
||||
| BOM shows no parts / 1 part | Normal for single-body STEP files — the STEP text parser falls back to 1-row BOM |
|
||||
| Chinese labels not translated | Check `$ANTHROPIC_API_KEY` is set. If running via CoWork/Desktop Commander, add key to `~/.zshenv` not just `~/.zshrc` |
|
||||
| BOM output is CSV not xlsx | `openpyxl` not installed — `pip install openpyxl` with venv active |
|
||||
| `_EN.step` entity count mismatch warning | File has unusual STEP encoding — rewrite skipped, original untouched, BOM still correct |
|
||||
| FreeCAD fallback not working | Use signed FreeCAD.app — conda-forge is Gatekeeper-blocked on Sequoia. Path: `/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd` (all lowercase) |
|
||||
| Diagram SVG opens blank | Check `yourfile__meta.json` warnings array for geometry errors |
|
||||
@@ -0,0 +1,430 @@
|
||||
# STEP File Processor Skill
|
||||
|
||||
## Purpose
|
||||
This skill enables Claude to work with STEP/STP CAD files used in manufacturing.
|
||||
It provides:
|
||||
- PNG thumbnail extraction at 6 camera angles
|
||||
- Parts manifest / BOM export to CSV with automatic Chinese→English translation
|
||||
- Optional English-labeled STEP copy (source file never modified)
|
||||
- Natural language geometric query interface (holes, faces, dimensions, quantities)
|
||||
|
||||
Designed for MPMedia's display enclosure and mounting kit product lines.
|
||||
|
||||
---
|
||||
|
||||
## When to invoke this skill
|
||||
|
||||
Trigger on any of:
|
||||
- User provides a `.step` or `.stp` file path and asks to process, view, extract, or query it
|
||||
- User asks to "generate thumbnails" or "render views" of a STEP file
|
||||
- User asks to "extract BOM", "get parts list", or "show manifest" from a STEP file
|
||||
- User asks a geometric question about a STEP file: holes, faces, dimensions, quantities
|
||||
- User asks to "translate" a STEP file's part names
|
||||
- Claude Code context where a STEP file is present in the working directory
|
||||
|
||||
**Read this entire SKILL.md before writing any code.**
|
||||
|
||||
---
|
||||
|
||||
## Library Stack
|
||||
|
||||
### Primary: build123d
|
||||
- Modern OpenCASCADE Python wrapper; native Apple Silicon arm64 wheels
|
||||
- Clean, Pythonic API for STEP reading, assembly traversal, geometry queries
|
||||
- Offscreen PNG rendering via: build123d → export GLB/OBJ → trimesh → pyrender
|
||||
- Install: `pip install build123d trimesh pyrender pillow numpy pandas anthropic`
|
||||
- ARM64 OCP wheel required first — see INSTALL.md
|
||||
|
||||
### Fallback: FreeCAD headless
|
||||
- Native macOS-arm64 build available via conda-forge (verified Nov 2024)
|
||||
- Used when build123d fails to load or render a specific file
|
||||
- Invoked via subprocess calling `FreeCADCmd` or by importing `FreeCAD` + `Part`
|
||||
- **Never import FreeCADGui** — headless only, GUI modules will crash
|
||||
- Install: `conda install -c conda-forge freecad` (arm64 native, no Rosetta)
|
||||
|
||||
### Translation: Claude API (claude-sonnet-4-20250514)
|
||||
- Translates Chinese part names with manufacturing context awareness
|
||||
- e.g. 安装支架 → "Mounting Bracket" (not just "installation support")
|
||||
- Requires ANTHROPIC_API_KEY in environment
|
||||
|
||||
### Supporting libraries (both tracks)
|
||||
- `Pillow` — PNG save/compose
|
||||
- `numpy` — geometry math
|
||||
- `pandas` — CSV/BOM output
|
||||
- `trimesh` + `pyrender` — GLB→PNG rendering fallback path
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
step-processor/
|
||||
step_processor.py ← CLI entry point + REPL
|
||||
modules/
|
||||
loader.py ← STEP loading (build123d primary, FreeCAD fallback)
|
||||
bom.py ← Assembly tree traversal + BOM extraction
|
||||
renderer.py ← Offscreen PNG rendering (6 views)
|
||||
translator.py ← Claude API translation layer
|
||||
rewriter.py ← STEP label patcher (_EN copy only, never modifies source)
|
||||
query_engine.py ← Geometric query handler (holes, faces, dimensions)
|
||||
INSTALL.md
|
||||
SKILL.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dual-Track Fallback Pattern
|
||||
|
||||
Every module must implement this pattern:
|
||||
|
||||
```python
|
||||
def load_step(filepath):
|
||||
try:
|
||||
return _load_via_build123d(filepath)
|
||||
except ImportError:
|
||||
logger.warning("build123d not available — falling back to FreeCAD")
|
||||
return _load_via_freecad(filepath)
|
||||
except Exception as e:
|
||||
logger.warning(f"build123d failed ({e}) — falling back to FreeCAD")
|
||||
return _load_via_freecad(filepath)
|
||||
```
|
||||
|
||||
Always log fallbacks at WARNING level so the user knows which path ran.
|
||||
|
||||
FreeCAD fallback invocation pattern (subprocess, cleanest isolation):
|
||||
```python
|
||||
import subprocess, json, tempfile
|
||||
|
||||
def _load_via_freecad(filepath):
|
||||
script = f"""
|
||||
import FreeCAD, Part, json
|
||||
shape = Part.read("{filepath}")
|
||||
# extract data, write to temp JSON
|
||||
"""
|
||||
result = subprocess.run(["FreeCADCmd", "--console", "-c", script], capture_output=True)
|
||||
# parse result...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Camera Views
|
||||
|
||||
Always produce these 6 views unless user specifies otherwise:
|
||||
|
||||
| View Name | Camera Direction | File Suffix |
|
||||
|------------|---------------------------|------------------|
|
||||
| Front | +Y looking toward origin | `_front.png` |
|
||||
| Bottom | -Z looking up | `_bottom.png` |
|
||||
| Left | -X looking right | `_left.png` |
|
||||
| Right | +X looking left | `_right.png` |
|
||||
| Iso Left | +X+Y+Z (left-forward) | `_iso_left.png` |
|
||||
| Iso Right | -X+Y+Z (right-forward) | `_iso_right.png` |
|
||||
|
||||
Default resolution: 1024×768. All PNGs saved to same folder as source STEP file.
|
||||
|
||||
---
|
||||
|
||||
## Output Naming Convention
|
||||
|
||||
Given input: `/path/to/EnclosureA.step`
|
||||
|
||||
| Output | Path |
|
||||
|---------------------|-----------------------------------|
|
||||
| Thumbnails (×6) | `/path/to/EnclosureA_front.png` etc. |
|
||||
| BOM CSV | `/path/to/EnclosureA_bom.csv` |
|
||||
| English STEP copy | `/path/to/EnclosureA_EN.step` |
|
||||
|
||||
**Never overwrite or modify the source STEP file.**
|
||||
|
||||
`_EN.step` is only created when Chinese labels are detected. If all labels are
|
||||
already ASCII/English, skip silently and note it in output.
|
||||
|
||||
---
|
||||
|
||||
## BOM CSV Schema
|
||||
|
||||
```
|
||||
part_number, part_name_original, part_name_english, quantity, level, parent,
|
||||
bbox_x_mm, bbox_y_mm, bbox_z_mm, notes
|
||||
```
|
||||
|
||||
- `level` = assembly depth (0 = root, 1 = direct children, etc.)
|
||||
- `parent` = part_name_english of parent assembly
|
||||
- `notes` = translator flags (e.g. "machine-translated", "ambiguous term")
|
||||
- Bounding boxes in millimeters, 2 decimal places
|
||||
- If no translation needed, `part_name_original` == `part_name_english`
|
||||
|
||||
---
|
||||
|
||||
## Translation Logic
|
||||
|
||||
```
|
||||
bom.py extracts all part names
|
||||
→ translator.py checks each name for CJK unicode range (\u4e00-\u9fff)
|
||||
→ if any found: batch call Claude API with this system prompt:
|
||||
|
||||
"You are a mechanical engineering translator specializing in Chinese
|
||||
manufacturing CAD files for display and enclosure products. Translate
|
||||
the following part names from Chinese to English. Preserve technical
|
||||
precision. Use standard hardware terminology. Output ONLY a JSON object
|
||||
mapping original → translated, nothing else.
|
||||
Example: {\"安装支架\": \"Mounting Bracket\", \"螺钉M4\": \"M4 Screw\"}"
|
||||
|
||||
→ inject translations into BOM CSV
|
||||
→ if translation requested: produce _EN.step via rewriter.py
|
||||
```
|
||||
|
||||
Flag uncertain translations in the `notes` column rather than silently guessing.
|
||||
|
||||
---
|
||||
|
||||
## STEP Label Rewriting (_EN copy)
|
||||
|
||||
`rewriter.py` produces a translated copy — line-by-line, targeted replacement:
|
||||
|
||||
1. Read source file line by line
|
||||
2. Identify lines containing `PRODUCT('`, `PRODUCT_DEFINITION_CONTEXT(` etc.
|
||||
3. Extract quoted name strings (first argument of PRODUCT entity)
|
||||
4. Replace CJK strings with translated equivalents from the translation map
|
||||
5. Write to `{filename}_EN.step`
|
||||
6. Validate: entity `#` count before == after. If mismatch → abort, warn, delete partial output
|
||||
|
||||
**Do not use broad regex that could touch entity reference numbers (#123 etc.)**
|
||||
Target only the quoted string arguments of known STEP entity types.
|
||||
|
||||
---
|
||||
|
||||
## Geometric Query Engine
|
||||
|
||||
`query_engine.py` handles natural language queries about loaded geometry.
|
||||
|
||||
### Supported Query Types
|
||||
|
||||
| Query pattern | What it extracts |
|
||||
|----------------------------------|-------------------------------------------------------|
|
||||
| "list all mounting holes" | Cylindrical faces, axis ⊥ to primary face, dia < 15mm|
|
||||
| "list all holes" | All cylindrical through-features |
|
||||
| "holes diameter [N]mm" | Filter holes by diameter |
|
||||
| "tapped holes" / "threaded" | Cylinders with helical features (if present in model) |
|
||||
| "face count" | Total face count by type (planar, cylindrical, etc.) |
|
||||
| "bounding box" | Overall model extents in mm |
|
||||
| "part [name] dimensions" | Bounding box of a specific named part |
|
||||
| "largest face" | Largest planar face area in mm² |
|
||||
| "wall thickness" | Min distance between opposing parallel faces |
|
||||
| "all parts" | Full assembly listing with quantities |
|
||||
|
||||
### Query Invocation
|
||||
|
||||
**CLI (single query, exit after):**
|
||||
```bash
|
||||
python step_processor.py enclosure.step --query "list all mounting holes with diameter and depth"
|
||||
```
|
||||
|
||||
**REPL (interactive, geometry stays loaded in memory between queries):**
|
||||
```bash
|
||||
python step_processor.py enclosure.step --repl
|
||||
> list mounting holes
|
||||
> bounding box
|
||||
> how many M4 holes
|
||||
> exit
|
||||
```
|
||||
|
||||
### Query Output Format
|
||||
|
||||
Always return a structured table:
|
||||
```
|
||||
MOUNTING HOLES — EnclosureA.step
|
||||
─────────────────────────────────────────────────────────────
|
||||
# Part Name Diameter Depth Position (x,y,z)
|
||||
─────────────────────────────────────────────────────────────
|
||||
1 Front Panel 4.20 mm 8.00 mm (12.50, 0.00, 45.20)
|
||||
2 Front Panel 4.20 mm 8.00 mm (87.50, 0.00, 45.20)
|
||||
─────────────────────────────────────────────────────────────
|
||||
Total: 8 holes across 3 parts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
python step_processor.py <file.step> [options]
|
||||
|
||||
Options:
|
||||
--thumbnails Generate 6 PNG views (default: on)
|
||||
--no-thumbnails Skip PNG generation
|
||||
--bom Export BOM CSV (default: on)
|
||||
--no-bom Skip BOM export
|
||||
--translate Auto-translate Chinese labels in BOM + produce _EN.step
|
||||
--no-translate Skip translation even if Chinese labels detected
|
||||
--query "..." Run a single geometric query and exit
|
||||
--repl Enter interactive query REPL
|
||||
--resolution WxH Thumbnail resolution (default: 1024x768)
|
||||
--views front,iso_left,iso_right Comma-separated subset of views
|
||||
--verbose Show library selection, fallback notices, timing
|
||||
```
|
||||
|
||||
**Default behavior (no flags):** `--thumbnails --bom` plus auto-detect translation.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Condition | Behavior |
|
||||
|------------------------------------|--------------------------------------------------------|
|
||||
| File not found | Exit with clear message, no partial output |
|
||||
| Neither library available | Exit with INSTALL.md reference |
|
||||
| build123d render fails | Auto-fallback to FreeCAD path, log warning |
|
||||
| Translation API unavailable | Write BOM with original names only, note in output |
|
||||
| STEP has no assembly structure | Treat as single part, one-row BOM |
|
||||
| Chinese detected, no translate flag| Auto-translate by default (skip with --no-translate) |
|
||||
| _EN.step entity count mismatch | Abort rewrite, warn, delete partial file |
|
||||
| FreeCAD headless GUI module import | Catch ImportError, skip that module, continue |
|
||||
|
||||
---
|
||||
|
||||
## INSTALL.md Summary
|
||||
|
||||
**build123d (primary — native arm64, no conda):**
|
||||
```bash
|
||||
# 1. Install the arm64 OCP wheel from CadQuery GitHub releases:
|
||||
# https://github.com/CadQuery/OCP/releases — pick macosx_*_arm64.whl for your Python
|
||||
pip install path/to/cadquery_ocp-*.whl
|
||||
|
||||
# 2. Install build123d and rendering stack
|
||||
pip install build123d trimesh pyrender pillow numpy pandas anthropic
|
||||
|
||||
# 3. Set API key
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
**FreeCAD (fallback — native arm64 via conda):**
|
||||
```bash
|
||||
# Install Miniforge arm64 if not already present:
|
||||
# https://github.com/conda-forge/miniforge/releases
|
||||
|
||||
conda install -c conda-forge freecad
|
||||
# Verify headless works:
|
||||
FreeCADCmd --version
|
||||
```
|
||||
|
||||
No Rosetta. No x64 sub-environment. Both libraries run natively on Apple Silicon.
|
||||
|
||||
---
|
||||
|
||||
## Notes for Claude
|
||||
|
||||
- Default run = thumbnails + BOM + auto-translate if Chinese detected
|
||||
- When a user asks a geometry question, invoke query_engine — don't describe the STEP file from the BOM alone
|
||||
- Always confirm output file locations at end of run
|
||||
- If Chinese labels found, proactively note it and offer to show the translation map
|
||||
- In REPL mode, keep responses tight — it's a terminal session
|
||||
- When describing holes or geometry, lead with the table, not prose
|
||||
- If build123d fallback fires, tell the user which library ran
|
||||
|
||||
---
|
||||
|
||||
## Sub-skill: External Dimensional Diagram
|
||||
|
||||
### Purpose
|
||||
Generates a standardized external dimensional diagram for installation planning,
|
||||
product reference, and non-CAD users. NOT a manufacturing drawing.
|
||||
Think: installation reference sheet with clean orthographic views + one ISO.
|
||||
|
||||
### Terminology
|
||||
Use: "external dimensional diagram"
|
||||
Never use: shop drawing, manufacturing drawing, fabrication drawing
|
||||
|
||||
### Entry point
|
||||
```python
|
||||
from modules.external_diagram import step_external_diagram
|
||||
|
||||
meta = step_external_diagram(
|
||||
path="enclosure.step",
|
||||
mode="enclosure_only", # or enclosure_plus_mounting, mounting_only
|
||||
mapping_file="MR28UW_mapping.json", # optional
|
||||
datablock_file="MR28UW.md", # optional, auto-detected if omitted
|
||||
options={
|
||||
"style": "line_drawing", # or "rendered" — auto if omitted
|
||||
"pdf": True, # default False
|
||||
"mounting_variant": "Wall Mount", # for enclosure_plus_mounting mode
|
||||
"render_variants": True, # generate diagram per mounting variant
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Modes
|
||||
- `enclosure_only` — enclosure body only (default when no mapping file)
|
||||
- `enclosure_plus_mounting` — enclosure + selected mounting subassembly
|
||||
- `mounting_only` — mounting geometry only
|
||||
|
||||
### Styles
|
||||
- `line_drawing` — pure SVG wireframe (MR28 reference style). Auto-selected for simple enclosures.
|
||||
- `rendered` — rendered ISO views composited with dimensioned orthographic line views
|
||||
(MR16 reference style). Auto-selected for complex assemblies (>200 faces).
|
||||
|
||||
### Layout auto-selection
|
||||
- `single_sheet` — default for most models
|
||||
- `multi_page` — auto-selected when any dimension > 1200mm or part count > 60
|
||||
|
||||
### Outputs per run
|
||||
| File | Always? | Description |
|
||||
|------|---------|-------------|
|
||||
| `{stem}__external-diagram.svg` | Yes | Source SVG, always retained |
|
||||
| `{stem}__external-diagram.png` | Yes | PNG export via cairosvg |
|
||||
| `{stem}__external-diagram.pdf` | Optional (`--pdf` flag) | PDF via cairosvg |
|
||||
| `{stem}__meta.json` | Yes | Full metadata per schema |
|
||||
| Individual view PNGs | Rendered style only | front, side, rear, iso |
|
||||
| Variant diagrams | If `render_variants=True` | One set per mounting variant |
|
||||
|
||||
### Dimensions shown
|
||||
Required: overall width, height, depth
|
||||
Auto-detected and shown when found:
|
||||
- Active area / screen aperture (with diagonal in mm and inches)
|
||||
- Mounting hole chain spacing (horizontal or vertical, auto-selected by readability)
|
||||
- VESA pattern if detected
|
||||
- Edge offsets to mounting geometry
|
||||
Units: metric primary (mm) with imperial in parentheses, smaller italic
|
||||
|
||||
### Parts mapping file
|
||||
JSON file per product model — classifies parts as enclosure/mounting/internal/fastener.
|
||||
Drives show/hide logic per mode. Supports mounting variants.
|
||||
Schema: `schemas/parts_mapping_schema.json`
|
||||
Example: `schemas/parts_mapping_schema.json` → `examples` array
|
||||
|
||||
### Datablock / title block
|
||||
Sourced from a `.md` file alongside the STEP file (auto-detected by matching stem name)
|
||||
or passed explicitly as `datablock_file`.
|
||||
Template: `templates/datablock_template.md`
|
||||
Edit the template to configure: model number, display name, revision, date, company,
|
||||
and any custom fields. Custom fields appear in the data block footer.
|
||||
|
||||
### Metadata schema
|
||||
Full JSON schema: `schemas/external_diagram_schema.json`
|
||||
Designed for AI consumption, Odoo product data integration, search indexing,
|
||||
and CoWork documentation workflows. All geometry in mm.
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
# Basic — enclosure only, auto style
|
||||
python step_processor.py enclosure.step --diagram
|
||||
|
||||
# With options
|
||||
python step_processor.py enclosure.step --diagram --diagram-mode enclosure_plus_mounting \
|
||||
--diagram-style line_drawing --diagram-pdf \
|
||||
--mapping mapping/MR28UW_mapping.json \
|
||||
--datablock MR28UW.md
|
||||
|
||||
# Render all mounting variants
|
||||
python step_processor.py enclosure.step --diagram --diagram-mode enclosure_plus_mounting \
|
||||
--diagram-variants
|
||||
```
|
||||
|
||||
### Notes for Claude
|
||||
- When a user says "generate a diagram", "make a dimensional sheet", or "create reference drawing" → invoke this sub-skill
|
||||
- Always confirm mode with user if unclear (enclosure_only vs with mounting)
|
||||
- If no mapping file exists, default to enclosure_only and note it
|
||||
- If multiple mounting variants detected in assembly tree, offer to render each
|
||||
- The meta.json output is the primary artifact for downstream AI/workflow consumption
|
||||
- cairosvg is required for PNG/PDF export: `pip install cairosvg`
|
||||
- Weight/mass fields in meta.json are reserved for future Odoo product data integration
|
||||
@@ -0,0 +1 @@
|
||||
# step-processor modules package
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
bom.py — BOM extraction from STEP assembly tree.
|
||||
|
||||
Primary: build123d assembly traversal.
|
||||
Fallback: STEP ISO 10303-21 text parser for PRODUCT entities.
|
||||
Always produces a complete DataFrame; saved as MPM-branded Excel (.xlsx).
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .loader import StepModel
|
||||
|
||||
logger = logging.getLogger("step_processor.bom")
|
||||
|
||||
BOM_COLUMNS = [
|
||||
"part_number", "part_name_original", "part_name_english",
|
||||
"quantity", "level", "parent",
|
||||
"bbox_x_mm", "bbox_y_mm", "bbox_z_mm", "notes"
|
||||
]
|
||||
|
||||
# ── Excel output — MPM brand palette (hex, no #) ─────────────────────────────
|
||||
_MPM_DARK_SHADE = "232022" # header background + body text
|
||||
_MPM_LIGHT_SHADE = "F5F1EC" # header text
|
||||
_MPM_WARM_OFF_WHITE = "FAF7F2" # alternating row tint
|
||||
_MPM_MIDDLE_GOLD = "DCBB4F" # accent border under header row
|
||||
|
||||
# Column rename + reorder for stakeholder-facing Excel output.
|
||||
# Internal processing always uses BOM_COLUMNS names.
|
||||
_XLSX_RENAME = {
|
||||
"part_name_english": "part_description",
|
||||
"part_name_original": "part_name_supplier",
|
||||
}
|
||||
_XLSX_ORDER = [
|
||||
"part_number", "part_description", "quantity", "level", "parent",
|
||||
"bbox_x_mm", "bbox_y_mm", "bbox_z_mm", "notes", "part_name_supplier",
|
||||
]
|
||||
_XLSX_HEADERS = {
|
||||
"part_number": "Part #",
|
||||
"part_description": "Part Description",
|
||||
"quantity": "Qty",
|
||||
"level": "Level",
|
||||
"parent": "Parent",
|
||||
"bbox_x_mm": "X (mm)",
|
||||
"bbox_y_mm": "Y (mm)",
|
||||
"bbox_z_mm": "Z (mm)",
|
||||
"notes": "Notes",
|
||||
"part_name_supplier": "Supplier Part Name",
|
||||
}
|
||||
_XLSX_WIDTHS = {
|
||||
"part_number": 12, "part_description": 40, "quantity": 8,
|
||||
"level": 7, "parent": 22, "bbox_x_mm": 11, "bbox_y_mm": 11,
|
||||
"bbox_z_mm": 11, "notes": 34, "part_name_supplier": 40,
|
||||
}
|
||||
|
||||
|
||||
def _safe(v):
|
||||
"""Convert NaN/None → None so openpyxl writes blank cells."""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
if isinstance(v, float) and math.isnan(v):
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
return v
|
||||
|
||||
|
||||
def extract_bom(model: StepModel) -> pd.DataFrame:
|
||||
"""Extract BOM from a loaded StepModel. Returns DataFrame with BOM_COLUMNS.
|
||||
|
||||
Name-extraction strategy
|
||||
------------------------
|
||||
The STEP text parser is always the primary source for part_name_original.
|
||||
It reads raw bytes with GBK/UTF-8 encoding detection, correctly decoding
|
||||
Chinese CAD part labels.
|
||||
|
||||
OCC's STEP reader (used by build123d) applies an internal codec that maps
|
||||
each 2-byte GBK sequence to an incorrect Unicode codepoint — the resulting
|
||||
strings cannot be recovered. We therefore never rely on child.label for
|
||||
part names when the file may contain CJK characters.
|
||||
|
||||
OCC assembly walk (_bom_from_parts) is kept as a fallback only for files
|
||||
where the text parser returns nothing (e.g., non-PRODUCT-entity STEP files).
|
||||
"""
|
||||
rows = []
|
||||
|
||||
# Primary: STEP text parser — encoding-aware, correct for ASCII and CJK files
|
||||
rows = _bom_from_step_text(model.path)
|
||||
|
||||
if not rows and model.backend == "build123d" and model.parts:
|
||||
# Fallback: OCC assembly walk (CJK names will be garbled but structure intact)
|
||||
logger.debug("STEP text parser empty — falling back to OCC assembly walk")
|
||||
rows = _bom_from_parts(model.parts)
|
||||
|
||||
if not rows:
|
||||
logger.info("No assembly structure — treating as single part")
|
||||
stem = model.path.stem
|
||||
rows = [{"part_number": "001", "part_name_original": stem,
|
||||
"part_name_english": stem, "quantity": 1, "level": 0,
|
||||
"parent": "", "bbox_x_mm": None, "bbox_y_mm": None,
|
||||
"bbox_z_mm": None, "notes": "single-body file"}]
|
||||
df = pd.DataFrame(rows, columns=BOM_COLUMNS)
|
||||
if model.backend == "build123d":
|
||||
df = _enrich_bboxes(model, df)
|
||||
logger.info(f"BOM extracted: {len(df)} parts")
|
||||
return df
|
||||
|
||||
|
||||
def _bom_from_parts(parts: list) -> list:
|
||||
name_counts = Counter(p["name"] for p in parts)
|
||||
seen = set()
|
||||
rows = []
|
||||
for i, p in enumerate(parts):
|
||||
name = p["name"]
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
rows.append({
|
||||
"part_number": f"{len(rows)+1:03d}",
|
||||
"part_name_original": name,
|
||||
"part_name_english": name,
|
||||
"quantity": name_counts[name],
|
||||
"level": p.get("level", 0),
|
||||
"parent": p.get("parent", ""),
|
||||
"bbox_x_mm": None, "bbox_y_mm": None, "bbox_z_mm": None,
|
||||
"notes": "",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _read_step_text(step_path: Path) -> str:
|
||||
"""Read STEP file text with CJK-aware encoding detection.
|
||||
|
||||
STEP files from Chinese manufacturers embed raw GBK bytes in name strings.
|
||||
Strategy: try UTF-8 first (correct for modern files); if replacement chars
|
||||
appear, retry as GBK (covers Chinese CAD exports); fall back to latin-1
|
||||
which never fails (may contain mojibake, but at least it's readable).
|
||||
"""
|
||||
for enc in ('utf-8', 'gbk'):
|
||||
try:
|
||||
text = step_path.read_text(encoding=enc)
|
||||
if enc == 'utf-8' and '�' in text:
|
||||
# Replacement chars detected — GBK bytes can't be UTF-8
|
||||
continue
|
||||
return text
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
return step_path.read_text(encoding='latin-1', errors='replace')
|
||||
|
||||
|
||||
def _bom_from_step_text(step_path: Path) -> list:
|
||||
"""Parse STEP ISO 10303-21 PRODUCT entities directly."""
|
||||
try:
|
||||
text = _read_step_text(step_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read STEP text: {e}")
|
||||
return []
|
||||
pattern = re.compile(r"#\d+\s*=\s*PRODUCT\s*\(\s*'([^']*)'", re.IGNORECASE)
|
||||
seen = {}
|
||||
for match in pattern.finditer(text):
|
||||
name = match.group(1).strip()
|
||||
if not name or name.upper() in ("", "NONE"):
|
||||
continue
|
||||
if name in seen:
|
||||
seen[name]["quantity"] += 1
|
||||
else:
|
||||
seen[name] = {
|
||||
"part_number": f"{len(seen)+1:03d}",
|
||||
"part_name_original": name, "part_name_english": name,
|
||||
"quantity": 1, "level": 0, "parent": "",
|
||||
"bbox_x_mm": None, "bbox_y_mm": None, "bbox_z_mm": None,
|
||||
"notes": "parsed from STEP text",
|
||||
}
|
||||
rows = list(seen.values())
|
||||
if rows:
|
||||
logger.info(f"STEP text parser found {len(rows)} unique part names")
|
||||
return rows
|
||||
|
||||
|
||||
def _enrich_bboxes(model: StepModel, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Add bounding box dims per part from build123d. Best-effort."""
|
||||
try:
|
||||
bb = model.shape.bounding_box()
|
||||
if len(df) == 1:
|
||||
df.at[0, "bbox_x_mm"] = round(bb.size.X, 2)
|
||||
df.at[0, "bbox_y_mm"] = round(bb.size.Y, 2)
|
||||
df.at[0, "bbox_z_mm"] = round(bb.size.Z, 2)
|
||||
else:
|
||||
children = getattr(model.shape, "children", []) or []
|
||||
for i, child in enumerate(children):
|
||||
if i >= len(df):
|
||||
break
|
||||
try:
|
||||
cb = child.bounding_box()
|
||||
df.at[i, "bbox_x_mm"] = round(cb.size.X, 2)
|
||||
df.at[i, "bbox_y_mm"] = round(cb.size.Y, 2)
|
||||
df.at[i, "bbox_z_mm"] = round(cb.size.Z, 2)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"bbox enrichment skipped: {e}")
|
||||
return df
|
||||
|
||||
|
||||
def save_bom_csv(df: pd.DataFrame, step_path: Path) -> Path:
|
||||
"""Write BOM DataFrame to CSV (legacy fallback)."""
|
||||
out_path = step_path.parent / f"{step_path.stem}_bom.csv"
|
||||
df.to_csv(out_path, index=False)
|
||||
logger.info(f"BOM CSV → {out_path.name}")
|
||||
return out_path
|
||||
|
||||
|
||||
def save_bom_xlsx(df: pd.DataFrame, step_path: Path) -> Path:
|
||||
"""Write BOM DataFrame to an MPM-branded Excel workbook.
|
||||
|
||||
Column changes vs internal schema (BOM_COLUMNS):
|
||||
part_name_english → Part Description (column 2)
|
||||
part_name_original → Supplier Part Name (last column)
|
||||
Falls back to CSV if openpyxl is unavailable.
|
||||
"""
|
||||
out_path = step_path.parent / f"{step_path.stem}_bom.xlsx"
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
except ImportError:
|
||||
logger.warning("openpyxl not installed — falling back to CSV")
|
||||
return save_bom_csv(df, step_path)
|
||||
|
||||
# Build display DataFrame
|
||||
disp = df.rename(columns=_XLSX_RENAME).copy()
|
||||
for col in _XLSX_ORDER:
|
||||
if col not in disp.columns:
|
||||
disp[col] = None
|
||||
disp = disp[_XLSX_ORDER]
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Bill of Materials"
|
||||
|
||||
gold_border = Border(bottom=Side(style="medium", color=_MPM_MIDDLE_GOLD))
|
||||
hdr_fill = PatternFill("solid", fgColor=_MPM_DARK_SHADE)
|
||||
hdr_font = Font(name="Montserrat", bold=True, color=_MPM_LIGHT_SHADE, size=10)
|
||||
hdr_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
# Header row
|
||||
for c, col in enumerate(_XLSX_ORDER, 1):
|
||||
cell = ws.cell(row=1, column=c, value=_XLSX_HEADERS.get(col, col))
|
||||
cell.font = hdr_font
|
||||
cell.fill = hdr_fill
|
||||
cell.alignment = hdr_align
|
||||
cell.border = gold_border
|
||||
ws.column_dimensions[get_column_letter(c)].width = _XLSX_WIDTHS.get(col, 15)
|
||||
ws.row_dimensions[1].height = 28
|
||||
|
||||
# Data rows
|
||||
body_font = Font(name="Open Sans", size=10, color=_MPM_DARK_SHADE)
|
||||
body_align = Alignment(horizontal="left", vertical="center")
|
||||
for r, (_, row) in enumerate(disp.iterrows(), 2):
|
||||
fill = PatternFill("solid", fgColor=_MPM_WARM_OFF_WHITE if r % 2 == 0 else "FFFFFF")
|
||||
for c, col in enumerate(_XLSX_ORDER, 1):
|
||||
cell = ws.cell(row=r, column=c, value=_safe(row[col]))
|
||||
cell.font = body_font
|
||||
cell.fill = fill
|
||||
cell.alignment = body_align
|
||||
|
||||
ws.freeze_panes = "A2"
|
||||
wb.save(str(out_path))
|
||||
logger.info(f"BOM XLSX → {out_path.name}")
|
||||
return out_path
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
loader.py — STEP file loading with build123d primary and FreeCAD fallback.
|
||||
|
||||
Returns a StepModel dataclass used by all other modules.
|
||||
FreeCAD fallback invokes the signed app bundle Python to avoid
|
||||
Gatekeeper issues on macOS 15 Sequoia.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger("step_processor.loader")
|
||||
|
||||
FREECAD_PYTHON = "/Applications/FreeCAD.app/Contents/Resources/bin/python"
|
||||
FREECAD_LIB = "/Applications/FreeCAD.app/Contents/Resources/lib"
|
||||
FREECAD_CMD = "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepModel:
|
||||
"""Unified model object returned by load_step(). Used by all modules."""
|
||||
shape: Any
|
||||
backend: str # "build123d" | "freecad"
|
||||
path: Path
|
||||
parts: list = field(default_factory=list)
|
||||
face_count: int = 0
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def load_step(filepath) -> Optional["StepModel"]:
|
||||
"""Load a STEP file. Tries build123d first; falls back to FreeCAD."""
|
||||
step_path = Path(filepath).expanduser().resolve()
|
||||
if not step_path.exists():
|
||||
logger.error(f"File not found: {step_path}")
|
||||
return None
|
||||
try:
|
||||
return _load_via_build123d(step_path)
|
||||
except ImportError:
|
||||
logger.warning("build123d not available — falling back to FreeCAD")
|
||||
return _load_via_freecad(step_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"build123d failed ({type(e).__name__}: {e}) — falling back to FreeCAD")
|
||||
return _load_via_freecad(step_path)
|
||||
|
||||
|
||||
def _load_via_build123d(step_path: Path) -> "StepModel":
|
||||
"""Load using build123d. Raises on failure."""
|
||||
from build123d import import_step
|
||||
logger.info(f"[build123d] Loading: {step_path.name}")
|
||||
shape = import_step(str(step_path))
|
||||
face_count = 0
|
||||
try:
|
||||
face_count = sum(1 for _ in shape.faces())
|
||||
except Exception:
|
||||
pass
|
||||
parts = _extract_parts_build123d(shape)
|
||||
logger.info(f"[build123d] Loaded: {step_path.name} | {face_count} faces | {len(parts)} parts")
|
||||
return StepModel(shape=shape, backend="build123d", path=step_path,
|
||||
parts=parts, face_count=face_count)
|
||||
|
||||
|
||||
def _fix_gbk_mojibake(s: str) -> str:
|
||||
"""
|
||||
Recover Chinese text stored as mojibake in STEP part labels.
|
||||
|
||||
STEP files from Chinese CAD tools (SolidWorks CN, etc.) embed raw GBK bytes
|
||||
in PRODUCT name strings. OpenCASCADE reads STEP strings as latin-1, which
|
||||
re-interprets those GBK bytes as latin-1 code points — classic mojibake.
|
||||
|
||||
Fix: re-encode the string to latin-1 (restoring the original GBK byte
|
||||
sequence) then decode as GBK to get correct Unicode Chinese characters.
|
||||
|
||||
If the string is pure ASCII, or the round-trip fails (already valid Unicode
|
||||
or a non-GBK extended char), returns the original string unchanged.
|
||||
"""
|
||||
if not s or all(ord(c) < 128 for c in s):
|
||||
return s # pure ASCII: nothing to fix
|
||||
try:
|
||||
return s.encode('latin-1').decode('gbk')
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
return s # not GBK mojibake — leave original
|
||||
|
||||
|
||||
def _extract_parts_build123d(shape) -> list:
|
||||
"""Walk build123d compound tree and extract named parts."""
|
||||
parts = []
|
||||
def _walk(compound, level=0, parent_name=""):
|
||||
children = []
|
||||
try:
|
||||
children = compound.children if hasattr(compound, "children") else []
|
||||
except Exception:
|
||||
pass
|
||||
if children:
|
||||
for child in children:
|
||||
raw = (getattr(child, "label", "") or
|
||||
getattr(child, "name", "") or f"Part_{level}")
|
||||
name = _fix_gbk_mojibake(raw)
|
||||
parts.append({"name": name, "level": level, "parent": parent_name})
|
||||
_walk(child, level + 1, name)
|
||||
else:
|
||||
raw = (getattr(compound, "label", "") or
|
||||
getattr(compound, "name", "") or "")
|
||||
if raw:
|
||||
name = _fix_gbk_mojibake(raw)
|
||||
parts.append({"name": name, "level": level, "parent": parent_name})
|
||||
_walk(shape)
|
||||
return parts
|
||||
|
||||
|
||||
def _load_via_freecad(step_path: Path) -> Optional["StepModel"]:
|
||||
"""Load using FreeCAD app bundle Python via subprocess."""
|
||||
if not Path(FREECAD_PYTHON).exists():
|
||||
logger.error(f"FreeCAD Python not found at {FREECAD_PYTHON}. Install FreeCAD.app.")
|
||||
return None
|
||||
logger.info(f"[FreeCAD] Loading: {step_path.name}")
|
||||
script = f"""
|
||||
import sys, json
|
||||
sys.path.insert(0, {repr(FREECAD_LIB)})
|
||||
import FreeCAD, Part
|
||||
try:
|
||||
shape = Part.read({repr(str(step_path))})
|
||||
bb = shape.BoundBox
|
||||
sub = shape.SubShapes if hasattr(shape, 'SubShapes') else []
|
||||
parts = [{{"name": f"Part_{{i}}", "level": 1, "parent": "root"}}
|
||||
for i in range(len(sub))]
|
||||
print(json.dumps({{"ok": True, "face_count": len(shape.Faces),
|
||||
"parts": parts,
|
||||
"bbox": {{"XMin": bb.XMin, "XMax": bb.XMax,
|
||||
"YMin": bb.YMin, "YMax": bb.YMax,
|
||||
"ZMin": bb.ZMin, "ZMax": bb.ZMax}}}}))
|
||||
except Exception as e:
|
||||
print(json.dumps({{"ok": False, "error": str(e)}}))
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write(script)
|
||||
script_path = f.name
|
||||
|
||||
|
||||
try:
|
||||
proc = subprocess.run([FREECAD_PYTHON, script_path],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
json_line = next((l.strip() for l in proc.stdout.splitlines()
|
||||
if l.strip().startswith("{")), None)
|
||||
if not json_line:
|
||||
logger.error(f"[FreeCAD] No JSON output. stderr: {proc.stderr[:300]}")
|
||||
return None
|
||||
data = json.loads(json_line)
|
||||
if not data.get("ok"):
|
||||
logger.error(f"[FreeCAD] Load failed: {data.get('error')}")
|
||||
return None
|
||||
proxy = _FreeCADShapeProxy(data["bbox"], data["face_count"])
|
||||
logger.info(f"[FreeCAD] Loaded: {step_path.name} | {data['face_count']} faces")
|
||||
return StepModel(shape=proxy, backend="freecad", path=step_path,
|
||||
parts=data.get("parts", []),
|
||||
face_count=data["face_count"],
|
||||
metadata={"bbox": data["bbox"]})
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("[FreeCAD] Load timed out after 120s")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[FreeCAD] Unexpected error: {e}")
|
||||
return None
|
||||
finally:
|
||||
Path(script_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
class _FreeCADShapeProxy:
|
||||
"""Proxy carrying FreeCAD geometry data extracted via subprocess."""
|
||||
def __init__(self, bbox_dict: dict, face_count: int):
|
||||
self.BoundBox = _BoundBox(bbox_dict)
|
||||
self.face_count = face_count
|
||||
self.Faces = [None] * face_count
|
||||
def faces(self):
|
||||
for _ in range(self.face_count):
|
||||
yield object()
|
||||
|
||||
|
||||
class _BoundBox:
|
||||
def __init__(self, d: dict):
|
||||
self.XMin = d.get("XMin", 0); self.XMax = d.get("XMax", 0)
|
||||
self.YMin = d.get("YMin", 0); self.YMax = d.get("YMax", 0)
|
||||
self.ZMin = d.get("ZMin", 0); self.ZMax = d.get("ZMax", 0)
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
query_engine.py — Natural language geometric query handler.
|
||||
|
||||
Supports both single-query (--query "...") and interactive REPL (--repl).
|
||||
REPL keeps the model in memory between queries for speed.
|
||||
All output is formatted ASCII tables.
|
||||
|
||||
Supported query types (see SKILL.md for full reference):
|
||||
bounding box overall model extents
|
||||
face count total faces by type
|
||||
all parts full assembly listing
|
||||
list all holes all cylindrical through-features
|
||||
list all mounting holes cylinders dia < 15mm, axis ⊥ primary face
|
||||
holes diameter N filter by diameter
|
||||
wall thickness min distance between opposing parallel faces
|
||||
largest face largest planar face area
|
||||
help list supported queries
|
||||
exit / quit exit REPL
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import textwrap
|
||||
from typing import Optional
|
||||
|
||||
from .loader import StepModel
|
||||
|
||||
logger = logging.getLogger("step_processor.query")
|
||||
|
||||
# Regex patterns for query routing
|
||||
_BBOX_RE = re.compile(r"\b(bounding.?box|extents|dimensions|overall.?size)\b", re.I)
|
||||
_FACECOUNT_RE = re.compile(r"\b(face.?count|how many faces|number of faces)\b", re.I)
|
||||
_PARTS_RE = re.compile(r"\ball.?parts|part.?list|assembly|components\b", re.I)
|
||||
_HOLES_RE = re.compile(r"\bholes?\b", re.I)
|
||||
_MOUNTING_RE = re.compile(r"\bmounting\b", re.I)
|
||||
_DIA_RE = re.compile(r"diameter\s+([\d.]+)\s*mm?", re.I)
|
||||
_WALL_RE = re.compile(r"\bwall.?thickness\b", re.I)
|
||||
_LARGEST_RE = re.compile(r"\blargest.?face\b", re.I)
|
||||
_HELP_RE = re.compile(r"\bhelp\b", re.I)
|
||||
_EXIT_RE = re.compile(r"\b(exit|quit|q)\b", re.I)
|
||||
|
||||
|
||||
def run_query(model: StepModel, query: str) -> str:
|
||||
"""Dispatch a query string and return formatted output."""
|
||||
q = query.strip()
|
||||
if _EXIT_RE.search(q):
|
||||
return "EXIT"
|
||||
if _HELP_RE.search(q):
|
||||
return _help_text()
|
||||
if _BBOX_RE.search(q):
|
||||
return _query_bounding_box(model)
|
||||
if _FACECOUNT_RE.search(q):
|
||||
return _query_face_count(model)
|
||||
if _PARTS_RE.search(q):
|
||||
return _query_all_parts(model)
|
||||
if _HOLES_RE.search(q):
|
||||
dia_match = _DIA_RE.search(q)
|
||||
dia_filter = float(dia_match.group(1)) if dia_match else None
|
||||
mounting_only = bool(_MOUNTING_RE.search(q))
|
||||
return _query_holes(model, mounting_only=mounting_only, dia_filter=dia_filter)
|
||||
if _WALL_RE.search(q):
|
||||
return _query_wall_thickness(model)
|
||||
if _LARGEST_RE.search(q):
|
||||
return _query_largest_face(model)
|
||||
return (f"Query not recognized: '{q}'\n"
|
||||
f"Type 'help' to see supported queries.")
|
||||
|
||||
|
||||
def repl(model: StepModel, step_path):
|
||||
"""Launch interactive REPL. Returns when user types exit/quit."""
|
||||
print(f"\nSTEP Query REPL — {step_path.name}")
|
||||
print("Type 'help' for supported queries, 'exit' to quit.\n")
|
||||
while True:
|
||||
try:
|
||||
q = input("> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
if not q:
|
||||
continue
|
||||
result = run_query(model, q)
|
||||
if result == "EXIT":
|
||||
break
|
||||
print(result)
|
||||
print()
|
||||
|
||||
|
||||
# ── Query implementations ─────────────────────────────────────────────────────
|
||||
|
||||
def _query_bounding_box(model: StepModel) -> str:
|
||||
try:
|
||||
if model.backend == "build123d":
|
||||
bb = model.shape.bounding_box()
|
||||
x = round(bb.size.X, 2)
|
||||
y = round(bb.size.Y, 2)
|
||||
z = round(bb.size.Z, 2)
|
||||
else:
|
||||
bb = model.shape.bounding_box
|
||||
x = round(bb.XMax - bb.XMin, 2)
|
||||
y = round(bb.YMax - bb.YMin, 2)
|
||||
z = round(bb.ZMax - bb.ZMin, 2)
|
||||
return _table(
|
||||
f"BOUNDING BOX — {model.path.name}",
|
||||
["Axis", "Dimension"],
|
||||
[["Width (X)", f"{x} mm ({x/25.4:.3f} in)"],
|
||||
["Depth (Y)", f"{y} mm ({y/25.4:.3f} in)"],
|
||||
["Height (Z)", f"{z} mm ({z/25.4:.3f} in)"]]
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Bounding box query failed: {e}"
|
||||
|
||||
|
||||
def _query_face_count(model: StepModel) -> str:
|
||||
if model.backend != "build123d":
|
||||
return f"Face count query requires build123d (loaded via {model.backend})"
|
||||
try:
|
||||
from build123d import Compound
|
||||
all_faces = model.shape.faces()
|
||||
planar = sum(1 for f in all_faces if f.geom_type() == "PLANE")
|
||||
cylindrical = sum(1 for f in all_faces if f.geom_type() == "CYLINDER")
|
||||
other = len(all_faces) - planar - cylindrical
|
||||
return _table(
|
||||
f"FACE COUNT — {model.path.name}",
|
||||
["Type", "Count"],
|
||||
[["Planar", str(planar)],
|
||||
["Cylindrical", str(cylindrical)],
|
||||
["Other", str(other)],
|
||||
["Total", str(len(all_faces))]]
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Face count failed: {e}"
|
||||
|
||||
|
||||
def _query_all_parts(model: StepModel) -> str:
|
||||
if not model.parts:
|
||||
return (f"No assembly structure found in {model.path.name}.\n"
|
||||
f"File appears to be a single solid body.")
|
||||
rows = []
|
||||
for p in model.parts:
|
||||
rows.append([
|
||||
p.get("part_number", "—"),
|
||||
p.get("name", "—"),
|
||||
str(p.get("quantity", 1)),
|
||||
str(p.get("level", 0)),
|
||||
p.get("parent", ""),
|
||||
])
|
||||
return _table(
|
||||
f"ALL PARTS — {model.path.name}",
|
||||
["#", "Name", "Qty", "Level", "Parent"],
|
||||
rows
|
||||
) + f"\nTotal: {len(model.parts)} parts"
|
||||
|
||||
|
||||
def _query_holes(model: StepModel, mounting_only: bool = False,
|
||||
dia_filter: Optional[float] = None) -> str:
|
||||
if model.backend != "build123d":
|
||||
return f"Hole query requires build123d (loaded via {model.backend})"
|
||||
try:
|
||||
holes = _find_holes(model, mounting_only=mounting_only, dia_filter=dia_filter)
|
||||
if not holes:
|
||||
label = "mounting holes" if mounting_only else "holes"
|
||||
qualifier = f" ≈{dia_filter}mm" if dia_filter else ""
|
||||
return f"No {label}{qualifier} found in {model.path.name}."
|
||||
header = "MOUNTING HOLES" if mounting_only else "ALL HOLES"
|
||||
# Group by diameter bucket for summary view
|
||||
from collections import Counter
|
||||
dia_counts = Counter(round(h["dia"], 1) for h in holes)
|
||||
MAX_ROWS = 50
|
||||
display_holes = holes[:MAX_ROWS]
|
||||
rows = []
|
||||
for i, h in enumerate(display_holes, 1):
|
||||
rows.append([
|
||||
str(i),
|
||||
f"{h['dia']:.2f} mm",
|
||||
f"{h['depth']:.2f} mm" if h["depth"] else "—",
|
||||
f"({h['x']:.1f}, {h['y']:.1f}, {h['z']:.1f})",
|
||||
])
|
||||
result = _table(
|
||||
f"{header} — {model.path.name}",
|
||||
["#", "Diameter", "Depth", "Position (x,y,z)"],
|
||||
rows
|
||||
)
|
||||
result += f"\nShowing {len(display_holes)} of {len(holes)} unique hole locations"
|
||||
result += "\n\nDIAMETER SUMMARY"
|
||||
result += "\n" + "─" * 30
|
||||
for dia, count in sorted(dia_counts.items()):
|
||||
result += f"\n {dia:.1f} mm ×{count}"
|
||||
result += "\n" + "─" * 30
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Hole query failed: {e}"
|
||||
|
||||
|
||||
def _find_holes(model: StepModel, mounting_only: bool, dia_filter):
|
||||
"""Extract and deduplicate cylindrical faces from build123d model.
|
||||
|
||||
Deduplication: round axis position to 1mm grid, group by (dia_bucket, x, y, z).
|
||||
This collapses multiple cylindrical faces from the same physical hole
|
||||
(e.g. inner + outer surface of same cylinder) into one entry.
|
||||
"""
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Cylinder
|
||||
|
||||
seen = {} # key → best entry
|
||||
try:
|
||||
faces = model.shape.faces()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
for face in faces:
|
||||
try:
|
||||
adaptor = BRepAdaptor_Surface(face.wrapped)
|
||||
if adaptor.GetType() != GeomAbs_Cylinder:
|
||||
continue
|
||||
cyl = adaptor.Cylinder()
|
||||
r = cyl.Radius()
|
||||
dia = round(r * 2, 2)
|
||||
# Diameter filters
|
||||
if mounting_only and dia > 15.0:
|
||||
continue
|
||||
if dia_filter and abs(dia - dia_filter) > 0.5:
|
||||
continue
|
||||
axis_pt = cyl.Location()
|
||||
# Round to 1mm grid for deduplication
|
||||
gx = round(axis_pt.X())
|
||||
gy = round(axis_pt.Y())
|
||||
gz = round(axis_pt.Z())
|
||||
dia_bucket = round(dia, 1)
|
||||
key = (dia_bucket, gx, gy, gz)
|
||||
if key not in seen:
|
||||
bb = face.bounding_box()
|
||||
depth = round(max(bb.size.X, bb.size.Y, bb.size.Z), 2)
|
||||
seen[key] = {
|
||||
"dia": dia,
|
||||
"depth": depth,
|
||||
"x": round(axis_pt.X(), 1),
|
||||
"y": round(axis_pt.Y(), 1),
|
||||
"z": round(axis_pt.Z(), 1),
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def _query_wall_thickness(model: StepModel) -> str:
|
||||
if model.backend != "build123d":
|
||||
return f"Wall thickness query requires build123d (loaded via {model.backend})"
|
||||
try:
|
||||
faces = model.shape.faces()
|
||||
planar = [f for f in faces if f.geom_type() == "PLANE"]
|
||||
if len(planar) < 2:
|
||||
return "Insufficient planar faces to determine wall thickness."
|
||||
# Heuristic: find minimum non-zero distance between parallel opposing faces
|
||||
min_t = None
|
||||
for i, f1 in enumerate(planar):
|
||||
n1 = f1.normal_at()
|
||||
for f2 in planar[i+1:]:
|
||||
n2 = f2.normal_at()
|
||||
# Parallel if normals are anti-parallel
|
||||
dot = abs(n1.dot(n2))
|
||||
if dot > 0.99:
|
||||
c1 = f1.center()
|
||||
c2 = f2.center()
|
||||
dist = round(abs((c1 - c2).dot(n1)), 3)
|
||||
if dist > 0.01:
|
||||
if min_t is None or dist < min_t:
|
||||
min_t = dist
|
||||
if min_t is None:
|
||||
return "Could not determine wall thickness from available faces."
|
||||
return _table(
|
||||
f"WALL THICKNESS — {model.path.name}",
|
||||
["Measurement", "Value"],
|
||||
[["Minimum wall thickness",
|
||||
f"{min_t} mm ({min_t/25.4:.3f} in)"]]
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Wall thickness query failed: {e}"
|
||||
|
||||
|
||||
def _query_largest_face(model: StepModel) -> str:
|
||||
if model.backend != "build123d":
|
||||
return f"Largest face query requires build123d (loaded via {model.backend})"
|
||||
try:
|
||||
faces = model.shape.faces()
|
||||
planar = [(f, f.area()) for f in faces if f.geom_type() == "PLANE"]
|
||||
if not planar:
|
||||
return "No planar faces found."
|
||||
largest, area = max(planar, key=lambda x: x[1])
|
||||
bb = largest.bounding_box()
|
||||
return _table(
|
||||
f"LARGEST PLANAR FACE — {model.path.name}",
|
||||
["Property", "Value"],
|
||||
[["Area", f"{round(area, 2)} mm²"],
|
||||
["Width", f"{round(bb.size.X, 2)} mm"],
|
||||
["Height", f"{round(bb.size.Z, 2)} mm"]]
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Largest face query failed: {e}"
|
||||
|
||||
|
||||
# ── Formatting helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _table(title: str, headers: list, rows: list) -> str:
|
||||
col_widths = [len(h) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
col_widths[i] = max(col_widths[i], len(str(cell)))
|
||||
sep = "─" * (sum(col_widths) + 3 * len(headers) - 1)
|
||||
lines = [title, sep]
|
||||
header_line = " ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers))
|
||||
lines.append(header_line)
|
||||
lines.append(sep)
|
||||
for row in rows:
|
||||
lines.append(" ".join(str(cell).ljust(col_widths[i]) for i, cell in enumerate(row)))
|
||||
lines.append(sep)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _help_text() -> str:
|
||||
return textwrap.dedent("""\
|
||||
SUPPORTED QUERIES
|
||||
─────────────────────────────────────────────────────────────
|
||||
bounding box Overall model extents (W×D×H in mm)
|
||||
face count Faces by type (planar, cylindrical, other)
|
||||
all parts Full assembly listing with quantities
|
||||
list all holes All cylindrical through-features
|
||||
list all mounting holes Holes smaller than 15mm diameter
|
||||
holes diameter 4.2mm Filter holes by specific diameter
|
||||
wall thickness Minimum wall thickness estimate
|
||||
largest face Largest planar face area
|
||||
help Show this message
|
||||
exit Exit the REPL
|
||||
─────────────────────────────────────────────────────────────
|
||||
Tip: geometry queries require build123d backend.
|
||||
If the file loaded via FreeCAD fallback, only bounding box
|
||||
and parts list are available.""")
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
renderer.py — Offscreen PNG thumbnail generation.
|
||||
|
||||
Pipeline (with color): build123d → GLTF export → trimesh scene → pyrender → PNG
|
||||
Pipeline (fallback): build123d → per-solid STL → colored trimesh scene → pyrender → PNG
|
||||
|
||||
Coordinate convention AFTER STEP→GLTF export→trimesh load:
|
||||
trimesh applies a Z-up→Y-up GLTF convention that swaps STEP's Y and Z axes:
|
||||
X = width (~248mm for MR16) — left/right
|
||||
Y = depth (~41mm for MR16) — front/back; Y_min=screen face, Y_max=back panel
|
||||
Z = height (~459mm for MR16) — tall axis; Z_min=top end, Z_max=bottom end
|
||||
→ "front" camera sits at -Y (screen side) looking toward +Y to see the LCD face.
|
||||
→ World "up" vector is (0,0,-1) — negative Z = top of display in image.
|
||||
|
||||
6 standard views: front, rear, left, right, iso_left, iso_right
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .loader import StepModel
|
||||
|
||||
logger = logging.getLogger("step_processor.renderer")
|
||||
|
||||
# Camera direction vectors: where the camera is PLACED relative to model center.
|
||||
# Camera always looks toward center from direction * distance.
|
||||
#
|
||||
# Trimesh world axes after GLTF load (STEP Y and Z are swapped by GLTF Y-up conv):
|
||||
# X = width — left/right
|
||||
# Y = depth — Y_min = screen face (LCD), Y_max = back panel (ports)
|
||||
# Z = height — Z_min = TOP end of display, Z_max = BOTTOM end of display
|
||||
#
|
||||
# "front": camera at -Y (screen side) looks toward +Y → sees LCD face.
|
||||
# "rear": camera at +Y (back side) looks toward -Y → sees port panel.
|
||||
# "left": camera at -X looks toward +X → sees left edge.
|
||||
# "right": camera at +X looks toward -X → sees right edge.
|
||||
# iso views: -Y component keeps camera on screen side; -Z = toward top end.
|
||||
VIEW_CAMERAS = {
|
||||
"front": ( 0, -1, 0), # LCD screen face (Y_min side)
|
||||
"rear": ( 0, 1, 0), # back panel/ports (Y_max side)
|
||||
"left": (-1, 0, 0), # left edge (X_min side)
|
||||
"right": ( 1, 0, 0), # right edge (X_max side)
|
||||
"top": ( 0, 0, -1), # top end (Z_min side)
|
||||
"bottom": ( 0, 0, 1), # bottom end (Z_max side)
|
||||
"iso_left": (-1, -1, -0.5), # front-left-above: screen + left edge + top
|
||||
"iso_right": ( 1, -1, -0.5), # front-right-above: screen + right edge + top
|
||||
}
|
||||
DEFAULT_VIEWS = ["front", "rear", "left", "right", "iso_left", "iso_right"]
|
||||
|
||||
# Color palette for per-part coloring when GLTF has no embedded colors
|
||||
# 20 distinct RGBA colors (alpha=200 for slight transparency on overlaps)
|
||||
PART_COLORS = [
|
||||
[180, 180, 185, 255], # light steel
|
||||
[ 70, 130, 180, 255], # steel blue
|
||||
[205, 133, 63, 255], # peru / bronze
|
||||
[ 60, 179, 113, 255], # medium sea green
|
||||
[188, 143, 143, 255], # rosy brown
|
||||
[100, 149, 237, 255], # cornflower blue
|
||||
[255, 160, 50, 255], # dark orange
|
||||
[147, 112, 219, 255], # medium purple
|
||||
[ 46, 139, 87, 255], # sea green
|
||||
[205, 92, 92, 255], # indian red
|
||||
[135, 206, 235, 255], # sky blue
|
||||
[244, 164, 96, 255], # sandy brown
|
||||
[106, 90, 205, 255], # slate blue
|
||||
[ 32, 178, 170, 255], # light sea green
|
||||
[220, 20, 60, 255], # crimson
|
||||
[218, 165, 32, 255], # goldenrod
|
||||
[ 72, 61, 139, 255], # dark slate blue
|
||||
[143, 188, 143, 255], # dark sea green
|
||||
[255, 99, 71, 255], # tomato
|
||||
[176, 196, 222, 255], # light steel blue
|
||||
]
|
||||
DEFAULT_WIDTH = 1024
|
||||
DEFAULT_HEIGHT = 768
|
||||
|
||||
|
||||
def render_views(model: StepModel, step_path: Path,
|
||||
views=None, width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT) -> dict:
|
||||
"""Render PNG views. Returns dict of view_name → output Path."""
|
||||
views = views or DEFAULT_VIEWS
|
||||
stem = step_path.stem
|
||||
out_dir = step_path.parent
|
||||
results = {}
|
||||
mesh = _get_mesh(model, step_path)
|
||||
if mesh is None:
|
||||
logger.warning("Could not obtain mesh — thumbnails skipped")
|
||||
return results
|
||||
for view_name in views:
|
||||
if view_name not in VIEW_CAMERAS:
|
||||
logger.warning(f"Unknown view '{view_name}' — skipping")
|
||||
continue
|
||||
out_path = out_dir / f"{stem}_{view_name}.png"
|
||||
try:
|
||||
_render_single_view(mesh, VIEW_CAMERAS[view_name], out_path, width, height)
|
||||
results[view_name] = out_path
|
||||
logger.info(f"Rendered: {out_path.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Render failed for '{view_name}': {e}")
|
||||
return results
|
||||
|
||||
|
||||
def _get_mesh(model: StepModel, step_path: Path):
|
||||
"""Get a single assembled trimesh.Trimesh from the model.
|
||||
|
||||
Always returns a single concatenated mesh (not a Scene) so the camera
|
||||
distance and bounds calculations are correct and parts don't explode.
|
||||
|
||||
Priority:
|
||||
1. build123d → GLTF → scene.dump() → concatenate with transforms applied
|
||||
(preserves per-part colors if embedded in STEP)
|
||||
2. build123d → single STL of full assembly (fallback, monochrome but correct)
|
||||
3. FreeCAD path → bounding box box mesh
|
||||
"""
|
||||
try:
|
||||
import trimesh
|
||||
except ImportError:
|
||||
logger.warning("trimesh not installed — thumbnails unavailable. pip install trimesh")
|
||||
return None
|
||||
|
||||
if model.backend == "build123d":
|
||||
# ── GLTF path: color-aware, transforms flattened via scene.dump() ─────
|
||||
with tempfile.NamedTemporaryFile(suffix=".gltf", delete=False) as tmp:
|
||||
gltf_path = Path(tmp.name)
|
||||
try:
|
||||
from build123d import export_gltf
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
export_gltf(model.shape, str(gltf_path))
|
||||
scene = trimesh.load(str(gltf_path))
|
||||
|
||||
if isinstance(scene, trimesh.Scene) and scene.geometry:
|
||||
# scene.dump() applies the full transform graph → parts at correct positions
|
||||
dumped = scene.dump()
|
||||
if dumped:
|
||||
# Pull baseColorFactor directly from PBR material per mesh.
|
||||
# .to_color() loses this in some trimesh versions.
|
||||
materialized = []
|
||||
for m in dumped:
|
||||
try:
|
||||
bc = m.visual.material.baseColorFactor
|
||||
if bc is not None:
|
||||
m.visual = trimesh.visual.ColorVisuals(
|
||||
mesh=m, face_colors=np.array(bc, dtype=np.uint8))
|
||||
else:
|
||||
m.visual = trimesh.visual.ColorVisuals(
|
||||
mesh=m, face_colors=[185, 190, 195, 255])
|
||||
except Exception:
|
||||
try:
|
||||
m.visual = m.visual.to_color()
|
||||
except Exception:
|
||||
pass
|
||||
materialized.append(m)
|
||||
|
||||
mesh = trimesh.util.concatenate(materialized)
|
||||
if mesh is not None and len(mesh.faces) > 0:
|
||||
n_colors = len(set(
|
||||
tuple(c) for c in mesh.visual.face_colors[:, :3][::1000]
|
||||
)) if hasattr(mesh.visual, 'face_colors') else 0
|
||||
logger.info(f"GLTF: assembled {len(mesh.faces)} faces, "
|
||||
f"~{n_colors} distinct colors sampled")
|
||||
return mesh
|
||||
except Exception as e:
|
||||
logger.warning(f"GLTF path failed ({e}) — falling back to STL")
|
||||
finally:
|
||||
try:
|
||||
gltf_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── STL fallback: single merged mesh, correct positions, monochrome ───
|
||||
with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as tmp:
|
||||
stl_path = Path(tmp.name)
|
||||
try:
|
||||
from build123d import export_stl
|
||||
export_stl(model.shape, str(stl_path))
|
||||
mesh = trimesh.load(str(stl_path), force="mesh")
|
||||
if mesh is not None and len(mesh.faces) > 0:
|
||||
mesh.visual.face_colors = [185, 190, 195, 255]
|
||||
logger.info(f"STL fallback: {len(mesh.faces)} faces (uniform color)")
|
||||
return mesh
|
||||
except Exception as e:
|
||||
logger.warning(f"STL fallback failed ({e})")
|
||||
finally:
|
||||
try:
|
||||
stl_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# FreeCAD / last resort: bounding box
|
||||
return _bbox_wireframe_mesh(model)
|
||||
|
||||
|
||||
def _mesh_has_color(mesh) -> bool:
|
||||
"""Return True if the mesh has meaningful (non-gray) face colors."""
|
||||
try:
|
||||
fc = mesh.visual.face_colors
|
||||
if fc is None or len(fc) == 0:
|
||||
return False
|
||||
# If all faces are within ±25 of [128,128,128] treat as uncolored
|
||||
gray = [128, 128, 128]
|
||||
mean_rgb = fc[:, :3].mean(axis=0)
|
||||
return not all(abs(int(mean_rgb[i]) - gray[i]) < 25 for i in range(3))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _bbox_wireframe_mesh(model: StepModel):
|
||||
"""Create a simple box mesh from the model's bounding box. Last-resort fallback."""
|
||||
try:
|
||||
import trimesh
|
||||
bb = model.shape.bounding_box()
|
||||
# _FreeCADShapeProxy stores a _BoundBox
|
||||
if hasattr(bb, "XMin"):
|
||||
extents = [
|
||||
bb.XMax - bb.XMin,
|
||||
bb.YMax - bb.YMin,
|
||||
bb.ZMax - bb.ZMin,
|
||||
]
|
||||
center = [
|
||||
(bb.XMax + bb.XMin) / 2,
|
||||
(bb.YMax + bb.YMin) / 2,
|
||||
(bb.ZMax + bb.ZMin) / 2,
|
||||
]
|
||||
else:
|
||||
# build123d BoundBox
|
||||
extents = [bb.size.X, bb.size.Y, bb.size.Z]
|
||||
center = [bb.center.X, bb.center.Y, bb.center.Z]
|
||||
mesh = trimesh.creation.box(extents=extents)
|
||||
mesh.apply_translation(center)
|
||||
logger.debug("Using bbox wireframe mesh for rendering")
|
||||
return mesh
|
||||
except Exception as e:
|
||||
logger.warning(f"Bbox wireframe mesh failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _render_single_view(mesh, camera_direction: tuple, out_path: Path,
|
||||
width: int, height: int):
|
||||
"""Render one view using pyrender offscreen, save to out_path."""
|
||||
try:
|
||||
import pyrender
|
||||
except ImportError:
|
||||
raise ImportError("pyrender not installed. pip install pyrender")
|
||||
|
||||
# Normalize camera direction
|
||||
direction = np.array(camera_direction, dtype=float)
|
||||
direction = direction / np.linalg.norm(direction)
|
||||
|
||||
# Bounding box of the assembled mesh
|
||||
bounds = mesh.bounds # shape (2,3): [[xmin,ymin,zmin],[xmax,ymax,zmax]]
|
||||
center = (bounds[0] + bounds[1]) / 2.0
|
||||
diag = np.linalg.norm(bounds[1] - bounds[0])
|
||||
|
||||
# Camera sits at 2.5× diagonal distance from center, looking at center
|
||||
camera_distance = diag * 2.5
|
||||
eye = center + direction * camera_distance
|
||||
|
||||
# World up: (0,0,-1) = negative Z = top of display in trimesh GLTF space.
|
||||
# Fallback to (0,-1,0) for top/bottom end views where direction ≈ ±Z.
|
||||
world_up = np.array([0, 0, -1], dtype=float)
|
||||
if abs(np.dot(direction, world_up)) > 0.9:
|
||||
world_up = np.array([0, -1, 0], dtype=float)
|
||||
|
||||
# Proven camera frame formula (right-handed, same structure as original code):
|
||||
# right = cross(world_up, direction) [world_up × backward]
|
||||
# cam_up = cross(direction, right) [backward × right]
|
||||
# col2 = direction [camera +Z = backward; looks down -Z]
|
||||
right = np.cross(world_up, direction)
|
||||
if np.linalg.norm(right) < 1e-8:
|
||||
right = np.cross(np.array([1, 0, 0], dtype=float), direction)
|
||||
right = right / np.linalg.norm(right)
|
||||
cam_up = np.cross(direction, right)
|
||||
cam_up = cam_up / np.linalg.norm(cam_up)
|
||||
|
||||
# 4×4 camera pose: columns = [right, cam_up, backward, eye]
|
||||
camera_pose = np.eye(4)
|
||||
camera_pose[:3, 0] = right
|
||||
camera_pose[:3, 1] = cam_up
|
||||
camera_pose[:3, 2] = direction # camera +Z = backward; pyrender looks down -Z
|
||||
camera_pose[:3, 3] = eye
|
||||
|
||||
# Build pyrender scene — white background
|
||||
pr_scene = pyrender.Scene(ambient_light=[0.35, 0.35, 0.35],
|
||||
bg_color=[255, 255, 255, 255])
|
||||
pr_scene.add(pyrender.Mesh.from_trimesh(mesh, smooth=False))
|
||||
|
||||
# FOV sized so the model fills ~80% of the frame
|
||||
yfov = 2.0 * np.arctan((diag * 0.5) / camera_distance) * 1.25
|
||||
camera = pyrender.PerspectiveCamera(yfov=yfov, aspectRatio=width / height)
|
||||
pr_scene.add(camera, pose=camera_pose)
|
||||
|
||||
# Key light from camera position + fill from above-opposite
|
||||
pr_scene.add(pyrender.DirectionalLight(color=np.ones(3), intensity=4.5),
|
||||
pose=camera_pose)
|
||||
fill_pose = np.eye(4)
|
||||
# Fill light: offset from top of display (-Z) to avoid zero-vector when
|
||||
# direction is parallel to (0,1,0) (e.g. rear view).
|
||||
fill_dir = -direction + np.array([0, 0, -1], dtype=float)
|
||||
fill_norm = np.linalg.norm(fill_dir)
|
||||
if fill_norm < 1e-8:
|
||||
fill_dir = np.array([0, 0, -1], dtype=float)
|
||||
else:
|
||||
fill_dir = fill_dir / fill_norm
|
||||
fill_eye = center - fill_dir * camera_distance
|
||||
fill_pose[:3, 3] = fill_eye
|
||||
pr_scene.add(pyrender.DirectionalLight(color=np.ones(3), intensity=1.8),
|
||||
pose=fill_pose)
|
||||
|
||||
# Offscreen render
|
||||
r = pyrender.OffscreenRenderer(viewport_width=width, viewport_height=height)
|
||||
try:
|
||||
color, _ = r.render(pr_scene)
|
||||
finally:
|
||||
r.delete()
|
||||
|
||||
from PIL import Image
|
||||
Image.fromarray(color).save(str(out_path))
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
rewriter.py — STEP label rewriter for Chinese→English translation.
|
||||
|
||||
Produces {stem}_EN.step — NEVER modifies source file.
|
||||
Targets only PRODUCT entity name strings.
|
||||
Validates entity count before/after to ensure file integrity.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("step_processor.rewriter")
|
||||
|
||||
# Targets both quoted strings in: #N = PRODUCT('id', 'name', 'description', ...)
|
||||
# ISO 10303-21 PRODUCT has two name fields; CAD viewers typically display the second.
|
||||
# Chinese CAD exports set both to the same Chinese string, so both must be replaced.
|
||||
# Groups: (prefix) (id) (sep) (name) (suffix-quote)
|
||||
PRODUCT_PATTERN = re.compile(
|
||||
r"(#\d+\s*=\s*PRODUCT\s*\(\s*')([^']*)(',\s*')([^']*)(')",
|
||||
re.IGNORECASE
|
||||
)
|
||||
ENTITY_PATTERN = re.compile(r"^#\d+\s*=\s*\S+\s*\(", re.MULTILINE)
|
||||
|
||||
|
||||
def _read_step_for_rewrite(source_path: Path) -> str:
|
||||
"""Read STEP file with GBK-aware encoding detection.
|
||||
|
||||
STEP files from Chinese CAD tools embed raw GBK bytes in PRODUCT name
|
||||
strings. Reading as UTF-8 turns those bytes into replacement characters
|
||||
(U+FFFD), which makes the Chinese→English lookup fail. We try GBK when
|
||||
UTF-8 produces replacement chars so the regex substitution can actually
|
||||
find and replace the Chinese strings.
|
||||
"""
|
||||
for enc in ('utf-8', 'gbk'):
|
||||
try:
|
||||
text = source_path.read_text(encoding=enc)
|
||||
if enc == 'utf-8' and '�' in text:
|
||||
continue # has replacement chars — retry as GBK
|
||||
return text
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
return source_path.read_text(encoding='latin-1', errors='replace')
|
||||
|
||||
|
||||
def rewrite_step(source_path: Path, translation_map: dict):
|
||||
"""
|
||||
Produce English-labeled copy of the STEP file.
|
||||
Returns output Path or None if no rewrite needed or failed.
|
||||
"""
|
||||
if not translation_map:
|
||||
logger.info("No translations to apply — _EN.step skipped")
|
||||
return None
|
||||
try:
|
||||
source_text = _read_step_for_rewrite(source_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not read source STEP: {e}")
|
||||
return None
|
||||
original_count = len(ENTITY_PATTERN.findall(source_text))
|
||||
if not any(orig in source_text for orig in translation_map):
|
||||
logger.info("No Chinese labels in STEP text — _EN.step skipped")
|
||||
return None
|
||||
lines = source_text.splitlines(keepends=True)
|
||||
replaced_count = 0
|
||||
output_lines = []
|
||||
for line in lines:
|
||||
new_line, count = _replace_product_names(line, translation_map)
|
||||
replaced_count += count
|
||||
output_lines.append(new_line)
|
||||
output_text = "".join(output_lines)
|
||||
new_count = len(ENTITY_PATTERN.findall(output_text))
|
||||
if new_count != original_count:
|
||||
logger.error(
|
||||
f"Entity count mismatch: {original_count} → {new_count}. "
|
||||
"Aborting — source file untouched.")
|
||||
return None
|
||||
if replaced_count == 0:
|
||||
logger.info("No PRODUCT entities matched — _EN.step skipped")
|
||||
return None
|
||||
out_path = source_path.parent / f"{source_path.stem}_EN.step"
|
||||
try:
|
||||
out_path.write_text(output_text, encoding="utf-8")
|
||||
logger.info(f"_EN.step written: {out_path.name} ({replaced_count} labels replaced)")
|
||||
return out_path
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write _EN.step: {e}")
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
|
||||
def _replace_product_names(line: str, translation_map: dict):
|
||||
count = 0
|
||||
def replacer(m):
|
||||
nonlocal count
|
||||
# Try id field first (group 2), fall back to name field (group 4)
|
||||
# Both are Chinese in Chinese CAD exports; replace both with English.
|
||||
translated = translation_map.get(m.group(2)) or translation_map.get(m.group(4))
|
||||
if translated:
|
||||
count += 1
|
||||
# Replace both the id field and the name field
|
||||
return m.group(1) + translated + m.group(3) + translated + m.group(5)
|
||||
return m.group(0)
|
||||
new_line = PRODUCT_PATTERN.sub(replacer, line)
|
||||
return new_line, count
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
translator.py — Chinese to English part name translation via Claude API.
|
||||
|
||||
Detects CJK unicode range. Batches all names in a single API call per file.
|
||||
Flags uncertain translations in the notes column.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger("step_processor.translator")
|
||||
|
||||
CJK_PATTERN = re.compile(r'[一-鿿㐀-䶿]')
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a mechanical engineering translator specializing in Chinese "
|
||||
"manufacturing CAD files for display and enclosure products. "
|
||||
"Translate the following part names from Chinese to English. "
|
||||
"Preserve technical precision. Use standard hardware/manufacturing terminology. "
|
||||
"Output ONLY a JSON object mapping original Chinese to translated English, nothing else.\n"
|
||||
'Example: {"安装支架": "Mounting Bracket", "螺钉M4": "M4 Screw", "前面板": "Front Panel"}'
|
||||
)
|
||||
|
||||
|
||||
def has_chinese(text: str) -> bool:
|
||||
"""Return True if text contains CJK characters."""
|
||||
return bool(CJK_PATTERN.search(str(text)))
|
||||
|
||||
|
||||
def translate_bom(df: pd.DataFrame, model_name: str = "") -> pd.DataFrame:
|
||||
"""Detect Chinese part names and translate via Claude API."""
|
||||
needs_translation = df["part_name_original"].apply(has_chinese)
|
||||
chinese_names = df.loc[needs_translation, "part_name_original"].unique().tolist()
|
||||
if not chinese_names:
|
||||
logger.info("No Chinese part names detected — translation skipped")
|
||||
return df
|
||||
logger.info(f"Translating {len(chinese_names)} Chinese part names...")
|
||||
translation_map = _call_claude_api(chinese_names, model_name)
|
||||
if not translation_map:
|
||||
logger.warning("Translation API returned no results — retaining original names")
|
||||
df.loc[needs_translation, "notes"] = (
|
||||
df.loc[needs_translation, "notes"].apply(
|
||||
lambda n: (n + "; " if n else "") + "translation-failed"))
|
||||
return df
|
||||
for idx, row in df.iterrows():
|
||||
original = row["part_name_original"]
|
||||
if has_chinese(original):
|
||||
translated = translation_map.get(original)
|
||||
if translated:
|
||||
df.at[idx, "part_name_english"] = translated
|
||||
note_tag = "ambiguous-translation" if "[?]" in translated else "machine-translated"
|
||||
else:
|
||||
df.at[idx, "part_name_english"] = original
|
||||
note_tag = "translation-missing"
|
||||
existing = row["notes"]
|
||||
df.at[idx, "notes"] = (existing + "; " if existing else "") + note_tag
|
||||
logger.info(f"Translated {needs_translation.sum()} parts")
|
||||
return df
|
||||
|
||||
|
||||
def get_translation_map(df: pd.DataFrame) -> dict:
|
||||
"""Return dict of original → english for all translated rows."""
|
||||
mask = df["part_name_original"] != df["part_name_english"]
|
||||
return dict(zip(df.loc[mask, "part_name_original"],
|
||||
df.loc[mask, "part_name_english"]))
|
||||
|
||||
|
||||
def _call_claude_api(names: list, model_name: str = "") -> dict:
|
||||
"""Single batched Claude API call. Returns original→translated dict."""
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
logger.error("ANTHROPIC_API_KEY not set — translation unavailable")
|
||||
return {}
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
logger.error("anthropic package not installed — pip install anthropic")
|
||||
return {}
|
||||
names_json = json.dumps(names, ensure_ascii=False)
|
||||
user_msg = f"Translate these part names from Chinese to English:\n{names_json}"
|
||||
if model_name:
|
||||
user_msg += f"\n\nContext: Parts from a {model_name} display enclosure assembly."
|
||||
try:
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
response = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=2048,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
)
|
||||
text = response.content[0].text.strip()
|
||||
json_match = re.search(r'\{.*\}', text, re.DOTALL)
|
||||
if json_match:
|
||||
text = json_match.group(0)
|
||||
result = json.loads(text)
|
||||
logger.info(f"API returned {len(result)} translations")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Translation API JSON parse error: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Translation API error: {type(e).__name__}: {e}")
|
||||
return {}
|
||||
@@ -0,0 +1,286 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "mpmedia/step-processor/external-dimensional-diagram/v1",
|
||||
"title": "ExternalDimensionalDiagram",
|
||||
"description": "Schema for the external dimensional diagram sub-skill output. Designed for AI consumption, search indexing, and downstream documentation workflows (Odoo product data, CoWork, PDF assembly). All geometry values in millimeters. Imperial equivalents computed at export time.",
|
||||
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version", "generated_at", "source_path", "model_name",
|
||||
"mode", "style", "engine_used", "units",
|
||||
"overall_width", "overall_height", "overall_depth",
|
||||
"bounding_box", "selected_parts", "excluded_parts",
|
||||
"layout", "outputs", "warnings", "notes"
|
||||
],
|
||||
|
||||
"properties": {
|
||||
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"const": "1.0",
|
||||
"description": "Schema version. Increment minor for additive changes, major for breaking changes."
|
||||
},
|
||||
|
||||
"generated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 UTC timestamp of generation run."
|
||||
},
|
||||
|
||||
"source_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the source STEP file."
|
||||
},
|
||||
|
||||
"model_name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable product/model name. Sourced from datablock MD file if present, otherwise derived from filename stem."
|
||||
},
|
||||
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["enclosure_only", "enclosure_plus_mounting", "mounting_only"],
|
||||
"description": "Geometry scope mode used for this run."
|
||||
},
|
||||
|
||||
"style": {
|
||||
"type": "string",
|
||||
"enum": ["line_drawing", "rendered"],
|
||||
"description": "line_drawing = pure SVG wireframe/line art (MR28-style). rendered = rendered ISO views composited with dimensioned orthographic line views (MR16-style)."
|
||||
},
|
||||
|
||||
"layout_mode": {
|
||||
"type": "string",
|
||||
"enum": ["single_sheet", "multi_page"],
|
||||
"description": "single_sheet = all views on one SVG/PDF sheet. multi_page = one view per page, used for large/complex models or when any single view would be too dense at standard scale. Auto-selected based on model bounding box and view count unless overridden."
|
||||
},
|
||||
|
||||
"engine_used": {
|
||||
"type": "string",
|
||||
"enum": ["build123d", "freecad"],
|
||||
"description": "Geometry engine that successfully loaded and processed the file."
|
||||
},
|
||||
|
||||
"fallback_invoked": {
|
||||
"type": "boolean",
|
||||
"description": "True if the primary engine (build123d) failed and the fallback was used."
|
||||
},
|
||||
|
||||
"units": {
|
||||
"type": "object",
|
||||
"required": ["primary", "secondary"],
|
||||
"properties": {
|
||||
"primary": { "type": "string", "const": "mm" },
|
||||
"secondary": {
|
||||
"type": "string",
|
||||
"const": "in",
|
||||
"description": "Imperial inches shown in parentheses at reduced italic size where space allows."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"overall_width": { "type": "number", "description": "Overall external width in mm (X axis)." },
|
||||
"overall_height": { "type": "number", "description": "Overall external height in mm (Z axis)." },
|
||||
"overall_depth": { "type": "number", "description": "Overall external depth in mm (Y axis)." },
|
||||
|
||||
"bounding_box": {
|
||||
"type": "object",
|
||||
"required": ["x_min", "x_max", "y_min", "y_max", "z_min", "z_max"],
|
||||
"properties": {
|
||||
"x_min": { "type": "number" },
|
||||
"x_max": { "type": "number" },
|
||||
"y_min": { "type": "number" },
|
||||
"y_max": { "type": "number" },
|
||||
"z_min": { "type": "number" },
|
||||
"z_max": { "type": "number" }
|
||||
}
|
||||
},
|
||||
|
||||
"active_area": {
|
||||
"type": ["object", "null"],
|
||||
"description": "Detected front-face aperture or screen cutout. Null if not detectable.",
|
||||
"properties": {
|
||||
"width": { "type": "number", "description": "mm" },
|
||||
"height": { "type": "number", "description": "mm" },
|
||||
"diagonal": { "type": "number", "description": "mm" },
|
||||
"diagonal_inches": { "type": "number", "description": "Computed inches for display diagonal labeling." },
|
||||
"offset_left": { "type": "number", "description": "mm from enclosure left edge" },
|
||||
"offset_top": { "type": "number", "description": "mm from enclosure top edge" },
|
||||
"detection_confidence": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"],
|
||||
"description": "Confidence level of aperture detection."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"mounting_dimensions": {
|
||||
"type": ["object", "null"],
|
||||
"description": "Null if mode is enclosure_only or no mounting geometry detected.",
|
||||
"properties": {
|
||||
"pattern_type": {
|
||||
"type": "string",
|
||||
"enum": ["linear", "grid", "vesa", "custom", "unknown"],
|
||||
"description": "Detected mounting hole pattern type."
|
||||
},
|
||||
"vesa_standard": {
|
||||
"type": ["string", "null"],
|
||||
"description": "e.g. VESA 100x100, VESA 200x200. Null if not a standard VESA pattern."
|
||||
},
|
||||
"hole_diameter": { "type": "number", "description": "mm. Null if mixed diameters." },
|
||||
"hole_count": { "type": "integer" },
|
||||
"spacing_x": { "type": ["number", "null"], "description": "Center-to-center horizontal spacing in mm." },
|
||||
"spacing_y": { "type": ["number", "null"], "description": "Center-to-center vertical spacing in mm." },
|
||||
"spacing_chain_x": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "type": "number" },
|
||||
"description": "Array of sequential horizontal spacings for chain dimensioning. e.g. [113, 200, 200, 200, 113]"
|
||||
},
|
||||
"spacing_chain_y": {
|
||||
"type": ["array", "null"],
|
||||
"items": { "type": "number" },
|
||||
"description": "Array of sequential vertical spacings for chain dimensioning."
|
||||
},
|
||||
"offset_from_left": { "type": ["number", "null"] },
|
||||
"offset_from_top": { "type": ["number", "null"] },
|
||||
"offset_from_right": { "type": ["number", "null"] },
|
||||
"offset_from_bottom": { "type": ["number", "null"] }
|
||||
}
|
||||
},
|
||||
|
||||
"mounting_variants": {
|
||||
"type": ["array", "null"],
|
||||
"description": "When the STEP file contains multiple mounting subassemblies (e.g. wall mount + ceiling mount + floor stand), each variant is listed here. Each can produce a separate diagram run.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["variant_name", "part_ids"],
|
||||
"properties": {
|
||||
"variant_name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable variant label. e.g. 'Wall Mount - VESA 200x200'"
|
||||
},
|
||||
"part_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Part names/IDs from the assembly tree included in this variant."
|
||||
},
|
||||
"diagram_output": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Path to the generated diagram for this variant, if rendered."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"selected_parts": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Part names included in the geometry scope for this run."
|
||||
},
|
||||
|
||||
"excluded_parts": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Part names explicitly excluded (internal components, fasteners, etc.)."
|
||||
},
|
||||
|
||||
"mapping_file_used": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Path to the parts mapping JSON file used, if any."
|
||||
},
|
||||
|
||||
"datablock": {
|
||||
"type": "object",
|
||||
"description": "Metadata shown in the diagram title/data block. Sourced from datablock MD file.",
|
||||
"properties": {
|
||||
"model_number": { "type": "string" },
|
||||
"display_name": { "type": "string" },
|
||||
"drawing_date": { "type": "string", "format": "date" },
|
||||
"revision": { "type": "string" },
|
||||
"drawn_by": { "type": "string" },
|
||||
"company": { "type": "string" },
|
||||
"units_note": { "type": "string", "default": "Dimensions in mm (in)" },
|
||||
"scale": { "type": "string", "default": "NTS" },
|
||||
"custom_fields": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Any additional key-value pairs from the MD file to show in the data block."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"layout": {
|
||||
"type": "object",
|
||||
"description": "Describes the view layout used in the generated diagram.",
|
||||
"required": ["views_included", "sheet_size"],
|
||||
"properties": {
|
||||
"views_included": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["front", "rear", "left", "right", "top", "bottom", "isometric_front", "isometric_rear"]
|
||||
},
|
||||
"description": "Ordered list of views included in the diagram."
|
||||
},
|
||||
"sheet_size": {
|
||||
"type": "string",
|
||||
"enum": ["A4_landscape", "A4_portrait", "A3_landscape", "A3_portrait", "A2_landscape", "letter_landscape", "letter_portrait", "auto"],
|
||||
"description": "Sheet size used. 'auto' selects based on model aspect ratio and view count."
|
||||
},
|
||||
"scale_ratio": {
|
||||
"type": ["string", "null"],
|
||||
"description": "e.g. '1:5', '1:10', 'NTS' (not to scale). NTS used when views are schematic rather than precise scale."
|
||||
},
|
||||
"dimension_style": {
|
||||
"type": "string",
|
||||
"enum": ["chain", "ordinate", "baseline"],
|
||||
"description": "Dimensioning style used. chain = sequential (113-200-200-113). ordinate = from common datum. baseline = all from one edge."
|
||||
},
|
||||
"iso_style": {
|
||||
"type": "string",
|
||||
"enum": ["line_wireframe", "shaded_render", "none"],
|
||||
"description": "Isometric view rendering approach."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"outputs": {
|
||||
"type": "object",
|
||||
"description": "Paths to all generated output files.",
|
||||
"properties": {
|
||||
"diagram_png": { "type": ["string", "null"] },
|
||||
"diagram_pdf": { "type": ["string", "null"] },
|
||||
"diagram_svg": { "type": ["string", "null"], "description": "Source SVG always retained alongside exports." },
|
||||
"iso_png": { "type": ["string", "null"] },
|
||||
"front_png": { "type": ["string", "null"] },
|
||||
"side_png": { "type": ["string", "null"] },
|
||||
"rear_png": { "type": ["string", "null"] },
|
||||
"meta_json": { "type": "string", "description": "Path to this metadata JSON file." },
|
||||
"variant_outputs": {
|
||||
"type": ["array", "null"],
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"variant_name": { "type": "string" },
|
||||
"diagram_png": { "type": ["string", "null"] },
|
||||
"diagram_pdf": { "type": ["string", "null"] },
|
||||
"meta_json": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Non-fatal issues encountered during generation. e.g. 'active area detection confidence: low', 'mounting holes not detected', 'fallback engine used'."
|
||||
},
|
||||
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Informational messages. e.g. 'Chinese part names translated', '3 mounting variants detected'."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "mpmedia/step-processor/parts-mapping/v1",
|
||||
"title": "PartsMapping",
|
||||
"description": "Mapping file that classifies parts from a STEP assembly tree. Used by the external diagram generator to determine geometry scope and labels. Supports Chinese-to-English normalization. One file per product model, stored alongside the STEP file or in a shared product data directory.",
|
||||
|
||||
"type": "object",
|
||||
"required": ["model_number", "parts"],
|
||||
|
||||
"properties": {
|
||||
"model_number": {
|
||||
"type": "string",
|
||||
"description": "Product model number this mapping applies to. e.g. 'MR28UW'"
|
||||
},
|
||||
"parts": {
|
||||
"type": "object",
|
||||
"description": "Keyed by original part name from STEP file (may be Chinese). Value is a classification object.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": ["english_name", "category"],
|
||||
"properties": {
|
||||
"english_name": {
|
||||
"type": "string",
|
||||
"description": "Translated/normalized English name. Used in diagrams and BOM."
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["enclosure", "mounting", "internal", "fastener", "display_panel", "cable", "other"],
|
||||
"description": "Classification drives inclusion/exclusion logic per diagram mode."
|
||||
},
|
||||
"include_in_diagram": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Override flag. False = always exclude from diagrams regardless of mode."
|
||||
},
|
||||
"diagram_label": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Optional custom label shown on the diagram for this part. If null, english_name is used."
|
||||
},
|
||||
"mounting_variant": {
|
||||
"type": ["string", "null"],
|
||||
"description": "If this part belongs to a specific mounting variant, name it here. e.g. 'Wall Mount VESA 200x200'. Parts with the same variant_name are shown/hidden together."
|
||||
},
|
||||
"notes": {
|
||||
"type": "string",
|
||||
"description": "Free-text notes for documentation or translator flags."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"mounting_variants": {
|
||||
"type": ["array", "null"],
|
||||
"description": "Named mounting configurations available in this STEP file. Each variant is a named group of mounting parts that should be shown together.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"default": { "type": "boolean", "description": "True = use this variant when mode is enclosure_plus_mounting and no variant is specified." }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"examples": [
|
||||
{
|
||||
"model_number": "MR28UW",
|
||||
"parts": {
|
||||
"前面板": {
|
||||
"english_name": "Front Panel",
|
||||
"category": "enclosure",
|
||||
"include_in_diagram": true,
|
||||
"diagram_label": null,
|
||||
"mounting_variant": null
|
||||
},
|
||||
"安装支架": {
|
||||
"english_name": "Mounting Bracket",
|
||||
"category": "mounting",
|
||||
"include_in_diagram": true,
|
||||
"diagram_label": "Wall Mount Bracket",
|
||||
"mounting_variant": "Wall Mount"
|
||||
},
|
||||
"VESA板": {
|
||||
"english_name": "VESA Plate 200x200",
|
||||
"category": "mounting",
|
||||
"include_in_diagram": true,
|
||||
"diagram_label": "VESA 200x200 Plate",
|
||||
"mounting_variant": "VESA Mount"
|
||||
},
|
||||
"螺钉M4": {
|
||||
"english_name": "M4 Screw",
|
||||
"category": "fastener",
|
||||
"include_in_diagram": false,
|
||||
"notes": "Internal fastener - exclude from all diagrams"
|
||||
},
|
||||
"主板": {
|
||||
"english_name": "Main Board",
|
||||
"category": "internal",
|
||||
"include_in_diagram": false
|
||||
}
|
||||
},
|
||||
"mounting_variants": [
|
||||
{ "name": "Wall Mount", "description": "Standard wall mount bracket", "default": true },
|
||||
{ "name": "VESA Mount", "description": "VESA 200x200 compatible plate", "default": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
step_processor.py — CLI entry point for the STEP File Processor skill.
|
||||
|
||||
Default run: thumbnails + BOM + auto-translate if Chinese labels detected.
|
||||
|
||||
Usage:
|
||||
python step_processor.py <file.step> [options]
|
||||
|
||||
See SKILL.md or --help for full option reference.
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _setup_logging(verbose: bool):
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
fmt = "%(levelname)-5s %(name)s: %(message)s" if verbose else "%(levelname)-5s %(message)s"
|
||||
logging.basicConfig(level=level, format=fmt, stream=sys.stdout)
|
||||
|
||||
|
||||
def _parse_args():
|
||||
p = argparse.ArgumentParser(
|
||||
description="STEP File Processor — thumbnails, BOM, translation, geometry queries",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
p.add_argument("step_file", help="Path to .step or .stp file")
|
||||
|
||||
# Output control
|
||||
p.add_argument("--no-thumbnails", dest="thumbnails", action="store_false", default=True,
|
||||
help="Skip PNG thumbnail generation")
|
||||
p.add_argument("--no-bom", dest="bom", action="store_false", default=True,
|
||||
help="Skip BOM CSV export")
|
||||
p.add_argument("--translate", dest="translate", action="store_true", default=None,
|
||||
help="Force translation even if auto-detect is off")
|
||||
p.add_argument("--no-translate", dest="translate", action="store_false",
|
||||
help="Skip translation even if Chinese labels are detected")
|
||||
|
||||
# Thumbnail options
|
||||
p.add_argument("--resolution", default="1024x768",
|
||||
help="Thumbnail resolution WxH (default: 1024x768)")
|
||||
p.add_argument("--views", default=None,
|
||||
help="Comma-separated views: front,bottom,left,right,iso_left,iso_right")
|
||||
|
||||
# Query modes
|
||||
p.add_argument("--query", default=None, metavar="QUERY",
|
||||
help='Run a single geometric query and exit, e.g. "list all holes"')
|
||||
p.add_argument("--repl", action="store_true",
|
||||
help="Launch interactive geometry query REPL")
|
||||
|
||||
# Diagram
|
||||
p.add_argument("--diagram", action="store_true",
|
||||
help="Generate external dimensional diagram")
|
||||
p.add_argument("--diagram-mode",
|
||||
choices=["enclosure_only", "enclosure_plus_mounting", "mounting_only"],
|
||||
default="enclosure_only",
|
||||
help="Diagram mode (default: enclosure_only)")
|
||||
p.add_argument("--diagram-style", choices=["line_drawing", "rendered"], default=None,
|
||||
help="Diagram style (auto-selected if omitted)")
|
||||
p.add_argument("--diagram-pdf", action="store_true",
|
||||
help="Also export diagram as PDF")
|
||||
p.add_argument("--diagram-variants", action="store_true",
|
||||
help="Render one diagram per mounting variant")
|
||||
p.add_argument("--mapping", default=None, metavar="JSON_FILE",
|
||||
help="Parts mapping JSON file for diagram mode")
|
||||
p.add_argument("--datablock", default=None, metavar="MD_FILE",
|
||||
help="Datablock .md file for diagram title block")
|
||||
|
||||
# Misc
|
||||
p.add_argument("--verbose", "-v", action="store_true",
|
||||
help="Show backend selection, fallback notices, timing")
|
||||
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = _parse_args()
|
||||
_setup_logging(args.verbose)
|
||||
log = logging.getLogger("step_processor")
|
||||
|
||||
step_path = Path(args.step_file).expanduser().resolve()
|
||||
if not step_path.exists():
|
||||
log.error(f"File not found: {step_path}")
|
||||
sys.exit(1)
|
||||
if step_path.suffix.lower() not in (".step", ".stp"):
|
||||
log.warning(f"Unexpected extension '{step_path.suffix}' — continuing anyway")
|
||||
|
||||
# ── Load ─────────────────────────────────────────────────────────────────
|
||||
log.info(f"Loading: {step_path.name}")
|
||||
from modules.loader import load_step
|
||||
model = load_step(step_path)
|
||||
if model is None:
|
||||
log.error("Failed to load STEP file. Check that build123d or FreeCAD is installed.")
|
||||
log.error("See INSTALL.md for setup instructions.")
|
||||
sys.exit(1)
|
||||
log.info(f"[{model.backend}] Loaded: {step_path.name}")
|
||||
|
||||
# ── Query mode (single) ──────────────────────────────────────────────────
|
||||
if args.query:
|
||||
from modules.query_engine import run_query
|
||||
result = run_query(model, args.query)
|
||||
print(result)
|
||||
return
|
||||
|
||||
# ── REPL mode ────────────────────────────────────────────────────────────
|
||||
if args.repl:
|
||||
from modules.query_engine import repl
|
||||
repl(model, step_path)
|
||||
return
|
||||
|
||||
# ── BOM ──────────────────────────────────────────────────────────────────
|
||||
bom_df = None
|
||||
if args.bom:
|
||||
from modules.bom import extract_bom, save_bom_xlsx
|
||||
bom_df = extract_bom(model)
|
||||
xlsx_path = save_bom_xlsx(bom_df, step_path)
|
||||
log.info(f"BOM XLSX → {xlsx_path}")
|
||||
|
||||
# ── Translation ──────────────────────────────────────────────────────────
|
||||
if bom_df is not None:
|
||||
from modules.translator import has_chinese, translate_bom, get_translation_map
|
||||
needs_translation = args.translate
|
||||
if needs_translation is None:
|
||||
# Auto-detect
|
||||
needs_translation = bom_df["part_name_original"].apply(has_chinese).any()
|
||||
if needs_translation:
|
||||
log.info("Chinese part names detected — auto-translating")
|
||||
|
||||
if needs_translation:
|
||||
import os
|
||||
if not os.environ.get("ANTHROPIC_API_KEY"):
|
||||
log.warning("ANTHROPIC_API_KEY not set — skipping translation")
|
||||
else:
|
||||
bom_df = translate_bom(bom_df, model_name=step_path.stem)
|
||||
from modules.bom import save_bom_xlsx
|
||||
save_bom_xlsx(bom_df, step_path) # overwrite with translated version
|
||||
translation_map = get_translation_map(bom_df)
|
||||
if translation_map:
|
||||
from modules.rewriter import rewrite_step
|
||||
rewrite_step(step_path, translation_map)
|
||||
|
||||
# ── Thumbnails ───────────────────────────────────────────────────────────
|
||||
if args.thumbnails:
|
||||
try:
|
||||
w, h = (int(x) for x in args.resolution.split("x"))
|
||||
except ValueError:
|
||||
log.warning(f"Invalid resolution '{args.resolution}' — using 1024x768")
|
||||
w, h = 1024, 768
|
||||
views = [v.strip() for v in args.views.split(",")] if args.views else None
|
||||
from modules.renderer import render_views
|
||||
thumb_results = render_views(model, step_path, views=views, width=w, height=h)
|
||||
if thumb_results:
|
||||
log.info(f"Thumbnails: {len(thumb_results)} PNG(s) written")
|
||||
else:
|
||||
log.warning("No thumbnails generated (pyrender unavailable or mesh failed)")
|
||||
|
||||
# ── External dimensional diagram ─────────────────────────────────────────
|
||||
if args.diagram:
|
||||
from modules.external_diagram import step_external_diagram
|
||||
options = {
|
||||
"pdf": args.diagram_pdf,
|
||||
"render_variants": args.diagram_variants,
|
||||
}
|
||||
if args.diagram_style:
|
||||
options["style"] = args.diagram_style
|
||||
meta = step_external_diagram(
|
||||
path=str(step_path),
|
||||
mode=args.diagram_mode,
|
||||
mapping_file=args.mapping,
|
||||
datablock_file=args.datablock,
|
||||
options=options,
|
||||
)
|
||||
if meta:
|
||||
log.info(f"Diagram → {step_path.stem}__external-diagram.svg")
|
||||
if args.diagram_pdf:
|
||||
log.info(f"Diagram PDF → {step_path.stem}__external-diagram.pdf")
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────
|
||||
log.info("Done.")
|
||||
_print_summary(step_path, model, bom_df, args)
|
||||
|
||||
|
||||
def _print_summary(step_path: Path, model, bom_df, args):
|
||||
print(f"\n{'─'*60}")
|
||||
print(f" {step_path.name} [{model.backend}]")
|
||||
if bom_df is not None:
|
||||
print(f" BOM: {len(bom_df)} parts → {step_path.stem}_bom.xlsx")
|
||||
en_path = step_path.parent / f"{step_path.stem}_EN.step"
|
||||
if en_path.exists():
|
||||
print(f" Translated STEP → {en_path.name}")
|
||||
if args.thumbnails:
|
||||
thumb_count = sum(1 for ext in ["_front.png", "_rear.png", "_left.png",
|
||||
"_right.png", "_iso_left.png", "_iso_right.png"]
|
||||
if (step_path.parent / f"{step_path.stem}{ext}").exists())
|
||||
print(f" Thumbnails: {thumb_count} PNG(s)")
|
||||
if args.diagram:
|
||||
diag = step_path.parent / f"{step_path.stem}__external-diagram.svg"
|
||||
if diag.exists():
|
||||
print(f" Diagram → {diag.name}")
|
||||
print(f"{'─'*60}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,29 @@
|
||||
# Diagram Data Block — MR28UW
|
||||
|
||||
<!--
|
||||
This file drives the title/data block shown on external dimensional diagrams.
|
||||
Edit the fields below for your product.
|
||||
All fields are optional except model_number and display_name.
|
||||
Custom fields (anything below the standard set) will be appended to the data block
|
||||
in the order listed, as space allows.
|
||||
|
||||
Do not remove the field names — the parser uses them as keys.
|
||||
Leave a field blank rather than deleting it.
|
||||
-->
|
||||
|
||||
model_number: MR28UW
|
||||
display_name: 28" Ultra-Wide Stretched Bar Display
|
||||
revision: A
|
||||
drawing_date: 2025-01-01
|
||||
drawn_by:
|
||||
company: MPMedia
|
||||
units_note: Dimensions in mm (in)
|
||||
scale: NTS
|
||||
|
||||
<!-- Custom fields — add any key: value pairs below -->
|
||||
<!-- These will appear in the data block footer in order -->
|
||||
|
||||
panel_size: 28" (711.75 mm diagonal)
|
||||
aspect_ratio: 16:4.5
|
||||
ip_rating: IP54 (front face)
|
||||
operating_temp: -20°C to +60°C
|
||||