mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-31 01:42:25 +08:00
Merge pull request #139 from AlexGabbia/feat/freecad-harness
feat(freecad): add CLI harness for FreeCAD (258 commands, 17 workbenches)
This commit is contained in:
@@ -55,6 +55,7 @@
|
||||
!/browser/
|
||||
!/musescore/
|
||||
!/krita/
|
||||
!/freecad/
|
||||
!/iterm2/
|
||||
|
||||
# Step 5: Inside each software dir, ignore everything (including dotfiles)
|
||||
@@ -98,6 +99,8 @@
|
||||
/musescore/.*
|
||||
/krita/*
|
||||
/krita/.*
|
||||
/freecad/*
|
||||
/freecad/.*
|
||||
/iterm2/*
|
||||
/iterm2/.*
|
||||
|
||||
@@ -123,6 +126,7 @@
|
||||
!/browser/agent-harness/
|
||||
!/musescore/agent-harness/
|
||||
!/krita/agent-harness/
|
||||
!/freecad/agent-harness/
|
||||
!/iterm2/agent-harness/
|
||||
|
||||
# Step 7: Ignore build artifacts within allowed dirs
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# FreeCAD CLI Harness — Standard Operating Procedure
|
||||
|
||||
## Software Overview
|
||||
|
||||
**FreeCAD** is an open-source parametric 3D CAD modeler built on OpenCASCADE (OCCT).
|
||||
It supports Part design, Sketcher, Assembly, TechDraw, Mesh, and many other workbenches.
|
||||
|
||||
**This harness targets FreeCAD 1.1** (released March 2026) with 258 commands across 18 workbench groups.
|
||||
|
||||
- **Backend engine**: OpenCASCADE Technology (OCCT)
|
||||
- **Native format**: `.FCStd` (ZIP containing `Document.xml` + BREP geometry files)
|
||||
- **Python API**: `FreeCAD` (`App`) module — full document/object manipulation
|
||||
- **Headless mode**: `freecadcmd` or `freecad -c` — runs without GUI
|
||||
- **Macro execution**: `freecadcmd script.py` — executes Python macro headlessly
|
||||
- **Export formats**: STEP, IGES, STL, OBJ, DXF, SVG, PDF (via TechDraw)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ cli-anything-freecad (CLI + REPL) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
|
||||
│ │ document.py │ │ parts.py │ │ sketch.py │ │
|
||||
│ │ create/save │ │ primitives │ │ 2D shapes │ │
|
||||
│ └──────────────┘ └──────────────┘ └────────────┘ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
|
||||
│ │ body.py │ │ materials.py │ │ export.py │ │
|
||||
│ │ pad/pocket │ │ PBR mats │ │ STEP/STL │ │
|
||||
│ └──────────────┘ └──────────────┘ └────────────┘ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ session.py │ ← undo/redo, state management │
|
||||
│ └──────────────┘ │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ freecad_macro_gen.py — generates FreeCAD macros │
|
||||
│ freecad_backend.py — invokes FreeCAD headless │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ FreeCAD (freecadcmd) — the REAL software │
|
||||
│ OpenCASCADE — geometry kernel │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
The CLI maintains project state as a JSON document:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"name": "my_project",
|
||||
"units": "mm",
|
||||
"parts": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Box",
|
||||
"type": "box",
|
||||
"params": {"length": 10, "width": 10, "height": 10},
|
||||
"placement": {"position": [0, 0, 0], "rotation": [0, 0, 0]},
|
||||
"material_index": null,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"sketches": [],
|
||||
"bodies": [],
|
||||
"materials": [],
|
||||
"metadata": {
|
||||
"created": "2026-03-22T...",
|
||||
"modified": "2026-03-22T...",
|
||||
"software": "cli-anything-freecad 1.1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
| Group | Commands |
|
||||
|------------|-------------------------------------------------------|
|
||||
| `document` | new, open, save, info, profiles |
|
||||
| `part` | add, remove, list, get, transform, boolean |
|
||||
| `sketch` | new, add-line, add-circle, add-rect, constrain, close |
|
||||
| `body` | new, pad, pocket, fillet, chamfer, list |
|
||||
| `material` | create, assign, list, set |
|
||||
| `export` | render, info, presets |
|
||||
| `session` | undo, redo, status, history |
|
||||
| `draft` | wire, rectangle, circle, polygon, fillet-2d, shapestring, ... |
|
||||
| `assembly` | new, add-part, constrain, solve, insert-part, create-simulation, ... |
|
||||
| `techdraw` | new-page, add-view, add-annotation, export-pdf, ... |
|
||||
| `mesh` | import, from-shape, export, repair, decimate, ... |
|
||||
| `fem` | new-analysis, mesh-generate, solve, add-beam-section, add-tie, ... |
|
||||
| `cam` | new-job, add-profile, add-tapping, set-tool, generate-gcode, ... |
|
||||
| `measure` | distance, length, angle, area, volume, check-geometry, ... |
|
||||
| `import` | auto, step, iges, stl, obj, dxf, brep, 3mf, ... |
|
||||
| `surface` | filling, sections, extend, blend-curve, sew, cut |
|
||||
| `spread` | new, set-cell, get-cell, set-alias, import-csv, export-csv |
|
||||
|
||||
## FreeCAD 1.1 Changes
|
||||
|
||||
### Breaking: Datum/Origin Redesign
|
||||
FreeCAD 1.1 replaces the legacy `Origin` object with `LocalCoordinateSystem`.
|
||||
Use `body local-coordinate-system` to create configurable coordinate systems
|
||||
with cross-workbench attachment support. Datum planes, lines, and points now
|
||||
support `--attachment-mode` and `--attachment-refs` for flexible positioning.
|
||||
|
||||
**Note:** Files created with FreeCAD 1.1 are NOT backward-compatible with 1.0.
|
||||
|
||||
### New Features by Workbench
|
||||
- **PartDesign**: Whitworth threads (BSW/BSF/BSP/NPT), tapered holes, feature freeze toggle
|
||||
- **Assembly**: Inline part insertion, joint motion simulation
|
||||
- **CAM**: G84/G74 tapping, multi-pass profiles, new tool library system
|
||||
- **FEM**: Netgen refinement, beam sections (box/elliptical), tie constraints, result purging
|
||||
- **Sketcher**: Projection/reference modes, plane intersection, face-based external geometry
|
||||
- **Draft**: Edge-selective fillet, relative font paths
|
||||
- **TechDraw**: Area annotations with hole accounting, shape validation
|
||||
- **Measure**: Enhanced check-geometry with valid entries, additive measurements
|
||||
|
||||
## Rendering Pipeline
|
||||
|
||||
1. **Build JSON state** via CLI commands (document, part, sketch, body, material)
|
||||
2. **Generate FreeCAD macro** from JSON state (`freecad_macro_gen.py`)
|
||||
3. **Execute macro headlessly** via `freecadcmd script.py`
|
||||
4. **Export output** (STEP, IGES, STL, OBJ) from the generated `.FCStd` document
|
||||
5. **Verify output** (file exists, size > 0, correct format magic bytes)
|
||||
|
||||
## FreeCAD Python API Reference
|
||||
|
||||
```python
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
# Document management
|
||||
doc = FreeCAD.newDocument("MyProject")
|
||||
doc.saveAs("/path/to/project.FCStd")
|
||||
|
||||
# Primitives
|
||||
box = doc.addObject("Part::Box", "MyBox")
|
||||
box.Length = 10
|
||||
box.Width = 10
|
||||
box.Height = 10
|
||||
|
||||
cyl = doc.addObject("Part::Cylinder", "MyCylinder")
|
||||
cyl.Radius = 5
|
||||
cyl.Height = 20
|
||||
|
||||
sphere = doc.addObject("Part::Sphere", "MySphere")
|
||||
sphere.Radius = 10
|
||||
|
||||
cone = doc.addObject("Part::Cone", "MyCone")
|
||||
cone.Radius1 = 10
|
||||
cone.Radius2 = 5
|
||||
cone.Height = 15
|
||||
|
||||
torus = doc.addObject("Part::Torus", "MyTorus")
|
||||
torus.Radius1 = 10
|
||||
torus.Radius2 = 3
|
||||
|
||||
# Boolean operations
|
||||
cut = doc.addObject("Part::Cut", "Cut")
|
||||
cut.Base = box
|
||||
cut.Tool = cyl
|
||||
|
||||
fuse = doc.addObject("Part::Fuse", "Fuse")
|
||||
fuse.Base = box
|
||||
fuse.Tool = cyl
|
||||
|
||||
common = doc.addObject("Part::Common", "Common")
|
||||
common.Base = box
|
||||
common.Tool = cyl
|
||||
|
||||
# Placement
|
||||
import FreeCAD
|
||||
box.Placement = FreeCAD.Placement(
|
||||
FreeCAD.Vector(x, y, z),
|
||||
FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), angle_degrees)
|
||||
)
|
||||
|
||||
# Export
|
||||
Part.export([box, cyl], "/path/to/output.step")
|
||||
Part.export([box], "/path/to/output.stl")
|
||||
|
||||
# Recompute
|
||||
doc.recompute()
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **FreeCAD** (system package) — HARD DEPENDENCY
|
||||
- Windows: Download from freecad.org
|
||||
- Linux: `apt install freecad` or `snap install freecad`
|
||||
- macOS: `brew install --cask freecad`
|
||||
- **Python 3.10+**
|
||||
- **click** >= 8.0 (CLI framework)
|
||||
- **prompt-toolkit** >= 3.0 (REPL)
|
||||
@@ -0,0 +1,104 @@
|
||||
# cli-anything-freecad
|
||||
|
||||
CLI harness for **FreeCAD** parametric 3D CAD modeler. Create, modify, and export
|
||||
3D models from the command line or via AI agents — no GUI needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**FreeCAD** must be installed on your system. The CLI generates FreeCAD Python
|
||||
macros and executes them headlessly via `freecadcmd`.
|
||||
|
||||
- **Windows**: Download from [freecad.org](https://www.freecad.org/downloads.php)
|
||||
- **Linux**: `sudo apt install freecad` or `snap install freecad`
|
||||
- **macOS**: `brew install --cask freecad`
|
||||
|
||||
Verify installation:
|
||||
```bash
|
||||
freecadcmd --version
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd freecad/agent-harness
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
cli-anything-freecad --help
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### One-shot commands
|
||||
```bash
|
||||
# Create a new document
|
||||
cli-anything-freecad document new --name "MyPart" -o project.json
|
||||
|
||||
# Add a box
|
||||
cli-anything-freecad -p project.json part add box --name "Base" -P length=20 -P width=15 -P height=5
|
||||
|
||||
# Add a cylinder
|
||||
cli-anything-freecad -p project.json part add cylinder --name "Hole" -P radius=3 -P height=10 --position 10,7.5,0
|
||||
|
||||
# Boolean cut (subtract cylinder from box)
|
||||
cli-anything-freecad -p project.json part boolean cut 0 1 --name "BaseWithHole"
|
||||
|
||||
# Export to STEP
|
||||
cli-anything-freecad -p project.json export render output.step --preset step
|
||||
```
|
||||
|
||||
### Interactive REPL
|
||||
```bash
|
||||
cli-anything-freecad
|
||||
# or with a project:
|
||||
cli-anything-freecad -p project.json
|
||||
```
|
||||
|
||||
### JSON output for agents
|
||||
```bash
|
||||
cli-anything-freecad --json document new --name "AgentProject" -o project.json
|
||||
cli-anything-freecad --json -p project.json part add box
|
||||
cli-anything-freecad --json -p project.json export render output.step
|
||||
```
|
||||
|
||||
## Command Groups
|
||||
|
||||
| Group | Description |
|
||||
|-------|-------------|
|
||||
| `document` | Create, open, save, inspect documents |
|
||||
| `part` | Add/remove 3D primitives, transform, boolean ops |
|
||||
| `sketch` | Create 2D sketches with lines, circles, arcs, constraints |
|
||||
| `body` | PartDesign bodies — pad, pocket, fillet, chamfer, revolution |
|
||||
| `material` | Create and assign PBR materials |
|
||||
| `export` | Export to STEP, IGES, STL, OBJ, BREP, FCStd |
|
||||
| `session` | Undo/redo, status, history |
|
||||
|
||||
## Supported Primitives
|
||||
|
||||
Box, Cylinder, Sphere, Cone, Torus, Wedge
|
||||
|
||||
## Supported Export Formats
|
||||
|
||||
| Preset | Format | Description |
|
||||
|--------|--------|-------------|
|
||||
| `step` | .step | STEP AP214 (ISO 10303) — standard CAD exchange |
|
||||
| `iges` | .iges | IGES format |
|
||||
| `stl` | .stl | STL mesh (3D printing) |
|
||||
| `stl_fine` | .stl | Fine-mesh STL |
|
||||
| `obj` | .obj | Wavefront OBJ |
|
||||
| `brep` | .brep | OpenCASCADE BREP |
|
||||
| `fcstd` | .FCStd | Native FreeCAD document |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd freecad/agent-harness
|
||||
python -m pytest cli_anything/freecad/tests/ -v -s
|
||||
```
|
||||
|
||||
Force installed command testing:
|
||||
```bash
|
||||
CLI_ANYTHING_FORCE_INSTALLED=1 python -m pytest cli_anything/freecad/tests/ -v -s
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
"""cli-anything-freecad — CLI harness for FreeCAD parametric 3D CAD modeler."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Allow running as: python -m cli_anything.freecad"""
|
||||
|
||||
from cli_anything.freecad.freecad_cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core modules for FreeCAD CLI harness."""
|
||||
@@ -0,0 +1,590 @@
|
||||
"""FreeCAD CLI - Assembly module.
|
||||
|
||||
Manages assembly creation, component placement, constraints, solving,
|
||||
bill-of-materials generation, and exploded/collapsed views on a
|
||||
JSON-based project state.
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .document import ensure_collection
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_CONSTRAINTS: Set[str] = {
|
||||
"fixed", "coincident", "distance", "angle",
|
||||
"parallel", "perpendicular", "tangent",
|
||||
"revolute", "prismatic", "cylindrical",
|
||||
"ball", "planar", "gear", "belt",
|
||||
}
|
||||
|
||||
_COLLECTION_KEY = "assemblies"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _next_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for assemblies."""
|
||||
items = project.get(_COLLECTION_KEY, [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _unique_name(project: Dict[str, Any], base: str) -> str:
|
||||
"""Return a unique name derived from *base* inside the assemblies list."""
|
||||
existing = {item["name"] for item in project.get(_COLLECTION_KEY, [])}
|
||||
if base not in existing:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base}_{counter}" in existing:
|
||||
counter += 1
|
||||
return f"{base}_{counter}"
|
||||
|
||||
|
||||
def _validate_vec3(value: Any, label: str) -> List[float]:
|
||||
"""Validate that *value* is a list of exactly three numbers."""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise ValueError(f"{label} must be a list of 3 numbers, got {type(value).__name__}")
|
||||
if len(value) != 3:
|
||||
raise ValueError(f"{label} must have exactly 3 elements, got {len(value)}")
|
||||
try:
|
||||
return [float(v) for v in value]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{label} elements must be numeric: {exc}") from exc
|
||||
|
||||
|
||||
def _get_assembly(project: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"""Internal accessor with bounds checking."""
|
||||
items = ensure_collection(project, _COLLECTION_KEY)
|
||||
if not isinstance(index, int) or index < 0 or index >= len(items):
|
||||
raise IndexError(
|
||||
f"Assembly index {index} out of range (0..{len(items) - 1})"
|
||||
)
|
||||
return items[index]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_assembly(
|
||||
project: Dict[str, Any],
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new empty assembly and append it to the project.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
name : str or None
|
||||
Human-readable label. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created assembly dictionary.
|
||||
"""
|
||||
items = ensure_collection(project, _COLLECTION_KEY)
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "Assembly")
|
||||
|
||||
assembly: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"components": [],
|
||||
"constraints": [],
|
||||
"solved": False,
|
||||
}
|
||||
|
||||
items.append(assembly)
|
||||
return assembly
|
||||
|
||||
|
||||
def add_part_to_assembly(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
part_index: int,
|
||||
transform: Optional[List[float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a part reference to an assembly as a component.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
asm_index : int
|
||||
Index of the target assembly.
|
||||
part_index : int
|
||||
Index of the part in ``project["parts"]``.
|
||||
transform : list[float] or None
|
||||
Optional ``[x, y, z]`` placement offset. Defaults to ``[0, 0, 0]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created component entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *asm_index* or *part_index* is out of range.
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
|
||||
parts = project.get("parts", [])
|
||||
if not isinstance(part_index, int) or part_index < 0 or part_index >= len(parts):
|
||||
raise IndexError(
|
||||
f"Part index {part_index} out of range (0..{len(parts) - 1})"
|
||||
)
|
||||
|
||||
if transform is not None:
|
||||
transform = _validate_vec3(transform, "transform")
|
||||
else:
|
||||
transform = [0.0, 0.0, 0.0]
|
||||
|
||||
part = parts[part_index]
|
||||
component: Dict[str, Any] = {
|
||||
"part_index": part_index,
|
||||
"transform": transform,
|
||||
"name": part["name"],
|
||||
}
|
||||
|
||||
assembly["components"].append(component)
|
||||
assembly["solved"] = False
|
||||
return component
|
||||
|
||||
|
||||
def remove_part_from_assembly(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
component_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Remove a component from an assembly by its component index.
|
||||
|
||||
Returns the removed component dictionary.
|
||||
|
||||
Raises ``IndexError`` when either index is out of range.
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
components = assembly["components"]
|
||||
|
||||
if not isinstance(component_index, int) or component_index < 0 or component_index >= len(components):
|
||||
raise IndexError(
|
||||
f"Component index {component_index} out of range "
|
||||
f"(0..{len(components) - 1})"
|
||||
)
|
||||
|
||||
assembly["solved"] = False
|
||||
return components.pop(component_index)
|
||||
|
||||
|
||||
def list_assemblies(project: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Return all assemblies in the project."""
|
||||
return project.get(_COLLECTION_KEY, [])
|
||||
|
||||
|
||||
def get_assembly(project: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"""Return the assembly at *index* without removing it.
|
||||
|
||||
Raises ``IndexError`` when the index is out of range.
|
||||
"""
|
||||
return _get_assembly(project, index)
|
||||
|
||||
|
||||
def add_assembly_constraint(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
constraint_type: str,
|
||||
component_indices: List[int],
|
||||
**params: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a constraint between components in an assembly.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
asm_index : int
|
||||
Index of the target assembly.
|
||||
constraint_type : str
|
||||
One of :data:`VALID_CONSTRAINTS`.
|
||||
component_indices : list[int]
|
||||
Indices of components involved in the constraint.
|
||||
**params
|
||||
Extra parameters depending on the constraint type (e.g.
|
||||
``distance``, ``angle``, ``axis``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created constraint entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *constraint_type* is unknown or *component_indices* is invalid.
|
||||
IndexError
|
||||
If *asm_index* or any component index is out of range.
|
||||
"""
|
||||
if constraint_type not in VALID_CONSTRAINTS:
|
||||
valid = ", ".join(sorted(VALID_CONSTRAINTS))
|
||||
raise ValueError(
|
||||
f"Unknown constraint_type '{constraint_type}'. Valid: {valid}"
|
||||
)
|
||||
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
|
||||
if not isinstance(component_indices, (list, tuple)) or len(component_indices) == 0:
|
||||
raise ValueError("component_indices must be a non-empty list of integers")
|
||||
|
||||
num_components = len(assembly["components"])
|
||||
for ci in component_indices:
|
||||
if not isinstance(ci, int) or ci < 0 or ci >= num_components:
|
||||
raise IndexError(
|
||||
f"Component index {ci} out of range (0..{num_components - 1})"
|
||||
)
|
||||
|
||||
constraint: Dict[str, Any] = {
|
||||
"type": constraint_type,
|
||||
"component_indices": list(component_indices),
|
||||
"params": dict(params),
|
||||
}
|
||||
|
||||
assembly["constraints"].append(constraint)
|
||||
assembly["solved"] = False
|
||||
return constraint
|
||||
|
||||
|
||||
def solve_assembly(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Mark the assembly as solved and return a DOF estimate.
|
||||
|
||||
In the CLI harness the actual constraint solving happens in the
|
||||
generated FreeCAD macro. This function records the intent and
|
||||
provides a rough degrees-of-freedom estimate.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``{"solved": True, "dof": <int>}``
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
assembly["solved"] = True
|
||||
|
||||
num_components = len(assembly["components"])
|
||||
num_constraints = len(assembly["constraints"])
|
||||
dof = max(0, 6 * num_components - num_constraints)
|
||||
|
||||
return {"solved": True, "dof": dof}
|
||||
|
||||
|
||||
def degrees_of_freedom(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Estimate the remaining degrees of freedom for an assembly.
|
||||
|
||||
Uses the simple formula ``6 * components - constraints``, clamped
|
||||
to zero.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``{"dof": <int>, "components": <int>, "constraints": <int>}``
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
|
||||
num_components = len(assembly["components"])
|
||||
num_constraints = len(assembly["constraints"])
|
||||
dof = max(0, 6 * num_components - num_constraints)
|
||||
|
||||
return {
|
||||
"dof": dof,
|
||||
"components": num_components,
|
||||
"constraints": num_constraints,
|
||||
}
|
||||
|
||||
|
||||
def generate_bom(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate a bill of materials for an assembly.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``{"items": [{"name", "part_index", "quantity", "material"}], "total_parts": <int>}``
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
parts = project.get("parts", [])
|
||||
materials = project.get("materials", [])
|
||||
|
||||
# Count occurrences of each part_index
|
||||
counts: Dict[int, int] = {}
|
||||
for comp in assembly["components"]:
|
||||
pi = comp["part_index"]
|
||||
counts[pi] = counts.get(pi, 0) + 1
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for pi, qty in sorted(counts.items()):
|
||||
part = parts[pi] if pi < len(parts) else {"name": f"Part_{pi}", "material_index": None}
|
||||
mat_name = None
|
||||
mi = part.get("material_index")
|
||||
if mi is not None and mi < len(materials):
|
||||
mat_name = materials[mi].get("name")
|
||||
|
||||
items.append({
|
||||
"name": part["name"],
|
||||
"part_index": pi,
|
||||
"quantity": qty,
|
||||
"material": mat_name,
|
||||
})
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total_parts": len(assembly["components"]),
|
||||
}
|
||||
|
||||
|
||||
def explode_assembly(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
factor: float = 2.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Move assembly components outward by *factor* for an exploded view.
|
||||
|
||||
Each component's transform is scaled by *factor* relative to the
|
||||
assembly centroid.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``{"exploded": True, "factor": <float>, "components": <int>}``
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
components = assembly["components"]
|
||||
|
||||
if not components:
|
||||
return {"exploded": True, "factor": factor, "components": 0}
|
||||
|
||||
# Compute centroid
|
||||
cx = sum(c["transform"][0] for c in components) / len(components)
|
||||
cy = sum(c["transform"][1] for c in components) / len(components)
|
||||
cz = sum(c["transform"][2] for c in components) / len(components)
|
||||
|
||||
# Move each component outward
|
||||
for comp in components:
|
||||
t = comp["transform"]
|
||||
comp["transform"] = [
|
||||
cx + (t[0] - cx) * factor,
|
||||
cy + (t[1] - cy) * factor,
|
||||
cz + (t[2] - cz) * factor,
|
||||
]
|
||||
|
||||
return {"exploded": True, "factor": factor, "components": len(components)}
|
||||
|
||||
|
||||
def collapse_assembly(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Reset all component transforms to their origin positions.
|
||||
|
||||
If the assembly was previously solved, transforms are reset to
|
||||
``[0, 0, 0]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``{"collapsed": True, "components": <int>}``
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
|
||||
for comp in assembly["components"]:
|
||||
comp["transform"] = [0.0, 0.0, 0.0]
|
||||
|
||||
return {"collapsed": True, "components": len(assembly["components"])}
|
||||
|
||||
|
||||
def insert_new_part(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
part_type: str = "box",
|
||||
name: Optional[str] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
transform: Optional[List[float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new part inline within an assembly.
|
||||
|
||||
Instead of referencing an existing part from ``project["parts"]``,
|
||||
this function embeds an inline part definition directly in the
|
||||
assembly's components list.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
asm_index : int
|
||||
Index of the target assembly.
|
||||
part_type : str
|
||||
The type of part to create (e.g. ``"box"``, ``"cylinder"``).
|
||||
name : str or None
|
||||
Human-readable label. Auto-generated when *None*.
|
||||
params : dict or None
|
||||
Part-specific parameters (e.g. dimensions). Defaults to ``{}``.
|
||||
transform : list[float] or None
|
||||
Optional ``[x, y, z]`` placement offset. Defaults to ``[0, 0, 0]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created component entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *asm_index* is out of range.
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
|
||||
if transform is not None:
|
||||
transform = _validate_vec3(transform, "transform")
|
||||
else:
|
||||
transform = [0.0, 0.0, 0.0]
|
||||
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, f"InlinePart_{part_type}")
|
||||
|
||||
component: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"inline_part": {
|
||||
"type": part_type,
|
||||
"params": dict(params),
|
||||
},
|
||||
"transform": transform,
|
||||
}
|
||||
|
||||
assembly["components"].append(component)
|
||||
assembly["solved"] = False
|
||||
return component
|
||||
|
||||
|
||||
def create_simulation(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
name: Optional[str] = None,
|
||||
duration: float = 5.0,
|
||||
fps: int = 24,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a simulation entry on an assembly for joint motion/animation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
asm_index : int
|
||||
Index of the target assembly.
|
||||
name : str or None
|
||||
Human-readable label. Auto-generated when *None*.
|
||||
duration : float
|
||||
Total simulation duration in seconds.
|
||||
fps : int
|
||||
Frames per second for the simulation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created simulation dictionary.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *asm_index* is out of range.
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
|
||||
if name is None:
|
||||
name = f"Simulation_{len(assembly.get('simulations', [])) + 1}"
|
||||
|
||||
simulation: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"duration": float(duration),
|
||||
"fps": int(fps),
|
||||
"steps": [],
|
||||
"status": "configured",
|
||||
}
|
||||
|
||||
assembly.setdefault("simulations", []).append(simulation)
|
||||
return simulation
|
||||
|
||||
|
||||
def add_simulation_step(
|
||||
project: Dict[str, Any],
|
||||
asm_index: int,
|
||||
sim_index: int,
|
||||
joint_index: int,
|
||||
start_value: float = 0.0,
|
||||
end_value: float = 1.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Append a motion step to an existing simulation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
asm_index : int
|
||||
Index of the target assembly.
|
||||
sim_index : int
|
||||
Index of the simulation within the assembly's ``simulations`` list.
|
||||
joint_index : int
|
||||
Index of the joint/constraint this step drives.
|
||||
start_value : float
|
||||
Starting value for the joint parameter.
|
||||
end_value : float
|
||||
Ending value for the joint parameter.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created step dictionary.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *asm_index* or *sim_index* is out of range.
|
||||
"""
|
||||
assembly = _get_assembly(project, asm_index)
|
||||
|
||||
simulations = assembly.get("simulations", [])
|
||||
if not isinstance(sim_index, int) or sim_index < 0 or sim_index >= len(simulations):
|
||||
raise IndexError(
|
||||
f"Simulation index {sim_index} out of range "
|
||||
f"(0..{len(simulations) - 1})"
|
||||
)
|
||||
|
||||
simulation = simulations[sim_index]
|
||||
|
||||
step: Dict[str, Any] = {
|
||||
"joint_index": int(joint_index),
|
||||
"start_value": float(start_value),
|
||||
"end_value": float(end_value),
|
||||
}
|
||||
|
||||
simulation["steps"].append(step)
|
||||
return step
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,654 @@
|
||||
"""FreeCAD CLI - CAM/CNC module.
|
||||
|
||||
Manages CAM jobs, stock definitions, tool configurations, machining
|
||||
operations (profile, pocket, drilling, facing), G-code generation,
|
||||
simulation, and export on a JSON-based project state.
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .document import ensure_collection
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_STOCK_TYPES: Set[str] = {"box", "cylinder", "from_part"}
|
||||
VALID_TOOL_TYPES: Set[str] = {"endmill", "ballnose", "drill", "chamfer", "vbit", "facemill", "tap", "threadmill", "reamer"}
|
||||
|
||||
_COLLECTION_KEY = "cam_jobs"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _next_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for CAM jobs."""
|
||||
items = project.get(_COLLECTION_KEY, [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _unique_name(project: Dict[str, Any], base: str) -> str:
|
||||
"""Return a unique name derived from *base* inside the jobs list."""
|
||||
existing = {item["name"] for item in project.get(_COLLECTION_KEY, [])}
|
||||
if base not in existing:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base}_{counter}" in existing:
|
||||
counter += 1
|
||||
return f"{base}_{counter}"
|
||||
|
||||
|
||||
def _get_job(project: Dict[str, Any], job_index: int) -> Dict[str, Any]:
|
||||
"""Internal accessor with bounds checking."""
|
||||
items = ensure_collection(project, _COLLECTION_KEY)
|
||||
if not isinstance(job_index, int) or job_index < 0 or job_index >= len(items):
|
||||
raise IndexError(
|
||||
f"Job index {job_index} out of range (0..{len(items) - 1})"
|
||||
)
|
||||
return items[job_index]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def new_job(
|
||||
project: Dict[str, Any],
|
||||
part_index: int,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new CAM job for a part and append it to the project.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
part_index : int
|
||||
Index of the source part in ``project["parts"]``.
|
||||
name : str or None
|
||||
Human-readable label. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created job dictionary.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *part_index* is out of range.
|
||||
"""
|
||||
items = ensure_collection(project, _COLLECTION_KEY)
|
||||
|
||||
parts = project.get("parts", [])
|
||||
if not isinstance(part_index, int) or part_index < 0 or part_index >= len(parts):
|
||||
raise IndexError(
|
||||
f"Part index {part_index} out of range (0..{len(parts) - 1})"
|
||||
)
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "Job")
|
||||
|
||||
job: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"source_part_index": part_index,
|
||||
"stock": None,
|
||||
"tools": [],
|
||||
"operations": [],
|
||||
"gcode": None,
|
||||
}
|
||||
|
||||
items.append(job)
|
||||
return job
|
||||
|
||||
|
||||
def set_stock(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
stock_type: str = "box",
|
||||
extra_x: float = 2.0,
|
||||
extra_y: float = 2.0,
|
||||
extra_z: float = 2.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Define the raw stock material for a CAM job.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
stock_type : str
|
||||
Stock shape type (``"box"``, ``"cylinder"``, ``"from_part"``).
|
||||
extra_x : float
|
||||
Extra material on the X axis (each side).
|
||||
extra_y : float
|
||||
Extra material on the Y axis (each side).
|
||||
extra_z : float
|
||||
Extra material on the Z axis (each side).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The stock definition.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *stock_type* is unknown.
|
||||
"""
|
||||
if stock_type not in VALID_STOCK_TYPES:
|
||||
valid = ", ".join(sorted(VALID_STOCK_TYPES))
|
||||
raise ValueError(f"Unknown stock_type '{stock_type}'. Valid: {valid}")
|
||||
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
stock: Dict[str, Any] = {
|
||||
"type": stock_type,
|
||||
"extra_x": float(extra_x),
|
||||
"extra_y": float(extra_y),
|
||||
"extra_z": float(extra_z),
|
||||
}
|
||||
|
||||
job["stock"] = stock
|
||||
return stock
|
||||
|
||||
|
||||
def add_profile_op(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
faces: str = "all",
|
||||
depth: Optional[float] = None,
|
||||
step_down: float = 1.0,
|
||||
passes: Optional[int] = None,
|
||||
finishing_pass: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a profile (contour) machining operation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
faces : str
|
||||
Face selection (``"all"`` or specific face references).
|
||||
depth : float or None
|
||||
Total cut depth. When *None*, derived from part geometry.
|
||||
step_down : float
|
||||
Depth of cut per pass.
|
||||
passes : int or None
|
||||
Explicit number of passes. When provided, overrides automatic
|
||||
calculation from *step_down*.
|
||||
finishing_pass : bool
|
||||
When *True*, adds a light finishing pass after roughing.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The operation entry.
|
||||
"""
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
op: Dict[str, Any] = {
|
||||
"type": "profile",
|
||||
"faces": faces,
|
||||
"depth": float(depth) if depth is not None else None,
|
||||
"step_down": float(step_down),
|
||||
"passes": int(passes) if passes is not None else None,
|
||||
"finishing_pass": finishing_pass,
|
||||
}
|
||||
|
||||
job["operations"].append(op)
|
||||
return op
|
||||
|
||||
|
||||
def add_pocket_op(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
faces: str = "all",
|
||||
depth: Optional[float] = None,
|
||||
step_down: float = 1.0,
|
||||
step_over: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a pocket machining operation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
faces : str
|
||||
Face selection (``"all"`` or specific face references).
|
||||
depth : float or None
|
||||
Total pocket depth. When *None*, derived from part geometry.
|
||||
step_down : float
|
||||
Depth of cut per pass.
|
||||
step_over : float
|
||||
Lateral step-over as a fraction of tool diameter (0.0 to 1.0).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The operation entry.
|
||||
"""
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
op: Dict[str, Any] = {
|
||||
"type": "pocket",
|
||||
"faces": faces,
|
||||
"depth": float(depth) if depth is not None else None,
|
||||
"step_down": float(step_down),
|
||||
"step_over": float(step_over),
|
||||
}
|
||||
|
||||
job["operations"].append(op)
|
||||
return op
|
||||
|
||||
|
||||
def add_drilling_op(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
holes: str = "all",
|
||||
depth: Optional[float] = None,
|
||||
peck_depth: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a drilling operation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
holes : str
|
||||
Hole selection (``"all"`` or specific hole references).
|
||||
depth : float or None
|
||||
Total drill depth. When *None*, derived from part geometry.
|
||||
peck_depth : float or None
|
||||
Peck drilling increment. When *None*, drilling is continuous.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The operation entry.
|
||||
"""
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
op: Dict[str, Any] = {
|
||||
"type": "drilling",
|
||||
"holes": holes,
|
||||
"depth": float(depth) if depth is not None else None,
|
||||
"peck_depth": float(peck_depth) if peck_depth is not None else None,
|
||||
}
|
||||
|
||||
job["operations"].append(op)
|
||||
return op
|
||||
|
||||
|
||||
def add_facing_op(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
depth: float = 1.0,
|
||||
step_over: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a facing (surface levelling) operation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
depth : float
|
||||
Total material to remove from the top surface.
|
||||
step_over : float
|
||||
Lateral step-over as a fraction of tool diameter (0.0 to 1.0).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The operation entry.
|
||||
"""
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
op: Dict[str, Any] = {
|
||||
"type": "facing",
|
||||
"depth": float(depth),
|
||||
"step_over": float(step_over),
|
||||
}
|
||||
|
||||
job["operations"].append(op)
|
||||
return op
|
||||
|
||||
|
||||
def add_tapping_op(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
holes: str = "all",
|
||||
depth: Optional[float] = None,
|
||||
thread_pitch: float = 1.5,
|
||||
right_hand: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a tapping operation (G84 right-hand / G74 left-hand).
|
||||
|
||||
FreeCAD 1.1 introduces native tapping cycle support.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
holes : str
|
||||
Hole selection (``"all"`` or specific hole references).
|
||||
depth : float or None
|
||||
Total tap depth. When *None*, derived from part geometry.
|
||||
thread_pitch : float
|
||||
Thread pitch in project units.
|
||||
right_hand : bool
|
||||
When *True*, uses G84 (right-hand thread). When *False*,
|
||||
uses G74 (left-hand thread).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The operation entry.
|
||||
"""
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
op: Dict[str, Any] = {
|
||||
"type": "tapping",
|
||||
"holes": holes,
|
||||
"depth": float(depth) if depth is not None else None,
|
||||
"thread_pitch": float(thread_pitch),
|
||||
"right_hand": right_hand,
|
||||
"g_code": "G84" if right_hand else "G74",
|
||||
}
|
||||
|
||||
job["operations"].append(op)
|
||||
return op
|
||||
|
||||
|
||||
def set_tool(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
tool_number: int = 1,
|
||||
diameter: float = 6.0,
|
||||
flutes: int = 2,
|
||||
type: str = "endmill",
|
||||
tool_material: Optional[str] = None,
|
||||
coating: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Define or replace a cutting tool in a CAM job.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
tool_number : int
|
||||
Tool number in the tool table (T1, T2, etc.).
|
||||
diameter : float
|
||||
Tool diameter in project units.
|
||||
flutes : int
|
||||
Number of cutting flutes.
|
||||
type : str
|
||||
Tool type (``"endmill"``, ``"ballnose"``, ``"drill"``, etc.).
|
||||
tool_material : str or None
|
||||
Tool substrate material (e.g. ``"HSS"``, ``"carbide"``).
|
||||
coating : str or None
|
||||
Tool coating (e.g. ``"TiN"``, ``"AlTiN"``, ``"DLC"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The tool entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *type* is unknown.
|
||||
"""
|
||||
if type not in VALID_TOOL_TYPES:
|
||||
valid = ", ".join(sorted(VALID_TOOL_TYPES))
|
||||
raise ValueError(f"Unknown tool type '{type}'. Valid: {valid}")
|
||||
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
tool: Dict[str, Any] = {
|
||||
"tool_number": int(tool_number),
|
||||
"diameter": float(diameter),
|
||||
"flutes": int(flutes),
|
||||
"type": type,
|
||||
}
|
||||
|
||||
if tool_material is not None:
|
||||
tool["tool_material"] = str(tool_material)
|
||||
if coating is not None:
|
||||
tool["coating"] = str(coating)
|
||||
|
||||
# Replace existing tool with same number, or append
|
||||
for i, existing in enumerate(job["tools"]):
|
||||
if existing["tool_number"] == tool_number:
|
||||
job["tools"][i] = tool
|
||||
return tool
|
||||
|
||||
job["tools"].append(tool)
|
||||
return tool
|
||||
|
||||
|
||||
def generate_gcode(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Record metadata for G-code generation.
|
||||
|
||||
The actual G-code generation is performed by the generated FreeCAD
|
||||
macro. This function validates the job setup and stores generation
|
||||
metadata.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
G-code generation metadata.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the job is missing required setup (stock, tools, operations).
|
||||
"""
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
if job["stock"] is None:
|
||||
raise ValueError("Job has no stock defined (call set_stock first)")
|
||||
|
||||
if not job["tools"]:
|
||||
raise ValueError("Job has no tools defined (call set_tool first)")
|
||||
|
||||
if not job["operations"]:
|
||||
raise ValueError("Job has no operations defined")
|
||||
|
||||
job["gcode"] = {
|
||||
"status": "pending",
|
||||
"operations_count": len(job["operations"]),
|
||||
"tools_count": len(job["tools"]),
|
||||
}
|
||||
|
||||
return job["gcode"]
|
||||
|
||||
|
||||
def simulate_job(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Simulate a CAM job and return estimated metrics.
|
||||
|
||||
This is a rough estimation based on the number and type of
|
||||
operations. Actual simulation runs inside FreeCAD.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Simulation summary with estimated time and material removal.
|
||||
"""
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
if not job["operations"]:
|
||||
raise ValueError("Job has no operations to simulate")
|
||||
|
||||
# Rough time estimation per operation type (seconds)
|
||||
time_estimates = {
|
||||
"profile": 120.0,
|
||||
"pocket": 300.0,
|
||||
"drilling": 60.0,
|
||||
"facing": 180.0,
|
||||
"tapping": 90.0,
|
||||
}
|
||||
|
||||
total_time = 0.0
|
||||
for op in job["operations"]:
|
||||
total_time += time_estimates.get(op["type"], 120.0)
|
||||
|
||||
return {
|
||||
"job_name": job["name"],
|
||||
"operations_count": len(job["operations"]),
|
||||
"tools_used": len(job["tools"]),
|
||||
"estimated_time_seconds": total_time,
|
||||
"material_removal": "estimated",
|
||||
}
|
||||
|
||||
|
||||
def export_gcode(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Record metadata for exporting G-code to a file.
|
||||
|
||||
The actual export is performed by the generated FreeCAD macro.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
path : str
|
||||
Output file path for the G-code.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Export metadata.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *path* is invalid.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Path must be a non-empty string")
|
||||
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
return {
|
||||
"action": "export_gcode",
|
||||
"job_name": job["name"],
|
||||
"job_index": job_index,
|
||||
"path": path.strip(),
|
||||
"format": "gcode",
|
||||
}
|
||||
|
||||
|
||||
def import_tool_library(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
library_path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a FreeCAD 1.1 tool library file into a CAM job.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
library_path : str
|
||||
Path to the tool library file to import.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Import metadata.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *library_path* is invalid.
|
||||
"""
|
||||
if not isinstance(library_path, str) or not library_path.strip():
|
||||
raise ValueError("library_path must be a non-empty string")
|
||||
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
if "metadata" not in job:
|
||||
job["metadata"] = {}
|
||||
|
||||
job["metadata"]["tool_library_path"] = library_path.strip()
|
||||
|
||||
return {
|
||||
"action": "import_tool_library",
|
||||
"job_name": job["name"],
|
||||
"job_index": job_index,
|
||||
"library_path": library_path.strip(),
|
||||
}
|
||||
|
||||
|
||||
def export_tool_library(
|
||||
project: Dict[str, Any],
|
||||
job_index: int,
|
||||
path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Export the tool library of a CAM job to a file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
job_index : int
|
||||
Index of the target job.
|
||||
path : str
|
||||
Output file path for the tool library.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Export metadata.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *path* is invalid.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Path must be a non-empty string")
|
||||
|
||||
job = _get_job(project, job_index)
|
||||
|
||||
return {
|
||||
"action": "export_tool_library",
|
||||
"job_name": job["name"],
|
||||
"job_index": job_index,
|
||||
"path": path.strip(),
|
||||
"tools_count": len(job["tools"]),
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Document and project management for the FreeCAD CLI harness.
|
||||
|
||||
Provides creation, loading, saving, and inspection of JSON-based
|
||||
FreeCAD project files, along with a set of predefined unit/workflow profiles.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
SOFTWARE_VERSION = "cli-anything-freecad 1.0.0"
|
||||
PROJECT_SCHEMA_VERSION = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profiles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROFILES: Dict[str, Dict[str, Any]] = {
|
||||
"default": {
|
||||
"description": "Default profile with millimetre units",
|
||||
"units": "mm",
|
||||
},
|
||||
"metric_small": {
|
||||
"description": "Metric profile for small parts",
|
||||
"units": "mm",
|
||||
},
|
||||
"metric_large": {
|
||||
"description": "Metric profile for architectural / large-scale work",
|
||||
"units": "m",
|
||||
},
|
||||
"imperial": {
|
||||
"description": "Imperial profile with inch units",
|
||||
"units": "in",
|
||||
},
|
||||
"print3d": {
|
||||
"description": "Profile oriented for 3D printing workflows",
|
||||
"units": "mm",
|
||||
},
|
||||
"cnc": {
|
||||
"description": "Precision-focused profile for CNC machining",
|
||||
"units": "mm",
|
||||
},
|
||||
}
|
||||
|
||||
VALID_UNITS = {"mm", "m", "in"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Return the current local time as an ISO-8601 string."""
|
||||
return datetime.now().isoformat()
|
||||
|
||||
|
||||
# All collection keys that can exist in a project. The first four are
|
||||
# required for backward compatibility; the rest are lazily initialised.
|
||||
_REQUIRED_COLLECTIONS = ("parts", "sketches", "bodies", "materials")
|
||||
_OPTIONAL_COLLECTIONS = (
|
||||
"assemblies", "meshes", "techdraw_pages", "draft_objects",
|
||||
"measurements", "surfaces", "fem_analyses", "cam_jobs", "spreadsheets",
|
||||
)
|
||||
ALL_COLLECTIONS = _REQUIRED_COLLECTIONS + _OPTIONAL_COLLECTIONS
|
||||
|
||||
|
||||
def ensure_collection(project: Dict[str, Any], key: str) -> list:
|
||||
"""Return ``project[key]``, creating it as ``[]`` if absent."""
|
||||
if key not in project:
|
||||
project[key] = []
|
||||
return project[key]
|
||||
|
||||
|
||||
def _validate_project(project: Dict[str, Any]) -> None:
|
||||
"""Raise ``ValueError`` if *project* is missing required keys or has bad types."""
|
||||
required_keys = {"version", "name", "units", "parts", "sketches", "bodies", "materials", "metadata"}
|
||||
missing = required_keys - set(project.keys())
|
||||
if missing:
|
||||
raise ValueError(f"Project is missing required keys: {', '.join(sorted(missing))}")
|
||||
|
||||
if not isinstance(project["name"], str) or not project["name"]:
|
||||
raise ValueError("Project 'name' must be a non-empty string")
|
||||
|
||||
if project["units"] not in VALID_UNITS:
|
||||
raise ValueError(f"Invalid units '{project['units']}'. Must be one of: {', '.join(sorted(VALID_UNITS))}")
|
||||
|
||||
for collection in _REQUIRED_COLLECTIONS:
|
||||
if not isinstance(project[collection], list):
|
||||
raise ValueError(f"Project '{collection}' must be a list")
|
||||
|
||||
# Optional collections: validate type if present, but don't require
|
||||
for collection in _OPTIONAL_COLLECTIONS:
|
||||
if collection in project and not isinstance(project[collection], list):
|
||||
raise ValueError(f"Project '{collection}' must be a list")
|
||||
|
||||
if not isinstance(project.get("metadata"), dict):
|
||||
raise ValueError("Project 'metadata' must be a dict")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_document(
|
||||
name: str = "Untitled",
|
||||
units: str = "mm",
|
||||
profile: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new project document.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name:
|
||||
Human-readable project name.
|
||||
units:
|
||||
Unit system (``"mm"``, ``"m"``, or ``"in"``). Overridden by
|
||||
*profile* when a profile is supplied.
|
||||
profile:
|
||||
Optional profile key from :data:`PROFILES`. When given, the
|
||||
profile's ``units`` value takes precedence over the *units*
|
||||
argument.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
A new project dictionary ready for use.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *name* is empty, *units* is invalid, or *profile* is unknown.
|
||||
"""
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("Document name must be a non-empty string")
|
||||
|
||||
if profile is not None:
|
||||
if profile not in PROFILES:
|
||||
raise ValueError(
|
||||
f"Unknown profile '{profile}'. Available profiles: {', '.join(sorted(PROFILES))}"
|
||||
)
|
||||
units = PROFILES[profile]["units"]
|
||||
|
||||
if units not in VALID_UNITS:
|
||||
raise ValueError(f"Invalid units '{units}'. Must be one of: {', '.join(sorted(VALID_UNITS))}")
|
||||
|
||||
now = _now_iso()
|
||||
|
||||
project: Dict[str, Any] = {
|
||||
"version": PROJECT_SCHEMA_VERSION,
|
||||
"name": name.strip(),
|
||||
"units": units,
|
||||
"parts": [],
|
||||
"sketches": [],
|
||||
"bodies": [],
|
||||
"materials": [],
|
||||
"assemblies": [],
|
||||
"meshes": [],
|
||||
"techdraw_pages": [],
|
||||
"draft_objects": [],
|
||||
"measurements": [],
|
||||
"surfaces": [],
|
||||
"fem_analyses": [],
|
||||
"cam_jobs": [],
|
||||
"spreadsheets": [],
|
||||
"metadata": {
|
||||
"created": now,
|
||||
"modified": now,
|
||||
"software": SOFTWARE_VERSION,
|
||||
},
|
||||
}
|
||||
return project
|
||||
|
||||
|
||||
def open_document(path: str) -> Dict[str, Any]:
|
||||
"""Load a project document from a JSON file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
Filesystem path to the ``.json`` project file.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
The validated project dictionary.
|
||||
|
||||
Raises
|
||||
------
|
||||
FileNotFoundError
|
||||
If *path* does not exist.
|
||||
ValueError
|
||||
If the file cannot be parsed or fails validation.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Path must be a non-empty string")
|
||||
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"Project file not found: {path}")
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
project = json.load(fh)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Failed to parse project file: {exc}") from exc
|
||||
|
||||
if not isinstance(project, dict):
|
||||
raise ValueError("Project file must contain a JSON object at the top level")
|
||||
|
||||
_validate_project(project)
|
||||
return project
|
||||
|
||||
|
||||
def save_document(project: Dict[str, Any], path: str) -> str:
|
||||
"""Save a project document to a JSON file.
|
||||
|
||||
The ``metadata.modified`` timestamp is updated automatically before
|
||||
writing.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project:
|
||||
The project dictionary to persist.
|
||||
path:
|
||||
Destination file path.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The absolute path of the saved file.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the project fails validation or *path* is invalid.
|
||||
OSError
|
||||
If the file cannot be written.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Path must be a non-empty string")
|
||||
|
||||
_validate_project(project)
|
||||
|
||||
project["metadata"]["modified"] = _now_iso()
|
||||
|
||||
dir_name = os.path.dirname(path)
|
||||
if dir_name:
|
||||
os.makedirs(dir_name, exist_ok=True)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(project, fh, indent=2, ensure_ascii=False)
|
||||
|
||||
return os.path.abspath(path)
|
||||
|
||||
|
||||
def get_document_info(project: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a concise summary of a project document.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project:
|
||||
A valid project dictionary.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
Summary containing name, units, and collection counts.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the project fails validation.
|
||||
"""
|
||||
_validate_project(project)
|
||||
|
||||
info = {
|
||||
"name": project["name"],
|
||||
"units": project["units"],
|
||||
"version": project["version"],
|
||||
}
|
||||
for col in ALL_COLLECTIONS:
|
||||
info[f"{col}_count"] = len(project.get(col, []))
|
||||
info["metadata"] = project.get("metadata", {})
|
||||
return info
|
||||
|
||||
|
||||
def list_profiles() -> List[Dict[str, Any]]:
|
||||
"""Return a list of available project profiles.
|
||||
|
||||
Each entry contains the profile ``name``, ``units``, and
|
||||
``description``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[Dict[str, Any]]
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"name": key,
|
||||
"units": value["units"],
|
||||
"description": value["description"],
|
||||
}
|
||||
for key, value in PROFILES.items()
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
Export module for the FreeCAD CLI harness.
|
||||
|
||||
Handles rendering and exporting FreeCAD projects using the real FreeCAD
|
||||
headless backend, including generating macro scripts from project JSON
|
||||
state and converting to various CAD/mesh output formats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import struct
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cli_anything.freecad.utils.freecad_macro_gen import generate_macro
|
||||
from cli_anything.freecad.utils import freecad_backend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export preset definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
EXPORT_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"step": {
|
||||
"format": "step",
|
||||
"description": "STEP AP214 (ISO 10303)",
|
||||
},
|
||||
"iges": {
|
||||
"format": "iges",
|
||||
"description": "IGES format",
|
||||
},
|
||||
"stl": {
|
||||
"format": "stl",
|
||||
"description": "STL mesh (3D printing)",
|
||||
},
|
||||
"stl_fine": {
|
||||
"format": "stl",
|
||||
"mesh_deviation": 0.01,
|
||||
"description": "Fine STL mesh",
|
||||
},
|
||||
"obj": {
|
||||
"format": "obj",
|
||||
"description": "Wavefront OBJ",
|
||||
},
|
||||
"brep": {
|
||||
"format": "brep",
|
||||
"description": "OpenCASCADE BREP",
|
||||
},
|
||||
"fcstd": {
|
||||
"format": "fcstd",
|
||||
"description": "Native FreeCAD document",
|
||||
},
|
||||
"dxf": {
|
||||
"format": "dxf",
|
||||
"description": "AutoCAD DXF format",
|
||||
},
|
||||
"svg": {
|
||||
"format": "svg",
|
||||
"description": "Scalable Vector Graphics",
|
||||
},
|
||||
"gltf": {
|
||||
"format": "gltf",
|
||||
"description": "GL Transmission Format",
|
||||
},
|
||||
"3mf": {
|
||||
"format": "3mf",
|
||||
"description": "3D Manufacturing Format",
|
||||
},
|
||||
"ply": {
|
||||
"format": "ply",
|
||||
"description": "Polygon File Format",
|
||||
},
|
||||
"off": {
|
||||
"format": "off",
|
||||
"description": "Object File Format",
|
||||
},
|
||||
"amf": {
|
||||
"format": "amf",
|
||||
"description": "Additive Manufacturing Format",
|
||||
},
|
||||
"pdf": {
|
||||
"format": "pdf",
|
||||
"description": "PDF via TechDraw",
|
||||
},
|
||||
"png": {
|
||||
"format": "png",
|
||||
"description": "Rendered PNG image",
|
||||
},
|
||||
"jpg": {
|
||||
"format": "jpg",
|
||||
"description": "Rendered JPG image",
|
||||
},
|
||||
}
|
||||
|
||||
# Map format names to canonical file extensions
|
||||
_FORMAT_EXTENSIONS: Dict[str, str] = {
|
||||
"step": ".step",
|
||||
"iges": ".iges",
|
||||
"stl": ".stl",
|
||||
"obj": ".obj",
|
||||
"brep": ".brep",
|
||||
"fcstd": ".FCStd",
|
||||
"dxf": ".dxf",
|
||||
"svg": ".svg",
|
||||
"gltf": ".gltf",
|
||||
"3mf": ".3mf",
|
||||
"ply": ".ply",
|
||||
"off": ".off",
|
||||
"amf": ".amf",
|
||||
"pdf": ".pdf",
|
||||
"png": ".png",
|
||||
"jpg": ".jpg",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_step(path: str) -> bool:
|
||||
"""Check that *path* starts with the ISO-10303-21 header marker."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
header = fh.read(64)
|
||||
return header.strip().startswith("ISO-10303-21")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_stl(path: str) -> bool:
|
||||
"""Check for ASCII STL (``solid`` keyword) or valid binary STL header.
|
||||
|
||||
A binary STL has an 80-byte header followed by a 4-byte little-endian
|
||||
triangle count. An ASCII STL starts with the word ``solid``.
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
head = fh.read(80)
|
||||
if not head:
|
||||
return False
|
||||
# ASCII STL check
|
||||
text_head = head.decode("ascii", errors="ignore").strip().lower()
|
||||
if text_head.startswith("solid"):
|
||||
return True
|
||||
# Binary STL: 80-byte header + 4-byte uint32 triangle count
|
||||
with open(path, "rb") as fh:
|
||||
fh.seek(80)
|
||||
count_bytes = fh.read(4)
|
||||
if len(count_bytes) == 4:
|
||||
_tri_count = struct.unpack("<I", count_bytes)[0]
|
||||
return True
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_iges(path: str) -> bool:
|
||||
"""Check for IGES header markers in the first few lines.
|
||||
|
||||
IGES files have fixed-width 80-column records. The 73rd column of
|
||||
the first record should contain ``S`` (Start section).
|
||||
"""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
first_line = fh.readline()
|
||||
if not first_line:
|
||||
return False
|
||||
# The 73rd character (index 72) should be 'S' for the start section
|
||||
if len(first_line) >= 73 and first_line[72] == "S":
|
||||
return True
|
||||
# Fallback: look for common IGES keywords
|
||||
upper = first_line.upper()
|
||||
return "IGES" in upper or "INITIAL GRAPHICS" in upper
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_dxf(path: str) -> bool:
|
||||
"""Check that *path* contains DXF section markers."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
header = fh.read(256)
|
||||
return "0\nSECTION" in header or "AutoCAD" in header
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_svg(path: str) -> bool:
|
||||
"""Check that *path* contains SVG or XML markers."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
header = fh.read(256)
|
||||
return "<svg" in header.lower() or "<?xml" in header.lower()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_pdf(path: str) -> bool:
|
||||
"""Check that *path* starts with the ``%PDF-`` header."""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
header = fh.read(8)
|
||||
return header.startswith(b"%PDF-")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_gltf(path: str) -> bool:
|
||||
"""Check for glTF binary magic bytes or JSON with ``asset`` key."""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
magic = fh.read(4)
|
||||
# Binary glTF magic: "glTF"
|
||||
if magic == b"glTF":
|
||||
return True
|
||||
# JSON-based glTF
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
header = fh.read(512)
|
||||
return '"asset"' in header
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_3mf(path: str) -> bool:
|
||||
"""Check that *path* is a ZIP archive containing ``3D/3dmodel.model``."""
|
||||
try:
|
||||
if not zipfile.is_zipfile(path):
|
||||
return False
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
return "3D/3dmodel.model" in zf.namelist()
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return False
|
||||
|
||||
|
||||
_FORMAT_VALIDATORS: Dict[str, Any] = {
|
||||
"step": _validate_step,
|
||||
"iges": _validate_iges,
|
||||
"stl": _validate_stl,
|
||||
"dxf": _validate_dxf,
|
||||
"svg": _validate_svg,
|
||||
"pdf": _validate_pdf,
|
||||
"gltf": _validate_gltf,
|
||||
"3mf": _validate_3mf,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def export_project(
|
||||
project: dict,
|
||||
output_path: str,
|
||||
preset: str = "step",
|
||||
overwrite: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Export a FreeCAD project to a CAD/mesh file.
|
||||
|
||||
1. Generates a FreeCAD macro script via :func:`generate_macro`.
|
||||
2. Calls :func:`freecad_backend.run_macro` to execute it headlessly.
|
||||
3. Verifies the output file exists and has the correct format.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The project JSON state containing parts, bodies, and placements.
|
||||
output_path : str
|
||||
Destination file path for the exported geometry.
|
||||
preset : str
|
||||
Name of an export preset (see ``EXPORT_PRESETS``).
|
||||
overwrite : bool
|
||||
If *False* (default), raise ``FileExistsError`` when *output_path*
|
||||
already exists.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``{"output": str, "format": str, "file_size": int,
|
||||
"method": "freecad-headless"}``
|
||||
|
||||
Raises
|
||||
------
|
||||
FileExistsError
|
||||
If *output_path* exists and *overwrite* is False.
|
||||
ValueError
|
||||
If *preset* is not a known preset name.
|
||||
RuntimeError
|
||||
If the macro execution fails or the output file is missing/invalid.
|
||||
"""
|
||||
output_path = os.path.abspath(output_path)
|
||||
|
||||
if not overwrite and os.path.exists(output_path):
|
||||
raise FileExistsError(
|
||||
f"Output file already exists: {output_path}. "
|
||||
"Set overwrite=True to replace it."
|
||||
)
|
||||
|
||||
if preset not in EXPORT_PRESETS:
|
||||
raise ValueError(
|
||||
f"Unknown export preset '{preset}'. "
|
||||
f"Available presets: {', '.join(sorted(EXPORT_PRESETS))}"
|
||||
)
|
||||
|
||||
preset_config = EXPORT_PRESETS[preset]
|
||||
export_format = preset_config["format"]
|
||||
|
||||
# Ensure output directory exists
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
|
||||
# Generate the FreeCAD macro script
|
||||
macro_content = generate_macro(project, output_path, export_format=export_format)
|
||||
|
||||
# Execute via the headless backend
|
||||
result = freecad_backend.export_headless(
|
||||
macro_content, output_path, timeout=120,
|
||||
)
|
||||
|
||||
# Verify the output file
|
||||
if not os.path.isfile(output_path):
|
||||
raise RuntimeError(
|
||||
f"Export failed: output file was not created at {output_path}. "
|
||||
f"Backend result: {result}"
|
||||
)
|
||||
|
||||
# Run format-specific validation if available
|
||||
validator = _FORMAT_VALIDATORS.get(export_format)
|
||||
if validator and not validator(output_path):
|
||||
raise RuntimeError(
|
||||
f"Export produced an invalid {export_format.upper()} file at "
|
||||
f"{output_path}. The file header does not match the expected format."
|
||||
)
|
||||
|
||||
ext = _FORMAT_EXTENSIONS.get(export_format, f".{export_format}")
|
||||
file_size = os.path.getsize(output_path)
|
||||
|
||||
return {
|
||||
"output": output_path,
|
||||
"format": ext.lstrip("."),
|
||||
"file_size": file_size,
|
||||
"method": "freecad-headless",
|
||||
}
|
||||
|
||||
|
||||
def get_export_info(project: dict) -> Dict[str, Any]:
|
||||
"""Return a summary of what will be exported from *project*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The project JSON state.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Summary with keys ``part_count``, ``body_count``,
|
||||
``boolean_op_count``, ``available_presets``, and ``part_names``.
|
||||
"""
|
||||
parts = project.get("parts", [])
|
||||
bodies = project.get("bodies", [])
|
||||
boolean_ops = project.get("boolean_ops", [])
|
||||
|
||||
part_names = [p.get("name", "Unnamed") for p in parts]
|
||||
|
||||
return {
|
||||
"part_count": len(parts),
|
||||
"body_count": len(bodies),
|
||||
"boolean_op_count": len(boolean_ops),
|
||||
"part_names": part_names,
|
||||
"available_presets": list(EXPORT_PRESETS.keys()),
|
||||
}
|
||||
|
||||
|
||||
def list_presets() -> List[Dict[str, str]]:
|
||||
"""Return a list of available export presets with descriptions.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict]
|
||||
Each entry has ``name``, ``format``, and ``description`` keys.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"format": cfg["format"],
|
||||
"description": cfg["description"],
|
||||
}
|
||||
for name, cfg in EXPORT_PRESETS.items()
|
||||
]
|
||||
@@ -0,0 +1,754 @@
|
||||
"""FreeCAD CLI - FEM (Finite Element Method) analysis module.
|
||||
|
||||
Manages FEM analyses, boundary constraints (fixed, force, pressure,
|
||||
displacement, temperature, heat flux), material assignment, meshing,
|
||||
solving, and result export on a JSON-based project state.
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .document import ensure_collection
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_ELEMENT_TYPES: Set[str] = {"Tet4", "Tet10", "Hex8", "Hex20", "Tri3", "Tri6"}
|
||||
VALID_SOLVERS: Set[str] = {"calculix", "elmer", "z88"}
|
||||
VALID_EXPORT_FORMATS: Set[str] = {"vtk", "csv", "json"}
|
||||
VALID_BEAM_SECTIONS: Set[str] = {"rectangular", "circular", "box_beam", "elliptical", "pipe"}
|
||||
VALID_OUTPUT_FORMATS: Set[str] = {"vtu", "vtk", "result"}
|
||||
VALID_MESHERS: Set[str] = {"gmsh", "netgen"}
|
||||
|
||||
_COLLECTION_KEY = "fem_analyses"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _next_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for FEM analyses."""
|
||||
items = project.get(_COLLECTION_KEY, [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _unique_name(project: Dict[str, Any], base: str) -> str:
|
||||
"""Return a unique name derived from *base* inside the analyses list."""
|
||||
existing = {item["name"] for item in project.get(_COLLECTION_KEY, [])}
|
||||
if base not in existing:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base}_{counter}" in existing:
|
||||
counter += 1
|
||||
return f"{base}_{counter}"
|
||||
|
||||
|
||||
def _validate_vec3(value: Any, label: str) -> List[float]:
|
||||
"""Validate that *value* is a list of exactly three numbers."""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise ValueError(f"{label} must be a list of 3 numbers, got {type(value).__name__}")
|
||||
if len(value) != 3:
|
||||
raise ValueError(f"{label} must have exactly 3 elements, got {len(value)}")
|
||||
try:
|
||||
return [float(v) for v in value]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{label} elements must be numeric: {exc}") from exc
|
||||
|
||||
|
||||
def _get_analysis(project: Dict[str, Any], ai: int) -> Dict[str, Any]:
|
||||
"""Internal accessor with bounds checking.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ai : int
|
||||
Analysis index.
|
||||
"""
|
||||
items = ensure_collection(project, _COLLECTION_KEY)
|
||||
if not isinstance(ai, int) or ai < 0 or ai >= len(items):
|
||||
raise IndexError(
|
||||
f"Analysis index {ai} out of range (0..{len(items) - 1})"
|
||||
)
|
||||
return items[ai]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def new_analysis(
|
||||
project: Dict[str, Any],
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new FEM analysis and append it to the project.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
name : str or None
|
||||
Human-readable label. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created analysis dictionary.
|
||||
"""
|
||||
items = ensure_collection(project, _COLLECTION_KEY)
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "FEMAnalysis")
|
||||
|
||||
analysis: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"constraints": [],
|
||||
"material_index": None,
|
||||
"mesh_params": None,
|
||||
"solver": None,
|
||||
"results": None,
|
||||
}
|
||||
|
||||
items.append(analysis)
|
||||
return analysis
|
||||
|
||||
|
||||
def add_fixed_constraint(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
references: List[Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a fixed (zero-displacement) boundary constraint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
references : list
|
||||
Geometry references (faces, edges, vertices) to fix.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The constraint entry.
|
||||
"""
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
constraint: Dict[str, Any] = {
|
||||
"type": "fixed",
|
||||
"references": list(references),
|
||||
}
|
||||
|
||||
analysis["constraints"].append(constraint)
|
||||
return constraint
|
||||
|
||||
|
||||
def add_force_constraint(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
references: List[Any],
|
||||
magnitude: float,
|
||||
direction: Optional[List[float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a force constraint to the analysis.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
references : list
|
||||
Geometry references where the force is applied.
|
||||
magnitude : float
|
||||
Force magnitude in Newtons.
|
||||
direction : list[float] or None
|
||||
Force direction vector ``[x, y, z]``. Defaults to ``[0, 0, -1]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The constraint entry.
|
||||
"""
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
if direction is not None:
|
||||
direction = _validate_vec3(direction, "direction")
|
||||
else:
|
||||
direction = [0.0, 0.0, -1.0]
|
||||
|
||||
constraint: Dict[str, Any] = {
|
||||
"type": "force",
|
||||
"references": list(references),
|
||||
"magnitude": float(magnitude),
|
||||
"direction": direction,
|
||||
}
|
||||
|
||||
analysis["constraints"].append(constraint)
|
||||
return constraint
|
||||
|
||||
|
||||
def add_pressure_constraint(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
references: List[Any],
|
||||
pressure: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a pressure constraint to the analysis.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
references : list
|
||||
Geometry references (faces) where pressure is applied.
|
||||
pressure : float
|
||||
Pressure value in MPa.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The constraint entry.
|
||||
"""
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
constraint: Dict[str, Any] = {
|
||||
"type": "pressure",
|
||||
"references": list(references),
|
||||
"pressure": float(pressure),
|
||||
}
|
||||
|
||||
analysis["constraints"].append(constraint)
|
||||
return constraint
|
||||
|
||||
|
||||
def add_displacement_constraint(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
references: List[Any],
|
||||
displacement: Optional[List[float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a prescribed displacement constraint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
references : list
|
||||
Geometry references where the displacement is prescribed.
|
||||
displacement : list[float] or None
|
||||
Displacement vector ``[dx, dy, dz]``. Defaults to ``[0, 0, 0]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The constraint entry.
|
||||
"""
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
if displacement is not None:
|
||||
displacement = _validate_vec3(displacement, "displacement")
|
||||
else:
|
||||
displacement = [0.0, 0.0, 0.0]
|
||||
|
||||
constraint: Dict[str, Any] = {
|
||||
"type": "displacement",
|
||||
"references": list(references),
|
||||
"displacement": displacement,
|
||||
}
|
||||
|
||||
analysis["constraints"].append(constraint)
|
||||
return constraint
|
||||
|
||||
|
||||
def add_temperature_constraint(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
references: List[Any],
|
||||
temperature: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a temperature boundary constraint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
references : list
|
||||
Geometry references where temperature is fixed.
|
||||
temperature : float
|
||||
Temperature value in Kelvin.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The constraint entry.
|
||||
"""
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
constraint: Dict[str, Any] = {
|
||||
"type": "temperature",
|
||||
"references": list(references),
|
||||
"temperature": float(temperature),
|
||||
}
|
||||
|
||||
analysis["constraints"].append(constraint)
|
||||
return constraint
|
||||
|
||||
|
||||
def add_heatflux_constraint(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
references: List[Any],
|
||||
flux: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a heat flux boundary constraint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
references : list
|
||||
Geometry references where the heat flux is applied.
|
||||
flux : float
|
||||
Heat flux value in W/m^2.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The constraint entry.
|
||||
"""
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
constraint: Dict[str, Any] = {
|
||||
"type": "heatflux",
|
||||
"references": list(references),
|
||||
"flux": float(flux),
|
||||
}
|
||||
|
||||
analysis["constraints"].append(constraint)
|
||||
return constraint
|
||||
|
||||
|
||||
def set_fem_material(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
material_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Assign a material from the project's materials list to an analysis.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
material_index : int
|
||||
Index into ``project["materials"]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The updated analysis dictionary.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *material_index* is out of range.
|
||||
"""
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
materials = project.get("materials", [])
|
||||
if not isinstance(material_index, int) or material_index < 0 or material_index >= len(materials):
|
||||
raise IndexError(
|
||||
f"Material index {material_index} out of range (0..{len(materials) - 1})"
|
||||
)
|
||||
|
||||
analysis["material_index"] = material_index
|
||||
return analysis
|
||||
|
||||
|
||||
def generate_fem_mesh(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
max_size: Optional[float] = None,
|
||||
min_size: Optional[float] = None,
|
||||
element_type: str = "Tet10",
|
||||
mesher: str = "gmsh",
|
||||
gmsh_verbosity: int = 1,
|
||||
second_order_linear: bool = False,
|
||||
local_refinement: Optional[Dict[str, float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Configure mesh generation parameters for an analysis.
|
||||
|
||||
The actual mesh generation is performed by the generated FreeCAD
|
||||
macro. This function stores the meshing parameters.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
max_size : float or None
|
||||
Maximum element size. When *None*, FreeCAD uses automatic sizing.
|
||||
min_size : float or None
|
||||
Minimum element size. When *None*, FreeCAD uses automatic sizing.
|
||||
element_type : str
|
||||
Element type (e.g. ``"Tet4"``, ``"Tet10"``, ``"Hex8"``).
|
||||
mesher : str
|
||||
Meshing backend (``"gmsh"`` or ``"netgen"``).
|
||||
gmsh_verbosity : int
|
||||
Gmsh verbosity level (only relevant when *mesher* is ``"gmsh"``).
|
||||
second_order_linear : bool
|
||||
Enable Netgen Second Order Linear elements.
|
||||
local_refinement : dict or None
|
||||
Mapping of geometry references to local mesh sizes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The mesh parameters dictionary.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *element_type* or *mesher* is unknown.
|
||||
"""
|
||||
if element_type not in VALID_ELEMENT_TYPES:
|
||||
valid = ", ".join(sorted(VALID_ELEMENT_TYPES))
|
||||
raise ValueError(
|
||||
f"Unknown element_type '{element_type}'. Valid: {valid}"
|
||||
)
|
||||
|
||||
if mesher not in VALID_MESHERS:
|
||||
valid = ", ".join(sorted(VALID_MESHERS))
|
||||
raise ValueError(
|
||||
f"Unknown mesher '{mesher}'. Valid: {valid}"
|
||||
)
|
||||
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
mesh_params: Dict[str, Any] = {
|
||||
"max_size": float(max_size) if max_size is not None else None,
|
||||
"min_size": float(min_size) if min_size is not None else None,
|
||||
"element_type": element_type,
|
||||
"mesher": mesher,
|
||||
"gmsh_verbosity": int(gmsh_verbosity),
|
||||
"second_order_linear": bool(second_order_linear),
|
||||
"local_refinement": dict(local_refinement) if local_refinement is not None else None,
|
||||
}
|
||||
|
||||
analysis["mesh_params"] = mesh_params
|
||||
return mesh_params
|
||||
|
||||
|
||||
def add_beam_section(
|
||||
project: Dict[str, Any],
|
||||
analysis_index: int,
|
||||
section_type: str = "rectangular",
|
||||
references: Optional[List[str]] = None,
|
||||
width: Optional[float] = None,
|
||||
height: Optional[float] = None,
|
||||
radius: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add an ElementGeometry1D beam section (FreeCAD 1.1: box_beam, elliptical).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
analysis_index : int
|
||||
Analysis index.
|
||||
section_type : str
|
||||
Beam cross-section type (``"rectangular"``, ``"circular"``,
|
||||
``"box_beam"``, ``"elliptical"``, ``"pipe"``).
|
||||
references : list[str] or None
|
||||
Geometry references (edges) where the section applies.
|
||||
width : float or None
|
||||
Section width (relevant for rectangular / box_beam / elliptical).
|
||||
height : float or None
|
||||
Section height (relevant for rectangular / box_beam / elliptical).
|
||||
radius : float or None
|
||||
Section radius (relevant for circular / pipe).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The constraint entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *section_type* is unknown.
|
||||
"""
|
||||
if section_type not in VALID_BEAM_SECTIONS:
|
||||
valid = ", ".join(sorted(VALID_BEAM_SECTIONS))
|
||||
raise ValueError(
|
||||
f"Unknown section_type '{section_type}'. Valid: {valid}"
|
||||
)
|
||||
|
||||
analysis = _get_analysis(project, analysis_index)
|
||||
|
||||
constraint: Dict[str, Any] = {
|
||||
"type": "beam_section",
|
||||
"section_type": section_type,
|
||||
"references": list(references) if references is not None else [],
|
||||
"width": float(width) if width is not None else None,
|
||||
"height": float(height) if height is not None else None,
|
||||
"radius": float(radius) if radius is not None else None,
|
||||
}
|
||||
|
||||
analysis["constraints"].append(constraint)
|
||||
return constraint
|
||||
|
||||
|
||||
def add_tie_constraint(
|
||||
project: Dict[str, Any],
|
||||
analysis_index: int,
|
||||
master_refs: List[str],
|
||||
slave_refs: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a tie constraint between shell faces (FreeCAD 1.1).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
analysis_index : int
|
||||
Analysis index.
|
||||
master_refs : list[str]
|
||||
Geometry references for the master surface.
|
||||
slave_refs : list[str]
|
||||
Geometry references for the slave surface.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The constraint entry.
|
||||
"""
|
||||
analysis = _get_analysis(project, analysis_index)
|
||||
|
||||
constraint: Dict[str, Any] = {
|
||||
"type": "tie",
|
||||
"master_refs": list(master_refs),
|
||||
"slave_refs": list(slave_refs),
|
||||
}
|
||||
|
||||
analysis["constraints"].append(constraint)
|
||||
return constraint
|
||||
|
||||
|
||||
def purge_results(
|
||||
project: Dict[str, Any],
|
||||
analysis_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete all result objects from an analysis (FreeCAD 1.1).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
analysis_index : int
|
||||
Analysis index.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The updated analysis dictionary.
|
||||
"""
|
||||
analysis = _get_analysis(project, analysis_index)
|
||||
analysis["results"] = None
|
||||
return analysis
|
||||
|
||||
|
||||
def suppress_object(
|
||||
project: Dict[str, Any],
|
||||
analysis_index: int,
|
||||
constraint_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Toggle suppressed state on a constraint (FreeCAD 1.1).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
analysis_index : int
|
||||
Analysis index.
|
||||
constraint_index : int
|
||||
Index of the constraint to toggle.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The updated constraint dictionary.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *constraint_index* is out of range.
|
||||
"""
|
||||
analysis = _get_analysis(project, analysis_index)
|
||||
constraints = analysis["constraints"]
|
||||
|
||||
if not isinstance(constraint_index, int) or constraint_index < 0 or constraint_index >= len(constraints):
|
||||
raise IndexError(
|
||||
f"Constraint index {constraint_index} out of range "
|
||||
f"(0..{len(constraints) - 1})"
|
||||
)
|
||||
|
||||
constraint = constraints[constraint_index]
|
||||
constraint["suppressed"] = not constraint.get("suppressed", False)
|
||||
return constraint
|
||||
|
||||
|
||||
def solve_fem(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
solver: str = "calculix",
|
||||
output_format: Optional[str] = None,
|
||||
buckling_accuracy: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Configure the FEM solver for an analysis.
|
||||
|
||||
The actual solving is performed by the generated FreeCAD macro.
|
||||
This function stores the solver configuration and validates that
|
||||
the analysis has the minimum required setup.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
solver : str
|
||||
Solver backend name (``"calculix"``, ``"elmer"``, ``"z88"``).
|
||||
output_format : str or None
|
||||
Result output format (``"vtu"``, ``"vtk"``, ``"result"``).
|
||||
When *None*, the solver default is used.
|
||||
buckling_accuracy : float or None
|
||||
Buckling accuracy parameter for CalculiX solver.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Solver configuration summary.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *solver* is unknown, *output_format* is invalid, or the
|
||||
analysis is missing constraints or mesh parameters.
|
||||
"""
|
||||
if solver not in VALID_SOLVERS:
|
||||
valid = ", ".join(sorted(VALID_SOLVERS))
|
||||
raise ValueError(f"Unknown solver '{solver}'. Valid: {valid}")
|
||||
|
||||
if output_format is not None and output_format not in VALID_OUTPUT_FORMATS:
|
||||
valid = ", ".join(sorted(VALID_OUTPUT_FORMATS))
|
||||
raise ValueError(
|
||||
f"Unknown output_format '{output_format}'. Valid: {valid}"
|
||||
)
|
||||
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
if not analysis["constraints"]:
|
||||
raise ValueError("Analysis has no constraints defined")
|
||||
|
||||
if analysis["mesh_params"] is None:
|
||||
raise ValueError(
|
||||
"Mesh parameters must be set before solving "
|
||||
"(call generate_fem_mesh first)"
|
||||
)
|
||||
|
||||
analysis["solver"] = solver
|
||||
analysis["results"] = {
|
||||
"status": "pending",
|
||||
"solver": solver,
|
||||
"constraints_count": len(analysis["constraints"]),
|
||||
"output_format": output_format,
|
||||
"buckling_accuracy": float(buckling_accuracy) if buckling_accuracy is not None else None,
|
||||
}
|
||||
|
||||
return analysis["results"]
|
||||
|
||||
|
||||
def get_fem_results(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return the results of an analysis.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The results dictionary, or a status indicator if not yet solved.
|
||||
"""
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
if analysis["results"] is None:
|
||||
return {"status": "not_run", "message": "Analysis has not been solved yet"}
|
||||
|
||||
return analysis["results"]
|
||||
|
||||
|
||||
def export_fem_results(
|
||||
project: Dict[str, Any],
|
||||
ai: int,
|
||||
path: str,
|
||||
format: str = "vtk",
|
||||
) -> Dict[str, Any]:
|
||||
"""Record metadata for exporting FEM results.
|
||||
|
||||
The actual export is performed by the generated FreeCAD macro.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
ai : int
|
||||
Analysis index.
|
||||
path : str
|
||||
Output file path.
|
||||
format : str
|
||||
Export format (``"vtk"``, ``"csv"``, ``"json"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Export metadata.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *format* is unknown or *path* is invalid.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Path must be a non-empty string")
|
||||
|
||||
if format not in VALID_EXPORT_FORMATS:
|
||||
valid = ", ".join(sorted(VALID_EXPORT_FORMATS))
|
||||
raise ValueError(f"Unknown format '{format}'. Valid: {valid}")
|
||||
|
||||
analysis = _get_analysis(project, ai)
|
||||
|
||||
return {
|
||||
"action": "export_fem_results",
|
||||
"analysis_name": analysis["name"],
|
||||
"analysis_index": ai,
|
||||
"path": path.strip(),
|
||||
"format": format,
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
"""FreeCAD CLI - Import module.
|
||||
|
||||
Provides functions for importing geometry files in various formats into
|
||||
the project state. Depending on the format, imported geometry is added
|
||||
to ``project["parts"]``, ``project["meshes"]``, or
|
||||
``project["draft_objects"]``.
|
||||
|
||||
Named ``import_mod`` to avoid collision with the Python ``import`` keyword.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from cli_anything.freecad.core.document import ensure_collection
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Formats that produce solid/BREP parts.
|
||||
PART_FORMATS: Set[str] = {"step", "stp", "iges", "igs", "brep", "brp"}
|
||||
|
||||
#: Formats that produce triangle meshes.
|
||||
MESH_FORMATS: Set[str] = {"stl", "obj", "ply", "off", "3mf", "amf", "gltf", "glb"}
|
||||
|
||||
#: Formats that produce 2D draft / mixed objects.
|
||||
DRAFT_FORMATS: Set[str] = {"dxf", "svg"}
|
||||
|
||||
#: Extension -> canonical format name mapping.
|
||||
EXT_MAP: Dict[str, str] = {
|
||||
".step": "step",
|
||||
".stp": "step",
|
||||
".iges": "iges",
|
||||
".igs": "iges",
|
||||
".stl": "stl",
|
||||
".obj": "obj",
|
||||
".dxf": "dxf",
|
||||
".svg": "svg",
|
||||
".brep": "brep",
|
||||
".brp": "brep",
|
||||
".3mf": "3mf",
|
||||
".ply": "ply",
|
||||
".off": "off",
|
||||
".gltf": "gltf",
|
||||
".glb": "gltf",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_path(path: str, label: str = "path") -> str:
|
||||
"""Validate that *path* is a non-empty string and return it stripped."""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError(f"{label} must be a non-empty string")
|
||||
return path.strip()
|
||||
|
||||
|
||||
def _detect_format(path: str) -> str:
|
||||
"""Detect the canonical format from a file extension.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The canonical format key (e.g. ``"step"``, ``"stl"``).
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the extension is not recognised.
|
||||
"""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext not in EXT_MAP:
|
||||
raise ValueError(
|
||||
f"Cannot detect format from extension '{ext}'. "
|
||||
f"Supported extensions: {', '.join(sorted(EXT_MAP))}"
|
||||
)
|
||||
return EXT_MAP[ext]
|
||||
|
||||
|
||||
def _next_part_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for parts."""
|
||||
items = project.get("parts", [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _next_mesh_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for meshes."""
|
||||
items = project.get("meshes", [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _next_draft_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for draft objects."""
|
||||
items = project.get("draft_objects", [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _unique_name(project: Dict[str, Any], base: str, key: str) -> str:
|
||||
"""Return a unique name derived from *base* inside ``project[key]``."""
|
||||
existing = {item["name"] for item in project.get(key, [])}
|
||||
if base not in existing:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base}_{counter}" in existing:
|
||||
counter += 1
|
||||
return f"{base}_{counter}"
|
||||
|
||||
|
||||
def _default_name(path: str, name: Optional[str], project: Dict[str, Any], key: str) -> str:
|
||||
"""Derive a name from *path* if *name* is None, then make it unique."""
|
||||
if name is not None:
|
||||
return name
|
||||
base = os.path.splitext(os.path.basename(path))[0]
|
||||
return _unique_name(project, base, key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal import builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _import_as_part(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
fmt: str,
|
||||
name: Optional[str] = None,
|
||||
import_params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create an imported part entry in ``project["parts"]``."""
|
||||
parts = ensure_collection(project, "parts")
|
||||
label = _default_name(path, name, project, "parts")
|
||||
|
||||
part: Dict[str, Any] = {
|
||||
"id": _next_part_id(project),
|
||||
"name": label,
|
||||
"type": "imported",
|
||||
"params": {
|
||||
"source_path": path,
|
||||
"source_format": fmt,
|
||||
"import_params": import_params or {},
|
||||
},
|
||||
"placement": {
|
||||
"position": [0.0, 0.0, 0.0],
|
||||
"rotation": [0.0, 0.0, 0.0],
|
||||
},
|
||||
"material_index": None,
|
||||
"visible": True,
|
||||
}
|
||||
|
||||
parts.append(part)
|
||||
return part
|
||||
|
||||
|
||||
def _import_as_mesh(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
fmt: str,
|
||||
name: Optional[str] = None,
|
||||
import_params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create an imported mesh entry in ``project["meshes"]``."""
|
||||
meshes = ensure_collection(project, "meshes")
|
||||
label = _default_name(path, name, project, "meshes")
|
||||
|
||||
mesh: Dict[str, Any] = {
|
||||
"id": _next_mesh_id(project),
|
||||
"name": label,
|
||||
"source": path,
|
||||
"format": fmt,
|
||||
"vertices_count": 0,
|
||||
"faces_count": 0,
|
||||
"operations_applied": [],
|
||||
}
|
||||
|
||||
meshes.append(mesh)
|
||||
return mesh
|
||||
|
||||
|
||||
def _import_as_draft(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
fmt: str,
|
||||
name: Optional[str] = None,
|
||||
import_params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create an imported draft object entry in ``project["draft_objects"]``."""
|
||||
objs = ensure_collection(project, "draft_objects")
|
||||
label = _default_name(path, name, project, "draft_objects")
|
||||
|
||||
draft_obj: Dict[str, Any] = {
|
||||
"id": _next_draft_id(project),
|
||||
"name": label,
|
||||
"type": "imported",
|
||||
"properties": {
|
||||
"source_path": path,
|
||||
"source_format": fmt,
|
||||
"import_params": import_params or {},
|
||||
},
|
||||
"placement": {
|
||||
"position": [0.0, 0.0, 0.0],
|
||||
"rotation": [0.0, 0.0, 0.0],
|
||||
},
|
||||
"visible": True,
|
||||
}
|
||||
|
||||
objs.append(draft_obj)
|
||||
return draft_obj
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def import_file(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
format: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a file, auto-detecting the format from its extension.
|
||||
|
||||
Depending on the detected format the imported geometry is placed in
|
||||
``project["parts"]``, ``project["meshes"]``, or
|
||||
``project["draft_objects"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Filesystem path to the file.
|
||||
format : str or None
|
||||
Explicit format override. When *None* the format is detected
|
||||
from the file extension.
|
||||
name : str or None
|
||||
Label for the imported object. Derived from filename when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created import entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the format cannot be detected or is unsupported.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
fmt = format.lower() if format else _detect_format(path)
|
||||
|
||||
if fmt in PART_FORMATS or fmt in {"step", "iges", "brep"}:
|
||||
return _import_as_part(project, path, fmt, name)
|
||||
elif fmt in MESH_FORMATS:
|
||||
return _import_as_mesh(project, path, fmt, name)
|
||||
elif fmt in DRAFT_FORMATS:
|
||||
return _import_as_draft(project, path, fmt, name)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported format '{fmt}'. Supported: "
|
||||
f"{', '.join(sorted(PART_FORMATS | MESH_FORMATS | DRAFT_FORMATS))}"
|
||||
)
|
||||
|
||||
|
||||
def import_step(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a STEP file into ``project["parts"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the STEP file.
|
||||
name : str or None
|
||||
Label for the imported part.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created part entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_part(project, path, "step", name)
|
||||
|
||||
|
||||
def import_iges(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import an IGES file into ``project["parts"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the IGES file.
|
||||
name : str or None
|
||||
Label for the imported part.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created part entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_part(project, path, "iges", name)
|
||||
|
||||
|
||||
def import_stl(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import an STL file into ``project["meshes"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the STL file.
|
||||
name : str or None
|
||||
Label for the imported mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created mesh entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_mesh(project, path, "stl", name)
|
||||
|
||||
|
||||
def import_obj(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import an OBJ file into ``project["meshes"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the OBJ file.
|
||||
name : str or None
|
||||
Label for the imported mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created mesh entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_mesh(project, path, "obj", name)
|
||||
|
||||
|
||||
def import_dxf(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a DXF file into ``project["draft_objects"]`` or ``project["parts"]``.
|
||||
|
||||
DXF files primarily contain 2D geometry and are imported as draft
|
||||
objects by default.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the DXF file.
|
||||
name : str or None
|
||||
Label for the imported object.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created draft object entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_draft(project, path, "dxf", name)
|
||||
|
||||
|
||||
def import_svg(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import an SVG file into ``project["draft_objects"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the SVG file.
|
||||
name : str or None
|
||||
Label for the imported object.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created draft object entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_draft(project, path, "svg", name)
|
||||
|
||||
|
||||
def import_brep(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a BREP file into ``project["parts"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the BREP file.
|
||||
name : str or None
|
||||
Label for the imported part.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created part entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_part(project, path, "brep", name)
|
||||
|
||||
|
||||
def import_3mf(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a 3MF file into ``project["meshes"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the 3MF file.
|
||||
name : str or None
|
||||
Label for the imported mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created mesh entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_mesh(project, path, "3mf", name)
|
||||
|
||||
|
||||
def import_ply(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a PLY file into ``project["meshes"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the PLY file.
|
||||
name : str or None
|
||||
Label for the imported mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created mesh entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_mesh(project, path, "ply", name)
|
||||
|
||||
|
||||
def import_off(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import an OFF file into ``project["meshes"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the OFF file.
|
||||
name : str or None
|
||||
Label for the imported mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created mesh entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_mesh(project, path, "off", name)
|
||||
|
||||
|
||||
def import_gltf(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a glTF/GLB file into ``project["meshes"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Path to the glTF or GLB file.
|
||||
name : str or None
|
||||
Label for the imported mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created mesh entry.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
return _import_as_mesh(project, path, "gltf", name)
|
||||
|
||||
|
||||
def import_info(path: str) -> Dict[str, Any]:
|
||||
"""Preview file metadata without modifying any project.
|
||||
|
||||
Returns information about the file including size, detected format,
|
||||
and estimated object count. Does **not** require a project dict.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : str
|
||||
Filesystem path to the file.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Metadata dictionary with keys ``path``, ``format``,
|
||||
``size_bytes``, ``exists``, and ``estimated_objects``.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *path* is empty or the format is unrecognised.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
fmt = _detect_format(path)
|
||||
|
||||
exists = os.path.isfile(path)
|
||||
size = os.path.getsize(path) if exists else 0
|
||||
|
||||
# Classify destination
|
||||
if fmt in PART_FORMATS:
|
||||
target = "parts"
|
||||
elif fmt in MESH_FORMATS:
|
||||
target = "meshes"
|
||||
elif fmt in DRAFT_FORMATS:
|
||||
target = "draft_objects"
|
||||
else:
|
||||
target = "unknown"
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"format": fmt,
|
||||
"size_bytes": size,
|
||||
"exists": exists,
|
||||
"estimated_objects": 1,
|
||||
"target_collection": target,
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
"""
|
||||
Materials module for the FreeCAD CLI harness.
|
||||
|
||||
Provides material creation, assignment, and management with a library of
|
||||
physically-based rendering presets for common engineering materials.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Material presets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"steel": {
|
||||
"color": [0.7, 0.7, 0.75, 1.0],
|
||||
"metallic": 0.9,
|
||||
"roughness": 0.3,
|
||||
},
|
||||
"aluminum": {
|
||||
"color": [0.8, 0.8, 0.85, 1.0],
|
||||
"metallic": 0.9,
|
||||
"roughness": 0.2,
|
||||
},
|
||||
"copper": {
|
||||
"color": [0.72, 0.45, 0.2, 1.0],
|
||||
"metallic": 1.0,
|
||||
"roughness": 0.25,
|
||||
},
|
||||
"brass": {
|
||||
"color": [0.78, 0.68, 0.35, 1.0],
|
||||
"metallic": 0.9,
|
||||
"roughness": 0.3,
|
||||
},
|
||||
"plastic_white": {
|
||||
"color": [0.95, 0.95, 0.95, 1.0],
|
||||
"metallic": 0.0,
|
||||
"roughness": 0.4,
|
||||
},
|
||||
"plastic_black": {
|
||||
"color": [0.1, 0.1, 0.1, 1.0],
|
||||
"metallic": 0.0,
|
||||
"roughness": 0.5,
|
||||
},
|
||||
"wood": {
|
||||
"color": [0.55, 0.35, 0.15, 1.0],
|
||||
"metallic": 0.0,
|
||||
"roughness": 0.7,
|
||||
},
|
||||
"glass": {
|
||||
"color": [0.85, 0.9, 0.95, 0.3],
|
||||
"metallic": 0.0,
|
||||
"roughness": 0.05,
|
||||
},
|
||||
"rubber": {
|
||||
"color": [0.15, 0.15, 0.15, 1.0],
|
||||
"metallic": 0.0,
|
||||
"roughness": 0.9,
|
||||
},
|
||||
"gold": {
|
||||
"color": [1.0, 0.84, 0.0, 1.0],
|
||||
"metallic": 1.0,
|
||||
"roughness": 0.1,
|
||||
},
|
||||
"titanium": {
|
||||
"color": [0.75, 0.75, 0.78, 1.0],
|
||||
"metallic": 0.9,
|
||||
"roughness": 0.25,
|
||||
"density": 4507,
|
||||
"youngs_modulus": 116,
|
||||
"poisson_ratio": 0.34,
|
||||
"yield_strength": 880,
|
||||
"ultimate_strength": 950,
|
||||
},
|
||||
"stainless_steel": {
|
||||
"color": [0.75, 0.75, 0.77, 1.0],
|
||||
"metallic": 0.95,
|
||||
"roughness": 0.2,
|
||||
"density": 8000,
|
||||
"youngs_modulus": 193,
|
||||
"poisson_ratio": 0.29,
|
||||
"yield_strength": 205,
|
||||
"ultimate_strength": 515,
|
||||
},
|
||||
"cast_iron": {
|
||||
"color": [0.4, 0.4, 0.42, 1.0],
|
||||
"metallic": 0.85,
|
||||
"roughness": 0.6,
|
||||
"density": 7200,
|
||||
"youngs_modulus": 170,
|
||||
"poisson_ratio": 0.26,
|
||||
},
|
||||
"carbon_fiber": {
|
||||
"color": [0.1, 0.1, 0.12, 1.0],
|
||||
"metallic": 0.3,
|
||||
"roughness": 0.15,
|
||||
"density": 1600,
|
||||
"youngs_modulus": 230,
|
||||
},
|
||||
"nylon": {
|
||||
"color": [0.9, 0.88, 0.82, 1.0],
|
||||
"metallic": 0.0,
|
||||
"roughness": 0.5,
|
||||
"density": 1150,
|
||||
"youngs_modulus": 2.7,
|
||||
},
|
||||
"abs": {
|
||||
"color": [0.95, 0.95, 0.9, 1.0],
|
||||
"metallic": 0.0,
|
||||
"roughness": 0.45,
|
||||
"density": 1040,
|
||||
"youngs_modulus": 2.3,
|
||||
},
|
||||
"pla": {
|
||||
"color": [0.9, 0.9, 0.85, 1.0],
|
||||
"metallic": 0.0,
|
||||
"roughness": 0.4,
|
||||
"density": 1240,
|
||||
"youngs_modulus": 3.5,
|
||||
},
|
||||
"petg": {
|
||||
"color": [0.85, 0.88, 0.92, 1.0],
|
||||
"metallic": 0.05,
|
||||
"roughness": 0.35,
|
||||
"density": 1270,
|
||||
"youngs_modulus": 2.2,
|
||||
},
|
||||
"concrete": {
|
||||
"color": [0.7, 0.7, 0.68, 1.0],
|
||||
"metallic": 0.0,
|
||||
"roughness": 0.9,
|
||||
"density": 2400,
|
||||
"youngs_modulus": 30,
|
||||
},
|
||||
"granite": {
|
||||
"color": [0.55, 0.5, 0.48, 1.0],
|
||||
"metallic": 0.1,
|
||||
"roughness": 0.7,
|
||||
"density": 2700,
|
||||
"youngs_modulus": 70,
|
||||
},
|
||||
"marble": {
|
||||
"color": [0.92, 0.9, 0.88, 1.0],
|
||||
"metallic": 0.05,
|
||||
"roughness": 0.3,
|
||||
"density": 2700,
|
||||
"youngs_modulus": 70,
|
||||
},
|
||||
}
|
||||
|
||||
# Valid material properties and their constraints
|
||||
MATERIAL_PROPS: Dict[str, Dict[str, Any]] = {
|
||||
"color": {"type": "color4", "description": "Base color [R, G, B, A] (0.0-1.0)"},
|
||||
"metallic": {"type": "float", "min": 0.0, "max": 1.0, "description": "Metallic factor"},
|
||||
"roughness": {"type": "float", "min": 0.0, "max": 1.0, "description": "Roughness factor"},
|
||||
"name": {"type": "str", "description": "Material display name"},
|
||||
"density": {"type": "float", "min": 0.0, "description": "Density (kg/m^3)"},
|
||||
"youngs_modulus": {"type": "float", "min": 0.0, "description": "Young's modulus (GPa)"},
|
||||
"poisson_ratio": {"type": "float", "min": 0.0, "max": 0.5, "description": "Poisson's ratio"},
|
||||
"thermal_conductivity": {"type": "float", "min": 0.0, "description": "Thermal conductivity (W/(m*K))"},
|
||||
"specific_heat": {"type": "float", "min": 0.0, "description": "Specific heat capacity (J/(kg*K))"},
|
||||
"yield_strength": {"type": "float", "min": 0.0, "description": "Yield strength (MPa)"},
|
||||
"ultimate_strength": {"type": "float", "min": 0.0, "description": "Ultimate tensile strength (MPa)"},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _next_id(project: Dict[str, Any]) -> int:
|
||||
"""Generate the next unique material ID."""
|
||||
materials = project.get("materials", [])
|
||||
existing_ids = [m.get("id", 0) for m in materials]
|
||||
return max(existing_ids, default=-1) + 1
|
||||
|
||||
|
||||
def _unique_name(project: Dict[str, Any], base_name: str) -> str:
|
||||
"""Generate a unique material name."""
|
||||
materials = project.get("materials", [])
|
||||
existing_names = {m.get("name", "") for m in materials}
|
||||
if base_name not in existing_names:
|
||||
return base_name
|
||||
counter = 1
|
||||
while f"{base_name}.{counter:03d}" in existing_names:
|
||||
counter += 1
|
||||
return f"{base_name}.{counter:03d}"
|
||||
|
||||
|
||||
def _validate_project(project: Dict[str, Any]) -> None:
|
||||
"""Raise ``ValueError`` if *project* is not a valid dict with a materials list."""
|
||||
if not isinstance(project, dict):
|
||||
raise ValueError("Project must be a dictionary")
|
||||
if "materials" not in project:
|
||||
raise ValueError("Project is missing 'materials' collection")
|
||||
if not isinstance(project["materials"], list):
|
||||
raise ValueError("Project 'materials' must be a list")
|
||||
|
||||
|
||||
def _get_material(project: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"""Return material at *index* or raise ``IndexError``."""
|
||||
materials = project["materials"]
|
||||
if index < 0 or index >= len(materials):
|
||||
raise IndexError(
|
||||
f"Material index {index} out of range (0-{len(materials) - 1})"
|
||||
)
|
||||
return materials[index]
|
||||
|
||||
|
||||
def _validate_color(color: List[float]) -> List[float]:
|
||||
"""Validate and return a color as a list of 4 floats in [0, 1]."""
|
||||
if not isinstance(color, (list, tuple)):
|
||||
raise ValueError(f"Color must be a list, got {type(color).__name__}")
|
||||
if len(color) < 3:
|
||||
raise ValueError(f"Color must have at least 3 components [R, G, B], got {len(color)}")
|
||||
if len(color) == 3:
|
||||
color = list(color) + [1.0]
|
||||
if len(color) > 4:
|
||||
raise ValueError(f"Color must have at most 4 components [R, G, B, A], got {len(color)}")
|
||||
result: List[float] = []
|
||||
for i, c in enumerate(color):
|
||||
try:
|
||||
val = float(c)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Color component {i} must be numeric: {exc}") from exc
|
||||
if not 0.0 <= val <= 1.0:
|
||||
raise ValueError(f"Color component {i} must be 0.0-1.0, got {val}")
|
||||
result.append(val)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_material(
|
||||
project: Dict[str, Any],
|
||||
name: str = "Material",
|
||||
preset: Optional[str] = None,
|
||||
color: Optional[List[float]] = None,
|
||||
metallic: float = 0.0,
|
||||
roughness: float = 0.5,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new material, optionally based on a preset.
|
||||
|
||||
When *preset* is given, its ``color``, ``metallic``, and ``roughness``
|
||||
values are used as defaults. Explicit *color*, *metallic*, and
|
||||
*roughness* arguments override the preset values.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project:
|
||||
The project dictionary.
|
||||
name:
|
||||
Material display name.
|
||||
preset:
|
||||
Optional preset key from :data:`PRESETS`.
|
||||
color:
|
||||
Base color ``[R, G, B, A]`` with components in ``[0, 1]``.
|
||||
metallic:
|
||||
Metallic factor ``[0, 1]``.
|
||||
roughness:
|
||||
Roughness factor ``[0, 1]``.
|
||||
**kwargs:
|
||||
Optional engineering properties: ``density``, ``youngs_modulus``,
|
||||
``poisson_ratio``, ``thermal_conductivity``, ``specific_heat``,
|
||||
``yield_strength``, ``ultimate_strength``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
The newly created material dictionary.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the preset is unknown, colour is invalid, or numeric values are
|
||||
out of range.
|
||||
"""
|
||||
_validate_project(project)
|
||||
|
||||
# Resolve preset defaults
|
||||
preset_data: Dict[str, Any] = {}
|
||||
if preset is not None:
|
||||
if preset not in PRESETS:
|
||||
raise ValueError(
|
||||
f"Unknown preset '{preset}'. Available presets: {', '.join(sorted(PRESETS))}"
|
||||
)
|
||||
preset_data = PRESETS[preset]
|
||||
# Use preset name as the material name if caller left the default
|
||||
if name == "Material":
|
||||
name = preset.replace("_", " ").title()
|
||||
|
||||
# Determine final values (explicit args override preset)
|
||||
final_color: List[float]
|
||||
if color is not None:
|
||||
final_color = _validate_color(color)
|
||||
elif "color" in preset_data:
|
||||
final_color = list(preset_data["color"])
|
||||
else:
|
||||
final_color = [0.8, 0.8, 0.8, 1.0]
|
||||
|
||||
final_metallic = metallic
|
||||
if preset_data and metallic == 0.0 and "metallic" in preset_data:
|
||||
# Only use preset metallic when caller left the default
|
||||
final_metallic = preset_data["metallic"]
|
||||
final_metallic = float(final_metallic)
|
||||
|
||||
final_roughness = roughness
|
||||
if preset_data and roughness == 0.5 and "roughness" in preset_data:
|
||||
final_roughness = preset_data["roughness"]
|
||||
final_roughness = float(final_roughness)
|
||||
|
||||
if not 0.0 <= final_metallic <= 1.0:
|
||||
raise ValueError(f"Metallic must be 0.0-1.0, got {final_metallic}")
|
||||
if not 0.0 <= final_roughness <= 1.0:
|
||||
raise ValueError(f"Roughness must be 0.0-1.0, got {final_roughness}")
|
||||
|
||||
mat_name = _unique_name(project, name)
|
||||
|
||||
mat: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": mat_name,
|
||||
"preset": preset,
|
||||
"color": final_color,
|
||||
"metallic": final_metallic,
|
||||
"roughness": final_roughness,
|
||||
"assigned_to": [],
|
||||
}
|
||||
|
||||
# Engineering properties from preset (as defaults) and kwargs (overrides)
|
||||
_ENG_PROPS = (
|
||||
"density", "youngs_modulus", "poisson_ratio",
|
||||
"thermal_conductivity", "specific_heat",
|
||||
"yield_strength", "ultimate_strength",
|
||||
)
|
||||
for ep in _ENG_PROPS:
|
||||
value = kwargs.get(ep)
|
||||
if value is None and preset_data:
|
||||
value = preset_data.get(ep)
|
||||
if value is not None:
|
||||
value = float(value)
|
||||
spec = MATERIAL_PROPS.get(ep, {})
|
||||
if spec.get("min") is not None and value < spec["min"]:
|
||||
raise ValueError(
|
||||
f"Property '{ep}' minimum is {spec['min']}, got {value}"
|
||||
)
|
||||
if spec.get("max") is not None and value > spec["max"]:
|
||||
raise ValueError(
|
||||
f"Property '{ep}' maximum is {spec['max']}, got {value}"
|
||||
)
|
||||
mat[ep] = value
|
||||
|
||||
project["materials"].append(mat)
|
||||
return mat
|
||||
|
||||
|
||||
def assign_material(
|
||||
project: Dict[str, Any],
|
||||
material_index: int,
|
||||
part_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Assign a material to a part.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project:
|
||||
The project dictionary.
|
||||
material_index:
|
||||
Index of the material in ``project["materials"]``.
|
||||
part_index:
|
||||
Index of the part in ``project["parts"]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
Assignment summary with material and part names/IDs.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If either index is out of range.
|
||||
"""
|
||||
_validate_project(project)
|
||||
|
||||
mat = _get_material(project, material_index)
|
||||
|
||||
parts = project.get("parts", [])
|
||||
if not isinstance(parts, list):
|
||||
raise ValueError("Project 'parts' must be a list")
|
||||
if part_index < 0 or part_index >= len(parts):
|
||||
raise IndexError(
|
||||
f"Part index {part_index} out of range (0-{len(parts) - 1})"
|
||||
)
|
||||
|
||||
part = parts[part_index]
|
||||
|
||||
# Record the assignment on the material
|
||||
if part_index not in mat.get("assigned_to", []):
|
||||
mat.setdefault("assigned_to", []).append(part_index)
|
||||
|
||||
# Record the material on the part
|
||||
part["material_id"] = mat["id"]
|
||||
part["material_index"] = material_index
|
||||
|
||||
return {
|
||||
"material": mat["name"],
|
||||
"material_id": mat["id"],
|
||||
"part": part.get("name", f"Part {part_index}"),
|
||||
"part_id": part.get("id", part_index),
|
||||
}
|
||||
|
||||
|
||||
def list_materials(project: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Return a summary list of all materials in the project.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project:
|
||||
The project dictionary.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[Dict[str, Any]]
|
||||
List of material summaries.
|
||||
"""
|
||||
_validate_project(project)
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
for i, mat in enumerate(project["materials"]):
|
||||
result.append({
|
||||
"index": i,
|
||||
"id": mat.get("id", i),
|
||||
"name": mat.get("name", f"Material {i}"),
|
||||
"preset": mat.get("preset"),
|
||||
"color": mat.get("color", [0.8, 0.8, 0.8, 1.0]),
|
||||
"metallic": mat.get("metallic", 0.0),
|
||||
"roughness": mat.get("roughness", 0.5),
|
||||
"assigned_to": mat.get("assigned_to", []),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_material(project: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"""Return the full material dictionary at the given index.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project:
|
||||
The project dictionary.
|
||||
index:
|
||||
Material index.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
The complete material dictionary.
|
||||
"""
|
||||
_validate_project(project)
|
||||
return _get_material(project, index)
|
||||
|
||||
|
||||
def set_material_property(
|
||||
project: Dict[str, Any],
|
||||
index: int,
|
||||
prop: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
"""Set a single property on a material.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project:
|
||||
The project dictionary.
|
||||
index:
|
||||
Material index.
|
||||
prop:
|
||||
Property name. One of ``"color"``, ``"metallic"``, ``"roughness"``,
|
||||
or ``"name"``.
|
||||
value:
|
||||
New value. Type depends on the property.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *index* is out of range.
|
||||
ValueError
|
||||
If *prop* is unknown or *value* is invalid.
|
||||
"""
|
||||
_validate_project(project)
|
||||
mat = _get_material(project, index)
|
||||
|
||||
if prop not in MATERIAL_PROPS:
|
||||
raise ValueError(
|
||||
f"Unknown material property: '{prop}'. "
|
||||
f"Valid properties: {', '.join(sorted(MATERIAL_PROPS))}"
|
||||
)
|
||||
|
||||
spec = MATERIAL_PROPS[prop]
|
||||
ptype = spec["type"]
|
||||
|
||||
if ptype == "float":
|
||||
value = float(value)
|
||||
if "min" in spec and value < spec["min"]:
|
||||
raise ValueError(f"Property '{prop}' minimum is {spec['min']}, got {value}")
|
||||
if "max" in spec and value > spec["max"]:
|
||||
raise ValueError(f"Property '{prop}' maximum is {spec['max']}, got {value}")
|
||||
mat[prop] = value
|
||||
elif ptype == "color4":
|
||||
if isinstance(value, str):
|
||||
value = [float(x.strip()) for x in value.split(",")]
|
||||
mat[prop] = _validate_color(value)
|
||||
elif ptype == "str":
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Property '{prop}' must be a non-empty string")
|
||||
mat[prop] = value.strip()
|
||||
else:
|
||||
mat[prop] = value
|
||||
|
||||
|
||||
def list_presets() -> List[Dict[str, Any]]:
|
||||
"""Return a list of all available material presets.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List[Dict[str, Any]]
|
||||
Each entry contains the preset ``name``, ``color``, ``metallic``,
|
||||
``roughness``, and any engineering properties.
|
||||
"""
|
||||
results: List[Dict[str, Any]] = []
|
||||
for key, value in PRESETS.items():
|
||||
entry: Dict[str, Any] = {
|
||||
"name": key,
|
||||
"color": list(value["color"]),
|
||||
"metallic": value["metallic"],
|
||||
"roughness": value["roughness"],
|
||||
}
|
||||
for ep in (
|
||||
"density", "youngs_modulus", "poisson_ratio",
|
||||
"thermal_conductivity", "specific_heat",
|
||||
"yield_strength", "ultimate_strength",
|
||||
):
|
||||
if ep in value:
|
||||
entry[ep] = value[ep]
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
def import_material(project: Dict[str, Any], path: str) -> Dict[str, Any]:
|
||||
"""Load a material from a JSON file and add it to the project.
|
||||
|
||||
The JSON file should contain keys such as ``name``, ``color``,
|
||||
``metallic``, ``roughness``, and optional engineering properties.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project:
|
||||
The project dictionary.
|
||||
path:
|
||||
Path to a JSON file describing the material.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
The newly created material dictionary.
|
||||
|
||||
Raises
|
||||
------
|
||||
FileNotFoundError
|
||||
If *path* does not exist.
|
||||
ValueError
|
||||
If the JSON is invalid or material properties are out of range.
|
||||
"""
|
||||
_validate_project(project)
|
||||
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Material JSON must be an object, got {type(data).__name__}")
|
||||
|
||||
# Extract recognised kwargs for create_material
|
||||
create_kwargs: Dict[str, Any] = {}
|
||||
for ep in (
|
||||
"density", "youngs_modulus", "poisson_ratio",
|
||||
"thermal_conductivity", "specific_heat",
|
||||
"yield_strength", "ultimate_strength",
|
||||
):
|
||||
if ep in data:
|
||||
create_kwargs[ep] = data[ep]
|
||||
|
||||
return create_material(
|
||||
project,
|
||||
name=data.get("name", "Imported Material"),
|
||||
preset=data.get("preset"),
|
||||
color=data.get("color"),
|
||||
metallic=float(data.get("metallic", 0.0)),
|
||||
roughness=float(data.get("roughness", 0.5)),
|
||||
**create_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def export_material(project: Dict[str, Any], index: int, path: str) -> Dict[str, Any]:
|
||||
"""Save a material to a JSON file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project:
|
||||
The project dictionary.
|
||||
index:
|
||||
Material index.
|
||||
path:
|
||||
Destination file path.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Dict[str, Any]
|
||||
Summary with ``path`` and ``material_name``.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *index* is out of range.
|
||||
"""
|
||||
_validate_project(project)
|
||||
mat = _get_material(project, index)
|
||||
|
||||
# Build a clean export dict (omit internal bookkeeping)
|
||||
export_data: Dict[str, Any] = {}
|
||||
for key in (
|
||||
"name", "color", "metallic", "roughness", "preset",
|
||||
"density", "youngs_modulus", "poisson_ratio",
|
||||
"thermal_conductivity", "specific_heat",
|
||||
"yield_strength", "ultimate_strength",
|
||||
):
|
||||
if key in mat:
|
||||
export_data[key] = mat[key]
|
||||
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(export_data, fh, indent=2)
|
||||
|
||||
return {"path": path, "material_name": mat.get("name", f"Material {index}")}
|
||||
@@ -0,0 +1,730 @@
|
||||
"""FreeCAD CLI - Measurement and geometry analysis module.
|
||||
|
||||
Computes measurements from part/body geometry stored in the JSON project
|
||||
state. For simple primitives (box, cylinder, sphere, cone, torus) the
|
||||
module implements exact mathematical formulas. More complex shapes store
|
||||
measurement requests that are resolved via macro execution.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cli_anything.freecad.core.document import ensure_collection
|
||||
from cli_anything.freecad.core.parts import PRIMITIVES, get_part
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _next_measurement_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for measurements."""
|
||||
items = project.get("measurements", [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _store_measurement(
|
||||
project: Dict[str, Any],
|
||||
kind: str,
|
||||
result: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Wrap *result* in a measurement record and append to the project."""
|
||||
measurements = ensure_collection(project, "measurements")
|
||||
record: Dict[str, Any] = {
|
||||
"id": _next_measurement_id(project),
|
||||
"kind": kind,
|
||||
**result,
|
||||
}
|
||||
measurements.append(record)
|
||||
return record
|
||||
|
||||
|
||||
def _get_position(part: Dict[str, Any]) -> List[float]:
|
||||
"""Return the placement position of a part as ``[x, y, z]``."""
|
||||
return list(part["placement"]["position"])
|
||||
|
||||
|
||||
def _bbox_center(part: Dict[str, Any]) -> List[float]:
|
||||
"""Estimate the bounding-box centre of a part from its position and params."""
|
||||
pos = _get_position(part)
|
||||
p = part["params"]
|
||||
t = part["type"]
|
||||
|
||||
if t == "box":
|
||||
return [
|
||||
pos[0] + p["length"] / 2.0,
|
||||
pos[1] + p["width"] / 2.0,
|
||||
pos[2] + p["height"] / 2.0,
|
||||
]
|
||||
elif t == "cylinder":
|
||||
r = p["radius"]
|
||||
return [
|
||||
pos[0] + r,
|
||||
pos[1] + r,
|
||||
pos[2] + p["height"] / 2.0,
|
||||
]
|
||||
elif t == "sphere":
|
||||
r = p["radius"]
|
||||
return [pos[0] + r, pos[1] + r, pos[2] + r]
|
||||
elif t == "cone":
|
||||
r = max(p["radius1"], p["radius2"])
|
||||
return [
|
||||
pos[0] + r,
|
||||
pos[1] + r,
|
||||
pos[2] + p["height"] / 2.0,
|
||||
]
|
||||
elif t == "torus":
|
||||
R = p["radius1"]
|
||||
r = p["radius2"]
|
||||
return [
|
||||
pos[0] + R + r,
|
||||
pos[1] + R + r,
|
||||
pos[2] + r,
|
||||
]
|
||||
elif t == "wedge":
|
||||
return [
|
||||
pos[0] + (p["xmin"] + p["xmax"]) / 2.0,
|
||||
pos[1] + (p["ymin"] + p["ymax"]) / 2.0,
|
||||
pos[2] + (p["zmin"] + p["zmax"]) / 2.0,
|
||||
]
|
||||
# Boolean or unknown — fall back to placement position
|
||||
return pos
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Volume / area formulas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _compute_volume(part: Dict[str, Any]) -> Optional[float]:
|
||||
"""Compute volume from primitive parameters. Returns *None* for unknowns."""
|
||||
p = part["params"]
|
||||
t = part["type"]
|
||||
|
||||
if t == "box":
|
||||
return p["length"] * p["width"] * p["height"]
|
||||
elif t == "cylinder":
|
||||
return math.pi * p["radius"] ** 2 * p["height"]
|
||||
elif t == "sphere":
|
||||
return (4.0 / 3.0) * math.pi * p["radius"] ** 3
|
||||
elif t == "cone":
|
||||
r1, r2, h = p["radius1"], p["radius2"], p["height"]
|
||||
return (1.0 / 3.0) * math.pi * h * (r1 ** 2 + r1 * r2 + r2 ** 2)
|
||||
elif t == "torus":
|
||||
R, r = p["radius1"], p["radius2"]
|
||||
return 2.0 * math.pi ** 2 * R * r ** 2
|
||||
elif t == "wedge":
|
||||
# Approximate as bounding box (exact wedge needs more info)
|
||||
dx = p["xmax"] - p["xmin"]
|
||||
dy = p["ymax"] - p["ymin"]
|
||||
dz = p["zmax"] - p["zmin"]
|
||||
return dx * dy * dz
|
||||
return None
|
||||
|
||||
|
||||
def _compute_area(part: Dict[str, Any]) -> Optional[float]:
|
||||
"""Compute surface area from primitive parameters. Returns *None* for unknowns."""
|
||||
p = part["params"]
|
||||
t = part["type"]
|
||||
|
||||
if t == "box":
|
||||
l, w, h = p["length"], p["width"], p["height"]
|
||||
return 2.0 * (l * w + w * h + l * h)
|
||||
elif t == "cylinder":
|
||||
r, h = p["radius"], p["height"]
|
||||
return 2.0 * math.pi * r * (r + h)
|
||||
elif t == "sphere":
|
||||
return 4.0 * math.pi * p["radius"] ** 2
|
||||
elif t == "cone":
|
||||
r1, r2, h = p["radius1"], p["radius2"], p["height"]
|
||||
slant = math.sqrt((r1 - r2) ** 2 + h ** 2)
|
||||
return (
|
||||
math.pi * r1 ** 2
|
||||
+ math.pi * r2 ** 2
|
||||
+ math.pi * (r1 + r2) * slant
|
||||
)
|
||||
elif t == "torus":
|
||||
R, r = p["radius1"], p["radius2"]
|
||||
return 4.0 * math.pi ** 2 * R * r
|
||||
elif t == "wedge":
|
||||
dx = p["xmax"] - p["xmin"]
|
||||
dy = p["ymax"] - p["ymin"]
|
||||
dz = p["zmax"] - p["zmin"]
|
||||
return 2.0 * (dx * dy + dy * dz + dx * dz)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inertia helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _compute_inertia(part: Dict[str, Any]) -> Optional[Dict[str, float]]:
|
||||
"""Estimate principal moments of inertia (Ixx, Iyy, Izz) assuming unit density."""
|
||||
p = part["params"]
|
||||
t = part["type"]
|
||||
vol = _compute_volume(part)
|
||||
if vol is None:
|
||||
return None
|
||||
m = vol # unit density
|
||||
|
||||
if t == "box":
|
||||
l, w, h = p["length"], p["width"], p["height"]
|
||||
return {
|
||||
"Ixx": m * (w ** 2 + h ** 2) / 12.0,
|
||||
"Iyy": m * (l ** 2 + h ** 2) / 12.0,
|
||||
"Izz": m * (l ** 2 + w ** 2) / 12.0,
|
||||
}
|
||||
elif t == "cylinder":
|
||||
r, h = p["radius"], p["height"]
|
||||
return {
|
||||
"Ixx": m * (3.0 * r ** 2 + h ** 2) / 12.0,
|
||||
"Iyy": m * (3.0 * r ** 2 + h ** 2) / 12.0,
|
||||
"Izz": m * r ** 2 / 2.0,
|
||||
}
|
||||
elif t == "sphere":
|
||||
r = p["radius"]
|
||||
I = 2.0 * m * r ** 2 / 5.0
|
||||
return {"Ixx": I, "Iyy": I, "Izz": I}
|
||||
elif t == "cone":
|
||||
r1, r2, h = p["radius1"], p["radius2"], p["height"]
|
||||
# Approximate using average radius
|
||||
r_avg = (r1 + r2) / 2.0
|
||||
return {
|
||||
"Ixx": m * (3.0 * r_avg ** 2 + h ** 2) / 12.0,
|
||||
"Iyy": m * (3.0 * r_avg ** 2 + h ** 2) / 12.0,
|
||||
"Izz": m * r_avg ** 2 / 2.0,
|
||||
}
|
||||
elif t == "torus":
|
||||
R, r = p["radius1"], p["radius2"]
|
||||
Ixx = m * (5.0 * r ** 2 + 4.0 * R ** 2) / 8.0
|
||||
return {
|
||||
"Ixx": Ixx,
|
||||
"Iyy": Ixx,
|
||||
"Izz": m * (3.0 * r ** 2 + 4.0 * R ** 2) / 4.0,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def measure_distance(
|
||||
project: Dict[str, Any], index1: int, index2: int,
|
||||
additive: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Measure the Euclidean distance between two parts (bounding-box centres).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
index1 : int
|
||||
Index of the first part in ``project["parts"]``.
|
||||
index2 : int
|
||||
Index of the second part in ``project["parts"]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``distance`` value and axis deltas.
|
||||
"""
|
||||
part1 = get_part(project, index1)
|
||||
part2 = get_part(project, index2)
|
||||
|
||||
c1 = _bbox_center(part1)
|
||||
c2 = _bbox_center(part2)
|
||||
|
||||
dx = c2[0] - c1[0]
|
||||
dy = c2[1] - c1[1]
|
||||
dz = c2[2] - c1[2]
|
||||
dist = math.sqrt(dx ** 2 + dy ** 2 + dz ** 2)
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"part1_index": index1,
|
||||
"part2_index": index2,
|
||||
"distance": round(dist, 6),
|
||||
"delta": [round(dx, 6), round(dy, 6), round(dz, 6)],
|
||||
}
|
||||
if additive:
|
||||
result["additive"] = True
|
||||
return _store_measurement(project, "distance", result)
|
||||
|
||||
|
||||
def measure_length(
|
||||
project: Dict[str, Any], index: int, edge_ref: Optional[str] = None,
|
||||
additive: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Estimate the length of a part edge.
|
||||
|
||||
For primitives without an explicit *edge_ref*, the longest dimension
|
||||
is returned as an estimate.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
index : int
|
||||
Index of the part in ``project["parts"]``.
|
||||
edge_ref : str or None
|
||||
Optional edge reference (e.g. ``"Edge1"``). When supplied the
|
||||
measurement is stored as a deferred request.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``length`` value.
|
||||
"""
|
||||
part = get_part(project, index)
|
||||
p = part["params"]
|
||||
t = part["type"]
|
||||
length: Optional[float] = None
|
||||
|
||||
if edge_ref is not None:
|
||||
# Deferred — requires macro execution
|
||||
result_deferred: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"edge_ref": edge_ref,
|
||||
"length": None,
|
||||
"deferred": True,
|
||||
}
|
||||
if additive:
|
||||
result_deferred["additive"] = True
|
||||
return _store_measurement(project, "length", result_deferred)
|
||||
|
||||
if t == "box":
|
||||
length = max(p["length"], p["width"], p["height"])
|
||||
elif t == "cylinder":
|
||||
length = p["height"]
|
||||
elif t == "sphere":
|
||||
length = 2.0 * p["radius"]
|
||||
elif t == "cone":
|
||||
length = p["height"]
|
||||
elif t == "torus":
|
||||
length = 2.0 * math.pi * p["radius1"]
|
||||
elif t == "wedge":
|
||||
length = max(
|
||||
p["xmax"] - p["xmin"],
|
||||
p["ymax"] - p["ymin"],
|
||||
p["zmax"] - p["zmin"],
|
||||
)
|
||||
|
||||
result_len: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"edge_ref": edge_ref,
|
||||
"length": round(length, 6) if length is not None else None,
|
||||
"deferred": length is None,
|
||||
}
|
||||
if additive:
|
||||
result_len["additive"] = True
|
||||
return _store_measurement(project, "length", result_len)
|
||||
|
||||
|
||||
def measure_angle(
|
||||
project: Dict[str, Any], index1: int, index2: int,
|
||||
additive: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Measure the angle between two parts based on their centre vectors from the origin.
|
||||
|
||||
The angle is computed between the vectors from the world origin to
|
||||
each part's bounding-box centre. Returns 0.0 when either vector is
|
||||
zero-length.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``angle_deg`` value.
|
||||
"""
|
||||
part1 = get_part(project, index1)
|
||||
part2 = get_part(project, index2)
|
||||
|
||||
c1 = _bbox_center(part1)
|
||||
c2 = _bbox_center(part2)
|
||||
|
||||
mag1 = math.sqrt(sum(v ** 2 for v in c1))
|
||||
mag2 = math.sqrt(sum(v ** 2 for v in c2))
|
||||
|
||||
if mag1 == 0.0 or mag2 == 0.0:
|
||||
angle_deg = 0.0
|
||||
else:
|
||||
dot = sum(a * b for a, b in zip(c1, c2))
|
||||
cos_val = max(-1.0, min(1.0, dot / (mag1 * mag2)))
|
||||
angle_deg = math.degrees(math.acos(cos_val))
|
||||
|
||||
result_angle: Dict[str, Any] = {
|
||||
"part1_index": index1,
|
||||
"part2_index": index2,
|
||||
"angle_deg": round(angle_deg, 6),
|
||||
}
|
||||
if additive:
|
||||
result_angle["additive"] = True
|
||||
return _store_measurement(project, "angle", result_angle)
|
||||
|
||||
|
||||
def measure_area(project: Dict[str, Any], index: int, additive: bool = False) -> Dict[str, Any]:
|
||||
"""Estimate the surface area of a part from its primitive parameters.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``area`` value (or *None* for unsupported types).
|
||||
"""
|
||||
part = get_part(project, index)
|
||||
area = _compute_area(part)
|
||||
|
||||
result_area: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"area": round(area, 6) if area is not None else None,
|
||||
"deferred": area is None,
|
||||
}
|
||||
if additive:
|
||||
result_area["additive"] = True
|
||||
return _store_measurement(project, "area", result_area)
|
||||
|
||||
|
||||
def measure_volume(project: Dict[str, Any], index: int, additive: bool = False) -> Dict[str, Any]:
|
||||
"""Estimate the volume of a part from its primitive parameters.
|
||||
|
||||
Formulas used:
|
||||
- box: V = l * w * h
|
||||
- cylinder: V = pi * r^2 * h
|
||||
- sphere: V = 4/3 * pi * r^3
|
||||
- cone: V = 1/3 * pi * h * (r1^2 + r1*r2 + r2^2)
|
||||
- torus: V = 2 * pi^2 * R * r^2
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``volume`` value (or *None* for unsupported types).
|
||||
"""
|
||||
part = get_part(project, index)
|
||||
volume = _compute_volume(part)
|
||||
|
||||
result_vol: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"volume": round(volume, 6) if volume is not None else None,
|
||||
"deferred": volume is None,
|
||||
}
|
||||
if additive:
|
||||
result_vol["additive"] = True
|
||||
return _store_measurement(project, "volume", result_vol)
|
||||
|
||||
|
||||
def measure_radius(project: Dict[str, Any], index: int, additive: bool = False) -> Dict[str, Any]:
|
||||
"""Return the radius of a cylindrical, spherical, or toroidal part.
|
||||
|
||||
For cones, the larger of ``radius1`` / ``radius2`` is returned.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``radius`` value.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the part type has no meaningful radius.
|
||||
"""
|
||||
part = get_part(project, index)
|
||||
p = part["params"]
|
||||
t = part["type"]
|
||||
|
||||
if t == "cylinder":
|
||||
radius = p["radius"]
|
||||
elif t == "sphere":
|
||||
radius = p["radius"]
|
||||
elif t == "cone":
|
||||
radius = max(p["radius1"], p["radius2"])
|
||||
elif t == "torus":
|
||||
radius = p["radius2"]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Part type '{t}' has no meaningful radius. "
|
||||
f"Supported: cylinder, sphere, cone, torus"
|
||||
)
|
||||
|
||||
result_rad: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"radius": round(radius, 6),
|
||||
}
|
||||
if additive:
|
||||
result_rad["additive"] = True
|
||||
return _store_measurement(project, "radius", result_rad)
|
||||
|
||||
|
||||
def measure_diameter(project: Dict[str, Any], index: int, additive: bool = False) -> Dict[str, Any]:
|
||||
"""Return the diameter of a cylindrical, spherical, or toroidal part.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``diameter`` value.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the part type has no meaningful diameter.
|
||||
"""
|
||||
part = get_part(project, index)
|
||||
p = part["params"]
|
||||
t = part["type"]
|
||||
|
||||
if t == "cylinder":
|
||||
diameter = 2.0 * p["radius"]
|
||||
elif t == "sphere":
|
||||
diameter = 2.0 * p["radius"]
|
||||
elif t == "cone":
|
||||
diameter = 2.0 * max(p["radius1"], p["radius2"])
|
||||
elif t == "torus":
|
||||
diameter = 2.0 * p["radius2"]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Part type '{t}' has no meaningful diameter. "
|
||||
f"Supported: cylinder, sphere, cone, torus"
|
||||
)
|
||||
|
||||
result_dia: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"diameter": round(diameter, 6),
|
||||
}
|
||||
if additive:
|
||||
result_dia["additive"] = True
|
||||
return _store_measurement(project, "diameter", result_dia)
|
||||
|
||||
|
||||
def measure_position(project: Dict[str, Any], index: int, additive: bool = False) -> Dict[str, Any]:
|
||||
"""Return the placement position of a part.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``position`` ``[x, y, z]``.
|
||||
"""
|
||||
part = get_part(project, index)
|
||||
pos = _get_position(part)
|
||||
|
||||
result_pos: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"position": pos,
|
||||
}
|
||||
if additive:
|
||||
result_pos["additive"] = True
|
||||
return _store_measurement(project, "position", result_pos)
|
||||
|
||||
|
||||
def measure_center_of_mass(
|
||||
project: Dict[str, Any], index: int,
|
||||
additive: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Estimate the centre of mass (geometric centre for simple shapes).
|
||||
|
||||
For uniform-density primitives the centre of mass coincides with the
|
||||
bounding-box centre.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``center_of_mass`` ``[x, y, z]``.
|
||||
"""
|
||||
part = get_part(project, index)
|
||||
com = _bbox_center(part)
|
||||
|
||||
result_com: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"center_of_mass": [round(v, 6) for v in com],
|
||||
}
|
||||
if additive:
|
||||
result_com["additive"] = True
|
||||
return _store_measurement(project, "center_of_mass", result_com)
|
||||
|
||||
|
||||
def measure_bounding_box(
|
||||
project: Dict[str, Any], index: int,
|
||||
additive: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Compute the axis-aligned bounding box of a part.
|
||||
|
||||
The bounding box is derived from the part's position and primitive
|
||||
parameters.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``min``, ``max``, and ``size`` vectors.
|
||||
"""
|
||||
part = get_part(project, index)
|
||||
pos = _get_position(part)
|
||||
p = part["params"]
|
||||
t = part["type"]
|
||||
|
||||
if t == "box":
|
||||
bb_min = pos[:]
|
||||
bb_max = [
|
||||
pos[0] + p["length"],
|
||||
pos[1] + p["width"],
|
||||
pos[2] + p["height"],
|
||||
]
|
||||
elif t == "cylinder":
|
||||
r = p["radius"]
|
||||
bb_min = [pos[0] - r, pos[1] - r, pos[2]]
|
||||
bb_max = [pos[0] + r, pos[1] + r, pos[2] + p["height"]]
|
||||
elif t == "sphere":
|
||||
r = p["radius"]
|
||||
bb_min = [pos[0] - r, pos[1] - r, pos[2] - r]
|
||||
bb_max = [pos[0] + r, pos[1] + r, pos[2] + r]
|
||||
elif t == "cone":
|
||||
r = max(p["radius1"], p["radius2"])
|
||||
bb_min = [pos[0] - r, pos[1] - r, pos[2]]
|
||||
bb_max = [pos[0] + r, pos[1] + r, pos[2] + p["height"]]
|
||||
elif t == "torus":
|
||||
R, r = p["radius1"], p["radius2"]
|
||||
outer = R + r
|
||||
bb_min = [pos[0] - outer, pos[1] - outer, pos[2] - r]
|
||||
bb_max = [pos[0] + outer, pos[1] + outer, pos[2] + r]
|
||||
elif t == "wedge":
|
||||
bb_min = [
|
||||
pos[0] + p["xmin"],
|
||||
pos[1] + p["ymin"],
|
||||
pos[2] + p["zmin"],
|
||||
]
|
||||
bb_max = [
|
||||
pos[0] + p["xmax"],
|
||||
pos[1] + p["ymax"],
|
||||
pos[2] + p["zmax"],
|
||||
]
|
||||
else:
|
||||
# Unknown / boolean — deferred
|
||||
result_bb_def: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"size": None,
|
||||
"deferred": True,
|
||||
}
|
||||
if additive:
|
||||
result_bb_def["additive"] = True
|
||||
return _store_measurement(project, "bounding_box", result_bb_def)
|
||||
|
||||
size = [bb_max[i] - bb_min[i] for i in range(3)]
|
||||
|
||||
result_bb: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"min": [round(v, 6) for v in bb_min],
|
||||
"max": [round(v, 6) for v in bb_max],
|
||||
"size": [round(v, 6) for v in size],
|
||||
"deferred": False,
|
||||
}
|
||||
if additive:
|
||||
result_bb["additive"] = True
|
||||
return _store_measurement(project, "bounding_box", result_bb)
|
||||
|
||||
|
||||
def measure_inertia(project: Dict[str, Any], index: int, additive: bool = False) -> Dict[str, Any]:
|
||||
"""Estimate the principal moments of inertia (unit density).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Measurement record with ``Ixx``, ``Iyy``, ``Izz`` values.
|
||||
"""
|
||||
part = get_part(project, index)
|
||||
inertia = _compute_inertia(part)
|
||||
|
||||
if inertia is not None:
|
||||
inertia = {k: round(v, 6) for k, v in inertia.items()}
|
||||
|
||||
result_inertia: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"inertia": inertia,
|
||||
"deferred": inertia is None,
|
||||
}
|
||||
if additive:
|
||||
result_inertia["additive"] = True
|
||||
return _store_measurement(project, "inertia", result_inertia)
|
||||
|
||||
|
||||
def check_geometry(
|
||||
project: Dict[str, Any],
|
||||
index: int,
|
||||
include_valid: bool = False,
|
||||
skip_objects: Optional[List[int]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Perform basic geometry validation on a part.
|
||||
|
||||
Checks that all numeric parameters are positive and that the part
|
||||
type is a known primitive.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
index : int
|
||||
Index of the part in ``project["parts"]``.
|
||||
include_valid : bool
|
||||
When ``True``, also reports valid shape entries in ``valid_entries``
|
||||
(default ``False``).
|
||||
skip_objects : list[int] or None
|
||||
When provided, excludes these part indices from the check. If the
|
||||
requested *index* is in the skip list the result is returned
|
||||
immediately with ``"skipped": True``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Record with ``valid`` boolean and list of ``issues``.
|
||||
"""
|
||||
if skip_objects is not None and index in skip_objects:
|
||||
return _store_measurement(project, "geometry_check", {
|
||||
"part_index": index,
|
||||
"valid": True,
|
||||
"issues": [],
|
||||
"skipped": True,
|
||||
})
|
||||
|
||||
part = get_part(project, index)
|
||||
issues: List[str] = []
|
||||
valid_entries: List[str] = []
|
||||
|
||||
t = part["type"]
|
||||
if t not in PRIMITIVES:
|
||||
issues.append(f"Unknown primitive type '{t}'")
|
||||
else:
|
||||
p = part["params"]
|
||||
defaults = PRIMITIVES[t]
|
||||
for key in defaults:
|
||||
if key in p:
|
||||
val = p[key]
|
||||
# Angle parameters may be negative (e.g. angle1 on sphere/torus)
|
||||
if "angle" not in key and val <= 0:
|
||||
issues.append(f"Parameter '{key}' must be positive, got {val}")
|
||||
elif include_valid:
|
||||
valid_entries.append(f"Parameter '{key}' = {val}")
|
||||
else:
|
||||
issues.append(f"Missing expected parameter '{key}'")
|
||||
|
||||
# Validate placement exists
|
||||
placement = part.get("placement")
|
||||
if placement is None:
|
||||
issues.append("Missing 'placement' on part")
|
||||
else:
|
||||
if "position" not in placement:
|
||||
issues.append("Missing 'position' in placement")
|
||||
elif include_valid:
|
||||
valid_entries.append("Placement 'position' present")
|
||||
if "rotation" not in placement:
|
||||
issues.append("Missing 'rotation' in placement")
|
||||
elif include_valid:
|
||||
valid_entries.append("Placement 'rotation' present")
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"part_index": index,
|
||||
"valid": len(issues) == 0,
|
||||
"issues": issues,
|
||||
}
|
||||
if include_valid:
|
||||
result["valid_entries"] = valid_entries
|
||||
if skip_objects is not None:
|
||||
result["skipped"] = False
|
||||
|
||||
return _store_measurement(project, "geometry_check", result)
|
||||
@@ -0,0 +1,747 @@
|
||||
"""FreeCAD CLI - Mesh operations module.
|
||||
|
||||
Manages mesh import, export, tessellation from shapes, analysis, boolean
|
||||
operations, decimation, remeshing, smoothing, repair, and conversion back
|
||||
to solid shapes. Meshes are stored in ``project["meshes"]`` via
|
||||
:func:`~cli_anything.freecad.core.document.ensure_collection`.
|
||||
"""
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from cli_anything.freecad.core.document import ensure_collection
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MESH_FORMATS: Set[str] = {"stl", "obj", "ply", "off", "3mf", "amf", "bms"}
|
||||
MESH_BOOLEAN_OPS: Set[str] = {"union", "difference", "intersection"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _next_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for meshes."""
|
||||
items = project.get("meshes", [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _unique_name(project: Dict[str, Any], base: str) -> str:
|
||||
"""Return a unique name derived from *base* inside ``project["meshes"]``."""
|
||||
existing = {item["name"] for item in project.get("meshes", [])}
|
||||
if base not in existing:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base}_{counter}" in existing:
|
||||
counter += 1
|
||||
return f"{base}_{counter}"
|
||||
|
||||
|
||||
def _get_mesh(project: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"""Return the mesh at *index*, raising ``IndexError`` if out of range."""
|
||||
meshes = project.get("meshes", [])
|
||||
if not isinstance(index, int) or index < 0 or index >= len(meshes):
|
||||
raise IndexError(
|
||||
f"Mesh index {index} out of range (0..{len(meshes) - 1})"
|
||||
)
|
||||
return meshes[index]
|
||||
|
||||
|
||||
def _validate_path(path: str, label: str = "path") -> str:
|
||||
"""Validate that *path* is a non-empty string and return it normalised."""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError(f"{label} must be a non-empty string")
|
||||
return path.strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def import_mesh(
|
||||
project: Dict[str, Any],
|
||||
path: str,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Import a mesh file and register it in ``project["meshes"]``.
|
||||
|
||||
The actual file loading happens during macro generation; this function
|
||||
records the import intent and metadata.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
path : str
|
||||
Filesystem path to the mesh file.
|
||||
name : str or None
|
||||
Human-readable label. Derived from filename when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created mesh entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *path* is empty.
|
||||
"""
|
||||
path = _validate_path(path)
|
||||
meshes = ensure_collection(project, "meshes")
|
||||
|
||||
ext = os.path.splitext(path)[1].lstrip(".").lower()
|
||||
if name is None:
|
||||
base = os.path.splitext(os.path.basename(path))[0]
|
||||
name = _unique_name(project, base)
|
||||
|
||||
mesh: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"source": path,
|
||||
"format": ext if ext else "unknown",
|
||||
"vertices_count": 0,
|
||||
"faces_count": 0,
|
||||
"operations_applied": [],
|
||||
}
|
||||
|
||||
meshes.append(mesh)
|
||||
return mesh
|
||||
|
||||
|
||||
def mesh_from_shape(
|
||||
project: Dict[str, Any],
|
||||
part_index: int,
|
||||
name: Optional[str] = None,
|
||||
max_length: Optional[float] = None,
|
||||
deviation: float = 0.1,
|
||||
) -> Dict[str, Any]:
|
||||
"""Tessellate a solid part into a triangular mesh.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
part_index : int
|
||||
Index of the source part in ``project["parts"]``.
|
||||
name : str or None
|
||||
Label for the new mesh. Auto-generated when *None*.
|
||||
max_length : float or None
|
||||
Maximum edge length constraint. *None* means no constraint.
|
||||
deviation : float
|
||||
Surface deviation tolerance (default ``0.1``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created mesh entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *part_index* is out of range.
|
||||
ValueError
|
||||
If *deviation* is not positive.
|
||||
"""
|
||||
parts = project.get("parts", [])
|
||||
if not isinstance(part_index, int) or part_index < 0 or part_index >= len(parts):
|
||||
raise IndexError(
|
||||
f"Part index {part_index} out of range (0..{len(parts) - 1})"
|
||||
)
|
||||
|
||||
if deviation <= 0:
|
||||
raise ValueError("deviation must be a positive number")
|
||||
|
||||
meshes = ensure_collection(project, "meshes")
|
||||
part = parts[part_index]
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, f"{part['name']}_Mesh")
|
||||
|
||||
params: Dict[str, Any] = {"deviation": float(deviation)}
|
||||
if max_length is not None:
|
||||
if max_length <= 0:
|
||||
raise ValueError("max_length must be a positive number")
|
||||
params["max_length"] = float(max_length)
|
||||
|
||||
mesh: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"source": part_index,
|
||||
"format": "tessellated",
|
||||
"vertices_count": 0,
|
||||
"faces_count": 0,
|
||||
"operations_applied": [
|
||||
{"op": "tessellate", "params": params},
|
||||
],
|
||||
}
|
||||
|
||||
meshes.append(mesh)
|
||||
return mesh
|
||||
|
||||
|
||||
def export_mesh(
|
||||
project: Dict[str, Any],
|
||||
mesh_index: int,
|
||||
path: str,
|
||||
format: str = "stl",
|
||||
) -> Dict[str, Any]:
|
||||
"""Record an export request for the mesh at *mesh_index*.
|
||||
|
||||
The actual file writing is performed during macro generation; this
|
||||
function validates the arguments and returns export metadata.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh to export.
|
||||
path : str
|
||||
Destination file path.
|
||||
format : str
|
||||
Output format (default ``"stl"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Export metadata including mesh id, path, and format.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *mesh_index* is out of range.
|
||||
ValueError
|
||||
If *format* is unsupported or *path* is empty.
|
||||
"""
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
path = _validate_path(path, "export path")
|
||||
|
||||
fmt = format.lower()
|
||||
if fmt not in MESH_FORMATS:
|
||||
valid = ", ".join(sorted(MESH_FORMATS))
|
||||
raise ValueError(f"Unsupported mesh format '{fmt}'. Valid: {valid}")
|
||||
|
||||
return {
|
||||
"mesh_id": mesh["id"],
|
||||
"mesh_name": mesh["name"],
|
||||
"path": path,
|
||||
"format": fmt,
|
||||
}
|
||||
|
||||
|
||||
def mesh_info(project: Dict[str, Any], mesh_index: int) -> Dict[str, Any]:
|
||||
"""Return a summary of the mesh at *mesh_index*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Copy of the mesh entry.
|
||||
"""
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
return deepcopy(mesh)
|
||||
|
||||
|
||||
def analyze_mesh(project: Dict[str, Any], mesh_index: int) -> Dict[str, Any]:
|
||||
"""Return stored analysis results for the mesh at *mesh_index*.
|
||||
|
||||
Analysis covers vertex/face counts, bounding-box estimation, and
|
||||
volume/area placeholders. Actual numerical analysis happens in the
|
||||
FreeCAD macro; this records the intent and returns current metadata.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Analysis result dictionary.
|
||||
"""
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
return {
|
||||
"mesh_id": mesh["id"],
|
||||
"name": mesh["name"],
|
||||
"vertices_count": mesh["vertices_count"],
|
||||
"faces_count": mesh["faces_count"],
|
||||
"format": mesh["format"],
|
||||
"operations_applied": list(mesh["operations_applied"]),
|
||||
"analysis": "pending_macro_execution",
|
||||
}
|
||||
|
||||
|
||||
def check_mesh(project: Dict[str, Any], mesh_index: int) -> Dict[str, Any]:
|
||||
"""Check the mesh at *mesh_index* for common problems.
|
||||
|
||||
Returns a diagnostic dictionary. The actual checks (non-manifold
|
||||
edges, self-intersections, degenerate faces) are performed by the
|
||||
FreeCAD macro; this function records the request.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Diagnostic placeholder with mesh metadata.
|
||||
"""
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
return {
|
||||
"mesh_id": mesh["id"],
|
||||
"name": mesh["name"],
|
||||
"checks": [
|
||||
"non_manifold_edges",
|
||||
"self_intersections",
|
||||
"degenerate_faces",
|
||||
"duplicate_faces",
|
||||
"duplicate_points",
|
||||
"orientation",
|
||||
],
|
||||
"status": "pending_macro_execution",
|
||||
}
|
||||
|
||||
|
||||
def mesh_boolean(
|
||||
project: Dict[str, Any],
|
||||
op: str,
|
||||
base_index: int,
|
||||
tool_index: int,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Perform a boolean operation between two meshes.
|
||||
|
||||
Creates a new mesh entry representing the result.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
op : str
|
||||
One of ``"union"``, ``"difference"``, or ``"intersection"``.
|
||||
base_index : int
|
||||
Index of the base mesh.
|
||||
tool_index : int
|
||||
Index of the tool mesh.
|
||||
name : str or None
|
||||
Label for the result mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created result mesh entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *op* is unknown or indices are equal.
|
||||
IndexError
|
||||
If either index is out of range.
|
||||
"""
|
||||
if op not in MESH_BOOLEAN_OPS:
|
||||
valid = ", ".join(sorted(MESH_BOOLEAN_OPS))
|
||||
raise ValueError(f"Unknown mesh boolean op '{op}'. Valid: {valid}")
|
||||
|
||||
if base_index == tool_index:
|
||||
raise ValueError("base_index and tool_index must differ")
|
||||
|
||||
base_mesh = _get_mesh(project, base_index)
|
||||
tool_mesh = _get_mesh(project, tool_index)
|
||||
|
||||
meshes = ensure_collection(project, "meshes")
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, f"MeshBool_{op.capitalize()}")
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"source": f"boolean:{op}",
|
||||
"format": "computed",
|
||||
"vertices_count": 0,
|
||||
"faces_count": 0,
|
||||
"operations_applied": [
|
||||
{
|
||||
"op": f"boolean_{op}",
|
||||
"params": {
|
||||
"base_id": base_mesh["id"],
|
||||
"tool_id": tool_mesh["id"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
meshes.append(result)
|
||||
return result
|
||||
|
||||
|
||||
def decimate_mesh(
|
||||
project: Dict[str, Any],
|
||||
mesh_index: int,
|
||||
target_faces: int = 1000,
|
||||
) -> Dict[str, Any]:
|
||||
"""Decimate (simplify) the mesh at *mesh_index*.
|
||||
|
||||
Records a decimation operation targeting *target_faces* triangles.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh to decimate.
|
||||
target_faces : int
|
||||
Target number of faces after decimation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The updated mesh entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *mesh_index* is out of range.
|
||||
ValueError
|
||||
If *target_faces* is not a positive integer.
|
||||
"""
|
||||
if not isinstance(target_faces, int) or target_faces <= 0:
|
||||
raise ValueError("target_faces must be a positive integer")
|
||||
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
mesh["operations_applied"].append({
|
||||
"op": "decimate",
|
||||
"params": {"target_faces": target_faces},
|
||||
})
|
||||
return mesh
|
||||
|
||||
|
||||
def remesh_mesh(
|
||||
project: Dict[str, Any],
|
||||
mesh_index: int,
|
||||
target_length: float = 1.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Remesh the mesh at *mesh_index* with uniform edge lengths.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh to remesh.
|
||||
target_length : float
|
||||
Target edge length for the remeshed output.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The updated mesh entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *mesh_index* is out of range.
|
||||
ValueError
|
||||
If *target_length* is not positive.
|
||||
"""
|
||||
if target_length <= 0:
|
||||
raise ValueError("target_length must be a positive number")
|
||||
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
mesh["operations_applied"].append({
|
||||
"op": "remesh",
|
||||
"params": {"target_length": float(target_length)},
|
||||
})
|
||||
return mesh
|
||||
|
||||
|
||||
def smooth_mesh(
|
||||
project: Dict[str, Any],
|
||||
mesh_index: int,
|
||||
iterations: int = 3,
|
||||
factor: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply Laplacian smoothing to the mesh at *mesh_index*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh to smooth.
|
||||
iterations : int
|
||||
Number of smoothing passes (default ``3``).
|
||||
factor : float
|
||||
Smoothing intensity factor between 0 and 1 (default ``0.5``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The updated mesh entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *mesh_index* is out of range.
|
||||
ValueError
|
||||
If *iterations* or *factor* is invalid.
|
||||
"""
|
||||
if not isinstance(iterations, int) or iterations <= 0:
|
||||
raise ValueError("iterations must be a positive integer")
|
||||
if not (0.0 < factor <= 1.0):
|
||||
raise ValueError("factor must be between 0 (exclusive) and 1 (inclusive)")
|
||||
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
mesh["operations_applied"].append({
|
||||
"op": "smooth",
|
||||
"params": {"iterations": iterations, "factor": float(factor)},
|
||||
})
|
||||
return mesh
|
||||
|
||||
|
||||
def repair_mesh(project: Dict[str, Any], mesh_index: int) -> Dict[str, Any]:
|
||||
"""Record a repair operation for the mesh at *mesh_index*.
|
||||
|
||||
Repair includes fixing degenerate faces, removing duplicates,
|
||||
harmonising normals, and filling small holes.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh to repair.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The updated mesh entry.
|
||||
"""
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
mesh["operations_applied"].append({
|
||||
"op": "repair",
|
||||
"params": {},
|
||||
})
|
||||
return mesh
|
||||
|
||||
|
||||
def fill_holes(
|
||||
project: Dict[str, Any],
|
||||
mesh_index: int,
|
||||
max_hole_size: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""Fill holes in the mesh at *mesh_index*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh.
|
||||
max_hole_size : int
|
||||
Maximum number of edges bounding a hole to fill (default ``10``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The updated mesh entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *max_hole_size* is not a positive integer.
|
||||
"""
|
||||
if not isinstance(max_hole_size, int) or max_hole_size <= 0:
|
||||
raise ValueError("max_hole_size must be a positive integer")
|
||||
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
mesh["operations_applied"].append({
|
||||
"op": "fill_holes",
|
||||
"params": {"max_hole_size": max_hole_size},
|
||||
})
|
||||
return mesh
|
||||
|
||||
|
||||
def flip_normals(project: Dict[str, Any], mesh_index: int) -> Dict[str, Any]:
|
||||
"""Flip all face normals on the mesh at *mesh_index*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The updated mesh entry.
|
||||
"""
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
mesh["operations_applied"].append({
|
||||
"op": "flip_normals",
|
||||
"params": {},
|
||||
})
|
||||
return mesh
|
||||
|
||||
|
||||
def merge_meshes(
|
||||
project: Dict[str, Any],
|
||||
indices: List[int],
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge multiple meshes into a single mesh.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
indices : list[int]
|
||||
Indices of the meshes to merge.
|
||||
name : str or None
|
||||
Label for the merged mesh.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created merged mesh entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If fewer than two indices are supplied or any index is invalid.
|
||||
"""
|
||||
if not isinstance(indices, (list, tuple)) or len(indices) < 2:
|
||||
raise ValueError("At least two mesh indices are required for merging")
|
||||
|
||||
source_ids = []
|
||||
for idx in indices:
|
||||
mesh = _get_mesh(project, idx)
|
||||
source_ids.append(mesh["id"])
|
||||
|
||||
meshes = ensure_collection(project, "meshes")
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "MergedMesh")
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"source": "merged",
|
||||
"format": "computed",
|
||||
"vertices_count": 0,
|
||||
"faces_count": 0,
|
||||
"operations_applied": [
|
||||
{"op": "merge", "params": {"source_ids": source_ids}},
|
||||
],
|
||||
}
|
||||
|
||||
meshes.append(result)
|
||||
return result
|
||||
|
||||
|
||||
def split_mesh(project: Dict[str, Any], mesh_index: int) -> Dict[str, Any]:
|
||||
"""Split a mesh into its disconnected components.
|
||||
|
||||
Records the split operation. The actual component separation is
|
||||
performed by the FreeCAD macro; this function returns metadata
|
||||
about the request.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the mesh to split.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Metadata describing the split request.
|
||||
"""
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
mesh["operations_applied"].append({
|
||||
"op": "split",
|
||||
"params": {},
|
||||
})
|
||||
return {
|
||||
"mesh_id": mesh["id"],
|
||||
"name": mesh["name"],
|
||||
"status": "split_pending_macro_execution",
|
||||
}
|
||||
|
||||
|
||||
def mesh_to_shape(
|
||||
project: Dict[str, Any],
|
||||
mesh_index: int,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Convert a mesh to a solid shape and add it to ``project["parts"]``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
mesh_index : int
|
||||
Index of the source mesh.
|
||||
name : str or None
|
||||
Label for the resulting part.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created part entry in ``project["parts"]``.
|
||||
"""
|
||||
mesh = _get_mesh(project, mesh_index)
|
||||
parts = ensure_collection(project, "parts")
|
||||
|
||||
if name is None:
|
||||
base = f"{mesh['name']}_Solid"
|
||||
existing = {p["name"] for p in parts}
|
||||
if base in existing:
|
||||
counter = 2
|
||||
while f"{base}_{counter}" in existing:
|
||||
counter += 1
|
||||
base = f"{base}_{counter}"
|
||||
name = base
|
||||
|
||||
# Compute next part id
|
||||
part_id = max((p["id"] for p in parts), default=0) + 1
|
||||
|
||||
part: Dict[str, Any] = {
|
||||
"id": part_id,
|
||||
"name": name,
|
||||
"type": "mesh_to_shape",
|
||||
"params": {
|
||||
"source_mesh_id": mesh["id"],
|
||||
},
|
||||
"placement": {
|
||||
"position": [0.0, 0.0, 0.0],
|
||||
"rotation": [0.0, 0.0, 0.0],
|
||||
},
|
||||
"material_index": None,
|
||||
"visible": True,
|
||||
}
|
||||
|
||||
parts.append(part)
|
||||
return part
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Session management for FreeCAD CLI harness.
|
||||
|
||||
Handles project state, undo/redo history, and session persistence
|
||||
with atomic file locking for safe concurrent access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def _locked_save_json(path: str, data: Any, **dump_kwargs: Any) -> None:
|
||||
"""Persist JSON data to *path* using atomic file locking.
|
||||
|
||||
Opens with ``"r+"`` to avoid truncation before the lock is acquired.
|
||||
Falls back to ``"w"`` when the file does not yet exist. On platforms
|
||||
that lack :mod:`fcntl` (Windows) the write proceeds without locking.
|
||||
"""
|
||||
try:
|
||||
f = open(path, "r+")
|
||||
except FileNotFoundError:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||
f = open(path, "w")
|
||||
|
||||
with f:
|
||||
_locked = False
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
_locked = True
|
||||
except (ImportError, OSError):
|
||||
pass
|
||||
try:
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(data, f, **dump_kwargs)
|
||||
f.flush()
|
||||
finally:
|
||||
if _locked:
|
||||
import fcntl as _fcntl
|
||||
|
||||
_fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
|
||||
|
||||
|
||||
class Session:
|
||||
"""Manages project state, undo/redo snapshots and persistence for a FreeCAD project."""
|
||||
|
||||
MAX_UNDO: int = 50
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.project: Optional[Dict] = None
|
||||
self.project_path: Optional[str] = None
|
||||
self._undo_stack: List[Dict] = []
|
||||
self._redo_stack: List[Dict] = []
|
||||
self._modified: bool = False
|
||||
|
||||
# -- project access --------------------------------------------------------
|
||||
|
||||
def get_project(self) -> Dict:
|
||||
"""Return the current project dict.
|
||||
|
||||
Raises :class:`RuntimeError` if no project is loaded.
|
||||
"""
|
||||
if self.project is None:
|
||||
raise RuntimeError("No project is currently loaded.")
|
||||
return self.project
|
||||
|
||||
def set_project(self, project: Dict, path: Optional[str] = None) -> None:
|
||||
"""Replace the current project, clearing all undo/redo history."""
|
||||
self.project = project
|
||||
if path is not None:
|
||||
self.project_path = path
|
||||
self._undo_stack.clear()
|
||||
self._redo_stack.clear()
|
||||
self._modified = False
|
||||
|
||||
# -- snapshot / undo / redo ------------------------------------------------
|
||||
|
||||
def snapshot(self, description: str = "") -> None:
|
||||
"""Save a deep copy of the current project state before a mutation.
|
||||
|
||||
The redo stack is cleared on every new snapshot. When the undo
|
||||
stack exceeds :attr:`MAX_UNDO`, the oldest entry is discarded
|
||||
(FIFO).
|
||||
"""
|
||||
if self.project is None:
|
||||
return
|
||||
|
||||
entry: Dict = {
|
||||
"timestamp": time.time(),
|
||||
"description": description,
|
||||
"state": copy.deepcopy(self.project),
|
||||
}
|
||||
self._undo_stack.append(entry)
|
||||
|
||||
# FIFO limit
|
||||
if len(self._undo_stack) > self.MAX_UNDO:
|
||||
self._undo_stack.pop(0)
|
||||
|
||||
self._redo_stack.clear()
|
||||
self._modified = True
|
||||
|
||||
def undo(self) -> Optional[str]:
|
||||
"""Restore the previous project state.
|
||||
|
||||
Returns the snapshot description, or ``None`` if there is nothing
|
||||
to undo.
|
||||
"""
|
||||
if not self._undo_stack:
|
||||
return None
|
||||
|
||||
# Save current state onto redo stack before restoring.
|
||||
redo_entry: Dict = {
|
||||
"timestamp": time.time(),
|
||||
"description": self._undo_stack[-1].get("description", ""),
|
||||
"state": copy.deepcopy(self.project),
|
||||
}
|
||||
self._redo_stack.append(redo_entry)
|
||||
|
||||
entry = self._undo_stack.pop()
|
||||
self.project = entry["state"]
|
||||
self._modified = True
|
||||
return entry.get("description", "")
|
||||
|
||||
def redo(self) -> Optional[str]:
|
||||
"""Restore the next project state after an undo.
|
||||
|
||||
Returns the snapshot description, or ``None`` if there is nothing
|
||||
to redo.
|
||||
"""
|
||||
if not self._redo_stack:
|
||||
return None
|
||||
|
||||
# Save current state onto undo stack before restoring.
|
||||
undo_entry: Dict = {
|
||||
"timestamp": time.time(),
|
||||
"description": self._redo_stack[-1].get("description", ""),
|
||||
"state": copy.deepcopy(self.project),
|
||||
}
|
||||
self._undo_stack.append(undo_entry)
|
||||
|
||||
entry = self._redo_stack.pop()
|
||||
self.project = entry["state"]
|
||||
self._modified = True
|
||||
return entry.get("description", "")
|
||||
|
||||
# -- persistence -----------------------------------------------------------
|
||||
|
||||
def save_session(self, path: Optional[str] = None) -> str:
|
||||
"""Persist the current project to disk using atomic file locking.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path:
|
||||
Destination file path. Falls back to :attr:`project_path`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The absolute path the project was saved to.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If no path is available.
|
||||
RuntimeError
|
||||
If no project is loaded.
|
||||
"""
|
||||
save_path = path or self.project_path
|
||||
if save_path is None:
|
||||
raise ValueError("No save path specified and no project_path set.")
|
||||
if self.project is None:
|
||||
raise RuntimeError("No project is currently loaded.")
|
||||
|
||||
save_path = os.path.abspath(save_path)
|
||||
_locked_save_json(save_path, self.project, indent=2, default=str)
|
||||
|
||||
self.project_path = save_path
|
||||
self._modified = False
|
||||
return save_path
|
||||
|
||||
# -- query helpers ---------------------------------------------------------
|
||||
|
||||
def status(self) -> Dict:
|
||||
"""Return a summary of the current session state."""
|
||||
return {
|
||||
"has_project": self.project is not None,
|
||||
"project_path": self.project_path,
|
||||
"modified": self._modified,
|
||||
"undo_depth": len(self._undo_stack),
|
||||
"redo_depth": len(self._redo_stack),
|
||||
}
|
||||
|
||||
def list_history(self) -> List[Dict]:
|
||||
"""Return the undo stack in reverse chronological order (newest first)."""
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"timestamp": entry["timestamp"],
|
||||
"description": entry.get("description", ""),
|
||||
}
|
||||
for i, entry in reversed(list(enumerate(self._undo_stack)))
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,504 @@
|
||||
"""FreeCAD CLI - Spreadsheet module for parametric data tables.
|
||||
|
||||
Manages spreadsheet creation and cell manipulation within the JSON-based
|
||||
project state. Spreadsheets can store raw values, formulas (prefixed
|
||||
with ``=``), and named aliases for parametric linking.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from cli_anything.freecad.core.document import ensure_collection
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CELL_REF_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]*$")
|
||||
_ALIAS_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _next_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for spreadsheets."""
|
||||
items = project.get("spreadsheets", [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _unique_name(project: Dict[str, Any], base: str) -> str:
|
||||
"""Return a unique spreadsheet name derived from *base*."""
|
||||
existing = {item["name"] for item in project.get("spreadsheets", [])}
|
||||
if base not in existing:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base}_{counter}" in existing:
|
||||
counter += 1
|
||||
return f"{base}_{counter}"
|
||||
|
||||
|
||||
def _validate_cell_ref(cell_ref: str) -> str:
|
||||
"""Validate and return a normalised cell reference (upper-case).
|
||||
|
||||
Raises ``ValueError`` if the reference is malformed.
|
||||
"""
|
||||
if not isinstance(cell_ref, str):
|
||||
raise ValueError(
|
||||
f"Cell reference must be a string, got {type(cell_ref).__name__}"
|
||||
)
|
||||
ref = cell_ref.strip().upper()
|
||||
if not _CELL_REF_RE.match(ref):
|
||||
raise ValueError(
|
||||
f"Invalid cell reference '{cell_ref}'. "
|
||||
f"Expected format like A1, B2, AA23 (1-3 uppercase letters + row number >= 1)"
|
||||
)
|
||||
return ref
|
||||
|
||||
|
||||
def _get_sheet(project: Dict[str, Any], sheet_index: int) -> Dict[str, Any]:
|
||||
"""Return the spreadsheet at *sheet_index*.
|
||||
|
||||
Raises ``IndexError`` when the index is out of range.
|
||||
"""
|
||||
sheets = project.get("spreadsheets", [])
|
||||
if not isinstance(sheet_index, int) or sheet_index < 0 or sheet_index >= len(sheets):
|
||||
raise IndexError(
|
||||
f"Spreadsheet index {sheet_index} out of range "
|
||||
f"(0..{len(sheets) - 1})"
|
||||
)
|
||||
return sheets[sheet_index]
|
||||
|
||||
|
||||
def _parse_cell_ref(cell_ref: str):
|
||||
"""Split a cell reference into (column_letters, row_number)."""
|
||||
match = re.match(r"^([A-Z]{1,3})([1-9][0-9]*)$", cell_ref)
|
||||
if not match:
|
||||
raise ValueError(f"Cannot parse cell reference '{cell_ref}'")
|
||||
return match.group(1), int(match.group(2))
|
||||
|
||||
|
||||
def _col_to_index(col: str) -> int:
|
||||
"""Convert column letters to a zero-based index (A=0, B=1, ..., Z=25, AA=26)."""
|
||||
result = 0
|
||||
for ch in col:
|
||||
result = result * 26 + (ord(ch) - ord("A") + 1)
|
||||
return result - 1
|
||||
|
||||
|
||||
def _index_to_col(index: int) -> str:
|
||||
"""Convert a zero-based column index back to letters."""
|
||||
result = []
|
||||
index += 1 # 1-based
|
||||
while index > 0:
|
||||
index, remainder = divmod(index - 1, 26)
|
||||
result.append(chr(ord("A") + remainder))
|
||||
return "".join(reversed(result))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_spreadsheet(
|
||||
project: Dict[str, Any], name: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new spreadsheet and append it to the project.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
name : str or None
|
||||
Label for the spreadsheet. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created spreadsheet dictionary.
|
||||
"""
|
||||
sheets = ensure_collection(project, "spreadsheets")
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "Spreadsheet")
|
||||
elif not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("Spreadsheet name must be a non-empty string")
|
||||
else:
|
||||
name = name.strip()
|
||||
|
||||
sheet: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"cells": {},
|
||||
"aliases": {},
|
||||
}
|
||||
|
||||
sheets.append(sheet)
|
||||
return sheet
|
||||
|
||||
|
||||
def set_cell(
|
||||
project: Dict[str, Any],
|
||||
sheet_index: int,
|
||||
cell_ref: str,
|
||||
value: Union[str, int, float],
|
||||
) -> Dict[str, Any]:
|
||||
"""Set a cell value in a spreadsheet.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
sheet_index : int
|
||||
Index of the spreadsheet in ``project["spreadsheets"]``.
|
||||
cell_ref : str
|
||||
Cell reference such as ``"A1"``, ``"B2"``, ``"AA23"``.
|
||||
value : str, int, or float
|
||||
The value to store. Strings starting with ``"="`` are treated as
|
||||
formulas.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
A summary containing the cell reference and stored value.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *sheet_index* is out of range.
|
||||
ValueError
|
||||
If *cell_ref* is invalid.
|
||||
"""
|
||||
ref = _validate_cell_ref(cell_ref)
|
||||
sheet = _get_sheet(project, sheet_index)
|
||||
|
||||
# Determine cell content type
|
||||
if isinstance(value, str) and value.startswith("="):
|
||||
cell_data = {"value": value, "type": "formula"}
|
||||
elif isinstance(value, (int, float)):
|
||||
cell_data = {"value": value, "type": "number"}
|
||||
else:
|
||||
cell_data = {"value": str(value), "type": "string"}
|
||||
|
||||
sheet["cells"][ref] = cell_data
|
||||
|
||||
return {
|
||||
"sheet_index": sheet_index,
|
||||
"cell_ref": ref,
|
||||
"value": cell_data["value"],
|
||||
"type": cell_data["type"],
|
||||
}
|
||||
|
||||
|
||||
def get_cell(
|
||||
project: Dict[str, Any], sheet_index: int, cell_ref: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Retrieve a cell value from a spreadsheet.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The project state dictionary.
|
||||
sheet_index : int
|
||||
Index of the spreadsheet.
|
||||
cell_ref : str
|
||||
Cell reference such as ``"A1"``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Cell data including ``value`` and ``type``, or ``None`` values if
|
||||
the cell is empty.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *sheet_index* is out of range.
|
||||
ValueError
|
||||
If *cell_ref* is invalid.
|
||||
"""
|
||||
ref = _validate_cell_ref(cell_ref)
|
||||
sheet = _get_sheet(project, sheet_index)
|
||||
|
||||
cell_data = sheet["cells"].get(ref)
|
||||
if cell_data is None:
|
||||
return {
|
||||
"sheet_index": sheet_index,
|
||||
"cell_ref": ref,
|
||||
"value": None,
|
||||
"type": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"sheet_index": sheet_index,
|
||||
"cell_ref": ref,
|
||||
"value": cell_data["value"],
|
||||
"type": cell_data["type"],
|
||||
}
|
||||
|
||||
|
||||
def set_alias(
|
||||
project: Dict[str, Any],
|
||||
sheet_index: int,
|
||||
cell_ref: str,
|
||||
alias: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Assign an alias name to a cell.
|
||||
|
||||
Aliases allow cells to be referenced by name in formulas and
|
||||
parametric expressions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
sheet_index : int
|
||||
Index of the spreadsheet.
|
||||
cell_ref : str
|
||||
Cell reference to alias.
|
||||
alias : str
|
||||
Alias name. Must start with a letter or underscore and contain
|
||||
only alphanumeric characters and underscores.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Summary with ``cell_ref`` and ``alias``.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *sheet_index* is out of range.
|
||||
ValueError
|
||||
If *cell_ref* or *alias* is invalid, or the alias is already in use.
|
||||
"""
|
||||
ref = _validate_cell_ref(cell_ref)
|
||||
sheet = _get_sheet(project, sheet_index)
|
||||
|
||||
if not isinstance(alias, str) or not alias.strip():
|
||||
raise ValueError("Alias must be a non-empty string")
|
||||
alias = alias.strip()
|
||||
|
||||
if not _ALIAS_RE.match(alias):
|
||||
raise ValueError(
|
||||
f"Invalid alias '{alias}'. Must start with a letter or underscore "
|
||||
f"and contain only alphanumeric characters and underscores."
|
||||
)
|
||||
|
||||
# Check alias uniqueness (allow re-aliasing the same cell)
|
||||
for existing_ref, existing_alias in sheet["aliases"].items():
|
||||
if existing_alias == alias and existing_ref != ref:
|
||||
raise ValueError(
|
||||
f"Alias '{alias}' is already assigned to cell {existing_ref}"
|
||||
)
|
||||
|
||||
sheet["aliases"][ref] = alias
|
||||
|
||||
return {
|
||||
"sheet_index": sheet_index,
|
||||
"cell_ref": ref,
|
||||
"alias": alias,
|
||||
}
|
||||
|
||||
|
||||
def import_csv(
|
||||
project: Dict[str, Any],
|
||||
sheet_index: int,
|
||||
path: str,
|
||||
start_cell: str = "A1",
|
||||
) -> Dict[str, Any]:
|
||||
"""Import CSV data into a spreadsheet.
|
||||
|
||||
Each CSV value is stored as a cell starting from *start_cell*.
|
||||
Numeric strings are converted to floats automatically.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
sheet_index : int
|
||||
Index of the target spreadsheet.
|
||||
path : str
|
||||
Path to the CSV file.
|
||||
start_cell : str
|
||||
Top-left cell for the imported data. Defaults to ``"A1"``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Summary with number of rows and columns imported.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *sheet_index* is out of range.
|
||||
FileNotFoundError
|
||||
If *path* does not exist.
|
||||
ValueError
|
||||
If *start_cell* is invalid.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Path must be a non-empty string")
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"CSV file not found: {path}")
|
||||
|
||||
start_ref = _validate_cell_ref(start_cell)
|
||||
start_col_letters, start_row = _parse_cell_ref(start_ref)
|
||||
start_col = _col_to_index(start_col_letters)
|
||||
|
||||
sheet = _get_sheet(project, sheet_index)
|
||||
|
||||
with open(path, "r", encoding="utf-8", newline="") as fh:
|
||||
reader = csv.reader(fh)
|
||||
rows_imported = 0
|
||||
max_cols = 0
|
||||
|
||||
for row_offset, row in enumerate(reader):
|
||||
for col_offset, raw_value in enumerate(row):
|
||||
col_letters = _index_to_col(start_col + col_offset)
|
||||
row_num = start_row + row_offset
|
||||
ref = f"{col_letters}{row_num}"
|
||||
|
||||
# Try to parse as number
|
||||
value: Union[str, float]
|
||||
try:
|
||||
value = float(raw_value)
|
||||
# Keep as int if no decimal part
|
||||
if value == int(value):
|
||||
value = int(value)
|
||||
cell_type = "number"
|
||||
except (ValueError, OverflowError):
|
||||
value = raw_value
|
||||
cell_type = "string"
|
||||
|
||||
sheet["cells"][ref] = {"value": value, "type": cell_type}
|
||||
|
||||
rows_imported += 1
|
||||
if len(row) > max_cols:
|
||||
max_cols = len(row)
|
||||
|
||||
return {
|
||||
"sheet_index": sheet_index,
|
||||
"rows_imported": rows_imported,
|
||||
"columns_imported": max_cols,
|
||||
"start_cell": start_ref,
|
||||
}
|
||||
|
||||
|
||||
def export_csv(
|
||||
project: Dict[str, Any], sheet_index: int, path: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Export a spreadsheet to a CSV file.
|
||||
|
||||
Cells are written in row-major order. Empty cells produce empty
|
||||
strings in the output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The project state dictionary.
|
||||
sheet_index : int
|
||||
Index of the spreadsheet.
|
||||
path : str
|
||||
Destination file path.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Summary with the absolute path and dimensions.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *sheet_index* is out of range.
|
||||
ValueError
|
||||
If *path* is invalid.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Path must be a non-empty string")
|
||||
|
||||
sheet = _get_sheet(project, sheet_index)
|
||||
cells = sheet["cells"]
|
||||
|
||||
if not cells:
|
||||
# Write an empty file
|
||||
dir_name = os.path.dirname(path)
|
||||
if dir_name:
|
||||
os.makedirs(dir_name, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8", newline="") as fh:
|
||||
pass
|
||||
return {
|
||||
"sheet_index": sheet_index,
|
||||
"path": os.path.abspath(path),
|
||||
"rows": 0,
|
||||
"columns": 0,
|
||||
}
|
||||
|
||||
# Determine grid bounds
|
||||
min_col, max_col = float("inf"), 0
|
||||
min_row, max_row = float("inf"), 0
|
||||
|
||||
for ref in cells:
|
||||
col_letters, row_num = _parse_cell_ref(ref)
|
||||
col_idx = _col_to_index(col_letters)
|
||||
min_col = min(min_col, col_idx)
|
||||
max_col = max(max_col, col_idx)
|
||||
min_row = min(min_row, row_num)
|
||||
max_row = max(max_row, row_num)
|
||||
|
||||
min_col = int(min_col)
|
||||
min_row = int(min_row)
|
||||
|
||||
dir_name = os.path.dirname(path)
|
||||
if dir_name:
|
||||
os.makedirs(dir_name, exist_ok=True)
|
||||
|
||||
num_rows = max_row - min_row + 1
|
||||
num_cols = max_col - min_col + 1
|
||||
|
||||
with open(path, "w", encoding="utf-8", newline="") as fh:
|
||||
writer = csv.writer(fh)
|
||||
for row_num in range(min_row, max_row + 1):
|
||||
row_data: List[str] = []
|
||||
for col_idx in range(min_col, max_col + 1):
|
||||
ref = f"{_index_to_col(col_idx)}{row_num}"
|
||||
cell = cells.get(ref)
|
||||
if cell is not None:
|
||||
row_data.append(str(cell["value"]))
|
||||
else:
|
||||
row_data.append("")
|
||||
writer.writerow(row_data)
|
||||
|
||||
return {
|
||||
"sheet_index": sheet_index,
|
||||
"path": os.path.abspath(path),
|
||||
"rows": num_rows,
|
||||
"columns": num_cols,
|
||||
}
|
||||
|
||||
|
||||
def list_spreadsheets(project: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Return a summary of all spreadsheets in the project.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[dict]
|
||||
Each entry has ``id``, ``name``, ``cell_count``, and ``alias_count``.
|
||||
"""
|
||||
sheets = project.get("spreadsheets", [])
|
||||
return [
|
||||
{
|
||||
"id": s["id"],
|
||||
"name": s["name"],
|
||||
"cell_count": len(s.get("cells", {})),
|
||||
"alias_count": len(s.get("aliases", {})),
|
||||
}
|
||||
for s in sheets
|
||||
]
|
||||
@@ -0,0 +1,391 @@
|
||||
"""FreeCAD CLI - Surface workbench module.
|
||||
|
||||
Provides surface creation and manipulation functions including filling,
|
||||
lofting through sections, extending, blending, sewing, and cutting.
|
||||
All surfaces are stored in ``project["surfaces"]`` via
|
||||
:func:`~cli_anything.freecad.core.document.ensure_collection`.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cli_anything.freecad.core.document import ensure_collection
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _next_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for surfaces."""
|
||||
items = project.get("surfaces", [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _unique_name(project: Dict[str, Any], base: str) -> str:
|
||||
"""Return a unique name derived from *base* inside ``project["surfaces"]``."""
|
||||
existing = {item["name"] for item in project.get("surfaces", [])}
|
||||
if base not in existing:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base}_{counter}" in existing:
|
||||
counter += 1
|
||||
return f"{base}_{counter}"
|
||||
|
||||
|
||||
def _get_surface(project: Dict[str, Any], index: int) -> Dict[str, Any]:
|
||||
"""Return the surface at *index*, raising ``IndexError`` if out of range."""
|
||||
surfaces = project.get("surfaces", [])
|
||||
if not isinstance(index, int) or index < 0 or index >= len(surfaces):
|
||||
raise IndexError(
|
||||
f"Surface index {index} out of range (0..{len(surfaces) - 1})"
|
||||
)
|
||||
return surfaces[index]
|
||||
|
||||
|
||||
def _validate_index_list(indices: Any, label: str, min_count: int = 1) -> List[int]:
|
||||
"""Validate that *indices* is a list of non-negative integers."""
|
||||
if not isinstance(indices, (list, tuple)):
|
||||
raise ValueError(f"{label} must be a list of indices")
|
||||
if len(indices) < min_count:
|
||||
raise ValueError(f"{label} requires at least {min_count} index(es), got {len(indices)}")
|
||||
for i, v in enumerate(indices):
|
||||
if not isinstance(v, int) or v < 0:
|
||||
raise ValueError(f"{label}[{i}] must be a non-negative integer, got {v!r}")
|
||||
return list(indices)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def surface_filling(
|
||||
project: Dict[str, Any],
|
||||
edge_indices: List[int],
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a surface that fills a boundary defined by edges.
|
||||
|
||||
The filling surface interpolates through the specified boundary
|
||||
edges, producing a smooth G1/G2 surface patch.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
edge_indices : list[int]
|
||||
Indices referencing boundary edges (from parts or sketches).
|
||||
name : str or None
|
||||
Label for the surface. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created surface entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *edge_indices* has fewer than 1 entry or contains invalid values.
|
||||
"""
|
||||
refs = _validate_index_list(edge_indices, "edge_indices", min_count=1)
|
||||
surfaces = ensure_collection(project, "surfaces")
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "Filling")
|
||||
|
||||
surface: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"type": "filling",
|
||||
"params": {},
|
||||
"source_refs": refs,
|
||||
}
|
||||
|
||||
surfaces.append(surface)
|
||||
return surface
|
||||
|
||||
|
||||
def surface_sections(
|
||||
project: Dict[str, Any],
|
||||
section_indices: List[int],
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a loft-like surface through cross-section profiles.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
section_indices : list[int]
|
||||
Indices referencing cross-section profiles (edges, wires, or
|
||||
sketches). At least two sections are required.
|
||||
name : str or None
|
||||
Label for the surface. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created surface entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If fewer than two sections are provided.
|
||||
"""
|
||||
refs = _validate_index_list(section_indices, "section_indices", min_count=2)
|
||||
surfaces = ensure_collection(project, "surfaces")
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "Sections")
|
||||
|
||||
surface: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"type": "sections",
|
||||
"params": {},
|
||||
"source_refs": refs,
|
||||
}
|
||||
|
||||
surfaces.append(surface)
|
||||
return surface
|
||||
|
||||
|
||||
def surface_extend(
|
||||
project: Dict[str, Any],
|
||||
surface_index: int,
|
||||
length: float = 10.0,
|
||||
direction: str = "normal",
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Extend an existing surface by *length* along *direction*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
surface_index : int
|
||||
Index of the surface to extend.
|
||||
length : float
|
||||
Extension distance (default ``10``).
|
||||
direction : str
|
||||
Extension direction — ``"normal"`` (default), ``"u"``, or ``"v"``.
|
||||
name : str or None
|
||||
Label for the new surface. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created extended surface entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *surface_index* is out of range.
|
||||
ValueError
|
||||
If *length* is not positive or *direction* is invalid.
|
||||
"""
|
||||
source = _get_surface(project, surface_index)
|
||||
|
||||
if length <= 0:
|
||||
raise ValueError("length must be a positive number")
|
||||
|
||||
valid_dirs = {"normal", "u", "v"}
|
||||
if direction not in valid_dirs:
|
||||
raise ValueError(f"direction must be one of {', '.join(sorted(valid_dirs))}, got '{direction}'")
|
||||
|
||||
surfaces = ensure_collection(project, "surfaces")
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, f"{source['name']}_Extended")
|
||||
|
||||
surface: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"type": "extend",
|
||||
"params": {
|
||||
"length": float(length),
|
||||
"direction": direction,
|
||||
"source_surface_id": source["id"],
|
||||
},
|
||||
"source_refs": [surface_index],
|
||||
}
|
||||
|
||||
surfaces.append(surface)
|
||||
return surface
|
||||
|
||||
|
||||
def surface_blend_curve(
|
||||
project: Dict[str, Any],
|
||||
edge_index1: int,
|
||||
edge_index2: int,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a blend surface between two edges.
|
||||
|
||||
The blend surface smoothly connects two boundary edges with
|
||||
tangency continuity.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
edge_index1 : int
|
||||
Index referencing the first boundary edge.
|
||||
edge_index2 : int
|
||||
Index referencing the second boundary edge.
|
||||
name : str or None
|
||||
Label for the surface. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created surface entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If edge indices are equal or negative.
|
||||
"""
|
||||
if not isinstance(edge_index1, int) or edge_index1 < 0:
|
||||
raise ValueError("edge_index1 must be a non-negative integer")
|
||||
if not isinstance(edge_index2, int) or edge_index2 < 0:
|
||||
raise ValueError("edge_index2 must be a non-negative integer")
|
||||
if edge_index1 == edge_index2:
|
||||
raise ValueError("edge_index1 and edge_index2 must differ")
|
||||
|
||||
surfaces = ensure_collection(project, "surfaces")
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "BlendCurve")
|
||||
|
||||
surface: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"type": "blend_curve",
|
||||
"params": {},
|
||||
"source_refs": [edge_index1, edge_index2],
|
||||
}
|
||||
|
||||
surfaces.append(surface)
|
||||
return surface
|
||||
|
||||
|
||||
def surface_sew(
|
||||
project: Dict[str, Any],
|
||||
surface_indices: List[int],
|
||||
tolerance: float = 0.01,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Sew multiple surfaces into a single shell.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
surface_indices : list[int]
|
||||
Indices of the surfaces to sew together. At least two required.
|
||||
tolerance : float
|
||||
Sewing tolerance (default ``0.01``).
|
||||
name : str or None
|
||||
Label for the resulting surface. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created sewn surface entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If fewer than two surfaces or tolerance is invalid.
|
||||
IndexError
|
||||
If any surface index is out of range.
|
||||
"""
|
||||
if tolerance <= 0:
|
||||
raise ValueError("tolerance must be a positive number")
|
||||
|
||||
refs = _validate_index_list(surface_indices, "surface_indices", min_count=2)
|
||||
|
||||
# Validate that each referenced surface exists
|
||||
source_ids = []
|
||||
for idx in refs:
|
||||
s = _get_surface(project, idx)
|
||||
source_ids.append(s["id"])
|
||||
|
||||
surfaces = ensure_collection(project, "surfaces")
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "SewnSurface")
|
||||
|
||||
surface: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"type": "sew",
|
||||
"params": {
|
||||
"tolerance": float(tolerance),
|
||||
"source_surface_ids": source_ids,
|
||||
},
|
||||
"source_refs": refs,
|
||||
}
|
||||
|
||||
surfaces.append(surface)
|
||||
return surface
|
||||
|
||||
|
||||
def surface_cut(
|
||||
project: Dict[str, Any],
|
||||
surface_index: int,
|
||||
cutting_index: int,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Cut a surface with another surface or shape.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
surface_index : int
|
||||
Index of the surface to cut.
|
||||
cutting_index : int
|
||||
Index of the cutting surface or shape reference.
|
||||
name : str or None
|
||||
Label for the resulting surface. Auto-generated when *None*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created cut surface entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
IndexError
|
||||
If *surface_index* is out of range.
|
||||
ValueError
|
||||
If indices are equal.
|
||||
"""
|
||||
source = _get_surface(project, surface_index)
|
||||
|
||||
if surface_index == cutting_index:
|
||||
raise ValueError("surface_index and cutting_index must differ")
|
||||
|
||||
surfaces = ensure_collection(project, "surfaces")
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, f"{source['name']}_Cut")
|
||||
|
||||
surface: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"type": "cut",
|
||||
"params": {
|
||||
"source_surface_id": source["id"],
|
||||
"cutting_index": cutting_index,
|
||||
},
|
||||
"source_refs": [surface_index, cutting_index],
|
||||
}
|
||||
|
||||
surfaces.append(surface)
|
||||
return surface
|
||||
@@ -0,0 +1,643 @@
|
||||
"""FreeCAD CLI - TechDraw module.
|
||||
|
||||
Manages technical drawing pages, views (standard, projection, section,
|
||||
detail), dimensions, annotations, leaders, centerlines, hatches, and
|
||||
PDF/SVG export on a JSON-based project state.
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .document import ensure_collection
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_DIM_TYPES: Set[str] = {"length", "distance", "radius", "diameter", "angle"}
|
||||
|
||||
_COLLECTION_KEY = "techdraw_pages"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _next_id(project: Dict[str, Any]) -> int:
|
||||
"""Return the next available integer ID for TechDraw pages."""
|
||||
items = project.get(_COLLECTION_KEY, [])
|
||||
if not items:
|
||||
return 1
|
||||
return max(item["id"] for item in items) + 1
|
||||
|
||||
|
||||
def _unique_name(project: Dict[str, Any], base: str) -> str:
|
||||
"""Return a unique name derived from *base* inside the pages list."""
|
||||
existing = {item["name"] for item in project.get(_COLLECTION_KEY, [])}
|
||||
if base not in existing:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base}_{counter}" in existing:
|
||||
counter += 1
|
||||
return f"{base}_{counter}"
|
||||
|
||||
|
||||
def _validate_vec2(value: Any, label: str) -> List[float]:
|
||||
"""Validate that *value* is a list of exactly two numbers."""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise ValueError(f"{label} must be a list of 2 numbers, got {type(value).__name__}")
|
||||
if len(value) != 2:
|
||||
raise ValueError(f"{label} must have exactly 2 elements, got {len(value)}")
|
||||
try:
|
||||
return [float(v) for v in value]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{label} elements must be numeric: {exc}") from exc
|
||||
|
||||
|
||||
def _validate_vec3(value: Any, label: str) -> List[float]:
|
||||
"""Validate that *value* is a list of exactly three numbers."""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise ValueError(f"{label} must be a list of 3 numbers, got {type(value).__name__}")
|
||||
if len(value) != 3:
|
||||
raise ValueError(f"{label} must have exactly 3 elements, got {len(value)}")
|
||||
try:
|
||||
return [float(v) for v in value]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{label} elements must be numeric: {exc}") from exc
|
||||
|
||||
|
||||
def _get_page(project: Dict[str, Any], page_index: int) -> Dict[str, Any]:
|
||||
"""Internal accessor with bounds checking."""
|
||||
items = ensure_collection(project, _COLLECTION_KEY)
|
||||
if not isinstance(page_index, int) or page_index < 0 or page_index >= len(items):
|
||||
raise IndexError(
|
||||
f"Page index {page_index} out of range (0..{len(items) - 1})"
|
||||
)
|
||||
return items[page_index]
|
||||
|
||||
|
||||
def _get_view(
|
||||
project: Dict[str, Any], page_index: int, view_index: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Internal accessor for a view within a page."""
|
||||
page = _get_page(project, page_index)
|
||||
views = page["views"]
|
||||
if not isinstance(view_index, int) or view_index < 0 or view_index >= len(views):
|
||||
raise IndexError(
|
||||
f"View index {view_index} out of range (0..{len(views) - 1})"
|
||||
)
|
||||
return views[view_index]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def new_page(
|
||||
project: Dict[str, Any],
|
||||
name: Optional[str] = None,
|
||||
template: str = "A4_LandscapeTD",
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new TechDraw page and append it to the project.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
name : str or None
|
||||
Human-readable label. Auto-generated when *None*.
|
||||
template : str
|
||||
Drawing template name (e.g. ``"A4_LandscapeTD"``, ``"A3_LandscapeTD"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created page dictionary.
|
||||
"""
|
||||
items = ensure_collection(project, _COLLECTION_KEY)
|
||||
|
||||
if name is None:
|
||||
name = _unique_name(project, "Page")
|
||||
|
||||
page: Dict[str, Any] = {
|
||||
"id": _next_id(project),
|
||||
"name": name,
|
||||
"template": template,
|
||||
"views": [],
|
||||
"dimensions": [],
|
||||
"annotations": [],
|
||||
}
|
||||
|
||||
items.append(page)
|
||||
return page
|
||||
|
||||
|
||||
def set_template(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
template: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Change the template of an existing page.
|
||||
|
||||
Returns the updated page dictionary.
|
||||
"""
|
||||
if not isinstance(template, str) or not template.strip():
|
||||
raise ValueError("Template must be a non-empty string")
|
||||
|
||||
page = _get_page(project, page_index)
|
||||
page["template"] = template.strip()
|
||||
return page
|
||||
|
||||
|
||||
def add_view(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
source_index: int,
|
||||
direction: Optional[List[float]] = None,
|
||||
scale: float = 1.0,
|
||||
position: Optional[List[float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a standard view of a part/body to a TechDraw page.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
page_index : int
|
||||
Index of the target page.
|
||||
source_index : int
|
||||
Index of the source object in ``project["parts"]``.
|
||||
direction : list[float] or None
|
||||
View projection direction ``[x, y, z]``. Defaults to ``[0, 0, 1]``.
|
||||
scale : float
|
||||
View scale factor.
|
||||
position : list[float] or None
|
||||
Position on the page ``[x, y]``. Defaults to ``[0, 0]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The newly created view entry.
|
||||
"""
|
||||
page = _get_page(project, page_index)
|
||||
|
||||
if direction is not None:
|
||||
direction = _validate_vec3(direction, "direction")
|
||||
else:
|
||||
direction = [0.0, 0.0, 1.0]
|
||||
|
||||
if position is not None:
|
||||
position = _validate_vec2(position, "position")
|
||||
else:
|
||||
position = [0.0, 0.0]
|
||||
|
||||
view: Dict[str, Any] = {
|
||||
"type": "standard",
|
||||
"source_index": source_index,
|
||||
"direction": direction,
|
||||
"scale": float(scale),
|
||||
"position": position,
|
||||
}
|
||||
|
||||
page["views"].append(view)
|
||||
return view
|
||||
|
||||
|
||||
def add_projection_group(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
source_index: int,
|
||||
directions: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a projection group (front, right, top, etc.) to a page.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
page_index : int
|
||||
Index of the target page.
|
||||
source_index : int
|
||||
Index of the source object in ``project["parts"]``.
|
||||
directions : list[str] or None
|
||||
Projection names. Defaults to ``["front", "right", "top"]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The projection group view entry.
|
||||
"""
|
||||
page = _get_page(project, page_index)
|
||||
|
||||
if directions is None:
|
||||
directions = ["front", "right", "top"]
|
||||
|
||||
group: Dict[str, Any] = {
|
||||
"type": "projection_group",
|
||||
"source_index": source_index,
|
||||
"directions": list(directions),
|
||||
"scale": 1.0,
|
||||
"position": [0.0, 0.0],
|
||||
}
|
||||
|
||||
page["views"].append(group)
|
||||
return group
|
||||
|
||||
|
||||
def add_section_view(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
view_index: int,
|
||||
section_normal: Optional[List[float]] = None,
|
||||
section_origin: Optional[List[float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a section view derived from an existing view.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
page_index : int
|
||||
Index of the target page.
|
||||
view_index : int
|
||||
Index of the parent view within the page.
|
||||
section_normal : list[float] or None
|
||||
Normal vector of the section plane. Defaults to ``[1, 0, 0]``.
|
||||
section_origin : list[float] or None
|
||||
Origin point of the section plane. Defaults to ``[0, 0, 0]``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The section view entry.
|
||||
"""
|
||||
page = _get_page(project, page_index)
|
||||
# Validate parent view exists
|
||||
_get_view(project, page_index, view_index)
|
||||
|
||||
if section_normal is not None:
|
||||
section_normal = _validate_vec3(section_normal, "section_normal")
|
||||
else:
|
||||
section_normal = [1.0, 0.0, 0.0]
|
||||
|
||||
if section_origin is not None:
|
||||
section_origin = _validate_vec3(section_origin, "section_origin")
|
||||
else:
|
||||
section_origin = [0.0, 0.0, 0.0]
|
||||
|
||||
section: Dict[str, Any] = {
|
||||
"type": "section",
|
||||
"parent_view_index": view_index,
|
||||
"section_normal": section_normal,
|
||||
"section_origin": section_origin,
|
||||
"scale": 1.0,
|
||||
"position": [0.0, 0.0],
|
||||
}
|
||||
|
||||
page["views"].append(section)
|
||||
return section
|
||||
|
||||
|
||||
def add_detail_view(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
view_index: int,
|
||||
center: Optional[List[float]] = None,
|
||||
radius: float = 20.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a detail (magnified) view of part of an existing view.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
page_index : int
|
||||
Index of the target page.
|
||||
view_index : int
|
||||
Index of the parent view within the page.
|
||||
center : list[float] or None
|
||||
Center of the detail circle ``[x, y]``. Defaults to ``[0, 0]``.
|
||||
radius : float
|
||||
Radius of the detail circle.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The detail view entry.
|
||||
"""
|
||||
page = _get_page(project, page_index)
|
||||
_get_view(project, page_index, view_index)
|
||||
|
||||
if center is not None:
|
||||
center = _validate_vec2(center, "center")
|
||||
else:
|
||||
center = [0.0, 0.0]
|
||||
|
||||
detail: Dict[str, Any] = {
|
||||
"type": "detail",
|
||||
"parent_view_index": view_index,
|
||||
"center": center,
|
||||
"radius": float(radius),
|
||||
"scale": 2.0,
|
||||
"position": [0.0, 0.0],
|
||||
}
|
||||
|
||||
page["views"].append(detail)
|
||||
return detail
|
||||
|
||||
|
||||
def add_dimension(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
view_index: int,
|
||||
dim_type: str,
|
||||
references: List[Any],
|
||||
value: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a dimension annotation to a page.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
page_index : int
|
||||
Index of the target page.
|
||||
view_index : int
|
||||
Index of the view the dimension references.
|
||||
dim_type : str
|
||||
One of ``"length"``, ``"distance"``, ``"radius"``,
|
||||
``"diameter"``, ``"angle"``.
|
||||
references : list
|
||||
Geometry references (edges, vertices) for the dimension.
|
||||
value : float or None
|
||||
Explicit override value. When *None* the value is derived from
|
||||
the referenced geometry during macro execution.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The dimension entry.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *dim_type* is unknown.
|
||||
"""
|
||||
if dim_type not in VALID_DIM_TYPES:
|
||||
valid = ", ".join(sorted(VALID_DIM_TYPES))
|
||||
raise ValueError(f"Unknown dim_type '{dim_type}'. Valid: {valid}")
|
||||
|
||||
page = _get_page(project, page_index)
|
||||
_get_view(project, page_index, view_index)
|
||||
|
||||
dimension: Dict[str, Any] = {
|
||||
"type": dim_type,
|
||||
"view_index": view_index,
|
||||
"references": list(references),
|
||||
"value": float(value) if value is not None else None,
|
||||
}
|
||||
|
||||
page["dimensions"].append(dimension)
|
||||
return dimension
|
||||
|
||||
|
||||
def add_annotation(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
text: str,
|
||||
position: Optional[List[float]] = None,
|
||||
area_mode: bool = False,
|
||||
shape_validation: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a text annotation to a page.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
page_index : int
|
||||
Index of the target page.
|
||||
text : str
|
||||
Annotation text content.
|
||||
position : list[float] or None
|
||||
Position on the page ``[x, y]``. Defaults to ``[0, 0]``.
|
||||
area_mode : bool
|
||||
When ``True``, computes area accounting for face holes (default ``False``).
|
||||
shape_validation : bool
|
||||
Enables shape validation (default ``True``).
|
||||
|
||||
Returns the annotation entry.
|
||||
"""
|
||||
page = _get_page(project, page_index)
|
||||
|
||||
if position is not None:
|
||||
position = _validate_vec2(position, "position")
|
||||
else:
|
||||
position = [0.0, 0.0]
|
||||
|
||||
annotation: Dict[str, Any] = {
|
||||
"type": "annotation",
|
||||
"text": str(text),
|
||||
"position": position,
|
||||
"area_mode": bool(area_mode),
|
||||
"shape_validation": bool(shape_validation),
|
||||
}
|
||||
|
||||
page["annotations"].append(annotation)
|
||||
return annotation
|
||||
|
||||
|
||||
def add_leader(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
points: List[List[float]],
|
||||
text: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a leader line with optional text to a page.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
page_index : int
|
||||
Index of the target page.
|
||||
points : list[list[float]]
|
||||
List of ``[x, y]`` waypoints for the leader line.
|
||||
text : str
|
||||
Optional text label at the end of the leader.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The leader entry.
|
||||
"""
|
||||
page = _get_page(project, page_index)
|
||||
|
||||
validated_points: List[List[float]] = []
|
||||
for i, pt in enumerate(points):
|
||||
validated_points.append(_validate_vec2(pt, f"points[{i}]"))
|
||||
|
||||
leader: Dict[str, Any] = {
|
||||
"type": "leader",
|
||||
"points": validated_points,
|
||||
"text": str(text),
|
||||
}
|
||||
|
||||
page["annotations"].append(leader)
|
||||
return leader
|
||||
|
||||
|
||||
def add_centerline(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
view_index: int,
|
||||
references: List[Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a centerline to a view on a page.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
page_index : int
|
||||
Index of the target page.
|
||||
view_index : int
|
||||
Index of the view within the page.
|
||||
references : list
|
||||
Geometry references (edges, faces) that define the centerline.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The centerline entry.
|
||||
"""
|
||||
page = _get_page(project, page_index)
|
||||
_get_view(project, page_index, view_index)
|
||||
|
||||
centerline: Dict[str, Any] = {
|
||||
"type": "centerline",
|
||||
"view_index": view_index,
|
||||
"references": list(references),
|
||||
}
|
||||
|
||||
page["annotations"].append(centerline)
|
||||
return centerline
|
||||
|
||||
|
||||
def add_hatch(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
view_index: int,
|
||||
pattern: str = "steel",
|
||||
scale: float = 1.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add a hatch pattern to a view on a page.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
The mutable project state dictionary.
|
||||
page_index : int
|
||||
Index of the target page.
|
||||
view_index : int
|
||||
Index of the view within the page.
|
||||
pattern : str
|
||||
Hatch pattern name (e.g. ``"steel"``, ``"aluminum"``).
|
||||
scale : float
|
||||
Pattern scale factor.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
The hatch entry.
|
||||
"""
|
||||
page = _get_page(project, page_index)
|
||||
_get_view(project, page_index, view_index)
|
||||
|
||||
hatch: Dict[str, Any] = {
|
||||
"type": "hatch",
|
||||
"view_index": view_index,
|
||||
"pattern": str(pattern),
|
||||
"scale": float(scale),
|
||||
}
|
||||
|
||||
page["annotations"].append(hatch)
|
||||
return hatch
|
||||
|
||||
|
||||
def export_page_pdf(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Record metadata for exporting a page to PDF.
|
||||
|
||||
The actual export is performed by the generated FreeCAD macro.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Export metadata including page name and output path.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Path must be a non-empty string")
|
||||
|
||||
page = _get_page(project, page_index)
|
||||
|
||||
return {
|
||||
"action": "export_pdf",
|
||||
"page_name": page["name"],
|
||||
"page_index": page_index,
|
||||
"path": path.strip(),
|
||||
"format": "pdf",
|
||||
}
|
||||
|
||||
|
||||
def export_page_svg(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Record metadata for exporting a page to SVG.
|
||||
|
||||
The actual export is performed by the generated FreeCAD macro.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
Export metadata including page name and output path.
|
||||
"""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise ValueError("Path must be a non-empty string")
|
||||
|
||||
page = _get_page(project, page_index)
|
||||
|
||||
return {
|
||||
"action": "export_svg",
|
||||
"page_name": page["name"],
|
||||
"page_index": page_index,
|
||||
"path": path.strip(),
|
||||
"format": "svg",
|
||||
}
|
||||
|
||||
|
||||
def list_views(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return all views on a page."""
|
||||
page = _get_page(project, page_index)
|
||||
return page["views"]
|
||||
|
||||
|
||||
def get_view(
|
||||
project: Dict[str, Any],
|
||||
page_index: int,
|
||||
view_index: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a specific view from a page.
|
||||
|
||||
Raises ``IndexError`` when either index is out of range.
|
||||
"""
|
||||
return _get_view(project, page_index, view_index)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
---
|
||||
name: "cli-anything-freecad"
|
||||
description: "Complete CLI harness for FreeCAD parametric 3D CAD modeler (258 commands). Covers ALL workbenches: Part (29 primitives + boolean + mirror + loft + sweep), Sketcher (26 cmds: geometry + constraints + editing), PartDesign (38 cmds: pad/pocket/groove/fillet/chamfer/patterns/hole/datum), Assembly (11 cmds), Mesh (16 cmds), TechDraw (15 cmds: views + dimensions + PDF/SVG), Draft (33 cmds: 2D shapes + arrays + transforms), FEM (12 cmds), CAM/CNC (10 cmds), Surface (6 cmds), Spreadsheet (7 cmds), Import (13 formats), Export (17 formats), Measure (12 cmds), Materials (21 presets). Headless FreeCAD export to STEP/IGES/STL/OBJ/DXF/PDF/glTF/3MF."
|
||||
---
|
||||
|
||||
# cli-anything-freecad
|
||||
|
||||
Complete CLI harness for **FreeCAD** — 258 commands across 17 groups covering ALL workbenches.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
FreeCAD must be installed: `freecadcmd` must be in PATH.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install -e freecad/agent-harness
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
cli-anything-freecad --json <command> # JSON output for agents
|
||||
cli-anything-freecad --json -p proj.json <cmd> # With project file
|
||||
cli-anything-freecad # Interactive REPL
|
||||
```
|
||||
|
||||
## Command Groups (258 commands)
|
||||
|
||||
### document (5) — Document management
|
||||
```bash
|
||||
cli-anything-freecad --json document new --name "Part" -o proj.json
|
||||
cli-anything-freecad --json document new --profile print3d -o proj.json
|
||||
cli-anything-freecad --json -p proj.json document info
|
||||
cli-anything-freecad --json -p proj.json document save -o copy.json
|
||||
cli-anything-freecad --json document profiles
|
||||
```
|
||||
|
||||
### part (29) — 3D primitives, boolean, transforms, operations
|
||||
```bash
|
||||
# Primitives: box, cylinder, sphere, cone, torus, wedge, helix, spiral, thread, plane, polygon_3d
|
||||
cli-anything-freecad --json -p p.json part add box -P length=20 -P width=15 -P height=5
|
||||
cli-anything-freecad --json -p p.json part add cylinder -P radius=3 -P height=10 --position 10,7.5,0
|
||||
|
||||
# Operations
|
||||
cli-anything-freecad --json -p p.json part boolean cut 0 1
|
||||
cli-anything-freecad --json -p p.json part copy 0
|
||||
cli-anything-freecad --json -p p.json part mirror 0 --plane XY
|
||||
cli-anything-freecad --json -p p.json part scale 0 --factor 2.0
|
||||
cli-anything-freecad --json -p p.json part loft --indices 0,1,2
|
||||
cli-anything-freecad --json -p p.json part sweep 0 1
|
||||
cli-anything-freecad --json -p p.json part revolve 0 --axis Z --angle 360
|
||||
cli-anything-freecad --json -p p.json part extrude 0 --direction 0,0,1 --length 10
|
||||
cli-anything-freecad --json -p p.json part fillet-3d 0 --radius 2
|
||||
cli-anything-freecad --json -p p.json part thickness 0 --thickness 1
|
||||
cli-anything-freecad --json -p p.json part compound --indices 0,1,2
|
||||
cli-anything-freecad --json -p p.json part section 0 --plane XY
|
||||
cli-anything-freecad --json -p p.json part info 0
|
||||
cli-anything-freecad --json -p p.json part line-3d --start 0,0,0 --end 10,5,0
|
||||
cli-anything-freecad --json -p p.json part wire --points "0,0,0;10,0,0;10,10,0"
|
||||
```
|
||||
|
||||
### sketch (26) — 2D constrained sketching
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json sketch new --plane XY
|
||||
cli-anything-freecad --json -p p.json sketch add-line 0 --start 0,0 --end 20,0
|
||||
cli-anything-freecad --json -p p.json sketch add-circle 0 --center 10,10 --radius 5
|
||||
cli-anything-freecad --json -p p.json sketch add-rect 0 --corner 0,0 --width 20 --height 15
|
||||
cli-anything-freecad --json -p p.json sketch add-arc 0 --center 0,0 --radius 5
|
||||
cli-anything-freecad --json -p p.json sketch add-ellipse 0 --center 0,0 --major-radius 10 --minor-radius 5
|
||||
cli-anything-freecad --json -p p.json sketch add-polygon 0 --center 0,0 --sides 6 --radius 10
|
||||
cli-anything-freecad --json -p p.json sketch add-bspline 0 --points "0,0;5,10;10,0;15,10"
|
||||
cli-anything-freecad --json -p p.json sketch add-slot 0 --center1 0,0 --center2 10,0 --radius 2
|
||||
cli-anything-freecad --json -p p.json sketch constrain 0 distance --elements 0,1 --value 10
|
||||
cli-anything-freecad --json -p p.json sketch edit-element 0 0 --radius 8
|
||||
cli-anything-freecad --json -p p.json sketch remove-element 0 2
|
||||
cli-anything-freecad --json -p p.json sketch validate 0
|
||||
cli-anything-freecad --json -p p.json sketch solve-status 0
|
||||
# Constraints: coincident, horizontal, vertical, parallel, perpendicular, equal,
|
||||
# fixed, distance, angle, radius, tangent, symmetric, block, diameter,
|
||||
# point_on_object, distance_x, distance_y
|
||||
```
|
||||
|
||||
### body (38) — PartDesign features
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json body new
|
||||
cli-anything-freecad --json -p p.json body pad 0 0 --length 10
|
||||
cli-anything-freecad --json -p p.json body pocket 0 1 --length 5
|
||||
cli-anything-freecad --json -p p.json body groove 0 0 --angle 360
|
||||
cli-anything-freecad --json -p p.json body fillet 0 --radius 2
|
||||
cli-anything-freecad --json -p p.json body chamfer 0 --size 1.5
|
||||
cli-anything-freecad --json -p p.json body revolution 0 0 --angle 360
|
||||
cli-anything-freecad --json -p p.json body additive-loft 0 --sketch-indices 0,1
|
||||
cli-anything-freecad --json -p p.json body additive-pipe 0 0 1
|
||||
cli-anything-freecad --json -p p.json body additive-helix 0 0 --pitch 5 --height 20
|
||||
cli-anything-freecad --json -p p.json body additive-box 0 -P length=10 -P width=10 -P height=10
|
||||
cli-anything-freecad --json -p p.json body hole 0 0 --diameter 5 --depth 10 --threaded
|
||||
cli-anything-freecad --json -p p.json body draft-feature 0 --angle 5
|
||||
cli-anything-freecad --json -p p.json body thickness-feature 0 --thickness 1
|
||||
cli-anything-freecad --json -p p.json body linear-pattern 0 --occurrences 5 --length 50
|
||||
cli-anything-freecad --json -p p.json body polar-pattern 0 --occurrences 6 --angle 360
|
||||
cli-anything-freecad --json -p p.json body mirrored 0 --plane XY
|
||||
cli-anything-freecad --json -p p.json body datum-plane 0 --reference XY --offset 10
|
||||
```
|
||||
|
||||
### material (8) — PBR materials with engineering properties
|
||||
```bash
|
||||
# 21 presets: steel, aluminum, copper, brass, titanium, stainless_steel, cast_iron,
|
||||
# carbon_fiber, nylon, abs, pla, petg, plastic_white, plastic_black, wood, glass,
|
||||
# rubber, gold, concrete, granite, marble
|
||||
cli-anything-freecad --json -p p.json material create --preset steel
|
||||
cli-anything-freecad --json -p p.json material create --preset titanium
|
||||
cli-anything-freecad --json -p p.json material assign 0 0
|
||||
cli-anything-freecad --json -p p.json material set 0 density 7800
|
||||
cli-anything-freecad --json -p p.json material import-material mat.json
|
||||
cli-anything-freecad --json -p p.json material export-material 0 --output mat.json
|
||||
```
|
||||
|
||||
### assembly (11) — Assembly management
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json assembly new --name "MyAssembly"
|
||||
cli-anything-freecad --json -p p.json assembly add-part 0 0
|
||||
cli-anything-freecad --json -p p.json assembly constrain 0 coincident --components 0,1
|
||||
cli-anything-freecad --json -p p.json assembly constrain 0 distance --components 0,1 --distance 10
|
||||
cli-anything-freecad --json -p p.json assembly solve 0
|
||||
cli-anything-freecad --json -p p.json assembly dof 0
|
||||
cli-anything-freecad --json -p p.json assembly bom 0
|
||||
cli-anything-freecad --json -p p.json assembly explode 0 --factor 2.0
|
||||
# Constraints: fixed, coincident, distance, angle, parallel, perpendicular,
|
||||
# tangent, revolute, prismatic, cylindrical, ball, planar, gear, belt
|
||||
```
|
||||
|
||||
### mesh (16) — Mesh operations
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json mesh from-shape 0 --deviation 0.1
|
||||
cli-anything-freecad --json -p p.json mesh import path/to/model.stl
|
||||
cli-anything-freecad --json -p p.json mesh export 0 output.stl --format stl
|
||||
cli-anything-freecad --json -p p.json mesh boolean union 0 1
|
||||
cli-anything-freecad --json -p p.json mesh decimate 0 --target-faces 1000
|
||||
cli-anything-freecad --json -p p.json mesh smooth 0 --iterations 5
|
||||
cli-anything-freecad --json -p p.json mesh repair 0
|
||||
cli-anything-freecad --json -p p.json mesh to-shape 0
|
||||
```
|
||||
|
||||
### techdraw (15) — Technical drawings
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json techdraw new-page
|
||||
cli-anything-freecad --json -p p.json techdraw add-view 0 0 --direction 0,0,1 --scale 1.0
|
||||
cli-anything-freecad --json -p p.json techdraw add-projection-group 0 0
|
||||
cli-anything-freecad --json -p p.json techdraw add-section-view 0 0
|
||||
cli-anything-freecad --json -p p.json techdraw add-dimension 0 0 length --references 0,1
|
||||
cli-anything-freecad --json -p p.json techdraw add-annotation 0 "Note text"
|
||||
cli-anything-freecad --json -p p.json techdraw export-pdf 0 drawing.pdf
|
||||
cli-anything-freecad --json -p p.json techdraw export-svg 0 drawing.svg
|
||||
```
|
||||
|
||||
### draft (33) — 2D drafting
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json draft wire --points "0,0,0;10,0,0;10,10,0"
|
||||
cli-anything-freecad --json -p p.json draft rectangle --width 20 --height 15
|
||||
cli-anything-freecad --json -p p.json draft circle --radius 10
|
||||
cli-anything-freecad --json -p p.json draft polygon --sides 6 --radius 10
|
||||
cli-anything-freecad --json -p p.json draft text --content "Hello" --position 0,0,0
|
||||
cli-anything-freecad --json -p p.json draft move 0 --vector 10,5,0
|
||||
cli-anything-freecad --json -p p.json draft array-linear 0 --direction 1,0,0 --count 5 --spacing 10
|
||||
cli-anything-freecad --json -p p.json draft array-polar 0 --center 0,0,0 --count 6
|
||||
cli-anything-freecad --json -p p.json draft extrude 0 --direction 0,0,1 --length 10
|
||||
cli-anything-freecad --json -p p.json draft to-sketch 0
|
||||
```
|
||||
|
||||
### measure (12) — Measurement and analysis
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json measure volume 0
|
||||
cli-anything-freecad --json -p p.json measure area 0
|
||||
cli-anything-freecad --json -p p.json measure distance 0 1
|
||||
cli-anything-freecad --json -p p.json measure bounding-box 0
|
||||
cli-anything-freecad --json -p p.json measure center-of-mass 0
|
||||
cli-anything-freecad --json -p p.json measure check-geometry 0
|
||||
```
|
||||
|
||||
### surface (6) — Surface operations
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json surface filling --edges 0,1,2
|
||||
cli-anything-freecad --json -p p.json surface sections --sections 0,1,2
|
||||
cli-anything-freecad --json -p p.json surface extend 0 --length 10
|
||||
cli-anything-freecad --json -p p.json surface sew --indices 0,1
|
||||
```
|
||||
|
||||
### fem (12) — Finite Element Analysis
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json fem new-analysis
|
||||
cli-anything-freecad --json -p p.json fem add-fixed 0 --references face1,face2
|
||||
cli-anything-freecad --json -p p.json fem add-force 0 --references face3 --magnitude 1000
|
||||
cli-anything-freecad --json -p p.json fem set-material 0 0
|
||||
cli-anything-freecad --json -p p.json fem mesh-generate 0 --max-size 5
|
||||
cli-anything-freecad --json -p p.json fem solve 0
|
||||
cli-anything-freecad --json -p p.json fem results 0
|
||||
```
|
||||
|
||||
### cam (10) — CNC machining
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json cam new-job 0
|
||||
cli-anything-freecad --json -p p.json cam set-stock 0 --stock-type box
|
||||
cli-anything-freecad --json -p p.json cam set-tool 0 --diameter 6 --type endmill
|
||||
cli-anything-freecad --json -p p.json cam add-profile 0
|
||||
cli-anything-freecad --json -p p.json cam add-pocket 0 --depth 5
|
||||
cli-anything-freecad --json -p p.json cam generate-gcode 0
|
||||
cli-anything-freecad --json -p p.json cam export-gcode 0 output.nc
|
||||
```
|
||||
|
||||
### spreadsheet (7) — Parametric data tables
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json spreadsheet new
|
||||
cli-anything-freecad --json -p p.json spreadsheet set-cell 0 A1 "50"
|
||||
cli-anything-freecad --json -p p.json spreadsheet set-cell 0 B1 "=A1*2"
|
||||
cli-anything-freecad --json -p p.json spreadsheet set-alias 0 A1 plate_width
|
||||
cli-anything-freecad --json -p p.json spreadsheet export-csv 0 data.csv
|
||||
```
|
||||
|
||||
### import (13) — Import CAD/mesh files
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json import auto model.step
|
||||
cli-anything-freecad --json -p p.json import step model.step
|
||||
cli-anything-freecad --json -p p.json import stl model.stl
|
||||
cli-anything-freecad --json -p p.json import dxf drawing.dxf
|
||||
cli-anything-freecad --json -p p.json import info model.step
|
||||
# Formats: step, iges, stl, obj, dxf, svg, brep, 3mf, ply, off, gltf
|
||||
```
|
||||
|
||||
### export (3) — Export to 17 formats
|
||||
```bash
|
||||
# Presets: step, iges, stl, stl_fine, obj, brep, fcstd, dxf, svg, gltf, 3mf, ply, off, amf, pdf, png, jpg
|
||||
cli-anything-freecad --json -p p.json export render output.step --preset step
|
||||
cli-anything-freecad --json -p p.json export render model.stl --preset stl --overwrite
|
||||
cli-anything-freecad --json -p p.json export presets
|
||||
```
|
||||
|
||||
### session (4) — Undo/redo
|
||||
```bash
|
||||
cli-anything-freecad --json -p p.json session undo
|
||||
cli-anything-freecad --json -p p.json session redo
|
||||
cli-anything-freecad --json -p p.json session status
|
||||
cli-anything-freecad --json -p p.json session history
|
||||
```
|
||||
|
||||
## JSON Output
|
||||
|
||||
All commands support `--json`. Responses include structured data. Errors: `{"error": "message"}`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing FreeCAD: Clear install instructions
|
||||
- Invalid types: Lists valid options
|
||||
- Index out of range: Reports valid range
|
||||
- File exists: Use `--overwrite`
|
||||
@@ -0,0 +1,230 @@
|
||||
# TEST.md — cli-anything-freecad Test Plan and Results
|
||||
|
||||
## Test Inventory Plan
|
||||
|
||||
| Test File | Type | Estimated Tests |
|
||||
|-----------|------|----------------|
|
||||
| `test_core.py` | Unit tests | ~45 tests |
|
||||
| `test_full_e2e.py` | E2E + CLI subprocess | ~15 tests |
|
||||
|
||||
## Unit Test Plan (`test_core.py`)
|
||||
|
||||
### document.py (~8 tests)
|
||||
- Create document with defaults
|
||||
- Create document with profile
|
||||
- Create document with invalid profile → ValueError
|
||||
- Open document from file
|
||||
- Open document from invalid path → FileNotFoundError
|
||||
- Save document and verify file content
|
||||
- Get document info with parts/sketches/bodies
|
||||
- List profiles returns all presets
|
||||
|
||||
### parts.py (~10 tests)
|
||||
- Add box with defaults
|
||||
- Add all primitive types (cylinder, sphere, cone, torus, wedge)
|
||||
- Add part with custom position and rotation
|
||||
- Add part with custom params
|
||||
- Add part with invalid type → ValueError
|
||||
- Remove part by index
|
||||
- Remove part with invalid index → IndexError
|
||||
- Transform part position/rotation
|
||||
- Boolean cut operation
|
||||
- Boolean fuse and common operations
|
||||
|
||||
### sketch.py (~8 tests)
|
||||
- Create sketch on XY plane
|
||||
- Create sketch on XZ/YZ planes
|
||||
- Add line to sketch
|
||||
- Add circle to sketch
|
||||
- Add rectangle (generates 4 lines + 4 constraints)
|
||||
- Add arc to sketch
|
||||
- Add distance constraint with value
|
||||
- Close sketch
|
||||
|
||||
### body.py (~7 tests)
|
||||
- Create body
|
||||
- Pad body with sketch
|
||||
- Pocket body
|
||||
- Fillet body
|
||||
- Chamfer body
|
||||
- Revolution body
|
||||
- List bodies and get body details
|
||||
|
||||
### materials.py (~7 tests)
|
||||
- Create material with defaults
|
||||
- Create material from preset
|
||||
- Create material with custom color
|
||||
- Assign material to part
|
||||
- Set material property (color, metallic, roughness)
|
||||
- Invalid property → ValueError
|
||||
- List presets
|
||||
|
||||
### session.py (~6 tests)
|
||||
- Session status with no project
|
||||
- Set project and verify status
|
||||
- Snapshot and undo
|
||||
- Undo/redo cycle
|
||||
- Save session to file
|
||||
- History listing
|
||||
|
||||
## E2E Test Plan (`test_full_e2e.py`)
|
||||
|
||||
### Intermediate file tests (~5 tests)
|
||||
- Create full project JSON and verify structure
|
||||
- Build complex model (multiple parts, booleans, materials)
|
||||
- Generate FreeCAD macro and validate syntax
|
||||
- Project save/load roundtrip
|
||||
- Multi-step workflow (document → parts → booleans → materials → export)
|
||||
|
||||
### True backend tests (~5 tests — require FreeCAD installed)
|
||||
- Export simple box to STEP and validate format
|
||||
- Export multi-part model to STL and validate
|
||||
- Export to FCStd native format
|
||||
- Full workflow: create → add parts → boolean → export STEP
|
||||
- Verify file sizes are reasonable
|
||||
|
||||
### CLI subprocess tests (~5 tests)
|
||||
- `--help` returns 0
|
||||
- `--json document new` creates valid JSON
|
||||
- `--json part add box` returns part data
|
||||
- `--json part list` shows all parts
|
||||
- Full workflow via subprocess: new → add → boolean → export
|
||||
|
||||
## Realistic Workflow Scenarios
|
||||
|
||||
### Scenario 1: Mechanical Part with Hole
|
||||
Simulates creating a base plate with a mounting hole.
|
||||
1. `document new` → create project
|
||||
2. `part add box` → base plate
|
||||
3. `part add cylinder` → hole template
|
||||
4. `part boolean cut` → subtract hole from plate
|
||||
5. `material create --preset steel` → assign material
|
||||
6. `export render output.step` → export
|
||||
|
||||
### Scenario 2: Sketch-Based Extrusion
|
||||
Simulates creating a bracket from a sketch.
|
||||
1. `document new` → create project
|
||||
2. `sketch new --plane XY` → create sketch
|
||||
3. `sketch add-rect` → profile shape
|
||||
4. `sketch add-circle` → hole in profile
|
||||
5. `body new` → create body
|
||||
6. `body pad` → extrude sketch
|
||||
7. `body fillet` → round edges
|
||||
8. `export render output.step` → export
|
||||
|
||||
### Scenario 3: Multi-Part Assembly
|
||||
Simulates assembling multiple components.
|
||||
1. `document new` → create project
|
||||
2. Add multiple parts with different positions
|
||||
3. Apply materials (steel, aluminum)
|
||||
4. Create boolean unions where needed
|
||||
5. Export to STEP
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
**Date**: 2026-03-22
|
||||
**Platform**: Windows 11 — Python 3.14.2 — pytest 9.0.2
|
||||
**Command**: `python -m pytest cli_anything/freecad/tests/ -v --tb=no`
|
||||
|
||||
```
|
||||
cli_anything/freecad/tests/test_core.py::TestDocument::test_create_default PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestDocument::test_create_with_profile PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestDocument::test_create_invalid_profile PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestDocument::test_save_and_open PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestDocument::test_open_nonexistent PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestDocument::test_get_info PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestDocument::test_get_info_with_data PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestDocument::test_list_profiles PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_box_defaults PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_all_primitives[box] PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_all_primitives[cylinder] PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_all_primitives[sphere] PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_all_primitives[cone] PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_all_primitives[torus] PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_all_primitives[wedge] PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_with_position_rotation PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_with_custom_params PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_add_invalid_type PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_remove_part PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_remove_invalid_index PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_list_parts PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_transform_part PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_boolean_cut PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestParts::test_boolean_fuse_common PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSketch::test_create_sketch PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSketch::test_add_line PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSketch::test_add_circle PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSketch::test_add_rectangle PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSketch::test_add_arc PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSketch::test_add_constraint_distance PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSketch::test_close_sketch PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSketch::test_list_and_get_sketch PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestBody::test_create_body PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestBody::test_pad PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestBody::test_pocket PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestBody::test_fillet PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestBody::test_chamfer PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestBody::test_revolution PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestBody::test_list_and_get_body PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestMaterials::test_create_default PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestMaterials::test_create_from_preset PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestMaterials::test_create_with_color PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestMaterials::test_assign_to_part PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestMaterials::test_set_property PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestMaterials::test_set_invalid_property PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestMaterials::test_list_presets PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSession::test_status_no_project PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSession::test_set_project PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSession::test_snapshot_and_undo PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSession::test_undo_redo_cycle PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSession::test_save_session PASSED
|
||||
cli_anything/freecad/tests/test_core.py::TestSession::test_list_history PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestIntermediateFiles::test_full_project_json_structure PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestIntermediateFiles::test_multi_part_boolean_workflow PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestIntermediateFiles::test_macro_generation_syntax PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestIntermediateFiles::test_save_load_roundtrip PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestIntermediateFiles::test_complex_workflow PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestFreeCADBackend::test_find_freecad PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestFreeCADBackend::test_get_version PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestFreeCADBackend::test_export_box_step PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestFreeCADBackend::test_export_multi_part_stl PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestFreeCADBackend::test_export_fcstd PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestCLISubprocess::test_help PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestCLISubprocess::test_document_new_json PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestCLISubprocess::test_part_add_json PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestCLISubprocess::test_part_list_json PASSED
|
||||
cli_anything/freecad/tests/test_full_e2e.py::TestCLISubprocess::test_full_workflow_subprocess PASSED
|
||||
|
||||
============================= 67 passed in 3.47s ==============================
|
||||
```
|
||||
|
||||
### Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total tests | 67 |
|
||||
| Passed | 67 |
|
||||
| Failed | 0 |
|
||||
| Skipped | 0 |
|
||||
| Pass rate | **100%** |
|
||||
| Execution time | 3.47s |
|
||||
|
||||
### Coverage Notes
|
||||
|
||||
- **Unit tests**: Full coverage of original 6 core modules (document, parts, sketch, body, materials, session)
|
||||
- **E2E intermediate**: Project JSON structure, macro generation, save/load roundtrip
|
||||
- **E2E backend**: STEP, STL, and FCStd export with format validation (requires FreeCAD)
|
||||
- **CLI subprocess**: Full workflow tested via installed command
|
||||
- **Not covered**: REPL interactive mode (requires terminal), IGES/OBJ/BREP export (tested indirectly via presets)
|
||||
|
||||
### Post-Expansion Status (258 commands)
|
||||
|
||||
After expanding from 38 to 258 commands across 17 groups:
|
||||
- All 67 existing tests continue to pass (backward compatible)
|
||||
- **17 core modules** all parse without syntax errors
|
||||
- All 17 CLI groups register correctly and respond to `--help`
|
||||
- End-to-end workflow verified: document → part add → copy → mirror → measure → assembly
|
||||
- New modules covered: measure, spreadsheet, mesh, draft, surface, import, assembly, techdraw, fem, cam
|
||||
- New expanded functions: parts (+19), sketch (+17), body (+30), materials (+2+11 presets), export (+10 presets)
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for cli-anything-freecad."""
|
||||
@@ -0,0 +1,871 @@
|
||||
"""
|
||||
Comprehensive unit tests for the cli-anything-freecad core modules.
|
||||
|
||||
All tests use synthetic data and require no external dependencies
|
||||
beyond pytest.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from cli_anything.freecad.core.document import (
|
||||
PROFILES,
|
||||
create_document,
|
||||
get_document_info,
|
||||
list_profiles,
|
||||
open_document,
|
||||
save_document,
|
||||
)
|
||||
from cli_anything.freecad.core.parts import (
|
||||
PRIMITIVES,
|
||||
add_part,
|
||||
boolean_op,
|
||||
get_part,
|
||||
list_parts,
|
||||
remove_part,
|
||||
transform_part,
|
||||
)
|
||||
from cli_anything.freecad.core.sketch import (
|
||||
add_arc,
|
||||
add_circle,
|
||||
add_constraint,
|
||||
add_line,
|
||||
add_rectangle,
|
||||
close_sketch,
|
||||
create_sketch,
|
||||
get_sketch,
|
||||
list_sketches,
|
||||
)
|
||||
from cli_anything.freecad.core.body import (
|
||||
chamfer,
|
||||
create_body,
|
||||
datum_plane,
|
||||
datum_line,
|
||||
datum_point,
|
||||
fillet,
|
||||
get_body,
|
||||
hole_feature,
|
||||
list_bodies,
|
||||
local_coordinate_system,
|
||||
pad,
|
||||
pocket,
|
||||
revolution,
|
||||
toggle_freeze,
|
||||
)
|
||||
from cli_anything.freecad.core.materials import (
|
||||
PRESETS,
|
||||
assign_material,
|
||||
create_material,
|
||||
get_material,
|
||||
list_materials,
|
||||
list_presets,
|
||||
set_material_property,
|
||||
)
|
||||
from cli_anything.freecad.core.session import Session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_project(**overrides):
|
||||
"""Create a minimal valid project dict, applying any overrides."""
|
||||
proj = create_document(name="TestProject")
|
||||
proj.update(overrides)
|
||||
return proj
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestDocument
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestDocument:
|
||||
"""Tests for the document module."""
|
||||
|
||||
def test_create_default(self):
|
||||
proj = create_document()
|
||||
assert proj["name"] == "Untitled"
|
||||
assert proj["units"] == "mm"
|
||||
assert proj["version"] == "1.0"
|
||||
assert proj["parts"] == []
|
||||
assert proj["sketches"] == []
|
||||
assert proj["bodies"] == []
|
||||
assert proj["materials"] == []
|
||||
assert "created" in proj["metadata"]
|
||||
assert "modified" in proj["metadata"]
|
||||
assert "software" in proj["metadata"]
|
||||
|
||||
def test_create_with_profile(self):
|
||||
proj = create_document(name="ImperialProject", profile="imperial")
|
||||
assert proj["units"] == "in"
|
||||
assert proj["name"] == "ImperialProject"
|
||||
|
||||
proj2 = create_document(profile="metric_large")
|
||||
assert proj2["units"] == "m"
|
||||
|
||||
def test_create_invalid_profile(self):
|
||||
with pytest.raises(ValueError, match="Unknown profile"):
|
||||
create_document(profile="nonexistent_profile")
|
||||
|
||||
def test_save_and_open(self, tmp_path):
|
||||
proj = create_document(name="RoundTrip", units="mm")
|
||||
add_part(proj, "box", name="TestBox")
|
||||
|
||||
filepath = str(tmp_path / "roundtrip.json")
|
||||
abs_path = save_document(proj, filepath)
|
||||
assert os.path.isfile(abs_path)
|
||||
|
||||
loaded = open_document(filepath)
|
||||
assert loaded["name"] == "RoundTrip"
|
||||
assert loaded["units"] == "mm"
|
||||
assert len(loaded["parts"]) == 1
|
||||
assert loaded["parts"][0]["name"] == "TestBox"
|
||||
|
||||
def test_open_nonexistent(self, tmp_path):
|
||||
missing = str(tmp_path / "does_not_exist.json")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
open_document(missing)
|
||||
|
||||
def test_get_info(self):
|
||||
proj = create_document(name="InfoTest")
|
||||
info = get_document_info(proj)
|
||||
assert info["name"] == "InfoTest"
|
||||
assert info["units"] == "mm"
|
||||
assert info["parts_count"] == 0
|
||||
assert info["sketches_count"] == 0
|
||||
assert info["bodies_count"] == 0
|
||||
assert info["materials_count"] == 0
|
||||
|
||||
def test_get_info_with_data(self):
|
||||
proj = create_document(name="DataTest")
|
||||
add_part(proj, "box")
|
||||
add_part(proj, "cylinder")
|
||||
create_sketch(proj)
|
||||
|
||||
info = get_document_info(proj)
|
||||
assert info["parts_count"] == 2
|
||||
assert info["sketches_count"] == 1
|
||||
|
||||
def test_list_profiles(self):
|
||||
profiles = list_profiles()
|
||||
assert isinstance(profiles, list)
|
||||
assert len(profiles) == len(PROFILES)
|
||||
names = {p["name"] for p in profiles}
|
||||
assert "default" in names
|
||||
assert "imperial" in names
|
||||
for p in profiles:
|
||||
assert "name" in p
|
||||
assert "units" in p
|
||||
assert "description" in p
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestParts
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestParts:
|
||||
"""Tests for the parts module."""
|
||||
|
||||
def test_add_box_defaults(self):
|
||||
proj = _make_project()
|
||||
part = add_part(proj, "box")
|
||||
assert part["type"] == "box"
|
||||
assert part["name"] == "Box"
|
||||
assert part["params"]["length"] == 10.0
|
||||
assert part["params"]["width"] == 10.0
|
||||
assert part["params"]["height"] == 10.0
|
||||
assert part["placement"]["position"] == [0.0, 0.0, 0.0]
|
||||
assert part["placement"]["rotation"] == [0.0, 0.0, 0.0]
|
||||
assert part["visible"] is True
|
||||
assert part["material_index"] is None
|
||||
assert len(proj["parts"]) == 1
|
||||
|
||||
@pytest.mark.parametrize("ptype", ["box", "cylinder", "sphere", "cone", "torus", "wedge"])
|
||||
def test_add_all_primitives(self, ptype):
|
||||
proj = _make_project()
|
||||
part = add_part(proj, ptype)
|
||||
assert part["type"] == ptype
|
||||
# All default params from PRIMITIVES should be present
|
||||
for key in PRIMITIVES[ptype]:
|
||||
assert key in part["params"]
|
||||
assert part["params"][key] == PRIMITIVES[ptype][key]
|
||||
|
||||
def test_add_with_position_rotation(self):
|
||||
proj = _make_project()
|
||||
part = add_part(proj, "box", position=[1.0, 2.0, 3.0], rotation=[45.0, 0.0, 90.0])
|
||||
assert part["placement"]["position"] == [1.0, 2.0, 3.0]
|
||||
assert part["placement"]["rotation"] == [45.0, 0.0, 90.0]
|
||||
|
||||
def test_add_with_custom_params(self):
|
||||
proj = _make_project()
|
||||
part = add_part(proj, "box", params={"length": 20.0, "width": 5.0})
|
||||
assert part["params"]["length"] == 20.0
|
||||
assert part["params"]["width"] == 5.0
|
||||
assert part["params"]["height"] == 10.0 # default unchanged
|
||||
|
||||
def test_add_invalid_type(self):
|
||||
proj = _make_project()
|
||||
with pytest.raises(ValueError, match="Unknown part_type"):
|
||||
add_part(proj, "hexagon")
|
||||
|
||||
def test_remove_part(self):
|
||||
proj = _make_project()
|
||||
add_part(proj, "box", name="A")
|
||||
add_part(proj, "cylinder", name="B")
|
||||
assert len(proj["parts"]) == 2
|
||||
|
||||
removed = remove_part(proj, 0)
|
||||
assert removed["name"] == "A"
|
||||
assert len(proj["parts"]) == 1
|
||||
assert proj["parts"][0]["name"] == "B"
|
||||
|
||||
def test_remove_invalid_index(self):
|
||||
proj = _make_project()
|
||||
add_part(proj, "box")
|
||||
with pytest.raises(IndexError):
|
||||
remove_part(proj, 5)
|
||||
with pytest.raises(IndexError):
|
||||
remove_part(proj, -1)
|
||||
|
||||
def test_list_parts(self):
|
||||
proj = _make_project()
|
||||
assert list_parts(proj) == []
|
||||
add_part(proj, "box", name="A")
|
||||
add_part(proj, "sphere", name="B")
|
||||
parts = list_parts(proj)
|
||||
assert len(parts) == 2
|
||||
assert parts[0]["name"] == "A"
|
||||
assert parts[1]["name"] == "B"
|
||||
|
||||
def test_transform_part(self):
|
||||
proj = _make_project()
|
||||
add_part(proj, "box")
|
||||
updated = transform_part(proj, 0, position=[10.0, 20.0, 30.0])
|
||||
assert updated["placement"]["position"] == [10.0, 20.0, 30.0]
|
||||
# Rotation unchanged
|
||||
assert updated["placement"]["rotation"] == [0.0, 0.0, 0.0]
|
||||
|
||||
updated2 = transform_part(proj, 0, rotation=[90.0, 0.0, 0.0])
|
||||
assert updated2["placement"]["rotation"] == [90.0, 0.0, 0.0]
|
||||
# Position unchanged from previous transform
|
||||
assert updated2["placement"]["position"] == [10.0, 20.0, 30.0]
|
||||
|
||||
def test_boolean_cut(self):
|
||||
proj = _make_project()
|
||||
add_part(proj, "box", name="Base")
|
||||
add_part(proj, "cylinder", name="Tool")
|
||||
result = boolean_op(proj, "cut", 0, 1)
|
||||
|
||||
assert result["type"] == "cut"
|
||||
assert result["params"]["base_id"] == proj["parts"][0]["id"]
|
||||
assert result["params"]["tool_id"] == proj["parts"][1]["id"]
|
||||
assert result["visible"] is True
|
||||
# Operands should be hidden
|
||||
assert proj["parts"][0]["visible"] is False
|
||||
assert proj["parts"][1]["visible"] is False
|
||||
assert len(proj["parts"]) == 3
|
||||
|
||||
def test_boolean_fuse_common(self):
|
||||
proj = _make_project()
|
||||
add_part(proj, "box", name="A")
|
||||
add_part(proj, "box", name="B")
|
||||
|
||||
fuse_result = boolean_op(proj, "fuse", 0, 1)
|
||||
assert fuse_result["type"] == "fuse"
|
||||
|
||||
# Add two more for common test
|
||||
add_part(proj, "sphere", name="C")
|
||||
add_part(proj, "sphere", name="D")
|
||||
common_result = boolean_op(proj, "common", 3, 4)
|
||||
assert common_result["type"] == "common"
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown boolean op"):
|
||||
boolean_op(proj, "intersect", 0, 1)
|
||||
|
||||
with pytest.raises(ValueError, match="must differ"):
|
||||
boolean_op(proj, "cut", 0, 0)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestSketch
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSketch:
|
||||
"""Tests for the sketch module."""
|
||||
|
||||
def test_create_sketch(self):
|
||||
proj = _make_project()
|
||||
sk = create_sketch(proj, name="MySketch", plane="XZ", offset=5.0)
|
||||
assert sk["name"] == "MySketch"
|
||||
assert sk["plane"] == "XZ"
|
||||
assert sk["offset"] == 5.0
|
||||
assert sk["elements"] == []
|
||||
assert sk["constraints"] == []
|
||||
assert sk["closed"] is False
|
||||
assert len(proj["sketches"]) == 1
|
||||
|
||||
# Invalid plane
|
||||
with pytest.raises(ValueError, match="Invalid plane"):
|
||||
create_sketch(proj, plane="AB")
|
||||
|
||||
def test_add_line(self):
|
||||
proj = _make_project()
|
||||
create_sketch(proj)
|
||||
line = add_line(proj, 0, start=[0.0, 0.0], end=[10.0, 5.0])
|
||||
assert line["type"] == "line"
|
||||
assert line["start"] == [0.0, 0.0]
|
||||
assert line["end"] == [10.0, 5.0]
|
||||
assert len(proj["sketches"][0]["elements"]) == 1
|
||||
|
||||
def test_add_circle(self):
|
||||
proj = _make_project()
|
||||
create_sketch(proj)
|
||||
circle = add_circle(proj, 0, center=[1.0, 2.0], radius=8.0)
|
||||
assert circle["type"] == "circle"
|
||||
assert circle["center"] == [1.0, 2.0]
|
||||
assert circle["radius"] == 8.0
|
||||
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
add_circle(proj, 0, radius=-1.0)
|
||||
|
||||
def test_add_rectangle(self):
|
||||
proj = _make_project()
|
||||
create_sketch(proj)
|
||||
result = add_rectangle(proj, 0, corner=[0.0, 0.0], width=20.0, height=10.0)
|
||||
|
||||
assert result["type"] == "rectangle"
|
||||
assert len(result["line_ids"]) == 4
|
||||
assert len(result["constraint_ids"]) == 4
|
||||
assert result["width"] == 20.0
|
||||
assert result["height"] == 10.0
|
||||
|
||||
# 4 line elements and 4 constraints should be in the sketch
|
||||
sk = proj["sketches"][0]
|
||||
assert len(sk["elements"]) == 4
|
||||
assert len(sk["constraints"]) == 4
|
||||
|
||||
def test_add_arc(self):
|
||||
proj = _make_project()
|
||||
create_sketch(proj)
|
||||
arc = add_arc(proj, 0, center=[0.0, 0.0], radius=10.0, start_angle=0.0, end_angle=90.0)
|
||||
assert arc["type"] == "arc"
|
||||
assert arc["radius"] == 10.0
|
||||
assert arc["start_angle"] == 0.0
|
||||
assert arc["end_angle"] == 90.0
|
||||
# Check computed start/end points
|
||||
assert arc["start_point"][0] == pytest.approx(10.0)
|
||||
assert arc["start_point"][1] == pytest.approx(0.0)
|
||||
assert arc["end_point"][0] == pytest.approx(0.0, abs=1e-10)
|
||||
assert arc["end_point"][1] == pytest.approx(10.0)
|
||||
|
||||
def test_add_constraint_distance(self):
|
||||
proj = _make_project()
|
||||
create_sketch(proj)
|
||||
line = add_line(proj, 0, start=[0.0, 0.0], end=[10.0, 0.0])
|
||||
|
||||
constraint = add_constraint(
|
||||
proj, 0, constraint_type="distance", elements=[line["id"]], value=15.0
|
||||
)
|
||||
assert constraint["type"] == "distance"
|
||||
assert constraint["value"] == 15.0
|
||||
assert constraint["elements"] == [line["id"]]
|
||||
|
||||
# Missing value for dimensional constraint
|
||||
with pytest.raises(ValueError, match="requires a numeric value"):
|
||||
add_constraint(proj, 0, constraint_type="distance", elements=[line["id"]])
|
||||
|
||||
# Unknown constraint type
|
||||
with pytest.raises(ValueError, match="Unknown constraint type"):
|
||||
add_constraint(proj, 0, constraint_type="magical", elements=[line["id"]])
|
||||
|
||||
def test_close_sketch(self):
|
||||
proj = _make_project()
|
||||
create_sketch(proj)
|
||||
add_line(proj, 0)
|
||||
|
||||
closed = close_sketch(proj, 0)
|
||||
assert closed["closed"] is True
|
||||
|
||||
# Cannot add elements to a closed sketch
|
||||
with pytest.raises(ValueError, match="closed sketch"):
|
||||
add_line(proj, 0)
|
||||
|
||||
# Cannot close an already closed sketch
|
||||
with pytest.raises(ValueError, match="already closed"):
|
||||
close_sketch(proj, 0)
|
||||
|
||||
def test_list_and_get_sketch(self):
|
||||
proj = _make_project()
|
||||
create_sketch(proj, name="S1", plane="XY")
|
||||
create_sketch(proj, name="S2", plane="YZ")
|
||||
add_line(proj, 0)
|
||||
|
||||
summaries = list_sketches(proj)
|
||||
assert len(summaries) == 2
|
||||
assert summaries[0]["name"] == "S1"
|
||||
assert summaries[0]["plane"] == "XY"
|
||||
assert summaries[0]["element_count"] == 1
|
||||
assert summaries[1]["name"] == "S2"
|
||||
assert summaries[1]["plane"] == "YZ"
|
||||
|
||||
sk = get_sketch(proj, 1)
|
||||
assert sk["name"] == "S2"
|
||||
|
||||
with pytest.raises(IndexError):
|
||||
get_sketch(proj, 99)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBody
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBody:
|
||||
"""Tests for the body module."""
|
||||
|
||||
def _project_with_sketch(self):
|
||||
"""Return a project with one closed sketch containing a rectangle."""
|
||||
proj = _make_project()
|
||||
create_sketch(proj, name="BaseSketch")
|
||||
add_rectangle(proj, 0, corner=[0, 0], width=10, height=10)
|
||||
close_sketch(proj, 0)
|
||||
return proj
|
||||
|
||||
def test_create_body(self):
|
||||
proj = _make_project()
|
||||
body = create_body(proj, name="MyBody")
|
||||
assert body["name"] == "MyBody"
|
||||
assert body["features"] == []
|
||||
assert body["base_sketch_index"] is None
|
||||
assert len(proj["bodies"]) == 1
|
||||
|
||||
# Auto-naming
|
||||
body2 = create_body(proj)
|
||||
assert body2["name"] == "Body" # first auto "Body" is taken by none; unique check
|
||||
|
||||
def test_pad(self):
|
||||
proj = self._project_with_sketch()
|
||||
create_body(proj, name="PadBody")
|
||||
feature = pad(proj, body_index=0, sketch_index=0, length=15.0, symmetric=True)
|
||||
assert feature["type"] == "pad"
|
||||
assert feature["length"] == 15.0
|
||||
assert feature["symmetric"] is True
|
||||
assert feature["reversed"] is False
|
||||
assert proj["bodies"][0]["base_sketch_index"] == 0
|
||||
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
pad(proj, body_index=0, sketch_index=0, length=-5.0)
|
||||
|
||||
def test_pocket(self):
|
||||
proj = self._project_with_sketch()
|
||||
create_body(proj, name="PocketBody")
|
||||
# Add a pad first so body has features
|
||||
pad(proj, body_index=0, sketch_index=0, length=20.0)
|
||||
|
||||
# Create a second sketch for the pocket
|
||||
create_sketch(proj, name="PocketSketch")
|
||||
add_rectangle(proj, 1, corner=[2, 2], width=3, height=3)
|
||||
close_sketch(proj, 1)
|
||||
|
||||
feature = pocket(proj, body_index=0, sketch_index=1, length=5.0)
|
||||
assert feature["type"] == "pocket"
|
||||
assert feature["length"] == 5.0
|
||||
|
||||
def test_fillet(self):
|
||||
proj = self._project_with_sketch()
|
||||
create_body(proj)
|
||||
pad(proj, body_index=0, sketch_index=0, length=10.0)
|
||||
|
||||
feat = fillet(proj, body_index=0, radius=2.0, edges="all")
|
||||
assert feat["type"] == "fillet"
|
||||
assert feat["radius"] == 2.0
|
||||
assert feat["edges"] == "all"
|
||||
|
||||
feat2 = fillet(proj, body_index=0, radius=1.0, edges=[0, 1, 2])
|
||||
assert feat2["edges"] == [0, 1, 2]
|
||||
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
fillet(proj, body_index=0, radius=-1.0)
|
||||
|
||||
def test_chamfer(self):
|
||||
proj = self._project_with_sketch()
|
||||
create_body(proj)
|
||||
pad(proj, body_index=0, sketch_index=0, length=10.0)
|
||||
|
||||
feat = chamfer(proj, body_index=0, size=1.5, edges="all")
|
||||
assert feat["type"] == "chamfer"
|
||||
assert feat["size"] == 1.5
|
||||
assert feat["edges"] == "all"
|
||||
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
chamfer(proj, body_index=0, size=0.0)
|
||||
|
||||
def test_revolution(self):
|
||||
proj = self._project_with_sketch()
|
||||
create_body(proj)
|
||||
feat = revolution(proj, body_index=0, sketch_index=0, angle=180.0, axis="Y")
|
||||
assert feat["type"] == "revolution"
|
||||
assert feat["angle"] == 180.0
|
||||
assert feat["axis"] == "Y"
|
||||
assert feat["reversed"] is False
|
||||
|
||||
with pytest.raises(ValueError, match="angle must be in"):
|
||||
revolution(proj, body_index=0, sketch_index=0, angle=0.0)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid revolution axis"):
|
||||
revolution(proj, body_index=0, sketch_index=0, axis="W")
|
||||
|
||||
def test_list_and_get_body(self):
|
||||
proj = self._project_with_sketch()
|
||||
create_body(proj, name="B1")
|
||||
create_body(proj, name="B2")
|
||||
pad(proj, body_index=0, sketch_index=0, length=10.0)
|
||||
|
||||
summaries = list_bodies(proj)
|
||||
assert len(summaries) == 2
|
||||
assert summaries[0]["name"] == "B1"
|
||||
assert summaries[0]["feature_count"] == 1
|
||||
assert summaries[1]["name"] == "B2"
|
||||
assert summaries[1]["feature_count"] == 0
|
||||
|
||||
body = get_body(proj, 0)
|
||||
assert body["name"] == "B1"
|
||||
|
||||
with pytest.raises(IndexError):
|
||||
get_body(proj, 99)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestMaterials
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestMaterials:
|
||||
"""Tests for the materials module."""
|
||||
|
||||
def test_create_default(self):
|
||||
proj = _make_project()
|
||||
mat = create_material(proj)
|
||||
assert mat["name"] == "Material"
|
||||
assert mat["preset"] is None
|
||||
assert mat["color"] == [0.8, 0.8, 0.8, 1.0]
|
||||
assert mat["metallic"] == 0.0
|
||||
assert mat["roughness"] == 0.5
|
||||
assert mat["assigned_to"] == []
|
||||
assert len(proj["materials"]) == 1
|
||||
|
||||
def test_create_from_preset(self):
|
||||
proj = _make_project()
|
||||
mat = create_material(proj, preset="steel")
|
||||
assert mat["preset"] == "steel"
|
||||
assert mat["color"] == PRESETS["steel"]["color"]
|
||||
assert mat["metallic"] == PRESETS["steel"]["metallic"]
|
||||
assert mat["roughness"] == PRESETS["steel"]["roughness"]
|
||||
# Name is derived from preset key
|
||||
assert mat["name"] == "Steel"
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown preset"):
|
||||
create_material(proj, preset="unobtanium")
|
||||
|
||||
def test_create_with_color(self):
|
||||
proj = _make_project()
|
||||
mat = create_material(proj, name="Red", color=[1.0, 0.0, 0.0])
|
||||
# 3-component color gets alpha appended
|
||||
assert mat["color"] == [1.0, 0.0, 0.0, 1.0]
|
||||
|
||||
mat2 = create_material(proj, name="SemiRed", color=[1.0, 0.0, 0.0, 0.5])
|
||||
assert mat2["color"] == [1.0, 0.0, 0.0, 0.5]
|
||||
|
||||
def test_assign_to_part(self):
|
||||
proj = _make_project()
|
||||
add_part(proj, "box", name="MyBox")
|
||||
create_material(proj, name="BlueMat", color=[0.0, 0.0, 1.0])
|
||||
|
||||
result = assign_material(proj, material_index=0, part_index=0)
|
||||
assert result["material"] == "BlueMat"
|
||||
assert result["part"] == "MyBox"
|
||||
# Material should track the assignment
|
||||
assert 0 in proj["materials"][0]["assigned_to"]
|
||||
# Part should reference the material
|
||||
assert proj["parts"][0]["material_index"] == 0
|
||||
|
||||
def test_set_property(self):
|
||||
proj = _make_project()
|
||||
create_material(proj, name="Editable")
|
||||
|
||||
set_material_property(proj, 0, "roughness", 0.9)
|
||||
assert proj["materials"][0]["roughness"] == 0.9
|
||||
|
||||
set_material_property(proj, 0, "name", "Renamed")
|
||||
assert proj["materials"][0]["name"] == "Renamed"
|
||||
|
||||
set_material_property(proj, 0, "color", [0.1, 0.2, 0.3, 1.0])
|
||||
assert proj["materials"][0]["color"] == [0.1, 0.2, 0.3, 1.0]
|
||||
|
||||
def test_set_invalid_property(self):
|
||||
proj = _make_project()
|
||||
create_material(proj)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
set_material_property(proj, 0, "nonexistent_prop", 42)
|
||||
|
||||
with pytest.raises(ValueError, match="maximum"):
|
||||
set_material_property(proj, 0, "metallic", 2.0)
|
||||
|
||||
def test_list_presets(self):
|
||||
presets = list_presets()
|
||||
assert isinstance(presets, list)
|
||||
assert len(presets) == len(PRESETS)
|
||||
names = {p["name"] for p in presets}
|
||||
assert "steel" in names
|
||||
assert "gold" in names
|
||||
for p in presets:
|
||||
assert "name" in p
|
||||
assert "color" in p
|
||||
assert "metallic" in p
|
||||
assert "roughness" in p
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestSession
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSession:
|
||||
"""Tests for the session module."""
|
||||
|
||||
def test_status_no_project(self):
|
||||
session = Session()
|
||||
status = session.status()
|
||||
assert status["has_project"] is False
|
||||
assert status["project_path"] is None
|
||||
assert status["modified"] is False
|
||||
assert status["undo_depth"] == 0
|
||||
assert status["redo_depth"] == 0
|
||||
|
||||
with pytest.raises(RuntimeError, match="No project"):
|
||||
session.get_project()
|
||||
|
||||
def test_set_project(self):
|
||||
session = Session()
|
||||
proj = create_document(name="SessionTest")
|
||||
session.set_project(proj, path="/tmp/test.json")
|
||||
|
||||
assert session.get_project()["name"] == "SessionTest"
|
||||
assert session.project_path == "/tmp/test.json"
|
||||
status = session.status()
|
||||
assert status["has_project"] is True
|
||||
assert status["modified"] is False
|
||||
|
||||
def test_snapshot_and_undo(self):
|
||||
session = Session()
|
||||
proj = create_document(name="UndoTest")
|
||||
session.set_project(proj)
|
||||
|
||||
# Take a snapshot, then mutate
|
||||
session.snapshot("before adding box")
|
||||
add_part(session.get_project(), "box", name="TempBox")
|
||||
assert len(session.get_project()["parts"]) == 1
|
||||
|
||||
# Undo should restore the state before the mutation
|
||||
desc = session.undo()
|
||||
assert desc == "before adding box"
|
||||
assert len(session.get_project()["parts"]) == 0
|
||||
|
||||
# Undo with empty stack returns None
|
||||
assert session.undo() is None
|
||||
|
||||
def test_undo_redo_cycle(self):
|
||||
session = Session()
|
||||
proj = create_document(name="RedoTest")
|
||||
session.set_project(proj)
|
||||
|
||||
# Snapshot -> mutate -> undo -> redo
|
||||
session.snapshot("add cylinder")
|
||||
add_part(session.get_project(), "cylinder", name="Cyl")
|
||||
assert len(session.get_project()["parts"]) == 1
|
||||
|
||||
session.undo()
|
||||
assert len(session.get_project()["parts"]) == 0
|
||||
assert session.status()["redo_depth"] == 1
|
||||
|
||||
desc = session.redo()
|
||||
assert desc == "add cylinder"
|
||||
assert len(session.get_project()["parts"]) == 1
|
||||
|
||||
# Redo with empty stack returns None
|
||||
assert session.redo() is None
|
||||
|
||||
def test_save_session(self, tmp_path):
|
||||
session = Session()
|
||||
proj = create_document(name="SaveTest")
|
||||
session.set_project(proj)
|
||||
|
||||
filepath = str(tmp_path / "session_save.json")
|
||||
saved_path = session.save_session(path=filepath)
|
||||
assert os.path.isfile(saved_path)
|
||||
assert session.status()["modified"] is False
|
||||
|
||||
# Verify the file contains valid JSON matching the project
|
||||
with open(saved_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
assert data["name"] == "SaveTest"
|
||||
|
||||
# Save without path after initial save should use stored path
|
||||
session.snapshot("mark modified")
|
||||
saved_again = session.save_session()
|
||||
assert saved_again == saved_path
|
||||
|
||||
def test_list_history(self):
|
||||
session = Session()
|
||||
proj = create_document(name="HistoryTest")
|
||||
session.set_project(proj)
|
||||
|
||||
session.snapshot("step 1")
|
||||
add_part(session.get_project(), "box")
|
||||
session.snapshot("step 2")
|
||||
add_part(session.get_project(), "cylinder")
|
||||
session.snapshot("step 3")
|
||||
|
||||
history = session.list_history()
|
||||
assert len(history) == 3
|
||||
# Newest first
|
||||
assert history[0]["description"] == "step 3"
|
||||
assert history[1]["description"] == "step 2"
|
||||
assert history[2]["description"] == "step 1"
|
||||
# Each entry has required keys
|
||||
for entry in history:
|
||||
assert "index" in entry
|
||||
assert "timestamp" in entry
|
||||
assert "description" in entry
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestFreeCAD11Features — New features added for FreeCAD 1.1
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestFreeCAD11Features:
|
||||
"""Tests for FreeCAD 1.1 new features across modules."""
|
||||
|
||||
# -- Body: LocalCoordinateSystem --
|
||||
|
||||
def test_local_coordinate_system_default(self):
|
||||
proj = _make_project()
|
||||
body = create_body(proj, name="LCSBody")
|
||||
feat = local_coordinate_system(proj, 0)
|
||||
assert feat["type"] == "local_coordinate_system"
|
||||
assert feat["position"] == [0.0, 0.0, 0.0]
|
||||
assert feat["x_axis"] == [1.0, 0.0, 0.0]
|
||||
assert feat["y_axis"] == [0.0, 1.0, 0.0]
|
||||
assert feat["z_axis"] == [0.0, 0.0, 1.0]
|
||||
|
||||
def test_local_coordinate_system_custom_axes(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="LCSBody2")
|
||||
feat = local_coordinate_system(
|
||||
proj, 0,
|
||||
position=[10.0, 20.0, 30.0],
|
||||
x_axis=[0.0, 1.0, 0.0],
|
||||
z_axis=[1.0, 0.0, 0.0],
|
||||
)
|
||||
assert feat["position"] == [10.0, 20.0, 30.0]
|
||||
assert feat["x_axis"] == [0.0, 1.0, 0.0]
|
||||
|
||||
def test_local_coordinate_system_invalid_body(self):
|
||||
proj = _make_project()
|
||||
with pytest.raises(IndexError):
|
||||
local_coordinate_system(proj, 99)
|
||||
|
||||
# -- Body: Datum attachment --
|
||||
|
||||
def test_datum_plane_with_attachment(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="DatumBody")
|
||||
feat = datum_plane(proj, 0, attachment_mode="flat_face",
|
||||
attachment_refs=["Body.Face1"])
|
||||
assert feat["attachment_mode"] == "flat_face"
|
||||
assert feat["attachment_refs"] == ["Body.Face1"]
|
||||
|
||||
def test_datum_line_with_attachment(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="DatumBody2")
|
||||
feat = datum_line(proj, 0, attachment_mode="normal_to_edge",
|
||||
attachment_refs=["Body.Edge1"])
|
||||
assert feat["attachment_mode"] == "normal_to_edge"
|
||||
|
||||
def test_datum_point_with_attachment(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="DatumBody3")
|
||||
feat = datum_point(proj, 0, attachment_mode="translate",
|
||||
attachment_refs=["Body.Vertex1"])
|
||||
assert feat["attachment_mode"] == "translate"
|
||||
|
||||
def test_datum_invalid_attachment_mode(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="DatumBody4")
|
||||
with pytest.raises(ValueError, match="Invalid attachment_mode"):
|
||||
datum_plane(proj, 0, attachment_mode="nonexistent_mode")
|
||||
|
||||
# -- Body: Hole Whitworth threads --
|
||||
|
||||
def test_hole_whitworth_bsw(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="HoleBody")
|
||||
sk = create_sketch(proj)
|
||||
add_line(proj, 0, [0, 0], [10, 0])
|
||||
close_sketch(proj, 0)
|
||||
pad(proj, 0, sketch_index=0, length=10.0)
|
||||
feat = hole_feature(proj, 0, sketch_index=0, diameter=6.0, depth=10.0,
|
||||
threaded=True, thread_standard="BSW")
|
||||
assert feat["thread_standard"] == "BSW"
|
||||
|
||||
def test_hole_npt_auto_taper(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="HoleBody2")
|
||||
sk = create_sketch(proj)
|
||||
add_line(proj, 0, [0, 0], [10, 0])
|
||||
close_sketch(proj, 0)
|
||||
pad(proj, 0, sketch_index=0, length=10.0)
|
||||
feat = hole_feature(proj, 0, sketch_index=0, diameter=6.0, depth=10.0,
|
||||
threaded=True, thread_standard="NPT", tapered=True)
|
||||
assert feat["tapered"] is True
|
||||
assert abs(feat["taper_angle"] - 1.7899) < 0.001
|
||||
|
||||
def test_hole_invalid_thread_standard(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="HoleBody3")
|
||||
sk = create_sketch(proj)
|
||||
add_line(proj, 0, [0, 0], [10, 0])
|
||||
close_sketch(proj, 0)
|
||||
pad(proj, 0, sketch_index=0, length=10.0)
|
||||
with pytest.raises(ValueError, match="Invalid thread_standard"):
|
||||
hole_feature(proj, 0, sketch_index=0, diameter=6.0, depth=10.0,
|
||||
thread_standard="INVALID")
|
||||
|
||||
# -- Body: Toggle freeze --
|
||||
|
||||
def test_toggle_freeze(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="FreezeBody")
|
||||
create_sketch(proj)
|
||||
add_line(proj, 0, [0, 0], [10, 0])
|
||||
close_sketch(proj, 0)
|
||||
pad(proj, 0, sketch_index=0, length=5.0)
|
||||
feat = toggle_freeze(proj, 0, 0)
|
||||
assert feat["frozen"] is True
|
||||
feat2 = toggle_freeze(proj, 0, 0)
|
||||
assert feat2["frozen"] is False
|
||||
|
||||
def test_toggle_freeze_invalid_index(self):
|
||||
proj = _make_project()
|
||||
create_body(proj, name="FreezeBody2")
|
||||
with pytest.raises(IndexError):
|
||||
toggle_freeze(proj, 0, 99)
|
||||
@@ -0,0 +1,659 @@
|
||||
"""
|
||||
Full end-to-end tests for the cli-anything-freecad harness.
|
||||
|
||||
Covers three levels:
|
||||
1. TestIntermediateFiles -- JSON project + macro generation (no FreeCAD needed)
|
||||
2. TestFreeCADBackend -- headless FreeCAD export (skipped when not installed)
|
||||
3. TestCLISubprocess -- subprocess invocations of the CLI entry-point
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Imports from the harness under test
|
||||
# ---------------------------------------------------------------------------
|
||||
from cli_anything.freecad.core.document import (
|
||||
create_document,
|
||||
open_document,
|
||||
save_document,
|
||||
get_document_info,
|
||||
)
|
||||
from cli_anything.freecad.core.parts import (
|
||||
add_part,
|
||||
list_parts,
|
||||
get_part,
|
||||
boolean_op,
|
||||
transform_part,
|
||||
)
|
||||
from cli_anything.freecad.core.sketch import (
|
||||
create_sketch,
|
||||
add_line,
|
||||
add_circle,
|
||||
add_rectangle,
|
||||
add_arc,
|
||||
add_constraint,
|
||||
close_sketch,
|
||||
list_sketches,
|
||||
)
|
||||
from cli_anything.freecad.core.body import (
|
||||
create_body,
|
||||
pad,
|
||||
pocket,
|
||||
fillet,
|
||||
chamfer,
|
||||
revolution,
|
||||
list_bodies,
|
||||
)
|
||||
from cli_anything.freecad.core.materials import (
|
||||
create_material,
|
||||
assign_material,
|
||||
list_materials,
|
||||
)
|
||||
from cli_anything.freecad.core.export import export_project, get_export_info
|
||||
from cli_anything.freecad.utils.freecad_macro_gen import generate_macro
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _has_freecad() -> bool:
|
||||
"""Return True if FreeCAD headless backend can be located."""
|
||||
try:
|
||||
from cli_anything.freecad.utils.freecad_backend import find_freecad
|
||||
find_freecad()
|
||||
return True
|
||||
except (RuntimeError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_cli(name: str) -> List[str]:
|
||||
"""Resolve the CLI entry-point for subprocess tests.
|
||||
|
||||
Prefers an installed command on PATH; falls back to ``python -m``
|
||||
unless ``CLI_ANYTHING_FORCE_INSTALLED=1`` is set.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
print(f"[_resolve_cli] Using installed command: {path}")
|
||||
return [path]
|
||||
if force:
|
||||
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
||||
module = (
|
||||
name.replace("cli-anything-", "cli_anything.")
|
||||
.replace("-", "_")
|
||||
+ "."
|
||||
+ name.split("-")[-1]
|
||||
+ "_cli"
|
||||
)
|
||||
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
|
||||
return [sys.executable, "-m", module]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Intermediate-file tests (no FreeCAD required)
|
||||
# =========================================================================
|
||||
|
||||
class TestIntermediateFiles:
|
||||
"""Verify project creation, manipulation, and macro generation
|
||||
using only the Python API -- no FreeCAD binary needed."""
|
||||
|
||||
def test_full_project_json_structure(self, tmp_path):
|
||||
"""Create a complex project and verify the JSON schema."""
|
||||
proj = create_document(name="StructureTest", units="mm")
|
||||
|
||||
# Add varied parts
|
||||
add_part(proj, "box", name="MainBox", params={"length": 30, "width": 20, "height": 15})
|
||||
add_part(proj, "cylinder", name="Shaft", params={"radius": 3, "height": 50})
|
||||
add_part(proj, "sphere", name="Ball", params={"radius": 8})
|
||||
|
||||
# Add a sketch with elements
|
||||
create_sketch(proj, name="BaseSketch", plane="XY")
|
||||
add_rectangle(proj, 0, corner=[0, 0], width=20, height=10)
|
||||
|
||||
# Add a body with a pad
|
||||
create_body(proj, name="MainBody")
|
||||
pad(proj, 0, 0, length=15)
|
||||
|
||||
# Add a material
|
||||
create_material(proj, preset="steel")
|
||||
|
||||
# Save and reload
|
||||
path = str(tmp_path / "structure.json")
|
||||
save_document(proj, path)
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Top-level keys
|
||||
required_keys = {"version", "name", "units", "parts", "sketches",
|
||||
"bodies", "materials", "metadata"}
|
||||
assert required_keys.issubset(data.keys()), (
|
||||
f"Missing keys: {required_keys - set(data.keys())}"
|
||||
)
|
||||
|
||||
assert data["name"] == "StructureTest"
|
||||
assert data["units"] == "mm"
|
||||
assert data["version"] == "1.0"
|
||||
assert len(data["parts"]) == 3
|
||||
assert len(data["sketches"]) == 1
|
||||
assert len(data["bodies"]) == 1
|
||||
assert len(data["materials"]) == 1
|
||||
|
||||
# Verify part structure
|
||||
box = data["parts"][0]
|
||||
assert box["type"] == "box"
|
||||
assert box["name"] == "MainBox"
|
||||
assert box["params"]["length"] == 30.0
|
||||
assert "placement" in box
|
||||
assert box["placement"]["position"] == [0.0, 0.0, 0.0]
|
||||
|
||||
# Metadata
|
||||
assert "created" in data["metadata"]
|
||||
assert "modified" in data["metadata"]
|
||||
assert "software" in data["metadata"]
|
||||
|
||||
print(f"\n JSON structure validated: {path} ({os.path.getsize(path):,} bytes)")
|
||||
|
||||
def test_multi_part_boolean_workflow(self):
|
||||
"""Parts + booleans + materials, verify all state is consistent."""
|
||||
proj = create_document(name="BooleanTest")
|
||||
|
||||
# Add base and tool
|
||||
box = add_part(proj, "box", name="Base", params={"length": 20, "width": 20, "height": 20})
|
||||
cyl = add_part(proj, "cylinder", name="Hole",
|
||||
params={"radius": 5, "height": 30},
|
||||
position=[10, 10, -5])
|
||||
|
||||
assert len(list_parts(proj)) == 2
|
||||
assert box["id"] == 1
|
||||
assert cyl["id"] == 2
|
||||
|
||||
# Boolean cut
|
||||
cut_result = boolean_op(proj, "cut", base_index=0, tool_index=1, name="CutResult")
|
||||
assert cut_result["type"] == "cut"
|
||||
assert cut_result["params"]["base_id"] == box["id"]
|
||||
assert cut_result["params"]["tool_id"] == cyl["id"]
|
||||
assert cut_result["visible"] is True
|
||||
|
||||
# Source parts should now be hidden
|
||||
assert get_part(proj, 0)["visible"] is False
|
||||
assert get_part(proj, 1)["visible"] is False
|
||||
|
||||
# Total parts now 3 (box, cylinder, cut-result)
|
||||
assert len(list_parts(proj)) == 3
|
||||
|
||||
# Create material and assign to cut result
|
||||
mat = create_material(proj, preset="aluminum")
|
||||
assignment = assign_material(proj, material_index=0, part_index=2)
|
||||
assert assignment["material"] == mat["name"]
|
||||
assert assignment["part"] == "CutResult"
|
||||
|
||||
# Verify material assignment on part
|
||||
cut_part = get_part(proj, 2)
|
||||
assert cut_part["material_index"] == 0
|
||||
|
||||
# Verify material tracking
|
||||
materials = list_materials(proj)
|
||||
assert len(materials) == 1
|
||||
assert 2 in materials[0]["assigned_to"]
|
||||
|
||||
print("\n Boolean workflow verified: 2 primitives + cut + material assignment")
|
||||
|
||||
def test_macro_generation_syntax(self, tmp_path):
|
||||
"""Generate a macro and verify it is valid Python via ast.parse."""
|
||||
proj = create_document(name="MacroTest")
|
||||
add_part(proj, "box", name="TestBox", params={"length": 15, "width": 10, "height": 5})
|
||||
add_part(proj, "cylinder", name="TestCyl", params={"radius": 3, "height": 20})
|
||||
add_part(proj, "sphere", name="TestSphere", params={"radius": 7})
|
||||
|
||||
# Create body with features
|
||||
create_sketch(proj, plane="XY")
|
||||
add_rectangle(proj, 0, corner=[0, 0], width=10, height=10)
|
||||
create_body(proj, name="ExtrudedBody")
|
||||
pad(proj, 0, 0, length=10)
|
||||
|
||||
output_path = str(tmp_path / "output.step")
|
||||
macro = generate_macro(proj, output_path, export_format="step")
|
||||
|
||||
# Must be non-empty
|
||||
assert len(macro) > 100, f"Macro too short: {len(macro)} chars"
|
||||
|
||||
# Must be valid Python syntax
|
||||
try:
|
||||
ast.parse(macro)
|
||||
except SyntaxError as exc:
|
||||
pytest.fail(f"Generated macro has invalid Python syntax: {exc}\n\n{macro}")
|
||||
|
||||
# Must contain key FreeCAD imports
|
||||
assert "import FreeCAD" in macro
|
||||
assert "import Part" in macro
|
||||
assert "doc.recompute()" in macro
|
||||
|
||||
# Should reference our parts
|
||||
assert "TestBox" in macro
|
||||
assert "TestCyl" in macro
|
||||
assert "TestSphere" in macro
|
||||
|
||||
# Save macro for inspection
|
||||
macro_path = str(tmp_path / "macro.py")
|
||||
with open(macro_path, "w", encoding="utf-8") as f:
|
||||
f.write(macro)
|
||||
|
||||
print(f"\n Macro: {macro_path} ({len(macro):,} chars, {macro.count(chr(10))} lines)")
|
||||
|
||||
def test_save_load_roundtrip(self, tmp_path):
|
||||
"""Save a project, reload it, verify contents are identical."""
|
||||
proj = create_document(name="RoundTrip", units="in", profile="imperial")
|
||||
|
||||
add_part(proj, "box", name="BlockA", params={"length": 5, "width": 5, "height": 5})
|
||||
add_part(proj, "cone", name="ConeB",
|
||||
params={"radius1": 3, "radius2": 1, "height": 8})
|
||||
|
||||
create_sketch(proj, name="ProfileSketch", plane="XZ")
|
||||
add_line(proj, 0, start=[0, 0], end=[10, 0])
|
||||
add_circle(proj, 0, center=[5, 5], radius=3)
|
||||
|
||||
create_material(proj, name="CustomMat", color=[0.5, 0.3, 0.1, 1.0],
|
||||
metallic=0.7, roughness=0.4)
|
||||
assign_material(proj, 0, 0)
|
||||
|
||||
path = str(tmp_path / "roundtrip.json")
|
||||
save_document(proj, path)
|
||||
|
||||
# Reload
|
||||
loaded = open_document(path)
|
||||
|
||||
# Compare key fields (metadata.modified will differ slightly, so skip it)
|
||||
assert loaded["name"] == proj["name"]
|
||||
assert loaded["units"] == proj["units"]
|
||||
assert loaded["version"] == proj["version"]
|
||||
assert len(loaded["parts"]) == len(proj["parts"])
|
||||
assert len(loaded["sketches"]) == len(proj["sketches"])
|
||||
assert len(loaded["bodies"]) == len(proj["bodies"])
|
||||
assert len(loaded["materials"]) == len(proj["materials"])
|
||||
|
||||
# Deep-compare parts
|
||||
for i, (orig, reloaded) in enumerate(zip(proj["parts"], loaded["parts"])):
|
||||
assert orig["name"] == reloaded["name"], f"Part {i} name mismatch"
|
||||
assert orig["type"] == reloaded["type"], f"Part {i} type mismatch"
|
||||
assert orig["params"] == reloaded["params"], f"Part {i} params mismatch"
|
||||
|
||||
# Deep-compare sketches
|
||||
for i, (orig, reloaded) in enumerate(zip(proj["sketches"], loaded["sketches"])):
|
||||
assert orig["name"] == reloaded["name"], f"Sketch {i} name mismatch"
|
||||
assert orig["plane"] == reloaded["plane"], f"Sketch {i} plane mismatch"
|
||||
assert len(orig["elements"]) == len(reloaded["elements"])
|
||||
|
||||
print(f"\n Round-trip verified: {path} ({os.path.getsize(path):,} bytes)")
|
||||
|
||||
def test_complex_workflow(self, tmp_path):
|
||||
"""Full pipeline: document -> parts -> sketch -> body -> materials."""
|
||||
# 1. Create document
|
||||
proj = create_document(name="ComplexWorkflow", profile="print3d")
|
||||
assert proj["units"] == "mm"
|
||||
|
||||
# 2. Add multiple parts
|
||||
box = add_part(proj, "box", name="Platform",
|
||||
params={"length": 50, "width": 50, "height": 5})
|
||||
cyl = add_part(proj, "cylinder", name="Pillar",
|
||||
params={"radius": 5, "height": 40},
|
||||
position=[25, 25, 5])
|
||||
sphere = add_part(proj, "sphere", name="Top",
|
||||
params={"radius": 8},
|
||||
position=[25, 25, 45])
|
||||
|
||||
# 3. Transform a part
|
||||
transform_part(proj, 2, position=[25, 25, 50], rotation=[0, 0, 45])
|
||||
top = get_part(proj, 2)
|
||||
assert top["placement"]["position"] == [25.0, 25.0, 50.0]
|
||||
assert top["placement"]["rotation"] == [0.0, 0.0, 45.0]
|
||||
|
||||
# 4. Boolean fuse
|
||||
fuse_result = boolean_op(proj, "fuse", 0, 1, name="PlatformPillar")
|
||||
assert fuse_result["type"] == "fuse"
|
||||
assert len(list_parts(proj)) == 4 # box, cyl, sphere, fuse
|
||||
|
||||
# 5. Create sketch with various elements
|
||||
sk = create_sketch(proj, name="DetailSketch", plane="XY", offset=5.0)
|
||||
assert sk["plane"] == "XY"
|
||||
assert sk["offset"] == 5.0
|
||||
|
||||
add_rectangle(proj, 0, corner=[10, 10], width=30, height=30)
|
||||
add_circle(proj, 0, center=[25, 25], radius=10)
|
||||
add_arc(proj, 0, center=[25, 25], radius=15, start_angle=0, end_angle=180)
|
||||
|
||||
# Add a constraint
|
||||
sketch_data = proj["sketches"][0]
|
||||
line_ids = [el["id"] for el in sketch_data["elements"] if el["type"] == "line"]
|
||||
assert len(line_ids) >= 2, "Should have at least 2 lines from rectangle"
|
||||
add_constraint(proj, 0, "horizontal", [line_ids[0]])
|
||||
|
||||
# Close the sketch
|
||||
closed = close_sketch(proj, 0)
|
||||
assert closed["closed"] is True
|
||||
|
||||
sketches = list_sketches(proj)
|
||||
assert len(sketches) == 1
|
||||
assert sketches[0]["closed"] is True
|
||||
assert sketches[0]["element_count"] >= 6 # 4 rect lines + circle + arc
|
||||
|
||||
# 6. Create body with features
|
||||
body = create_body(proj, name="DetailBody")
|
||||
# Create a new open sketch for the body
|
||||
create_sketch(proj, name="BodySketch", plane="XY")
|
||||
add_rectangle(proj, 1, corner=[0, 0], width=20, height=20)
|
||||
|
||||
pad_feat = pad(proj, 0, 1, length=20)
|
||||
assert pad_feat["type"] == "pad"
|
||||
assert pad_feat["length"] == 20.0
|
||||
|
||||
fillet_feat = fillet(proj, 0, radius=2.0)
|
||||
assert fillet_feat["type"] == "fillet"
|
||||
assert fillet_feat["radius"] == 2.0
|
||||
|
||||
bodies = list_bodies(proj)
|
||||
assert len(bodies) == 1
|
||||
assert bodies[0]["feature_count"] == 2
|
||||
|
||||
# 7. Materials
|
||||
steel = create_material(proj, preset="steel")
|
||||
copper = create_material(proj, preset="copper")
|
||||
assert steel["preset"] == "steel"
|
||||
assert copper["preset"] == "copper"
|
||||
|
||||
assign_material(proj, 0, 0) # steel -> Platform(box)
|
||||
assign_material(proj, 1, 1) # copper -> Pillar(cylinder)
|
||||
|
||||
mats = list_materials(proj)
|
||||
assert len(mats) == 2
|
||||
assert 0 in mats[0]["assigned_to"]
|
||||
assert 1 in mats[1]["assigned_to"]
|
||||
|
||||
# 8. Save and verify
|
||||
path = str(tmp_path / "complex.json")
|
||||
saved = save_document(proj, path)
|
||||
assert os.path.isfile(saved)
|
||||
|
||||
info = get_document_info(proj)
|
||||
assert info["parts_count"] == 4
|
||||
assert info["sketches_count"] == 2
|
||||
assert info["bodies_count"] == 1
|
||||
assert info["materials_count"] == 2
|
||||
|
||||
# 9. Generate macro
|
||||
macro = generate_macro(proj, str(tmp_path / "complex.step"))
|
||||
ast.parse(macro) # valid Python
|
||||
|
||||
# 10. Export info
|
||||
exp_info = get_export_info(proj)
|
||||
assert exp_info["part_count"] == 4
|
||||
assert "Platform" in exp_info["part_names"]
|
||||
|
||||
print(f"\n Complex workflow: {path} ({os.path.getsize(path):,} bytes)")
|
||||
print(f" Parts: {info['parts_count']}, Sketches: {info['sketches_count']}, "
|
||||
f"Bodies: {info['bodies_count']}, Materials: {info['materials_count']}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. FreeCAD backend tests (require FreeCAD installed)
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.skipif(not _has_freecad(), reason="FreeCAD not installed")
|
||||
class TestFreeCADBackend:
|
||||
"""Tests that require the real FreeCAD headless backend."""
|
||||
|
||||
def test_find_freecad(self):
|
||||
"""Verify that find_freecad returns a valid path."""
|
||||
from cli_anything.freecad.utils.freecad_backend import find_freecad
|
||||
|
||||
path = find_freecad()
|
||||
assert os.path.isfile(path), f"FreeCAD not found at: {path}"
|
||||
print(f"\n FreeCAD found: {path}")
|
||||
|
||||
def test_get_version(self):
|
||||
"""Verify that get_version returns a version string."""
|
||||
from cli_anything.freecad.utils.freecad_backend import get_version
|
||||
|
||||
version = get_version()
|
||||
assert isinstance(version, str)
|
||||
assert len(version) > 0
|
||||
# Should contain at least one digit and a dot
|
||||
assert any(c.isdigit() for c in version), f"No digits in version: {version}"
|
||||
print(f"\n FreeCAD version: {version}")
|
||||
|
||||
def test_export_box_step(self, tmp_path):
|
||||
"""Create a project with a box, export to STEP, validate format."""
|
||||
proj = create_document(name="StepExport")
|
||||
add_part(proj, "box", name="ExportBox",
|
||||
params={"length": 20, "width": 15, "height": 10})
|
||||
|
||||
output = str(tmp_path / "box.step")
|
||||
result = export_project(proj, output, preset="step")
|
||||
|
||||
assert os.path.isfile(output)
|
||||
size = os.path.getsize(output)
|
||||
assert size > 0, "STEP file is empty"
|
||||
|
||||
# Validate STEP header
|
||||
with open(output, "r", encoding="utf-8", errors="ignore") as f:
|
||||
header = f.read(64)
|
||||
assert header.strip().startswith("ISO-10303-21"), (
|
||||
f"Invalid STEP header: {header[:40]!r}"
|
||||
)
|
||||
|
||||
print(f"\n STEP: {output} ({size:,} bytes)")
|
||||
|
||||
def test_export_multi_part_stl(self, tmp_path):
|
||||
"""Export multiple parts to STL, validate format."""
|
||||
proj = create_document(name="StlExport")
|
||||
add_part(proj, "box", name="Block",
|
||||
params={"length": 10, "width": 10, "height": 10})
|
||||
add_part(proj, "cylinder", name="Rod",
|
||||
params={"radius": 3, "height": 20},
|
||||
position=[15, 0, 0])
|
||||
|
||||
output = str(tmp_path / "multi.stl")
|
||||
result = export_project(proj, output, preset="stl")
|
||||
|
||||
assert os.path.isfile(output)
|
||||
size = os.path.getsize(output)
|
||||
assert size > 0, "STL file is empty"
|
||||
|
||||
# Validate STL: ASCII starts with "solid", binary has 80-byte header
|
||||
with open(output, "rb") as f:
|
||||
head = f.read(80)
|
||||
|
||||
text_head = head.decode("ascii", errors="ignore").strip().lower()
|
||||
is_ascii = text_head.startswith("solid")
|
||||
|
||||
is_binary = False
|
||||
if not is_ascii:
|
||||
with open(output, "rb") as f:
|
||||
f.seek(80)
|
||||
count_bytes = f.read(4)
|
||||
if len(count_bytes) == 4:
|
||||
tri_count = struct.unpack("<I", count_bytes)[0]
|
||||
is_binary = tri_count > 0
|
||||
|
||||
assert is_ascii or is_binary, "File is neither ASCII nor binary STL"
|
||||
|
||||
fmt = "ASCII" if is_ascii else "binary"
|
||||
print(f"\n STL ({fmt}): {output} ({size:,} bytes)")
|
||||
|
||||
def test_export_fcstd(self, tmp_path):
|
||||
"""Export to native FCStd format."""
|
||||
proj = create_document(name="FcstdExport")
|
||||
add_part(proj, "box", name="NativeBox",
|
||||
params={"length": 25, "width": 25, "height": 25})
|
||||
|
||||
output = str(tmp_path / "native.FCStd")
|
||||
result = export_project(proj, output, preset="fcstd")
|
||||
|
||||
assert os.path.isfile(output)
|
||||
size = os.path.getsize(output)
|
||||
assert size > 0, "FCStd file is empty"
|
||||
|
||||
print(f"\n FCStd: {output} ({size:,} bytes)")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. CLI subprocess tests
|
||||
# =========================================================================
|
||||
|
||||
class TestCLISubprocess:
|
||||
"""Test the CLI entry-point via subprocess invocations."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cli_cmd(self):
|
||||
"""Resolve the CLI command once for all tests."""
|
||||
self.cli = _resolve_cli("cli-anything-freecad")
|
||||
|
||||
def _run(self, *args: str, **kwargs) -> subprocess.CompletedProcess:
|
||||
"""Run a CLI command and return the result."""
|
||||
cmd = self.cli + list(args)
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_help(self):
|
||||
"""--help returns exit code 0 and prints usage."""
|
||||
result = self._run("--help")
|
||||
assert result.returncode == 0, (
|
||||
f"--help failed (rc={result.returncode}): {result.stderr}"
|
||||
)
|
||||
assert "freecad" in result.stdout.lower() or "usage" in result.stdout.lower(), (
|
||||
f"Unexpected help output: {result.stdout[:200]}"
|
||||
)
|
||||
print(f"\n --help: rc={result.returncode}, {len(result.stdout)} chars")
|
||||
|
||||
def test_document_new_json(self, tmp_path):
|
||||
"""'--json document new -o <path>' creates valid JSON output."""
|
||||
out_file = str(tmp_path / "new_doc.json")
|
||||
result = self._run("--json", "document", "new",
|
||||
"--name", "TestDoc", "-o", out_file)
|
||||
assert result.returncode == 0, (
|
||||
f"document new failed (rc={result.returncode}): {result.stderr}"
|
||||
)
|
||||
|
||||
# stdout should be valid JSON
|
||||
data = json.loads(result.stdout)
|
||||
assert data["name"] == "TestDoc"
|
||||
assert "version" in data
|
||||
|
||||
# File should exist
|
||||
assert os.path.isfile(out_file)
|
||||
print(f"\n document new: {out_file} ({os.path.getsize(out_file):,} bytes)")
|
||||
|
||||
def test_part_add_json(self, tmp_path):
|
||||
"""Create doc then add a part, verify JSON output."""
|
||||
proj_file = str(tmp_path / "part_add.json")
|
||||
|
||||
# Create document
|
||||
r1 = self._run("--json", "document", "new",
|
||||
"--name", "PartTest", "-o", proj_file)
|
||||
assert r1.returncode == 0, f"doc new failed: {r1.stderr}"
|
||||
|
||||
# Add a box part
|
||||
r2 = self._run("--json", "-p", proj_file, "part", "add", "box",
|
||||
"--name", "MyBox", "-P", "length=30")
|
||||
assert r2.returncode == 0, f"part add failed: {r2.stderr}"
|
||||
|
||||
data = json.loads(r2.stdout)
|
||||
assert data["type"] == "box"
|
||||
assert data["name"] == "MyBox"
|
||||
assert data["params"]["length"] == 30.0
|
||||
|
||||
print(f"\n part add: {data['name']} (type={data['type']})")
|
||||
|
||||
def test_part_list_json(self, tmp_path):
|
||||
"""Create doc, add parts, list them, verify count."""
|
||||
proj_file = str(tmp_path / "part_list.json")
|
||||
|
||||
# Create document
|
||||
self._run("--json", "document", "new",
|
||||
"--name", "ListTest", "-o", proj_file)
|
||||
|
||||
# Add two parts
|
||||
self._run("--json", "-p", proj_file, "part", "add", "box", "--name", "A")
|
||||
self._run("--json", "-p", proj_file, "part", "add", "cylinder", "--name", "B")
|
||||
|
||||
# List parts
|
||||
r = self._run("--json", "-p", proj_file, "part", "list")
|
||||
assert r.returncode == 0, f"part list failed: {r.stderr}"
|
||||
|
||||
parts = json.loads(r.stdout)
|
||||
assert isinstance(parts, list)
|
||||
assert len(parts) == 2
|
||||
names = {p["name"] for p in parts}
|
||||
assert "A" in names
|
||||
assert "B" in names
|
||||
|
||||
print(f"\n part list: {len(parts)} parts ({names})")
|
||||
|
||||
def test_full_workflow_subprocess(self, tmp_path):
|
||||
"""Full subprocess workflow: create -> box -> cylinder -> boolean cut -> list."""
|
||||
proj_file = str(tmp_path / "workflow.json")
|
||||
|
||||
# 1. Create document
|
||||
r = self._run("--json", "document", "new",
|
||||
"--name", "WorkflowTest", "-o", proj_file)
|
||||
assert r.returncode == 0, f"doc new: {r.stderr}"
|
||||
|
||||
# 2. Add box
|
||||
r = self._run("--json", "-p", proj_file, "part", "add", "box",
|
||||
"--name", "Base", "-P", "length=20", "-P", "width=20",
|
||||
"-P", "height=20")
|
||||
assert r.returncode == 0, f"add box: {r.stderr}"
|
||||
box = json.loads(r.stdout)
|
||||
assert box["name"] == "Base"
|
||||
|
||||
# 3. Add cylinder
|
||||
r = self._run("--json", "-p", proj_file, "part", "add", "cylinder",
|
||||
"--name", "Hole", "-P", "radius=5", "-P", "height=30",
|
||||
"-pos", "10,10,-5")
|
||||
assert r.returncode == 0, f"add cylinder: {r.stderr}"
|
||||
cyl = json.loads(r.stdout)
|
||||
assert cyl["name"] == "Hole"
|
||||
|
||||
# 4. Boolean cut
|
||||
r = self._run("--json", "-p", proj_file,
|
||||
"part", "boolean", "cut", "0", "1")
|
||||
assert r.returncode == 0, f"boolean cut: {r.stderr}"
|
||||
cut = json.loads(r.stdout)
|
||||
assert cut["type"] == "cut"
|
||||
|
||||
# 5. List parts -- should have 3 (box, cylinder, cut-result)
|
||||
r = self._run("--json", "-p", proj_file, "part", "list")
|
||||
assert r.returncode == 0, f"part list: {r.stderr}"
|
||||
parts = json.loads(r.stdout)
|
||||
assert len(parts) == 3, f"Expected 3 parts, got {len(parts)}: {parts}"
|
||||
|
||||
# Verify visibility: first two hidden, cut result visible
|
||||
visible_count = sum(1 for p in parts if p.get("visible", True))
|
||||
assert visible_count >= 1, "At least the cut result should be visible"
|
||||
|
||||
type_names = [p["type"] for p in parts]
|
||||
assert "cut" in type_names, f"No 'cut' part found in types: {type_names}"
|
||||
|
||||
print(f"\n Workflow complete: {len(parts)} parts")
|
||||
for p in parts:
|
||||
print(f" {p['name']}: type={p['type']}, visible={p.get('visible', '?')}")
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility modules for FreeCAD CLI harness."""
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Backend module that wraps the real FreeCAD headless CLI (FreeCADCmd).
|
||||
|
||||
Provides functions to locate the FreeCAD console executable and invoke it
|
||||
in headless mode for macro execution, export, and version queries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FreeCAD discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INSTALL_INSTRUCTIONS = textwrap.dedent("""\
|
||||
FreeCAD console executable (FreeCADCmd) not found.
|
||||
|
||||
Install FreeCAD and make sure FreeCADCmd is on your PATH, or install
|
||||
it to one of the standard locations:
|
||||
|
||||
Windows:
|
||||
- C:\\Program Files\\FreeCAD*\\bin\\FreeCADCmd.exe
|
||||
Download from https://www.freecad.org/downloads.php
|
||||
|
||||
macOS:
|
||||
brew install --cask freecad
|
||||
(or download from https://www.freecad.org/downloads.php)
|
||||
|
||||
Linux (Debian / Ubuntu):
|
||||
sudo apt install freecad
|
||||
Linux (Flatpak):
|
||||
flatpak install flathub org.freecadweb.FreeCAD
|
||||
Linux (Snap):
|
||||
sudo snap install freecad
|
||||
Linux (conda-forge):
|
||||
conda install -c conda-forge freecad
|
||||
""")
|
||||
|
||||
# Executable names to search for, in priority order
|
||||
_FREECAD_NAMES = ["freecadcmd", "FreeCADCmd", "freecad", "FreeCAD"]
|
||||
|
||||
|
||||
def find_freecad() -> str:
|
||||
"""Locate the FreeCAD console executable on the system.
|
||||
|
||||
Search order:
|
||||
1. ``FREECAD_PATH`` environment variable (explicit override).
|
||||
2. Known executable names on ``PATH`` (via :func:`shutil.which`).
|
||||
3. Common Windows install directories (glob-matched).
|
||||
4. Common macOS application bundle path.
|
||||
5. Common Linux paths.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Absolute path to the FreeCAD console executable.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If FreeCAD cannot be found, with installation instructions in
|
||||
the message.
|
||||
"""
|
||||
# 1. Environment variable override
|
||||
env_path = os.environ.get("FREECAD_PATH")
|
||||
if env_path and os.path.isfile(env_path):
|
||||
return os.path.abspath(env_path)
|
||||
|
||||
# 2. On PATH
|
||||
for name in _FREECAD_NAMES:
|
||||
which = shutil.which(name)
|
||||
if which:
|
||||
return os.path.abspath(which)
|
||||
|
||||
# 3. Windows common locations
|
||||
if platform.system() == "Windows":
|
||||
win_patterns = [
|
||||
"C:/Program Files/FreeCAD*/bin/FreeCADCmd.exe",
|
||||
"C:/Program Files (x86)/FreeCAD*/bin/FreeCADCmd.exe",
|
||||
"C:/Program Files/FreeCAD*/bin/FreeCAD.exe",
|
||||
"C:/Program Files (x86)/FreeCAD*/bin/FreeCAD.exe",
|
||||
]
|
||||
for pattern in win_patterns:
|
||||
matches = sorted(glob.glob(pattern), reverse=True)
|
||||
if matches:
|
||||
return os.path.abspath(matches[0])
|
||||
|
||||
# 4. macOS application bundle
|
||||
if platform.system() == "Darwin":
|
||||
mac_paths = [
|
||||
"/Applications/FreeCAD.app/Contents/MacOS/FreeCADCmd",
|
||||
"/Applications/FreeCAD.app/Contents/MacOS/FreeCAD",
|
||||
]
|
||||
for mac_path in mac_paths:
|
||||
if os.path.isfile(mac_path):
|
||||
return mac_path
|
||||
|
||||
# 5. Common Linux paths
|
||||
if platform.system() == "Linux":
|
||||
linux_paths = [
|
||||
"/usr/bin/freecadcmd",
|
||||
"/usr/bin/freecad",
|
||||
"/usr/local/bin/freecadcmd",
|
||||
"/usr/local/bin/freecad",
|
||||
"/snap/bin/freecad",
|
||||
]
|
||||
for linux_path in linux_paths:
|
||||
if os.path.isfile(linux_path):
|
||||
return linux_path
|
||||
|
||||
raise RuntimeError(_INSTALL_INSTRUCTIONS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run(
|
||||
args: list[str],
|
||||
*,
|
||||
timeout: int = 120,
|
||||
check: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run a subprocess and return a normalised result dict."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
result: Dict[str, Any] = {
|
||||
"ok": proc.returncode == 0,
|
||||
"returncode": proc.returncode,
|
||||
"stdout": proc.stdout.strip(),
|
||||
"stderr": proc.stderr.strip(),
|
||||
"command": " ".join(args),
|
||||
}
|
||||
if check and proc.returncode != 0:
|
||||
raise subprocess.CalledProcessError(
|
||||
proc.returncode, args, proc.stdout, proc.stderr,
|
||||
)
|
||||
return result
|
||||
except FileNotFoundError as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"returncode": -1,
|
||||
"stdout": "",
|
||||
"stderr": str(exc),
|
||||
"command": " ".join(args),
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"ok": False,
|
||||
"returncode": -1,
|
||||
"stdout": "",
|
||||
"stderr": f"FreeCAD process timed out after {timeout}s",
|
||||
"command": " ".join(args),
|
||||
}
|
||||
|
||||
|
||||
def _write_temp_script(content: str) -> str:
|
||||
"""Write *content* to a temporary ``.py`` file and return its path."""
|
||||
fd, path = tempfile.mkstemp(suffix=".py", prefix="freecad_macro_")
|
||||
try:
|
||||
os.write(fd, content.encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""Return the FreeCAD version string (e.g. ``"0.21.2"``).
|
||||
|
||||
Runs ``FreeCADCmd --version`` and parses the output.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Version string extracted from FreeCAD's output.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If the version cannot be determined.
|
||||
"""
|
||||
freecad = find_freecad()
|
||||
result = _run([freecad, "--version"])
|
||||
if result["ok"] and result["stdout"]:
|
||||
# Output is typically "FreeCAD 0.21.2" or similar
|
||||
for line in result["stdout"].splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Try to extract version number
|
||||
parts = line.split()
|
||||
for part in parts:
|
||||
# Look for something that looks like a version number
|
||||
if any(c.isdigit() for c in part) and "." in part:
|
||||
return part.strip(",").strip()
|
||||
# Fallback: return the whole first line
|
||||
return line
|
||||
if result["stderr"]:
|
||||
raise RuntimeError(f"Failed to get FreeCAD version: {result['stderr']}")
|
||||
raise RuntimeError("Failed to get FreeCAD version (no output)")
|
||||
|
||||
|
||||
def run_macro(
|
||||
script_path: str,
|
||||
timeout: int = 120,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a FreeCAD Python macro script headlessly.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
script_path : str
|
||||
Path to the ``.py`` macro file to execute.
|
||||
timeout : int
|
||||
Maximum seconds to wait for execution.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``{"command": str, "returncode": int, "stdout": str, "stderr": str}``
|
||||
"""
|
||||
freecad = find_freecad()
|
||||
script_path = str(Path(script_path).resolve())
|
||||
|
||||
result = _run([freecad, script_path], timeout=timeout)
|
||||
|
||||
return {
|
||||
"command": result["command"],
|
||||
"returncode": result["returncode"],
|
||||
"stdout": result["stdout"],
|
||||
"stderr": result["stderr"],
|
||||
}
|
||||
|
||||
|
||||
def export_headless(
|
||||
macro_content: str,
|
||||
output_path: str,
|
||||
timeout: int = 120,
|
||||
) -> Dict[str, Any]:
|
||||
"""Write a macro to a temp file, execute it, and verify the output.
|
||||
|
||||
This is a convenience wrapper that combines writing a macro script
|
||||
to a temporary file, executing it via :func:`run_macro`, and verifying
|
||||
that the expected output file was created.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
macro_content : str
|
||||
Complete Python macro script content.
|
||||
output_path : str
|
||||
Expected output file path (the macro should write to this path).
|
||||
timeout : int
|
||||
Maximum seconds to wait for execution.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
``{"output": str, "format": str, "method": "freecad-headless",
|
||||
"file_size": int}``
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If the macro execution fails (non-zero exit code) or the output
|
||||
file is not created.
|
||||
"""
|
||||
output_path = os.path.abspath(output_path)
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
|
||||
script_path = _write_temp_script(macro_content)
|
||||
try:
|
||||
result = run_macro(script_path, timeout=timeout)
|
||||
finally:
|
||||
# Best-effort cleanup of the temp script
|
||||
try:
|
||||
os.unlink(script_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if result["returncode"] != 0:
|
||||
raise RuntimeError(
|
||||
f"FreeCAD macro execution failed (exit code {result['returncode']}). "
|
||||
f"stderr: {result['stderr']}"
|
||||
)
|
||||
|
||||
if not os.path.isfile(output_path):
|
||||
raise RuntimeError(
|
||||
f"FreeCAD macro completed but output file was not created: "
|
||||
f"{output_path}. stdout: {result['stdout']}"
|
||||
)
|
||||
|
||||
ext = Path(output_path).suffix.lstrip(".")
|
||||
file_size = os.path.getsize(output_path)
|
||||
|
||||
return {
|
||||
"output": output_path,
|
||||
"format": ext,
|
||||
"method": "freecad-headless",
|
||||
"file_size": file_size,
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Macro generation module for the FreeCAD CLI harness.
|
||||
|
||||
Generates complete FreeCAD Python macro scripts from JSON project state.
|
||||
The generated scripts can be executed headlessly via ``FreeCADCmd`` to
|
||||
create geometry and export to various CAD/mesh formats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Safe name helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""Convert a user-supplied name into a valid FreeCAD object label.
|
||||
|
||||
Replaces non-alphanumeric characters with underscores and ensures the
|
||||
name does not start with a digit.
|
||||
"""
|
||||
safe = re.sub(r"[^A-Za-z0-9_]", "_", name)
|
||||
if safe and safe[0].isdigit():
|
||||
safe = f"_{safe}"
|
||||
return safe or "Unnamed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gen_header() -> List[str]:
|
||||
"""Generate import statements and document creation."""
|
||||
return [
|
||||
"# Auto-generated FreeCAD macro by CLI-Anything FreeCAD harness",
|
||||
"import sys",
|
||||
"import os",
|
||||
"import FreeCAD",
|
||||
"import Part",
|
||||
"",
|
||||
"doc = FreeCAD.newDocument('ExportDoc')",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def _gen_parts(project: dict) -> List[str]:
|
||||
"""Generate Part primitives (Box, Cylinder, Sphere, Cone, Torus)."""
|
||||
lines: List[str] = []
|
||||
parts = project.get("parts", [])
|
||||
|
||||
for part in parts:
|
||||
part_type = part.get("type", "box").lower()
|
||||
name = _safe_name(part.get("name", f"Part_{part_type}"))
|
||||
props = part.get("params", part.get("properties", {}))
|
||||
|
||||
if part_type == "box":
|
||||
length = props.get("length", props.get("Length", 10.0))
|
||||
width = props.get("width", props.get("Width", 10.0))
|
||||
height = props.get("height", props.get("Height", 10.0))
|
||||
lines.append(f"obj_{name} = doc.addObject('Part::Box', '{name}')")
|
||||
lines.append(f"obj_{name}.Length = {length}")
|
||||
lines.append(f"obj_{name}.Width = {width}")
|
||||
lines.append(f"obj_{name}.Height = {height}")
|
||||
|
||||
elif part_type == "cylinder":
|
||||
radius = props.get("radius", props.get("Radius", 5.0))
|
||||
height = props.get("height", props.get("Height", 10.0))
|
||||
lines.append(f"obj_{name} = doc.addObject('Part::Cylinder', '{name}')")
|
||||
lines.append(f"obj_{name}.Radius = {radius}")
|
||||
lines.append(f"obj_{name}.Height = {height}")
|
||||
|
||||
elif part_type == "sphere":
|
||||
radius = props.get("radius", props.get("Radius", 5.0))
|
||||
lines.append(f"obj_{name} = doc.addObject('Part::Sphere', '{name}')")
|
||||
lines.append(f"obj_{name}.Radius = {radius}")
|
||||
|
||||
elif part_type == "cone":
|
||||
radius1 = props.get("radius1", props.get("Radius1", 5.0))
|
||||
radius2 = props.get("radius2", props.get("Radius2", 0.0))
|
||||
height = props.get("height", props.get("Height", 10.0))
|
||||
lines.append(f"obj_{name} = doc.addObject('Part::Cone', '{name}')")
|
||||
lines.append(f"obj_{name}.Radius1 = {radius1}")
|
||||
lines.append(f"obj_{name}.Radius2 = {radius2}")
|
||||
lines.append(f"obj_{name}.Height = {height}")
|
||||
|
||||
elif part_type == "torus":
|
||||
radius1 = props.get("radius1", props.get("Radius1", 10.0))
|
||||
radius2 = props.get("radius2", props.get("Radius2", 2.0))
|
||||
lines.append(f"obj_{name} = doc.addObject('Part::Torus', '{name}')")
|
||||
lines.append(f"obj_{name}.Radius1 = {radius1}")
|
||||
lines.append(f"obj_{name}.Radius2 = {radius2}")
|
||||
|
||||
else:
|
||||
lines.append(f"# WARNING: Unknown part type '{part_type}' for '{name}'")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _gen_boolean_ops(project: dict) -> List[str]:
|
||||
"""Generate boolean operations (Cut, Fuse, Common)."""
|
||||
lines: List[str] = []
|
||||
boolean_ops = project.get("boolean_ops", [])
|
||||
|
||||
# Map user-friendly names to FreeCAD object types
|
||||
op_type_map = {
|
||||
"cut": "Part::Cut",
|
||||
"subtract": "Part::Cut",
|
||||
"fuse": "Part::Fuse",
|
||||
"union": "Part::Fuse",
|
||||
"common": "Part::Common",
|
||||
"intersect": "Part::Common",
|
||||
"intersection": "Part::Common",
|
||||
}
|
||||
|
||||
for op in boolean_ops:
|
||||
op_type = op.get("type", "fuse").lower()
|
||||
name = _safe_name(op.get("name", f"BoolOp_{op_type}"))
|
||||
base_name = _safe_name(op.get("base", ""))
|
||||
tool_name = _safe_name(op.get("tool", ""))
|
||||
fc_type = op_type_map.get(op_type, "Part::Fuse")
|
||||
|
||||
lines.append(f"obj_{name} = doc.addObject('{fc_type}', '{name}')")
|
||||
lines.append(f"obj_{name}.Base = doc.getObject('{base_name}')")
|
||||
lines.append(f"obj_{name}.Tool = doc.getObject('{tool_name}')")
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _gen_bodies(project: dict) -> List[str]:
|
||||
"""Generate PartDesign bodies with features (Pad, Pocket, etc.)."""
|
||||
lines: List[str] = []
|
||||
bodies = project.get("bodies", [])
|
||||
|
||||
if not bodies:
|
||||
return lines
|
||||
|
||||
lines.append("import PartDesign")
|
||||
lines.append("")
|
||||
|
||||
for body in bodies:
|
||||
body_name = _safe_name(body.get("name", "Body"))
|
||||
lines.append(
|
||||
f"body_{body_name} = doc.addObject('PartDesign::Body', '{body_name}')"
|
||||
)
|
||||
|
||||
features = body.get("features", [])
|
||||
for feat in features:
|
||||
feat_type = feat.get("type", "pad").lower()
|
||||
feat_name = _safe_name(feat.get("name", f"Feature_{feat_type}"))
|
||||
feat_props = feat.get("properties", {})
|
||||
|
||||
if feat_type == "pad":
|
||||
length = feat_props.get("length", feat_props.get("Length", 10.0))
|
||||
lines.append(
|
||||
f"feat_{feat_name} = body_{body_name}.newObject("
|
||||
f"'PartDesign::Pad', '{feat_name}')"
|
||||
)
|
||||
lines.append(f"feat_{feat_name}.Length = {length}")
|
||||
|
||||
elif feat_type == "pocket":
|
||||
length = feat_props.get("length", feat_props.get("Length", 5.0))
|
||||
lines.append(
|
||||
f"feat_{feat_name} = body_{body_name}.newObject("
|
||||
f"'PartDesign::Pocket', '{feat_name}')"
|
||||
)
|
||||
lines.append(f"feat_{feat_name}.Length = {length}")
|
||||
|
||||
elif feat_type == "revolution":
|
||||
angle = feat_props.get("angle", feat_props.get("Angle", 360.0))
|
||||
lines.append(
|
||||
f"feat_{feat_name} = body_{body_name}.newObject("
|
||||
f"'PartDesign::Revolution', '{feat_name}')"
|
||||
)
|
||||
lines.append(f"feat_{feat_name}.Angle = {angle}")
|
||||
|
||||
elif feat_type == "chamfer":
|
||||
size = feat_props.get("size", feat_props.get("Size", 1.0))
|
||||
lines.append(
|
||||
f"feat_{feat_name} = body_{body_name}.newObject("
|
||||
f"'PartDesign::Chamfer', '{feat_name}')"
|
||||
)
|
||||
lines.append(f"feat_{feat_name}.Size = {size}")
|
||||
|
||||
elif feat_type == "fillet":
|
||||
radius = feat_props.get("radius", feat_props.get("Radius", 1.0))
|
||||
lines.append(
|
||||
f"feat_{feat_name} = body_{body_name}.newObject("
|
||||
f"'PartDesign::Fillet', '{feat_name}')"
|
||||
)
|
||||
lines.append(f"feat_{feat_name}.Radius = {radius}")
|
||||
|
||||
else:
|
||||
lines.append(
|
||||
f"# WARNING: Unknown feature type '{feat_type}' "
|
||||
f"for '{feat_name}'"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _gen_placements(project: dict) -> List[str]:
|
||||
"""Generate placement (position and rotation) commands for parts."""
|
||||
lines: List[str] = []
|
||||
parts = project.get("parts", [])
|
||||
|
||||
for part in parts:
|
||||
name = _safe_name(part.get("name", ""))
|
||||
placement = part.get("placement", {})
|
||||
|
||||
if not placement:
|
||||
continue
|
||||
|
||||
position = placement.get("position", {})
|
||||
rotation = placement.get("rotation", {})
|
||||
|
||||
# Support both list [x, y, z] and dict {"x": ..., "y": ..., "z": ...}
|
||||
if isinstance(position, (list, tuple)):
|
||||
x = position[0] if len(position) > 0 else 0.0
|
||||
y = position[1] if len(position) > 1 else 0.0
|
||||
z = position[2] if len(position) > 2 else 0.0
|
||||
else:
|
||||
x = position.get("x", 0.0)
|
||||
y = position.get("y", 0.0)
|
||||
z = position.get("z", 0.0)
|
||||
|
||||
# Rotation: support list [rx, ry, rz] (Euler) or dict formats
|
||||
if isinstance(rotation, (list, tuple)):
|
||||
rx = rotation[0] if len(rotation) > 0 else 0.0
|
||||
ry = rotation[1] if len(rotation) > 1 else 0.0
|
||||
rz = rotation[2] if len(rotation) > 2 else 0.0
|
||||
if rx != 0.0 or ry != 0.0 or rz != 0.0:
|
||||
lines.append(
|
||||
f"obj_{name}.Placement = FreeCAD.Placement("
|
||||
f"FreeCAD.Vector({x}, {y}, {z}), "
|
||||
f"FreeCAD.Rotation({rz}, {ry}, {rx}))"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f"obj_{name}.Placement.Base = FreeCAD.Vector({x}, {y}, {z})"
|
||||
)
|
||||
elif "axis" in rotation and "angle" in rotation:
|
||||
axis = rotation["axis"]
|
||||
ax = axis.get("x", 0.0)
|
||||
ay = axis.get("y", 0.0)
|
||||
az = axis.get("z", 1.0)
|
||||
angle = rotation["angle"]
|
||||
lines.append(
|
||||
f"obj_{name}.Placement = FreeCAD.Placement("
|
||||
f"FreeCAD.Vector({x}, {y}, {z}), "
|
||||
f"FreeCAD.Rotation(FreeCAD.Vector({ax}, {ay}, {az}), {angle}))"
|
||||
)
|
||||
elif any(k in rotation for k in ("yaw", "pitch", "roll")):
|
||||
yaw = rotation.get("yaw", 0.0)
|
||||
pitch = rotation.get("pitch", 0.0)
|
||||
roll = rotation.get("roll", 0.0)
|
||||
lines.append(
|
||||
f"obj_{name}.Placement = FreeCAD.Placement("
|
||||
f"FreeCAD.Vector({x}, {y}, {z}), "
|
||||
f"FreeCAD.Rotation({yaw}, {pitch}, {roll}))"
|
||||
)
|
||||
else:
|
||||
# Position only, no rotation
|
||||
lines.append(
|
||||
f"obj_{name}.Placement.Base = FreeCAD.Vector({x}, {y}, {z})"
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _gen_export(
|
||||
project: dict,
|
||||
output_path: str,
|
||||
export_format: str,
|
||||
) -> List[str]:
|
||||
"""Generate export commands for the specified format.
|
||||
|
||||
Supported formats:
|
||||
- ``step`` / ``iges``: via ``Part.export()``
|
||||
- ``stl``: via ``Mesh.export()``
|
||||
- ``obj``: via ``Mesh.export()``
|
||||
- ``brep``: via ``Part.export()``
|
||||
- ``fcstd``: via ``doc.saveAs()``
|
||||
"""
|
||||
lines: List[str] = []
|
||||
|
||||
# Escape backslashes for Windows paths in the generated Python script
|
||||
safe_path = output_path.replace("\\", "/")
|
||||
|
||||
# Recompute the document before exporting
|
||||
lines.append("doc.recompute()")
|
||||
lines.append("")
|
||||
|
||||
# Collect all visible shape objects for export
|
||||
lines.append("# Collect all shape objects for export")
|
||||
lines.append("export_objects = []")
|
||||
lines.append("for obj in doc.Objects:")
|
||||
lines.append(" if hasattr(obj, 'Shape') and obj.Shape.isValid():")
|
||||
lines.append(" export_objects.append(obj)")
|
||||
lines.append("")
|
||||
|
||||
fmt = export_format.lower()
|
||||
|
||||
if fmt in ("step", "iges", "brep"):
|
||||
lines.append(f"Part.export(export_objects, '{safe_path}')")
|
||||
|
||||
elif fmt in ("stl", "obj"):
|
||||
lines.append("import Mesh")
|
||||
lines.append(f"Mesh.export(export_objects, '{safe_path}')")
|
||||
|
||||
elif fmt == "fcstd":
|
||||
lines.append(f"doc.saveAs('{safe_path}')")
|
||||
|
||||
else:
|
||||
# Fallback to Part.export for unknown formats
|
||||
lines.append(f"# Unknown format '{fmt}', attempting Part.export")
|
||||
lines.append(f"Part.export(export_objects, '{safe_path}')")
|
||||
|
||||
lines.append("")
|
||||
lines.append("print('Export complete:', os.path.abspath('{safe_path}'))")
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def generate_macro(
|
||||
project: dict,
|
||||
output_path: str,
|
||||
export_format: str = "step",
|
||||
) -> str:
|
||||
"""Generate a complete FreeCAD Python macro script from project state.
|
||||
|
||||
The generated script, when executed by ``FreeCADCmd``, will:
|
||||
1. Create a new FreeCAD document.
|
||||
2. Add all parts/primitives defined in the project.
|
||||
3. Apply boolean operations.
|
||||
4. Create PartDesign bodies with features.
|
||||
5. Set placements (positions and rotations).
|
||||
6. Export to the requested format.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
project : dict
|
||||
Project JSON state. Expected top-level keys:
|
||||
|
||||
- ``parts``: list of part definitions (type, name, properties,
|
||||
placement).
|
||||
- ``boolean_ops``: list of boolean operation definitions.
|
||||
- ``bodies``: list of PartDesign body definitions with features.
|
||||
|
||||
output_path : str
|
||||
Destination file path for the export.
|
||||
export_format : str
|
||||
Target format: ``"step"``, ``"iges"``, ``"stl"``, ``"obj"``,
|
||||
``"brep"``, or ``"fcstd"``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Complete Python macro script ready for execution by FreeCADCmd.
|
||||
"""
|
||||
sections: List[List[str]] = [
|
||||
_gen_header(),
|
||||
_gen_parts(project),
|
||||
_gen_boolean_ops(project),
|
||||
_gen_bodies(project),
|
||||
_gen_placements(project),
|
||||
_gen_export(project, output_path, export_format),
|
||||
]
|
||||
|
||||
# Flatten all sections and join with newlines
|
||||
all_lines: List[str] = []
|
||||
for section in sections:
|
||||
all_lines.extend(section)
|
||||
|
||||
return "\n".join(all_lines)
|
||||
@@ -0,0 +1,523 @@
|
||||
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
|
||||
|
||||
Copy this file into your CLI package at:
|
||||
cli_anything/<software>/utils/repl_skin.py
|
||||
|
||||
Usage:
|
||||
from cli_anything.<software>.utils.repl_skin import ReplSkin
|
||||
|
||||
skin = ReplSkin("shotcut", version="1.0.0")
|
||||
skin.print_banner() # auto-detects skills/SKILL.md inside the package
|
||||
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
|
||||
skin.success("Project saved")
|
||||
skin.error("File not found")
|
||||
skin.warning("Unsaved changes")
|
||||
skin.info("Processing 24 clips...")
|
||||
skin.status("Track 1", "3 clips, 00:02:30")
|
||||
skin.table(headers, rows)
|
||||
skin.print_goodbye()
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ── ANSI color codes (no external deps for core styling) ──────────────
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_BOLD = "\033[1m"
|
||||
_DIM = "\033[2m"
|
||||
_ITALIC = "\033[3m"
|
||||
_UNDERLINE = "\033[4m"
|
||||
|
||||
# Brand colors
|
||||
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
|
||||
_CYAN_BG = "\033[48;5;80m"
|
||||
_WHITE = "\033[97m"
|
||||
_GRAY = "\033[38;5;245m"
|
||||
_DARK_GRAY = "\033[38;5;240m"
|
||||
_LIGHT_GRAY = "\033[38;5;250m"
|
||||
|
||||
# Software accent colors — each software gets a unique accent
|
||||
_ACCENT_COLORS = {
|
||||
"gimp": "\033[38;5;214m", # warm orange
|
||||
"blender": "\033[38;5;208m", # deep orange
|
||||
"inkscape": "\033[38;5;39m", # bright blue
|
||||
"audacity": "\033[38;5;33m", # navy blue
|
||||
"libreoffice": "\033[38;5;40m", # green
|
||||
"obs_studio": "\033[38;5;55m", # purple
|
||||
"kdenlive": "\033[38;5;69m", # slate blue
|
||||
"shotcut": "\033[38;5;35m", # teal green
|
||||
"freecad": "\033[38;5;196m", # FreeCAD red
|
||||
}
|
||||
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
|
||||
|
||||
# Status colors
|
||||
_GREEN = "\033[38;5;78m"
|
||||
_YELLOW = "\033[38;5;220m"
|
||||
_RED = "\033[38;5;196m"
|
||||
_BLUE = "\033[38;5;75m"
|
||||
_MAGENTA = "\033[38;5;176m"
|
||||
|
||||
# ── Brand icon ────────────────────────────────────────────────────────
|
||||
|
||||
# The cli-anything icon: a small colored diamond/chevron mark
|
||||
_ICON = f"{_CYAN}{_BOLD}◆{_RESET}"
|
||||
_ICON_SMALL = f"{_CYAN}▸{_RESET}"
|
||||
|
||||
# ── Box drawing characters ────────────────────────────────────────────
|
||||
|
||||
_H_LINE = "─"
|
||||
_V_LINE = "│"
|
||||
_TL = "╭"
|
||||
_TR = "╮"
|
||||
_BL = "╰"
|
||||
_BR = "╯"
|
||||
_T_DOWN = "┬"
|
||||
_T_UP = "┴"
|
||||
_T_RIGHT = "├"
|
||||
_T_LEFT = "┤"
|
||||
_CROSS = "┼"
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes for length calculation."""
|
||||
import re
|
||||
return re.sub(r"\033\[[^m]*m", "", text)
|
||||
|
||||
|
||||
def _visible_len(text: str) -> int:
|
||||
"""Get visible length of text (excluding ANSI codes)."""
|
||||
return len(_strip_ansi(text))
|
||||
|
||||
|
||||
class ReplSkin:
|
||||
"""Unified REPL skin for cli-anything CLIs.
|
||||
|
||||
Provides consistent branding, prompts, and message formatting
|
||||
across all CLI harnesses built with the cli-anything methodology.
|
||||
"""
|
||||
|
||||
def __init__(self, software: str, version: str = "1.0.0",
|
||||
history_file: str | None = None, skill_path: str | None = None):
|
||||
"""Initialize the REPL skin.
|
||||
|
||||
Args:
|
||||
software: Software name (e.g., "gimp", "shotcut", "blender").
|
||||
version: CLI version string.
|
||||
history_file: Path for persistent command history.
|
||||
Defaults to ~/.cli-anything-<software>/history
|
||||
skill_path: Path to the SKILL.md file for agent discovery.
|
||||
Auto-detected from the package's skills/ directory if not provided.
|
||||
Displayed in banner for AI agents to know where to read skill info.
|
||||
"""
|
||||
self.software = software.lower().replace("-", "_")
|
||||
self.display_name = software.replace("_", " ").title()
|
||||
self.version = version
|
||||
|
||||
# Auto-detect skill path from package layout:
|
||||
# cli_anything/<software>/utils/repl_skin.py (this file)
|
||||
# cli_anything/<software>/skills/SKILL.md (target)
|
||||
if skill_path is None:
|
||||
from pathlib import Path
|
||||
_auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
|
||||
if _auto.is_file():
|
||||
skill_path = str(_auto)
|
||||
self.skill_path = skill_path
|
||||
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
|
||||
|
||||
# History file
|
||||
if history_file is None:
|
||||
from pathlib import Path
|
||||
hist_dir = Path.home() / f".cli-anything-{self.software}"
|
||||
hist_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.history_file = str(hist_dir / "history")
|
||||
else:
|
||||
self.history_file = history_file
|
||||
|
||||
# Detect terminal capabilities
|
||||
self._color = self._detect_color_support()
|
||||
|
||||
def _detect_color_support(self) -> bool:
|
||||
"""Check if terminal supports color."""
|
||||
if os.environ.get("NO_COLOR"):
|
||||
return False
|
||||
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
|
||||
return False
|
||||
if not hasattr(sys.stdout, "isatty"):
|
||||
return False
|
||||
return sys.stdout.isatty()
|
||||
|
||||
def _c(self, code: str, text: str) -> str:
|
||||
"""Apply color code if colors are supported."""
|
||||
if not self._color:
|
||||
return text
|
||||
return f"{code}{text}{_RESET}"
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────────
|
||||
|
||||
def print_banner(self):
|
||||
"""Print the startup banner with branding."""
|
||||
inner = 54
|
||||
|
||||
def _box_line(content: str) -> str:
|
||||
"""Wrap content in box drawing, padding to inner width."""
|
||||
pad = inner - _visible_len(content)
|
||||
vl = self._c(_DARK_GRAY, _V_LINE)
|
||||
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
|
||||
|
||||
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
|
||||
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
|
||||
|
||||
# Title: ◆ cli-anything · Shotcut
|
||||
icon = self._c(_CYAN + _BOLD, "◆")
|
||||
brand = self._c(_CYAN + _BOLD, "cli-anything")
|
||||
dot = self._c(_DARK_GRAY, "·")
|
||||
name = self._c(self.accent + _BOLD, self.display_name)
|
||||
title = f" {icon} {brand} {dot} {name}"
|
||||
|
||||
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
|
||||
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
|
||||
empty = ""
|
||||
|
||||
# Skill path for agent discovery
|
||||
skill_line = None
|
||||
if self.skill_path:
|
||||
skill_icon = self._c(_MAGENTA, "◇")
|
||||
skill_label = self._c(_DARK_GRAY, " Skill:")
|
||||
skill_path_display = self._c(_LIGHT_GRAY, self.skill_path)
|
||||
skill_line = f" {skill_icon} {skill_label} {skill_path_display}"
|
||||
|
||||
print(top)
|
||||
print(_box_line(title))
|
||||
print(_box_line(ver))
|
||||
if skill_line:
|
||||
print(_box_line(skill_line))
|
||||
print(_box_line(empty))
|
||||
print(_box_line(tip))
|
||||
print(bot)
|
||||
print()
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
def prompt(self, project_name: str = "", modified: bool = False,
|
||||
context: str = "") -> str:
|
||||
"""Build a styled prompt string for prompt_toolkit or input().
|
||||
|
||||
Args:
|
||||
project_name: Current project name (empty if none open).
|
||||
modified: Whether the project has unsaved changes.
|
||||
context: Optional extra context to show in prompt.
|
||||
|
||||
Returns:
|
||||
Formatted prompt string.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Icon
|
||||
if self._color:
|
||||
parts.append(f"{_CYAN}◆{_RESET} ")
|
||||
else:
|
||||
parts.append("> ")
|
||||
|
||||
# Software name
|
||||
parts.append(self._c(self.accent + _BOLD, self.software))
|
||||
|
||||
# Project context
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
parts.append(f" {self._c(_DARK_GRAY, '[')}")
|
||||
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
|
||||
parts.append(self._c(_DARK_GRAY, ']'))
|
||||
|
||||
parts.append(self._c(_GRAY, " ❯ "))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def prompt_tokens(self, project_name: str = "", modified: bool = False,
|
||||
context: str = ""):
|
||||
"""Build prompt_toolkit formatted text tokens for the prompt.
|
||||
|
||||
Use with prompt_toolkit's FormattedText for proper ANSI handling.
|
||||
|
||||
Returns:
|
||||
list of (style, text) tuples for prompt_toolkit.
|
||||
"""
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
tokens = []
|
||||
|
||||
tokens.append(("class:icon", "◆ "))
|
||||
tokens.append(("class:software", self.software))
|
||||
|
||||
if project_name or context:
|
||||
ctx = context or project_name
|
||||
mod = "*" if modified else ""
|
||||
tokens.append(("class:bracket", " ["))
|
||||
tokens.append(("class:context", f"{ctx}{mod}"))
|
||||
tokens.append(("class:bracket", "]"))
|
||||
|
||||
tokens.append(("class:arrow", " ❯ "))
|
||||
|
||||
return tokens
|
||||
|
||||
def get_prompt_style(self):
|
||||
"""Get a prompt_toolkit Style object matching the skin.
|
||||
|
||||
Returns:
|
||||
prompt_toolkit.styles.Style
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.styles import Style
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
|
||||
|
||||
return Style.from_dict({
|
||||
"icon": "#5fdfdf bold", # cyan brand color
|
||||
"software": f"{accent_hex} bold",
|
||||
"bracket": "#585858",
|
||||
"context": "#bcbcbc",
|
||||
"arrow": "#808080",
|
||||
# Completion menu
|
||||
"completion-menu.completion": "bg:#303030 #bcbcbc",
|
||||
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
|
||||
"completion-menu.meta.completion": "bg:#303030 #808080",
|
||||
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
|
||||
# Auto-suggest
|
||||
"auto-suggest": "#585858",
|
||||
# Bottom toolbar
|
||||
"bottom-toolbar": "bg:#1c1c1c #808080",
|
||||
"bottom-toolbar.text": "#808080",
|
||||
})
|
||||
|
||||
# ── Messages ──────────────────────────────────────────────────────
|
||||
|
||||
def success(self, message: str):
|
||||
"""Print a success message with green checkmark."""
|
||||
icon = self._c(_GREEN + _BOLD, "✓")
|
||||
print(f" {icon} {self._c(_GREEN, message)}")
|
||||
|
||||
def error(self, message: str):
|
||||
"""Print an error message with red cross."""
|
||||
icon = self._c(_RED + _BOLD, "✗")
|
||||
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Print a warning message with yellow triangle."""
|
||||
icon = self._c(_YELLOW + _BOLD, "⚠")
|
||||
print(f" {icon} {self._c(_YELLOW, message)}")
|
||||
|
||||
def info(self, message: str):
|
||||
"""Print an info message with blue dot."""
|
||||
icon = self._c(_BLUE, "●")
|
||||
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
|
||||
|
||||
def hint(self, message: str):
|
||||
"""Print a subtle hint message."""
|
||||
print(f" {self._c(_DARK_GRAY, message)}")
|
||||
|
||||
def section(self, title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(f" {self._c(self.accent + _BOLD, title)}")
|
||||
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
|
||||
|
||||
# ── Status display ────────────────────────────────────────────────
|
||||
|
||||
def status(self, label: str, value: str):
|
||||
"""Print a key-value status line."""
|
||||
lbl = self._c(_GRAY, f" {label}:")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def status_block(self, items: dict[str, str], title: str = ""):
|
||||
"""Print a block of status key-value pairs.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs.
|
||||
title: Optional title for the block.
|
||||
"""
|
||||
if title:
|
||||
self.section(title)
|
||||
|
||||
max_key = max(len(k) for k in items) if items else 0
|
||||
for label, value in items.items():
|
||||
lbl = self._c(_GRAY, f" {label:<{max_key}}")
|
||||
val = self._c(_WHITE, f" {value}")
|
||||
print(f"{lbl}{val}")
|
||||
|
||||
def progress(self, current: int, total: int, label: str = ""):
|
||||
"""Print a simple progress indicator.
|
||||
|
||||
Args:
|
||||
current: Current step number.
|
||||
total: Total number of steps.
|
||||
label: Optional label for the progress.
|
||||
"""
|
||||
pct = int(current / total * 100) if total > 0 else 0
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total) if total > 0 else 0
|
||||
bar = "█" * filled + "░" * (bar_width - filled)
|
||||
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
|
||||
if label:
|
||||
text += f" {self._c(_LIGHT_GRAY, label)}"
|
||||
print(text)
|
||||
|
||||
# ── Table display ─────────────────────────────────────────────────
|
||||
|
||||
def table(self, headers: list[str], rows: list[list[str]],
|
||||
max_col_width: int = 40):
|
||||
"""Print a formatted table with box-drawing characters.
|
||||
|
||||
Args:
|
||||
headers: Column header strings.
|
||||
rows: List of rows, each a list of cell strings.
|
||||
max_col_width: Maximum column width before truncation.
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [min(len(h), max_col_width) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
col_widths[i] = min(
|
||||
max(col_widths[i], len(str(cell))), max_col_width
|
||||
)
|
||||
|
||||
def pad(text: str, width: int) -> str:
|
||||
t = str(text)[:width]
|
||||
return t + " " * (width - len(t))
|
||||
|
||||
# Header
|
||||
header_cells = [
|
||||
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
|
||||
for i, h in enumerate(headers)
|
||||
]
|
||||
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
header_line = f" {sep.join(header_cells)}"
|
||||
print(header_line)
|
||||
|
||||
# Separator
|
||||
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
|
||||
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
|
||||
print(sep_line)
|
||||
|
||||
# Rows
|
||||
for row in rows:
|
||||
cells = []
|
||||
for i, cell in enumerate(row):
|
||||
if i < len(col_widths):
|
||||
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
|
||||
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
|
||||
print(f" {row_sep.join(cells)}")
|
||||
|
||||
# ── Help display ──────────────────────────────────────────────────
|
||||
|
||||
def help(self, commands: dict[str, str]):
|
||||
"""Print a formatted help listing.
|
||||
|
||||
Args:
|
||||
commands: Dict of command -> description pairs.
|
||||
"""
|
||||
self.section("Commands")
|
||||
max_cmd = max(len(c) for c in commands) if commands else 0
|
||||
for cmd, desc in commands.items():
|
||||
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
|
||||
desc_styled = self._c(_GRAY, f" {desc}")
|
||||
print(f"{cmd_styled}{desc_styled}")
|
||||
print()
|
||||
|
||||
# ── Goodbye ───────────────────────────────────────────────────────
|
||||
|
||||
def print_goodbye(self):
|
||||
"""Print a styled goodbye message."""
|
||||
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
|
||||
|
||||
# ── Prompt toolkit session factory ────────────────────────────────
|
||||
|
||||
def create_prompt_session(self):
|
||||
"""Create a prompt_toolkit PromptSession with skin styling.
|
||||
|
||||
Returns:
|
||||
A configured PromptSession, or None if prompt_toolkit unavailable.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
|
||||
style = self.get_prompt_style()
|
||||
|
||||
session = PromptSession(
|
||||
history=FileHistory(self.history_file),
|
||||
auto_suggest=AutoSuggestFromHistory(),
|
||||
style=style,
|
||||
enable_history_search=True,
|
||||
)
|
||||
return session
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def get_input(self, pt_session, project_name: str = "",
|
||||
modified: bool = False, context: str = "") -> str:
|
||||
"""Get input from user using prompt_toolkit or fallback.
|
||||
|
||||
Args:
|
||||
pt_session: A prompt_toolkit PromptSession (or None).
|
||||
project_name: Current project name.
|
||||
modified: Whether project has unsaved changes.
|
||||
context: Optional context string.
|
||||
|
||||
Returns:
|
||||
User input string (stripped).
|
||||
"""
|
||||
if pt_session is not None:
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
tokens = self.prompt_tokens(project_name, modified, context)
|
||||
return pt_session.prompt(FormattedText(tokens)).strip()
|
||||
else:
|
||||
raw_prompt = self.prompt(project_name, modified, context)
|
||||
return input(raw_prompt).strip()
|
||||
|
||||
# ── Toolbar builder ───────────────────────────────────────────────
|
||||
|
||||
def bottom_toolbar(self, items: dict[str, str]):
|
||||
"""Create a bottom toolbar callback for prompt_toolkit.
|
||||
|
||||
Args:
|
||||
items: Dict of label -> value pairs to show in toolbar.
|
||||
|
||||
Returns:
|
||||
A callable that returns FormattedText for the toolbar.
|
||||
"""
|
||||
def toolbar():
|
||||
from prompt_toolkit.formatted_text import FormattedText
|
||||
parts = []
|
||||
for i, (k, v) in enumerate(items.items()):
|
||||
if i > 0:
|
||||
parts.append(("class:bottom-toolbar.text", " │ "))
|
||||
parts.append(("class:bottom-toolbar.text", f" {k}: "))
|
||||
parts.append(("class:bottom-toolbar", v))
|
||||
return FormattedText(parts)
|
||||
return toolbar
|
||||
|
||||
|
||||
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
|
||||
|
||||
_ANSI_256_TO_HEX = {
|
||||
"\033[38;5;33m": "#0087ff", # audacity navy blue
|
||||
"\033[38;5;35m": "#00af5f", # shotcut teal
|
||||
"\033[38;5;39m": "#00afff", # inkscape bright blue
|
||||
"\033[38;5;40m": "#00d700", # libreoffice green
|
||||
"\033[38;5;55m": "#5f00af", # obs purple
|
||||
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
|
||||
"\033[38;5;75m": "#5fafff", # default sky blue
|
||||
"\033[38;5;80m": "#5fd7d7", # brand cyan
|
||||
"\033[38;5;208m": "#ff8700", # blender deep orange
|
||||
"\033[38;5;196m": "#ff0000", # freecad red
|
||||
"\033[38;5;214m": "#ffaf00", # gimp warm orange
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Setup for cli-anything-freecad — CLI harness for FreeCAD."""
|
||||
|
||||
from setuptools import setup, find_namespace_packages
|
||||
|
||||
setup(
|
||||
name="cli-anything-freecad",
|
||||
version="1.0.0",
|
||||
description="CLI harness for FreeCAD parametric 3D CAD modeler",
|
||||
long_description=open("cli_anything/freecad/README.md").read(),
|
||||
long_description_content_type="text/markdown",
|
||||
author="CLI-Anything Contributors",
|
||||
license="Apache-2.0",
|
||||
packages=find_namespace_packages(include=["cli_anything.*"]),
|
||||
python_requires=">=3.10",
|
||||
install_requires=[
|
||||
"click>=8.0.0",
|
||||
"prompt-toolkit>=3.0.0",
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cli-anything-freecad=cli_anything.freecad.freecad_cli:main",
|
||||
],
|
||||
},
|
||||
package_data={
|
||||
"cli_anything.freecad": ["skills/*.md"],
|
||||
},
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Scientific/Engineering :: Visualization",
|
||||
],
|
||||
)
|
||||
@@ -313,6 +313,20 @@
|
||||
"contributor": "zhangxilong-43",
|
||||
"contributor_url": "https://github.com/zhangxilong-43"
|
||||
},
|
||||
{
|
||||
"name": "freecad",
|
||||
"display_name": "FreeCAD",
|
||||
"version": "1.1.0",
|
||||
"description": "Parametric 3D CAD modeling via FreeCAD CLI (258 commands: Part, Sketcher, PartDesign, Assembly, Mesh, TechDraw, Draft, FEM, CAM, and more)",
|
||||
"requires": "FreeCAD >= 1.1 (freecad.org)",
|
||||
"homepage": "https://www.freecad.org",
|
||||
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=freecad/agent-harness",
|
||||
"entry_point": "cli-anything-freecad",
|
||||
"skill_md": "freecad/agent-harness/cli_anything/freecad/skills/SKILL.md",
|
||||
"category": "3d",
|
||||
"contributor": "AlexGabbia",
|
||||
"contributor_url": "https://github.com/AlexGabbia"
|
||||
},
|
||||
{
|
||||
"name": "iterm2",
|
||||
"display_name": "iTerm2",
|
||||
|
||||
Reference in New Issue
Block a user