diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9475a83 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Core library code lives in `src/yapcad`, with geometry primitives (`geom.py`, `geom3d.py`), boolean ops (`boolean/`), IO adapters (`io/`), and render helpers (`drawable.py`, `ezdxf_drawable.py`). Vendored helpers live under `src/yapcad/contrib`, with corresponding license notices in `third_party/`. PyPI metadata sits alongside in `pyproject.toml` and `setup.cfg`. Automated tests are under `tests/`, mirroring the module names (e.g., `test_geom3d.py`). Sphinx docs reside in `docs/`, while `examples/`, `dxf/`, and `images/` provide sample CAD assets for manual inspection. + +## Setup, Build & Test Commands +Create a local environment with: +```bash +python -m venv .venv && source .venv/bin/activate +pip install -e .[tests] +``` +Run the full suite (includes coverage) via `pytest`. Targeted runs, such as `pytest tests/test_geom3d.py`, keep iteration fast. Update geometry visual baselines with `python run_visual_tests.py --update`, then review artifacts in `build/`. Build the distribution wheel with `python -m build` and documentation with `sphinx-build docs build/sphinx`. + +## Coding Style & Naming Conventions +Follow PEP 8 with 4-space indentation and descriptive, lowercase module names. Public API classes use `CamelCase`, functions and variables stay `snake_case`. Prefer explicit imports from sibling modules. Keep geometry helper names aligned with their dimensionality (e.g., `*_3d`). Run `flake8` when touching complex files to stay consistent with `setup.cfg`. + +## Testing Guidelines +pytest discovers files named `test_*.py` inside `tests/`; mirror production module names to keep coverage readable. Maintain coverage at or above the default `--cov yapcad --cov-report term-missing` threshold before opening PRs. Use parametrized tests for shape variants and add integration checks for new IO handlers in `tests/test_io_*.py`. When adding CAD fixtures, store them under `examples/` and reference them relative to the repo root. + +## Commit & Pull Request Guidelines +Commit messages follow an imperative summary (`Add 3D sweep helper`), optionally tagging release steps (`Prepare v0.6.0 release`). Squash noisy WIP commits locally. For pull requests, include: purpose summary, testing commands executed, links to relevant issues, and before/after renders if geometry changes affect visuals. Ensure CI (pytest + docs) passes prior to requesting review and note any follow-up tasks explicitly. diff --git a/README.md b/README.md index 0efbab1..800cf22 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,18 @@ STL for downstream slicing and simulation. You can still use experiments, but the core is increasingly optimised for 3D generative design. -## software status +## software status (October 2025) -**yapCAD** is in **active development** and is already being used for -professional engineering purposes. Recent improvements include robust 3D -boolean operations (union, intersection, difference) with proper normal -orientation and degenerate triangle filtering. The 0.5.x series focuses on -production-ready 3D workflows with validated STL and STEP export. +**yapCAD** is in **active development** and already powers production design +pipelines. Highlights from the 0.5.x cycle include: + +- `.ycpkg` project packaging with manifest, geometry JSON, exports, and metadata. +- CLI helpers (`tools/ycpkg_validate.py`, `tools/ycpkg_export.py`) for validation and DXF/STEP/STL export. +- Robust 3D boolean operations with validated tessellation and mesh diagnostics. +- Native sketch primitives (lines, arcs, splines) preserved through package round-trips and exported to DXF as analytic entities. +- Regression-tested spline extrusion flow (`tests/test_splines.py`) covering STEP/STL/DXF export. + +Upcoming work (tracked in `docs/yapCADone.md` and `docs/yapBREP.md`) focuses on the parametric DSL/compiler, validation execution layer, provenance signatures, STEP/STL import, and analytic BREP/STEP export. If you are using **yapCAD** in interesting ways, feel free to let us know in the [**yapCAD** discussions](https://github.com/rdevaul/yapCAD/discussions) @@ -46,47 +51,30 @@ You can also clone the github repository and install from source: ### examples -The **yapCAD** github repository includes examples. To run the -examples, clone the github repository as shown above, and make sure -that your PYTHONPATH includes the cloned top-level `yapCAD` directory. -You will find the examples in the `yapCAD/examples` directory. - -For a fully worked 2D parametric design system, see the `boxcut` example. For a -3D generative example that builds a multi-stage rocket, visualises it, and -exports STL, see `examples/rocket_demo.py`. **NOTE** The 3D rocket example code -was generated in one shot by `gpt-5-codex` from the following prompt: +Clone the repository (or install the package) and ensure `PYTHONPATH` contains the +top-level `src` directory. Example entry points: - Using what you know about yapCAD, I'd like you to create a demo that builds a simple 3D model of a rocket, visualizes it using pyglet, and then writes out the STL file. I'd like the rocket to have a cluster of five engines, guidance fins, a cylindrical body with at least one diameter transition before the payload fairing, and an aerodynamic fairing. Can you do this for me? +- `examples/boxcut` – fully parametric 2D joinery workflow (DXF output). +- `examples/rocket_demo.py` – generative multi-stage rocket with viewer + STL export. +- `examples/rocket_cutaway_internal.py` – layout/cutaway helper demo exporting STEP (screenshot below). +- `examples/involute_gear_package/` – canonical gear library packaged as `.ycpkg` and reused by assemblies. ![**yapCAD** rocket cutaway STEP export](images/RocketCutawaySTEP.png) -To see how the newer helper utilities can be combined to lay out internal -subsystems and export STEP, try `examples/rocket_cutaway_internal.py` — the -screenshot above shows its STEP output rendered in FreeCAD. +Several demos were authored with LLM assistance to illustrate automation-friendly workflows. ### documentation Online **yapCAD** documentation can be found here: -https://yapcad.readthedocs.io/en/latest/ — some module references -lag behind the current 3D-focused APIs, so you may want to build a -local copy (see below) to explore the latest `geometry_utils`, -`geometry_checks`, `metadata`, and `io` modules. Recent additions worth -calling out include: - -- `yapcad.geometry_utils` & `yapcad.triangulator` – robust triangle - utilities used by the ear-cut tessellator and STEP exporter. -- `yapcad.geom3d_util.stack_solids` – a convenience routine that packs - solids along an axis using bounding boxes and optional spacing - directives (used by the rocket cutaway demo). -- `yapcad.geom3d_util.cutaway_solid_x` – simple clipping helper for - creating sectional views of assemblies. -- `yapcad.io.step`/`yapcad.io.stl` – production-ready faceted exporters suitable for - interchange with FreeCAD, slicers, and other simulation tools. STEP export supports - multi-component assemblies with proper face orientation. -- `tools/validate_mesh.py` – CLI helper that runs `admesh`, `meshfix`, and an - optional slicer to gauge whether STL output is robust enough for CAM; see - `docs/mesh_validation.md` for usage. +https://yapcad.readthedocs.io/en/latest/ — key documents include: + +- `docs/ycpkg_spec.md` – `.ycpkg` manifest schema, packaging workflow, CLI usage. +- `docs/yapBREP.md` – analytic STEP/BREP upgrade roadmap. +- `docs/dsl_spec.md` – parametric DSL and validation plans. +- Module references for `yapcad.io`, `yapcad.geom3d_util`, `yapcad.geometry_utils`, + and `yapcad.metadata`. +- Mesh validation workflow (`docs/mesh_validation.md`, `tools/validate_mesh.py`). To build the HTML **yapCAD** documentation locally, install the documentation dependencies and run Sphinx from the project root: @@ -102,6 +90,31 @@ supported by Sphinx. See the [Sphinx documentation](https://www.sphinx-doc.org/en/master/) for more information. +### project packages & CLI + +The preferred interchange format is the `.ycpkg` project package: + +``` +my_design.ycpkg/ +├── manifest.yaml +├── geometry/primary.json +├── exports/ +├── validation/ +└── ... +``` + +Helper commands: + +```bash +# Validate package structure / hashes +python tools/ycpkg_validate.py path/to/design.ycpkg + +# Export STEP/STL/DXF from a package +python tools/ycpkg_export.py path/to/design.ycpkg --format step --format stl --output exports/ +``` + +See [`docs/ycpkg_spec.md`](docs/ycpkg_spec.md) for the manifest schema and workflow details. + ### running tests The repository includes a comprehensive pytest suite that exercises both core @@ -513,6 +526,13 @@ So, if you want to do simple 2D drawings, we have you covered. If you want to build a GPU-accelerated constructive solid geometry system, you can do that, too. +## Third-party credits + +The involute gear helper is derived from the MIT-licensed +[figgear](https://github.com/chromia/figgear) project. The vendored implementation +lives in `yapcad.contrib.figgear`, and the original license text is preserved in +`third_party/figgear_LICENSE`. + ## Note This project has been set up using PyScaffold 3.2.3. For details and usage diff --git a/README.rst b/README.rst index 43dd246..174dffc 100644 --- a/README.rst +++ b/README.rst @@ -11,15 +11,9 @@ python 3, now with a growing focus on 3D generative design and STL export .. note:: - The 3D rocket demo above was produced in a single shot by - ``gpt-5-codex`` from the prompt:: - - Using what you know about yapCAD, I'd like you to create a demo that - builds a simple 3D model of a rocket, visualizes it using pyglet, and - then writes out the STL file. I'd like the rocket to have a cluster of - five engines, guidance fins, a cylindrical body with at least one - diameter transition before the payload fairing, and an aerodynamic - fairing. Can you do this for me? + Many examples were authored with LLM assistance to illustrate + automation-friendly workflows, but the code lives in the repository and can + be customised directly or via upcoming DSL tooling. .. figure:: images/RocketCutawaySTEP.png :alt: **yapCAD** rocket cutaway STEP export @@ -39,14 +33,19 @@ systems. Starting with the 0.5 release, the emphasis has shifted toward simulation, while retaining support for DXF generation and computational geometry experiments. -software status ---------------- +software status (October 2025) +------------------------------ + +**yapCAD** is in **active development** and already powers production design +pipelines. Highlights from the 0.5.x cycle include: -**yapCAD** is in **active development** and is already being used for -professional engineering purposes. Recent improvements include robust 3D -boolean operations (union, intersection, difference) with proper normal -orientation and degenerate triangle filtering. The 0.5.x series focuses on -production-ready 3D workflows with validated STL and STEP export. +* ``.ycpkg`` project packaging with manifest, geometry JSON, exports, and metadata. +* CLI helpers (``tools/ycpkg_validate.py``, ``tools/ycpkg_export.py``) for validation and DXF/STEP/STL export. +* Robust 3D boolean operations with validated tessellation and mesh diagnostics. +* Native sketch primitives (lines, arcs, splines) preserved through package round-trips and exported to DXF as analytic entities. +* Regression-tested spline extrusion flow (``tests/test_splines.py``) covering STEP/STL/DXF export. + +Upcoming work (tracked in ``docs/yapCADone.md`` and ``docs/yapBREP.md``) focuses on the parametric DSL/compiler, validation execution layer, provenance signatures, STEP/STL import, and analytic BREP/STEP support. If you are using **yapCAD** in interesting ways, feel free to let us know in the `yapCAD discussions `__ forum @@ -77,36 +76,24 @@ You can also clone the github repository and install from source: examples ~~~~~~~~ -The **yapCAD** github repository includes examples. To run the examples, -clone the github repository as shown above, and make sure that your -PYTHONPATH includes the cloned top-level ``yapCAD`` directory. You will -find the examples in the ``yapCAD/examples`` directory. +Clone the repository (or install the package) and ensure ``PYTHONPATH`` includes the +top-level ``src`` directory. Example entry points: -For a fully worked 2D parametric design system, see the ``boxcut`` example. -For a 3D generative example that builds a multi-stage rocket, visualises it, -and exports STL, see ``examples/rocket_demo.py``. To explore the new stacking -and cutaway helpers while exporting STEP, run -``examples/rocket_cutaway_internal.py`` whose output is shown above. +* ``examples/boxcut`` – parametric 2D joinery workflow (DXF output). +* ``examples/rocket_demo.py`` – generative multi-stage rocket with viewer + STL export. +* ``examples/rocket_cutaway_internal.py`` – subsystem layout/cutaway demo exporting STEP. +* ``examples/involute_gear_package`` – canonical gear library packaged as ``.ycpkg`` and reused by assemblies. documentation ~~~~~~~~~~~~~ -Online **yapCAD** documentation can be found here: -https://yapcad.readthedocs.io/en/latest/ — some module references lag -behind the latest 3D-focused APIs, so you may want to build a local copy -as described below to explore ``geometry_utils``, ``geometry_checks``, -``metadata``, and the ``yapcad.io`` exporters. Highlights from the most -recent updates include: - -* ``yapcad.geometry_utils`` and ``yapcad.triangulator`` – triangle helpers - backing the ear-cut tessellator and faceted exporters. -* ``yapcad.geom3d_util.stack_solids`` – quickly pack solids along an axis - using bounding boxes and optional ``space:`` directives. -* ``yapcad.geom3d_util.cutaway_solid_x`` – trim solids against a plane to - create sectional visualisations. -* ``yapcad.io.step``/``yapcad.io.stl`` – production-ready faceted exporters - suitable for interchange with FreeCAD, slicers, and other simulation tools. - STEP export supports multi-component assemblies with proper face orientation. +Online **yapCAD** documentation is available at https://yapcad.readthedocs.io/en/latest/ — key references: + +* ``docs/ycpkg_spec.md`` – ``.ycpkg`` manifest schema, packaging workflow, CLI usage. +* ``docs/yapBREP.md`` – analytic STEP/BREP roadmap. +* ``docs/dsl_spec.md`` – parametric DSL and validation plans. +* Module references for ``yapcad.io``, ``yapcad.geom3d_util``, ``yapcad.geometry_utils``, and ``yapcad.metadata``. +* Mesh validation workflow (``docs/mesh_validation.md``, ``tools/validate_mesh.py``). To build the HTML **yapCAD** documentation locally, install the documentation dependencies and run Sphinx from the project root:: @@ -120,6 +107,28 @@ supported by Sphinx. See the `Sphinx documentation `__ for more information. +project packages & CLI +~~~~~~~~~~~~~~~~~~~~~~ + +The preferred interchange format is the ``.ycpkg`` project package:: + + my_design.ycpkg/ + ├── manifest.yaml + ├── geometry/primary.json + ├── exports/ + ├── validation/ + └── ... + +Helper commands:: + + # Validate package structure / hashes + python tools/ycpkg_validate.py path/to/design.ycpkg + + # Export STEP/STL/DXF from a package + python tools/ycpkg_export.py path/to/design.ycpkg --format step --format stl --output exports/ + +See ``docs/ycpkg_spec.md`` for details. + running tests ~~~~~~~~~~~~~ @@ -544,6 +553,14 @@ So, if you want to do simple 2D drawings, we have you covered. If you want to build a GPU-accelerated constructive solid geometry system, you can do that, too. +Third-party credits +------------------- + +The involute gear helper is derived from the MIT-licensed +`figgear `__ project. The vendored +implementation lives in ``yapcad.contrib.figgear`` and its original +license text is preserved in ``third_party/figgear_LICENSE``. + Note ---- diff --git a/docs/conf.py b/docs/conf.py index d9247dd..ac3932d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -155,10 +155,13 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -html_theme_options = { - 'sidebar_width': '300px', - 'page_width': '1200px' -} +if html_theme == 'alabaster': + html_theme_options = { + 'sidebar_width': '300px', + 'page_width': '1200px' + } +else: + html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] diff --git a/docs/dsl_spec.md b/docs/dsl_spec.md new file mode 100644 index 0000000..9eb0a35 --- /dev/null +++ b/docs/dsl_spec.md @@ -0,0 +1,165 @@ +# yapCAD DSL Specification (Draft) + +**Version:** `yapdsl-v0.1` +**Status:** Proposal – intent for next roadmap milestone + +This document outlines an imperative, statically-checkable DSL for authoring reusable yapCAD modules (e.g., involute gears, fastener libraries). The DSL captures command definitions, parameter validation, canonical geometry generation, and metadata emission while allowing controlled fallbacks to Python for advanced logic. See `examples/involute_gear_package/src/involute_gear.dsl` for the draft module referenced below. + +--- + +## 1. Design Goals + +- **Deterministic & Auditable**: Commands produce canonical geometry entities with recorded parameters, package versions, and provenance metadata. +- **Composable**: Packages can consume other packages' DSL exports (e.g., a fastener package calling an involute gear package). +- **Static Validation**: Type hints, `require` constraints, and strongly typed geometry primitives catch mistakes before execution. +- **Python Escape Hatch**: Optional `python {}` blocks allow full expressiveness when needed but are marked as “untrusted” unless signed. +- **Instance Reuse**: DSL commands may register canonical entities once and refer to them via `manifest.instances` to avoid duplication (key for BOMs). + +--- + +## 2. Module Structure + +``` +module involute_gear; + +use math; +use fasteners.metric as metric; + +command INVOLUTE_SPUR( + teeth: int, + module_mm: float, + face_width_mm: float, + pressure_angle_deg: float = 20.0 +) -> solid { + require teeth >= 6; + require module_mm > 0; + + let profile: polygon2d = involute_profile(teeth, module_mm, pressure_angle_deg); + let blank: solid = extrude(profile, face_width_mm); + + emit blank with { + layer: "gear", + derived2d: profile, + metadata: { + "application": "INVOLUTE_SPUR" + } + }; +} + +command INVOLUTE_SPUR2D(...) -> polygon2d { + ... +} +``` + +### 2.1 Module Declarations +- `module ;` defines the namespace (used when other packages reference commands). +- `use` statements import other modules or standard libraries (`math`, `fasteners.metric`, etc.). + +### 2.2 Command Signature +- `command NAME(param: type = default, ...) -> return_type { ... }` +- Supported primitive types: `int`, `float`, `string`, `bool`, `polygon2d`, `solid`, `sketch`, `transform`, `list`, `dict`. +- DSL should allow type aliases and user-defined structs in later versions. + +### 2.3 Statements +- `let` binds immutable values. +- `require` enforces preconditions; failures raise validation errors during assembly. +- `emit with { ... }` returns geometry plus extra metadata. + * `layer`: overrides default metadata layer. + * `derived2d`: optional 2D geometry reference (stored under `geometry/derived/`). + * `metadata`: free-form JSON object merged into entity metadata. + * `register`: optionally register canonical entities (see §3.4). + +--- + +## 3. Semantics & Runtime + +### 3.1 Parameter Hashing & Invocation Metadata +- Each command invocation records: + ``` + metadata.invocation = { + package: "involute_gear", + command: "INVOLUTE_SPUR", + version: "0.2.0", + parameters: {...}, + sourceSignature: "sha256:…" + } + ``` +- Serialised geometry must include this block so downstream assemblies know which DSL command produced it. + +### 3.2 Canonical Entities & Instance Registration +- DSL commands can call `register_instance(name: string, entity: solid|sketch, key: dict)` to write canonical geometry under `geometry/entities/`. +- `manifest.instances` references canonical entities via `entity: geometry/entities/.json`, plus transforms or counts. +- Assembly commands refer to existing canonical entities via `use_instance(name, transform)` to avoid duplication (key for fastener arrays, gears, etc.). + +### 3.3 Fallback to Python + +``` +command CUSTOM_PROFILE(config: dict) -> polygon2d { + python { + from scripts.custom_helpers import make_profile + profile = make_profile(config) + } + emit profile with { layer: "custom" }; +} +``` + +- Python blocks execute inside a controlled runtime (package `scripts/`). +- Metadata should capture `invocation["python"]["module"]` and hash of helper script. +- Unsigned Python blocks may require manual approval before packaging. + +### 3.4 Metadata Helpers +- DSL should allow simple metadata tags: + ``` + emit blank with { + layer: "structure", + tags: ["gear", "m2"], + derived2d: profile + }; + ``` +- Layers default to `"default"` if unspecified; DSL authors can set more meaningful names. + +--- + +## 4. Grammar Sketch (v0.1) + +``` +module_decl ::= "module" IDENT ";" +use_stmt ::= "use" IDENT ( "." IDENT )* ";" +command_decl ::= "command" IDENT "(" params? ")" "->" type block +params ::= param ( "," param )* +param ::= IDENT ":" type ( "=" expr )? +block ::= "{" stmt* "}" +stmt ::= let_stmt | require_stmt | emit_stmt | python_block | call_stmt | ... +let_stmt ::= "let" IDENT ":" type "=" expr ";" +require_stmt ::= "require" expr ";" +emit_stmt ::= "emit" expr "with" emit_block ";" +emit_block ::= "{" emit_field ( "," emit_field )* "}" +emit_field ::= IDENT ":" expr +python_block ::= "python" block +expr ::= literals | identifiers | function calls | operations +``` + +Detailed grammar (with precedence and type-checking rules) will evolve as we implement the compiler. + +--- + +## 5. Tooling Roadmap + +1. **DSL Parser/Checker** (`yapcad dsl lint`): + - Parse modules, enforce types/`require` statements, flag Python fallbacks. +2. **DSL Compiler** (`yapcad dsl compile`): + - Evaluate commands, capture canonical geometry, store invocation metadata, register `source.modules`. +3. **Assembler Integration**: + - Allow packages to declare dependencies on other `.ycpkg` modules and call their commands. +4. **BOM/Instance Engine**: + - Read `manifest.instances` and canonical entities to produce quantity rollups. + +--- + +## 6. Open Questions + +- Signature scheme and policy for Python fallback approval. +- How to distribute/resolve `.dsl` dependencies (e.g., package registry vs. local path). +- Versioning semantics when multiple packages export same command name. + +Feedback is welcome as we start implementing the compiler and packaging integration. diff --git a/docs/geometry_json_schema.md b/docs/geometry_json_schema.md new file mode 100644 index 0000000..0f1f43a --- /dev/null +++ b/docs/geometry_json_schema.md @@ -0,0 +1,181 @@ +# yapCAD Geometry JSON Schema + +**Schema ID:** `yapcad-geometry-json-v0.1` +**Status:** Accepted working version +**Purpose:** Serialise yapCAD solids, surfaces, assemblies, and associated metadata into a portable JSON document for storage, interchange, or inclusion in `.ycpkg` packages. + +--- + +## 1. Document Structure + +```json5 +{ + "schema": "yapcad-geometry-json-v0.1", + "generator": { + "name": "yapCAD", + "version": "0.6.0", + "build": "sha256:…" + }, + "units": "mm", + "entities": [ + { ... }, // solids, surfaces, meshes, groups + ], + "relationships": [ + { ... } // optional assembly graph + ], + "attachments": [ + { ... } // external artefacts (STEP/STL manifests) + ] +} +``` + +- `schema` (string, required): Version identifier. +- `generator` (object, optional): Tool metadata (name/version/build hash). +- `units` (string, optional): Default length unit (e.g. `"mm"`, `"inch"`). +- `entities` (array, required): Geometry objects described below. +- `relationships` (array, optional): Parent/child links, assembly constraints. +- `attachments` (array, optional): Non-native files referenced by hash/path. + +--- + +## 2. Entity Definitions + +Each entity is a JSON object with the following common fields: + +| field | type | description | +|----------------|----------|-------------| +| `id` | string | Stable UUID (should match `entityId` in metadata). | +| `type` | string | `"solid"`, `"surface"`, `"mesh"`, `"group"`, `"datum"`, `"sketch"`. | +| `name` | string | Optional human-readable label. | +| `metadata` | object | Metadata dictionary conforming to `metadata_namespace.md`. | +| `boundingBox` | number[] | `[xmin, ymin, zmin, xmax, ymax, zmax]` in document units. | +| `properties` | object | Derived properties (volume, area). | + +Each entity's `metadata` block MUST include the root fields from `metadata_namespace.md`, including `layer` (defaulting to `"default"`). Serialisers SHOULD propagate layer assignments for solids down to child surfaces and sketches so that viewers can offer layer-based visibility controls. + +### 2.1 Solids (`type: "solid"`) + +```json5 +{ + "type": "solid", + "faces": [ + { + "surface": "", + "orientation": 1, // +1 outer, -1 reversed + "edges": ["", ...] + } + ], + "shell": ["", ...], // ordered to define closed surface set + "voids": [ + ["", ...] + ] +} +``` + +### 2.2 Surfaces (`type: "surface"`) + +```json5 +{ + "type": "surface", + "vertices": [[x, y, z, 1], ...], + "normals": [[nx, ny, nz, 0], ...], + "faces": [[i0, i1, i2], ...], // index into `vertices` + "triangulation": { + "winding": "ccw", + "topology": "triangle" + } +} +``` + +### 2.3 Meshes (`type: "mesh"`) + +- For imported STL/PLY assets. Same structure as surface but without homogeneous coordinates requirement (`[x, y, z]`). + +### 2.4 Groups/Assemblies (`type: "group"`) + +```json5 +{ + "type": "group", + "children": ["", ...], + "transform": [ + [1,0,0,dx], + [0,1,0,dy], + [0,0,1,dz], + [0,0,0,1] + ] +} +``` + +--- + +## 3. Relationships + +Relationships capture connections not expressible as simple hierarchy: + +```json5 +{ + "type": "mated", + "entities": ["", ""], + "constraint": { + "kind": "coincident", + "faceA": "", + "faceB": "", + "offset": 0.0 + } +} +``` + +Supported relationship types (initial set): + +- `mated` +- `derived-from` +- `superseded-by` +- `references-layer` + +--- + +## 4. Attachments + +Attachment entries register external artefacts alongside hashes for integrity. + +```json5 +{ + "id": "step-export-001", + "kind": "step", + "path": "exports/rocket.step", + "hash": "sha256:…", + "createdBy": "", + "metadata": { + "brep": true, + "tessellated": true + } +} +``` + +--- + +## 5. Serialization Rules + +1. Numeric values MUST be finite doubles. Serialisers must replace `±inf`/`NaN` with errors. +2. Homogeneous coordinates still use yapCAD convention (`w=1` for points, `w=0` for vectors). +3. Vertex indices are zero-based. +4. The root document MAY contain multiple solids or groups; exporters should topologically sort for dependency-free reconstruction. +5. Unknown fields MUST be preserved on round-trip (forward compatibility). + +--- + +## 6. Integration Guidance + +- **Geometry export**: implement `to_geometry_json(entity)` that walks solids, surfaces, metadata and writes this schema. +- **Import**: validate `schema` version, then rebuild yapCAD list structures; reattach metadata via helper functions. +- **Manifest use**: `.ycpkg` manifests can reference geometry JSON by path and include matching hashes. +- **Streaming**: allow chunked outputs by splitting `entities` across files and referencing them via `attachments` or manifest entries. + +--- + +## 7. Future Enhancements + +- Formal JSON Schema / Pydantic definitions. +- Compression guidelines (e.g. `.json.zst`). +- Support for parametric feature history (link to DSL once available). +- Extend relationships to cover constraint solving results and tolerance stacks. diff --git a/docs/index.rst b/docs/index.rst index b54eea8..1ddfa25 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,18 +17,18 @@ yapCAD Welcome to **yapCAD**, yet another procedural CAD and computational geometry system, written in Python_. The project is in active development, -with recent releases focusing on production-ready 3D generative workflows +with the 0.5.x series delivering production-ready 3D generative workflows (solid modeling, robust boolean operations, STL/STEP export, interactive -rendering) alongside the original 2D/DXF tooling. +rendering) plus the new `.ycpkg` project packaging model. .. note:: **yapCAD** was created to solve some fairly specific problems in procedural CAD and `parametric design`_. Earlier releases were primarily geared toward generating 2D drawings in the `AutoCad - DXF`_ format; the current 0.5.x series delivers robust 3D boolean + DXF`_ format; the current 0.5.x cycle adds robust 3D boolean operations, validated primitive generation, comprehensive mesh - validation tools, and production-ready STL/STEP export. + validation tools, `.ycpkg` packaging, and production-ready STL/STEP export. Why use yapCAD? yapCAD allows you to transform the 2D and 3D mechanical design process from the manual creation of drawings, parts, and assemblies @@ -36,15 +36,9 @@ rendering) alongside the original 2D/DXF tooling. modular, parametric, and even LLM-generated code. Because yapCAD's foundations are in software, you can use powerful agentic - or "vibe coding" tools to translate your design intent into functional and - parameterized design, without ever writing a line of code yourself. For - example, the rocket example rendered above was generated in one-shot by - `gpt-5-codex` from the prompt: "Using what you know about yapCAD, I'd like - you to create a demo that builds a simple 3D model of a rocket, visualizes - it using pyglet, and then writes out the STL file. I'd like the rocket to - have a cluster of five engines, guidance fins, a cylindrical body with at - least one diameter transition before the payload fairing, and an aerodynamic - fairing. Can you do this for me?" + tools to translate your design intent into functional, parameterized code + without manual drawing edits. Several shipped examples were authored via LLM + prompts to demonstrate automation-friendly workflows. And becuse yapCAD designs are software, they can be parametric and modular. So if you are tired of manually editing your CAD files whenever you change the @@ -74,7 +68,10 @@ Contents Authors Changelog Module Reference - README + README + Project Packaging + DSL Draft + BREP Roadmap Indices and tables diff --git a/docs/metadata_namespace.md b/docs/metadata_namespace.md new file mode 100644 index 0000000..cba1433 --- /dev/null +++ b/docs/metadata_namespace.md @@ -0,0 +1,102 @@ +# yapCAD Metadata Namespace Specification + +**Version:** `metadata-namespace-v0.1` +**Status:** Accepted working version +**Scope:** Metadata dictionaries attached to solids, surfaces, geometry collections, assemblies, and exported artefacts. + +--- + +## 1. Overview +yapCAD embeds metadata alongside geometry objects using dictionary slots (see `src/yapcad/metadata.py`). This namespace specification formalises those dictionaries so that materials, manufacturing intent, design provenance, and constraints can be shared between authoring tools, automation agents, and downstream exporters. + +Every metadata dictionary MUST include: + +| key | type | description | +|--------------|----------|-------------| +| `schema` | string | Version identifier, e.g. `metadata-namespace-v0.1`. | +| `entityId` | string | Stable UUID for the geometry entity (use `ensure_*_id`). | +| `timestamp` | string | Optional ISO 8601 timestamp of last update. | +| `tags` | string[] | Free-form labels (e.g. `["prototype", "revision-A"]`). | +| `layer` | string | Logical drawing layer for the entity (`"default"` if unspecified). | + +Additional sections are nested dictionaries keyed by namespace names described below. Implementations MAY omit an entire section; consumers must treat unknown sections as opaque extension points. + +--- + +## 2. Material Namespace (`material`) + +| key | type | notes | +|----------------|---------------|-------| +| `name` | string | Human-readable descriptor (`"6061 Aluminum"`, `"PLA"`). | +| `standard` | string | Reference to published spec (`"ASTM B209-14"`). | +| `grade` | string | Alloy/temper or plastic grade (`"6061-T6"`). | +| `density_kg_m3`| number | Density if known. | +| `source` | string | Supplier, stock number. | + +--- + +## 3. Manufacturing Namespace (`manufacturing`) + +| key | type | notes | +|-------------------|----------|-------| +| `process` | string | Primary process (`"waterjet"`, `"SLA"`, `"3-axis CNC"`). | +| `instructions` | string | Operator notes or setup text. | +| `fixtures` | string[] | Fixture/tooling identifiers. | +| `layers` | object | Optional map relating drawing layers or geometry subsets to process directives (e.g. `{ "layer0": {"operation": "cut", "draft_deg": 0}, "layer1": {"operation": "drill", "tool": "Ø6mm"} }`). | +| `postprocessing` | string[] | e.g. `["deburr", "anodise-clear"]`. | + +--- + +## 4. Design History Namespace (`designHistory`) + +| key | type | notes | +|---------------|-----------|-------| +| `author` | string | Person or agent responsible. | +| `source` | string | `"prompt"`, `"script"`, `"import"` etc. | +| `context` | string | Prompt text, script path, or design brief summary. | +| `tools` | string[] | Tools/agents used (`["LLM-gpt4.1", "topology-opt-v2"]`). | +| `iterations` | object[] | Chronological entries (`[{ "revision": "A", "timestamp": "...", "notes": "..."}]`). | + +--- + +## 5. Constraint Namespace (`constraints`) + +| key | type | notes | +|-----------------|-----------|-------| +| `mass` | object | `{ "max_kg": 5.0, "target_kg": 4.2 }`. | +| `envelope` | object | Bounding box or volume limits (`{ "x_mm": 300, "y_mm": 150, "z_mm": 80 }`). | +| `performance` | object[] | Domain-specific constraints (e.g. load, stiffness). | +| `compliance` | string[] | Required certifications (`["ASME Y14.5-2018"]`). | + +--- + +## 6. Analysis Namespace (`analysis`) + +| key | type | notes | +|---------------|-----------|-------| +| `simulations` | object[] | Entries containing solver, mesh, result identifiers. | +| `validation` | object[] | Links to test cases or measurement data. | + +--- + +## 7. Custom Extensions + +- Extension namespaces MUST be top-level keys using reverse-domain or organisation prefixes, e.g. `"com.example.additive": {...}`. +- Extension content MUST NOT overwrite standard namespaces. + +--- + +## 8. Usage Guidelines + +1. Call `ensure_*_id` before populating metadata to guarantee `entityId`. +2. When updating metadata, bump `timestamp` and keep prior entries in `designHistory.iterations`. +3. Serialisers SHOULD preserve unknown keys to allow forward compatibility. +4. Exporters SHOULD include the metadata dictionary (or a digest reference) in their manifest entries. + +--- + +## 9. Future Work + +- Define JSON Schema files for programmatic validation. +- Add localisation support for human-readable strings (multiple languages). +- Align metadata namespace with planned `.ycpkg` manifest structure. diff --git a/docs/yapBREP.md b/docs/yapBREP.md new file mode 100644 index 0000000..a3fb156 --- /dev/null +++ b/docs/yapBREP.md @@ -0,0 +1,181 @@ +# yapCAD BREP Roadmap + +This note captures a concrete plan for extending yapCAD’s native data structures so +we can ingest/export analytic STEP models without collapsing them to triangle meshes. +The intent is to evolve yapCAD into a lightweight BREP kernel while keeping today’s +list-based API backwards compatible. + +--- + +## 1. Current State + +* **Curves (2D)** – first-class line, arc, circle, Catmull–Rom, NURBS primitives; polygons + and polylines are stored as lists of points. There is no explicit ellipse/conic type yet. +* **Surfaces (3D)** – planes, spheres, cones, cylinders, prisms etc. exist as “generators” + that produce tessellated surfaces (`['surface', verts, normals, faces, …]`). Once + tessellated, the analytic definition is lost; the solid stores metadata about the + procedure that produced it. +* **Solids** – list-based shells referencing surfaces, plus metadata. No explicit vertex/edge + topology is stored; the mesh is the only representation. +* **Serialization** – geometry JSON stores solids/surfaces as triangle soup, and sketches as + polylines + primitives. STEP export is faceted only; STEP import is not implemented. + +--- + +## 2. Goals + +1. **Retain analytic fidelity** – curves and surfaces imported from STEP should remain + parametric (no forced tessellation), with tessellation performed lazily when required. +2. **Explicit topology** – introduce vertices, edges, trims, loops, faces, shells so we can + mirror STEP-style BREPs. +3. **Backwards compatibility** – existing list-based APIs (`geom*`, `geometry_json`, DSP + packages) should continue to work; new structures should integrate without breaking them. +4. **Extensible serialization** – `.ycpkg` geometry JSON must round-trip analytic curves and + surfaces; tessellations become derived assets. +5. **Interoperable export** – STEP export should regenerate analytic entities instead of + only meshed facets; DXF export for sketches already does this for curves. + +--- + +## 3. Core Data Structure Extensions + +### 3.1 Curves (2D/3D) + +* **New curve primitives** + * `ellipse(center, major, minor, axis, span)` / `isellipse`. + * `conic` for generic quadrics (parabola, hyperbola) – store focal parameters + orientation. + * 3D curves mirror 2D definitions but with explicit plane/axis metadata, matching STEP + `CIRCLE`, `ELLIPSE`, `B_SPLINE_CURVE`, `CONIC` etc. +* **Parameter ranges** – store `[u0, u1]` on curve instances when they are used as edges. +* **Tolerance metadata** – attach `curve_meta` with modeling precision, so downstream ops can + compare within a consistent epsilon. + +### 3.2 Surfaces + +* Create first-class `planesurface`, `cylindersurface`, `conesurface`, `spheresurface`, + `tori surface`, and generic `nurbs_surface`. Each stores its parameter domain (`u,v` + bounds), local axis, and control net (for splines). +* Maintain a tessellation cache per surface (e.g., `surface[TRI_CACHE]`) plus refinement + options. +* Provide evaluation utilities (`evaluate_surface`, `surface_normal`, `surface_uv_project`) + to support trimming, intersections, etc. + +### 3.3 Topology Graph + +Introduce new list-based constructs (or light classes) layered above the existing surface/ +solid representation: + +| Entity | Responsibilities | +|---------|------------------------------------------------------------------------------| +| `brep_vertex` | world-space point + tolerance + incident edges references | +| `brep_edge` | references a curve primitive + parameter range + start/end vertices; | +| | stores sense (forward/reverse). | +| `brep_trim` | oriented edge with UV parameterisation for a specific face | +| `brep_loop` | ordered list of trims forming outer/inner boundary of a face | +| `brep_face` | references a surface primitive + loops + natural orientation | +| `brep_shell` | set of faces forming a closed shell (outer or inner) | +| `brep_solid` | collection of shells (outer + optional inner voids) | + +Each object keeps metadata (`layer`, `tags`, creation history) just like current solids. +Tessellations are derived on demand from these definitions. + +--- + +## 4. Lazy Tessellation Strategy + +* Provide a `tessellate(surface, quality)` API that converts analytic surfaces to the + current triangle format. +* Modify `geometry_to_json` so solids with analytic faces are serialized as analytic + definitions + optional tessellation cache. +* The viewer/exporters can request tessellation when needed (e.g., to render meshes or + export STL). For STEP export we use the analytic definitions directly. + +--- + +## 5. Serialization (geometry JSON & STEP) + +1. **JSON schema updates** + * Extend sketch `primitives` to include ellipse/conic entries (already storing lines, + arcs, splines). + * Introduce new entity types: + * `"curve"` (analytic definition + parameters). + * `"surface"` (existing tessellated surfaces) + `"analyticSurface"` (parametric). + * `"brep"` object that carries vertices/edges/trims/faces/shells with references. + * Keep backwards-compatible `polylines` and tessellated surfaces for consumers that + still expect meshes. +2. **STEP export** + * Refactor exporter to walk the new BREP structure and emit STEP analytic entities + (plane, cylinder, B-spline surface, etc.) and topological relationships. + * For solids that only have tessellations, fall back to faceted BREP (current behaviour). +3. **STEP import** + * Parse analytic geometry into the new primitives; tessellate lazily for visualization. + * Support at least the common STEP AP203/AP214 entities used in CAD: planes, cylinders, + cones, spheres, torus, b-spline surfaces, trimmed surfaces, edges with analytic curves. + +--- + +## 6. API & Package Touch Points + +* `geom.py` – add ellipse/conic primitives; extend sampling, length, bbox, intersection + utilities to understand them. +* `geom3d.py` – new surface constructors; evaluation/tessellation helpers; solids now hold + `brep_solid` references in metadata. +* `geom3d_util.py` – extrusion/loft/tube functions should emit both analytic surfaces and + BREP topology (edges, trims) in addition to tessellated surfaces. +* `geometry_json.py` – encode/decode new primitive types; maintain compatibility with + existing consumers. +* `package/viewer.py` – when given analytic surfaces, tessellate on the fly for display. +* `tools/ycpkg_export.py` – allow exporting either analytic STEP or derived STL/STP meshes. + +--- + +## 7. Implementation Phases + +1. **Foundations** + * Add ellipse/conic primitives (+ tests). + * Add analytic surface structures & evaluation utilities. + * Introduce BREP topology containers linked to existing solids. +2. **Serialization** + * Update geometry JSON reader/writer to store primitives & BREP graph. + * Ensure `.ycpkg` round-tripping covers new entity types (unit tests). +3. **Tessellation refactor** + * Centralize tessellation (quality settings, caching). + * Viewer/export updates to request tessellations lazily. +4. **STEP import/export** + * Build analytic STEP export pipeline (faces -> trimmed surfaces -> topology). + * Implement STEP reader that populates the new structures. +5. **Tooling & validation** + * Extend `ycpkg_export.py` to offer `--formats step-analytic`, `--formats stl`. + * Add diagnostic tools for watertightness, trimming, topology consistency. + +--- + +## 8. Risks & Mitigations + +* **Tolerance management** – adopt a consistent global modeling tolerance, propagate to + curves/surfaces/vertices. Provide helpers for fuzzy comparisons. +* **Performance** – analytic evaluation and tessellation must be efficient; consider + caching compiled spline representations (e.g., via NumPy) or delegating to OCC in the + future. +* **Complex STEP entities** – initial scope should focus on the 80/20 set (planes, + cylinders, cones, spheres, torus, NURBS). More exotic surfaces (offsets, swept, + intersection curves) can be mapped via approximation or deferred. +* **Backwards compatibility** – ensure old packages with only tessellated data still load, + and new packages degrade gracefully when primitives are missing. + +--- + +## 9. Next Steps + +1. Prototype ellipse/conic primitives in `geom.py` + tests. +2. Draft analytic surface objects (plane/cylinder/cone) and wire them into extrusion/ + revolution helpers. +3. Define minimal BREP data structures (vertex, edge, face) in a new module (e.g., + `yapcad.brep`) and start storing them alongside solids. +4. Update geometry JSON to carry `primitives` and BREP topology; add regression tests. +5. Build a simple STEP analytic exporter to validate the data model. + +This staged approach lets us evolve yapCAD incrementally: first by capturing analytic +information during geometry creation, then by enhancing serialization and finally by +supporting full STEP import/export. Once in place, STL import can coexist (as tessellated +geometry), while STEP models benefit from a richer, faithful representation. diff --git a/docs/yapCADone.md b/docs/yapCADone.md index 9b25102..99ad0b6 100644 --- a/docs/yapCADone.md +++ b/docs/yapCADone.md @@ -9,6 +9,13 @@ Deliver a requirements-driven, provenance-aware design platform where yapCAD pro - Openness: use documented schemas and standard formats (STEP, STL, JSON/YAML manifests) for interoperability. - Automation friendly: support LLM-driven design loops and continuous validation pipelines. +## Progress Snapshot (October 2025) +- `.ycpkg` packaging, manifest schema, and CLI tooling implemented (validation & export helpers, metadata tracking, analytic sketch primitives). +- DXF/STEP/STL exports available; viewers operate on packaged geometry; regression tests cover spline/tessellation workflows. +- DSL, validation framework, and security/signature features remain in design phase (`docs/dsl_spec.md`, `docs/ycpkg_spec.md`). +- Analytic BREP roadmap captured in `docs/yapBREP.md` (ellipses/conics, analytic surfaces, topology graph). +- Upcoming work: DSL compiler, validation execution layer, STEP/STL import, analytic STEP exporter, automation APIs. + ## Functional Requirements 1. **Project Packaging** - Define a `.ycpkg` package (directory or archive) containing manifest, requirements, design sources, validation definitions, results, exports, metadata. @@ -47,39 +54,33 @@ Deliver a requirements-driven, provenance-aware design platform where yapCAD pro ## Roadmap & Milestones -### Phase 1 – Shared Foundations (v0.x) -- Implement mesh view utilities, metadata extensions, validation helpers (see `yapCADfoundations.md`). -- Ship STL exporter/importer using new helpers. +### Phase 1 – Shared Foundations (v0.x) — ✅ Completed +- Mesh/metadata utilities (`yapCADfoundations.md`), viewer refactor, and geometry JSON enhancements delivered. +- STL/STEP exporters available; STL import deferred to Phase 4 analytic work. -### Phase 2 – Project Packaging Prototype -- Introduce manifest schema and `.ycpkg` structure (manifest, design, tests, results, exports). -- Implement loader/saver preserving existing geometry plus new metadata; include migration scripts. -- Add basic CLI for packaging and validation status reporting. +### Phase 2 – Project Packaging Prototype — ✅ Completed +- Manifest schema, `.ycpkg` layout, CLIs (`ycpkg_validate`, `ycpkg_export`), external asset support (`add_geometry_file`) in place. +- Regression tests cover packaging round-trips; migration tooling still TBD for legacy 0.x projects. -### Phase 3 – Parametric DSL & Validation Layer -- Implement the declarative DSL compiler to Python/macros, including static validation and metadata emission. -- Build validation framework (test definitions, execution records, status propagation). -- Integrate with continuous testing workflows; implement CLI/API for LLM agents. -- Document spec/validation authoring process. +### Phase 3 – Parametric DSL & Validation Layer — 🚧 In Progress (Design) +- Specifications drafted (`docs/dsl_spec.md`), but compiler/runtime and validation execution manager not yet implemented. +- Next steps: prototype DSL compiler, define validation schema, integrate with packaging/metadata. -### Phase 4 – Export/Import Expansion -- STEP tessellated exporter leveraging mesh helper; extend to BREP where analytic surfaces available. -- STEP/STL import pipelines mapping to project structures, performing integrity checks. -- Viewer refactor to consume packaged projects. +### Phase 4 – Export/Import Expansion — 🚧 Ongoing +- STEP (faceted), STL, DXF exports implemented; viewer consumes packaged geometry. +- STL import pending; analytic STEP import/export scoped in `docs/yapBREP.md` (requires new BREP kernel work). -### Phase 5 – Provenance & Security Enhancements -- Incorporate optional hashing/signature support for specs, geometry, and manifests. -- Provide verification tools and documentation for secure pipelines. +### Phase 5 – Provenance & Security Enhancements — ⏳ Not Started +- Hashing exists for geometry/assets; signatures/approvals still on backlog. -### Phase 6 – Release yapCAD 1.0 -- Finalize documentation, migration guides, and API references. -- Update tutorials/examples to use new project model and workflows. -- Tag 1.0 release, deprecate legacy APIs with clear transition plan. +### Phase 6 – Release yapCAD 1.0 — ⏳ Not Started +- Depends on completing Phases 3–5 plus documentation and migration tooling. ## Dependencies & Tooling Considerations - Potential third-party STEP libraries (e.g. pythonocc-core) for parsing/advanced export—evaluate licensing and integration cost. - Hashing/signature libraries (cryptography, nacl) for provenance features. - Testing infrastructure to run simulations (container support, job orchestration where required). +- Analytic BREP implementation plan documented in `docs/yapBREP.md`; evaluate whether to leverage OCC kernels or extend native primitives per that roadmap. ## Risks & Mitigations - **Complex migration:** provide automated conversion scripts and dual-loading support during transition. diff --git a/docs/ycpkg_spec.md b/docs/ycpkg_spec.md new file mode 100644 index 0000000..52ebea8 --- /dev/null +++ b/docs/ycpkg_spec.md @@ -0,0 +1,265 @@ +# yapCAD Package (`.ycpkg`) Specification + +**Version:** `ycpkg-spec-v0.1` +**Status:** Draft – initial workflow design + +--- + +## 1. Goals + +The `.ycpkg` package provides a portable container for yapCAD projects, bundling geometry, metadata, validation artefacts, and exports. It is designed to: + +- Preserve provenance and traceability (aligns with `metadata-namespace-v0.1`). +- Enable reproducible automation (LLM agents, CI pipelines). +- Support incremental evolution (versioned manifests, optional extensions). + +Distribution format is a directory tree or zipped archive. Commands must work on either form. + +--- + +## 2. Directory Layout + +``` +my_design.ycpkg/ +├── manifest.yaml +├── geometry/ +│ ├── primary.json # native yapCAD geometry (schema v0.1) +│ ├── entities/ # canonical solids/sketches produced by DSL commands +│ └── derived/… # optional derived geometry files +├── instances/ # placements/replications referencing canonical entities +│ └── *.json +├── metadata/ +│ ├── requirements.yaml # requirement statements + trace links +│ └── history.log # change log (optional) +├── src/ # packaged DSL modules +│ └── *.dsl +├── scripts/ # optional Python helpers invoked by DSL fallback blocks +│ └── *.py +├── validation/ +│ ├── plans/… # definitions per test/tool +│ └── results/… # captured outputs (JSON/CSV/etc.) +├── exports/ +│ ├── model.step +│ └── model.stl +├── attachments/ +│ └── … # arbitrary supporting files +└── README.md # human guidance (optional) +``` + +All file references recorded in the manifest must use relative paths within the package. + +--- + +## 3. Manifest (`manifest.yaml`) + +High-level structure: + +```yaml +schema: ycpkg-spec-v0.1 +id: d7d0f8b5-… +name: Rocket Bulkhead +version: 0.1.0 +description: > + Parametric bulkhead design produced via yapCAD. +created: + timestamp: 2024-10-12T18:04:00Z + author: rdevaul +generator: + tool: yapCAD + version: 0.6.0 + build: sha256:… +units: mm +tags: [prototype, waterjet] + +geometry: + primary: + path: geometry/primary.json + hash: sha256:… + schema: yapcad-geometry-json-v0.1 + entities: ["solid-main", "surface-top"] + # Each entity's metadata.layer encodes the logical drawing layer (default "default"). + # Sketch entries now capture both sampled polylines and ``primitives`` + # (line/arc/circle/catmullrom/nurbs/polyline records) so round-tripping + # preserves parametric fidelity for downstream exporters. + derived: + - path: geometry/derived/offset.json + purpose: "shell offset for analysis" + hash: sha256:… + - path: geometry/derived/external_gear.step + purpose: "imported downstream component" + format: step + source: + kind: import + original: /workspace/library/gear.step + hash: sha256:… + +instances: + - id: gear_a + entity: geometry/entities/gear_primary.json + count: 4 + transforms: + - instances/gear_a_1.json + - instances/gear_a_2.json + - id: bolt_m10 + entity: geometry/entities/bolt_m10.json + count: 100 + +source: + modules: + - id: involute_gear + path: src/involute_gear.dsl + language: yapdsl-v0.1 + hash: sha256:… + exports: [INVOLUTE_SPUR, INVOLUTE_SPUR2D] + - id: metric_fasteners + path: src/metric_fasteners.dsl + language: yapdsl-v0.1 + runtime: + python: + helpers: + - path: scripts/custom_helpers.py + hash: sha256:… + +metadata: + requirements: metadata/requirements.yaml + history: metadata/history.log + +validation: + plans: + - id: mass-check + path: validation/plans/mass_check.yaml + results: + - plan: mass-check + path: validation/results/mass_check.json + status: passed + timestamp: 2024-10-12T19:00:00Z + +exports: + - id: step-main + kind: step + path: exports/model.step + hash: sha256:… + sourceEntities: ["solid-main"] + - id: stl-main + kind: stl + path: exports/model.stl + hash: sha256:… + +attachments: + - id: prompt + path: attachments/design_prompt.txt + description: "LLM prompt that generated initial geometry" + hash: sha256:… + +Sketch entities now persist a `primitives` array alongside their sampled `polylines`. Each primitive captures the original parametric definition—`line`, `circle`, `arc`, `catmullrom`, `nurbs`, or explicit `polyline`—so geometry exchanged between yapCAD agents retains full fidelity. Export tools (e.g., `tools/ycpkg_export.py`) can therefore emit native DXF entities instead of approximating curves with short segments. + +provenance: + parent: null + revisions: + - version: 0.0.1 + timestamp: 2024-09-29T12:00:00Z + notes: Initial concept import + author: assistant + +extensions: + # Optional vendor-specific data + com.example.workflow: + state: approved +``` + +Key notes: + +- `hash` values use lowercase algorithm names (`sha256`, `blake3`, …). +- `geometry.primary.entities` lists IDs found in the JSON geometry file to speed lookup. +- `extensions` is the sanctioned namespace for custom data. + +--- + +## 4. CLI Workflow + +### 4.1 Package Creation + +`yapcad package create -o my_design.ycpkg/ [options]` + +Steps: +1. Run user-supplied script to generate geometry. +2. Collect metadata from geometry objects. +3. Emit `geometry/primary.json` via `geometry_to_json`. +4. Optionally run exports (`--export step,stl`). +5. Assemble manifest and write to disk. + +### 4.2 Validation Update + +`yapcad package validate my_design.ycpkg/ --plan validation/plans/mass_check.yaml` + +Steps: +1. Load manifest. +2. Execute specified validation plan (commands defined in YAML). +3. Store outputs under `validation/results/` with status. +4. Update manifest `validation.results`. + +### 4.3 Metadata Edit / Sync + +`yapcad package annotate my_design.ycpkg/ --material "6061-T6"` + +Updates geometry metadata via helper APIs, rewrites `geometry/primary.json`, refreshes manifest hashes. + +### 4.4 Export Refresh + +`yapcad package export my_design.ycpkg/ --kind step` + +Regenerates exports, updates manifest. + +--- + +## 5. API Surface + +Implement Python helpers under `yapcad.package`: + +- `PackageManifest`: dataclass with `load(path)`, `save()`, `recompute_hashes()`. +- `create_package(source: Callable, target_dir: Path, options)` – drives creation workflow. +- `load_geometry(manifest: PackageManifest)` – returns list of yapCAD entities. +- `update_metadata(manifest, modifier_fn)` – convenience to mutate metadata and persist. +- `run_validation(manifest, plan_id, runner)` – hooks to validation subsystem. +- `add_geometry_file(manifest, source_path, ...)` – copy an external STEP/STL/etc. into `geometry/derived/` (or attachments) and register it in the manifest with hash + provenance. + +These APIs should be usable by both CLI and programmatic automation. + +--- + +## 6. Hashing Rules + +- Use SHA-256 as default; allow override via CLI (`--hash blake3`). +- Hashes computed over raw bytes; store as `sha256:`. +- When updating a file, manifest must be rewritten with new hash. + +--- + +## 7. Zipped Packages + +- `.ycpkg` may be zipped (`zip -r my_design.ycpkg.zip my_design.ycpkg/`). +- CLI must accept `.zip` by transparently mounting to temp dir. +- Manifest should include `packageFormat: directory|zip`. + +--- + +## 8. Future Enhancements +- Layer-aware viewer interactions (already implemented) may evolve into annotated layer libraries. +- DSL compilation pipeline will eventually support signed modules and hashed invocation metadata per entity. +- Canonical-entity instancing will feed BOM generation utilities. +- Digital signatures (`signatures` section) referencing PKI chain. +- Dependency graph for multi-part assemblies. +- Delta packages storing overrides against a base `.ycpkg`. +## Viewer & Validation Quick Reference + +- `tools/ycpkg_validate.py ` – verifies hashes and geometry JSON. +- `tools/ycpkg_viewer.py ` – launches the interactive viewer. + * 3D mode shows perspective/front/top/side quadrants, supports layer toggles (`1-9` to toggle, `0` reset) and a help overlay (`H`/`F1`). + * 2D sketches receive the same layer toggles, pan/zoom controls, and help overlay. +│ ├── register/… # signatures, approvals, etc. (optional) +### 4.5 DSL Compilation & Canonical Entities + +- `yapcad dsl compile src/involute_gear.dsl` parses the DSL module, emits canonical solids/sketches under `geometry/entities/`, and records exported commands in the manifest `source.modules` block. +- `yapcad package assemble --dsl module:command=params` resolves DSL invocations, deduplicates canonical geometry, and populates the `instances/` directory plus `manifest.instances` entries. +- Python fallback blocks inside DSL modules must live under `scripts/` and are listed under `source.runtime` for reproducibility. +- `register_instance(entity_id, params, transform)` helpers (TBD) will let higher-level assemblies reuse canonical geometry and populate `manifest.instances`. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..be285d7 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Example scripts and helper modules for yapCAD.""" diff --git a/examples/example8_package.py b/examples/example8_package.py new file mode 100644 index 0000000..b99fe8c --- /dev/null +++ b/examples/example8_package.py @@ -0,0 +1,162 @@ +"""Derivative of example8 that can package the generated 2D geometry as a .ycpkg.""" + +from __future__ import annotations + +import argparse +import random +import shutil +from pathlib import Path + +from yapcad.ezdxf_drawable import ezdxfDraw +from yapcad.geom import arc, bbox, mirror, point, add, cos, sin, pi2, iscircle +from yapcad.poly import Polygon +from yapcad.package import create_package_from_entities + +from examples import example10 # reuse randomPoints helper + + +def draw_legend(drawer: ezdxfDraw) -> None: + drawer.layer = 'DOCUMENTATION' + att = {'style': 'OpenSans-Bold', 'height': 2.5} + drawer.draw_text("yapCAD", point(5, 15), attr=att) + drawer.draw_text("example8_package.py", point(5, 11), attr=att) + drawer.draw_text("Polygon flowers, mirrored geometry", point(5, 7), attr=att) + drawer.layer = False + + +def flower(center=point(0, 0), + petals=10, + min_diam=5.0, + max_diam=15, + min_radius=20, + max_radius=40, + inside_radius=5, + return_poly=False): + + glist = [] + for i in range(petals): + angle = i * 360 / petals + anrad = angle * pi2 / 360.0 + + an2 = (((i + 0.5) / petals) % 1.0) * 360 + an2rad = an2 * pi2 / 360.0 + + radius = (max_radius - min_radius) * random.random() + min_radius + diam = (max_diam - min_diam) * random.random() + min_diam + pnt = add(point(cos(anrad) * radius, sin(anrad) * radius), center) + pnt2 = add(point(cos(an2rad) * inside_radius, sin(an2rad) * inside_radius), center) + a = arc(pnt, diam / 2) + glist.append(a) + glist.append(pnt2) + + poly = Polygon(glist) + if return_poly: + return poly + return poly.geom + + +def mirror_array(pnt=point(-45, 45)): + flwr = flower(pnt, return_poly=True) + glist = flwr.geom + bb = bbox(glist) + flwr2 = Polygon(flwr.geom) + flwr2.grow(1.0) + glist.extend(flwr2.geom) + + random_points = example10.randomPoints(bb, 500) + for p in random_points: + if flwr.isinsideXY(p): + glist.append(arc(p, 0.4)) + + ply = [ + point(bb[0]), + point(bb[1][0], bb[0][1]), + point(bb[1]), + point(bb[0][0], bb[1][1]), + ] + glist = glist + ply + glist = glist + mirror(glist, 'yz') + glist = glist + mirror(glist, 'xz') + return glist + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate example8 geometry and optional package.") + parser.add_argument("--output", default="example8-out", help="Base filename for DXF output.") + parser.add_argument("--package", type=Path, help="Optional directory for .ycpkg output.") + parser.add_argument("--name", default="Example 8 Flowers", help="Package manifest name.") + parser.add_argument("--version", default="0.1.0", help="Package manifest version.") + parser.add_argument( + "--description", + default="2D flower sketch generated by example8", + help="Package manifest description.", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing package directory if present.", + ) + parser.add_argument("--no-display", action="store_true", help="Disable DXF viewer pop-up.") + return parser + + +def main(argv: list[str] | None = None) -> None: + parser = build_arg_parser() + args = parser.parse_args(argv) + + glist = mirror_array() + + print("example8_package.py -- yapCAD computational geometry and DXF drawing example") + drawer = ezdxfDraw() + drawer.filename = args.output + draw_legend(drawer) + + circles = [] + others = [] + for g in glist: + if iscircle(g): + circles.append(g) + else: + others.append(g) + drawer.linecolor = 'white' + drawer.draw(others) + drawer.linecolor = 'aqua' + drawer.draw(circles) + + drawer.display() # persists the DXF to disk + dxf_path = Path(f"{args.output}.dxf").resolve() + print(f"DXF written to {dxf_path}") + + if args.package: + manifest = create_package_from_entities( + [{'geometry': glist, 'metadata': {'layer': 'flowers'}}], + args.package, + name=args.name, + version=args.version, + description=args.description, + units="mm", + overwrite=args.overwrite, + ) + export_target = manifest.root / "exports" / dxf_path.name + export_target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(dxf_path, export_target) + manifest.data.setdefault("exports", []).append({ + "id": "dxf-main", + "kind": "dxf", + "path": str(export_target.relative_to(manifest.root)), + }) + tags = manifest.data.setdefault("tags", []) + if "2d" not in tags: + tags.append("2d") + if "flowers" not in tags: + tags.append("flowers") + manifest.recompute_hashes() + manifest.save() + print(f"Package created at {manifest.manifest_path}") + + # Legacy flag retained for parity; ezdxfDraw.display() simply writes the file + # so there is no additional GUI to launch here. + + +if __name__ == "__main__": + main() diff --git a/examples/involute_gear_package/README.md b/examples/involute_gear_package/README.md new file mode 100644 index 0000000..4346fe0 --- /dev/null +++ b/examples/involute_gear_package/README.md @@ -0,0 +1,51 @@ +# Involute Gear Package Example + +This example demonstrates how to build a `.ycpkg` package that supplies parametric involute spur gears and how that package can be consumed inside another design. +The gear profile math is derived from the open-source [figgear](https://github.com/chromia/figgear) generator (MIT Licensed) and is vendored into `yapcad.contrib.figgear`, so no extra dependency is required. + +## Files + +``` +examples/involute_gear_package/ +├── README.md +├── involute_gear.py # Python helpers that generate 2D profile + 3D solid +├── build_gear_package.py # Creates a .ycpkg with canonical gear geometry +├── sample_drive.py # Uses the gear package to build a simple gear pair assembly +├── src/ +│ └── involute_gear.dsl # Draft DSL module describing INVOLUTE_SPUR commands +└── scripts/ + └── involute_helpers.py # Placeholder for future DSL Python fallback blocks +``` + +## Usage + +`build_gear_package.py` copies the DSL module and helper scripts into the package and records their hashes inside `manifest.source`, so every canonical gear bundle remains self-contained. + +1. Create canonical gear packages (adjust tooth counts as needed): + +```bash +python examples/involute_gear_package/build_gear_package.py \ + --output /tmp/driver_gear.ycpkg \ + --teeth 18 \ + --module-mm 2.0 \ + --face-width-mm 8.0 + +python examples/involute_gear_package/build_gear_package.py \ + --output /tmp/driven_gear.ycpkg \ + --teeth 36 \ + --module-mm 2.0 \ + --face-width-mm 8.0 +``` + +2. Build a simple two-gear drive package that reuses those canonical bundles: + +```bash +python examples/involute_gear_package/sample_drive.py \ + --driver-package /tmp/driver_gear.ycpkg \ + --driven-package /tmp/driven_gear.ycpkg \ + --output /tmp/gear_drive.ycpkg +``` + +Passing `--driver-package` / `--driven-package` makes `sample_drive.py` load and reuse the canonical solids (and their invocation metadata) rather than regenerating geometry. If these flags are omitted the script falls back to generating gears in-place using the helper functions. + +Each package embeds both the 2D profile and extruded solid along with invocation metadata (package/command/parameters) so downstream tools know which DSL command produced the geometry. Layers are set to `gear-profile`, `gear`, etc., allowing the viewer to toggle visibility by layer. diff --git a/examples/involute_gear_package/__init__.py b/examples/involute_gear_package/__init__.py new file mode 100644 index 0000000..6305cc2 --- /dev/null +++ b/examples/involute_gear_package/__init__.py @@ -0,0 +1 @@ +"""Involute gear package example helpers.""" diff --git a/examples/involute_gear_package/build_gear_package.py b/examples/involute_gear_package/build_gear_package.py new file mode 100644 index 0000000..cdf3f1a --- /dev/null +++ b/examples/involute_gear_package/build_gear_package.py @@ -0,0 +1,127 @@ +"""Build a .ycpkg package containing a canonical involute spur gear.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import shutil +from typing import Any, Dict + +from yapcad.metadata import ( + add_tags, + get_solid_metadata, + get_surface_metadata, + set_layer, +) +from yapcad.package import create_package_from_entities +from yapcad.package.core import _compute_hash + +from .involute_gear import generate_involute_spur + + +def _add_invocation(meta: Dict[str, Any], command: str, params: Dict[str, Any]) -> None: + meta["invocation"] = { + "package": "involute_gear", + "command": command, + "version": "0.1.0", + "parameters": params, + } + + +def _copy_into_package(src: Path, dest_rel: str, pkg_root: Path) -> Path: + """Copy ``src`` into the package under ``dest_rel`` and return the destination path.""" + dest = pkg_root / dest_rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + return dest + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build an involute gear package (.ycpkg).") + parser.add_argument("--output", type=Path, required=True, help="Target directory for the package.") + parser.add_argument("--teeth", type=int, default=24) + parser.add_argument("--module-mm", type=float, default=2.0) + parser.add_argument("--face-width-mm", type=float, default=8.0) + parser.add_argument("--pressure-angle-deg", type=float, default=20.0) + args = parser.parse_args() + + profile_surface, gear_solid = generate_involute_spur( + teeth=args.teeth, + module_mm=args.module_mm, + face_width_mm=args.face_width_mm, + pressure_angle_deg=args.pressure_angle_deg, + ) + + params = { + "teeth": args.teeth, + "module_mm": args.module_mm, + "face_width_mm": args.face_width_mm, + "pressure_angle_deg": args.pressure_angle_deg, + } + + profile_meta = get_surface_metadata(profile_surface, create=True) + set_layer(profile_meta, "gear-profile") + add_tags(profile_meta, ["gear-profile"]) + _add_invocation(profile_meta, "INVOLUTE_SPUR2D", params) + + solid_meta = get_solid_metadata(gear_solid, create=True) + set_layer(solid_meta, "gear") + add_tags(solid_meta, ["gear", f"{args.teeth}-teeth"]) + _add_invocation(solid_meta, "INVOLUTE_SPUR", params) + + pkg_root = args.output + manifest = create_package_from_entities( + [ + {"geometry": profile_surface, "metadata": profile_meta}, + {"geometry": gear_solid, "metadata": solid_meta}, + ], + pkg_root, + name="Involute Spur Gear", + version="0.1.0", + description="Parametric involute spur gear", + author="involute-example", + units="mm", + overwrite=True, + ) + + # Bundle DSL + helper scripts inside the package for reproducibility. + example_root = Path(__file__).resolve().parent + dsl_src = example_root / "src" / "involute_gear.dsl" + helper_src = example_root / "scripts" / "involute_helpers.py" + dsl_dest = _copy_into_package(dsl_src, "src/involute_gear.dsl", pkg_root) + helper_dest = _copy_into_package(helper_src, "scripts/involute_helpers.py", pkg_root) + + source_block = manifest.data.setdefault("source", {}) + modules = source_block.setdefault("modules", []) + modules = [entry for entry in modules if entry.get("id") != "involute_gear"] + modules.append( + { + "id": "involute_gear", + "path": "src/involute_gear.dsl", + "language": "yapdsl-v0.1", + "exports": ["INVOLUTE_SPUR", "INVOLUTE_SPUR2D"], + "hash": _compute_hash(dsl_dest), + } + ) + source_block["modules"] = modules + + runtime = source_block.setdefault("runtime", {}) + python_runtime = runtime.setdefault("python", {}) + helpers = python_runtime.setdefault("helpers", []) + helpers = [entry for entry in helpers if entry.get("path") != "scripts/involute_helpers.py"] + helpers.append( + { + "path": "scripts/involute_helpers.py", + "hash": _compute_hash(helper_dest), + } + ) + python_runtime["helpers"] = helpers + runtime["python"] = python_runtime + source_block["runtime"] = runtime + + manifest.save() + print(f"Package written to {manifest.manifest_path}") + + +if __name__ == "__main__": + main() diff --git a/examples/involute_gear_package/involute_gear.py b/examples/involute_gear_package/involute_gear.py new file mode 100644 index 0000000..e22a1e4 --- /dev/null +++ b/examples/involute_gear_package/involute_gear.py @@ -0,0 +1,93 @@ +"""Utility functions for generating involute spur gears.""" + +from __future__ import annotations + +import math +from typing import List, Tuple + +from yapcad.geom import point, poly +from yapcad.geom3d import translatesolid, rotatesolid +from yapcad.geom3d_util import extrude, poly2surfaceXY +from yapcad.geom3d import point as point3d + +from yapcad.contrib.figgear import make_gear_figure as _figgear_make_gear + + +def _arc_points( + radius: float, + start_angle: float, + end_angle: float, + steps: int, + *, + clockwise: bool = False, +) -> List[Tuple[float, float]]: + if steps < 2: + steps = 2 + if clockwise: + if end_angle > start_angle: + end_angle -= 2 * math.pi + else: + if end_angle < start_angle: + end_angle += 2 * math.pi + samples = [] + for i in range(steps): + t = start_angle + (end_angle - start_angle) * i / (steps - 1) + samples.append((radius * math.cos(t), radius * math.sin(t))) + return samples + + +def generate_involute_profile( + teeth: int, + module_mm: float, + pressure_angle_deg: float = 20.0, + *, + tooth_resolution: int = 12, + tip_resolution: int = 6, + root_resolution: int = 5, +) -> List[Tuple[float, float]]: + if teeth < 6: + raise ValueError("teeth must be >= 6 for a usable spur gear") + if module_mm <= 0: + raise ValueError("module must be positive") + + figure_points, _ = _figgear_make_gear( + m=module_mm, + z=teeth, + alpha_deg=pressure_angle_deg, + bottom_type="line", + #bottom_type="spline", + involute_step=max(module_mm / max(tooth_resolution, 8), 0.05), + spline_division_num=max(root_resolution * 4, 20), + ) + + profile = [(float(x), float(y)) for x, y in figure_points] + if profile[0] != profile[-1]: + profile.append(profile[0]) + + area = 0.0 + for (x0, y0), (x1, y1) in zip(profile, profile[1:]): + area += x0 * y1 - x1 * y0 + if area < 0: + profile.reverse() + return profile + + +def generate_involute_spur(teeth: int, module_mm: float, face_width_mm: float, + pressure_angle_deg: float = 20.0) -> Tuple[object, object]: + outline_pts = generate_involute_profile(teeth, module_mm, pressure_angle_deg) + outline = [point(x, y) for x, y in outline_pts] + outline_geom = poly(outline) + surface, _ = poly2surfaceXY(outline_geom) + gear_solid = extrude(surface, face_width_mm) + if len(gear_solid) > 2: + gear_solid[2] = [] + if len(gear_solid) > 3: + gear_solid[3] = [] + return surface, gear_solid + + +def position_gear(solid_geom: list, centre: Tuple[float, float, float], spin_deg: float = 0.0) -> list: + placed = translatesolid(solid_geom, point3d(*centre)) + if spin_deg: + placed = rotatesolid(placed, spin_deg, axis=point3d(0, 0, 1), cent=point3d(*centre)) + return placed diff --git a/examples/involute_gear_package/sample_drive.py b/examples/involute_gear_package/sample_drive.py new file mode 100644 index 0000000..d623cc1 --- /dev/null +++ b/examples/involute_gear_package/sample_drive.py @@ -0,0 +1,121 @@ +"""Create a simple two-gear drive assembly using the involute gear helper.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from copy import deepcopy +from typing import Optional, Tuple + +from yapcad.metadata import get_solid_metadata, set_layer, add_tags +from yapcad.package import create_package_from_entities, PackageManifest, load_geometry +from yapcad.geom3d import issolid + +from .involute_gear import generate_involute_spur, position_gear + + +def _load_solid_from_package(pkg_path: Path) -> Tuple[list, dict]: + manifest = PackageManifest.load(pkg_path) + entities = load_geometry(manifest) + solids = [geom for geom in entities if issolid(geom)] + if not solids: + raise ValueError(f"package {pkg_path} did not contain any solids") + solid = deepcopy(solids[0]) + meta = get_solid_metadata(solid, create=False) or {} + return solid, meta + + +def _extract_invocation(meta: dict) -> dict: + invocation = meta.get("invocation") if meta else None + if isinstance(invocation, dict): + params = invocation.get("parameters") or {} + if isinstance(params, dict): + return params + return {} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build a simple two-gear drive package.") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--driver-teeth", type=int, default=18) + parser.add_argument("--driven-teeth", type=int, default=36) + parser.add_argument("--module-mm", type=float, default=1.5) + parser.add_argument("--face-width-mm", type=float, default=6.0) + parser.add_argument( + "--driver-package", + type=Path, + help="Optional .ycpkg providing a canonical driver gear to reuse.", + ) + parser.add_argument( + "--driven-package", + type=Path, + help="Optional .ycpkg providing a canonical driven gear to reuse.", + ) + args = parser.parse_args() + + driver_meta: Optional[dict] = None + driven_meta: Optional[dict] = None + + if args.driver_package: + driver_solid, driver_meta = _load_solid_from_package(args.driver_package) + else: + _, driver_solid = generate_involute_spur( + teeth=args.driver_teeth, + module_mm=args.module_mm, + face_width_mm=args.face_width_mm, + ) + + if args.driven_package: + driven_solid, driven_meta = _load_solid_from_package(args.driven_package) + else: + _, driven_solid = generate_involute_spur( + teeth=args.driven_teeth, + module_mm=args.module_mm, + face_width_mm=args.face_width_mm, + ) + + driver_params = _extract_invocation(driver_meta) if driver_meta else {} + driven_params = _extract_invocation(driven_meta) if driven_meta else {} + driver_teeth = driver_params.get("teeth", args.driver_teeth) + driven_teeth = driven_params.get("teeth", args.driven_teeth) + driver_module = driver_params.get("module_mm", args.module_mm) + driven_module = driven_params.get("module_mm", args.module_mm) + + if abs(driver_module - driven_module) > 1e-9: + raise ValueError( + f"driver module ({driver_module}) does not match driven module ({driven_module}); " + "gears must share the same module to mesh." + ) + module_mm = driver_module + + center_distance = module_mm * (driver_teeth + driven_teeth) / 2.0 + placed_driver = position_gear(driver_solid, centre=(0.0, 0.0, 0.0)) + placed_driven = position_gear(driven_solid, centre=(center_distance, 0.0, 0.0)) + + driver_meta = get_solid_metadata(placed_driver, create=True) + set_layer(driver_meta, "gear-driver") + add_tags(driver_meta, ["gear", "driver"]) + + driven_meta = get_solid_metadata(placed_driven, create=True) + set_layer(driven_meta, "gear-driven") + add_tags(driven_meta, ["gear", "driven"]) + + manifest = create_package_from_entities( + [ + {"geometry": placed_driver, "metadata": driver_meta}, + {"geometry": placed_driven, "metadata": driven_meta}, + ], + args.output, + name="Simple Gear Drive", + version="0.1.0", + description="Two-gear drive referencing involute gear helpers", + units="mm", + overwrite=True, + ) + + manifest.save() + print(f"Assembly package written to {manifest.manifest_path}") + + +if __name__ == "__main__": + main() diff --git a/examples/involute_gear_package/scripts/involute_helpers.py b/examples/involute_gear_package/scripts/involute_helpers.py new file mode 100644 index 0000000..b35cddd --- /dev/null +++ b/examples/involute_gear_package/scripts/involute_helpers.py @@ -0,0 +1,10 @@ +"""Placeholder helpers for the DSL fallback blocks.""" + +from __future__ import annotations + +from ..involute_gear import generate_involute_profile + + +def involute_profile(teeth: int, module_mm: float, pressure_angle_deg: float = 20.0): + """Return an involute profile as a list of (x, y) tuples.""" + return generate_involute_profile(teeth, module_mm, pressure_angle_deg) diff --git a/examples/involute_gear_package/src/involute_gear.dsl b/examples/involute_gear_package/src/involute_gear.dsl new file mode 100644 index 0000000..924525a --- /dev/null +++ b/examples/involute_gear_package/src/involute_gear.dsl @@ -0,0 +1,37 @@ +module involute_gear; + +command INVOLUTE_SPUR( + teeth: int, + module_mm: float, + face_width_mm: float, + pressure_angle_deg: float = 20.0 +) -> solid { + require teeth >= 6; + require module_mm > 0; + + profile = INVOLUTE_SPUR2D(teeth, module_mm, pressure_angle_deg); + blank = extrude(profile, face_width_mm); + emit blank with { + layer: "gear", + derived2d: profile, + metadata: { + "description": "Involute spur gear" + } + }; +} + +command INVOLUTE_SPUR2D( + teeth: int, + module_mm: float, + pressure_angle_deg: float = 20.0 +) -> polygon2d { + require teeth >= 6; + require module_mm > 0; + + python { + from scripts.involute_helpers import involute_profile + return involute_profile(teeth=teeth, + module_mm=module_mm, + pressure_angle_deg=pressure_angle_deg) + } +} diff --git a/examples/rocket_demo.py b/examples/rocket_demo.py index da368ae..5983478 100644 --- a/examples/rocket_demo.py +++ b/examples/rocket_demo.py @@ -6,12 +6,15 @@ from __future__ import annotations +import argparse from pathlib import Path from yapcad.geom import point, vect from yapcad.geom3d import solid, surface, translatesolid, rotatesolid from yapcad.geom3d_util import conic, extrude from yapcad.io.stl import write_stl +from yapcad.metadata import add_tags, set_material, get_solid_metadata, set_layer +from yapcad.package import create_package_from_entities def stack_conic(baser: float, topr: float, height: float, z0: float) -> list: @@ -84,6 +87,31 @@ def build_rocket() -> tuple[list[list], list[list]]: return components, assembly +def annotate_components(components: list[list], assembly: list) -> None: + """Attach basic metadata to individual components and the overall assembly.""" + for idx, comp in enumerate(components): + meta = get_solid_metadata(comp, create=True) + add_tags(meta, ['rocket', f'component-{idx}']) + if idx == 0: + set_material(meta, name='Aluminium 6061-T6') + # Assign simple layers for categories + structure_indices = list(range(7)) # core stack + propulsion_indices = list(range(7, 12)) # engines + aero_indices = list(range(12, len(components))) # fins + for idx in structure_indices: + if idx < len(components): + set_layer(get_solid_metadata(components[idx], create=True), 'structure') + for idx in propulsion_indices: + if idx < len(components): + set_layer(get_solid_metadata(components[idx], create=True), 'propulsion') + for idx in aero_indices: + if idx < len(components): + set_layer(get_solid_metadata(components[idx], create=True), 'aerodynamics') + add_tags(get_solid_metadata(assembly, create=True), ['rocket', 'assembly']) + set_material(get_solid_metadata(assembly, create=True), name='Composite') + set_layer(get_solid_metadata(assembly, create=True), 'assembly') + + def visualise(components: list[list]) -> None: """Render the rocket components with simple colouring.""" from yapcad.pyglet_drawable import pygletDraw @@ -106,15 +134,68 @@ def export_stl(solid_obj: list, output_path: Path) -> Path: return output_path -def main(): +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Generate the rocket demo geometry.") + parser.add_argument( + "--no-view", + action="store_true", + help="Skip launching the interactive viewer.", + ) + parser.add_argument( + "--package", + type=Path, + help="Optional output directory for a .ycpkg package.", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing package directory if present.", + ) + parser.add_argument( + "--name", + default="Rocket Demo", + help="Display name recorded in the package manifest.", + ) + parser.add_argument( + "--version", + default="0.1.0", + help="Version string recorded in the package manifest.", + ) + parser.add_argument( + "--description", + default="yapCAD rocket demonstration assembly", + help="Description stored in the package manifest.", + ) + return parser + + +def main(argv: list[str] | None = None): + parser = build_arg_parser() + args = parser.parse_args(argv) + components, assembly = build_rocket() + annotate_components(components, assembly) + out_path = export_stl(assembly, Path('rocket_demo.stl')) print(f'STL written to {out_path}') - try: - visualise(components) - except Exception as exc: - print(f'Visualisation skipped: {exc}') + if args.package: + pkg_dir = args.package + manifest = create_package_from_entities( + components + [assembly], + pkg_dir, + name=args.name, + version=args.version, + description=args.description, + overwrite=args.overwrite, + ) + print(f'Package created at {manifest.manifest_path}') + + if not args.no_view: + try: + visualise(components) + except Exception as exc: + print(f'Visualisation skipped: {exc}') if __name__ == '__main__': diff --git a/pyproject.toml b/pyproject.toml index cfd966b..86eb828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,15 @@ [project] name = "yapCAD" -version = "0.5.1" +version = "0.6.0" authors = [ { name="Richard DeVaul", email="richard.devaul@gmail.com" }, ] description = "yet another procedural CAD and computational geometry system" readme = "README.rst" requires-python = ">=3.10" +license = "MIT" classifiers = [ "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dependencies = [ @@ -18,6 +18,7 @@ dependencies = [ "mpmath>=1.2", "numpy>=1.22", "mapbox-earcut>=1.0.3", + "PyYAML>=6.0", "pyobjc-core; platform_system == 'Darwin'", "pyobjc-framework-Cocoa; platform_system == 'Darwin'", "pyobjc-framework-Quartz; platform_system == 'Darwin'" diff --git a/requirements.txt b/requirements.txt index 20dbce7..c44ce97 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ pyglet>=1.5,<2 mpmath>=1.2 numpy>=1.22 mapbox-earcut>=1.0.3 +PyYAML>=6.0 pyobjc-core; platform_system == "Darwin" pyobjc-framework-Cocoa; platform_system == "Darwin" pyobjc-framework-Quartz; platform_system == "Darwin" diff --git a/setup.cfg b/setup.cfg index aeaeb6a..4b8ff70 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,35 +12,11 @@ package_dir = =src # DON'T CHANGE THE FOLLOWING LINE! IT WILL BE UPDATED BY PYSCAFFOLD! setup_requires = pyscaffold>=3.2a0,<3.3a0 -# Add here dependencies of your project (semicolon/line-separated), e.g. -install_requires = - ezdxf>=1.1 - pyglet>=1.5,<2 - mpmath>=1.2 - numpy>=1.22 - mapbox-earcut>=1.0.3 - pyobjc-core; platform_system == "Darwin" - pyobjc-framework-Cocoa; platform_system == "Darwin" - pyobjc-framework-Quartz; platform_system == "Darwin" -# The usage of test_requires is discouraged, see `Dependency Management` docs -# tests_require = pytest; pytest-cov -# Require a specific Python version, e.g. Python 2.7 or >= 3.4 -python_requires = >=3.10 - [options.packages.find] where = src exclude = tests -[options.extras_require] -# Add here additional requirements for extra features, to install with: -# `pip install yapCAD[PDF]` like: -# PDF = ReportLab; RXP -# Add here test requirements (semicolon/line-separated) -testing = - pytest - pytest-cov - [options.entry_points] # Add here console scripts like: # console_scripts = @@ -75,8 +51,8 @@ testpaths = tests dists = bdist_wheel [bdist_wheel] -# Use this option if your package is pure-python -universal = 1 +# Build platform-specific wheels; pure-python installs remain supported. +universal = 0 [build_sphinx] source_dir = docs diff --git a/src/yapcad/boolean/native.py b/src/yapcad/boolean/native.py index deb2f43..38a15a9 100644 --- a/src/yapcad/boolean/native.py +++ b/src/yapcad/boolean/native.py @@ -99,6 +99,12 @@ def _candidate_planes_for_triangle(tri, target, tri_plane, tol): candidates.append(elem) if not candidates: candidates = list(_iter_triangles_from_surface(surf)) + meta = _ensure_surface_metadata_dict(surf) + orientation = meta.get('_surface_orientation') + if orientation is None: + orientation = _determine_surface_orientation(surf, target, extent, tol) + meta['_surface_orientation'] = orientation + sense_value = -orientation for cand in candidates: plane = _triangle_plane(cand) n, d = plane @@ -107,17 +113,19 @@ def _candidate_planes_for_triangle(tri, target, tri_plane, tol): (cand[0][1] + cand[1][1] + cand[2][1]) / 3.0, (cand[0][2] + cand[1][2] + cand[2][2]) / 3.0, ) - vec = point(centroid[0] - center[0], - centroid[1] - center[1], - centroid[2] - center[2]) - dot_sign = n[0] * vec[0] + n[1] * vec[1] + n[2] * vec[2] - if dot_sign > epsilon_dot: - sense = -1 - elif dot_sign < -epsilon_dot: - sense = 1 - else: - center_eval = _plane_eval(plane, center) - sense = -1 if center_eval <= 0 else 1 + sense = sense_value + if sense == 0: + vec = point(centroid[0] - center[0], + centroid[1] - center[1], + centroid[2] - center[2]) + dot_sign = n[0] * vec[0] + n[1] * vec[1] + n[2] * vec[2] + if dot_sign > epsilon_dot: + sense = -1 + elif dot_sign < -epsilon_dot: + sense = 1 + else: + center_eval = _plane_eval(plane, center) + sense = -1 if center_eval <= 0 else 1 plane_with_sense = (n, d, sense) key = _plane_key(plane_with_sense, tol) if key not in seen: @@ -297,6 +305,41 @@ def _plane_key(plane, tol): return (round(n[0] * scale), round(n[1] * scale), round(n[2] * scale), round(d * scale), sense) +def _plane_key_no_sense(plane, tol): + n, d = plane + scale = 1.0 / tol + return (round(n[0] * scale), round(n[1] * scale), round(n[2] * scale), round(d * scale)) + + +def _determine_surface_orientation(surf, solid, extent, tol): + offset = max(extent * 1e-3, tol * 1000.0, 1e-3) + eval_tol = max(tol * 10.0, 1e-6) + for tri in _iter_triangles_from_surface(surf): + try: + center, normal = _geom3d().tri2p0n(tri) + except ValueError: + continue + mag_n = _mag3([normal[0], normal[1], normal[2]]) + if mag_n < tol: + continue + inside_probe = point(center[0] - normal[0] * offset, + center[1] - normal[1] * offset, + center[2] - normal[2] * offset) + outside_probe = point(center[0] + normal[0] * offset, + center[1] + normal[1] * offset, + center[2] + normal[2] * offset) + try: + inside_contains = solid_contains_point(solid, inside_probe, tol=eval_tol) + outside_contains = solid_contains_point(solid, outside_probe, tol=eval_tol) + except ValueError: + break + if inside_contains and not outside_contains: + return 1 + if outside_contains and not inside_contains: + return -1 + return 1 + + def _sub3(a, b): return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] @@ -741,6 +784,85 @@ def _boolean_fragments(source, target, tol): return outside_tris, inside_tris, inside_overlap +def _union_boundary_from_inside(triangles, other, tol): + if not triangles: + return [] + box = _geom3d().solidbbox(other) + if box: + extent = max(box[1][0] - box[0][0], + box[1][1] - box[0][1], + box[1][2] - box[0][2]) + else: + extent = 1.0 + offset = max(extent * 1e-3, tol * 1000.0, 1e-3) + boundary = [] + for tri in triangles: + try: + center, normal = _geom3d().tri2p0n(tri) + except ValueError: + continue + mag_n = _mag3([normal[0], normal[1], normal[2]]) + if mag_n < tol: + continue + unit = [normal[0] / mag_n, normal[1] / mag_n, normal[2] / mag_n] + probe = point(center[0] + unit[0] * offset, + center[1] + unit[1] * offset, + center[2] + unit[2] * offset) + if not box or not _geom3d().isinsidebbox(box, probe): + boundary.append(tri) + continue + hits = _collect_segment_intersections(center, probe, other, tol) + if hits: + earliest = min(t for t, _ in hits) + if earliest < 1.0 - tol * 10.0: + boundary.append(tri) + continue + try: + check_tol = max(tol * 100.0, 1e-6) + if not solid_contains_point(other, probe, tol=check_tol): + boundary.append(tri) + except ValueError: + boundary.append(tri) + return boundary + + +def _filter_triangles_against_other(triangles, other, tol): + if not triangles: + return [] + box = _geom3d().solidbbox(other) + if box: + extent = max(box[1][0] - box[0][0], + box[1][1] - box[0][1], + box[1][2] - box[0][2]) + else: + extent = 1.0 + offset = max(extent * 1e-3, tol * 1000.0, 1e-3) + check_tol = max(tol * 100.0, 1e-6) + filtered = [] + for tri in triangles: + try: + center, normal = _geom3d().tri2p0n(tri) + except ValueError: + filtered.append(tri) + continue + mag_n = _mag3([normal[0], normal[1], normal[2]]) + if mag_n < tol: + filtered.append(tri) + continue + unit = [normal[0] / mag_n, normal[1] / mag_n, normal[2] / mag_n] + probe = point(center[0] + unit[0] * offset, + center[1] + unit[1] * offset, + center[2] + unit[2] * offset) + if box and not _geom3d().isinsidebbox(box, probe): + filtered.append(tri) + continue + try: + if not solid_contains_point(other, probe, tol=check_tol): + filtered.append(tri) + except ValueError: + filtered.append(tri) + return filtered + def _ray_triangle_intersection(origin, direction, triangle, tol=_DEFAULT_RAY_TOL): v0, v1, v2 = triangle @@ -969,27 +1091,28 @@ def solid_boolean(a, b, operation, tol=_DEFAULT_RAY_TOL, *, stitch=False): outside_b, inside_b, overlap_b = _boolean_fragments(b, a, tol) if operation == 'union': - result_tris = outside_a + outside_b + result_tris = [] + filtered_outside_a = _filter_triangles_against_other(outside_a, b, tol) + filtered_outside_b = _filter_triangles_against_other(outside_b, a, tol) + if filtered_outside_a: + result_tris.extend(filtered_outside_a) + if filtered_outside_b: + result_tris.extend(filtered_outside_b) + boundary_a = _union_boundary_from_inside(inside_a, b, tol) + if boundary_a: + result_tris.extend(boundary_a) + boundary_b = _union_boundary_from_inside(inside_b, a, tol) + if boundary_b: + result_tris.extend(boundary_b) + filtered_overlap_a = _filter_triangles_against_other(overlap_a, b, tol) + filtered_overlap_b = _filter_triangles_against_other(overlap_b, a, tol) + if filtered_overlap_a: + result_tris.extend(filtered_overlap_a) + if filtered_overlap_b: + result_tris.extend(filtered_overlap_b) if not result_tris: result_tris = inside_a if inside_a else inside_b - # Filter out triangles in the interior of the overlap region - # For union, if a triangle's center is inside both input solids, - # it's in the interior and should not be on the surface - filtered_tris = [] - for tri in result_tris: - center = point((tri[0][0] + tri[1][0] + tri[2][0]) / 3.0, - (tri[0][1] + tri[1][1] + tri[2][1]) / 3.0, - (tri[0][2] + tri[1][2] + tri[2][2]) / 3.0) - # Use a tighter tolerance for containment check to avoid false positives - check_tol = tol * 100 - in_a = solid_contains_point(a, center, tol=check_tol) - in_b = solid_contains_point(b, center, tol=check_tol) - # Keep triangle only if it's not clearly inside both solids - if not (in_a and in_b): - filtered_tris.append(tri) - result_tris = filtered_tris - elif operation == 'intersection': result_tris = inside_a + inside_b if not result_tris: @@ -1007,6 +1130,3 @@ def solid_boolean(a, b, operation, tol=_DEFAULT_RAY_TOL, *, stitch=False): if surface_result: return _geom3d().solid([surface_result], [], ['boolean', operation]) return _geom3d().solid([], [], ['boolean', operation]) - - - diff --git a/src/yapcad/contrib/__init__.py b/src/yapcad/contrib/__init__.py new file mode 100644 index 0000000..f71e821 --- /dev/null +++ b/src/yapcad/contrib/__init__.py @@ -0,0 +1,9 @@ +""" +Utility modules that are vendored or optional extras. + +Currently includes a lightweight port of ``figgear``'s involute gear +generator so that yapCAD can generate gear profiles without requiring +an external dependency at build/test time. +""" + +__all__ = ["figgear"] diff --git a/src/yapcad/contrib/figgear.py b/src/yapcad/contrib/figgear.py new file mode 100644 index 0000000..1deefb6 --- /dev/null +++ b/src/yapcad/contrib/figgear.py @@ -0,0 +1,236 @@ +""" +Vendored helpers derived from the MIT-licensed ``figgear`` project. + +Original project: https://github.com/chromia/figgear + +MIT License +----------- + +Copyright (c) chromia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +This module provides ``make_gear_figure`` with a compatible signature but +implements the limited subset of functionality that yapCAD requires. The +implementation relies only on the Python standard library to avoid adding +heavy runtime dependencies such as SciPy. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Iterable, List, Sequence, Tuple, Dict + +Point = Tuple[float, float] +PointList = List[Point] + + +@dataclass(frozen=True) +class _GearParameters: + module: float + teeth: int + pressure_angle_deg: float + involute_step: float + spline_division_num: int + bottom_type: str + + +def _inv(alpha: float) -> float: + """Return the involute function for ``alpha``.""" + return math.tan(alpha) - alpha + + +def _catmull_rom( + control: Sequence[Point], + *, + division_num: int, +) -> Iterable[Point]: + """Yield interpolated points between control[1] and control[2].""" + + if division_num <= 1: + return [] + + p0, p1, p2, p3 = control + for i in range(1, division_num): + t = i / division_num + t2 = t * t + t3 = t2 * t + cx = ( + 0.5 + * ( + (2 * p1[0]) + + (-p0[0] + p2[0]) * t + + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3 + ) + ) + cy = ( + 0.5 + * ( + (2 * p1[1]) + + (-p0[1] + p2[1]) * t + + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3 + ) + ) + yield (cx, cy) + + +def _add_bottom_points_line(points: PointList, new_points: Sequence[Point]) -> None: + """Append the interior points of the tooth root as straight segments.""" + points.extend(new_points[1:-1]) + + +def _add_bottom_points_spline( + points: PointList, + new_points: Sequence[Point], + division_num: int, +) -> None: + """Append interpolated points along the tooth root using a cubic spline.""" + if division_num <= 1: + _add_bottom_points_line(points, new_points) + return + points.extend(_catmull_rom(new_points, division_num=division_num)) + + +def _ensure_closed(points: PointList) -> None: + if points and points[0] != points[-1]: + points.append(points[0]) + + +def make_gear_figure( + m: float, + z: int, + alpha_deg: float, + bottom_type: str, + **kwargs, +) -> Tuple[PointList, Dict[str, float]]: + """Generate a 2D involute spur gear profile.""" + + if z <= 0: + raise ValueError("gear must have a positive tooth count") + if m <= 0: + raise ValueError("module must be positive") + bottom_type = bottom_type.lower() + if bottom_type not in {"spline", "line"}: + raise ValueError("bottom_type must be 'spline' or 'line'") + + params = _GearParameters( + module=m, + teeth=z, + pressure_angle_deg=alpha_deg, + involute_step=float(kwargs.get("involute_step", 0.5)), + spline_division_num=int(kwargs.get("spline_division_num", 50)), + bottom_type=bottom_type, + ) + if params.involute_step <= 0: + raise ValueError("involute_step must be positive") + if params.spline_division_num <= 0: + raise ValueError("spline_division_num must be positive") + + alpha = math.radians(params.pressure_angle_deg) + pitch = params.module * math.pi + tooth_thickness = pitch / 2.0 + + diameter_pitch = params.teeth * params.module + diameter_addendum = diameter_pitch + 2 * params.module + diameter_dedendum = diameter_pitch - 2.5 * params.module + diameter_base = diameter_pitch * math.cos(alpha) + + radius_pitch = diameter_pitch / 2.0 + radius_addendum = diameter_addendum / 2.0 + radius_dedendum = diameter_dedendum / 2.0 + radius_base = diameter_base / 2.0 + + angle_per_tooth = 2 * math.pi / params.teeth + angle_thickness = tooth_thickness / radius_pitch + inv_at_pitch = _inv(math.acos(radius_base / radius_pitch)) + angle_base = angle_thickness + inv_at_pitch * 2.0 + angle_bottom = angle_per_tooth - angle_base + + cos_bottom = math.cos(-angle_bottom) + sin_bottom = math.sin(-angle_bottom) + + inv_segments = max(2, int(math.ceil((radius_addendum - radius_base) / params.involute_step))) + + profile: PointList = [] + + for tooth_idx in range(params.teeth): + t = angle_per_tooth * tooth_idx + cos_t = math.cos(t) + sin_t = math.sin(t) + + xa = radius_base * cos_t + ya = radius_base * sin_t + xb = radius_dedendum * cos_t + yb = radius_dedendum * sin_t + xc = xb * cos_bottom - yb * sin_bottom + yc = xb * sin_bottom + yb * cos_bottom + xd = xa * cos_bottom - ya * sin_bottom + yd = xa * sin_bottom + ya * cos_bottom + base_points = [(xd, yd), (xc, yc), (xb, yb), (xa, ya)] + + if params.bottom_type == "line": + _add_bottom_points_line(profile, base_points) + else: + _add_bottom_points_spline( + profile, base_points, division_num=params.spline_division_num + ) + + points_inv1: PointList = [] + points_inv2: PointList = [] + cos_inv2 = math.cos(t + angle_base) + sin_inv2 = math.sin(t + angle_base) + + for segment in range(inv_segments + 1): + r = radius_base + (radius_addendum - radius_base) * (segment / inv_segments) + r = max(r, radius_base) + inv_alpha = _inv(math.acos(radius_base / r)) + x = r * math.cos(inv_alpha) + y = r * math.sin(inv_alpha) + + x1 = x * cos_t - y * sin_t + y1 = x * sin_t + y * cos_t + points_inv1.append((x1, y1)) + + x2 = x * cos_inv2 - (-y) * sin_inv2 + y2 = x * sin_inv2 + (-y) * cos_inv2 + points_inv2.append((x2, y2)) + + profile.extend(points_inv1) + profile.extend(reversed(points_inv2)) + + _ensure_closed(profile) + + blueprints = { + "diameter_addendum": diameter_addendum, + "diameter_pitch": diameter_pitch, + "diameter_base": diameter_base, + "diameter_dedendum": diameter_dedendum, + "radius_addendum": radius_addendum, + "radius_pitch": radius_pitch, + "radius_base": radius_base, + "radius_dedendum": radius_dedendum, + } + + return profile, blueprints + + +__all__ = ["make_gear_figure"] diff --git a/src/yapcad/geom.py b/src/yapcad/geom.py index e701a16..deff28e 100644 --- a/src/yapcad/geom.py +++ b/src/yapcad/geom.py @@ -2277,6 +2277,7 @@ def isgeomlist(a): return False b = list(filter(lambda x: not (ispoint(x) or isline(x) \ or isarc(x) or ispoly(x) \ + or iscatmullrom(x) or isnurbs(x) \ or isgeomlist(x)),a)) return not len(b) > 0 @@ -2492,6 +2493,10 @@ def geomlistbbox(gl): ply = ply+g elif isarc(g): ply = ply + arcbbox(g) + elif iscatmullrom(g) or isnurbs(g): + segs = 64 + for i in range(segs + 1): + ply.append(sample(g, i / segs)) elif isgeomlist(g): ply = ply + geomlistbbox(g) else: diff --git a/src/yapcad/geom3d_util.py b/src/yapcad/geom3d_util.py index 9a344a2..585d5b2 100644 --- a/src/yapcad/geom3d_util.py +++ b/src/yapcad/geom3d_util.py @@ -21,13 +21,13 @@ def sphere2cartesian(lat,lon,rad): """ - Utility function to convert spherical polar coordinates to - cartesian coordinates for a sphere centered at the origin. - ``lat`` -- latitude - ``lon`` -- longitude - ``rad`` -- sphere radius + Convert spherical polar coordinates to Cartesian coordinates for a + sphere centered at the origin. - returns a ``yapcad.geom`` point + :param lat: latitude in degrees + :param lon: longitude in degrees + :param rad: sphere radius + :returns: ``yapcad.geom`` point in homogeneous coordinates """ if lat == 90: return [0,0,rad,1] @@ -393,11 +393,14 @@ def conic(baser,topr,height, center=point(0,0,0),angr=10): def makeRevolutionSurface(contour,zStart,zEnd,steps,arcSamples=36): """ - Take a countour (any function z->y mapped over the interval - - ``zStart`` and ``zEnd`` and produce the surface of revolution - around the z axis. Sample ``steps`` contours of the function, - which in turn are turned into circles sampled `arcSamples`` times. + Generate a surface of revolution by sampling a contour function. + + :param contour: callable mapping ``z`` to a radial distance + :param zStart: lower bound for the ``z`` interval + :param zEnd: upper bound for the ``z`` interval + :param steps: number of contour samples between ``zStart`` and ``zEnd`` + :param arcSamples: number of samples around the revolution arc + :returns: ``['surface', vertices, normals, faces]`` list representing the surface """ sV=[] @@ -459,13 +462,13 @@ def makeRevolutionSurface(contour,zStart,zEnd,steps,arcSamples=36): pp2 = [angle_cos[a2_idx]*r1, angle_sin[a2_idx]*r1, z+zD, 1.0] try: - _, n = tri2p0n([sV[start_pole_idx], pp1, pp2]) + _, n = tri2p0n([sV[start_pole_idx], pp2, pp1]) except ValueError: continue k1, sV, sN = addVertex(pp1, n, sV, sN) k2, sV, sN = addVertex(pp2, n, sV, sN) - sF.append([start_pole_idx, k1, k2]) + sF.append([start_pole_idx, k2, k1]) continue if i == steps - 1 and need_end_cap: @@ -480,13 +483,13 @@ def makeRevolutionSurface(contour,zStart,zEnd,steps,arcSamples=36): p2 = [angle_cos[a2_idx]*r0, angle_sin[a2_idx]*r0, z, 1.0] try: - _, n = tri2p0n([p1, sV[end_pole_idx], p2]) + _, n = tri2p0n([p1, p2, sV[end_pole_idx]]) except ValueError: continue k1, sV, sN = addVertex(p1, n, sV, sN) k2, sV, sN = addVertex(p2, n, sV, sN) - sF.append([k1, end_pole_idx, k2]) + sF.append([k1, k2, end_pole_idx]) continue # Regular quad strips for non-pole sections diff --git a/src/yapcad/geometry.py b/src/yapcad/geometry.py index 903588a..3abfe5f 100644 --- a/src/yapcad/geometry.py +++ b/src/yapcad/geometry.py @@ -417,15 +417,13 @@ def intersectXY(self,g,inside=True,params=False): def surface(self,minang = 5.0, minlen = 0.5): """ - triangulate a closed polyline or geometry list, return a surface. - the ``minang`` parameter specifies a minimum angular resolution - for sampling arcs, and ``minleng`` specifies a minimum distance - between sampled points. - ``surface = ['surface',vertices,normals,faces]``, where: - ``vertices`` is a list of ``yapcad.geom`` points, - ``normals`` is a list of ``yapcad.geom`` points of the same length as ``vertices``, - and ``faces`` is the list of faces, which is to say lists of three indices that - refer to the vertices of the triangle that represents each face. + Triangulate a closed polyline or geometry list and return a surface. + + :param minang: minimum angular resolution (degrees) for sampling arcs + :param minlen: minimum distance between sampled points + :returns: ``['surface', vertices, normals, faces]``. ``vertices`` and + ``normals`` are aligned lists of ``yapcad.geom`` points; ``faces`` is + a list of index triples describing the triangular mesh. """ if not self.isclosed(): raise ValueError("non-closed figure has no surface representation") diff --git a/src/yapcad/io/geometry_json.py b/src/yapcad/io/geometry_json.py new file mode 100644 index 0000000..928ff6c --- /dev/null +++ b/src/yapcad/io/geometry_json.py @@ -0,0 +1,544 @@ +"""Geometry JSON serialization/deserialization helpers. + +Implements the draft schema described in ``docs/geometry_json_schema.md``. +""" + +from __future__ import annotations + +import uuid +from typing import Any, Dict, Iterable, List, Optional, Tuple, Sequence + +from yapcad.geom import ( + arc, + catmullrom, + isnurbs, + iscatmullrom, + nurbs, + point, + vect, + bbox as bbox2d, + isarc, + iscircle, + isgeomlist, + isline, + length, + line, + sample, +) +from yapcad.geom3d import issolid, issurface, solidbbox, surfacebbox +from yapcad.metadata import ( + get_solid_metadata, + get_surface_metadata, + ensure_solid_id, + ensure_surface_id, + set_solid_metadata, + set_surface_metadata, + set_layer, +) +SCHEMA_ID = "yapcad-geometry-json-v0.1" + + +def _float_vec(vec: Iterable[float]) -> List[float]: + return [float(c) for c in vec] + + +def _int_vec(vec: Iterable[int]) -> List[int]: + return [int(c) for c in vec] + + +def _bbox_or_none(box: Optional[List[List[float]]]) -> Optional[List[float]]: + if not box: + return None + (xmin, ymin, zmin, _), (xmax, ymax, zmax, _) = box + return [float(xmin), float(ymin), float(zmin), float(xmax), float(ymax), float(zmax)] + + +def _point_components(pt: Sequence[float]) -> List[float]: + """Return point components including homogeneous coordinate.""" + x = float(pt[0]) + y = float(pt[1]) + z = float(pt[2]) if len(pt) > 2 else 0.0 + w = float(pt[3]) if len(pt) > 3 else 1.0 + return [x, y, z, w] + + +def _point_from_components(components: Sequence[float]) -> List[float]: + if len(components) >= 4: + return point(float(components[0]), float(components[1]), float(components[2]), float(components[3])) + if len(components) == 3: + return point(float(components[0]), float(components[1]), float(components[2])) + return point(float(components[0]), float(components[1])) + + +def _serialize_surface(surface: list, metadata_override: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + try: + surface_id = ensure_surface_id(surface) + except Exception as exc: + raise RuntimeError(f"failed to ensure surface metadata for {surface!r}") from exc + metadata = get_surface_metadata(surface, create=True) + if metadata_override: + metadata.update(metadata_override) + if "layer" not in metadata or not metadata.get("layer"): + metadata["layer"] = "default" + verts = surface[1] + norms = surface[2] + faces = surface[3] + try: + bbox = _bbox_or_none(surfacebbox(surface)) + except Exception: + bbox = None + return { + "id": metadata.get("entityId", surface_id), + "type": "surface", + "name": metadata.get("name"), + "metadata": metadata, + "boundingBox": bbox, + "properties": {}, + "vertices": [_float_vec(v) for v in verts], + "normals": [_float_vec(n) for n in norms], + "faces": [_int_vec(face) for face in faces], + "triangulation": { + "winding": "ccw", + "topology": "triangle", + }, + } + + +def _serialize_solid(solid: list, surface_cache: Dict[str, Dict[str, Any]], metadata_override: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + solid_id = ensure_solid_id(solid) + metadata = get_solid_metadata(solid, create=True) + if metadata_override: + metadata.update(metadata_override) + if "layer" not in metadata or not metadata.get("layer"): + metadata["layer"] = "default" + try: + bbox = _bbox_or_none(solidbbox(solid)) + except Exception: + bbox = None + + shell_ids: List[str] = [] + layers_seen = [] + parent_layer = metadata.get("layer") + for surf in solid[1]: + if not issurface(surf): + continue + surface_meta = get_surface_metadata(surf, create=True) + surface_override = None + if metadata_override: + surface_override = dict(metadata_override) + if parent_layer and (not surface_meta.get("layer") or surface_meta.get("layer") == "default"): + surface_override = dict(surface_override or {}) + surface_override["layer"] = parent_layer + serialized = _serialize_surface(surf, surface_override) + surface_id = serialized["id"] + while surface_id in surface_cache: + new_id = uuid.uuid4().hex + meta = serialized["metadata"] + meta["entityId"] = new_id + meta["id"] = new_id + serialized["id"] = new_id + set_surface_metadata(surf, meta) + surface_id = new_id + surface_cache[surface_id] = serialized + shell_ids.append(surface_id) + layers_seen.append(serialized["metadata"].get("layer", "default")) + + voids: List[List[str]] = [] + if len(solid) > 2: + for void in solid[2] or []: + void_ids: List[str] = [] + for surf in void: + if not issurface(surf): + continue + surface_meta = get_surface_metadata(surf, create=True) + surface_override = None + if metadata_override: + surface_override = dict(metadata_override) + if parent_layer and (not surface_meta.get("layer") or surface_meta.get("layer") == "default"): + surface_override = dict(surface_override or {}) + surface_override["layer"] = parent_layer + serialized = _serialize_surface(surf, surface_override) + surface_id = serialized["id"] + while surface_id in surface_cache: + new_id = uuid.uuid4().hex + meta = serialized["metadata"] + meta["entityId"] = new_id + meta["id"] = new_id + serialized["id"] = new_id + set_surface_metadata(surf, meta) + surface_id = new_id + surface_cache[surface_id] = serialized + void_ids.append(surface_id) + layers_seen.append(serialized["metadata"].get("layer", "default")) + voids.append(void_ids) + + if "layer" not in metadata or not metadata.get("layer"): + unique_layers = [layer for layer in layers_seen if layer] + if unique_layers: + first_layer = unique_layers[0] + if all(layer == first_layer for layer in unique_layers): + metadata["layer"] = first_layer + else: + metadata["layer"] = "default" + else: + metadata["layer"] = "default" + + return { + "id": metadata.get("entityId", solid_id), + "type": "solid", + "name": metadata.get("name"), + "metadata": metadata, + "boundingBox": bbox, + "properties": {}, + "shell": shell_ids, + "voids": voids, + } + + +def _polyline_points(sequence: List[float]) -> List[float]: + return [float(sequence[0]), float(sequence[1])] + + +def _sample_geometry_element(element: list, min_segments: int = 8) -> List[List[float]]: + if isline(element): + return [ + _polyline_points(element[0]), + _polyline_points(element[1]), + ] + if iscatmullrom(element) or isnurbs(element): + segs = max(min_segments, 32) + else: + segs = max(min_segments, int(max(length(element), 1.0))) + points = [] + for i in range(segs + 1): + t = i / segs + p = sample(element, t) + points.append(_polyline_points(p)) + return points + + +def _serialize_sketch(geomlist: list, metadata_override: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + poly_vectors: List[List[List[float]]] = [] + primitives: List[Dict[str, Any]] = [] + + for element in geomlist: + if isline(element): + start = _polyline_points(element[0]) + end = _polyline_points(element[1]) + poly_vectors.append([start, end]) + primitives.append( + { + "kind": "line", + "start": start, + "end": end, + } + ) + elif iscircle(element): + center = _point_components(element[0]) + radius = float(element[1][0]) + primitive: Dict[str, Any] = { + "kind": "circle", + "center": center, + "radius": radius, + "orientation": int(element[1][3]), + } + if len(element) >= 3: + primitive["normal"] = _point_components(element[2]) + primitives.append(primitive) + poly_vectors.append(_sample_geometry_element(element)) + elif isarc(element): + center = _point_components(element[0]) + radius = float(element[1][0]) + start_angle = float(element[1][1]) + end_angle = float(element[1][2]) + primitive = { + "kind": "arc", + "center": center, + "radius": radius, + "start": start_angle, + "end": end_angle, + "orientation": int(element[1][3]), + } + if len(element) >= 3: + primitive["normal"] = _point_components(element[2]) + primitives.append(primitive) + poly_vectors.append(_sample_geometry_element(element)) + elif iscatmullrom(element): + control_points = [_point_components(pt) for pt in element[1]] + params = dict(element[2]) + primitives.append( + { + "kind": "catmullrom", + "points": control_points, + "params": params, + } + ) + poly_vectors.append(_sample_geometry_element(element)) + elif isnurbs(element): + control_points = [_point_components(pt) for pt in element[1]] + params = dict(element[2]) + primitives.append( + { + "kind": "nurbs", + "points": control_points, + "params": params, + } + ) + poly_vectors.append(_sample_geometry_element(element)) + elif isinstance(element, list) and len(element) >= 2 and all(isinstance(pt, list) for pt in element): + coords = [_polyline_points(pt) for pt in element] + if coords: + poly_vectors.append(coords) + primitives.append({"kind": "polyline", "points": coords}) + + box = bbox2d(geomlist) + if box: + min_pt, max_pt = box + bbox = [float(min_pt[0]), float(min_pt[1]), float(max_pt[0]), float(max_pt[1])] + else: + bbox = None + metadata = { + "schema": "metadata-namespace-v0.1", + "entityId": str(uuid.uuid4()), + "tags": [], + "layer": "default", + } + if metadata_override: + metadata.update(metadata_override) + return { + "id": metadata["entityId"], + "type": "sketch", + "name": None, + "metadata": metadata, + "boundingBox": bbox, + "polylines": poly_vectors, + "primitives": primitives, + } + + +def geometry_to_json( + entities: Iterable[list], + *, + units: Optional[str] = None, + generator: Optional[Dict[str, Any]] = None, + relationships: Optional[List[Dict[str, Any]]] = None, + attachments: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """Serialize solids/surfaces into the geometry JSON document.""" + serialized_entities: List[Dict[str, Any]] = [] + surface_cache: Dict[str, Dict[str, Any]] = {} + + for item in entities: + metadata_override = None + entity = item + if isinstance(item, dict) and 'geometry' in item: + metadata_override = dict(item.get('metadata', {})) + entity = item['geometry'] + elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], dict): + entity, metadata_override = item[0], dict(item[1]) + + if issolid(entity): + if metadata_override: + meta = get_solid_metadata(entity, create=True) + layer = metadata_override.get("layer") + if layer: + set_layer(meta, layer) + meta.update({k: v for k, v in metadata_override.items() if k != "layer"}) + solid_entry = _serialize_solid(entity, surface_cache, metadata_override) + serialized_entities.append(solid_entry) + elif issurface(entity): + if metadata_override: + meta = get_surface_metadata(entity, create=True) + layer = metadata_override.get("layer") + if layer: + set_layer(meta, layer) + meta.update({k: v for k, v in metadata_override.items() if k != "layer"}) + surface_entry = _serialize_surface(entity, metadata_override) + surface_cache.setdefault(surface_entry["id"], surface_entry) + elif isgeomlist(entity): + sketch_entry = _serialize_sketch(entity, metadata_override) + serialized_entities.append(sketch_entry) + else: + raise ValueError("unsupported entity type for serialization") + + serialized_entities.extend(surface_cache.values()) + + doc: Dict[str, Any] = { + "schema": SCHEMA_ID, + "entities": serialized_entities, + } + if units: + doc["units"] = units + if generator: + doc["generator"] = generator + if relationships: + doc["relationships"] = list(relationships) + if attachments: + doc["attachments"] = list(attachments) + return doc + + +def _rehydrate_surface(entry: Dict[str, Any]) -> list: + verts = [list(map(float, v)) for v in entry.get("vertices", [])] + norms = [list(map(float, n)) for n in entry.get("normals", [])] + faces = [list(map(int, f)) for f in entry.get("faces", [])] + surface = ['surface', verts, norms, faces, [], []] + metadata = entry.get("metadata") + if metadata: + set_surface_metadata(surface, metadata) + return surface + + +def geometry_from_json(doc: Dict[str, Any]) -> List[list]: + """Deserialize geometry JSON into yapCAD list structures.""" + if doc.get("schema") != SCHEMA_ID: + raise ValueError(f"unsupported geometry schema: {doc.get('schema')}") + + entries_by_id: Dict[str, Dict[str, Any]] = {} + for entry in doc.get("entities", []): + entry_id = entry.get("id") + if not entry_id: + raise ValueError("entity missing id") + entries_by_id[entry_id] = entry + + surfaces: Dict[str, list] = {} + solids: List[list] = [] + + # First pass: instantiate surfaces explicitly listed + for entry in doc.get("entities", []): + if entry.get("type") == "surface": + surfaces[entry["id"]] = _rehydrate_surface(entry) + + # Second pass: instantiate solids (and any referenced surfaces/voids) + for entry in doc.get("entities", []): + if entry.get("type") != "solid": + continue + shell_surfaces: List[list] = [] + for sid in entry.get("shell", []): + surface = surfaces.get(sid) + if surface is None: + surf_entry = entries_by_id.get(sid) + if surf_entry is None: + raise ValueError(f"surface {sid} referenced by solid but not provided") + surface = _rehydrate_surface(surf_entry) + surfaces[sid] = surface + shell_surfaces.append(surface) + + voids: List[List[list]] = [] + for void_ids in entry.get("voids", []): + void_surfaces: List[list] = [] + for sid in void_ids: + surface = surfaces.get(sid) + if surface is None: + surf_entry = entries_by_id.get(sid) + if surf_entry is None: + raise ValueError(f"void surface {sid} missing") + surface = _rehydrate_surface(surf_entry) + surfaces[sid] = surface + void_surfaces.append(surface) + voids.append(void_surfaces) + + solid = ['solid', shell_surfaces, voids, []] + metadata = entry.get("metadata") + if metadata: + set_solid_metadata(solid, metadata) + solids.append(solid) + + if solids: + return solids + sketches = [ + entry for entry in doc.get("entities", []) if entry.get("type") == "sketch" + ] + if sketches: + geomlists: List[list] = [] + for sketch in sketches: + primitives = sketch.get("primitives") or [] + elements: List[list] = [] + for prim in primitives: + kind = prim.get("kind") + if kind == "line": + start = prim.get("start", []) + end = prim.get("end", []) + if len(start) >= 2 and len(end) >= 2: + p0 = point(float(start[0]), float(start[1])) + p1 = point(float(end[0]), float(end[1])) + elements.append(line(p0, p1)) + elif kind == "circle": + center = _point_from_components(prim.get("center", [0.0, 0.0, 0.0, 1.0])) + radius = float(prim.get("radius", 0.0)) + orientation = int(prim.get("orientation", -1)) + vect_data = vect(radius, 0.0, 360.0, orientation) + normal = prim.get("normal") + if normal is not None: + elements.append(arc(center, vect_data, _point_from_components(normal))) + else: + elements.append(arc(center, vect_data)) + elif kind == "arc": + center = _point_from_components(prim.get("center", [0.0, 0.0, 0.0, 1.0])) + radius = float(prim.get("radius", 0.0)) + start_angle = float(prim.get("start", 0.0)) + end_angle = float(prim.get("end", 0.0)) + orientation = int(prim.get("orientation", -1)) + vect_data = vect(radius, start_angle, end_angle, orientation) + normal = prim.get("normal") + if normal is not None: + elements.append(arc(center, vect_data, _point_from_components(normal))) + else: + elements.append(arc(center, vect_data)) + elif kind == "polyline": + coords = prim.get("points", []) + pts = [point(float(pt[0]), float(pt[1])) for pt in coords if len(pt) >= 2] + for idx in range(len(pts) - 1): + elements.append(line(pts[idx], pts[idx + 1])) + elif kind == "catmullrom": + ctrl_points = [_point_from_components(pt) for pt in prim.get("points", [])] + params = prim.get("params") or {} + elements.append( + catmullrom( + ctrl_points, + closed=bool(params.get("closed", False)), + alpha=float(params.get("alpha", 0.5)), + ) + ) + elif kind == "nurbs": + ctrl_points = [_point_from_components(pt) for pt in prim.get("points", [])] + params = prim.get("params") or {} + degree = int(params.get("degree", 3)) + weights = params.get("weights") + knots = params.get("knots") + elements.append( + nurbs( + ctrl_points, + degree=degree, + weights=weights, + knots=knots, + ) + ) + if elements: + geomlists.append(elements) + continue + + polylines = [] + for poly in sketch.get("polylines", []): + if len(poly) < 2: + continue + pts = [] + for pt in poly: + x, y = pt + pts.append(point(x, y)) + segments = [] + for idx in range(len(pts) - 1): + segments.append(line(pts[idx], pts[idx + 1])) + polylines.extend(segments) + if polylines: + geomlists.append(polylines) + if geomlists: + return geomlists + return list(surfaces.values()) + + +__all__ = [ + "SCHEMA_ID", + "geometry_to_json", + "geometry_from_json", +] diff --git a/src/yapcad/metadata.py b/src/yapcad/metadata.py index 0f51e8d..0fe6193 100644 --- a/src/yapcad/metadata.py +++ b/src/yapcad/metadata.py @@ -3,12 +3,61 @@ from __future__ import annotations import uuid -from typing import Dict, Tuple +from typing import Dict, Tuple, Any, Optional, Iterable from yapcad.geom3d import issolid, issurface _SURFACE_META_INDEX = 6 _SOLID_META_INDEX = 4 +_DEFAULT_SCHEMA = "metadata-namespace-v0.1" + +_ROOT_FIELDS = ("schema", "entityId", "timestamp", "tags", "layer") +_SECTION_KEYS = { + "material", + "manufacturing", + "designHistory", + "constraints", + "analysis", +} + + +def _initial_root(entity_id: Optional[str] = None) -> Dict[str, Any]: + data: Dict[str, Any] = { + "schema": _DEFAULT_SCHEMA, + "tags": [], + "layer": "default", + } + if entity_id: + data["entityId"] = entity_id + return data + + +def _ensure_root(meta: Dict[str, Any], entity_id: Optional[str] = None) -> Dict[str, Any]: + if not meta: + return _initial_root(entity_id) + if "schema" not in meta: + meta["schema"] = _DEFAULT_SCHEMA + if entity_id and "entityId" not in meta: + meta["entityId"] = entity_id + if "tags" not in meta: + meta["tags"] = [] + if "layer" not in meta or not meta["layer"]: + meta["layer"] = "default" + return meta + + +def _ensure_namespace(meta: Dict[str, Any], namespace: str) -> Dict[str, Any]: + if namespace not in meta or not isinstance(meta[namespace], dict): + meta[namespace] = {} + return meta[namespace] + + +def _append_tag(meta: Dict[str, Any], tag: str) -> None: + if not tag: + return + tags = meta.setdefault("tags", []) + if isinstance(tags, list) and tag not in tags: + tags.append(tag) def _ensure_metadata(container: list, index: int, create: bool) -> Dict: @@ -29,7 +78,16 @@ def _ensure_metadata(container: list, index: int, create: bool) -> Dict: def get_surface_metadata(surface: list, create: bool = False) -> Dict: if not issurface(surface): raise ValueError('invalid surface passed to get_surface_metadata') - return _ensure_metadata(surface, _SURFACE_META_INDEX, create) + meta = _ensure_metadata(surface, _SURFACE_META_INDEX, create) + if meta is None: + return {} + entity_id = ensure_surface_id(surface) if create else meta.get("entityId") + root = _ensure_root(meta, entity_id) + if len(surface) <= _SURFACE_META_INDEX: + surface.append(root) + else: + surface[_SURFACE_META_INDEX] = root + return root def set_surface_metadata(surface: list, metadata: Dict) -> list: @@ -45,9 +103,16 @@ def set_surface_metadata(surface: list, metadata: Dict) -> list: def ensure_surface_id(surface: list) -> str: - meta = get_surface_metadata(surface, create=True) + meta = _ensure_metadata(surface, _SURFACE_META_INDEX, True) + if meta is None: + meta = {} + surface.append(meta) + meta = _ensure_root(meta) + if 'entityId' not in meta: + meta['entityId'] = str(uuid.uuid4()) if 'id' not in meta: - meta['id'] = str(uuid.uuid4()) + meta['id'] = meta['entityId'] + surface[_SURFACE_META_INDEX] = meta return meta['id'] @@ -66,7 +131,16 @@ def set_surface_origin(surface: list, origin: str) -> list: def get_solid_metadata(sld: list, create: bool = False) -> Dict: if not issolid(sld): raise ValueError('invalid solid passed to get_solid_metadata') - return _ensure_metadata(sld, _SOLID_META_INDEX, create) + meta = _ensure_metadata(sld, _SOLID_META_INDEX, create) + if meta is None: + return {} + entity_id = ensure_solid_id(sld) if create else meta.get("entityId") + root = _ensure_root(meta, entity_id) + if len(sld) <= _SOLID_META_INDEX: + sld.append(root) + else: + sld[_SOLID_META_INDEX] = root + return root def set_solid_metadata(sld: list, metadata: Dict) -> list: @@ -82,9 +156,16 @@ def set_solid_metadata(sld: list, metadata: Dict) -> list: def ensure_solid_id(sld: list) -> str: - meta = get_solid_metadata(sld, create=True) + meta = _ensure_metadata(sld, _SOLID_META_INDEX, True) + if meta is None: + meta = {} + sld.append(meta) + meta = _ensure_root(meta) + if 'entityId' not in meta: + meta['entityId'] = str(uuid.uuid4()) if 'id' not in meta: - meta['id'] = str(uuid.uuid4()) + meta['id'] = meta['entityId'] + sld[_SOLID_META_INDEX] = meta return meta['id'] @@ -96,6 +177,164 @@ def set_solid_context(sld: list, context: Dict) -> list: return sld +def set_layer(meta: Dict[str, Any], layer: str) -> Dict[str, Any]: + if not layer: + layer = "default" + root = _ensure_root(meta) + root["layer"] = layer + return root + + +# --------------------------------------------------------------------------- +# Metadata namespace helpers + +def add_tags(meta: Dict[str, Any], tags: Iterable[str]) -> Dict[str, Any]: + root = _ensure_root(meta) + for tag in tags: + _append_tag(root, tag) + return root + + +def set_material(meta: Dict[str, Any], *, name: Optional[str] = None, + standard: Optional[str] = None, grade: Optional[str] = None, + density_kg_m3: Optional[float] = None, + source: Optional[str] = None) -> Dict[str, Any]: + root = _ensure_root(meta) + section = _ensure_namespace(root, "material") + if name is not None: + section["name"] = name + if standard is not None: + section["standard"] = standard + if grade is not None: + section["grade"] = grade + if density_kg_m3 is not None: + section["density_kg_m3"] = float(density_kg_m3) + if source is not None: + section["source"] = source + return root + + +def set_manufacturing(meta: Dict[str, Any], *, + process: Optional[str] = None, + instructions: Optional[str] = None, + fixtures: Optional[Iterable[str]] = None, + layers: Optional[Dict[str, Any]] = None, + postprocessing: Optional[Iterable[str]] = None) -> Dict[str, Any]: + root = _ensure_root(meta) + section = _ensure_namespace(root, "manufacturing") + if process is not None: + section["process"] = process + if instructions is not None: + section["instructions"] = instructions + if fixtures is not None: + section["fixtures"] = list(fixtures) + if layers is not None: + section["layers"] = dict(layers) + if postprocessing is not None: + section["postprocessing"] = list(postprocessing) + return root + + +def add_design_history_entry(meta: Dict[str, Any], *, + author: Optional[str] = None, + source: Optional[str] = None, + context: Optional[str] = None, + tools: Optional[Iterable[str]] = None, + revision: Optional[str] = None, + timestamp: Optional[str] = None, + notes: Optional[str] = None) -> Dict[str, Any]: + root = _ensure_root(meta) + section = _ensure_namespace(root, "designHistory") + if author is not None: + section["author"] = author + if source is not None: + section["source"] = source + if context is not None: + section["context"] = context + if tools is not None: + section["tools"] = list(tools) + entries = section.setdefault("iterations", []) + entry: Dict[str, Any] = {} + if revision is not None: + entry["revision"] = revision + if timestamp is not None: + entry["timestamp"] = timestamp + if notes is not None: + entry["notes"] = notes + if entry: + entries.append(entry) + return root + + +def set_mass_constraint(meta: Dict[str, Any], *, + max_kg: Optional[float] = None, + target_kg: Optional[float] = None) -> Dict[str, Any]: + root = _ensure_root(meta) + section = _ensure_namespace(root, "constraints") + mass = section.setdefault("mass", {}) + if max_kg is not None: + mass["max_kg"] = float(max_kg) + if target_kg is not None: + mass["target_kg"] = float(target_kg) + return root + + +def set_envelope_constraint(meta: Dict[str, Any], *, + x_mm: Optional[float] = None, + y_mm: Optional[float] = None, + z_mm: Optional[float] = None) -> Dict[str, Any]: + root = _ensure_root(meta) + section = _ensure_namespace(root, "constraints") + envelope = section.setdefault("envelope", {}) + if x_mm is not None: + envelope["x_mm"] = float(x_mm) + if y_mm is not None: + envelope["y_mm"] = float(y_mm) + if z_mm is not None: + envelope["z_mm"] = float(z_mm) + return root + + +def add_compliance(meta: Dict[str, Any], standards: Iterable[str]) -> Dict[str, Any]: + root = _ensure_root(meta) + section = _ensure_namespace(root, "constraints") + listing = section.setdefault("compliance", []) + for item in standards: + if item and item not in listing: + listing.append(item) + return root + + +def add_analysis_record(meta: Dict[str, Any], record: Dict[str, Any]) -> Dict[str, Any]: + root = _ensure_root(meta) + section = _ensure_namespace(root, "analysis") + simulations = section.setdefault("simulations", []) + simulations.append(dict(record)) + return root + + +__all__ = [ + 'get_surface_metadata', + 'set_surface_metadata', + 'ensure_surface_id', + 'set_surface_units', + 'set_surface_origin', + 'get_solid_metadata', + 'set_solid_metadata', + 'ensure_solid_id', + 'set_solid_context', + 'add_tags', + 'set_material', + 'set_manufacturing', + 'add_design_history_entry', + 'set_mass_constraint', + 'set_envelope_constraint', + 'add_compliance', + 'add_analysis_record', + 'set_layer', +] + + __all__ = [ 'get_surface_metadata', 'set_surface_metadata', diff --git a/src/yapcad/package/__init__.py b/src/yapcad/package/__init__.py new file mode 100644 index 0000000..4675f2c --- /dev/null +++ b/src/yapcad/package/__init__.py @@ -0,0 +1,32 @@ +"""Public API for yapCAD `.ycpkg` package workflows.""" + +from __future__ import annotations + +from .core import ( + MANIFEST_FILENAME, + PACKAGE_SCHEMA, + PackageManifest, + create_package_from_entities, + add_geometry_file, + load_geometry, +) +from .validator import validate_package + + +def view_package(package_path, *, strict: bool = False): + """Import and invoke the interactive viewer lazily.""" + + from .viewer import view_package as _view # local import to avoid pyglet init during tests + + return _view(package_path, strict=strict) + +__all__ = [ + "PACKAGE_SCHEMA", + "MANIFEST_FILENAME", + "PackageManifest", + "create_package_from_entities", + "add_geometry_file", + "load_geometry", + "validate_package", + "view_package", +] diff --git a/src/yapcad/package/core.py b/src/yapcad/package/core.py new file mode 100644 index 0000000..372001b --- /dev/null +++ b/src/yapcad/package/core.py @@ -0,0 +1,289 @@ +"""Core `.ycpkg` packaging helpers.""" + +from __future__ import annotations + +import datetime as _dt +import hashlib +import shutil +import json +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence + +from yapcad import __version__ as _yapcad_version +from yapcad.geom3d import issolid, issurface +from yapcad.io.geometry_json import SCHEMA_ID as GEOMETRY_SCHEMA, geometry_from_json, geometry_to_json +from yapcad.metadata import ( + get_solid_metadata, + get_surface_metadata, +) + +PACKAGE_SCHEMA = "ycpkg-spec-v0.1" +MANIFEST_FILENAME = "manifest.yaml" + + +def _compute_hash(path: Path, algorithm: str = "sha256") -> str: + h = hashlib.new(algorithm) + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + h.update(chunk) + return f"{algorithm}:{h.hexdigest()}" + + +def _now_iso() -> str: + return _dt.datetime.now(_dt.timezone.utc).isoformat() + + +def _collect_tags(entities: Iterable[list]) -> List[str]: + tags: List[str] = [] + seen = set() + for entity in entities: + if issolid(entity): + meta = get_solid_metadata(entity, create=False) or {} + elif issurface(entity): + meta = get_surface_metadata(entity, create=False) or {} + else: + continue + for tag in meta.get("tags", []): + if tag not in seen: + tags.append(tag) + seen.add(tag) + return tags + + +def _ensure_subdirs(root: Path) -> None: + for sub in ("geometry", "metadata", "validation/plans", "validation/results", "exports", "attachments"): + (root / sub).mkdir(parents=True, exist_ok=True) + + +def _serialize_geometry(entities: Sequence[list], target: Path, root: Path) -> Dict[str, Any]: + doc = geometry_to_json(entities) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("w", encoding="utf-8") as fp: + json.dump(doc, fp, indent=2, sort_keys=False) + fp.write("\n") + entity_ids = [entry["id"] for entry in doc.get("entities", []) if entry.get("id")] + return { + "path": str(target.relative_to(root)), + "schema": GEOMETRY_SCHEMA, + "entities": entity_ids, + } + + +@dataclass +class PackageManifest: + """Wrapper around the manifest document.""" + + root: Path + data: Dict[str, Any] = field(default_factory=dict) + manifest_name: str = MANIFEST_FILENAME + + @property + def manifest_path(self) -> Path: + return self.root / self.manifest_name + + @classmethod + def load(cls, package_path: Path | str) -> "PackageManifest": + root = Path(package_path) + manifest_path = root / MANIFEST_FILENAME + if not manifest_path.exists(): + raise FileNotFoundError(f"manifest not found: {manifest_path}") + with manifest_path.open("r", encoding="utf-8") as fp: + data = json.load(fp) if manifest_path.suffix == ".json" else None + if data is None: + import yaml # local import to avoid hard dependency if unused + with manifest_path.open("r", encoding="utf-8") as fp: + data = yaml.safe_load(fp) or {} + return cls(root=root, data=data) + + def save(self) -> None: + self.data.setdefault("schema", PACKAGE_SCHEMA) + self.root.mkdir(parents=True, exist_ok=True) + import yaml + + with self.manifest_path.open("w", encoding="utf-8") as fp: + yaml.safe_dump(self.data, fp, sort_keys=False) + + def recompute_hashes(self, *, algorithm: str = "sha256") -> None: + geom = self.data.get("geometry", {}) + for section in ("primary",): + info = geom.get(section) + if info: + path = self.root / info["path"] + if path.exists(): + info["hash"] = _compute_hash(path, algorithm) + for key in ("derived",): + items = geom.get(key, []) or [] + for info in items: + path = self.root / info["path"] + if path.exists(): + info["hash"] = _compute_hash(path, algorithm) + + for entry_key in ("exports", "attachments"): + for info in self.data.get(entry_key, []) or []: + path = self.root / info["path"] + if path.exists(): + info["hash"] = _compute_hash(path, algorithm) + + def geometry_primary_path(self) -> Path: + geom = self.data.get("geometry", {}).get("primary") + if not geom: + raise ValueError("manifest missing geometry.primary section") + return self.root / geom["path"] + + +def create_package_from_entities( + entities: Sequence[list], + target_dir: Path | str, + *, + name: str, + version: str, + description: Optional[str] = None, + author: Optional[str] = None, + units: Optional[str] = None, + generator: Optional[Dict[str, Any]] = None, + overwrite: bool = False, + hash_algorithm: str = "sha256", +) -> PackageManifest: + if not entities: + raise ValueError("no entities supplied for packaging") + + root = Path(target_dir) + if root.exists(): + if not overwrite and any(root.iterdir()): + raise FileExistsError(f"target directory {root} already exists and is not empty") + else: + root.mkdir(parents=True) + + _ensure_subdirs(root) + primary_path = root / "geometry" / "primary.json" + geometry_info = _serialize_geometry(entities, primary_path, root) + geometry_info["hash"] = _compute_hash(primary_path, hash_algorithm) + + tags = _collect_tags(entities) + manifest_data: Dict[str, Any] = { + "schema": PACKAGE_SCHEMA, + "id": str(uuid.uuid4()), + "name": name, + "version": version, + "description": description or "", + "created": { + "timestamp": _now_iso(), + }, + "generator": generator + or { + "tool": "yapCAD", + "version": _yapcad_version, + }, + "units": units or "mm", + "tags": tags, + "geometry": { + "primary": geometry_info, + }, + } + if author: + manifest_data["created"]["author"] = author + manifest = PackageManifest(root=root, data=manifest_data) + manifest.save() + return manifest + + +def load_geometry(manifest: PackageManifest) -> List[list]: + primary_path = manifest.geometry_primary_path() + with primary_path.open("r", encoding="utf-8") as fp: + doc = json.load(fp) + return geometry_from_json(doc) + + +def add_geometry_file( + manifest: PackageManifest, + source: Path | str, + *, + dest_relative: str | None = None, + purpose: Optional[str] = None, + category: str = "derived", + overwrite: bool = False, + metadata: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Copy an external geometry file (e.g., STEP/STL) into the package and record it. + + Args: + manifest: Loaded manifest wrapper. + source: Path to the external file that should be bundled. + dest_relative: Optional relative destination path inside the package root. + Defaults to ``geometry/derived/``. + purpose: Optional description stored alongside the entry. + category: Manifest section to update. Supported: ``"derived"`` (default), ``"attachments"``. + overwrite: Allow replacing an existing file at the target location. + metadata: Additional key/value pairs merged into the manifest entry. + + Returns: + The manifest entry dictionary that was inserted. + """ + + src_path = Path(source) + if not src_path.exists(): + raise FileNotFoundError(f"geometry source not found: {src_path}") + + if dest_relative is None: + if category == "derived": + dest_relative_path = Path("geometry") / "derived" / src_path.name + elif category == "attachments": + dest_relative_path = Path("attachments") / src_path.name + else: + dest_relative_path = Path(src_path.name) + else: + dest_relative_path = Path(dest_relative) + if dest_relative_path.is_absolute(): + raise ValueError("dest_relative must be a relative path") + + dest_path = manifest.root / dest_relative_path + dest_path.parent.mkdir(parents=True, exist_ok=True) + + if dest_path.exists() and not overwrite: + raise FileExistsError(f"target file already exists: {dest_path}") + + shutil.copy2(src_path, dest_path) + + entry: Dict[str, Any] = { + "path": str(dest_relative_path.as_posix()), + "hash": _compute_hash(dest_path), + "format": src_path.suffix.lstrip(".").lower(), + "source": { + "kind": "import", + "original": str(src_path), + }, + } + if purpose: + entry["purpose"] = purpose + if metadata: + entry.update(metadata) + + if category == "derived": + geometry = manifest.data.setdefault("geometry", {}) + derived = geometry.setdefault("derived", []) + derived = [item for item in derived if item.get("path") != entry["path"]] + derived.append(entry) + geometry["derived"] = derived + elif category == "attachments": + attachments = manifest.data.setdefault("attachments", []) + attachments = [item for item in attachments if item.get("path") != entry["path"]] + entry.setdefault("id", dest_relative_path.stem) + attachments.append(entry) + manifest.data["attachments"] = attachments + else: + raise ValueError(f"unsupported category for geometry file: {category}") + + return entry + + +__all__ = [ + "PACKAGE_SCHEMA", + "MANIFEST_FILENAME", + "PackageManifest", + "create_package_from_entities", + "load_geometry", + "_compute_hash", + "add_geometry_file", +] diff --git a/src/yapcad/package/validator.py b/src/yapcad/package/validator.py new file mode 100644 index 0000000..31ba4f1 --- /dev/null +++ b/src/yapcad/package/validator.py @@ -0,0 +1,88 @@ +"""Validation utilities for `.ycpkg` packages.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import List, Tuple + +from yapcad.io.geometry_json import geometry_from_json +from .core import PackageManifest, _compute_hash + + +def _check_file(path: Path, expected_hash: str | None) -> Tuple[bool, List[str]]: + messages: List[str] = [] + if not path.exists(): + messages.append(f"ERROR: missing file {path}") + return False, messages + actual_hash = _compute_hash(path) + if expected_hash and expected_hash.lower() != actual_hash.lower(): + messages.append( + f"ERROR: hash mismatch for {path} (expected {expected_hash}, got {actual_hash})" + ) + return False, messages + return True, messages + + +def validate_package(path: Path | str, *, strict: bool = False) -> Tuple[bool, List[str]]: + """Validate manifest and referenced artefacts. + + Returns (is_valid, messages). Messages are strings with severity prefixes. + """ + pkg_path = Path(path) + messages: List[str] = [] + try: + manifest = PackageManifest.load(pkg_path) + except Exception as exc: + return False, [f"ERROR: failed to load manifest: {exc}"] + + data = manifest.data + if data.get("schema") != "ycpkg-spec-v0.1": + messages.append(f"ERROR: unsupported package schema {data.get('schema')}") + + # Geometry primary + try: + primary_path = manifest.geometry_primary_path() + except Exception as exc: + messages.append(f"ERROR: geometry.primary missing: {exc}") + return False, messages + + ok, file_messages = _check_file(primary_path, data.get("geometry", {}).get("primary", {}).get("hash")) + messages.extend(file_messages) + if ok: + try: + with primary_path.open("r", encoding="utf-8") as fp: + doc = json.load(fp) + geometry_from_json(doc) + except Exception as exc: + messages.append(f"ERROR: invalid geometry JSON: {exc}") + ok = False + + overall_ok = ok and not any(msg.startswith("ERROR") for msg in messages) + + # Derived geometry + derived_entries = data.get("geometry", {}).get("derived", []) or [] + for entry in derived_entries: + derived_path = manifest.root / entry["path"] + ok_entry, msgs = _check_file(derived_path, entry.get("hash")) + messages.extend(msgs) + overall_ok = overall_ok and ok_entry + + # Exports and attachments + for section in ("exports", "attachments"): + for entry in data.get(section, []) or []: + file_path = manifest.root / entry["path"] + ok_entry, msgs = _check_file(file_path, entry.get("hash")) + messages.extend(msgs) + overall_ok = overall_ok and ok_entry + if strict and entry.get("hash") is None: + messages.append(f"WARNING: {section} entry {entry.get('id')} missing hash") + + if overall_ok: + messages.insert(0, f"OK: {pkg_path} passed validation") + else: + messages.insert(0, f"FAILED: {pkg_path} has validation errors") + return overall_ok, messages + + +__all__ = ["validate_package"] diff --git a/src/yapcad/package/viewer.py b/src/yapcad/package/viewer.py new file mode 100644 index 0000000..e346be6 --- /dev/null +++ b/src/yapcad/package/viewer.py @@ -0,0 +1,753 @@ +"""Interactive viewer for yapCAD `.ycpkg` packages.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from typing import Dict, List, Sequence, Tuple + +import pyglet +from pyglet.window import key +from pyglet.gl import ( + GL_COLOR_BUFFER_BIT, + GL_DEPTH_BUFFER_BIT, + GL_DEPTH_TEST, + GL_COLOR_MATERIAL, + GL_LIGHT0, + GL_LIGHTING, + GL_MODELVIEW, + GL_ONE_MINUS_SRC_ALPHA, + GL_PROJECTION, + GL_SCISSOR_TEST, + GL_LINES, + GL_LINE_LOOP, + GL_QUADS, + GL_ENABLE_BIT, + GL_DEPTH_BUFFER_BIT, + GL_SRC_ALPHA, + GL_TRIANGLES, + glBegin, + glBlendFunc, + glClear, + glClearColor, + glColorMaterial, + glDisable, + glEnable, + glEnd, + glLightfv, + glLoadIdentity, + glMatrixMode, + glNormal3f, + glOrtho, + glPopMatrix, + glPopAttrib, + glPushMatrix, + glPushAttrib, + glScalef, + glScissor, + glTranslatef, + glVertex2f, + glVertex3f, + glViewport, + gluLookAt, + gluPerspective, + glColor4f, + GLfloat, +) + +from yapcad.io.geometry_json import geometry_from_json +from .core import PackageManifest +from .validator import validate_package + + +def _load_geometry_doc(package_root: Path, manifest_data: Dict[str, any]) -> Dict[str, any]: + primary_path = package_root / manifest_data["geometry"]["primary"]["path"] + with primary_path.open("r", encoding="utf-8") as fp: + return json.load(fp) + + +def _collect_triangles(doc: Dict[str, any]) -> Tuple[Dict[str, List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]], Tuple[float, float, float, float, float, float]]: + layer_tris: Dict[str, List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]] = {} + surfaces: Dict[str, Dict[str, any]] = {} + for entry in doc.get("entities", []): + if entry.get("type") == "surface": + surfaces[entry["id"]] = entry + for entry in doc.get("entities", []): + if entry.get("type") not in {"solid", "surface"}: + continue + if entry.get("type") == "surface": + surf_entry = entry + verts = surf_entry.get("vertices", []) + norms = surf_entry.get("normals", []) + faces = surf_entry.get("faces", []) + layer = surf_entry.get("metadata", {}).get("layer", "default") + bucket = layer_tris.setdefault(layer, []) + for tri in faces: + for idx in tri: + pt = verts[idx] + normal = norms[idx] if idx < len(norms) else [0.0, 0.0, 1.0, 0.0] + bucket.append(((float(pt[0]), float(pt[1]), float(pt[2])), (float(normal[0]), float(normal[1]), float(normal[2])))) + continue + for sid in entry.get("shell", []): + surf_entry = surfaces.get(sid) + if not surf_entry: + continue + verts = surf_entry.get("vertices", []) + norms = surf_entry.get("normals", []) + faces = surf_entry.get("faces", []) + layer = surf_entry.get("metadata", {}).get("layer", entry.get("metadata", {}).get("layer", "default")) + bucket = layer_tris.setdefault(layer, []) + for tri in faces: + for idx in tri: + pt = verts[idx] + normal = norms[idx] if idx < len(norms) else [0.0, 0.0, 1.0, 0.0] + bucket.append(((float(pt[0]), float(pt[1]), float(pt[2])), (float(normal[0]), float(normal[1]), float(normal[2])))) + if not layer_tris: + return layer_tris, (0, 0, 0, 0, 0, 0) + xs = [v[0][0] for tris in layer_tris.values() for v in tris] + ys = [v[0][1] for tris in layer_tris.values() for v in tris] + zs = [v[0][2] for tris in layer_tris.values() for v in tris] + return layer_tris, (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def _collect_polylines(doc: Dict[str, any]) -> Tuple[Dict[str, List[List[List[float]]]], Tuple[float, float, float, float]]: + sketches = [entry for entry in doc.get("entities", []) if entry.get("type") == "sketch"] + layer_polys: Dict[str, List[List[List[float]]]] = {} + xs: List[float] = [] + ys: List[float] = [] + for entry in sketches: + layer = entry.get("metadata", {}).get("layer", "default") + bucket = layer_polys.setdefault(layer, []) + for poly in entry.get("polylines", []): + points = [] + for pt in poly: + x, y = float(pt[0]), float(pt[1]) + points.append([x, y]) + xs.append(x) + ys.append(y) + if points: + bucket.append(points) + if not layer_polys: + return layer_polys, (0, 0, 0, 0) + return layer_polys, (min(xs), min(ys), max(xs), max(ys)) + + +def _compute_grid_step(span: float) -> float: + if span <= 0 or math.isnan(span): + return 1.0 + raw = span / 10.0 + if raw <= 0: + raw = span or 1.0 + exp = math.floor(math.log10(raw)) if raw > 0 else 0 + base = 10 ** exp + for factor in (1, 2, 5, 10): + step = factor * base + if raw <= step: + return step + return 10 * base + + +class FourViewWindow(pyglet.window.Window): + """Four-view 3D window with perspective + orthographic panels.""" + + def __init__(self, layer_triangles: Dict[str, List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]], bbox: Tuple[float, float, float, float, float, float], units: str = ""): + super().__init__(width=1200, height=800, caption="yapCAD Package Viewer") + self.layer_triangles = layer_triangles + self.layer_names = sorted(layer_triangles.keys()) or ["default"] + self.visible_layers = {layer: True for layer in self.layer_names} + self.bbox = bbox + self.units = units + self.show_help = False + self.azimuth = 35.0 + self.elevation = 25.0 + self.distance = max(bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2]) * 1.5 or 10.0 + self.pan_x = 0.0 + self.pan_y = 0.0 + self._dragging = False + self._last = (0, 0) + glEnable(GL_DEPTH_TEST) + glEnable(GL_LIGHTING) + glEnable(GL_LIGHT0) + glEnable(GL_COLOR_MATERIAL) + glColorMaterial(pyglet.gl.GL_FRONT_AND_BACK, pyglet.gl.GL_AMBIENT_AND_DIFFUSE) + light_position = (GLfloat * 4)(0.6, 0.8, 1.2, 0.0) + glLightfv(GL_LIGHT0, pyglet.gl.GL_POSITION, light_position) + light_diffuse = (GLfloat * 4)(0.8, 0.8, 0.8, 1.0) + glLightfv(GL_LIGHT0, pyglet.gl.GL_DIFFUSE, light_diffuse) + light_ambient = (GLfloat * 4)(0.15, 0.15, 0.15, 1.0) + glLightfv(GL_LIGHT0, pyglet.gl.GL_AMBIENT, light_ambient) + glClearColor(0.05, 0.05, 0.07, 1.0) + + def on_draw(self): + self.clear() + fb_width, fb_height = self.get_framebuffer_size() + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + w2 = fb_width // 2 + h2 = fb_height // 2 + self._draw_viewport(0, h2, w2, h2, "Perspective", perspective=True, fb_dims=(fb_width, fb_height)) + self._draw_viewport(w2, h2, w2, h2, "Front", orientation="front", fb_dims=(fb_width, fb_height)) + self._draw_viewport(0, 0, w2, h2, "Top", orientation="top", fb_dims=(fb_width, fb_height)) + self._draw_viewport(w2, 0, w2, h2, "Side", orientation="side", fb_dims=(fb_width, fb_height)) + self._draw_help_overlay(fb_width, fb_height) + + def _apply_camera(self, orientation: str | None, width: int, height: int, perspective: bool): + glMatrixMode(GL_PROJECTION) + glLoadIdentity() + aspect = max(width / height, 0.1) + if perspective: + gluPerspective(45.0, aspect, 0.1, 1000.0) + else: + span = self.distance + glOrtho(-span * aspect, span * aspect, -span, span, -1000.0, 1000.0) + + glMatrixMode(GL_MODELVIEW) + glLoadIdentity() + cx = (self.bbox[0] + self.bbox[3]) / 2.0 + cy = (self.bbox[1] + self.bbox[4]) / 2.0 + cz = (self.bbox[2] + self.bbox[5]) / 2.0 + + if perspective: + theta = math.radians(self.azimuth) + phi = math.radians(self.elevation) + eye_x = cx + self.distance * math.cos(phi) * math.cos(theta) + eye_y = cy + self.distance * math.sin(phi) + eye_z = cz + self.distance * math.cos(phi) * math.sin(theta) + gluLookAt(eye_x, eye_y, eye_z, cx + self.pan_x, cy + self.pan_y, cz, 0, 1, 0) + elif orientation == "front": + gluLookAt(cx, cy, cz + self.distance, cx + self.pan_x, cy + self.pan_y, cz, 0, 1, 0) + elif orientation == "top": + gluLookAt(cx, cy + self.distance, cz, cx + self.pan_x, cy, cz + self.pan_y, 0, 0, -1) + elif orientation == "side": + gluLookAt(cx + self.distance, cy, cz, cx, cy + self.pan_y, cz + self.pan_x, 0, 1, 0) + else: + gluLookAt(cx, cy, cz + self.distance, cx, cy, cz, 0, 1, 0) + + def _draw_triangles(self): + glColor4f(0.6, 0.85, 1.0, 1.0) + glBegin(GL_TRIANGLES) + for layer in self.layer_names: + if not self.visible_layers.get(layer, True): + continue + for v, n in self.layer_triangles.get(layer, []): + glNormal3f(n[0], n[1], n[2]) + glVertex3f(v[0], v[1], v[2]) + glEnd() + + def _draw_grid(self, orientation: str | None, perspective: bool): + xmin, ymin, zmin, xmax, ymax, zmax = self.bbox + span_x = max(xmax - xmin, 1e-6) + span_y = max(ymax - ymin, 1e-6) + span_z = max(zmax - zmin, 1e-6) + cx = (xmin + xmax) / 2.0 + cy = (ymin + ymax) / 2.0 + cz = (zmin + zmax) / 2.0 + + if perspective: + plane_z = zmin + step = _compute_grid_step(max(span_x, span_y)) + min_x = (math.floor(xmin / step) - 2) * step + max_x = (math.ceil(xmax / step) + 2) * step + min_y = (math.floor(ymin / step) - 2) * step + max_y = (math.ceil(ymax / step) + 2) * step + glDisable(GL_LIGHTING) + glColor4f(0.25, 0.25, 0.3, 0.7) + glBegin(GL_LINES) + value = min_x + while value <= max_x: + glVertex3f(value, min_y, plane_z) + glVertex3f(value, max_y, plane_z) + value += step + value = min_y + while value <= max_y: + glVertex3f(min_x, value, plane_z) + glVertex3f(max_x, value, plane_z) + value += step + glEnd() + glEnable(GL_LIGHTING) + return + + glDisable(GL_LIGHTING) + glColor4f(0.2, 0.2, 0.28, 0.8) + glBegin(GL_LINES) + if orientation == "front": + step_x = step_y = _compute_grid_step(max(span_x, span_y)) + min_x = (math.floor(xmin / step_x) - 2) * step_x + max_x = (math.ceil(xmax / step_x) + 2) * step_x + min_y = (math.floor(ymin / step_y) - 2) * step_y + max_y = (math.ceil(ymax / step_y) + 2) * step_y + value = min_x + while value <= max_x: + glVertex3f(value, min_y, cz) + glVertex3f(value, max_y, cz) + value += step_x + value = min_y + while value <= max_y: + glVertex3f(min_x, value, cz) + glVertex3f(max_x, value, cz) + value += step_y + elif orientation == "top": + step_x = step_z = _compute_grid_step(max(span_x, span_z)) + min_x = (math.floor(xmin / step_x) - 2) * step_x + max_x = (math.ceil(xmax / step_x) + 2) * step_x + min_z = (math.floor(zmin / step_z) - 2) * step_z + max_z = (math.ceil(zmax / step_z) + 2) * step_z + value = min_x + while value <= max_x: + glVertex3f(value, cy, min_z) + glVertex3f(value, cy, max_z) + value += step_x + value = min_z + while value <= max_z: + glVertex3f(min_x, cy, value) + glVertex3f(max_x, cy, value) + value += step_z + elif orientation == "side": + step_y = step_z = _compute_grid_step(max(span_y, span_z)) + min_y = (math.floor(ymin / step_y) - 2) * step_y + max_y = (math.ceil(ymax / step_y) + 2) * step_y + min_z = (math.floor(zmin / step_z) - 2) * step_z + max_z = (math.ceil(zmax / step_z) + 2) * step_z + value = min_y + while value <= max_y: + glVertex3f(cx, value, min_z) + glVertex3f(cx, value, max_z) + value += step_y + value = min_z + while value <= max_z: + glVertex3f(cx, min_y, value) + glVertex3f(cx, max_y, value) + value += step_z + glEnd() + glEnable(GL_LIGHTING) + + def _draw_viewport(self, x: int, y: int, width: int, height: int, label: str, perspective: bool = False, orientation: str | None = None, fb_dims: Tuple[int, int] | None = None): + fb_width, fb_height = fb_dims if fb_dims else (self.width, self.height) + glViewport(x, y, width, height) + glEnable(GL_SCISSOR_TEST) + glScissor(x, y, width, height) + self._apply_camera(orientation, width, height, perspective) + self._draw_grid(orientation, perspective) + self._draw_triangles() + glDisable(GL_LIGHTING) + label_text = label + if self.units: + label_text = f"{label} ({self.units})" + self._draw_axes_overlay(x, y, width, height, label_text, fb_width, fb_height, orientation, perspective) + glEnable(GL_LIGHTING) + glDisable(GL_SCISSOR_TEST) + + def _draw_axes_overlay(self, x: int, y: int, width: int, height: int, text: str, fb_width: int, fb_height: int, orientation: str | None, perspective: bool): + screen_x = x + max(12, width // 30) + screen_y = y + max(12, height // 30) + glMatrixMode(GL_PROJECTION) + glPushMatrix() + glLoadIdentity() + glOrtho(0, fb_width, 0, fb_height, -1, 1) + glMatrixMode(GL_MODELVIEW) + glPushMatrix() + glLoadIdentity() + font_px = max(16, min(width, height) // 10) + label = pyglet.text.Label( + text, + font_size=font_px, + x=screen_x, + y=screen_y, + anchor_x="left", + anchor_y="bottom", + color=(255, 255, 255, 220), + ) + label.draw() + axis_lines: List[str] = [] + if perspective: + pass + #axis_lines.append("+X →, +Y ↑, +Z out") + elif orientation == "front": + axis_lines.append("+X →, +Y ↑") + elif orientation == "top": + axis_lines.append("+X →, +Z ↑") + elif orientation == "side": + axis_lines.append("+Y →, +Z ↑") + + if len(self.layer_names) > 1: + layer_display = [] + for idx, layer in enumerate(self.layer_names, start=1): + state = "ON" if self.visible_layers.get(layer, True) else "off" + layer_display.append(f"{idx}:{layer}({state})") + axis_lines.append("Layers " + ", ".join(layer_display)) + elif self.layer_names: + axis_lines.append(f"Layer {self.layer_names[0]}") + + y_offset = font_px + 6 + for line in axis_lines: + axis_label = pyglet.text.Label( + line, + font_size=max(12, font_px - 2), + x=screen_x, + y=screen_y + y_offset, + anchor_x="left", + anchor_y="bottom", + color=(200, 200, 220, 200), + ) + axis_label.draw() + y_offset += axis_label.content_height + 2 + glMatrixMode(GL_MODELVIEW) + glPopMatrix() + glMatrixMode(GL_PROJECTION) + glPopMatrix() + glMatrixMode(GL_MODELVIEW) + + def _draw_help_overlay(self, fb_width: int, fb_height: int) -> None: + if not self.show_help: + return + glPushAttrib(GL_ENABLE_BIT | GL_DEPTH_BUFFER_BIT) + try: + glDisable(GL_LIGHTING) + glDisable(GL_DEPTH_TEST) + glMatrixMode(GL_PROJECTION) + glPushMatrix() + glLoadIdentity() + glOrtho(0, fb_width, 0, fb_height, -1, 1) + glMatrixMode(GL_MODELVIEW) + glPushMatrix() + glLoadIdentity() + + # margin = 40 + margin = fb_width // 20 + h_margin = fb_height // 30 + panel_width = fb_width - 2 * margin + panel_height = min(fb_height // 1.5 , fb_height - 2 * h_margin) + left = margin + bottom = fb_height - h_margin - panel_height + + glColor4f(0.05, 0.05, 0.08, 0.9) + pyglet.graphics.draw( + 4, + GL_QUADS, + ("v2f", [ + left, bottom, + left + panel_width, bottom, + left + panel_width, bottom + panel_height, + left, bottom + panel_height, + ]), + ) + + active_layers = ", ".join(layer for layer, vis in self.visible_layers.items() if vis) or "none" + help_lines = [ + "Viewer Controls", + "Perspective View (top-left):", + " Left drag within panel – rotate", + " Right drag – pan", + " Scroll/Swipe up – zoom out, down – zoom in", + "Front/Top/Side Views:", + " Right drag – pan (axes specific)", + "Layers:", + " Number keys 1-9 toggle layers, 0 resets", + f" Active layers: {active_layers}", + "General:", + " H or F1 – toggle help, ESC – close window", + ] + +# title_font = max(28, min(fb_width, fb_height) // 13) +# body_font = max(20, title_font // 1.5) + title_font = max(28, min(fb_width, fb_height) // 23) + body_font = max(20, title_font // 1.5) + y = bottom + panel_height - (h_margin + title_font) + for idx, line in enumerate(help_lines): + size = title_font if idx == 0 else body_font + label = pyglet.text.Label( + line, + font_size=int(size), + x=left + 26, + y=y, + anchor_x="left", + anchor_y="baseline", + color=(255, 255, 255, 235), + ) + label.draw() + y -= label.content_height + 10 + + glMatrixMode(GL_MODELVIEW) + glPopMatrix() + glMatrixMode(GL_PROJECTION) + glPopMatrix() + glMatrixMode(GL_MODELVIEW) + finally: + glPopAttrib() + + def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): + if buttons & pyglet.window.mouse.LEFT: + if x < self.width // 2 and y > self.height // 2: + self.azimuth += dx * 0.5 + self.elevation = max(-89.0, min(89.0, self.elevation - dy * 0.5)) + elif buttons & pyglet.window.mouse.RIGHT: + self.pan_x += dx * 0.01 + self.pan_y += dy * 0.01 + + def on_mouse_scroll(self, x, y, scroll_x, scroll_y): + if scroll_y > 0: + self.distance = max(0.5, self.distance * 1.1) + elif scroll_y < 0: + self.distance = max(0.5, self.distance * 0.9) + + def on_key_press(self, symbol, modifiers): + if symbol == key.ESCAPE: + self.close() + elif key._1 <= symbol <= key._9: + idx = symbol - key._1 + if idx < len(self.layer_names): + layer = self.layer_names[idx] + self.visible_layers[layer] = not self.visible_layers.get(layer, True) + elif symbol == key._0: + for layer in self.layer_names: + self.visible_layers[layer] = True + elif symbol in (key.H, key.F1): + self.show_help = not self.show_help +# else: +# print(f"Key pressed: {key.symbol_string(symbol)}") + + +class SketchWindow(pyglet.window.Window): + """2D viewer for sketch entities.""" + + def __init__(self, layer_polylines: Dict[str, List[List[List[float]]]], bounds: Tuple[float, float, float, float], units: str = ""): + super().__init__(width=900, height=700, caption="yapCAD Package Viewer (2D)") + self.layer_polylines = layer_polylines + self.layer_names = sorted(layer_polylines.keys()) or ["default"] + self.active_layers = {layer: True for layer in self.layer_names} + self.bounds = bounds + self.units = units + self.offset_x = -(bounds[0] + bounds[2]) / 2.0 + self.offset_y = -(bounds[1] + bounds[3]) / 2.0 + span_x = bounds[2] - bounds[0] + span_y = bounds[3] - bounds[1] + self.scale = 1.8 / max(span_x, span_y, 1.0) + self.pan = [0.0, 0.0] + self.dragging = False + self.drag_start = (0, 0) + self.show_help = False + glClearColor(0.05, 0.05, 0.07, 1.0) + glEnable(pyglet.gl.GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + + def _draw_grid(self): + xmin, ymin, xmax, ymax = self.bounds + span_x = max(xmax - xmin, 1e-6) + span_y = max(ymax - ymin, 1e-6) + step = _compute_grid_step(max(span_x, span_y)) + min_x = (math.floor(xmin / step) - 5) * step + max_x = (math.ceil(xmax / step) + 5) * step + min_y = (math.floor(ymin / step) - 5) * step + max_y = (math.ceil(ymax / step) + 5) * step + pyglet.gl.glColor4f(0.2, 0.2, 0.28, 0.5) + pyglet.gl.glBegin(pyglet.gl.GL_LINES) + value = min_x + while value <= max_x: + pyglet.gl.glVertex2f(value, min_y) + pyglet.gl.glVertex2f(value, max_y) + value += step + value = min_y + while value <= max_y: + pyglet.gl.glVertex2f(min_x, value) + pyglet.gl.glVertex2f(max_x, value) + value += step + pyglet.gl.glEnd() + + def on_draw(self): + self.clear() + fb_width, fb_height = self.get_framebuffer_size() + glMatrixMode(GL_PROJECTION) + glLoadIdentity() + glOrtho(-fb_width / 2, fb_width / 2, -fb_height / 2, fb_height / 2, -1, 1) + glMatrixMode(GL_MODELVIEW) + glLoadIdentity() + glScalef(self.scale * fb_width / 2, self.scale * fb_height / 2, 1) + glTranslatef(self.pan[0] + self.offset_x, self.pan[1] + self.offset_y, 0) + + self._draw_grid() + + pyglet.gl.glColor4f(0.6, 0.85, 1.0, 1.0) + for layer in self.layer_names: + if not self.active_layers.get(layer, True): + continue + for poly in self.layer_polylines.get(layer, []): + if len(poly) < 2: + continue + closed = poly[0] == poly[-1] + if closed: + glBegin(GL_LINE_LOOP) + for pt in poly[:-1]: + glVertex2f(pt[0], pt[1]) + else: + glBegin(GL_LINES) + for i in range(len(poly) - 1): + x0, y0 = poly[i] + x1, y1 = poly[i + 1] + glVertex2f(x0, y0) + glVertex2f(x1, y1) + glEnd() + + self._draw_overlay() + self._draw_help_overlay(fb_width, fb_height) + + def _draw_overlay(self): + glMatrixMode(GL_PROJECTION) + glPushMatrix() + glLoadIdentity() + fb_width, fb_height = self.get_framebuffer_size() + glOrtho(0, fb_width, 0, fb_height, -1, 1) + glMatrixMode(GL_MODELVIEW) + glPushMatrix() + glLoadIdentity() + + overlay_text = "Press H for help" + if self.units: + overlay_text += f" • Units: {self.units}" + if len(self.layer_names) > 1: + layer_display = [] + for idx, layer in enumerate(self.layer_names, start=1): + state = "ON" if self.active_layers.get(layer, True) else "off" + layer_display.append(f"{idx}:{layer}({state})") + overlay_text += " • Layers " + ", ".join(layer_display) + elif self.layer_names: + overlay_text += f" • Layer {self.layer_names[0]}" + info = pyglet.text.Label( + overlay_text, + font_size=12, + x=16, + y=16, + anchor_x="left", + anchor_y="bottom", + color=(255, 255, 255, 200), + ) + info.draw() + + glMatrixMode(GL_MODELVIEW) + glPopMatrix() + glMatrixMode(GL_PROJECTION) + glPopMatrix() + glMatrixMode(GL_MODELVIEW) + + def _draw_help_overlay(self, fb_width: int, fb_height: int) -> None: + if not self.show_help: + return + glMatrixMode(GL_PROJECTION) + glPushMatrix() + glLoadIdentity() + glOrtho(0, fb_width, 0, fb_height, -1, 1) + glMatrixMode(GL_MODELVIEW) + glPushMatrix() + glLoadIdentity() + + margin = fb_width // 20 + h_margin = fb_height // 30 + panel_width = fb_width/2 - 2 * margin + panel_height = min(fb_height // 1.5 , fb_height // 3 - h_margin) + left = margin + bottom = fb_height - margin - panel_height + + pyglet.gl.glColor4f(0.08, 0.08, 0.1, 0.9) + pyglet.graphics.draw( + 4, + GL_QUADS, + ("v2f", [ + left, bottom, + left + panel_width, bottom, + left + panel_width, bottom + panel_height, + left, bottom + panel_height, + ]), + ) + + help_lines = [ + "Sketch Viewer Controls", + " Scroll/Swipe up – zoom out", + " Scroll/Swipe down – zoom in", + " Right drag – pan", + " 1-9 – toggle layers (0 resets)", + " H or F1 – toggle help, ESC – close", + ] + title_font = max(36, min(fb_width, fb_height) // 30) + body_font = max(24, title_font // 1.5) + y = bottom + panel_height - (h_margin + title_font) + for idx, line in enumerate(help_lines): + size = title_font if idx == 0 else body_font + label = pyglet.text.Label( + line, + font_size=int(size), + x=left + 22, + y=y, + anchor_x="left", + anchor_y="baseline", + color=(255, 255, 255, 230), + ) + label.draw() + y -= label.content_height + 10 + + glMatrixMode(GL_MODELVIEW) + glPopMatrix() + glMatrixMode(GL_PROJECTION) + glPopMatrix() + glMatrixMode(GL_MODELVIEW) + + def on_mouse_scroll(self, x, y, scroll_x, scroll_y): + if scroll_y > 0: + self.scale *= 0.9 + elif scroll_y < 0: + self.scale *= 1.1 + + def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): + if buttons & pyglet.window.mouse.RIGHT: + self.pan[0] += dx / (self.width * self.scale) + self.pan[1] += dy / (self.height * self.scale) + + def on_key_press(self, symbol, modifiers): + if symbol == key.ESCAPE: + self.close() + elif pyglet.window.key._1 <= symbol <= pyglet.window.key._9: + idx = symbol - pyglet.window.key._1 + if idx < len(self.layer_names): + layer = self.layer_names[idx] + self.active_layers[layer] = not self.active_layers.get(layer, True) + elif symbol == pyglet.window.key._0: + for layer in self.layer_names: + self.active_layers[layer] = True + elif symbol in (key.H, key.F1): + self.show_help = not self.show_help +# else: +# print(f"Key pressed: {key.symbol_string(symbol)}") + + +def view_package(package_path: Path | str, *, strict: bool = False) -> bool: + """Validate a package and launch appropriate viewer.""" + pkg_path = Path(package_path) + ok, messages = validate_package(pkg_path, strict=strict) + for msg in messages: + print(msg) + if not ok: + return False + + manifest = PackageManifest.load(pkg_path) + doc = _load_geometry_doc(manifest.root, manifest.data) + try: + geometry_from_json(doc) + except Exception as exc: + print(f"Failed to load geometry: {exc}") + return False + + units = manifest.data.get("units", "") + layer_tris, bbox = _collect_triangles(doc) + print(f"bounding box: {bbox}") + if layer_tris: + window = FourViewWindow(layer_tris, bbox, units=units) + else: + layer_polys, bounds = _collect_polylines(doc) + if not layer_polys: + print("No geometry to display.") + return False + window = SketchWindow(layer_polys, bounds, units=units) + + pyglet.app.run() + return True + + +__all__ = ["view_package"] diff --git a/tests/test_contrib_figgear.py b/tests/test_contrib_figgear.py new file mode 100644 index 0000000..699b2e9 --- /dev/null +++ b/tests/test_contrib_figgear.py @@ -0,0 +1,49 @@ +from math import isclose + +import pytest + +from yapcad.contrib.figgear import make_gear_figure + + +def polygon_area(points): + area = 0.0 + for (x0, y0), (x1, y1) in zip(points, points[1:]): + area += x0 * y1 - x1 * y0 + return area / 2.0 + + +@pytest.mark.parametrize("bottom_type", ["line", "spline"]) +def test_make_gear_figure_properties(bottom_type): + points, info = make_gear_figure( + m=2.0, + z=24, + alpha_deg=20.0, + bottom_type=bottom_type, + involute_step=0.3, + spline_division_num=16, + ) + + assert len(points) > 0 + assert points[0] == points[-1] + + expected_keys = { + "diameter_addendum", + "diameter_pitch", + "diameter_base", + "diameter_dedendum", + "radius_addendum", + "radius_pitch", + "radius_base", + "radius_dedendum", + } + assert expected_keys == set(info.keys()) + + assert info["radius_addendum"] > info["radius_pitch"] > info["radius_base"] + assert info["radius_dedendum"] < info["radius_base"] + + area = polygon_area(points) + assert area > 0.0 + + # The addendum radius should match module-based expectations. + expected_addendum = (24 * 2.0 + 2 * 2.0) / 2.0 + assert isclose(info["radius_addendum"], expected_addendum, rel_tol=1e-9) diff --git a/tests/test_geom3d.py b/tests/test_geom3d.py index ad62d65..f0b7cb7 100644 --- a/tests/test_geom3d.py +++ b/tests/test_geom3d.py @@ -712,6 +712,40 @@ def fcontour(x,ir=10,len=20,fr=5): dd.display() +def test_makeRevolutionSurface_cap_normals(): + """End-cap normals on revolution surfaces should point outward.""" + + def contour(z): + # Zero radius at both ends, smooth bump in the middle. + return max(0.0, 0.5 - abs(z - 0.5)) + + surf = makeRevolutionSurface(contour, 0.0, 1.0, steps=12, arcSamples=24) + verts = surf[1] + faces = surf[3] + + def normals_for_pole(z_value): + idxs = [ + i for i, v in enumerate(verts) + if abs(v[0]) < epsilon and abs(v[1]) < epsilon and abs(v[2] - z_value) < epsilon + ] + assert len(idxs) == 1, "expected exactly one pole vertex" + pole_idx = idxs[0] + collected = [] + for face in faces: + if pole_idx in face: + tri = [verts[i] for i in face] + _, n = tri2p0n(tri) + collected.append(n[2]) + return collected + + bottom_normals = normals_for_pole(0.0) + top_normals = normals_for_pole(1.0) + + assert bottom_normals and top_normals + assert all(n < -1e-6 for n in bottom_normals) + assert all(n > 1e-6 for n in top_normals) + + class TestSolidTopology: """Test solid topology analysis functions: issolidclosed and volumeof""" diff --git a/tests/test_geometry_json.py b/tests/test_geometry_json.py new file mode 100644 index 0000000..d0860bf --- /dev/null +++ b/tests/test_geometry_json.py @@ -0,0 +1,86 @@ +import json + +from yapcad.geom import point, line, arc, iscircle, isarc, catmullrom, iscatmullrom, nurbs, isnurbs +from yapcad.geom3d import poly2surfaceXY, solid +from yapcad.io.geometry_json import geometry_from_json, geometry_to_json, SCHEMA_ID +from yapcad.metadata import ( + add_tags, + get_solid_metadata, + get_surface_metadata, + set_material, + set_layer, +) + + +def _make_prism_solid(): + poly = [ + point(0, 0), + point(1, 0), + point(1, 1), + point(0, 1), + point(0, 0), + ] + surf, _ = poly2surfaceXY(poly) + return solid([surf]) + + +def test_geometry_json_roundtrip(): + sld = _make_prism_solid() + meta = get_solid_metadata(sld, create=True) + add_tags(meta, ['test']) + set_material(meta, name='PLA') + set_layer(meta, 'structure') + # ensure surfaces inherit explicit layer + for surf in sld[1]: + set_layer(get_surface_metadata(surf, create=True), 'structure') + + doc = geometry_to_json([sld], units='mm', generator={'name': 'test', 'version': '0'}) + assert doc['schema'] == SCHEMA_ID + assert doc['units'] == 'mm' + assert len(doc['entities']) >= 2 # solid + surfaces + + solid_entry = next(e for e in doc['entities'] if e['type'] == 'solid') + assert solid_entry['metadata']['layer'] == 'structure' + surface_entries = [e for e in doc['entities'] if e['type'] == 'surface'] + assert surface_entries + assert all(entry['metadata']['layer'] == 'structure' for entry in surface_entries) + + # JSON encode/decode sanity + decoded = json.loads(json.dumps(doc)) + solids = geometry_from_json(decoded) + assert len(solids) == 1 + roundtripped = solids[0] + + round_meta = get_solid_metadata(roundtripped, create=False) + assert round_meta['material']['name'] == 'PLA' + assert 'test' in round_meta['tags'] + assert round_meta['layer'] == 'structure' + + surfaces = roundtripped[1] + assert surfaces and len(surfaces[0][1]) > 0 + surf_meta = get_surface_metadata(surfaces[0], create=False) + assert surf_meta['schema'] == 'metadata-namespace-v0.1' + assert surf_meta['layer'] == 'structure' + + +def test_sketch_primitives_roundtrip(): + geom = [ + line(point(0, 0), point(1, 0)), + arc(point(0, 0), 5), + arc(point(2, 2), 3, 0, 180), + catmullrom([point(0, 0), point(1, 2), point(2, 0)]), + nurbs([point(0, 0), point(1, 2), point(3, 3), point(4, 0)], degree=3), + ] + doc = geometry_to_json([{'geometry': geom, 'metadata': {'layer': 'sketch'}}]) + sketch_entry = next(e for e in doc['entities'] if e['type'] == 'sketch') + primitives = sketch_entry.get('primitives', []) + kinds = {prim['kind'] for prim in primitives} + assert {'line', 'circle', 'arc'}.issubset(kinds) + + roundtrip = geometry_from_json(doc) + assert len(roundtrip) == 1 + returned_geom = roundtrip[0] + assert any(iscircle(entity) for entity in returned_geom) + assert any(isarc(entity) and not iscircle(entity) for entity in returned_geom) + assert any(iscatmullrom(entity) for entity in returned_geom) + assert any(isnurbs(entity) for entity in returned_geom) diff --git a/tests/test_involute_gear.py b/tests/test_involute_gear.py new file mode 100644 index 0000000..e42e8e2 --- /dev/null +++ b/tests/test_involute_gear.py @@ -0,0 +1,93 @@ +import math + +from examples.involute_gear_package.involute_gear import generate_involute_profile + + +def _segments(points): + return list(zip(points[:-1], points[1:])) + + +def _near(pt_a, pt_b, tol=1e-9): + return abs(pt_a[0] - pt_b[0]) <= tol and abs(pt_a[1] - pt_b[1]) <= tol + + +def _segments_intersect(seg_a, seg_b, tol=1e-9): + (ax0, ay0), (ax1, ay1) = seg_a + (bx0, by0), (bx1, by1) = seg_b + + def orient(px0, py0, px1, py1, px2, py2): + return (px1 - px0) * (py2 - py0) - (py1 - py0) * (px2 - px0) + + def on_segment(px0, py0, px1, py1, qx, qy): + return (min(px0, px1) - tol <= qx <= max(px0, px1) + tol and + min(py0, py1) - tol <= qy <= max(py0, py1) + tol) + + o1 = orient(ax0, ay0, ax1, ay1, bx0, by0) + o2 = orient(ax0, ay0, ax1, ay1, bx1, by1) + o3 = orient(bx0, by0, bx1, by1, ax0, ay0) + o4 = orient(bx0, by0, bx1, by1, ax1, ay1) + + if o1 * o2 < -tol and o3 * o4 < -tol: + return True + + if abs(o1) <= tol and on_segment(ax0, ay0, ax1, ay1, bx0, by0): + return True + if abs(o2) <= tol and on_segment(ax0, ay0, ax1, ay1, bx1, by1): + return True + if abs(o3) <= tol and on_segment(bx0, by0, bx1, by1, ax0, ay0): + return True + if abs(o4) <= tol and on_segment(bx0, by0, bx1, by1, ax1, ay1): + return True + return False + + +def _has_real_self_intersections(points): + segs = _segments(points) + last = len(segs) - 1 + + for i, seg_a in enumerate(segs): + for j, seg_b in enumerate(segs): + if j <= i: + continue + if abs(i - j) <= 1: + continue + if (i == 0 and j == last) or (j == 0 and i == last): + continue + if (_near(seg_a[0], seg_b[0]) or _near(seg_a[0], seg_b[1]) or + _near(seg_a[1], seg_b[0]) or _near(seg_a[1], seg_b[1])): + continue + if _segments_intersect(seg_a, seg_b): + return True + return False + + +def _signed_area(points): + total = 0.0 + for (x0, y0), (x1, y1) in _segments(points): + total += x0 * y1 - x1 * y0 + return total / 2.0 + + +def test_involute_profile_orientation_and_simple_polygon(): + outline = generate_involute_profile(teeth=18, module_mm=2.0) + assert outline[0] == outline[-1] + area = _signed_area(outline) + assert area > 0, "outer loop must follow right-hand rule" + assert not _has_real_self_intersections(outline), "profile should be simple" + + +def test_tip_arc_traversal_is_short_path(): + outline = generate_involute_profile(teeth=18, module_mm=2.0) + # Track the maximum angular difference between successive vertices; tip arcs + # should not span almost an entire revolution. + max_delta = 0.0 + prev_angle = None + for x, y in outline: + angle = math.atan2(y, x) + if prev_angle is not None: + diff = abs(angle - prev_angle) + diff = min(diff, abs(diff - 2 * math.pi)) + max_delta = max(max_delta, diff) + prev_angle = angle + # With the corrected ordering, the outline never jumps across the circle. + assert max_delta < math.pi / 2, "adjacent segments should be localised on the circle" diff --git a/tests/test_metadata_utils.py b/tests/test_metadata_utils.py index e1f4087..a0e49d6 100644 --- a/tests/test_metadata_utils.py +++ b/tests/test_metadata_utils.py @@ -3,10 +3,19 @@ from yapcad.geom import point from yapcad.geom3d import poly2surfaceXY, solid, surface from yapcad.metadata import ( + add_analysis_record, + add_compliance, + add_design_history_entry, + add_tags, ensure_solid_id, ensure_surface_id, get_solid_metadata, get_surface_metadata, + set_envelope_constraint, + set_manufacturing, + set_mass_constraint, + set_material, + set_layer, set_solid_context, set_solid_metadata, set_surface_metadata, @@ -24,11 +33,16 @@ def test_surface_metadata_roundtrip(): ] surf, _ = poly2surfaceXY(poly) - assert get_surface_metadata(surf) == {} - - ensure_surface_id(surf) meta = get_surface_metadata(surf) - assert 'id' in meta + assert meta['schema'] == 'metadata-namespace-v0.1' + assert meta['tags'] == [] + assert meta['layer'] == 'default' + + meta = get_surface_metadata(surf, create=True) + assert meta['schema'] == 'metadata-namespace-v0.1' + assert 'entityId' in meta + assert meta['layer'] == 'default' + assert isinstance(meta['tags'], list) set_surface_units(surf, 'mm') set_surface_origin(surf, 'parametric:test') @@ -64,11 +78,15 @@ def test_solid_metadata_helpers(): surf, _ = poly2surfaceXY(poly) sld = solid([surf]) - assert get_solid_metadata(sld) == {} - - ensure_solid_id(sld) meta = get_solid_metadata(sld) - assert 'id' in meta + assert meta['schema'] == 'metadata-namespace-v0.1' + assert meta['tags'] == [] + assert meta['layer'] == 'default' + + meta = get_solid_metadata(sld, create=True) + assert meta['schema'] == 'metadata-namespace-v0.1' + assert 'entityId' in meta + assert meta['layer'] == 'default' set_solid_context(sld, {'solver': 'dummy'}) assert get_solid_metadata(sld)['context'] == {'solver': 'dummy'} @@ -76,3 +94,36 @@ def test_solid_metadata_helpers(): custom = {'id': str(uuid.uuid4())} set_solid_metadata(sld, custom) assert get_solid_metadata(sld) is custom + + +def test_metadata_namespace_helpers(): + poly = [ + point(0, 0), + point(1, 0), + point(0, 1), + point(0, 0), + ] + surf, _ = poly2surfaceXY(poly) + sld = solid([surf]) + ensure_solid_id(sld) + + meta = get_solid_metadata(sld) + add_tags(meta, ['prototype', 'waterjet']) + set_material(meta, name='6061 Aluminum', grade='6061-T6', density_kg_m3=2700) + set_manufacturing(meta, process='waterjet', instructions='Cut in one pass') + add_design_history_entry(meta, author='assistant', source='prompt', revision='A1') + set_mass_constraint(meta, max_kg=5.0) + set_envelope_constraint(meta, x_mm=300, y_mm=100, z_mm=25) + add_compliance(meta, ['ASME Y14.5-2018']) + add_analysis_record(meta, {'solver': 'fea-lite', 'resultId': 'run-42'}) + set_layer(meta, 'structure') + + assert 'prototype' in meta['tags'] + assert meta['material']['grade'] == '6061-T6' + assert meta['manufacturing']['process'] == 'waterjet' + assert meta['designHistory']['iterations'][0]['revision'] == 'A1' + assert meta['constraints']['mass']['max_kg'] == 5.0 + assert meta['constraints']['envelope']['x_mm'] == 300.0 + assert 'ASME Y14.5-2018' in meta['constraints']['compliance'] + assert meta['analysis']['simulations'][0]['solver'] == 'fea-lite' + assert meta['layer'] == 'structure' diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..d5a7ab6 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,141 @@ +import json + +from pathlib import Path + +from yapcad.geom import point +from yapcad.geom import line +from yapcad.geom3d import poly2surfaceXY, solid +from yapcad.metadata import add_tags, get_solid_metadata, get_surface_metadata, set_layer +from yapcad.package import ( + PackageManifest, + create_package_from_entities, + load_geometry, + add_geometry_file, +) +from yapcad.package.validator import validate_package + + +def _make_solid(): + poly = [ + point(0, 0), + point(1, 0), + point(1, 1), + point(0, 1), + point(0, 0), + ] + surf, _ = poly2surfaceXY(poly) + sld = solid([surf]) + meta = get_solid_metadata(sld, create=True) + add_tags(meta, ["prototype"]) + set_layer(meta, "structure") + for surface in sld[1]: + set_layer(get_surface_metadata(surface, create=True), "structure") + return sld + + +def test_create_package_from_entities(tmp_path: Path): + sld = _make_solid() + pkg_root = tmp_path / "demo.ycpkg" + manifest = create_package_from_entities( + [sld], + pkg_root, + name="Demo Part", + version="0.1.0", + author="tester", + description="Example packaged part", + ) + + assert manifest.manifest_path.exists() + data = manifest.data + assert data["schema"] == "ycpkg-spec-v0.1" + assert data["name"] == "Demo Part" + assert data["tags"] == ["prototype"] + + primary = data["geometry"]["primary"] + geometry_path = pkg_root / primary["path"] + assert geometry_path.exists() + assert primary["hash"].startswith("sha256:") + + reload_manifest = PackageManifest.load(pkg_root) + reload_manifest.recompute_hashes() + reload_manifest.save() + + entities = load_geometry(reload_manifest) + assert len(entities) == 1 + meta = get_solid_metadata(entities[0], create=False) + assert "prototype" in meta.get("tags", []) + assert meta.get("layer") == "structure" + + # verify geometry JSON layer annotation + with (pkg_root / primary["path"]).open() as fp: + doc = json.load(fp) + solid_entry = next(e for e in doc["entities"] if e["type"] == "solid") + assert solid_entry["metadata"].get("layer") == "structure" + assert all(entry["metadata"].get("layer") == "structure" for entry in doc["entities"] if entry["type"] == "surface") + + +def test_validate_package(tmp_path: Path): + sld = _make_solid() + pkg_root = tmp_path / "demo.ycpkg" + manifest = create_package_from_entities( + [sld], + pkg_root, + name="Validator Part", + version="0.1.0", + ) + manifest.recompute_hashes() + manifest.save() + ok, messages = validate_package(pkg_root) + assert ok, f"Validation failed: {messages}" + + +def test_package_from_geomlist(tmp_path: Path): + glist = [ + line(point(0, 0), point(10, 0)), + line(point(10, 0), point(10, 5)), + line(point(10, 5), point(0, 5)), + line(point(0, 5), point(0, 0)), + ] + pkg_root = tmp_path / "sketch.ycpkg" + manifest = create_package_from_entities( + [{'geometry': glist, 'metadata': {'layer': 'sketch'}}], + pkg_root, + name="Sketch", + version="0.1.0", + description="Rectangular outline", + ) + manifest.recompute_hashes() + manifest.save() + ok, messages = validate_package(pkg_root) + assert ok, f"Sketch package validation failed: {messages}" + + with (pkg_root / manifest.data['geometry']['primary']['path']).open() as fp: + doc = json.load(fp) + assert all(entry['metadata'].get('layer') == 'sketch' for entry in doc['entities']) + + +def test_add_geometry_file(tmp_path: Path): + sld = _make_solid() + pkg_root = tmp_path / "demo.ycpkg" + manifest = create_package_from_entities( + [sld], + pkg_root, + name="Demo Part", + version="0.1.0", + ) + + external = tmp_path / "imported_gear.step" + external.write_text("STEP 700\n") # dummy payload + + entry = add_geometry_file( + manifest, + external, + purpose="imported component", + ) + manifest.save() + + copied = pkg_root / entry["path"] + assert copied.exists() + assert entry["format"] == "step" + assert entry["source"]["kind"] == "import" + assert any(item["path"] == entry["path"] for item in manifest.data["geometry"]["derived"]) diff --git a/tests/test_solid_spatial.py b/tests/test_solid_spatial.py index 17a4c3f..263e2ba 100644 --- a/tests/test_solid_spatial.py +++ b/tests/test_solid_spatial.py @@ -10,6 +10,7 @@ translatesolid, _iter_triangles_from_solid, tri2p0n, + issolid, ) from yapcad.geom3d_util import prism, sphere, conic, tube @@ -72,6 +73,13 @@ def _make_cavity_solid(): return ['solid', cavity_surfaces, [], []] +def _merge_solids(*solids): + surfaces = [] + for sld in solids: + surfaces.extend(copy.deepcopy(sld[1])) + return ['solid', surfaces, [], []] + + def test_solid_contains_point_basic(): cube = prism(2, 2, 2) assert solid_contains_point(cube, point(0.1, 0.1, 0.1)) @@ -168,3 +176,34 @@ def test_solid_boolean_tube_side_hole(): assert not solid_contains_point(result, point(1.2, 0.0, 0.0)) assert solid_contains_point(result, point(0.0, 1.3, 0.0)) _assert_normals_outward(result, label='tube side hole') + + +@pytest.mark.slow +def test_solid_boolean_union_concave_target(): + outer = prism(4.0, 4.0, 4.0) + inner = prism(3.2, 3.2, 3.2) + shell = solid_boolean(outer, inner, 'difference') + block = prism(2.5, 1.5, 4.0) + + union = solid_boolean(block, shell, 'union') + + assert issolid(union, fast=False) + assert solid_contains_point(union, point(1.2, 0.0, 0.0)) + assert solid_contains_point(union, point(1.9, 0.0, 0.0)) + _assert_normals_outward(union, label='concave union') + + +@pytest.mark.slow +def test_solid_boolean_difference_disconnected_target(): + base = prism(5.0, 2.0, 2.0) + left_cut = translatesolid(prism(1.0, 1.5, 1.5), point(-1.8, 0.0, 0.0)) + right_cut = translatesolid(prism(1.0, 1.5, 1.5), point(1.8, 0.0, 0.0)) + cutter = _merge_solids(left_cut, right_cut) + + result = solid_boolean(base, cutter, 'difference') + + assert issolid(result, fast=False) + assert not solid_contains_point(result, point(-1.8, 0.0, 0.0)) + assert not solid_contains_point(result, point(1.8, 0.0, 0.0)) + assert solid_contains_point(result, point(0.0, 0.0, 0.0)) + _assert_normals_outward(result, label='difference disconnected target') diff --git a/tests/test_splines.py b/tests/test_splines.py index 331a633..8c9fe82 100644 --- a/tests/test_splines.py +++ b/tests/test_splines.py @@ -1,6 +1,10 @@ import math +import subprocess +import sys from pathlib import Path +import ezdxf + import pytest from yapcad.geom import ( @@ -10,8 +14,12 @@ iscatmullrom, nurbs, point, + sample, ) +from yapcad.geom3d import solidbbox +from yapcad.geom3d_util import extrude, poly2surfaceXY from yapcad.geom_util import geomlist2poly +from yapcad.package import create_package_from_entities from yapcad.spline import ( evaluate_catmullrom, evaluate_nurbs, @@ -136,3 +144,115 @@ def test_spline_dxf_output(tmp_path): output = tmp_path / 'splines.dxf' assert output.exists() assert output.stat().st_size > 0 + + +def _sample_closed_curve(curve, segments): + pts = [point(sample(curve, i / segments)) for i in range(segments)] + pts.append(point(sample(curve, 0.0))) + return pts + + +def test_spline_extrusion_package(tmp_path): + outer_ctrl = [ + point(-40, 0), + point(-30, 30), + point(0, 40), + point(30, 30), + point(40, 0), + point(30, -30), + point(0, -40), + point(-30, -30), + ] + outer_curve = catmullrom(outer_ctrl, closed=True) + + hole1_ctrl = [ + point(-10, 0), + point(-6, 8), + point(0, 10), + point(6, 8), + point(10, 0), + point(6, -8), + point(0, -10), + point(-6, -8), + point(-10, 0), + ] + hole1_curve = catmullrom(hole1_ctrl, closed=True) + + hole2_ctrl = [ + point(15, 5), + point(18, 8), + point(22, 5), + point(18, 2), + point(15, 5), + ] + hole2_curve = nurbs(hole2_ctrl, degree=3) + + outer_poly = _sample_closed_curve(outer_curve, segments=256) + hole_polys = [ + _sample_closed_curve(hole1_curve, segments=160), + _sample_closed_curve(hole2_curve, segments=120), + ] + surface, _ = poly2surfaceXY(outer_poly, hole_polys, minlen=0.1) + solid = extrude(surface, 6.0) + bbox = solidbbox(solid) + assert pytest.approx(bbox[1][2] - bbox[0][2], abs=1e-6) == 6.0 + + sketch_geom = [outer_curve, hole1_curve, hole2_curve] + + pkg_root = tmp_path / "spline_extrusion.ycpkg" + manifest = create_package_from_entities( + [ + {"geometry": solid, "metadata": {"layer": "solid", "tags": ["spline"]}}, + {"geometry": sketch_geom, "metadata": {"layer": "sketch"}}, + ], + pkg_root, + name="Spline Extrusion", + version="0.1", + units="mm", + overwrite=True, + ) + manifest.recompute_hashes() + manifest.save() + + primary_json = pkg_root / "geometry" / "primary.json" + import json + with primary_json.open() as fh: + doc = json.load(fh) + sketch_entries = [entry for entry in doc["entities"] if entry["type"] == "sketch"] + assert sketch_entries + primitive_kinds = {prim["kind"] for entry in sketch_entries for prim in entry.get("primitives", [])} + assert {"catmullrom", "nurbs"}.issubset(primitive_kinds) + + export_dir = tmp_path / "exports" + subprocess.check_call( + [ + sys.executable, + "tools/ycpkg_export.py", + str(pkg_root), + "--format", + "dxf", + "--format", + "step", + "--output", + str(export_dir), + "--overwrite", + ], + cwd=Path(__file__).resolve().parents[1], + ) + dxf_path = export_dir / "sketches.dxf" + step_path = export_dir / "solid_01.step" + assert dxf_path.exists() and dxf_path.stat().st_size > 0 + assert step_path.exists() and step_path.stat().st_size > 0 + assert "LWPOLYLINE" in dxf_path.read_text() + + doc = ezdxf.readfile(dxf_path) + polylines = list(doc.modelspace().query("LWPOLYLINE")) + assert polylines + # Pick the smallest loop (nurbs hole) + def loop_span(pl): + xs = [v[0] for v in pl.get_points()] + ys = [v[1] for v in pl.get_points()] + return max(xs) - min(xs) + max(ys) - min(ys) + + small_loop = min(polylines, key=loop_span) + assert small_loop.closed diff --git a/third_party/figgear_LICENSE b/third_party/figgear_LICENSE new file mode 100644 index 0000000..b601e37 --- /dev/null +++ b/third_party/figgear_LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) chromia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tools/ycpkg_export.py b/tools/ycpkg_export.py new file mode 100644 index 0000000..6dbb593 --- /dev/null +++ b/tools/ycpkg_export.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Export geometry from a yapCAD `.ycpkg` package to interchange formats.""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path +from typing import Iterable, List, Sequence + +from yapcad.geom import ( + isgeomlist, + isline, + isarc, + iscircle, + ispoint, + iscatmullrom, + isnurbs, + sample, +) +from yapcad.geom3d import issolid, issurface +from yapcad.geom_util import geomlist2poly_components, geomlist2poly_with_holes +from yapcad.io import write_step, write_stl +from yapcad.io.geometry_json import geometry_from_json, SCHEMA_ID +from yapcad.package import PackageManifest, load_geometry + + +def _export_step(solids: Iterable[Sequence], out_dir: Path) -> List[Path]: + paths: List[Path] = [] + for idx, solid in enumerate(solids, 1): + target = out_dir / f"solid_{idx:02d}.step" + write_step(solid, target) + paths.append(target) + return paths + + +def _export_stl(solids: Iterable[Sequence], out_dir: Path) -> List[Path]: + paths: List[Path] = [] + for idx, solid in enumerate(solids, 1): + target = out_dir / f"solid_{idx:02d}.stl" + write_stl(solid, target) + paths.append(target) + return paths + + +def _export_surfaces_step(surfaces: Iterable[Sequence], out_dir: Path) -> List[Path]: + paths: List[Path] = [] + for idx, surface in enumerate(surfaces, 1): + target = out_dir / f"surface_{idx:02d}.step" + write_step(surface, target) + paths.append(target) + return paths + + +def _export_sketches_dxf(sketches: Iterable[Sequence], out_path: Path) -> Path: + import ezdxf # imported lazily to avoid dependency when DXF export not requested + + doc = ezdxf.new("R2010") + msp = doc.modelspace() + + def _as_xy(pt: Sequence[float]) -> tuple[float, float]: + return float(pt[0]), float(pt[1]) + + def _close_pts(a: Sequence[float], b: Sequence[float], tol: float = 1e-4) -> bool: + dx = float(a[0]) - float(b[0]) + dy = float(a[1]) - float(b[1]) + return (dx * dx + dy * dy) ** 0.5 <= tol + + def _handle_element(element: Sequence, layer: str) -> int: + if isline(element): + start = _as_xy(element[0]) + end = _as_xy(element[1]) + msp.add_line(start, end, dxfattribs={"layer": layer}) + return 1 + if iscircle(element): + center = _as_xy(element[0]) + radius = float(element[1][0]) + msp.add_circle(center, radius, dxfattribs={"layer": layer}) + return 1 + if isarc(element): + center = _as_xy(element[0]) + radius = float(element[1][0]) + start_angle_raw = float(element[1][1]) + end_angle_raw = float(element[1][2]) + start_angle = start_angle_raw % 360.0 + end_angle = end_angle_raw % 360.0 + if abs(end_angle_raw - start_angle_raw) >= 360.0: + msp.add_circle(center, radius, dxfattribs={"layer": layer}) + return 1 + orient = element[1][3] + if orient == -2: + start_angle, end_angle = end_angle, start_angle + msp.add_arc( + center, + radius, + start_angle=start_angle, + end_angle=end_angle, + dxfattribs={"layer": layer}, + ) + return 1 + if iscatmullrom(element) or isnurbs(element): + segs = 64 + coords = [_as_xy(sample(element, i / segs)) for i in range(segs + 1)] + closed = False + if iscatmullrom(element): + closed = bool(element[2].get("closed", False)) + elif isnurbs(element): + ctrl = element[1] + if ctrl: + closed = _close_pts(ctrl[0], ctrl[-1]) + if len(coords) >= 2: + close = closed or _close_pts(coords[0], coords[-1]) + if close: + coords[-1] = coords[0] + coords = coords[:-1] + msp.add_lwpolyline(coords, format="xy", close=close, dxfattribs={"layer": layer}) + return 1 + return 0 + if isinstance(element, list) and element and all(isinstance(pt, list) and len(pt) >= 2 for pt in element): + coords = [_as_xy(pt) for pt in element] + if len(coords) < 2: + return 0 + close = _close_pts(coords[0], coords[-1]) + if close: + coords = coords[:-1] + if len(coords) < 2: + return 0 + msp.add_lwpolyline(coords, format="xy", close=close, dxfattribs={"layer": layer}) + return 1 + return 0 + + for idx, geom in enumerate(sketches, 1): + layer_name = f"SKETCH_{idx:02d}" + if layer_name not in doc.layers: + doc.layers.add(name=layer_name) + written = 0 + for element in geom: + written += _handle_element(element, layer_name) + if written == 0: + raise ValueError("failed to derive DXF entities from sketch geometry") + + doc.saveas(out_path) + return out_path + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Export geometry from a yapCAD .ycpkg package.", + ) + parser.add_argument("package", type=Path, help="Path to the .ycpkg directory.") + parser.add_argument( + "--output", + type=Path, + default=None, + help="Destination directory for exported files (default: PACKAGE/exports).", + ) + parser.add_argument( + "--format", + choices=["step", "stl", "dxf", "all"], + action="append", + help="Export format(s) to generate. Repeat this flag for multiple outputs.", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Allow replacing existing files in the output directory.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + manifest = PackageManifest.load(args.package) + geometry = load_geometry(manifest) + + solids = [] + surfaces = [] + sketches: List[Sequence] = [] + + for entity in geometry: + if issolid(entity): + solids.append(entity) + elif issurface(entity): + surfaces.append(entity) + doc_path = manifest.geometry_primary_path() + with doc_path.open("r", encoding="utf-8") as fh: + doc = json.load(fh) + sketch_entities = [entry for entry in doc.get("entities", []) if entry.get("type") == "sketch"] + for entry in sketch_entities: + subdoc = {"schema": SCHEMA_ID, "entities": [entry]} + sketches.extend(geometry_from_json(subdoc)) + formats = args.format or [] + requested = set() + for fmt in formats: + if fmt == "all": + requested.update({"step", "stl", "dxf"}) + else: + requested.add(fmt) + if not requested: + requested = {"step"} + + out_dir = args.output or (manifest.root / "exports") + out_dir.mkdir(parents=True, exist_ok=True) + + written: List[Path] = [] + + def _check_target(path: Path) -> None: + if path.exists() and not args.overwrite: + raise FileExistsError(f"export target already exists: {path}") + + if solids: + if "step" in requested: + targets = [out_dir / f"solid_{idx:02d}.step" for idx in range(1, len(solids) + 1)] + for target in targets: + _check_target(target) + written.extend(_export_step(solids, out_dir)) + if "stl" in requested: + targets = [out_dir / f"solid_{idx:02d}.stl" for idx in range(1, len(solids) + 1)] + for target in targets: + _check_target(target) + written.extend(_export_stl(solids, out_dir)) + if surfaces and "step" in requested: + targets = [out_dir / f"surface_{idx:02d}.step" for idx in range(1, len(surfaces) + 1)] + for target in targets: + _check_target(target) + written.extend(_export_surfaces_step(surfaces, out_dir)) + if sketches and "dxf" in requested: + dxf_path = out_dir / "sketches.dxf" + _check_target(dxf_path) + written.append(_export_sketches_dxf(sketches, dxf_path)) + + if not written: + print("No geometry exported (check formats and package contents).", file=sys.stderr) + return 1 + + for path in written: + print(path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ycpkg_validate.py b/tools/ycpkg_validate.py new file mode 100644 index 0000000..4e840d7 --- /dev/null +++ b/tools/ycpkg_validate.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Command-line validator for yapCAD .ycpkg packages.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from yapcad.package.validator import validate_package + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Validate a yapCAD .ycpkg package.") + parser.add_argument("package", type=Path, help="Path to the .ycpkg directory.") + parser.add_argument( + "--strict", + action="store_true", + help="Enable additional checks (e.g., require hashes on exports/attachments).", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + ok, messages = validate_package(args.package, strict=args.strict) + for msg in messages: + print(msg) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ycpkg_viewer.py b/tools/ycpkg_viewer.py new file mode 100644 index 0000000..cb53cbb --- /dev/null +++ b/tools/ycpkg_viewer.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Interactive viewer for yapCAD .ycpkg packages.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from yapcad.package.viewer import view_package + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Interactive viewer for .ycpkg packages.") + parser.add_argument("package", type=Path, help="Path to the package directory.") + parser.add_argument("--strict", action="store_true", help="Enable strict validation before viewing.") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + ok = view_package(args.package, strict=args.strict) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main())