feat: Add CadQuery wrapper library for parametric CAD modeling

Python wrapper around CadQuery for simplified 3D CAD operations with
clean API for creating shapes, performing boolean operations, and
exporting to various formats.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 17:22:38 +00:00
parent 04d8515a73
commit a8144a5903
48 changed files with 157235 additions and 0 deletions

4
cadquerywrapper/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Ignore log files
cadquerywrapper.log
__pycache__/
.venv/

341
cadquerywrapper/AGENTS.md Normal file
View File

@@ -0,0 +1,341 @@
# AGENTS.md
## 🧠 Project Overview
This project is a Python-based application. Agents working in this directory must adhere to Python 3.11+ best practices, with clear module separation, test coverage, security hygiene, and readable documentation.
---
## 📁 Project Structure
Agents must follow the directory structure below:
```
project-root/
├── src/ # Application source code
│ └── <package_name>/ # Python modules/packages
├── tests/ # Unit + integration tests
│ └── __init__.py
├── scripts/ # Utility and CLI scripts
├── docs/ # Documentation (Markdown or reStructuredText)
├── .github/ # GitHub Actions workflows
├── pyproject.toml # Project metadata & tool configs (preferred)
├── requirements.txt # Legacy dependency spec (optional)
├── .env.example # Environment variable template
└── AGENTS.md # Agent configuration (this file)
```
Do not create files outside this structure unless explicitly instructed.
---
## 🧪 Testing & Validation
Before submitting changes, agents **must** ensure all tests and linters pass:
```bash
# Set up environment (once)
python3 -m venv .venv
source .venv/bin/activate
pip install -e .[dev]
# Run unit tests
pytest --cov=src --cov-report=term --cov-report=xml
# Run linters
ruff check src/ tests/
mypy src/
black --check src/
```
### ✅ Required Quality Gates
- Code coverage: ≥ 90%
- Type coverage: 100% via `mypy`
- No linter errors (ruff + mypy)
- No black formatting issues
Agents must **not** commit code that fails these checks.
---
## 🧹 Code Style
- Use **PEP8 + PEP257** standards.
- Prefer `pyproject.toml` for tool configuration (Black, Ruff, Mypy, etc.).
- Function names: `snake_case`
- Class names: `PascalCase`
- Constant names: `UPPER_SNAKE_CASE`
- Use f-strings, not `%` or `.format()` for string interpolation.
- Keep function length under 50 lines where possible.
---
## 🔒 Security Guidelines
Agents must:
- Never commit `.env` files, secrets, or tokens.
- Use `dotenv` to load secrets during local execution.
- Avoid using `eval()`, `exec()`, or subprocess calls unless sandboxed and essential.
- Avoid external internet access during execution unless configured via API keys.
---
## 🧪 Testing Philosophy
- Use `pytest` with fixtures, mocking (`unittest.mock` or `pytest-mock`), and parameterized tests.
- Aim for 1:1 parity between modules and test modules.
- Include boundary cases and exception tests.
---
## 🧾 Commit & PR Conventions
Agents must format commits using **Conventional Commits**:
```
feat: add user authentication module
fix: correct path handling in file parser
docs: update README with usage example
```
Pull Requests should include:
- A concise title (≤ 72 chars)
- A summary of what changed and why
- Before/after behavior (if applicable)
- Testing evidence (CI, screenshots, coverage)
---
## 📦 Dependency Management
- Use **`pyproject.toml`** (via Poetry or PEP 621 standard)
- Prefer `pip install .[dev]` over `requirements.txt`
- Dev dependencies go under `[tool.poetry.group.dev.dependencies]` or similar
- Avoid `pip freeze` output for lockfiles — use `poetry.lock` or `requirements.lock` if necessary
---
## 🤖 Agent Behavior Summary
- Respect the file and folder structure
- Never break existing tests
- Follow Pythonic style and formatting
- Automate validation where possible
- Explain large changes via commit messages and PR descriptions
- Use comments where logic is non-obvious
---
Happy coding! 🐍
---
## ⚙️ Setup Script Requirement
Every project must include a `setup.sh` script at the root level that ensures:
- Python 3.11+ is installed
- Poetry is installed (via `install.python-poetry.org`)
- All dependencies are installed using `poetry install`
If this script does not exist, the agent must create it. It should be idempotent and safe to re-run.
Run it like this:
```bash
chmod +x setup.sh
./setup.sh
```
---
## 🖥 OS-Specific Setup Scripts (Expanded)
Agents must maintain the following OS-specific setup scripts:
- `setup_linux.sh`
- `setup_mac.sh`
- `setup_windows.ps1`
These scripts must:
- Ensure Python 3.11+ is present (install if missing)
- Install Poetry (if not already installed)
- Run `poetry install` to install dependencies
- Be idempotent and safe to re-run
A user must be able to simply clone the repo, run the appropriate script, and be ready to work.
Refer to `README.md` for user-friendly execution instructions.
---
## 🖥 OS-Specific Setup Scripts (Expanded)
Agents must maintain the following **OS-specific setup scripts** in the project root:
- `setup_linux.sh` for Ubuntu, Debian, Fedora, Arch, etc.
- `setup_mac.sh` for macOS (Intel/ARM); must auto-install Homebrew if missing
- `setup_windows.ps1` for Windows 10+ using PowerShell
Each script must:
- Ensure Python 3.11+ is installed
- Install Poetry if missing
- Run `poetry install` to fetch dependencies
- Be idempotent and safe to re-run
- Provide clear terminal output
These scripts should allow a contributor to clone the project, run one script, and be ready to develop.
---
### 🐧 setup_linux.sh
```bash
#!/bin/bash
set -e
echo "🔍 Checking Python 3.11+..."
if ! python3 --version | grep -q "3.11"; then
echo "Installing Python 3.11..."
if command -v apt &>/dev/null; then
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3.11-dev
sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
elif command -v dnf &>/dev/null; then
sudo dnf install -y python3.11
elif command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm python
else
echo "❌ Unsupported Linux package manager."
exit 1
fi
fi
echo "✅ Python version: $(python3 --version)"
echo "🔍 Checking for Poetry..."
if ! command -v poetry &>/dev/null; then
echo "📦 Installing Poetry..."
curl -sSL https://install.python-poetry.org | python3 -
export PATH="$HOME/.local/bin:$PATH"
else
echo "✅ Poetry is installed: $(poetry --version)"
fi
echo "📦 Installing dependencies..."
poetry install
echo "✅ Linux setup complete. Use 'poetry shell' to activate environment."
```
---
### 🍎 setup_mac.sh
```bash
#!/bin/bash
set -e
echo "🔍 Checking Python 3.11+..."
if ! python3 --version | grep -q "3.11"; then
echo "Installing Python 3.11 using Homebrew..."
if ! command -v brew &>/dev/null; then
echo "📦 Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
export PATH="/opt/homebrew/bin:$PATH"
fi
brew install python@3.11
brew link python@3.11 --force
fi
echo "✅ Python version: $(python3 --version)"
echo "🔍 Checking for Poetry..."
if ! command -v poetry &>/dev/null; then
echo "📦 Installing Poetry..."
curl -sSL https://install.python-poetry.org | python3 -
export PATH="$HOME/.local/bin:$PATH"
else
echo "✅ Poetry is installed: $(poetry --version)"
fi
echo "📦 Installing dependencies..."
poetry install
echo "✅ macOS setup complete. Use 'poetry shell' to activate environment."
```
---
### 🪟 setup_windows.ps1
```powershell
# PowerShell script
Write-Host "🔍 Checking Python 3.11+..."
$pythonVersion = python --version
if (-not ($pythonVersion -like "*3.11*")) {
Write-Host "Installing Python 3.11..."
Invoke-WebRequest -Uri "https://www.python.org/ftp/python/3.11.5/python-3.11.5-amd64.exe" -OutFile "python_installer.exe"
Start-Process -Wait -FilePath "./python_installer.exe" -ArgumentList "/quiet InstallAllUsers=1 PrependPath=1"
Remove-Item "python_installer.exe"
}
Write-Host "✅ Python version: $(python --version)"
Write-Host "🔍 Checking for Poetry..."
if (-not (Get-Command poetry -ErrorAction SilentlyContinue)) {
Write-Host "📦 Installing Poetry..."
(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | python -
$env:Path += ";$env:USERPROFILE\.poetry\bin"
}
Write-Host "📦 Installing dependencies..."
poetry install
Write-Host "✅ Windows setup complete. Run 'poetry shell' to activate environment."
```
---
### 📘 Instructing Users in README.md
Also ensure `README.md` includes this section:
```markdown
## 🖥 Setup Instructions
Run the setup script for your operating system:
**Linux:**
```bash
chmod +x setup_linux.sh
./setup_linux.sh
```
**macOS:**
```bash
chmod +x setup_mac.sh
./setup_mac.sh
```
**Windows (PowerShell):**
```powershell
.\setup_windows.ps1
```
After installation:
```bash
poetry shell
```
```

View File

@@ -0,0 +1,9 @@
# Code Style Guidelines
This project follows these conventions:
- Keep code PEP 8 compliant. Use `black` formatting with default settings.
- Type annotations are required for all public functions and classes.
- Do not use monkey patching. Any changes to third party libraries must be done via subclassing or wrapper functions instead of modifying objects at runtime.
These rules apply to all code contributions.

83
cadquerywrapper/README.md Normal file
View File

