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:
Yuhao
2026-03-26 18:51:35 +08:00
committed by GitHub
35 changed files with 21375 additions and 0 deletions
+4
View File
@@ -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
+191
View File
@@ -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
}
+33
View File
@@ -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",
],
)
+14
View File
@@ -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",