@@ -0,0 +1,83 @@
# CadQueryWrapper
CadQueryWrapper is a lightweight wrapper around [CadQuery/cadquery](https://github.com/CadQuery/cadquery). It provides a small validator for checking model parameters against 3D printer rules.
## Installation
If Python is not available on your system, run the helper script. Use the
Bash version on Linux and macOS or the batch version on Windows:
```bash
./install_python.sh
```
```cmd
install_python.bat
```
Both scripts are self-contained and do not require Python to run.
Then install the runtime dependencies with:
```bash
pip install -r requirements.txt
```
For running the test suite use the development requirements instead:
```bash
pip install -r requirements-dev.txt
```
## Usage
```python
import cadquery as cq
from cadquerywrapper import CadQueryWrapper, ValidationError
# create a CadQuery model
wp = cq.Workplane().box(1, 1, 1)
# load rules and create a wrapper using the workplane
wrapper = CadQueryWrapper("cadquerywrapper/rules/bambu_printability_rules.json", wp)
# validate using default rules
try:
wrapper.validate()
except ValidationError as exc:
print("Model invalid:", exc)
# exporting will raise ValidationError if parameters fail
wrapper.export_stl("out.stl")
```
See `examples/Ex001_Simple_Block.py` for a complete script that validates and
saves a simple block model using ``CadQueryWrapper``.
## Code Style
See [CODE_STYLE.md](CODE_STYLE.md) for contribution guidelines. Monkey patching is prohibited.
## 🖥 Setup Instructions
Run the setup script for your operating system:
**Linux:**
```bash
chmod +x setup_linux.sh
./setup_linux.sh
```
**macOS:**
```bash
chmod +x setup_mac.sh
./setup_mac.sh
```
**Windows (PowerShell):**
```powershell
.\setup_windows.ps1
```
After installation:
```bash
poetry shell
```

View File

@@ -0,0 +1,16 @@
"""CadQueryWrapper package."""
from .validator import ValidationError, Validator, load_rules, validate
from .save_validator import SaveValidator
from .project import CadQueryWrapper
from .logger import get_logger
__all__ = [
"Validator",
"SaveValidator",
"ValidationError",
"load_rules",
"validate",
"CadQueryWrapper",
"get_logger",
]

View File

@@ -0,0 +1,20 @@
import logging
from pathlib import Path
_LOG_PATH = Path("cadquerywrapper.log")
# Create logger for the package
logger = logging.getLogger("cadquerywrapper")
if not logger.handlers:
handler = logging.FileHandler(_LOG_PATH)
formatter = logging.Formatter(
"%(asctime)s - %(levelname)s - %(name)s - %(message)s"
)
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
def get_logger(name: str | None = None) -> logging.Logger:
"""Return a logger writing to ``cadquerywrapper.log``."""
return logger if name is None else logger.getChild(name)

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .logger import get_logger
logger = get_logger(__name__)
import cadquery as cq
from .save_validator import SaveValidator
from .validator import ValidationError, Validator
class CadQueryWrapper:
"""Main interface combining :class:`Validator` and :class:`SaveValidator`."""
def __init__(self, rules: dict | str | Path, workplane: Any):
logger.debug("Initializing CadQueryWrapper with rules %s", rules)
self.validator = Validator(rules)
self.saver = SaveValidator(self.validator)
self.workplane = workplane
self.attach_model(self.workplane, {})
@staticmethod
def attach_model(workplane: Any, model: dict) -> None:
"""Attach printability model data to ``workplane``."""
logger.debug("Attaching model %s to object %s", model, workplane)
SaveValidator.attach_model(workplane, model)
def validate(self) -> None:
"""Validate the attached model using :class:`SaveValidator`."""
logger.debug("Validating object %s", self.workplane)
self.saver._validate_obj(self.workplane)
def export(self, obj: Any | None = None, *args: Any, **kwargs: Any) -> Any:
"""Delegate to :meth:`SaveValidator.export`."""
obj = obj or self.workplane
logger.debug("Exporting %s", obj)
return self.saver.export(obj, *args, **kwargs)
def cq_export(self, obj: Any | None = None, *args: Any, **kwargs: Any) -> Any:
"""Delegate to :meth:`SaveValidator.cq_export`."""
obj = obj or self.workplane
logger.debug("cq_export %s", obj)
return self.saver.cq_export(obj, *args, **kwargs)
def export_stl(
self, shape: cq.Shape | None = None, *args: Any, **kwargs: Any
) -> None:
"""Delegate to :meth:`SaveValidator.export_stl`."""
shape = shape or self.workplane
logger.debug("export_stl %s", shape)
self.saver.export_stl(shape, *args, **kwargs)
def export_step(
self, shape: cq.Shape | None = None, *args: Any, **kwargs: Any
) -> None:
"""Delegate to :meth:`SaveValidator.export_step`."""
shape = shape or self.workplane
logger.debug("export_step %s", shape)
self.saver.export_step(shape, *args, **kwargs)
def export_bin(
self, shape: cq.Shape | None = None, *args: Any, **kwargs: Any
) -> None:
"""Delegate to :meth:`SaveValidator.export_bin`."""
shape = shape or self.workplane
logger.debug("export_bin %s", shape)
self.saver.export_bin(shape, *args, **kwargs)
def export_brep(
self, shape: cq.Shape | None = None, *args: Any, **kwargs: Any
) -> None:
"""Delegate to :meth:`SaveValidator.export_brep`."""
shape = shape or self.workplane
logger.debug("export_brep %s", shape)
self.saver.export_brep(shape, *args, **kwargs)
def assembly_export(
self, assembly: cq.Assembly | None = None, *args: Any, **kwargs: Any
) -> None:
"""Delegate to :meth:`SaveValidator.assembly_export`."""
assembly = assembly or self.workplane
logger.debug("assembly_export %s", assembly)
self.saver.assembly_export(assembly, *args, **kwargs)
def assembly_save(
self, assembly: cq.Assembly | None = None, *args: Any, **kwargs: Any
) -> None:
"""Delegate to :meth:`SaveValidator.assembly_save`."""
assembly = assembly or self.workplane
logger.debug("assembly_save %s", assembly)
self.saver.assembly_save(assembly, *args, **kwargs)
__all__ = ["CadQueryWrapper"]

View File

@@ -0,0 +1,38 @@
{
"printer": "Bambu Labs",
"nozzle_diameter_mm": 0.4,
"layer_height_mm": 0.2,
"rules": {
"minimum_wall_thickness_mm": 0.8,
"minimum_embossed_detail_height_mm": 0.2,
"minimum_engraved_detail_depth_mm": 0.2,
"minimum_text_height_mm": 4.0,
"minimum_text_line_thickness_mm": 0.5,
"minimum_hole_diameter_mm": 2.0,
"hole_size_tolerance_mm": 0.2,
"bridge_max_length_mm": 5.0,
"overhang_max_angle_deg": 45,
"minimum_clearance_between_parts_mm": 0.3,
"minimum_feature_size_mm": 0.4,
"recommended_support_gap_mm": 0.2,
"minimum_chamfer_or_fillet_mm": 0.5,
"max_model_size_mm": {
"X": 256,
"Y": 256,
"Z": 256
},
"maximum_file_triangle_count": 1000000,
"manifold_geometry_required": true,
"no_intersecting_geometry": true,
"no_open_edges": true,
"preferred_file_format": "STL",
"alternate_file_formats": ["3MF", "OBJ"]
},
"notes": [
"Values are tuned for typical Bambu Lab P1P, P1S, X1, or A1 with 0.4 mm nozzle.",
"Minimum feature sizes are physical—anything smaller may not print or adhere properly.",
"Bridge length and overhang angle assume no support and average cooling.",
"Clearance depends on material shrinkage—tune for specific filament types.",
"For press-fit or moving parts, increase clearance to 0.40.5 mm."
]
}

View File

@@ -0,0 +1,234 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .logger import get_logger
logger = get_logger(__name__)
import trimesh
import cadquery as cq
from .validator import (
ValidationError,
Validator,
assembly_has_intersections,
is_manifold,
shape_has_open_edges,
validate,
)
class SaveValidator:
"""Wrapper around CadQuery save functions that performs validation."""
def __init__(
self, rules: dict | str | Path | Validator, obj: Any | None = None
) -> None:
if isinstance(rules, Validator):
logger.debug("Initializing SaveValidator with Validator instance")
self.validator = rules
else:
logger.debug("Initializing SaveValidator with rules: %s", rules)
self.validator = Validator(rules)
if obj is not None:
logger.debug("Attaching empty model to object %s", obj)
self.attach_model(obj, {})
@staticmethod
def attach_model(workplane: Any, model: dict) -> None:
"""Attach printability model data to a CadQuery object."""
logger.debug("Attaching model %s to object %s", model, workplane)
setattr(workplane, "_printability_model", model)
def _validate_obj(self, obj: Any) -> None:
logger.debug("Validating object %s", obj)
model = getattr(obj, "_printability_model", None)
if model is None:
logger.debug("No model attached; skipping validation")
return
combined_model = dict(model)
max_size = self.validator.rules.get("rules", {}).get("max_model_size_mm")
if max_size is not None:
try:
bbox_obj = obj.val().BoundingBox()
except Exception: # pragma: no cover - val() may not exist
bbox_obj = None
if hasattr(obj, "BoundingBox"):
try:
bbox_obj = obj.BoundingBox()
except Exception: # pragma: no cover - bounding box failure
bbox_obj = None
if bbox_obj is not None:
combined_model["max_model_size_mm"] = {
"X": bbox_obj.xlen,
"Y": bbox_obj.ylen,
"Z": bbox_obj.zlen,
}
errors = validate(combined_model, self.validator.rules)
if errors:
logger.debug("Validation errors: %s", errors)
raise ValidationError("; ".join(errors))
rules = self.validator.rules.get("rules", {})
if rules.get("manifold_geometry_required"):
if not is_manifold(obj):
logger.debug("Non-manifold geometry detected")
raise ValidationError("Non-manifold geometry detected")
if rules.get("no_open_edges"):
if shape_has_open_edges(obj):
logger.debug("Object contains open edges")
raise ValidationError("Object contains open edges")
if rules.get("no_intersecting_geometry"):
if assembly_has_intersections(obj):
logger.debug("Intersecting geometry detected")
raise ValidationError("Intersecting geometry detected")
min_clear = rules.get("minimum_clearance_between_parts_mm")
if min_clear is not None and hasattr(obj, "solids"):
from .validator import assembly_minimum_clearance
clearance = assembly_minimum_clearance(obj)
if clearance is not None and clearance < min_clear:
logger.debug("Clearance %s below minimum %s", clearance, min_clear)
raise ValidationError(
f"Clearance {clearance} below minimum {min_clear}"
)
max_overhang = rules.get("overhang_max_angle_deg")
if max_overhang is not None:
from .validator import shape_max_overhang_angle
angle = shape_max_overhang_angle(obj)
if angle is not None and angle > max_overhang:
logger.debug(
"Overhang angle %s exceeds maximum %s", angle, max_overhang
)
raise ValidationError(
f"Overhang angle {angle} exceeds maximum {max_overhang}"
)
def _check_triangle_count(self, file_name: str | Path) -> None:
"""Validate mesh triangle count against configured limit."""
limit = self.validator.rules.get("rules", {}).get("maximum_file_triangle_count")
if limit is None:
return
mesh = trimesh.load_mesh(file_name)
tri_count = int(len(mesh.faces))
logger.debug("Triangle count for %s: %s", file_name, tri_count)
if tri_count > limit:
logger.debug("Triangle count %s exceeds maximum %s", tri_count, limit)
raise ValidationError(f"Triangle count {tri_count} exceeds maximum {limit}")
def _validate_file_format(self, file_name: str | Path | None) -> None:
"""Ensure ``file_name`` uses an allowed extension."""
if file_name is None:
return
rules = self.validator.rules.get("rules", {})
preferred = rules.get("preferred_file_format")
alternates = rules.get("alternate_file_formats", []) or []
allowed = []
if preferred:
allowed.append(str(preferred).lower().lstrip("."))
for alt in alternates:
if alt:
allowed.append(str(alt).lower().lstrip("."))
if not allowed:
return
ext = Path(file_name).suffix.lower().lstrip(".")
if ext and ext not in allowed:
logger.debug("File format %s not allowed; allowed: %s", ext, allowed)
raise ValidationError(f"File format {ext.upper()} is not supported")
def export(self, obj: Any, *args: Any, **kwargs: Any) -> Any:
"""Validate ``obj`` and delegate to :func:`cadquery.exporters.export`."""
logger.debug("export called with %s", obj)
self._validate_obj(obj)
file_name = args[0] if args else None
file_name = kwargs.get("fileName", kwargs.get("fname", file_name))
self._validate_file_format(file_name)
logger.debug("Saving with exporters.export to %s", file_name)
return cq.exporters.export(obj, *args, **kwargs)
def cq_export(self, obj: Any, *args: Any, **kwargs: Any) -> Any:
"""Validate ``obj`` and delegate to :func:`cadquery.export`."""
logger.debug("cq_export called with %s", obj)
self._validate_obj(obj)
file_name = args[0] if args else None
file_name = kwargs.get("fileName", kwargs.get("fname", file_name))
self._validate_file_format(file_name)
logger.debug("Saving with cq.export to %s", file_name)
return cq.export(obj, *args, **kwargs) # type: ignore[attr-defined]
def export_stl(self, shape: cq.Shape, *args: Any, **kwargs: Any) -> None:
"""Validate ``shape`` and call ``exportStl``."""
logger.debug("export_stl called with %s", shape)
self._validate_obj(shape)
file_name = args[0] if args else None
file_name = kwargs.get("fileName", file_name)
self._validate_file_format(file_name)
shape.exportStl(*args, **kwargs)
if file_name is not None:
try:
self._check_triangle_count(file_name)
except ValidationError:
Path(file_name).unlink(missing_ok=True)
raise
def export_step(self, shape: cq.Shape, *args: Any, **kwargs: Any) -> None:
"""Validate ``shape`` and call ``exportStep``."""
logger.debug("export_step called with %s", shape)
self._validate_obj(shape)
file_name = args[0] if args else None
file_name = kwargs.get("fileName", file_name)
self._validate_file_format(file_name)
shape.exportStep(*args, **kwargs)
def export_bin(self, shape: cq.Shape, *args: Any, **kwargs: Any) -> None:
"""Validate ``shape`` and call ``exportBin``."""
logger.debug("export_bin called with %s", shape)
self._validate_obj(shape)
file_name = args[0] if args else None
file_name = kwargs.get("fileName", file_name)
self._validate_file_format(file_name)
shape.exportBin(*args, **kwargs)
def export_brep(self, shape: cq.Shape, *args: Any, **kwargs: Any) -> None:
"""Validate ``shape`` and call ``exportBrep``."""
logger.debug("export_brep called with %s", shape)
self._validate_obj(shape)
file_name = args[0] if args else None
file_name = kwargs.get("fileName", file_name)
self._validate_file_format(file_name)
shape.exportBrep(*args, **kwargs)
def assembly_export(self, assembly: cq.Assembly, *args: Any, **kwargs: Any) -> None:
"""Validate ``assembly`` and call ``Assembly.export``."""
logger.debug("assembly_export called with %s", assembly)
self._validate_obj(assembly)
file_name = args[0] if args else None
file_name = kwargs.get("fileName", file_name)
self._validate_file_format(file_name)
assembly.export(*args, **kwargs)
def assembly_save(self, assembly: cq.Assembly, *args: Any, **kwargs: Any) -> None:
"""Validate ``assembly`` and call ``Assembly.save``."""
logger.debug("assembly_save called with %s", assembly)
self._validate_obj(assembly)
file_name = args[0] if args else None
file_name = kwargs.get("fileName", file_name)
self._validate_file_format(file_name)
assembly.save(*args, **kwargs)
__all__ = ["SaveValidator"]

View File

@@ -0,0 +1,288 @@
"""Printability rules validation helpers."""
import json
import logging
from pathlib import Path
from .logger import get_logger
logger = get_logger(__name__)
class ValidationError(Exception):
"""Raised when an object fails printability validation."""
pass
def load_rules(rules_path: str | Path) -> dict:
path = Path(rules_path)
logger.debug("Loading rules from %s", path)
with path.open() as f:
data = json.load(f)
logger.debug("Loaded rules: %s", data.keys())
return data
def validate(model: dict, rules: dict) -> list[str]:
"""Validate model parameters against printability rules.
Parameters
----------
model: dict
A dictionary containing model parameters to validate. Keys should
correspond to the rules names in the JSON file.
rules: dict
Dictionary loaded from a rules JSON file.
Returns
-------
list[str]
List of human readable error messages. Empty if model is valid.
"""
logger.debug("Validating model: %s", model)
errors = []
rule_values = rules.get("rules", {})
for key, value in rule_values.items():
model_value = model.get(key)
if model_value is None:
continue
if isinstance(value, dict):
if isinstance(model_value, dict) and key == "max_model_size_mm":
for axis, limit in value.items():
axis_value = model_value.get(axis)
if axis_value is None:
continue
if axis_value > limit:
msg = f"Model size {axis} {axis_value} exceeds maximum {limit}"
errors.append(msg)
logger.debug(msg)
continue
if model_value < value:
msg = (
f"{key.replace('_', ' ').capitalize()} {model_value} "
f"is below minimum {value}"
)
errors.append(msg)
logger.debug(msg)
logger.debug("Validation errors: %s", errors)
return errors
class Validator:
"""Object oriented wrapper around :func:`validate`.
The ``validate`` method will raise :class:`ValidationError` if the provided
model data does not satisfy the stored rules.
"""
def __init__(self, rules: dict | str | Path):
if isinstance(rules, (str, Path)):
logger.debug("Initializing Validator with rules file %s", rules)
self.rules = load_rules(rules)
else:
logger.debug("Initializing Validator with rules dict")
self.rules = rules
@classmethod
def from_file(cls, path: str | Path) -> "Validator":
"""Create a :class:`Validator` from a rules JSON file."""
logger.debug("Creating Validator from file %s", path)
return cls(load_rules(path))
def validate(self, model: dict) -> None:
"""Validate ``model`` against the stored ``rules``.
Raises
------
ValidationError
If any of the model values are below the configured limits.
"""
errors = validate(model, self.rules)
if errors:
logger.debug("Validation failed with errors: %s", errors)
raise ValidationError("; ".join(errors))
logger.debug("Model valid")
__all__ = ["ValidationError", "load_rules", "validate", "Validator"]
def is_manifold(shape: object) -> bool:
"""Return ``True`` if ``shape`` appears to be manifold."""
logger.debug("Checking if shape is manifold")
try:
if hasattr(shape, "isValid") and not shape.isValid():
logger.debug("Shape invalid")
return False
except Exception:
logger.debug("isValid check failed")
return False
try:
if hasattr(shape, "isClosed") and not shape.isClosed():
logger.debug("Shape not closed")
return False
except Exception:
logger.debug("isClosed check failed")
return False
logger.debug("Shape is manifold")
return True
def shape_has_open_edges(shape: object) -> bool:
"""Return ``True`` if ``shape`` seems to have open edges."""
logger.debug("Checking for open edges")
if hasattr(shape, "hasOpenEdges"):
try:
result = bool(shape.hasOpenEdges())
logger.debug("hasOpenEdges: %s", result)
return result
except Exception:
logger.debug("hasOpenEdges check failed")
return True
if hasattr(shape, "open_edges"):
result = bool(getattr(shape, "open_edges"))
logger.debug("open_edges attribute: %s", result)
return result
logger.debug("No open edges detected")
return False
def assembly_has_intersections(assembly: object) -> bool:
"""Return ``True`` if any solids in ``assembly`` intersect."""
logger.debug("Checking assembly for intersections")
solids = []
if hasattr(assembly, "solids"):
try:
solids = list(assembly.solids())
except Exception:
solids = []
if not solids and hasattr(assembly, "children"):
solids = [c for c in assembly.children if hasattr(c, "intersect")]
for i, shape1 in enumerate(solids):
for shape2 in solids[i + 1 :]:
try:
result = shape1.intersect(shape2)
except Exception:
continue
if result is None:
continue
is_null = False
if hasattr(result, "isNull"):
try:
is_null = result.isNull()
except Exception:
is_null = False
elif hasattr(result, "Volume"):
try:
is_null = result.Volume() == 0
except Exception:
is_null = False
if not is_null:
logger.debug("Intersection found between solids")
return True
logger.debug("No intersections found")
return False
def assembly_minimum_clearance(assembly: object) -> float | None:
"""Return the minimum distance between solids in ``assembly``."""
logger.debug("Computing minimum clearance in assembly")
solids = []
if hasattr(assembly, "solids"):
try:
solids = list(assembly.solids())
except Exception: # pragma: no cover - solids retrieval failure
solids = []
if not solids and hasattr(assembly, "children"):
solids = [c for c in assembly.children if hasattr(c, "distTo")]
min_dist: float | None = None
for i, shape1 in enumerate(solids):
for shape2 in solids[i + 1 :]:
dists = []
for shape_a, shape_b in ((shape1, shape2), (shape2, shape1)):
method = (
getattr(shape_a, "distTo", None)
or getattr(shape_a, "distance", None)
or getattr(shape_a, "Distance", None)
)
if callable(method):
try:
d = float(method(shape_b))
except Exception: # pragma: no cover - distance failure
continue
dists.append(d)
if not dists:
continue
pair_dist = min(dists)
if min_dist is None or pair_dist < min_dist:
min_dist = pair_dist
logger.debug("Minimum clearance: %s", min_dist)
return min_dist
def shape_max_overhang_angle(
shape: object, z_dir: tuple[float, float, float] = (0.0, 0.0, 1.0)
) -> float | None:
"""Return the maximum overhang angle of ``shape`` in degrees."""
logger.debug("Calculating max overhang angle")
faces = []
for attr in ("faces", "Faces", "all_faces"):
getter = getattr(shape, attr, None)
if callable(getter):
try:
faces = list(getter())
except Exception: # pragma: no cover - faces failure
faces = []
if faces:
break
elif isinstance(getter, (list, tuple)):
faces = list(getter)
break
if not faces:
logger.debug("No faces found")
return None
import math
z_len = math.sqrt(sum(c * c for c in z_dir)) or 1.0
z_axis = tuple(c / z_len for c in z_dir)
max_angle = 0.0
for face in faces:
normal = None
if hasattr(face, "normalAt"):
try:
normal = face.normalAt()
except Exception: # pragma: no cover - normal failure
normal = None
if normal is None and hasattr(face, "normal"):
normal = face.normal
if normal is None:
continue
if hasattr(normal, "toTuple"):
normal = normal.toTuple()
if not isinstance(normal, (list, tuple)) or len(normal) != 3:
continue
n_len = math.sqrt(sum(c * c for c in normal)) or 1.0
norm = tuple(c / n_len for c in normal)
dot = abs(sum(a * b for a, b in zip(norm, z_axis)))
dot = max(-1.0, min(1.0, dot))
angle = math.degrees(math.acos(dot))
if angle > max_angle:
max_angle = angle
logger.debug("Max overhang angle: %s", max_angle)
return max_angle
__all__ += [
"is_manifold",
"shape_has_open_edges",
"assembly_has_intersections",
"assembly_minimum_clearance",
"shape_max_overhang_angle",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
import cadquery as cq
from cadquerywrapper import CadQueryWrapper, ValidationError
# These can be modified rather than hardcoding values for each dimension.
length = 80.0 # Length of the block
height = 60.0 # Height of the block
thickness = 10.0 # Thickness of the block
# Create a 3D block based on the dimension variables above.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the X and Y origins to define the workplane, meaning that the
# positive Z direction is "up", and the negative Z direction is "down".
result = cq.Workplane("XY").box(length, height, thickness)
# Attach a simple model so validation has data to work with
CadQueryWrapper.attach_model(result, {"minimum_wall_thickness_mm": thickness})
# Wrap the workplane with validation and saving helpers
wrapper = CadQueryWrapper("cadquerywrapper/rules/bambu_printability_rules.json", result)
try:
wrapper.validate()
except ValidationError as exc:
print("Model invalid:", exc)
wrapper.export_stl("simple_block.stl")
# The following method is now outdated, but can still be used to display the
# results of the script if you want
# from Helpers import show
# show(result) # Render the result of this script
show_object(result)

View File

@@ -0,0 +1,25 @@
import cadquery as cq
# These can be modified rather than hardcoding values for each dimension.
length = 80.0 # Length of the block
height = 60.0 # Height of the block
thickness = 10.0 # Thickness of the block
center_hole_dia = 22.0 # Diameter of center hole in block
# Create a block based on the dimensions above and add a 22mm center hole.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the X and Y origins to define the workplane, meaning that the
# positive Z direction is "up", and the negative Z direction is "down".
# 2. The highest (max) Z face is selected and a new workplane is created on it.
# 3. The new workplane is used to drill a hole through the block.
# 3a. The hole is automatically centered in the workplane.
result = (
cq.Workplane("XY")
.box(length, height, thickness)
.faces(">Z")
.workplane()
.hole(center_hole_dia)
)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,44 @@
import cadquery as cq
# These can be modified rather than hardcoding values for each dimension.
length = 80.0 # Length of the block
width = 100.0 # Width of the block
thickness = 10.0 # Thickness of the block
center_hole_dia = 22.0 # Diameter of center hole in block
cbore_hole_diameter = 2.4 # Bolt shank/threads clearance hole diameter
cbore_inset = 12.0 # How far from the edge the cbored holes are set
cbore_diameter = 4.4 # Bolt head pocket hole diameter
cbore_depth = 2.1 # Bolt head pocket hole depth
# Create a 3D block based on the dimensions above and add a 22mm center hold
# and 4 counterbored holes for bolts
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the X and Y origins to define the workplane, meaning that the
# positive Z direction is "up", and the negative Z direction is "down".
# 2. The highest(max) Z face is selected and a new workplane is created on it.
# 3. The new workplane is used to drill a hole through the block.
# 3a. The hole is automatically centered in the workplane.
# 4. The highest(max) Z face is selected and a new workplane is created on it.
# 5. A for-construction rectangle is created on the workplane based on the
# block's overall dimensions.
# 5a. For-construction objects are used only to place other geometry, they
# do not show up in the final displayed geometry.
# 6. The vertices of the rectangle (corners) are selected, and a counter-bored
# hole is placed at each of the vertices (all 4 of them at once).
result = (
cq.Workplane("XY")
.box(length, width, thickness)
.faces(">Z")
.workplane()
.hole(center_hole_dia)
.faces(">Z")
.workplane()
.rect(length - cbore_inset, width - cbore_inset, forConstruction=True)
.vertices()
.cboreHole(cbore_hole_diameter, cbore_diameter, cbore_depth)
.edges("|Z")
.fillet(2.0)
)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,32 @@
import cadquery as cq
# These can be modified rather than hardcoding values for each dimension.
circle_radius = 50.0 # Radius of the plate
thickness = 13.0 # Thickness of the plate
rectangle_width = 13.0 # Width of rectangular hole in cylindrical plate
rectangle_length = 19.0 # Length of rectangular hole in cylindrical plate
# Extrude a cylindrical plate with a rectangular hole in the middle of it.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. The 2D geometry for the outer circle is created at the same time as the
# rectangle that will create the hole in the center.
# 2a. The circle and the rectangle will be automatically centered on the
# workplane.
# 2b. Unlike some other functions like the hole(), circle() takes
# a radius and not a diameter.
# 3. The circle and rectangle are extruded together, creating a cylindrical
# plate with a rectangular hole in the center.
# 3a. circle() and rect() could be changed to any other shape to completely
# change the resulting plate and/or the hole in it.
result = (
cq.Workplane("front")
.circle(circle_radius)
.rect(rectangle_width, rectangle_length)
.extrude(thickness)
)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,49 @@
import cadquery as cq
# These can be modified rather than hardcoding values for each dimension.
width = 2.0 # Overall width of the plate
thickness = 0.25 # Thickness of the plate
# Extrude a plate outline made of lines and an arc
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. Draws a line from the origin to an X position of the plate's width.
# 2a. The starting point of a 2D drawing like this will be at the center of the
# workplane (0, 0) unless the moveTo() function moves the starting point.
# 3. A line is drawn from the last position straight up in the Y direction
# 1.0 millimeters.
# 4. An arc is drawn from the last point, through point (1.0, 1.5) which is
# half-way back to the origin in the X direction and 0.5 mm above where
# the last line ended at. The arc then ends at (0.0, 1.0), which is 1.0 mm
# above (in the Y direction) where our first line started from.
# 5. An arc is drawn from the last point that ends on (-0.5, 1.0), the sag of
# the curve 0.2 determines that the curve is concave with the midpoint 0.1 mm
# from the arc baseline. If the sag was -0.2 the arc would be convex.
# This convention is valid when the profile is drawn counterclockwise.
# The reverse is true if the profile is drawn clockwise.
# Clockwise: +sag => convex, -sag => concave
# Counterclockwise: +sag => concave, -sag => convex
# 6. An arc is drawn from the last point that ends on (-0.7, -0.2), the arc is
# determined by the radius of -1.5 mm.
# Clockwise: +radius => convex, -radius => concave
# Counterclockwise: +radius => concave, -radius => convex
# 7. close() is called to automatically draw the last line for us and close
# the sketch so that it can be extruded.
# 7a. Without the close(), the 2D sketch will be left open and the extrude
# operation will provide unpredictable results.
# 8. The 2D sketch is extruded into a solid object of the specified thickness.
result = (
cq.Workplane("front")
.lineTo(width, 0)
.lineTo(width, 1.0)
.threePointArc((1.0, 1.5), (0.0, 1.0))
.sagittaArc((-0.5, 1.0), 0.2)
.radiusArc((-0.7, -0.2), -1.5)
.close()
.extrude(thickness)
)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,35 @@
import cadquery as cq
# These can be modified rather than hardcoding values for each dimension.
circle_radius = 3.0 # The outside radius of the plate
thickness = 0.25 # The thickness of the plate
# Make a plate with two cutouts in it by moving the workplane center point
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 1b. The initial workplane center point is the center of the circle, at (0,0).
# 2. A circle is created at the center of the workplane
# 2a. Notice that circle() takes a radius and not a diameter
result = cq.Workplane("front").circle(circle_radius)
# 3. The work center is movide to (1.5, 0.0) by calling center().
# 3a. The new center is specified relative to the previous center,not
# relative to global coordinates.
# 4. A 0.5mm x 0.5mm 2D square is drawn inside the circle.
# 4a. The plate has not been extruded yet, only 2D geometry is being created.
result = result.center(1.5, 0.0).rect(0.5, 0.5)
# 5. The work center is moved again, this time to (-1.5, 1.5).
# 6. A 2D circle is created at that new center with a radius of 0.25mm.
result = result.center(-1.5, 1.5).circle(0.25)
# 7. All 2D geometry is extruded to the specified thickness of the plate.
# 7a. The small circle and the square are enclosed in the outer circle of the
# plate and so it is assumed that we want them to be cut out of the plate.
# A separate cut operation is not needed.
result = result.extrude(thickness)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,32 @@
import cadquery as cq
# These can be modified rather than hardcoding values for each dimension.
plate_radius = 2.0 # The radius of the plate that will be extruded
hole_pattern_radius = 0.25 # Radius of circle where the holes will be placed
thickness = 0.125 # The thickness of the plate that will be extruded
# Make a plate with 4 holes in it at various points in a polar arrangement from
# the center of the workplane.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. A 2D circle is drawn that will become though outer profile of the plate.
r = cq.Workplane("front").circle(plate_radius)
# 3. Push 4 points on the stack that will be used as the center points of the
# holes.
r = r.pushPoints([(1.5, 0), (0, 1.5), (-1.5, 0), (0, -1.5)])
# 4. This circle() call will operate on all four points, putting a circle at
# each one.
r = r.circle(hole_pattern_radius)
# 5. All 2D geometry is extruded to the specified thickness of the plate.
# 5a. The small hole circles are enclosed in the outer circle of the plate and
# so it is assumed that we want them to be cut out of the plate. A
# separate cut operation is not needed.
result = r.extrude(thickness)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,42 @@
import cadquery as cq
# These can be modified rather than hardcoding values for each dimension.
width = 3.0 # The width of the plate
height = 4.0 # The height of the plate
thickness = 0.25 # The thickness of the plate
polygon_sides = 6 # The number of sides that the polygonal holes should have
polygon_dia = 1.0 # The diameter of the circle enclosing the polygon points
# Create a plate with two polygons cut through it
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. A 3D box is created in one box() operation to represent the plate.
# 2a. The box is centered around the origin, which creates a result that may
# be unituitive when the polygon cuts are made.
# 3. 2 points are pushed onto the stack and will be used as centers for the
# polygonal holes.
# 4. The two polygons are created, on for each point, with one call to
# polygon() using the number of sides and the circle that bounds the
# polygon.
# 5. The polygons are cut thru all objects that are in the line of extrusion.
# 5a. A face was not selected, and so the polygons are created on the
# workplane. Since the box was centered around the origin, the polygons end
# up being in the center of the box. This makes them cut from the center to
# the outside along the normal (positive direction).
# 6. The polygons are cut through all objects, starting at the center of the
# box/plate and going "downward" (opposite of normal) direction. Functions
# like cutBlind() assume a positive cut direction, but cutThruAll() assumes
# instead that the cut is made from a max direction and cuts downward from
# that max through all objects.
result = (
cq.Workplane("front")
.box(width, height, thickness)
.pushPoints([(0, 0.75), (0, -0.75)])
.polygon(polygon_sides, polygon_dia)
.cutThruAll()
)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,37 @@
import cadquery as cq
# These can be modified rather than hardcoding values for each dimension.
# Define up our Length, Height, Width, and thickness of the beam
(L, H, W, t) = (100.0, 20.0, 20.0, 1.0)
# Define the points that the polyline will be drawn to/thru
pts = [
(0, H / 2.0),
(W / 2.0, H / 2.0),
(W / 2.0, (H / 2.0 - t)),
(t / 2.0, (H / 2.0 - t)),
(t / 2.0, (t - H / 2.0)),
(W / 2.0, (t - H / 2.0)),
(W / 2.0, H / -2.0),
(0, H / -2.0),
]
# We generate half of the I-beam outline and then mirror it to create the full
# I-beam.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. moveTo() is used to move the first point from the origin (0, 0) to
# (0, 10.0), with 10.0 being half the height (H/2.0). If this is not done
# the first line will start from the origin, creating an extra segment that
# will cause the extrude to have an invalid shape.
# 3. The polyline function takes a list of points and generates the lines
# through all the points at once.
# 3. Only half of the I-beam profile has been drawn so far. That half is
# mirrored around the Y-axis to create the complete I-beam profile.
# 4. The I-beam profile is extruded to the final length of the beam.
result = cq.Workplane("front").polyline(pts).mirrorY().extrude(L)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,27 @@
import cadquery as cq
# 1. Establishes a workplane to create the spline on to extrude.
# 1a. Uses the X and Y origins to define the workplane, meaning that the
# positive Z direction is "up", and the negative Z direction is "down".
s = cq.Workplane("XY")
# The points that the spline will pass through
sPnts = [
(2.75, 1.5),
(2.5, 1.75),
(2.0, 1.5),
(1.5, 1.0),
(1.0, 1.25),
(0.5, 1.0),
(0, 1.0),
]
# 2. Generate our plate with the spline feature and make sure it is a
# closed entity
r = s.lineTo(3.0, 0).lineTo(3.0, 1.0).spline(sPnts, includeCurrent=True).close()
# 3. Extrude to turn the wire into a plate
result = r.extrude(0.5)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,20 @@
import cadquery as cq
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. A horizontal line is drawn on the workplane with the hLine function.
# 2a. 1.0 is the distance, not coordinate. hLineTo allows using xCoordinate
# not distance.
r = cq.Workplane("front").hLine(1.0)
# 3. Draw a series of vertical and horizontal lines with the vLine and hLine
# functions.
r = r.vLine(0.5).hLine(-0.25).vLine(-0.25).hLineTo(0.0)
# 4. Mirror the geometry about the Y axis and extrude it into a 3D object.
result = r.mirrorY().extrude(0.25)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,16 @@
import cadquery as cq
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. Creates a 3D box that will have a hole placed in it later.
result = cq.Workplane("front").box(2, 3, 0.5)
# 3. Find the top-most face with the >Z max selector.
# 3a. Establish a new workplane to build geometry on.
# 3b. Create a hole down into the box.
result = result.faces(">Z").workplane().hole(0.5)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,21 @@
import cadquery as cq
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. Creates a 3D box that will have a hole placed in it later.
result = cq.Workplane("front").box(3, 2, 0.5)
# 3. Select the lower left vertex and make a workplane.
# 3a. The top-most Z face is selected using the >Z selector.
# 3b. The lower-left vertex of the faces is selected with the <XY selector.
# 3c. A new workplane is created on the vertex to build future geometry on.
result = result.faces(">Z").vertices("<XY").workplane(centerOption="CenterOfMass")
# 4. A circle is drawn with the selected vertex as its center.
# 4a. The circle is cut down through the box to cut the corner out.
result = result.circle(1.0).cutThruAll()
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,20 @@
import cadquery as cq
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. Creates a 3D box that will have geometry based off it later.
result = cq.Workplane("front").box(3, 2, 0.5)
# 3. The lowest face in the X direction is selected with the <X selector.
# 4. A new workplane is created
# 4a.The workplane is offset from the object surface so that it is not touching
# the original box.
result = result.faces("<X").workplane(offset=0.75)
# 5. Creates a thin disc on the offset workplane that is floating near the box.
result = result.circle(1.0).extrude(0.5)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,28 @@
import cadquery as cq
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. Creates a plain box to base future geometry on with the box() function.
# 3. Selects the top-most Z face of the box.
# 4. Creates a new workplane and then moves and rotates it with the
# transformed function.
# 5. Creates a for-construction rectangle that only exists to use for placing
# other geometry.
# 6. Selects the vertices of the for-construction rectangle.
# 7. Places holes at the center of each selected vertex.
# 7a. Since the workplane is rotated, this results in angled holes in the face.
result = (
cq.Workplane("front")
.box(4.0, 4.0, 0.25)
.faces(">Z")
.workplane()
.transformed(offset=(0, -1.5, 1.0), rotate=(60, 0, 0))
.rect(1.5, 1.5, forConstruction=True)
.vertices()
.hole(0.25)
)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,26 @@
import cadquery as cq
# Create a block with holes in each corner of a rectangle on that workplane.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. Creates a plain box to base future geometry on with the box() function.
# 3. Selects the top-most Z face of the box.
# 4. Creates a new workplane to build new geometry on.
# 5. Creates a for-construction rectangle that only exists to use for placing
# other geometry.
# 6. Selects the vertices of the for-construction rectangle.
# 7. Places holes at the center of each selected vertex.
result = (
cq.Workplane("front")
.box(2, 2, 0.5)
.faces(">Z")
.workplane()
.rect(1.5, 1.5, forConstruction=True)
.vertices()
.hole(0.125)
)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,14 @@
import cadquery as cq
# Create a hollow box that's open on both ends with a thin wall.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. Creates a plain box to base future geometry on with the box() function.
# 3. Selects faces with normal in +z direction.
# 4. Create a shell by cutting out the top-most Z face.
result = cq.Workplane("front").box(2, 2, 2).faces("+Z").shell(0.05)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,25 @@
import cadquery as cq
# Create a lofted section between a rectangle and a circular section.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the named plane orientation "front" to define the workplane, meaning
# that the positive Z direction is "up", and the negative Z direction
# is "down".
# 2. Creates a plain box to base future geometry on with the box() function.
# 3. Selects the top-most Z face of the box.
# 4. Draws a 2D circle at the center of the the top-most face of the box.
# 5. Creates a workplane 3 mm above the face the circle was drawn on.
# 6. Draws a 2D circle on the new, offset workplane.
# 7. Creates a loft between the circle and the rectangle.
result = (
cq.Workplane("front")
.box(4.0, 4.0, 0.25)
.faces(">Z")
.circle(1.5)
.workplane(offset=3.0)
.rect(0.75, 0.5)
.loft(combine=True)
)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,25 @@
import cadquery as cq
# Create a plate with 4 counter-sunk holes in it.
# 1. Establishes a workplane using an XY object instead of a named plane.
# 2. Creates a plain box to base future geometry on with the box() function.
# 3. Selects the top-most face of the box and established a workplane on that.
# 4. Draws a for-construction rectangle on the workplane which only exists for
# placing other geometry.
# 5. Selects the corner vertices of the rectangle and places a counter-sink
# hole, using each vertex as the center of a hole using the cskHole()
# function.
# 5a. When the depth of the counter-sink hole is set to None, the hole will be
# cut through.
result = (
cq.Workplane(cq.Plane.XY())
.box(4, 2, 0.5)
.faces(">Z")
.workplane()
.rect(3.5, 1.5, forConstruction=True)
.vertices()
.cskHole(0.125, 0.25, 82.0, depth=None)
)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,13 @@
import cadquery as cq
# Create a plate with 4 rounded corners in the Z-axis.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the X and Y origins to define the workplane, meaning that the
# positive Z direction is "up", and the negative Z direction is "down".
# 2. Creates a plain box to base future geometry on with the box() function.
# 3. Selects all edges that are parallel to the Z axis.
# 4. Creates fillets on each of the selected edges with the specified radius.
result = cq.Workplane("XY").box(3, 3, 0.5).edges("|Z").fillet(0.125)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,24 @@
import cadquery as cq
# Create a simple block with a hole through it that we can split.
# 1. Establishes a workplane that an object can be built on.
# 1a. Uses the X and Y origins to define the workplane, meaning that the
# positive Z direction is "up", and the negative Z direction is "down".
# 2. Creates a plain box to base future geometry on with the box() function.
# 3. Selects the top-most face of the box and establishes a workplane on it
# that new geometry can be built on.
# 4. Draws a 2D circle on the new workplane and then uses it to cut a hole
# all the way through the box.
c = cq.Workplane("XY").box(1, 1, 1).faces(">Z").workplane().circle(0.25).cutThruAll()
# 5. Selects the face furthest away from the origin in the +Y axis direction.
# 6. Creates an offset workplane that is set in the center of the object.
# 6a. One possible improvement to this script would be to make the dimensions
# of the box variables, and then divide the Y-axis dimension by 2.0 and
# use that to create the offset workplane.
# 7. Uses the embedded workplane to split the object, keeping only the "top"
# portion.
result = c.faces(">Y").workplane(-0.5).split(keepTop=True)
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,21 @@
import cadquery as cq
# The dimensions of the model. These can be modified rather than changing the
# shape's code directly.
rectangle_width = 10.0
rectangle_length = 10.0
angle_degrees = 360.0
# Revolve a cylinder from a rectangle
# Switch comments around in this section to try the revolve operation with different parameters
result = cq.Workplane("XY").rect(rectangle_width, rectangle_length, False).revolve()
# result = cq.Workplane("XY").rect(rectangle_width, rectangle_length, False).revolve(angle_degrees)
# result = cq.Workplane("XY").rect(rectangle_width, rectangle_length).revolve(angle_degrees,(-5,-5))
# result = cq.Workplane("XY").rect(rectangle_width, rectangle_length).revolve(angle_degrees,(-5, -5),(-5, 5))
# result = cq.Workplane("XY").rect(rectangle_width, rectangle_length).revolve(angle_degrees,(-5,-5),(-5,5), False)
# Revolve a donut with square walls
# result = cq.Workplane("XY").rect(rectangle_width, rectangle_length, True).revolve(angle_degrees, (20, 0), (20, 10))
# Displays the result of this script
show_object(result)

View File

@@ -0,0 +1,36 @@
import cadquery as cq
# Points we will use to create spline and polyline paths to sweep over
pts = [(0, 1), (1, 2), (2, 4)]
# Spline path generated from our list of points (tuples)
path = cq.Workplane("XZ").spline(pts)
# Sweep a circle with a diameter of 1.0 units along the spline path we just created
defaultSweep = cq.Workplane("XY").circle(1.0).sweep(path)
# Sweep defaults to making a solid and not generating a Frenet solid. Setting Frenet to True helps prevent creep in
# the orientation of the profile as it is being swept
frenetShell = cq.Workplane("XY").circle(1.0).sweep(path, makeSolid=True, isFrenet=True)
# We can sweep shapes other than circles
defaultRect = cq.Workplane("XY").rect(1.0, 1.0).sweep(path)
# Switch to a polyline path, but have it use the same points as the spline
path = cq.Workplane("XZ").polyline(pts, includeCurrent=True)
# Using a polyline path leads to the resulting solid having segments rather than a single swept outer face
plineSweep = cq.Workplane("XY").circle(1.0).sweep(path)
# Switch to an arc for the path
path = cq.Workplane("XZ").threePointArc((1.0, 1.5), (0.0, 1.0))
# Use a smaller circle section so that the resulting solid looks a little nicer
arcSweep = cq.Workplane("XY").circle(0.5).sweep(path)
# Translate the resulting solids so that they do not overlap and display them left to right
show_object(defaultSweep)
show_object(frenetShell.translate((5, 0, 0)))
show_object(defaultRect.translate((10, 0, 0)))
show_object(plineSweep)
show_object(arcSweep.translate((20, 0, 0)))

View File

@@ -0,0 +1,88 @@
import cadquery as cq
# X axis line length 20.0
path = cq.Workplane("XZ").moveTo(-10, 0).lineTo(10, 0)
# Sweep a circle from diameter 2.0 to diameter 1.0 to diameter 2.0 along X axis length 10.0 + 10.0
defaultSweep = (
cq.Workplane("YZ")
.workplane(offset=-10.0)
.circle(2.0)
.workplane(offset=10.0)
.circle(1.0)
.workplane(offset=10.0)
.circle(2.0)
.sweep(path, multisection=True)
)
# We can sweep through different shapes
recttocircleSweep = (
cq.Workplane("YZ")
.workplane(offset=-10.0)
.rect(2.0, 2.0)
.workplane(offset=8.0)
.circle(1.0)
.workplane(offset=4.0)
.circle(1.0)
.workplane(offset=8.0)
.rect(2.0, 2.0)
.sweep(path, multisection=True)
)
circletorectSweep = (
cq.Workplane("YZ")
.workplane(offset=-10.0)
.circle(1.0)
.workplane(offset=7.0)
.rect(2.0, 2.0)
.workplane(offset=6.0)
.rect(2.0, 2.0)
.workplane(offset=7.0)
.circle(1.0)
.sweep(path, multisection=True)
)
# Placement of the Shape is important otherwise could produce unexpected shape
specialSweep = (
cq.Workplane("YZ")
.circle(1.0)
.workplane(offset=10.0)
.rect(2.0, 2.0)
.sweep(path, multisection=True)
)
# Switch to an arc for the path : line l=5.0 then half circle r=4.0 then line l=5.0
path = (
cq.Workplane("XZ")
.moveTo(-5, 4)
.lineTo(0, 4)
.threePointArc((4, 0), (0, -4))
.lineTo(-5, -4)
)
# Placement of different shapes should follow the path
# cylinder r=1.5 along first line
# then sweep along arc from r=1.5 to r=1.0
# then cylinder r=1.0 along last line
arcSweep = (
cq.Workplane("YZ")
.workplane(offset=-5)
.moveTo(0, 4)
.circle(1.5)
.workplane(offset=5, centerOption="CenterOfMass")
.circle(1.5)
.moveTo(0, -8)
.circle(1.0)
.workplane(offset=-5, centerOption="CenterOfMass")
.circle(1.0)
.sweep(path, multisection=True)
)
# Translate the resulting solids so that they do not overlap and display them left to right
show_object(defaultSweep)
show_object(circletorectSweep.translate((0, 5, 0)))
show_object(recttocircleSweep.translate((0, 10, 0)))
show_object(specialSweep.translate((0, 15, 0)))
show_object(arcSweep.translate((0, -5, 0)))

View File

@@ -0,0 +1,20 @@
import cadquery as cq
r = 0.5 # Radius of the helix
p = 0.4 # Pitch of the helix - vertical distance between loops
h = 2.4 # Height of the helix - total height
# Helix
wire = cq.Wire.makeHelix(pitch=p, height=h, radius=r)
helix = cq.Workplane(obj=wire)
# Final result: A 2D shape swept along a helix.
result = (
cq.Workplane("XZ") # helix is moving up the Z axis
.center(r, 0) # offset isosceles trapezoid
.polyline(((-0.15, 0.1), (0.0, 0.05), (0, 0.35), (-0.15, 0.3)))
.close() # make edges a wire
.sweep(helix, isFrenet=True) # Frenet keeps orientation as expected
)
show_object(result)

View File

@@ -0,0 +1,47 @@
import cadquery as cq
from cadquery.selectors import AreaNthSelector
case_bottom = (
cq.Workplane("XY")
.rect(20, 20)
.extrude(10) # solid 20x20x10 box
.edges("|Z or <Z")
.fillet(2) # rounding all edges except 4 edges of the top face
.faces(">Z")
.shell(2) # shell of thickness 2 with top face open
.faces(">Z")
.wires(AreaNthSelector(-1)) # selecting top outer wire
.toPending()
.workplane()
.offset2D(-1) # creating centerline wire of case seam face
.extrude(1) # covering the sell with temporary "lid"
.faces(">Z[-2]")
.wires(AreaNthSelector(0)) # selecting case crossection wire
.toPending()
.workplane()
.cutBlind(2) # cutting through the "lid" leaving a lip on case seam surface
)
# similar process repeated for the top part
# but instead of "growing" an inner lip
# material is removed inside case seam centerline
# to create an outer lip
case_top = (
cq.Workplane("XY")
.move(25)
.rect(20, 20)
.extrude(5)
.edges("|Z or >Z")
.fillet(2)
.faces("<Z")
.shell(2)
.faces("<Z")
.wires(AreaNthSelector(-1))
.toPending()
.workplane()
.offset2D(-1)
.cutBlind(-1)
)
show_object(case_bottom)
show_object(case_top, options={"alpha": 0.5})

View File

@@ -0,0 +1,70 @@
# This script can create any regular rectangular Lego(TM) Brick
import cadquery as cq
#####
# Inputs
######
lbumps = 1 # number of bumps long
wbumps = 1 # number of bumps wide
thin = True # True for thin, False for thick
#
# Lego Brick Constants-- these make a lego brick a lego :)
#
pitch = 8.0
clearance = 0.1
bumpDiam = 4.8
bumpHeight = 1.8
if thin:
height = 3.2
else:
height = 9.6
t = (pitch - (2 * clearance) - bumpDiam) / 2.0
postDiam = pitch - t # works out to 6.5
total_length = lbumps * pitch - 2.0 * clearance
total_width = wbumps * pitch - 2.0 * clearance
# make the base
s = cq.Workplane("XY").box(total_length, total_width, height)
# shell inwards not outwards
s = s.faces("<Z").shell(-1.0 * t)
# make the bumps on the top
s = (
s.faces(">Z")
.workplane()
.rarray(pitch, pitch, lbumps, wbumps, True)
.circle(bumpDiam / 2.0)
.extrude(bumpHeight)
)
# add posts on the bottom. posts are different diameter depending on geometry
# solid studs for 1 bump, tubes for multiple, none for 1x1
tmp = s.faces("<Z").workplane(invert=True)
if lbumps > 1 and wbumps > 1:
tmp = (
tmp.rarray(pitch, pitch, lbumps - 1, wbumps - 1, center=True)
.circle(postDiam / 2.0)
.circle(bumpDiam / 2.0)
.extrude(height - t)
)
elif lbumps > 1:
tmp = (
tmp.rarray(pitch, pitch, lbumps - 1, 1, center=True)
.circle(t)
.extrude(height - t)
)
elif wbumps > 1:
tmp = (
tmp.rarray(pitch, pitch, 1, wbumps - 1, center=True)
.circle(t)
.extrude(height - t)
)
else:
tmp = s
# Render the solid
show_object(tmp)

View File

@@ -0,0 +1,177 @@
from math import sin, cos, pi, sqrt
import cadquery as cq
# TEST_1
# example from PythonOCC core_geometry_geomplate.py, use of thickness = 0 returns 2D surface.
thickness = 0
edge_points = [(0.0, 0.0, 0.0), (0.0, 10.0, 0.0), (0.0, 10.0, 10.0), (0.0, 0.0, 10.0)]
surface_points = [(5.0, 5.0, 5.0)]
plate_0 = cq.Workplane("XY").interpPlate(edge_points, surface_points, thickness)
print("plate_0.val().Volume() = ", plate_0.val().Volume())
plate_0 = plate_0.translate((0, 6 * 12, 0))
show_object(plate_0)
# EXAMPLE 1
# Plate with 5 sides and 2 bumps, one side is not co-planar with the other sides
thickness = 0.1
edge_points = [
(-7.0, -7.0, 0.0),
(-3.0, -10.0, 3.0),
(7.0, -7.0, 0.0),
(7.0, 7.0, 0.0),
(-7.0, 7.0, 0.0),
]
edge_wire = cq.Workplane("XY").polyline(
[(-7.0, -7.0), (7.0, -7.0), (7.0, 7.0), (-7.0, 7.0)]
)
# edge_wire = edge_wire.add(cq.Workplane("YZ").workplane().transformed(offset=cq.Vector(0, 0, -7), rotate=cq.Vector(45, 0, 0)).polyline([(-7.,0.), (3,-3), (7.,0.)]))
# In CadQuery Sept-2019 it worked with rotate=cq.Vector(0, 45, 0). In CadQuery Dec-2019 rotate=cq.Vector(45, 0, 0) only closes the wire.
edge_wire = edge_wire.add(
cq.Workplane("YZ")
.workplane()
.transformed(offset=cq.Vector(0, 0, -7), rotate=cq.Vector(45, 0, 0))
.spline([(-7.0, 0.0), (3, -3), (7.0, 0.0)])
)
surface_points = [(-3.0, -3.0, -3.0), (3.0, 3.0, 3.0)]
plate_1 = cq.Workplane("XY").interpPlate(edge_wire, surface_points, thickness)
# plate_1 = cq.Workplane("XY").interpPlate(edge_points, surface_points, thickness) # list of (x,y,z) points instead of wires for edges
print("plate_1.val().Volume() = ", plate_1.val().Volume())
show_object(plate_1)
# EXAMPLE 2
# Embossed star, need to change optional parameters to obtain nice looking result.
r1 = 3.0
r2 = 10.0
fn = 6
thickness = 0.1
edge_points = [
(
(r1 * cos(i * pi / fn), r1 * sin(i * pi / fn))
if i % 2 == 0
else (r2 * cos(i * pi / fn), r2 * sin(i * pi / fn))
)
for i in range(2 * fn + 1)
]
edge_wire = cq.Workplane("XY").polyline(edge_points)
r2 = 4.5
surface_points = [
(r2 * cos(i * pi / fn), r2 * sin(i * pi / fn), 1.0) for i in range(2 * fn)
] + [(0.0, 0.0, -2.0)]
plate_2 = cq.Workplane("XY").interpPlate(
edge_wire,
surface_points,
thickness,
combine=True,
clean=True,
degree=3,
nbPtsOnCur=15,
nbIter=2,
anisotropy=False,
tol2d=0.00001,
tol3d=0.0001,
tolAng=0.01,
tolCurv=0.1,
maxDeg=8,
maxSegments=49,
)
# plate_2 = cq.Workplane("XY").interpPlate(edge_points, surface_points, thickness, combine=True, clean=True, Degree=3, NbPtsOnCur=15, NbIter=2, Anisotropie=False, Tol2d=0.00001, Tol3d=0.0001, TolAng=0.01, TolCurv=0.1, MaxDeg=8, MaxSegments=49) # list of (x,y,z) points instead of wires for edges
print("plate_2.val().Volume() = ", plate_2.val().Volume())
plate_2 = plate_2.translate((0, 2 * 12, 0))
show_object(plate_2)
# EXAMPLE 3
# Points on hexagonal pattern coordinates, use of pushpoints.
r1 = 1.0
N = 3
ca = cos(30.0 * pi / 180.0)
sa = sin(30.0 * pi / 180.0)
# EVEN ROWS
pts = [
(-3.0, -3.0),
(-1.267949, -3.0),
(0.464102, -3.0),
(2.196152, -3.0),
(-3.0, 0.0),
(-1.267949, 0.0),
(0.464102, 0.0),
(2.196152, 0.0),
(-2.133974, -1.5),
(-0.401923, -1.5),
(1.330127, -1.5),
(3.062178, -1.5),
(-2.133975, 1.5),
(-0.401924, 1.5),
(1.330127, 1.5),
(3.062178, 1.5),
]
# Spike surface
thickness = 0.1
fn = 6
edge_points = [
(
r1 * cos(i * 2 * pi / fn + 30 * pi / 180),
r1 * sin(i * 2 * pi / fn + 30 * pi / 180),
)
for i in range(fn + 1)
]
surface_points = [
(
r1 / 4 * cos(i * 2 * pi / fn + 30 * pi / 180),
r1 / 4 * sin(i * 2 * pi / fn + 30 * pi / 180),
0.75,
)
for i in range(fn + 1)
] + [(0, 0, 2)]
edge_wire = cq.Workplane("XY").polyline(edge_points)
plate_3 = (
cq.Workplane("XY")
.pushPoints(pts)
.interpPlate(
edge_wire,
surface_points,
thickness,
combine=False,
clean=False,
degree=2,
nbPtsOnCur=20,
nbIter=2,
anisotropy=False,
tol2d=0.00001,
tol3d=0.0001,
tolAng=0.01,
tolCurv=0.1,
maxDeg=8,
maxSegments=9,
)
)
print("plate_3.val().Volume() = ", plate_3.val().Volume())
plate_3 = plate_3.translate((0, 4 * 11, 0))
show_object(plate_3)
# EXAMPLE 4
# Gyroïd, all edges are splines on different workplanes.
thickness = 0.1
edge_points = [
[[3.54, 3.54], [1.77, 0.0], [3.54, -3.54]],
[[-3.54, -3.54], [0.0, -1.77], [3.54, -3.54]],
[[-3.54, -3.54], [0.0, -1.77], [3.54, -3.54]],
[[-3.54, -3.54], [-1.77, 0.0], [-3.54, 3.54]],
[[3.54, 3.54], [0.0, 1.77], [-3.54, 3.54]],
[[3.54, 3.54], [0.0, 1.77], [-3.54, 3.54]],
]
plane_list = ["XZ", "XY", "YZ", "XZ", "YZ", "XY"]
offset_list = [-3.54, 3.54, 3.54, 3.54, -3.54, -3.54]
edge_wire = (
cq.Workplane(plane_list[0]).workplane(offset=-offset_list[0]).spline(edge_points[0])
)
for i in range(len(edge_points) - 1):
edge_wire = edge_wire.add(
cq.Workplane(plane_list[i + 1])
.workplane(offset=-offset_list[i + 1])
.spline(edge_points[i + 1])
)
surface_points = [(0, 0, 0)]
plate_4 = cq.Workplane("XY").interpPlate(edge_wire, surface_points, thickness)
print("plate_4.val().Volume() = ", plate_4.val().Volume())
plate_4 = plate_4.translate((0, 5 * 12, 0))
show_object(plate_4)

View File

@@ -0,0 +1,29 @@
@echo off
where python >NUL 2>NUL
if %ERRORLEVEL%==0 (
echo Python is already installed.
exit /B 0
)
echo Python not found. Attempting installation...
where choco >NUL 2>NUL
if %ERRORLEVEL%==0 (
choco install -y python
) else (
where winget >NUL 2>NUL
if %ERRORLEVEL%==0 (
winget install -e --id Python.Python.3
) else (
echo No supported package manager found. Please install Python manually.
exit /B 1
)
)
where python >NUL 2>NUL
if %ERRORLEVEL%==0 (
echo Python installed successfully.
exit /B 0
) else (
echo Python installation failed. Please install manually.
exit /B 1
)

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -e
if command -v python3 >/dev/null 2>&1; then
echo "Python is already installed." && exit 0
fi
echo "Python not found. Attempting installation..."
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update && sudo apt-get install -y python3 python3-pip
elif command -v yum >/dev/null 2>&1; then
sudo yum install -y python3 python3-pip
elif command -v brew >/dev/null 2>&1; then
brew install python
else
echo "Unsupported package manager. Please install Python manually." >&2
exit 1
fi
if ! command -v python >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
sudo ln -s "$(command -v python3)" /usr/local/bin/python || true
fi
echo "Python installed successfully."

View File

@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8

View File

@@ -0,0 +1,2 @@
cadquery>=2.5
trimesh>=4

20
cadquerywrapper/setup.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -e
case "$(uname)" in
Linux*) script="setup_linux.sh" ;;
Darwin*) script="setup_mac.sh" ;;
MINGW*|MSYS*|CYGWIN*) script="setup_windows.ps1" ;;
*) echo "Unsupported OS: $(uname)" && exit 1 ;;
esac
if [ "$script" = "setup_windows.ps1" ]; then
if command -v pwsh >/dev/null 2>&1; then
pwsh "$script"
else
powershell.exe -ExecutionPolicy Bypass -File "$script"
fi
else
bash "$script"
fi

35
cadquerywrapper/setup_linux.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
set -e
echo "🔍 Checking Python 3.11+..."
if ! python3 --version | grep -q "3.11"; then
echo "Installing Python 3.11..."
if command -v apt &>/dev/null; then
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3.11-dev
sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
elif command -v dnf &>/dev/null; then
sudo dnf install -y python3.11
elif command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm python
else
echo "❌ Unsupported Linux package manager."
exit 1
fi
fi
echo "✅ Python version: $(python3 --version)"
echo "🔍 Checking for Poetry..."
if ! command -v poetry &>/dev/null; then
echo "📦 Installing Poetry..."
curl -sSL https://install.python-poetry.org | python3 -
export PATH="$HOME/.local/bin:$PATH"
else
echo "✅ Poetry is installed: $(poetry --version)"
fi
echo "📦 Installing dependencies..."
poetry install
echo "✅ Linux setup complete. Use 'poetry shell' to activate environment."

30
cadquerywrapper/setup_mac.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
set -e
echo "🔍 Checking Python 3.11+..."
if ! python3 --version | grep -q "3.11"; then
echo "Installing Python 3.11 using Homebrew..."
if ! command -v brew &>/dev/null; then
echo "📦 Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
export PATH="/opt/homebrew/bin:$PATH"
fi
brew install python@3.11
brew link python@3.11 --force
fi
echo "✅ Python version: $(python3 --version)"
echo "🔍 Checking for Poetry..."
if ! command -v poetry &>/dev/null; then
echo "📦 Installing Poetry..."
curl -sSL https://install.python-poetry.org | python3 -
export PATH="$HOME/.local/bin:$PATH"
else
echo "✅ Poetry is installed: $(poetry --version)"
fi
echo "📦 Installing dependencies..."
poetry install
echo "✅ macOS setup complete. Use 'poetry shell' to activate environment."

View File

@@ -0,0 +1,35 @@
# PowerShell script
Write-Host "🔍 Checking for Chocolatey..."
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Host "📦 Installing Chocolatey..."
Set-ExecutionPolicy Bypass -Scope Process -Force
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
}
Write-Host "📦 Updating Chocolatey..."
choco upgrade chocolatey -y
Write-Host "📦 Installing build tools..."
choco install python --version=3.11.5 -y
choco install visualstudio2022buildtools --package-parameters '--add Microsoft.VisualStudio.Workload.VCTools --includeRecommended' -y
choco install windows-sdk-10.0 -y
choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System' -y
choco install ninja -y
choco install make -y
choco install git -y
choco install openssl.light -y
Write-Host "✅ Python version: $(python --version)"
Write-Host "🔍 Checking for Poetry..."
if (-not (Get-Command poetry -ErrorAction SilentlyContinue)) {
Write-Host "📦 Installing Poetry..."
(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | python -
$env:Path += ';' + $env:USERPROFILE + '\.poetry\bin'
}
Write-Host "📦 Installing dependencies..."
poetry install
Write-Host "✅ Windows setup complete. Run 'poetry shell' to activate environment."

View File

@@ -0,0 +1,331 @@
import importlib
import sys
import types
from pathlib import Path
import trimesh
import pytest
import math
# Provide stub cadquery module before importing package modules
_dummy_cq = types.ModuleType("cadquery")
_dummy_cq.export_calls = []
def _export(obj, *args, **kwargs):
_dummy_cq.export_calls.append((obj, args, kwargs))
_dummy_cq.export = _export
_dummy_cq.exporters = types.SimpleNamespace()
_dummy_cq.exporters.calls = []
def _exporter(obj, *args, **kwargs):
_dummy_cq.exporters.calls.append((obj, args, kwargs))
_dummy_cq.exporters.export = _exporter
class DummyShape:
def __init__(self):
self.called = []
self.valid = True
self.closed = True
self._open_edges = False
self.will_intersect = False
def exportStl(self, *args, **kwargs):
self.called.append(("exportStl", args, kwargs))
def exportStep(self, *args, **kwargs):
self.called.append(("exportStep", args, kwargs))
def exportBin(self, *args, **kwargs):
self.called.append(("exportBin", args, kwargs))
def exportBrep(self, *args, **kwargs):
self.called.append(("exportBrep", args, kwargs))
def isValid(self):
return self.valid
def isClosed(self):
return self.closed
def hasOpenEdges(self):
return self._open_edges
class _IntersectResult:
def __init__(self, has_volume: bool):
self._has_volume = has_volume
def isNull(self):
return not self._has_volume
def Volume(self):
return 1 if self._has_volume else 0
def intersect(self, other):
has_vol = self.will_intersect and getattr(other, "will_intersect", False)
return self._IntersectResult(has_vol)
class DummyAssembly:
def __init__(self, solids=None):
self.called = []
self._solids = solids or []
def export(self, *args, **kwargs):
self.called.append(("export", args, kwargs))
def save(self, *args, **kwargs):
self.called.append(("save", args, kwargs))
def solids(self):
return self._solids
_dummy_cq.Shape = DummyShape
_dummy_cq.Assembly = DummyAssembly
class DummyBBoxShape(DummyShape):
def __init__(self, x: float, y: float, z: float):
super().__init__()
self._bbox = types.SimpleNamespace(xlen=x, ylen=y, zlen=z)
def val(self):
return self
def BoundingBox(self):
return self._bbox
class SphereShape(DummyShape):
def __init__(self, subdivisions: int):
super().__init__()
self.subdivisions = subdivisions
def exportStl(self, file_name: str, *args, **kwargs):
super().exportStl(file_name, *args, **kwargs)
mesh = trimesh.creation.icosphere(subdivisions=self.subdivisions)
mesh.export(file_name)
class NonManifoldShape(DummyShape):
def __init__(self, valid: bool = True, closed: bool = True):
super().__init__()
self.valid = valid
self.closed = closed
class OpenEdgeShape(DummyShape):
def __init__(self):
super().__init__()
self._open_edges = True
class IntersectSolid(DummyShape):
def __init__(self, will_intersect: bool = True):
super().__init__()
self.will_intersect = will_intersect
class ClearanceSolid(DummyShape):
def __init__(self, distance: float):
super().__init__()
self.distance = distance
def distTo(self, other): # noqa: D401 - simple distance stub
return self.distance
class _OverhangFace:
def __init__(self, angle: float):
self._angle = angle
def normalAt(self):
rad = math.radians(self._angle)
return (math.sin(rad), 0.0, math.cos(rad))
class OverhangShape(DummyShape):
def __init__(self, angles: list[float]):
super().__init__()
self._angles = angles
def faces(self): # noqa: D401 - returns dummy faces
return [_OverhangFace(a) for a in self._angles]
sys.modules.setdefault("cadquery", _dummy_cq)
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from cadquerywrapper.validator import Validator, load_rules, validate
from cadquerywrapper.save_validator import SaveValidator, ValidationError
from cadquerywrapper.project import CadQueryWrapper
RULES_PATH = Path("cadquerywrapper/rules/bambu_printability_rules.json")
def test_load_rules():
rules = load_rules(RULES_PATH)
assert rules["printer"] == "Bambu Labs"
def test_validate_errors():
rules = {"rules": {"minimum_wall_thickness_mm": 0.8}}
model = {"minimum_wall_thickness_mm": 0.5}
errors = validate(model, rules)
assert errors == ["Minimum wall thickness mm 0.5 is below minimum 0.8"]
def test_validator_from_file():
validator = Validator(RULES_PATH)
assert "minimum_wall_thickness_mm" in validator.rules["rules"]
def test_validator_validate_raises():
validator = Validator({"rules": {"minimum_wall_thickness_mm": 0.8}})
with pytest.raises(ValidationError):
validator.validate({"minimum_wall_thickness_mm": 0.5})
def test_save_validator_delegates_and_validates():
obj = DummyShape()
sv = SaveValidator(RULES_PATH, obj)
sv.export(obj)
assert _dummy_cq.exporters.calls[-1][0] is obj
def test_save_validator_invalid_raises():
sv = SaveValidator(RULES_PATH)
obj = DummyShape()
SaveValidator.attach_model(obj, {"minimum_wall_thickness_mm": 0.1})
with pytest.raises(ValidationError):
sv.export_stl(obj, "out.stl")
def test_wrapper_delegates_and_validates():
workplane = DummyShape()
wrapper = CadQueryWrapper(RULES_PATH, workplane)
# workplane already has an attached empty model
wrapper.export_stl()
assert workplane.called[-1][0] == "exportStl"
def test_wrapper_invalid_raises():
workplane = DummyShape()
wrapper = CadQueryWrapper(RULES_PATH, workplane)
CadQueryWrapper.attach_model(workplane, {"minimum_wall_thickness_mm": 0.1})
with pytest.raises(ValidationError):
wrapper.export_stl()
def test_wrapper_validate_no_args():
workplane = DummyShape()
wrapper = CadQueryWrapper(RULES_PATH, workplane)
CadQueryWrapper.attach_model(workplane, {"minimum_wall_thickness_mm": 0.5})
with pytest.raises(ValidationError):
wrapper.validate()
CadQueryWrapper.attach_model(workplane, {"minimum_wall_thickness_mm": 0.9})
wrapper.validate()
def test_validate_max_model_size_dict():
rules = {"rules": {"max_model_size_mm": {"X": 1, "Y": 1, "Z": 1}}}
model = {"max_model_size_mm": {"X": 2, "Y": 0.5, "Z": 0.5}}
errors = validate(model, rules)
assert errors == ["Model size X 2 exceeds maximum 1"]
def test_save_validator_model_too_large():
rules = {"rules": {"max_model_size_mm": {"X": 1, "Y": 1, "Z": 1}}}
obj = DummyBBoxShape(2, 0.5, 0.5)
sv = SaveValidator(rules, obj)
with pytest.raises(ValidationError):
sv.export_stl(obj, "out.stl")
def test_save_validator_triangle_count(tmp_path):
rules = {"rules": {"maximum_file_triangle_count": 100}}
shape = SphereShape(subdivisions=3)
sv = SaveValidator(rules, shape)
file_name = tmp_path / "sphere.stl"
with pytest.raises(ValidationError):
sv.export_stl(shape, file_name)
def test_save_validator_manifold_required():
rules = {"rules": {"manifold_geometry_required": True}}
shape = NonManifoldShape(valid=False)
sv = SaveValidator(rules, shape)
with pytest.raises(ValidationError):
sv.export_stl(shape, "out.stl")
def test_save_validator_open_edges():
rules = {"rules": {"no_open_edges": True}}
shape = OpenEdgeShape()
sv = SaveValidator(rules, shape)
with pytest.raises(ValidationError):
sv.export_stl(shape, "out.stl")
def test_save_validator_intersections():
rules = {"rules": {"no_intersecting_geometry": True}}
solid1 = IntersectSolid()
solid2 = IntersectSolid()
assembly = DummyAssembly([solid1, solid2])
sv = SaveValidator(rules, assembly)
with pytest.raises(ValidationError):
sv.assembly_save(assembly)
def test_save_validator_disallowed_format():
rules = {
"rules": {
"preferred_file_format": "STL",
"alternate_file_formats": ["3MF", "OBJ"],
}
}
sv = SaveValidator(rules)
shape = DummyShape()
with pytest.raises(ValidationError):
sv.export_stl(shape, "out.step")
def test_save_validator_disallowed_format_export():
rules = {
"rules": {
"preferred_file_format": "STL",
"alternate_file_formats": ["OBJ"],
}
}
sv = SaveValidator(rules)
obj = DummyShape()
with pytest.raises(ValidationError):
sv.export(obj, "model.step")
def test_save_validator_minimum_clearance():
rules = {"rules": {"minimum_clearance_between_parts_mm": 0.3}}
s1 = ClearanceSolid(0.2)
s2 = ClearanceSolid(0.5)
assembly = DummyAssembly([s1, s2])
sv = SaveValidator(rules, assembly)
with pytest.raises(ValidationError):
sv.assembly_save(assembly)
def test_save_validator_overhang_angle():
rules = {"rules": {"overhang_max_angle_deg": 45}}
shape = OverhangShape([30, 50])
sv = SaveValidator(rules, shape)
with pytest.raises(ValidationError):
sv.export_stl(shape, "out.stl")