diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 070f1a6..3f91eeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,6 @@ name: CI on: pull_request: workflow_dispatch: - push: - branches: [main] jobs: rust-ci: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00965b8..256a1be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,5 +15,5 @@ jobs: if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' uses: UniverLab/workflows/.github/workflows/rust-release.yml@main with: - binary-name: "cadforge" + binary-name: "cadspec" secrets: inherit diff --git a/.gitignore b/.gitignore index 92e211d..a77efdb 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ preview.meta.json # AI agent instruction files — not part of this repo CLAUDE.md AGENTS.md + +.mimocode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index f40a8e7..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,53 +0,0 @@ -# Contributing to cadforge - -Thank you for your interest in contributing! - -## Development workflow - -```bash -git clone https://github.com/UniverLab/cadforge.git -cd cadforge -cargo build -cargo test -``` - -Branch off `develop` for all changes. PRs go to `develop`, not `main`. - -## CI/CD and required secrets - -This project uses GitHub Actions for CI (`.github/workflows/ci.yml`) and automated releases (`.github/workflows/release.yml`). - -### Required repository secrets - -| Secret | Description | Where to get it | -|---|---|---| -| `CARGO_REGISTRY_TOKEN` | API token to publish crates to [crates.io](https://crates.io) | [crates.io/me](https://crates.io/me) → API Tokens → New Token | - -Configure secrets at: -`https://github.com/UniverLab/cadforge/settings/secrets/actions` - -> **Tip:** If you use [ghscaff](https://github.com/UniverLab/ghscaff), you can run `ghscaff apply` to configure missing secrets interactively, or set the env var before running: -> ```bash -> export CARGO_REGISTRY_TOKEN= -> ghscaff apply -> ``` - -## Release process - -Releases are automated via the `release.yml` workflow. To cut a release: - -1. Bump the version in `Cargo.toml` -2. Commit: `chore: release vX.Y.Z` -3. Tag: `git tag vX.Y.Z && git push origin vX.Y.Z` - -The workflow builds binaries for Linux, macOS, and Windows, publishes to crates.io, and creates a GitHub Release. - -## Code style - -```bash -cargo fmt # format -cargo clippy -- -D warnings # lint -cargo test # test -``` - -All three must pass before merging. diff --git a/Cargo.toml b/Cargo.toml index 6ce5be2..eb61594 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "cadforge" +name = "cadspec" version = "0.1.0-beta.2" edition = "2021" -description = "Architecture as Code — deterministic geometry engine for reproducible architectural design" +description = "CAD as code — deterministic geometry engine for reproducible CAD drawings" license = "MIT" -homepage = "https://github.com/UniverLab/cadforge" -repository = "https://github.com/UniverLab/cadforge" +homepage = "https://github.com/UniverLab/cadspec" +repository = "https://github.com/UniverLab/cadspec" publish = true exclude = ["crates/**"] diff --git a/LICENSE b/LICENSE index 19e48cf..f50fef1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,21 @@ -# MIT License +MIT License -See https://opensource.org/licenses/MIT for the full license text. +Copyright (c) 2026 Jheison Morales + +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/README.md b/README.md index ebe8a10..bfecc63 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,108 @@ ```text - █████ ███████████ - ░░███ ░░███░░░░░░█ - ██████ ██████ ███████ ░███ █ ░ ██████ ████████ ███████ ██████ - ███░░███ ░░░░░███ ███░░███ ░███████ ███░░███░░███░░███ ███░░███ ███░░███ -░███ ░░░ ███████ ░███ ░███ ░███░░░█ ░███ ░███ ░███ ░░░ ░███ ░███░███████ -░███ ███ ███░░███ ░███ ░███ ░███ ░ ░███ ░███ ░███ ░███ ░███░███░░░ -░░██████ ░░████████░░████████ █████ ░░██████ █████ ░░███████░░██████ - ░░░░░░ ░░░░░░░░ ░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░███ ░░░░░░ - ███ ░███ - ░░██████ - ░░░░░░ + █████ █████████ + ░░███ ███░░░░░███ + ██████ ██████ ███████ ░███ ░░░ ████████ ██████ ██████ + ███░░███ ░░░░░███ ███░░███ ░░█████████ ░░███░░███ ███░░███ ███░░███ +░███ ░░░ ███████ ░███ ░███ ░░░░░░░░███ ░███ ░███░███████ ░███ ░░░ +░███ ███ ███░░███ ░███ ░███ ███ ░███ ░███ ░███░███░░░ ░███ ███ +░░██████ ░░████████░░████████░░█████████ ░███████ ░░██████ ░░██████ + ░░░░░░ ░░░░░░░░ ░░░░░░░░ ░░░░░░░░░ ░███░░░ ░░░░░░ ░░░░░░ + ░███ + █████ + ░░░░░ ```

- CI - Crates.io + CI + Crates.io Status License

-cadforge is an **Architecture as Code** CLI tool and Rust library for declarative 2D CAD modeling. Write geometry as code in `.cf` TOML format, watch it live in the browser, and compile to DXF — built for humans and AI agents working together. +cadspec is a **CAD as code** CLI tool and Rust library for declarative CAD modeling. Write geometry as code in `.cf` TOML format, watch it live in the browser, and compile to DXF — built for humans and AI agents working together. --- -## Vibecoding CAD +## Features + +- **📐 Declarative Geometry** — Define architectural elements (lines, rects, circles, arcs, polylines, text, dimensions) in TOML `.cf` files. Deterministic, reproducible, version-controlled. +- **🛠️ Construction Tools** — `[[array]]` (linear and polar) and `[[mirror]]` expand into concrete primitives at build time; copies get derived ids. +- **📏 Styled Dimensions** — Auto-measured labels with configurable `text_size`, `precision`, `show_units`, and `offset`. +- **🔴 Live Preview** — `cadspec serve` runs a local server with pan/zoom, auto-reload on save (SSE), click-to-inspect, per-layer ghost/hide, 3D view, and build-error overlay. +- **🔗 Layer System** — Organize geometry by layer with custom names, colors, and line weights. +- **📄 DXF Export** — Compile `.cf` → DXF (AutoCAD-compatible). Full layer support, LWPOLYLINE polygons, LINE/CIRCLE/ARC geometry, TEXT annotations, DIMENSION entities, and SOLID-filled regions. +- **🖼️ Previews for Agents** — Raster PNG + metadata JSON (entity bounding boxes) and full-fidelity SVG with real text, dimensions, line styles, and highlights. +- **✅ Validation Engine** — `cadspec check` validates geometry and constraints; `--json` for tooling. +- **🔄 Formatting** — `cadspec fmt` normalizes `.cf` files. `--check` mode for CI. +- **🔍 DXF Import** — `cadspec import drawing.dxf` migrates existing drawings into `.cf` layers. + +--- + +## Installation -The core loop: **describe geometry in TOML, see it instantly, iterate.** +### Quick install (recommended) + +**Linux / macOS:** ```bash -cadforge new casa && cd casa -cadforge serve --open # live preview in the browser +curl -fsSL https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.sh | sh ``` -Now edit any `.cf` file — by hand or by asking an AI agent — and the browser -updates on every save. Parse errors and constraint violations appear as an -overlay instead of a crash. When the design is right, `cadforge build` emits -a deterministic, AutoCAD-compatible DXF. - -Agents get first-class support: - -- `cadforge schema` — full `.cf` language reference in one command; agents - self-discover the format without prior training. -- `cadforge check --json` / `cadforge layers --json` — machine-readable - validation reports. -- `cadforge preview` — a faithful PNG render (real text, measured dimension - labels, hatches, line styles) + `preview.meta.json` with per-entity bounding - boxes in world and pixel coordinates, so multimodal agents can *look* at the - plan and locate every entity in the image. -- `cadforge preview --highlight ln-001,tx-002` — labeled amber markers around - specific entities, so an agent can visually confirm its edit landed where - intended. -- `cadforge preview --format svg` — same render as vector SVG. +**Windows (PowerShell):** + +```powershell +irm https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.ps1 | iex +``` + +### Via cargo + +```bash +cargo install cadspec +``` + +Available on [crates.io](https://crates.io/crates/cadspec). + +### From source + +```bash +git clone https://github.com/UniverLab/cadspec.git +cd cadspec +cargo build --release +# Binary at target/release/cadspec +``` + +### GitHub Releases + +Check the [Releases](https://github.com/UniverLab/cadspec/releases) page for precompiled binaries (Linux x86_64, macOS x86_64/ARM64, Windows x86_64). + +### Uninstall + +```bash +rm -f ~/.local/bin/cadspec +``` --- -## Features +## Quick Start -### 🎯 Core Platform +```bash +cadspec new casa && cd casa +cadspec serve --open # live preview in the browser +``` -- **📐 Declarative Geometry** — Define architectural elements (lines, rects, circles, arcs, polylines, text, dimensions) in TOML `.cf` files. Deterministic, reproducible, version-controlled. -- **🛠️ Construction Tools** — `[[array]]` (linear and polar: spiral staircases, gear teeth, repeated columns) and `[[mirror]]` expand into concrete primitives at build time; copies get derived ids (`base@1`, `base@m`). -- **📏 Styled Dimensions** — Auto-measured labels with configurable `text_size`, `precision`, `show_units`, and `offset` per dimension. -- **🔴 Live Preview** — `cadforge serve` runs a local server with pan/zoom, auto-reload on save (SSE), click-to-inspect any entity (copy its source TOML as an agent prompt), per-layer ghost/hide states, a 3D stacked-layers view, and a build-error overlay. Zero config. -- **🔗 Layer System** — Organize geometry by layer with custom names, colors, and line weights. Compile single layers or full projects. -- **📄 DXF Export** — Compile `.cf` → DXF (AutoCAD-compatible). Full layer support, LWPOLYLINE for polylines, HATCH for solid fills, MTEXT for annotations. -- **🖼️ Previews for Agents** — Raster PNG + metadata JSON (entity bounding boxes) and full-fidelity SVG with real text, auto-measured dimensions, line styles, and clipped hatch patterns. -- **✅ Validation Engine** — `cadforge check` validates geometry and constraints without generating output; `--json` for tooling. - -### 🏗️ Project Management - -- **Project Scaffolding** — `cadforge new` creates a complete multi-layer project (muros, puertas, mobiliario, cotas) with meaningful architectural examples. -- **Multi-Layer Compilation** — Compile all layers or target specific layers with `--layer`. Custom output path with `--output`. -- **Auto-Rebuild** — `cadforge watch` monitors `.cf` and `.toml` files and auto-rebuilds DXF on changes with 300ms debounce. -- **Code Formatting** — `cadforge fmt` normalizes `.cf` files. `--check` mode for CI validation. -- **Constraints** — `parent`, `belongs_to`, and `spatial_dependency` rules between layers; warnings by default, build-blocking with `strict = true`. -- **DXF Import** — `cadforge import plano.dxf` migrates existing drawings into `.cf` layers + `project.toml`. - -### 🔧 Architecture - -- **Compiler Pipeline** — Parse → Resolve → Compile → Emit. Modular design for easy extension. -- **DXF Writer** — Direct DXF entity writing with proper AutoCAD compatibility. Layer/color/lineweight mapping. -- **Renderer** — One handwritten SVG backend; the PNG preview rasterizes it via resvg with an embedded monospace font (deterministic text on any machine, including fontless containers). -- **Error Reporting** — Structured errors with file, line, and context. Fast-fail on validation errors. +Edit any `.cf` file — the browser updates on every save. When the design is right: + +```bash +cadspec build # default output.dxf +cadspec build --output plano.dxf # custom output path +cadspec build --layer muros # compile single layer +``` + +## Documentation + +Full documentation lives in [`docs/`](docs/): installation, quick start, the +`.cf` format, live preview, building & export, working with agents, and the +complete CLI reference. --- @@ -89,35 +110,35 @@ Agents get first-class support: | Command | Description | |---------|-------------| -| `cadforge new ` | Create a new project with multi-layer scaffold | -| `cadforge init` | Initialize CADforge in current directory | -| `cadforge build` | Compile project to DXF | -| `cadforge build --check` | Validate project and constraints without generating DXF | -| `cadforge build --output ` | Compile to custom output path | -| `cadforge build --layer ` | Compile specific layer only | -| `cadforge serve` | Live preview server — browser auto-reloads on save | -| `cadforge serve --open --port 4377` | Open browser automatically on a custom port | -| `cadforge check` | Validate with project metadata and layer colors | -| `cadforge check --json` | Machine-readable validation report | -| `cadforge layers` | List layers with entity counts and colors | -| `cadforge layers --json` | Machine-readable layer listing | -| `cadforge schema` | Print the full `.cf` language reference (markdown) | -| `cadforge preview` | Faithful PNG render + metadata JSON | -| `cadforge preview --format svg` | Vector SVG preview (same renderer) | -| `cadforge preview --highlight ` | Amber markers around specific entities | -| `cadforge preview --width 1024 -H 768` | Custom resolution preview | -| `cadforge preview --layer ` | Preview specific layer only | -| `cadforge fmt` | Format .cf files (normalize whitespace) | -| `cadforge fmt --check` | Check formatting without modifying (CI) | -| `cadforge watch` | Auto-rebuild on file changes | -| `cadforge import ` | Import DXF into `.cf` layers + `project.toml` | -| `cadforge import --layer ` | Import only one DXF layer | -| `cadforge view` | Open the project in the configured viewer | -| `cadforge view --layer ` | Open only one layer in the viewer | -| `cadforge config set ` | Set global defaults (`author`, `units`) | -| `cadforge config show` | Show global defaults | - -### Live preview controls (`cadforge serve`) +| `cadspec new ` | Create a new project with multi-layer scaffold | +| `cadspec init` | Initialize cadspec in current directory | +| `cadspec build` | Compile project to DXF | +| `cadspec build --check` | Validate project and constraints without generating DXF | +| `cadspec build --output ` | Compile to custom output path | +| `cadspec build --layer ` | Compile specific layer only | +| `cadspec serve` | Live preview server — browser auto-reloads on save | +| `cadspec serve --open --port 4377` | Open browser automatically on a custom port | +| `cadspec check` | Validate with project metadata and layer colors | +| `cadspec check --json` | Machine-readable validation report | +| `cadspec layers` | List layers with entity counts and colors | +| `cadspec layers --json` | Machine-readable layer listing | +| `cadspec schema` | Print the full `.cf` language reference (markdown) | +| `cadspec preview` | Faithful PNG render + metadata JSON | +| `cadspec preview --format svg` | Vector SVG preview (same renderer) | +| `cadspec preview --highlight ` | Amber markers around specific entities | +| `cadspec preview --width 1024 -H 768` | Custom resolution preview | +| `cadspec preview --layer ` | Preview specific layer only | +| `cadspec fmt` | Format .cf files (normalize whitespace) | +| `cadspec fmt --check` | Check formatting without modifying (CI) | +| `cadspec watch` | Auto-rebuild on file changes | +| `cadspec import ` | Import DXF into `.cf` layers + `project.toml` | +| `cadspec import --layer ` | Import only one DXF layer | +| `cadspec view` | Open the project in the configured viewer | +| `cadspec view --layer ` | Open only one layer in the viewer | +| `cadspec config set ` | Set global defaults (`author`, `units`) | +| `cadspec config show` | Show global defaults | + +### Live preview controls (`cadspec serve`) - Scroll → zoom (centered on cursor) · drag → pan · double-click / `F` → fit - **Click an entity** → inspector with its source TOML block; `copy for agent` produces a ready-made targeted-edit prompt @@ -169,6 +190,8 @@ id = "tx-001" position = [4.0, 3.0] content = "SALA" size = 0.2 +font = "serif" +rotation = 15.0 [[dim]] id = "dm-001" @@ -181,7 +204,7 @@ offset = 0.5 `line`, `polyline`, `rect`, `circle`, `arc`, `text`, `point`, `dim`, `hatch`, `fill`, `group` -Run `cadforge schema` for the complete reference with all attributes. +Run `cadspec schema` for the complete reference with all attributes. --- @@ -210,24 +233,6 @@ Run `cadforge schema` for the complete reference with all attributes. --- -## Main Modules - -- `compiler/` — Project compilation pipeline, layer targeting, validation, JSON reports -- `dxf_writer/` — DXF entity writing, LWPOLYLINE, HATCH, MTEXT generation -- `preview/` — PNG rendering with configurable resolution, layer filtering, metadata JSON -- `svg/` — Vector SVG rendering: real text, measured dimensions, hatch clipping, grid -- `serve/` — Live preview server: file watcher + SSE auto-reload + error overlay -- `schema/` — Embedded `.cf` language reference for humans and agents -- `parser/` — TOML parsing, primitive extraction, array-of-tables handling -- `model/` — Data structures: Layer, Primitive, Project -- `scaffold/` — Multi-layer project creation with architectural examples -- `fmt/` — .cf file formatting and normalization -- `watch/` — File system watcher with auto-rebuild and debounce -- `importer/` — DXF → `.cf` migration -- `color/` — Color parsing and DXF color mapping - ---- - ## Data Storage | Data | Location | Format | @@ -236,55 +241,20 @@ Run `cadforge schema` for the complete reference with all attributes. | Build output | `./output.dxf` | DXF | | Preview output | `./preview.png`, `./preview.svg` | PNG / SVG | | Preview metadata | `./preview.meta.json` | JSON | -| Language reference | `cadforge schema` (stdout) | Markdown | - ---- - -## Usage - -**Create a new project:** -```bash -cadforge new mi-proyecto -cd mi-proyecto -``` - -**Live preview while you edit:** -```bash -cadforge serve --open # browser refreshes on every save -``` - -**Edit `.cf` files** (TOML format with your geometry — run `cadforge schema` for the reference) - -**Format and validate:** -```bash -cadforge fmt # normalize .cf files -cadforge check # validate without generating DXF -``` - -**Compile to DXF:** -```bash -cadforge build # default output.dxf -cadforge build --output plano.dxf # custom output path -cadforge build --layer muros # compile single layer -``` - -**Preview:** -```bash -cadforge preview # default 1600x1200 (fits content aspect) -cadforge preview --width 1024 --height 768 # custom resolution -cadforge preview --layer muros # single layer preview -``` - -**Auto-rebuild on changes:** -```bash -cadforge watch # monitors .cf and .toml files -``` +| Language reference | `cadspec schema` (stdout) | Markdown | --- ## Tech Stack -| Rust 2021 | clap | toml | toml_edit | resvg | dxf | notify | anyhow | serde | +| Concern | Crate | +|---|---| +| CLI parsing | `clap` (derive) | +| Error handling | `anyhow` | +| Serialization | `serde` + `toml` + `toml_edit` | +| DXF output | `dxf` | +| File watching | `notify` | +| PNG rasterization | `resvg` | --- @@ -294,4 +264,5 @@ MIT — see [LICENSE](LICENSE) for details. --- -Made with ❤️ by [JheisonMB](https://github.com/JheisonMB) and [UniverLab](https://github.com/UniverLab) \ No newline at end of file +An experiment of [UniverLab](https://github.com/UniverLab) — an open computational laboratory. +Made with ❤️ by [JheisonMB](https://github.com/JheisonMB) diff --git a/cadforge-spec.md b/cadforge-spec.md deleted file mode 100644 index 0abb8d8..0000000 --- a/cadforge-spec.md +++ /dev/null @@ -1,418 +0,0 @@ -# CADforge — Especificación y Roadmap v1.0 - -> Arquitectura como Código — motor determinista de geometría descriptiva para diseño arquitectónico reproducible, versionable y potenciado por agentes de IA. - ---- - -## 1. Visión - -El diseño arquitectónico actual sufre de **entropía gráfica**: los planos son colecciones de líneas sin semántica, imposibles de versionar, comparar o automatizar. CADforge propone un cambio de paradigma: - -**El plano no se dibuja, se declara.** - -Al igual que el código fuente de software, un espacio arquitectónico es el resultado de un lenguaje estructurado. Si el código no cambia, el plano es idéntico bit a bit cada vez que se compila. Esto elimina la ambigüedad del clic humano, habilita `git diff` sobre planos, y permite que los agentes de IA generen y modifiquen diseños con precisión quirúrgica. - -CADforge no es un programa de dibujo. Es la infraestructura para que la arquitectura sea una **ciencia de datos reproducible**. - ---- - -## 2. Ecosistema — Tres Proyectos Separados - -La arquitectura se divide en tres proyectos independientes que se integran entre sí: - -``` -cadforge → motor de geometría (librería Rust, crates.io) -cadforge-cli → interfaz de línea de comandos (binario Rust, crates.io) -cadforge-view → visor gráfico vectorial con modo calco (binario Rust) -``` - -### Separación de responsabilidades - -| Proyecto | Tipo | Responsabilidad | -|---|---|---| -| `cadforge` | Librería | Parser `.cf`, compilador → DXF, motor de geometría, sistema de capas y constraints | -| `cadforge-cli` | Binario | Comandos, wizard, build, watch, import/export, integración con agentes | -| `cadforge-view` | Binario | Visor vectorial estilo consola, modo calco, edición bidireccional → `.cf` | - -La librería `cadforge` es reutilizable por cualquier proyecto Rust — el CLI y el visor son consumidores de ella. - ---- - -## 3. Formato de Proyecto - -Un proyecto CADforge es un directorio con la siguiente estructura: - -``` -mi-proyecto/ -├── project.toml ← archivo raíz: metadatos, capas, constraints -├── capa-a.cf ← capa de primitivos (ej: planta estructural) -├── capa-b.cf ← capa de primitivos (ej: instalaciones) -└── capa-c.cf ← capa de primitivos (ej: acabados y anotaciones) -``` - -El nombre de cada `.cf` lo define el usuario — CADforge no impone nomenclatura ni semántica de capas. Una capa es simplemente un conjunto de primitivos geométricos agrupados. - -### project.toml - -```toml -[project] -name = "Vivienda Unifamiliar Lote 12" -scale = "1:100" -units = "m" -author = "Arq. Nombre Apellido" -version = "0.3.0" - -[layers] -capa-a = { file = "capa-a.cf", locked = false } -capa-b = { file = "capa-b.cf", locked = false } -capa-c = { file = "capa-c.cf", locked = false } - -[constraints] -# Si un primitivo de capa-a se mueve, notificar a capa-b -capa-a → capa-b = "spatial_dependency" - -# Los primitivos de capa-b deben vivir dentro del bbox de capa-a -capa-b.parent = "capa-a" - -# Los primitivos de capa-c referencian explícitamente elementos de capa-b -capa-c.belongs_to = "capa-b" -``` - ---- - -## 4. Lenguaje `.cf` (TOML) - -El formato `.cf` es TOML válido. Se eligió TOML sobre JSON por ser más legible para humanos y agentes — menos contexto, más señal. Los agentes de IA que ya conocen TOML pueden generar y modificar archivos `.cf` sin entrenamiento adicional. - -**Principio clave:** el lenguaje `.cf` trabaja exclusivamente con **primitivos geométricos**. No existe concepto de "muro", "puerta" o "habitación" en el motor base. Esa semántica es responsabilidad del usuario o de capas de abstracción futuras (`cadforge-arch` en v2+). El motor solo sabe de formas, posiciones, atributos visuales y relaciones espaciales. - -### Primitivos soportados en v1 - -| Primitivo | Descripción | -|---|---| -| `[[line]]` | Línea entre dos puntos | -| `[[polyline]]` | Polilínea de múltiples vértices, abierta o cerrada | -| `[[rect]]` | Rectángulo por origen, ancho y alto | -| `[[circle]]` | Círculo por centro y radio | -| `[[arc]]` | Arco por centro, radio y ángulos | -| `[[hatch]]` | Achurado sobre un contorno cerrado | -| `[[text]]` | Texto con posición, tamaño y alineación | -| `[[dim]]` | Cota lineal o angular | -| `[[point]]` | Punto de referencia | -| `[[group]]` | Agrupación de primitivos con id propio | - -### Atributos comunes - -Todos los primitivos comparten atributos visuales opcionales: - -```toml -id = "string" # identificador único en la capa -color = "#FFFFFF" # color en hex -weight = 0.35 # grosor de línea en mm -style = "solid" # solid | dashed | dotted | dashdot -layer = "capa-a" # capa a la que pertenece -visible = true -locked = false -``` - -### Ejemplos - -```toml -# capa-a.cf - -[[line]] -id = "ln-001" -from = [0.0, 0.0] -to = [8.5, 0.0] -weight = 0.50 - -[[polyline]] -id = "pl-001" -points = [[0.0, 0.0], [8.5, 0.0], [8.5, 6.0], [0.0, 6.0]] -closed = true -weight = 0.35 - -[[rect]] -id = "rc-001" -origin = [1.0, 1.0] -width = 3.5 -height = 4.0 -weight = 0.25 - -[[circle]] -id = "ci-001" -center = [4.0, 3.0] -radius = 0.5 - -[[arc]] -id = "ar-001" -center = [2.0, 2.0] -radius = 0.90 -from_angle = 0 -to_angle = 90 - -[[hatch]] -id = "ht-001" -boundary = "pl-001" # referencia al id del contorno cerrado -pattern = "ansi31" # ansi31 | ansi32 | ansi33 | ansi34 | solid | none -scale = 1.0 -angle = 45 - -[[text]] -id = "tx-001" -position = [4.0, 3.0] -content = "SALA" -size = 14 -align = "center" # left | center | right - -[[dim]] -id = "dm-001" -type = "linear" # linear | angular | radial -from = [0.0, 0.0] -to = [8.5, 0.0] -offset = 0.5 # distancia de la cota al elemento - -[[group]] -id = "gr-001" -members = ["ln-001", "rc-001", "tx-001"] -``` - -### Atributos globales de capa - -```toml -[layer] -name = "capa-a" -color = "#FFFFFF" -line_weight = 0.35 -visible = true -locked = false -``` - ---- - -## 5. Sistema de Capas y Constraints - -### Concepto - -Cada archivo `.cf` es una capa independiente. Las capas se orquestan desde `project.toml`. Las **constraints** son reglas declarativas que definen relaciones espaciales y de pertenencia entre capas. - -### Tipos de constraints - -| Constraint | Descripción | -|---|---| -| `spatial_dependency` | Si un objeto de la capa A se mueve, la capa B recibe notificación de conflicto | -| `parent` | Los objetos de la capa hija deben vivir dentro del bbox de la capa padre | -| `belongs_to` | Un objeto de la capa hija referencia explícitamente un objeto de la capa padre | - -### Comportamiento al compilar - -Cuando `cadforge build` detecta una violación de constraints: - -``` -⚠ CONSTRAINT VIOLATION - Layer: capa-b - Object: ln-042 - Constraint: spatial_dependency → capa-a - Detail: pl-001 in capa-a moved 0.30m east — ln-042 in capa-b now outside its bbox - Action: build continues with warning — update capa-b.cf to resolve -``` - -Las constraints no bloquean el build por defecto — emiten warnings. Se puede configurar `strict = true` en `project.toml` para bloquear. - ---- - -## 6. cadforge-cli — Comandos - -```bash -# Inicialización -cadforge new mi-proyecto # crea estructura de proyecto -cadforge init # inicializa en directorio existente - -# Compilación -cadforge build # compila todas las capas → DXF -cadforge build --layer muros # compila una capa específica -cadforge build --check # valida constraints sin generar output - -# Desarrollo -cadforge watch # modo watch: recompila al guardar cualquier .cf - -# Importación / Migración -cadforge import archivo.dxf # convierte DXF existente → .cf (por capas detectadas) -cadforge import archivo.dxf --layer muros # importa a capa específica - -# Visualización -cadforge view # abre cadforge-view con el proyecto actual -cadforge view --layer muros # abre solo una capa - -# Información -cadforge layers # lista capas del proyecto con estado -cadforge check # valida constraints y reporta conflictos - -# Configuración global -cadforge config set author "Arq. Nombre Apellido" -cadforge config set units m -cadforge config show -``` - ---- - -## 7. cadforge-view — Visor Vectorial - -### Filosofía de diseño - -El visor evoca la consola: **fondo negro, líneas vectoriales blancas/grises, tipografía monoespaciada**. No es un editor gráfico pesado — es una ventana de precisión sobre el archivo `.cf`. - -El rendering es **vectorial puro** — las líneas mantienen su calidad a cualquier zoom, sin pérdida de fidelidad como ocurriría en una TUI basada en caracteres. Se construye sobre una librería gráfica de bajo nivel (candidatos: `wgpu`, `femtovg`, `tiny-skia`). - -### Modos de operación - -**Modo lectura:** -- Renderiza el proyecto completo o por capas -- Zoom, pan, toggle de capas -- Muestra constraints activas - -**Modo edición (bidireccional):** -- Herramientas básicas: mover objeto, ajustar dimensión, agregar anotación -- Los cambios se traducen al `.cf` correspondiente **al guardar** (no en tiempo real) -- El archivo `.cf` es siempre la fuente de verdad - -**Modo calco:** -- La ventana del visor se vuelve semi-transparente (alpha configurable) -- Se puede posicionar sobre otra ventana (imagen, plano escaneado, referencia) -- El usuario traza sobre la referencia y los objetos se capturan como `.cf` -- **Auto-calco**: comando que analiza lo que está debajo de la ventana y propone objetos `.cf` detectados (experimental, v2) - -### Atajos de teclado - -``` -Z / X → zoom in / out -Flechas → pan -L → toggle lista de capas -1-9 → toggle visibilidad de capa por número -E → entrar a modo edición -C → entrar a modo calco -S → guardar cambios al .cf (en modo edición) -Esc → salir del modo actual -Q → cerrar visor -``` - ---- - -## 8. Importación DXF → `.cf` - -Una de las propuestas de valor más importantes: **migrar lo que ya existe**. - -```bash -cadforge import plano-existente.dxf -``` - -El importador: -1. Lee las capas del DXF original -2. Mapea cada capa DXF a un archivo `.cf` nuevo -3. Convierte geometría a objetos declarativos cuando es posible (líneas paralelas → `wall`, bloques → `column`, etc.) -4. Lo que no puede inferir lo convierte a `[[line]]` genéricas — siempre importable, siempre editable -5. Genera un `project.toml` con las capas detectadas - -``` -cadforge import plano.dxf - -✓ Detected 4 layers: MUROS, ESTRUCTURA, COTAS, TEXTO -✓ muros.cf — 23 walls inferred, 4 openings -✓ estructura.cf — 8 columns, 1 slab -✓ cotas.cf — 31 annotations (as [[annotation]]) -✓ texto.cf — 12 text objects (as [[annotation]]) -✓ project.toml — generated - -Import complete. Review .cf files and adjust inferred objects. -``` - ---- - -## 9. Integración con el Ecosistema Univerlab - -### gitkit -Los archivos `.cf` y `project.toml` son texto plano — `git diff` muestra exactamente qué cambió: -```diff -- thickness = 0.15 -+ thickness = 0.20 -``` -No hay comparación de binarios, no hay "Plano_Final_v3_ESTE_SI.dwg". - -### agent-canopy -Los agentes pueden leer y escribir archivos `.cf` directamente — TOML es un formato que los LLMs manejan bien. Un agente puede recibir instrucción: -> "Optimiza la distribución de luz natural del salón" - -Y modificar coordenadas y orientaciones en `muros.cf` de forma precisa y auditable. - -### ghscaff -Plantilla `cadforge` en `ghscaff` para inicializar nuevos proyectos CADforge con estructura de repo correcta desde el primer commit. - ---- - -## 10. Stack Técnico - -| Componente | Tecnología | -|---|---| -| Motor de geometría | `truck` (B-rep Rust), `nalgebra` | -| Output DXF | crate `dxf` | -| Parser TOML | `toml` crate | -| CLI | `clap` con derive macros | -| File watcher | `notify` crate | -| Rendering visor | `femtovg` / `tiny-skia` (vectorial, ligero) | -| Ventana visor | `winit` (cross-platform) | -| Transparencia/calco | APIs nativas de ventana por OS | - ---- - -## 11. Roadmap - -### MVP -- [ ] Parser TOML para archivos `.cf` -- [ ] Primitivos base: `line`, `polyline`, `rect`, `circle`, `arc`, `hatch`, `text`, `dim`, `point`, `group` -- [ ] Atributos comunes: color, weight, style, visible, locked -- [ ] Compilador `.cf` → DXF (2D, planos de planta) -- [ ] Sistema de capas con `project.toml` -- [ ] Constraints básicas: `parent`, `belongs_to` -- [ ] `cadforge build` y `cadforge watch` -- [ ] Live preview vía visor externo (LibreCAD, FreeCAD) -- [ ] Publicación en crates.io: `cadforge` (librería) + `cadforge-cli` - -### v1 -- [ ] Importador DXF → `.cf` con detección automática de primitivos -- [ ] `cadforge-view` — visor vectorial propio (fondo negro, líneas vectoriales) - - Modo lectura con zoom/pan y toggle de capas - - Modo edición básico con escritura bidireccional al guardar - - Modo calco con ventana semi-transparente -- [ ] Constraints con warnings en build: `spatial_dependency` -- [ ] Achurados estándar: ansi31, ansi32, ansi33, ansi34, solid -- [ ] Publicación `cadforge-view` en crates.io - -### v2 -- [ ] `cadforge-arch` — capa de abstracción arquitectónica sobre primitivos - - Objetos semánticos: `wall`, `opening`, `room`, `column`, `slab` - - Construidos sobre primitivos del motor base - - Publicado como crate independiente -- [ ] Arch-Linter — validación de normativas arquitectónicas - - Áreas mínimas por tipo de espacio - - Normativas locales configurables por país/región -- [ ] Auto-calco experimental — detección de geometría desde imagen de referencia -- [ ] Output adicional: STL (volumétrico), STEP (intercambio industrial) -- [ ] Constraints `strict = true` que bloquean el build - ---- - -## 12. No-Goals (v1) - -- No es un reemplazo de Revit o AutoCAD para flujos complejos de BIM -- No genera renders fotorrealistas -- No maneja modelos 3D complejos (solo extrusión simple de planta en v1) -- No requiere conexión a internet ni licencias propietarias -- No depende del MCP de Autodesk - ---- - -## 13. Contexto Académico - -`cadforge` es el proyecto de tesis de especialización en IA con enfoque en diseño arquitectónico. La hipótesis central es que tratar la arquitectura como código — con determinismo, versionado y agentes — representa un cambio de paradigma en el flujo de trabajo del diseño arquitectónico. - -El proyecto vive bajo [`univerlab`](https://github.com/univerlab) junto a `texforge`, `gitkit`, `ghscaff` y `agent-canopy`, siguiendo los mismos principios: binario standalone, offline first, sin scope creep. diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..73cc637 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,70 @@ +--- +title: Working with Agents +description: The agent-facing toolchain — schema discovery, JSON reports, visual previews with metadata. +order: 7 +--- + +# Working with Agents + +Cadspec is designed for **humans and AI agents working together** on the +same project. Everything an agent needs is exposed through the CLI. + +## Self-discovery — `cadspec schema` + +```bash +cadspec schema +``` + +Prints the complete `.cf` language reference as markdown. An agent with +shell access can learn the entire format in one command — no prior +training on cadspec required. + +## Machine-readable state + +```bash +cadspec check --json # validation report +cadspec layers --json # layers, entity counts, colors +``` + +## Visual grounding — `cadspec preview` + +```bash +cadspec preview # PNG + preview.meta.json +cadspec preview --format svg # same render as vector SVG +cadspec preview --width 1024 -H 768 # custom resolution +cadspec preview --layer muros # single layer +``` + +The PNG is a faithful render — real text, measured dimension labels, +hatches, line styles — and `preview.meta.json` contains per-entity +bounding boxes in **world and pixel coordinates**. A multimodal agent can +look at the image and locate every entity in it. + +Rendering is deterministic on any machine (an embedded monospace font is +used), including fontless containers. + +## Confirming edits — `--highlight` + +```bash +cadspec preview --highlight ln-001,tx-002 +``` + +Draws labeled amber markers around the listed entities, so an agent can +visually confirm its edit landed exactly where intended. + +## Targeted edit prompts from the browser + +In [live preview](live-preview.md), clicking an entity and pressing +**copy for agent** produces a ready-made prompt with the entity's id, +source TOML and file — paste it into your agent and ask for the change. + +## A typical agent loop + +```bash +cadspec schema # 1. learn the language +cadspec layers --json # 2. inspect the project +# ... edit .cf files ... +cadspec check --json # 3. validate +cadspec preview --highlight # 4. visually confirm +cadspec build # 5. emit the DXF +``` diff --git a/docs/building-and-export.md b/docs/building-and-export.md new file mode 100644 index 0000000..9bebda5 --- /dev/null +++ b/docs/building-and-export.md @@ -0,0 +1,113 @@ +--- +title: Building & Export +description: Deterministic DXF compilation, watch mode, validation, and DXF import. +order: 6 +--- + +# Building & Export + +## Compile to DXF + +```bash +cadspec build # default output.dxf +cadspec build --output plano.dxf # custom output path +cadspec build --layer muros # compile a single layer +cadspec build --check # validate only, no DXF +``` + +The output is deterministic: identical input produces a bit-identical +DXF. Export uses proper AutoCAD-compatible entities — LWPOLYLINE for +polylines and rectangles, LINE/CIRCLE/ARC for open geometry, TEXT for +annotations, DIMENSION for measured dims, and SOLID (fan-triangulated) +for filled regions. Hatch patterns are emitted as clipped LINE segments +(the `dxf` crate has no native HATCH entity). All entities carry full +layer/color/lineweight mapping. + +## Drawing sheets (planos) + +A *plano* is a named view of the model rendered onto a paper-sized sheet +with a frame and a title block (rótulo). Declare sheets in `project.toml` +and render them with `cadspec preview --plano `: + +```toml +[[plano]] +name = "general" +view = "plan" # plan | iso | front | back | left | right | top | section +scale = "1:100" # label shown in the title block +size = [420.0, 297.0] # paper size in mm (default A3 landscape) +title = "Planta general" # defaults to the plano name + +[[plano]] +name = "corte-a" +view = "section" +cut_axis = "x" # section only: x | y | z +cut_at = 4.0 # position of the cut plane along cut_axis +keep = "min" # which side to keep: min (default) | max +``` + +```bash +cadspec preview --plano general # render one sheet +cadspec preview --plano corte-a --format svg +``` + +The title block is generated from the project metadata; set `rotulo = +"titleblock.cf"` on a plano to draw a custom one with the same `.cf` +primitives as the rest of the drawing. The live preview (`cadspec +serve`) lists all declared planos in a panel. + +## Validation + +```bash +cadspec check # geometry + constraints, human-readable +cadspec check --json # machine-readable report +cadspec layers # list layers with entity counts and colors +cadspec layers --json # machine-readable layer listing +``` + +## Watch mode + +```bash +cadspec watch +``` + +Monitors `.cf` and `.toml` files and rebuilds the DXF on changes with a +300 ms debounce. + +## Formatting + +```bash +cadspec fmt # normalize .cf files in place +cadspec fmt --check # CI-friendly check mode +``` + +## Importing existing drawings + +```bash +cadspec import plano.dxf # all layers +cadspec import plano.dxf --layer muros # a single DXF layer +cadspec import plano.dxf --output casa # into a specific directory +``` + +Import migrates an existing DXF into `.cf` layer files plus a +`project.toml`, so legacy drawings can join the declarative workflow. +Lines, polylines, circles, arcs, text, points and dimensions map back to +their `.cf` primitives. Filled and hatched regions are reconstructed too: +adjacent SOLID entities on a layer (such as the fan-triangulated fills that +`cadspec build` emits) are fused back into a single `[[fill]]`, and the +clipped LINE segments a hatch expands into — marked with `CADSPEC_HATCH` +XDATA on export — are re-fused into a single `[[hatch]]` carrying its region +and pattern/angle/scale. So an export/import round-trip does not inflate the +entity count, and only lines this tool emitted are ever folded back into a +hatch (a foreign DXF's ordinary lines are left untouched). + +## Pipeline + +``` +.cf file (TOML) + │ + ▼ + Parser ──▶ Resolver ──▶ Compiler ──▶ DXF Emit + │ + ▼ + Boundary Resolver ──▶ Preview PNG/SVG + JSON meta +``` diff --git a/docs/cf-format.md b/docs/cf-format.md new file mode 100644 index 0000000..6fbf3af --- /dev/null +++ b/docs/cf-format.md @@ -0,0 +1,165 @@ +--- +title: The .cf Format +description: Layers, primitives, styled dimensions, construction tools and constraints. +order: 4 +--- + +# The .cf Format + +A cadspec project is a `project.toml` plus one `.cf` file per layer. +`.cf` files are TOML: a `[layer]` header followed by arrays of entity +tables. + +> The authoritative reference is always `cadspec schema` — it prints the +> complete language specification (markdown) for the exact version you +> have installed. + +## Layers + +```toml +[layer] +name = "muros" +color = "#FFFFFF" +``` + +Layers organize geometry with custom names, colors and line weights. You +can compile or preview single layers (`--layer muros`) or the whole +project. + +## Primitives + +Supported primitives: `line`, `polyline`, `rect`, `circle`, `arc`, +`text`, `point`, `dim`, `hatch`, `fill`, `group`. + +```toml +[[line]] +id = "ln-001" +from = [0.0, 0.0] +to = [8.5, 0.0] +weight = 0.50 + +[[rect]] +id = "rc-001" +origin = [1.0, 1.0] +width = 3.5 +height = 4.0 + +[[circle]] +id = "ci-001" +center = [4.0, 3.0] +radius = 0.5 + +[[arc]] +id = "ac-001" +center = [2.0, 2.0] +radius = 0.9 +from_angle = 0.0 +to_angle = 90.0 + +[[polyline]] +id = "pl-001" +points = [[0.0, 0.0], [5.0, 0.0], [5.0, 3.0], [0.0, 3.0]] +closed = true + +[[text]] +id = "tx-001" +position = [4.0, 3.0] +content = "SALA" +size = 0.2 +align = "center" +font = "serif" +rotation = 15.0 +bold = true +italic = false +``` + +Every entity has a stable `id` — that is what makes plans diffable and +lets agents target precise edits. + +### Text styling fields + +`[[text]]` entities accept four optional styling fields, all of which +default to a plain, unrotated, monospace label when omitted: + +- `font` — a CSS `font-family` value, passed through verbatim to the SVG + renderer (default: `"monospace"`). No fonts are downloaded; the value + is resolved by the browser/renderer against generic CSS families and + whatever fonts are installed locally. +- `rotation` — degrees, counter-clockwise, around the text anchor point + (default: `0`). +- `bold` — `true`/`false` (default: `false`). +- `italic` — `true`/`false` (default: `false`). + +Two limitations to keep in mind: + +1. `font`, `bold` and `italic` only affect the SVG/PNG preview. They are + **not** written to DXF — DXF has no simple equivalent short of + managing a `STYLE` table, which is out of scope. Only `rotation` is + carried through to the DXF `TEXT` entity (group code 50) and survives + a `build` → `import` roundtrip. +2. Text bounding boxes and `align` anchoring are approximated using + monospace glyph proportions. This approximation is increasingly + inaccurate for non-monospace `font` values and does not account for + `rotation` at all, so treat computed text extents as approximate + whenever either field is set. + +## Filled and hatched regions + +`fill` (solid color) and `hatch` (parallel pattern lines) mark an area. The +region can come from either a reference to a closed `polyline`/`rect` `id` in +the **same layer file** (`boundary`) or inline `points`: + +```toml +[[fill]] +id = "fl-closet" +points = [[0.0, 7.0], [1.2, 7.0], [1.2, 9.0], [0.0, 9.0]] +color = "#E0E0E0" + +[[hatch]] +id = "ht-bano" +boundary = "pl-bano" # id of a closed polyline/rect in this file +pattern = "ansi31" +scale = 2.0 +angle = 45.0 +``` + +Boundaries resolve per layer file: a `boundary` id defined in another layer +will not resolve. This is by design — layer files are the resolution +boundary, not an oversight to fix — and both `build` and `preview`/`serve` +warn on stderr and skip the region rather than failing or dropping it +silently. Use inline `points` to reference geometry from another layer. + +At build time a `hatch` expands into DXF `LINE`s (there is no native HATCH +entity in the DXF format this tool targets). Those pattern lines carry a +`CADSPEC_HATCH` marker so `cadspec import` re-fuses them back into a single +`[[hatch]]` — with inline `points` — instead of dozens of stray `[[line]]`s. +Only lines this tool emits carry the marker, so importing a foreign DXF never +mistakes ordinary lines for a hatch. + +## Styled dimensions + +`dim` entities are auto-measured — the label is computed from the +geometry, never typed by hand: + +```toml +[[dim]] +id = "dm-001" +from = [0, 0] +to = [5, 0] +offset = 0.5 +``` + +Configurable per dimension: `text_size`, `precision`, `show_units`, +`offset`. + +## Construction tools + +`[[array]]` (linear and polar — spiral staircases, gear teeth, repeated +columns) and `[[mirror]]` expand into concrete primitives at build time. +Copies get derived ids: `base@1`, `base@2`, … and `base@m` for mirrors. + +## Constraints + +Layers can declare relationships: `parent`, `belongs_to` and +`spatial_dependency`. Violations are warnings by default and +build-blocking with `strict = true` in `project.toml`. diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..e51dafb --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,74 @@ +--- +title: CLI Reference +description: Every cadspec command and flag. +order: 8 +--- + +# CLI Reference + +``` +cadspec [options] +``` + +## Project lifecycle + +| Command | Description | +|---------|-------------| +| `cadspec new ` | Create a new project with multi-layer scaffold | +| `cadspec init` | Initialize cadspec in the current directory | +| `cadspec build` | Compile project to DXF | +| `cadspec build --check` | Validate project and constraints without generating DXF | +| `cadspec build --output ` | Compile to custom output path | +| `cadspec build --layer ` | Compile a specific layer only | +| `cadspec watch` | Auto-rebuild on file changes (300 ms debounce) | + +## Preview + +| Command | Description | +|---------|-------------| +| `cadspec serve` | Live preview server (background) — browser auto-reloads on save | +| `cadspec serve --open --port

` | Open browser automatically on a custom port | +| `cadspec serve --foreground` | Stay attached: stream logs, stop with Ctrl+C | +| `cadspec serve --stop` | Stop the background server running for this project | +| `cadspec preview` | Faithful PNG render + `preview.meta.json` | +| `cadspec preview --format svg` | Vector SVG preview (same renderer) | +| `cadspec preview --format all` | Both PNG and SVG in one pass | +| `cadspec preview --3d` | Axonometric 3D view (extrusions + solids) | +| `cadspec preview --format gltf` | Export the 3D solids to `scene.gltf` (interactive 3D / interchange) | +| `cadspec preview --highlight ` | Amber markers around specific entities | +| `cadspec preview --width -H ` | Custom resolution | +| `cadspec preview --layer ` | Preview a specific layer only | +| `cadspec preview --plano ` | Render a named drawing sheet (`[[plano]]` in `project.toml`) | +| `cadspec view` | Open the project in the configured viewer | +| `cadspec view --layer ` | Open only one layer in the viewer | + +## Inspection & quality + +| Command | Description | +|---------|-------------| +| `cadspec check` | Validate with project metadata and layer colors | +| `cadspec check --json` | Machine-readable validation report | +| `cadspec layers` | List layers with entity counts and colors | +| `cadspec layers --json` | Machine-readable layer listing | +| `cadspec schema` | Print the full `.cf` language reference (markdown) | +| `cadspec fmt` | Format `.cf` files (normalize whitespace) | +| `cadspec fmt --check` | Check formatting without modifying (CI) | + +## Import & config + +| Command | Description | +|---------|-------------| +| `cadspec import ` | Import DXF into `.cf` layers + `project.toml` | +| `cadspec import --layer ` | Import only one DXF layer | +| `cadspec import --output

` | Import into a specific directory | +| `cadspec config set ` | Set global defaults (`author`, `units`) | +| `cadspec config show` | Show global defaults | + +## Output files + +| File | Description | +|------|-------------| +| `output.dxf` | Default build output | +| `preview.png` / `preview.svg` | Preview renders | +| `scene.gltf` | 3D scene export (glTF 2.0, self-contained) | +| `preview.meta.json` | Per-entity bounding boxes (world + pixel coordinates) | diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..549dc89 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,65 @@ +--- +title: Cadspec +description: CAD as code — declarative CAD in TOML, live browser preview, deterministic DXF output. +order: 1 +--- + +# Cadspec + +Cadspec is a **CAD as code** CLI tool and Rust library for +declarative CAD modeling. Write geometry as code in `.cf` TOML files, +watch it live in the browser, and compile to AutoCAD-compatible DXF — +built for humans and AI agents working together. + +## The plan is not drawn — it is declared + +Traditional CAD drawings are collections of lines without semantics: +impossible to version, diff or automate. Cadspec treats an architectural +plan like source code: + +- Same input → **bit-identical output**, every time. +- `git diff` works on floor plans. +- AI agents can generate and modify designs with surgical precision. + +## The core loop + +```bash +cadspec new casa && cd casa +cadspec serve --open # live preview in the browser +``` + +Edit any `.cf` file — by hand or by asking an AI agent — and the browser +updates on every save. Parse errors and constraint violations appear as an +overlay instead of a crash. When the design is right, `cadspec build` +emits a deterministic DXF. + +## Built for agents + +Agents get first-class support: + +- `cadspec schema` — the full `.cf` language reference in one command; + agents self-discover the format without prior training. +- `cadspec check --json` / `cadspec layers --json` — machine-readable + reports. +- `cadspec preview` — a faithful PNG render plus `preview.meta.json` + with per-entity bounding boxes, so multimodal agents can *look* at the + plan and locate every entity. +- `cadspec preview --highlight ln-001,tx-002` — labeled markers to + visually confirm an edit landed where intended. + +## How the documentation is organized + +- [Installation](installation.md) — install, update and uninstall. +- [Quick Start](quickstart.md) — from zero to DXF. +- [The .cf Format](cf-format.md) — layers, primitives, construction tools. +- [Live Preview](live-preview.md) — `cadspec serve` and its controls. +- [Building & Export](building-and-export.md) — build, watch, DXF import/export. +- [Working with Agents](agents.md) — the agent-facing toolchain. +- [CLI Reference](cli-reference.md) — every command and flag. + +## Part of UniverLab + +Cadspec is an experiment of [UniverLab](https://github.com/UniverLab), +an open computational laboratory. It follows the lab's engineering +principles: one tool one job, reproducibility first, offline-friendly +design. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..25e9df6 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,49 @@ +--- +title: Installation +description: Install cadspec with the quick installer, cargo, or from source. +order: 2 +--- + +# Installation + +> Cadspec is currently in **beta** (`0.1.0-beta.x`). Interfaces may still +> change before 1.0. + +## Quick install + +**Linux / macOS:** + +```bash +curl -fsSL https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.sh | sh +``` + +**Windows (PowerShell):** + +```powershell +irm https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.ps1 | iex +``` + +## Via cargo + +```bash +cargo install cadspec +``` + +Available on [crates.io](https://crates.io/crates/cadspec). + +## From source + +```bash +git clone https://github.com/UniverLab/cadspec.git +cd cadspec +cargo build --release +# Binary at target/release/cadspec +``` + +## Uninstall + +```bash +rm -f ~/.local/bin/cadspec +``` + +Projects are plain directories of TOML files — nothing else to clean up. diff --git a/docs/live-preview.md b/docs/live-preview.md new file mode 100644 index 0000000..6fa2694 --- /dev/null +++ b/docs/live-preview.md @@ -0,0 +1,49 @@ +--- +title: Live Preview +description: The cadspec serve loop — pan/zoom, inspector, layer states, 3D view, error overlay. +order: 5 +--- + +# Live Preview + +```bash +cadspec serve # start the local preview server (background) +cadspec serve --open --port 4377 # open browser, custom port +cadspec serve --foreground # stay attached, stream logs, Ctrl+C to stop +cadspec serve --stop # stop the background server for this project +``` + +`cadspec serve` runs a zero-config local server with auto-reload on +save (SSE). Edit a `.cf` file and the browser updates instantly. By +default it detaches into the background and keeps the terminal free; +use `--foreground` to stay attached or `--stop` to shut it down. + +## Controls + +| Action | Effect | +|---|---| +| Scroll | Zoom (centered on cursor) | +| Drag | Pan | +| Double-click / `F` | Fit to content | +| Click an entity | Inspector with its source TOML block | +| Layer panel / keys `1`–`9` | Cycle each layer **on → ghost → off** | +| `3D` button / key `3` | Stacked exploded view of the layers | + +## Inspector and "copy for agent" + +Clicking any entity opens an inspector showing its source TOML. The +**copy for agent** button produces a ready-made targeted-edit prompt you +can paste into an AI agent — the entity id, its current definition, and +the file it lives in. + +## Ghost mode + +Cycling a layer to *ghost* renders it as a faint trace — useful for +drawing one floor plan over another, or checking alignment between +structural and furniture layers. + +## Error overlay + +Build errors (parse errors, constraint violations) render as an overlay +with file and line detail instead of crashing the server. Fix the file, +save, and the view recovers — the loop never breaks. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..4b4c466 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,66 @@ +--- +title: Quick Start +description: Scaffold a project, preview it live, and compile to DXF. +order: 3 +--- + +# Quick Start + +## 1. Create a project + +```bash +cadspec new my-house +cd my-house +``` + +`cadspec new` scaffolds a complete multi-layer project (walls, doors, +furniture, dimensions) with meaningful architectural examples. To adopt +cadspec in an existing directory use `cadspec init`. + +## 2. Preview it live + +```bash +cadspec serve --open +``` + +A local server opens the plan in your browser. Edit any `.cf` file and +the view refreshes on save; build errors render as an overlay with +file/line detail, so the loop never breaks. See +[Live Preview](live-preview.md) for the full controls. + +## 3. Edit geometry + +Geometry lives in `.cf` files — TOML with one table per entity: + +```toml +[layer] +name = "muros" +color = "#FFFFFF" + +[[line]] +id = "ln-001" +from = [0.0, 0.0] +to = [8.5, 0.0] +weight = 0.50 +``` + +Run `cadspec schema` for the complete language reference, or read +[The .cf Format](cf-format.md). + +## 4. Validate and format + +```bash +cadspec check # validate geometry and constraints, no output files +cadspec fmt # normalize .cf files +``` + +## 5. Compile to DXF + +```bash +cadspec build # default output.dxf +cadspec build --output plano.dxf # custom output path +cadspec build --layer muros # single layer +``` + +The output is deterministic and AutoCAD-compatible. `cadspec watch` +rebuilds automatically while you edit. diff --git a/examples/vivienda/achurados.cf b/examples/vivienda/achurados.cf index b8b2dc8..9f49727 100644 --- a/examples/vivienda/achurados.cf +++ b/examples/vivienda/achurados.cf @@ -17,9 +17,11 @@ scale = 2.0 angle = 45.0 # ── Relleno solido del closet dormitorio 1 ───────────────── +# Coincide con el rect rc-closet-d1 de muros.cf (origen [0,7], 1.2 x 2.0). +# Se usan puntos en linea porque los boundary se resuelven por archivo de capa. [[fill]] id = "fl-closet-d1" -boundary = "rc-closet-d1" +points = [[0.0, 7.0], [1.2, 7.0], [1.2, 9.0], [0.0, 9.0]] color = "#E0E0E0" # ── Achurado de la cocina ─────────────────────────────────── diff --git a/project.toml b/project.toml new file mode 100644 index 0000000..4758097 --- /dev/null +++ b/project.toml @@ -0,0 +1,20 @@ + +[[plano]] +name = "P-01" +view = "iso" +size = [420.0, 297.0] +scale = "1:50" +title = "Vista isometrica" + +[[plano]] +name = "P-02" +view = "front" +title = "Alzado frontal" + +[[plano]] +name = "P-03" +view = "section" +cut_axis = "z" +cut_at = 1.5 +keep = "min" +title = "Corte horizontal z=1.5" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 943467b..fae492a 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,5 +1,5 @@ -# install.ps1 — download and install cadforge on Windows -# Usage: irm https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.ps1 | iex +# install.ps1 — download and install cadspec on Windows +# Usage: irm https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.ps1 | iex # # Options (set as env vars before running): # $env:VERSION = "0.1.0" # pin a specific version @@ -7,8 +7,8 @@ $ErrorActionPreference = "Stop" -$Repo = "UniverLab/cadforge" -$Binary = "cadforge.exe" +$Repo = "UniverLab/cadspec" +$Binary = "cadspec.exe" $Target = "x86_64-pc-windows-msvc" $InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { "$env:USERPROFILE\.local\bin" } @@ -43,9 +43,9 @@ if ($env:VERSION) { } # --- download --- -$Archive = "cadforge-$Tag-$Target.zip" +$Archive = "cadspec-$Tag-$Target.zip" $Url = "https://github.com/$Repo/releases/download/$Tag/$Archive" -$Tmp = Join-Path $env:TEMP "cadforge-install" +$Tmp = Join-Path $env:TEMP "cadspec-install" New-Item -ItemType Directory -Force -Path $Tmp | Out-Null Info "download" $Url @@ -55,6 +55,26 @@ try { Fail "Download failed: $_`nURL: $Url" } +# --- verify checksum --- +# Match against SHA256SUMS.txt from the same release. Missing sums file (older +# releases) -> skip; a present-but-mismatched checksum is fatal. +$SumsUrl = "https://github.com/$Repo/releases/download/$Tag/SHA256SUMS.txt" +$SumsOk = $false +try { + Invoke-WebRequest -Uri $SumsUrl -OutFile "$Tmp\SHA256SUMS.txt" -UseBasicParsing + $SumsOk = $true +} catch { + Info "checksum" "SHA256SUMS.txt not found for $Tag - skipping verification" +} +if ($SumsOk) { + $line = Select-String -Path "$Tmp\SHA256SUMS.txt" -Pattern ([regex]::Escape($Archive)) | Select-Object -First 1 + if (-not $line) { Fail "No checksum listed for $Archive in SHA256SUMS.txt" } + $expected = ($line.Line -split '\s+')[0].ToLower() + $actual = (Get-FileHash "$Tmp\$Archive" -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { Fail "Checksum mismatch for $Archive (expected $expected, got $actual)" } + Info "checksum" "verified" +} + # --- extract --- Expand-Archive -Path "$Tmp\$Archive" -DestinationPath $Tmp -Force $extracted = Join-Path $Tmp $Binary @@ -76,8 +96,31 @@ if ($userPath -notlike "*$InstallDir*") { # --- cleanup --- Remove-Item $Tmp -Recurse -Force +# --- install the cadspec agent skill (optional) --- +# Teaches AI agents how to drive cadspec. Skipped when npx is unavailable or +# $env:SKIP_SKILL is set; a failure here never fails the binary install above. +$Skill = "cadspec" +$SkillsRepo = "https://github.com/UniverLab/skills" +if ($env:SKIP_SKILL) { + Info "skill" "skipped (SKIP_SKILL set)" +} elseif (Get-Command npx -ErrorAction SilentlyContinue) { + Info "skill" "adding '$Skill' (npx skills add)" + try { + & npx -y skills add $SkillsRepo --skill $Skill + if ($LASTEXITCODE -eq 0) { + Info "skill" "installed" + } else { + Info "skill" "skipped - add later with: npx skills add $SkillsRepo --skill $Skill" + } + } catch { + Info "skill" "skipped - add later with: npx skills add $SkillsRepo --skill $Skill" + } +} else { + Info "skill" "npx not found - add later with: npx skills add $SkillsRepo --skill $Skill" +} + # --- verify --- $ver = & "$InstallDir\$Binary" --version 2>$null Info "done" $ver Write-Host "" -Info "ready" "Run 'cadforge --help' to get started!" +Info "ready" "Run 'cadspec --help' to get started!" diff --git a/scripts/install.sh b/scripts/install.sh index 044488a..dc63bea 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,10 +1,10 @@ #!/bin/sh -# install.sh — download and install cadforge from GitHub Releases -# Usage: curl -fsSL https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.sh | sh +# install.sh — download and install cadspec from GitHub Releases +# Usage: curl -fsSL https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.sh | sh set -eu -REPO="UniverLab/cadforge" -BINARY="cadforge" +REPO="UniverLab/cadspec" +BINARY="cadspec" INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" info() { printf ' \033[1;34m%s\033[0m %s\n' "$1" "$2"; } @@ -33,7 +33,7 @@ TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT # ============================================================ -# 1. Install cadforge +# 1. Install cadspec # ============================================================ # --- resolve version --- @@ -59,6 +59,29 @@ info "download" "$URL" HTTP_CODE=$(curl -fSL -w '%{http_code}' -o "$TMPDIR/$ARCHIVE" "$URL" 2>/dev/null) || true [ "$HTTP_CODE" = "200" ] || error "Download failed (HTTP $HTTP_CODE). Check that $TAG exists for $TARGET at:\n $URL" +# --- verify checksum --- +# Match against SHA256SUMS.txt from the same release. Missing sums file (older +# releases) or no sha256 tool → skip; a present-but-mismatched checksum is fatal. +SUMS_URL="https://github.com/$REPO/releases/download/${TAG}/SHA256SUMS.txt" +if curl -fsSL -o "$TMPDIR/SHA256SUMS.txt" "$SUMS_URL" 2>/dev/null; then + EXPECTED=$(awk -v f="$ARCHIVE" '$2 == f { print $1 }' "$TMPDIR/SHA256SUMS.txt" | head -1) + [ -n "$EXPECTED" ] || error "No checksum listed for $ARCHIVE in SHA256SUMS.txt" + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "$TMPDIR/$ARCHIVE" | awk '{ print $1 }') + elif command -v shasum >/dev/null 2>&1; then + ACTUAL=$(shasum -a 256 "$TMPDIR/$ARCHIVE" | awk '{ print $1 }') + else + ACTUAL="" + info "checksum" "no sha256 tool found — skipping verification" + fi + if [ -n "$ACTUAL" ]; then + [ "$ACTUAL" = "$EXPECTED" ] || error "Checksum mismatch for $ARCHIVE (expected $EXPECTED, got $ACTUAL)" + info "checksum" "verified" + fi +else + info "checksum" "SHA256SUMS.txt not found for $TAG — skipping verification" +fi + # --- extract --- tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" [ -f "$TMPDIR/$BINARY" ] || error "Binary not found in archive" @@ -88,7 +111,7 @@ if [ -n "$PATHS_TO_ADD" ]; then if [ -f "$profile" ]; then for dir in $PATHS_TO_ADD; do if ! grep -q "export PATH=\"$dir:\$PATH\"" "$profile" 2>/dev/null; then - printf '\n# Added by cadforge installer\nexport PATH="%s:$PATH"\n' "$dir" >> "$profile" + printf '\n# Added by cadspec installer\nexport PATH="%s:$PATH"\n' "$dir" >> "$profile" info "updated" "$profile" fi done @@ -97,9 +120,31 @@ if [ -n "$PATHS_TO_ADD" ]; then fi # ============================================================ -# 3. Verify +# 3. Install the cadspec agent skill (optional) +# ============================================================ +# Teaches AI agents how to drive cadspec. Skipped when npx is unavailable or +# SKIP_SKILL is set; a failure here never fails the binary install above. + +SKILL="cadspec" +SKILLS_REPO="https://github.com/UniverLab/skills" + +if [ -n "${SKIP_SKILL:-}" ]; then + info "skill" "skipped (SKIP_SKILL set)" +elif command -v npx >/dev/null 2>&1; then + info "skill" "adding '$SKILL' (npx skills add)" + if npx -y skills add "$SKILLS_REPO" --skill "$SKILL" /dev/null || echo "$BINARY installed")" echo "" -info "ready" "Run 'cadforge --help' to get started!" +info "ready" "Run 'cadspec --help' to get started!" diff --git a/src/color.rs b/src/color.rs index ba1da47..8d53aa5 100644 --- a/src/color.rs +++ b/src/color.rs @@ -1,35 +1,299 @@ //! Color and weight conversion utilities for DXF output. -/// ACI color index from hex string (best-effort mapping to standard palette). +/// Full AutoCAD Color Index (ACI) palette, indices 1-255, as RGB triples. +/// This is the standard DXF color table (same values used by AutoCAD, +/// LibreCAD, dxflib and ezdxf), not something specific to cadspec. +const ACI_PALETTE: [(u8, (u8, u8, u8)); 255] = [ + (1, (0xFF, 0x00, 0x00)), + (2, (0xFF, 0xFF, 0x00)), + (3, (0x00, 0xFF, 0x00)), + (4, (0x00, 0xFF, 0xFF)), + (5, (0x00, 0x00, 0xFF)), + (6, (0xFF, 0x00, 0xFF)), + (7, (0xFF, 0xFF, 0xFF)), + (8, (0x80, 0x80, 0x80)), + (9, (0xC0, 0xC0, 0xC0)), + (10, (0xFF, 0x00, 0x00)), + (11, (0xFF, 0x7F, 0x7F)), + (12, (0xA5, 0x00, 0x00)), + (13, (0xA5, 0x52, 0x52)), + (14, (0x7F, 0x00, 0x00)), + (15, (0x7F, 0x3F, 0x3F)), + (16, (0x4C, 0x00, 0x00)), + (17, (0x4C, 0x26, 0x26)), + (18, (0x26, 0x00, 0x00)), + (19, (0x26, 0x13, 0x13)), + (20, (0xFF, 0x3F, 0x00)), + (21, (0xFF, 0x9F, 0x7F)), + (22, (0xA5, 0x29, 0x00)), + (23, (0xA5, 0x67, 0x52)), + (24, (0x7F, 0x1F, 0x00)), + (25, (0x7F, 0x4F, 0x3F)), + (26, (0x4C, 0x13, 0x00)), + (27, (0x4C, 0x2F, 0x26)), + (28, (0x26, 0x09, 0x00)), + (29, (0x26, 0x17, 0x13)), + (30, (0xFF, 0x7F, 0x00)), + (31, (0xFF, 0xBF, 0x7F)), + (32, (0xA5, 0x52, 0x00)), + (33, (0xA5, 0x7C, 0x52)), + (34, (0x7F, 0x3F, 0x00)), + (35, (0x7F, 0x5F, 0x3F)), + (36, (0x4C, 0x26, 0x00)), + (37, (0x4C, 0x39, 0x26)), + (38, (0x26, 0x13, 0x00)), + (39, (0x26, 0x1C, 0x13)), + (40, (0xFF, 0xBF, 0x00)), + (41, (0xFF, 0xDF, 0x7F)), + (42, (0xA5, 0x7C, 0x00)), + (43, (0xA5, 0x91, 0x52)), + (44, (0x7F, 0x5F, 0x00)), + (45, (0x7F, 0x6F, 0x3F)), + (46, (0x4C, 0x39, 0x00)), + (47, (0x4C, 0x42, 0x26)), + (48, (0x26, 0x1C, 0x00)), + (49, (0x26, 0x21, 0x13)), + (50, (0xFF, 0xFF, 0x00)), + (51, (0xFF, 0xFF, 0x7F)), + (52, (0xA5, 0xA5, 0x00)), + (53, (0xA5, 0xA5, 0x52)), + (54, (0x7F, 0x7F, 0x00)), + (55, (0x7F, 0x7F, 0x3F)), + (56, (0x4C, 0x4C, 0x00)), + (57, (0x4C, 0x4C, 0x26)), + (58, (0x26, 0x26, 0x00)), + (59, (0x26, 0x26, 0x13)), + (60, (0xBF, 0xFF, 0x00)), + (61, (0xDF, 0xFF, 0x7F)), + (62, (0x7C, 0xA5, 0x00)), + (63, (0x91, 0xA5, 0x52)), + (64, (0x5F, 0x7F, 0x00)), + (65, (0x6F, 0x7F, 0x3F)), + (66, (0x39, 0x4C, 0x00)), + (67, (0x42, 0x4C, 0x26)), + (68, (0x1C, 0x26, 0x00)), + (69, (0x21, 0x26, 0x13)), + (70, (0x7F, 0xFF, 0x00)), + (71, (0xBF, 0xFF, 0x7F)), + (72, (0x52, 0xA5, 0x00)), + (73, (0x7C, 0xA5, 0x52)), + (74, (0x3F, 0x7F, 0x00)), + (75, (0x5F, 0x7F, 0x3F)), + (76, (0x26, 0x4C, 0x00)), + (77, (0x39, 0x4C, 0x26)), + (78, (0x13, 0x26, 0x00)), + (79, (0x1C, 0x26, 0x13)), + (80, (0x3F, 0xFF, 0x00)), + (81, (0x9F, 0xFF, 0x7F)), + (82, (0x29, 0xA5, 0x00)), + (83, (0x67, 0xA5, 0x52)), + (84, (0x1F, 0x7F, 0x00)), + (85, (0x4F, 0x7F, 0x3F)), + (86, (0x13, 0x4C, 0x00)), + (87, (0x2F, 0x4C, 0x26)), + (88, (0x09, 0x26, 0x00)), + (89, (0x17, 0x26, 0x13)), + (90, (0x00, 0xFF, 0x00)), + (91, (0x7F, 0xFF, 0x7F)), + (92, (0x00, 0xA5, 0x00)), + (93, (0x52, 0xA5, 0x52)), + (94, (0x00, 0x7F, 0x00)), + (95, (0x3F, 0x7F, 0x3F)), + (96, (0x00, 0x4C, 0x00)), + (97, (0x26, 0x4C, 0x26)), + (98, (0x00, 0x26, 0x00)), + (99, (0x13, 0x26, 0x13)), + (100, (0x00, 0xFF, 0x3F)), + (101, (0x7F, 0xFF, 0x9F)), + (102, (0x00, 0xA5, 0x29)), + (103, (0x52, 0xA5, 0x67)), + (104, (0x00, 0x7F, 0x1F)), + (105, (0x3F, 0x7F, 0x4F)), + (106, (0x00, 0x4C, 0x13)), + (107, (0x26, 0x4C, 0x2F)), + (108, (0x00, 0x26, 0x09)), + (109, (0x13, 0x58, 0x17)), + (110, (0x00, 0xFF, 0x7F)), + (111, (0x7F, 0xFF, 0xBF)), + (112, (0x00, 0xA5, 0x52)), + (113, (0x52, 0xA5, 0x7C)), + (114, (0x00, 0x7F, 0x3F)), + (115, (0x3F, 0x7F, 0x5F)), + (116, (0x00, 0x4C, 0x26)), + (117, (0x26, 0x4C, 0x39)), + (118, (0x00, 0x26, 0x13)), + (119, (0x13, 0x58, 0x1C)), + (120, (0x00, 0xFF, 0xBF)), + (121, (0x7F, 0xFF, 0xDF)), + (122, (0x00, 0xA5, 0x7C)), + (123, (0x52, 0xA5, 0x91)), + (124, (0x00, 0x7F, 0x5F)), + (125, (0x3F, 0x7F, 0x6F)), + (126, (0x00, 0x4C, 0x39)), + (127, (0x26, 0x4C, 0x42)), + (128, (0x00, 0x26, 0x1C)), + (129, (0x13, 0x58, 0x58)), + (130, (0x00, 0xFF, 0xFF)), + (131, (0x7F, 0xFF, 0xFF)), + (132, (0x00, 0xA5, 0xA5)), + (133, (0x52, 0xA5, 0xA5)), + (134, (0x00, 0x7F, 0x7F)), + (135, (0x3F, 0x7F, 0x7F)), + (136, (0x00, 0x4C, 0x4C)), + (137, (0x26, 0x4C, 0x4C)), + (138, (0x00, 0x26, 0x26)), + (139, (0x13, 0x58, 0x58)), + (140, (0x00, 0xBF, 0xFF)), + (141, (0x7F, 0xDF, 0xFF)), + (142, (0x00, 0x7C, 0xA5)), + (143, (0x52, 0x91, 0xA5)), + (144, (0x00, 0x5F, 0x7F)), + (145, (0x3F, 0x6F, 0x7F)), + (146, (0x00, 0x39, 0x4C)), + (147, (0x26, 0x42, 0x7E)), + (148, (0x00, 0x1C, 0x26)), + (149, (0x13, 0x58, 0x58)), + (150, (0x00, 0x7F, 0xFF)), + (151, (0x7F, 0xBF, 0xFF)), + (152, (0x00, 0x52, 0xA5)), + (153, (0x52, 0x7C, 0xA5)), + (154, (0x00, 0x3F, 0x7F)), + (155, (0x3F, 0x5F, 0x7F)), + (156, (0x00, 0x26, 0x4C)), + (157, (0x26, 0x39, 0x7E)), + (158, (0x00, 0x13, 0x26)), + (159, (0x13, 0x1C, 0x58)), + (160, (0x00, 0x3F, 0xFF)), + (161, (0x7F, 0x9F, 0xFF)), + (162, (0x00, 0x29, 0xA5)), + (163, (0x52, 0x67, 0xA5)), + (164, (0x00, 0x1F, 0x7F)), + (165, (0x3F, 0x4F, 0x7F)), + (166, (0x00, 0x13, 0x4C)), + (167, (0x26, 0x2F, 0x7E)), + (168, (0x00, 0x09, 0x26)), + (169, (0x13, 0x17, 0x58)), + (170, (0x00, 0x00, 0xFF)), + (171, (0x7F, 0x7F, 0xFF)), + (172, (0x00, 0x00, 0xA5)), + (173, (0x52, 0x52, 0xA5)), + (174, (0x00, 0x00, 0x7F)), + (175, (0x3F, 0x3F, 0x7F)), + (176, (0x00, 0x00, 0x4C)), + (177, (0x26, 0x26, 0x7E)), + (178, (0x00, 0x00, 0x26)), + (179, (0x13, 0x13, 0x58)), + (180, (0x3F, 0x00, 0xFF)), + (181, (0x9F, 0x7F, 0xFF)), + (182, (0x29, 0x00, 0xA5)), + (183, (0x67, 0x52, 0xA5)), + (184, (0x1F, 0x00, 0x7F)), + (185, (0x4F, 0x3F, 0x7F)), + (186, (0x13, 0x00, 0x4C)), + (187, (0x2F, 0x26, 0x7E)), + (188, (0x09, 0x00, 0x26)), + (189, (0x17, 0x13, 0x58)), + (190, (0x7F, 0x00, 0xFF)), + (191, (0xBF, 0x7F, 0xFF)), + (192, (0x52, 0x00, 0xA5)), + (193, (0x7C, 0x52, 0xA5)), + (194, (0x3F, 0x00, 0x7F)), + (195, (0x5F, 0x3F, 0x7F)), + (196, (0x26, 0x00, 0x4C)), + (197, (0x39, 0x26, 0x7E)), + (198, (0x13, 0x00, 0x26)), + (199, (0x1C, 0x13, 0x58)), + (200, (0xBF, 0x00, 0xFF)), + (201, (0xDF, 0x7F, 0xFF)), + (202, (0x7C, 0x00, 0xA5)), + (203, (0x91, 0x52, 0xA5)), + (204, (0x5F, 0x00, 0x7F)), + (205, (0x6F, 0x3F, 0x7F)), + (206, (0x39, 0x00, 0x4C)), + (207, (0x42, 0x26, 0x4C)), + (208, (0x1C, 0x00, 0x26)), + (209, (0x58, 0x13, 0x58)), + (210, (0xFF, 0x00, 0xFF)), + (211, (0xFF, 0x7F, 0xFF)), + (212, (0xA5, 0x00, 0xA5)), + (213, (0xA5, 0x52, 0xA5)), + (214, (0x7F, 0x00, 0x7F)), + (215, (0x7F, 0x3F, 0x7F)), + (216, (0x4C, 0x00, 0x4C)), + (217, (0x4C, 0x26, 0x4C)), + (218, (0x26, 0x00, 0x26)), + (219, (0x58, 0x13, 0x58)), + (220, (0xFF, 0x00, 0xBF)), + (221, (0xFF, 0x7F, 0xDF)), + (222, (0xA5, 0x00, 0x7C)), + (223, (0xA5, 0x52, 0x91)), + (224, (0x7F, 0x00, 0x5F)), + (225, (0x7F, 0x3F, 0x6F)), + (226, (0x4C, 0x00, 0x39)), + (227, (0x4C, 0x26, 0x42)), + (228, (0x26, 0x00, 0x1C)), + (229, (0x58, 0x13, 0x58)), + (230, (0xFF, 0x00, 0x7F)), + (231, (0xFF, 0x7F, 0xBF)), + (232, (0xA5, 0x00, 0x52)), + (233, (0xA5, 0x52, 0x7C)), + (234, (0x7F, 0x00, 0x3F)), + (235, (0x7F, 0x3F, 0x5F)), + (236, (0x4C, 0x00, 0x26)), + (237, (0x4C, 0x26, 0x39)), + (238, (0x26, 0x00, 0x13)), + (239, (0x58, 0x13, 0x1C)), + (240, (0xFF, 0x00, 0x3F)), + (241, (0xFF, 0x7F, 0x9F)), + (242, (0xA5, 0x00, 0x29)), + (243, (0xA5, 0x52, 0x67)), + (244, (0x7F, 0x00, 0x1F)), + (245, (0x7F, 0x3F, 0x4F)), + (246, (0x4C, 0x00, 0x13)), + (247, (0x4C, 0x26, 0x2F)), + (248, (0x26, 0x00, 0x09)), + (249, (0x58, 0x13, 0x17)), + (250, (0x00, 0x00, 0x00)), + (251, (0x65, 0x65, 0x65)), + (252, (0x66, 0x66, 0x66)), + (253, (0x99, 0x99, 0x99)), + (254, (0xCC, 0xCC, 0xCC)), + (255, (0xFF, 0xFF, 0xFF)), +]; + +/// ACI color index from hex string: nearest color in the full 255-entry +/// ACI palette (unparseable input falls back to 7, white). pub fn hex_to_aci(hex: &str) -> u8 { - match hex.to_uppercase().trim_start_matches('#') { - "FF0000" => 1, // red - "FFFF00" => 2, // yellow - "00FF00" => 3, // green - "00FFFF" => 4, // cyan - "0000FF" => 5, // blue - "FF00FF" => 6, // magenta - "FFFFFF" => 7, // white - "808080" => 8, // dark grey - "C0C0C0" => 9, // light grey - _ => 7, // default white + let hex = hex.trim_start_matches('#'); + let Ok(rgb) = u32::from_str_radix(hex, 16) else { + return 7; + }; + if hex.len() != 6 { + return 7; } + let (r, g, b) = ( + (rgb >> 16) as i32, + ((rgb >> 8) & 0xFF) as i32, + (rgb & 0xFF) as i32, + ); + ACI_PALETTE + .iter() + .min_by_key(|(_, (pr, pg, pb))| { + let (dr, dg, db) = (r - *pr as i32, g - *pg as i32, b - *pb as i32); + dr * dr + dg * dg + db * db + }) + .map(|(index, _)| *index) + .unwrap_or(7) } -/// Hex color from an ACI color index (inverse of `hex_to_aci`). -pub fn aci_to_hex(index: u8) -> &'static str { - match index { - 1 => "#FF0000", // red - 2 => "#FFFF00", // yellow - 3 => "#00FF00", // green - 4 => "#00FFFF", // cyan - 5 => "#0000FF", // blue - 6 => "#FF00FF", // magenta - 7 => "#FFFFFF", // white - 8 => "#808080", // dark grey - 9 => "#C0C0C0", // light grey - _ => "#FFFFFF", // default white - } +/// Hex color from an ACI color index (inverse of `hex_to_aci`). Indices +/// outside 1-255 (e.g. 0, BYBLOCK) fall back to white. +pub fn aci_to_hex(index: u8) -> String { + ACI_PALETTE + .iter() + .find(|(i, _)| *i == index) + .map(|(_, (r, g, b))| format!("#{r:02X}{g:02X}{b:02X}")) + .unwrap_or_else(|| "#FFFFFF".to_string()) } /// Convert hex color string to 24-bit integer for DXF true color. @@ -52,15 +316,40 @@ mod tests { assert_eq!(hex_to_aci("#FF0000"), 1); assert_eq!(hex_to_aci("#00FF00"), 3); assert_eq!(hex_to_aci("#FFFFFF"), 7); - assert_eq!(hex_to_aci("#123456"), 7); // unknown → white + } + + #[test] + fn hex_to_aci_maps_arbitrary_colors_to_nearest() { + assert_eq!(hex_to_aci("#FF4444"), 20); // reddish → nearest ACI red-orange shade + assert_eq!(hex_to_aci("#00CC44"), 112); // greenish → nearest ACI green-cyan shade + assert_eq!(hex_to_aci("#2244CC"), 152); // bluish → nearest ACI blue shade + assert_eq!(hex_to_aci("#123456"), 146); // dark muted → nearest ACI dark blue shade + assert_eq!(hex_to_aci("#invalid"), 7); // unparseable → white + assert_eq!(hex_to_aci("#FFF"), 7); // wrong length → white } #[test] fn aci_roundtrips_standard_palette() { for index in 1..=9u8 { - assert_eq!(hex_to_aci(aci_to_hex(index)), index); + assert_eq!(hex_to_aci(&aci_to_hex(index)), index); + } + assert_eq!(aci_to_hex(0), "#FFFFFF"); // out of range (e.g. BYBLOCK) → white + } + + #[test] + fn aci_roundtrips_full_palette_by_color() { + // Some ACI indices share the exact same RGB value (e.g. several dark + // greys), so hex_to_aci may not return the original index — but it + // must always land on an index with the identical color. + for index in 1..=255u8 { + let hex = aci_to_hex(index); + let roundtripped = hex_to_aci(&hex); + assert_eq!( + aci_to_hex(roundtripped), + hex, + "index {index} ({hex}) drifted to a different color on roundtrip" + ); } - assert_eq!(aci_to_hex(42), "#FFFFFF"); // unknown → white } #[test] diff --git a/src/compiler.rs b/src/compiler.rs index 48f9518..97b1a9a 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -333,11 +333,15 @@ pub fn compile_project( let project = parse_project(&project_dir.join("project.toml"))?; let mut writer = DxfWriter::new(); + let loaded_layers = load_layers(project_dir, &project.layers)?; for name in project.layers.keys() { - writer.add_layer(name, 7); + let color = loaded_layers + .get(name) + .and_then(|cf| cf.layer_meta.as_ref()) + .and_then(|meta| meta.color.as_deref()) + .unwrap_or("#FFFFFF"); + writer.add_layer(name, hex_to_aci(color)); } - - let loaded_layers = load_layers(project_dir, &project.layers)?; let issues = validate_constraints(&project, &loaded_layers); let strict = is_strict(&project); if !issues.is_empty() { @@ -369,14 +373,14 @@ pub fn compile_project( .unwrap_or_else(|| project_dir.join("output.dxf")); writer.save(&output_path)?; - println!("✓ DXF generado: {}", output_path.display()); + println!("✓ DXF generated: {}", output_path.display()); println!( - " {} entidades en {} capas", + " {} entities in {} layers", total_entities, layer_stats.len() ); for (name, count) in &layer_stats { - println!(" {}: {} entidades", name, count); + println!(" {}: {} entities", name, count); } Ok(()) } @@ -632,6 +636,7 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { e.position[1], e.size, &e.content, + e.rotation.unwrap_or(0.0), resolve_layer(&e.common, default_layer), &style, ); @@ -673,8 +678,27 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { let style = resolve_style(&e.common); let spacing = 0.1 * e.scale; // base spacing scaled - if let Some(boundary) = resolve_boundary(&e.boundary, cf) { - writer.hatch(&boundary, e.angle, spacing, layer, &style); + let boundary = if let Some(ref boundary_id) = e.boundary { + let resolved = resolve_boundary(boundary_id, cf); + if resolved.is_none() { + warn_unresolved_boundary( + "hatch", + e.common.id.as_deref(), + boundary_id, + default_layer, + ); + } + resolved + } else { + e.points + .as_ref() + .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + }; + + if let Some(boundary) = boundary { + writer.hatch( + &boundary, e.angle, spacing, e.scale, &e.pattern, layer, &style, + ); } } @@ -684,7 +708,16 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { let style = resolve_style(&e.common); let pts = if let Some(ref boundary_id) = e.boundary { - resolve_boundary(boundary_id, cf) + let resolved = resolve_boundary(boundary_id, cf); + if resolved.is_none() { + warn_unresolved_boundary( + "fill", + e.common.id.as_deref(), + boundary_id, + default_layer, + ); + } + resolved } else { e.points .as_ref() @@ -697,6 +730,26 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { } } +/// Warn (without failing the build) when a hatch/fill references a boundary id +/// that does not resolve to any closed polyline or rect in the same layer file. +/// Boundaries are resolved per layer file; a reference to an id defined in a +/// different layer will not resolve and the region is skipped. Shared with +/// `svg.rs` so preview/SVG rendering warns on the same condition as `build`. +pub(crate) fn warn_unresolved_boundary( + kind: &str, + entity_id: Option<&str>, + boundary: &str, + layer: &str, +) { + let who = entity_id + .map(|id| format!("'{}'", id)) + .unwrap_or_else(|| "".to_string()); + eprintln!( + "warning: {kind} {who} in layer '{layer}' references boundary '{boundary}', \ + which is not a closed polyline or rect in this layer — region skipped" + ); +} + /// Resolve a boundary id to a list of (x,y) points from polylines or rects in the file. pub fn resolve_boundary(id: &str, cf: &CfFile) -> Option> { // Search polylines diff --git a/src/config.rs b/src/config.rs index dca785c..d9cdd14 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,18 +1,18 @@ -//! Global configuration for cadforge CLI defaults. +//! Global configuration for cadspec CLI defaults. use anyhow::{anyhow, Context, Result}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use toml_edit::{value, DocumentMut, Item, Table}; const SUPPORTED_KEYS: &[&str] = &["author", "units"]; fn config_path() -> Result { let home = std::env::var("HOME").context("HOME environment variable is not set")?; - Ok(PathBuf::from(home).join(".cadforge").join("config.toml")) + Ok(PathBuf::from(home).join(".cadspec").join("config.toml")) } -fn load_document(path: &PathBuf) -> Result { +fn load_document(path: &Path) -> Result { if !path.exists() { return Ok(DocumentMut::new()); } @@ -34,6 +34,10 @@ fn ensure_defaults_table(doc: &mut DocumentMut) -> Result<&mut Table> { } pub fn config_set(key: &str, val: &str) -> Result<()> { + config_set_at(&config_path()?, key, val) +} + +fn config_set_at(path: &Path, key: &str, val: &str) -> Result<()> { if !SUPPORTED_KEYS.contains(&key) { let options = SUPPORTED_KEYS.join(", "); return Err(anyhow!( @@ -43,26 +47,27 @@ pub fn config_set(key: &str, val: &str) -> Result<()> { )); } - let path = config_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("Cannot create {}", parent.display()))?; } - let mut doc = load_document(&path)?; + let mut doc = load_document(path)?; let defaults = ensure_defaults_table(&mut doc)?; defaults[key] = value(val); - fs::write(&path, doc.to_string()) - .with_context(|| format!("Cannot write {}", path.display()))?; + fs::write(path, doc.to_string()).with_context(|| format!("Cannot write {}", path.display()))?; println!("✓ config {} = {}", key, val); println!(" {}", path.display()); Ok(()) } pub fn config_show() -> Result<()> { - let path = config_path()?; - let doc = load_document(&path)?; + config_show_at(&config_path()?) +} + +fn config_show_at(path: &Path) -> Result<()> { + let doc = load_document(path)?; println!("Global config: {}", path.display()); if let Some(defaults) = doc.get("defaults").and_then(Item::as_table) { for key in SUPPORTED_KEYS { @@ -73,3 +78,65 @@ pub fn config_show() -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_path(name: &str) -> PathBuf { + let dir = PathBuf::from("/tmp").join(name); + let _ = fs::remove_dir_all(&dir); + dir.join("config.toml") + } + + #[test] + fn config_set_rejects_unsupported_key() { + let path = scratch_path("cadspec_test_config_bad_key"); + + let err = config_set_at(&path, "bogus", "x").unwrap_err(); + + assert!(err.to_string().contains("Unsupported config key 'bogus'")); + assert!(!path.exists()); + } + + #[test] + fn config_set_creates_parent_dir_and_writes_value() { + let path = scratch_path("cadspec_test_config_new"); + + config_set_at(&path, "author", "Jheison").unwrap(); + + let doc = load_document(&path).unwrap(); + assert_eq!( + doc["defaults"]["author"].as_str(), + Some("Jheison"), + "config file: {}", + fs::read_to_string(&path).unwrap() + ); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn config_set_preserves_other_keys_and_overwrites_target() { + let path = scratch_path("cadspec_test_config_overwrite"); + + config_set_at(&path, "author", "Jheison").unwrap(); + config_set_at(&path, "units", "mm").unwrap(); + config_set_at(&path, "author", "Someone Else").unwrap(); + + let doc = load_document(&path).unwrap(); + assert_eq!(doc["defaults"]["author"].as_str(), Some("Someone Else")); + assert_eq!(doc["defaults"]["units"].as_str(), Some("mm")); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn config_show_on_missing_file_does_not_error() { + let path = scratch_path("cadspec_test_config_missing"); + + config_show_at(&path).unwrap(); + + assert!(!path.exists()); + } +} diff --git a/src/dxf_writer.rs b/src/dxf_writer.rs index e3f4dd3..9ca919b 100644 --- a/src/dxf_writer.rs +++ b/src/dxf_writer.rs @@ -3,10 +3,16 @@ use anyhow::Result; use dxf::entities::{Entity, EntityType, LwPolyline}; use dxf::enums::AcadVersion; -use dxf::tables::Layer; -use dxf::{Color, Drawing, LwPolylineVertex, Point}; +use dxf::tables::{AppId, Layer}; +use dxf::{Color, Drawing, LwPolylineVertex, Point, XData, XDataItem}; use std::path::Path; +/// XDATA application name stamped on the pattern lines a hatch expands into, so +/// the DXF importer can recognize and re-fuse them back into a single `[[hatch]]` +/// (instead of dozens of stray `[[line]]`s). Only lines this tool emits carry +/// the tag, so import never mistakes a foreign DXF's lines for a hatch. +pub const HATCH_XDATA_APP: &str = "CADSPEC_HATCH"; + /// Optional visual attributes applied to any entity. #[derive(Default, Clone)] pub struct EntityStyle { @@ -24,6 +30,9 @@ impl EntityStyle { /// Builder for constructing a DXF drawing from primitives. pub struct DxfWriter { drawing: Drawing, + /// Monotonic id assigned to each hatch so its expanded pattern lines can be + /// grouped back together on import. + hatch_group_seq: i32, } impl DxfWriter { @@ -36,7 +45,17 @@ impl DxfWriter { drawing.add_line_type(Self::make_line_type("DOTTED", &[0.0, -0.25])); drawing.add_line_type(Self::make_line_type("DASHDOT", &[0.5, -0.25, 0.0, -0.25])); - Self { drawing } + // Register the APPID our hatch pattern lines stamp XDATA under, so + // strict readers don't have to fall back on undeclared-app tolerance. + drawing.add_app_id(AppId { + name: HATCH_XDATA_APP.to_string(), + ..Default::default() + }); + + Self { + drawing, + hatch_group_seq: 0, + } } fn make_line_type(name: &str, pattern: &[f64]) -> dxf::tables::LineType { @@ -51,7 +70,13 @@ impl DxfWriter { } /// Add a named layer with an ACI color index (1-255). + /// Re-adding an existing layer updates its color instead of duplicating + /// the LAYER table record. pub fn add_layer(&mut self, name: &str, color_index: u8) { + if let Some(existing) = self.drawing.layers_mut().find(|l| l.name == name) { + existing.color = Color::from_index(color_index); + return; + } let layer = Layer { name: name.to_string(), color: Color::from_index(color_index), @@ -62,7 +87,8 @@ impl DxfWriter { // ── Single entry point for adding entities ───────────────────────── - fn add_entity(&mut self, entity_type: EntityType, layer: &str, style: &EntityStyle) { + /// Build a styled entity (layer + color/weight/line-type) without adding it. + fn styled_entity(entity_type: EntityType, layer: &str, style: &EntityStyle) -> Entity { let mut entity = Entity::new(entity_type); entity.common.layer = layer.to_string(); if let Some(c) = style.color_24bit { @@ -74,7 +100,12 @@ impl DxfWriter { if let Some(lt) = &style.line_type { entity.common.line_type_name = lt.clone(); } - self.drawing.add_entity(entity); + entity + } + + fn add_entity(&mut self, entity_type: EntityType, layer: &str, style: &EntityStyle) { + self.drawing + .add_entity(Self::styled_entity(entity_type, layer, style)); } // ── Public primitive methods ─────────────────────────────────────── @@ -153,12 +184,14 @@ impl DxfWriter { self.polyline(&points, true, layer, style); } + #[allow(clippy::too_many_arguments)] pub fn text( &mut self, x: f64, y: f64, height: f64, content: &str, + rotation: f64, layer: &str, style: &EntityStyle, ) { @@ -166,6 +199,7 @@ impl DxfWriter { location: Point::new(x, y, 0.0), text_height: height, value: content.to_string(), + rotation, ..Default::default() }; self.add_entity(EntityType::Text(text), layer, style); @@ -179,7 +213,6 @@ impl DxfWriter { self.add_entity(EntityType::ModelPoint(pt), layer, style); } - #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)] pub fn dim_linear( &mut self, @@ -226,6 +259,7 @@ impl DxfWriter { (ay + by) / 2.0 + ny * text_height * 0.5, text_height, label, + 0.0, layer, &text_style, ); @@ -258,14 +292,21 @@ impl DxfWriter { } } - /// Generate hatch pattern lines within a rectangular boundary. + /// Generate hatch pattern lines within a boundary polygon. /// `boundary` is a list of (x,y) points forming a closed polygon. /// `angle` is in degrees, `spacing` is distance between lines. + /// + /// `scale` and `pattern` are the source `[[hatch]]` values, carried on each + /// generated line as XDATA (alongside the boundary and a group id) so the + /// importer can re-fuse the lines into one `[[hatch]]` on a DXF round-trip. + #[allow(clippy::too_many_arguments)] pub fn hatch( &mut self, boundary: &[(f64, f64)], angle: f64, spacing: f64, + scale: f64, + pattern: &str, layer: &str, style: &EntityStyle, ) { @@ -273,6 +314,24 @@ impl DxfWriter { return; } + // XDATA stamped on every pattern line of this hatch: group id, source + // angle/scale/pattern, then the boundary polygon vertices. All lines of + // one hatch share the same payload, so any of them can rebuild it. + self.hatch_group_seq += 1; + let mut items = vec![ + XDataItem::Long(self.hatch_group_seq), + XDataItem::Real(angle), + XDataItem::Real(scale), + XDataItem::Str(pattern.to_string()), + ]; + for &(x, y) in boundary { + items.push(XDataItem::WorldSpacePosition(Point::new(x, y, 0.0))); + } + let xdata = XData { + application_name: HATCH_XDATA_APP.to_string(), + items, + }; + // Compute bounding box let (min_x, max_x, min_y, max_y) = bounding_box(boundary); @@ -301,7 +360,11 @@ impl DxfWriter { // Clip line to boundary polygon if let Some((cx1, cy1, cx2, cy2)) = clip_line_to_polygon(x1, y1, x2, y2, boundary) { - self.line(cx1, cy1, cx2, cy2, layer, style); + let line = + dxf::entities::Line::new(Point::new(cx1, cy1, 0.0), Point::new(cx2, cy2, 0.0)); + let mut entity = Self::styled_entity(EntityType::Line(line), layer, style); + entity.common.x_data.push(xdata.clone()); + self.drawing.add_entity(entity); } } } @@ -418,7 +481,7 @@ mod tests { w.circle(5.0, 5.0, 2.0, "MUROS", &s); w.rect(1.0, 1.0, 3.0, 4.0, "MUROS", &s); - let path = PathBuf::from("/tmp/cadforge_test_basic.dxf"); + let path = PathBuf::from("/tmp/cadspec_test_basic.dxf"); w.save(&path).unwrap(); assert!(path.exists()); } @@ -434,7 +497,7 @@ mod tests { }; w.line(0.0, 0.0, 1.0, 1.0, "TEST", &style); - let path = PathBuf::from("/tmp/cadforge_test_styled.dxf"); + let path = PathBuf::from("/tmp/cadspec_test_styled.dxf"); w.save(&path).unwrap(); assert!(path.exists()); } diff --git a/src/fmt.rs b/src/fmt.rs index 8f5bb4d..9bac02c 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -53,7 +53,7 @@ pub fn format_project(project_dir: &Path, check_only: bool) -> Result<()> { } if check_only && changed > 0 { - anyhow::bail!("{changed} file(s) need formatting. Run `cadforge fmt` to fix."); + anyhow::bail!("{changed} file(s) need formatting. Run `cadspec fmt` to fix."); } if !check_only { println!("✓ {changed} file(s) formatted"); @@ -351,4 +351,23 @@ mod tests { let input = "invalid [[[ toml"; assert_eq!(format_source(input), input); } + + #[test] + fn preserves_text_font_rotation_bold_italic() { + let input = "[layer]\nname = \"x\"\n\n[[text]]\nid = \"tx-1\"\nposition = [1.0, 2.0]\ncontent = \"hi\"\nsize = 0.3\nfont = \"serif\"\nrotation = 30.0\nbold = true\nitalic = true\n"; + let once = format_source(input); + let twice = format_source(&once); + assert_eq!(once, twice, "fmt not idempotent for text styling fields"); + for needle in [ + "font = \"serif\"", + "rotation = 30.0", + "bold = true", + "italic = true", + ] { + assert!( + once.contains(needle), + "expected {needle:?} to survive fmt, got:\n{once}" + ); + } + } } diff --git a/src/gltf.rs b/src/gltf.rs new file mode 100644 index 0000000..7d365cf --- /dev/null +++ b/src/gltf.rs @@ -0,0 +1,224 @@ +//! Minimal glTF 2.0 exporter. +//! +//! Turns the scene's solid meshes (from [`crate::render3d::scene_meshes`]) into a +//! single self-contained `.gltf`: JSON with the binary buffer base64-embedded as +//! a data URI. Pure Rust, deterministic, no extra dependencies. +//! +//! cadspec is Z-up (height is +Z); glTF is Y-up and right-handed, so every +//! position and normal is remapped `(x, y, z) → (x, z, -y)` (a proper rotation). +//! Triangles are flat-shaded (a per-face normal on each of its three vertices) +//! and materials are double-sided, since boolean output can have mixed winding. + +use crate::mesh::Mesh; + +type V3 = [f64; 3]; + +fn sub(a: V3, b: V3) -> V3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn cross(a: V3, b: V3) -> V3 { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +fn norm(a: V3) -> V3 { + let l = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt(); + if l < 1e-12 { + [0.0, 0.0, 1.0] + } else { + [a[0] / l, a[1] / l, a[2] / l] + } +} +/// Z-up (cadspec) → Y-up (glTF). +fn to_gltf(p: V3) -> [f32; 3] { + [p[0] as f32, p[2] as f32, -p[1] as f32] +} + +/// Parse `#rgb` / `#rrggbb` into linear-ish `[r, g, b, 1]` floats (0..1). +fn color_rgba(hex: &str) -> [f32; 4] { + let h = hex.trim().trim_start_matches('#'); + let parse = |s: &str| u8::from_str_radix(s, 16).unwrap_or(160) as f32 / 255.0; + match h.len() { + 3 => { + let r = parse(&h[0..1].repeat(2)); + let g = parse(&h[1..2].repeat(2)); + let b = parse(&h[2..3].repeat(2)); + [r, g, b, 1.0] + } + 6 => [parse(&h[0..2]), parse(&h[2..4]), parse(&h[4..6]), 1.0], + _ => [0.63, 0.63, 0.63, 1.0], + } +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn base64(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32); + out.push(B64[((n >> 18) & 63) as usize] as char); + out.push(B64[((n >> 12) & 63) as usize] as char); + out.push(if chunk.len() > 1 { + B64[((n >> 6) & 63) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + B64[(n & 63) as usize] as char + } else { + '=' + }); + } + out +} + +/// Serialize the scene meshes to a self-contained glTF 2.0 document. +pub fn scene_to_gltf(meshes: &[(Mesh, String)]) -> String { + // Binary buffer: per mesh, all vertex positions then all normals (FLOAT VEC3). + let mut buf: Vec = Vec::new(); + // Per-mesh accessor metadata gathered while writing the buffer. + struct Acc { + pos_off: usize, + nrm_off: usize, + count: usize, + min: [f32; 3], + max: [f32; 3], + color: [f32; 4], + } + let mut accs: Vec = Vec::new(); + + for (mesh, color) in meshes { + if mesh.tris.is_empty() { + continue; + } + let pos_off = buf.len(); + let mut min = [f32::MAX; 3]; + let mut max = [f32::MIN; 3]; + // Positions. + for t in &mesh.tris { + for v in t { + let p = to_gltf(*v); + for k in 0..3 { + min[k] = min[k].min(p[k]); + max[k] = max[k].max(p[k]); + buf.extend_from_slice(&p[k].to_le_bytes()); + } + } + } + let nrm_off = buf.len(); + // Flat normals (same for the three vertices of a triangle). + for t in &mesh.tris { + let n = norm(cross(sub(t[1], t[0]), sub(t[2], t[0]))); + let ng = to_gltf(n); + for _ in 0..3 { + for c in ng { + buf.extend_from_slice(&c.to_le_bytes()); + } + } + } + accs.push(Acc { + pos_off, + nrm_off, + count: mesh.tris.len() * 3, + min, + max, + color: color_rgba(color), + }); + } + + // Assemble the JSON. + let mut buffer_views = String::new(); + let mut accessors = String::new(); + let mut materials = String::new(); + let mut gltf_meshes = String::new(); + let mut nodes = String::new(); + let mut node_idx = String::new(); + + for (i, a) in accs.iter().enumerate() { + let bv_pos = i * 2; + let bv_nrm = i * 2 + 1; + let acc_pos = i * 2; + let acc_nrm = i * 2 + 1; + let bytes = a.count * 12; + if i > 0 { + buffer_views.push(','); + accessors.push(','); + materials.push(','); + gltf_meshes.push(','); + nodes.push(','); + node_idx.push(','); + } + buffer_views.push_str(&format!( + r#"{{"buffer":0,"byteOffset":{},"byteLength":{}}},{{"buffer":0,"byteOffset":{},"byteLength":{}}}"#, + a.pos_off, bytes, a.nrm_off, bytes + )); + accessors.push_str(&format!( + r#"{{"bufferView":{bv_pos},"componentType":5126,"count":{c},"type":"VEC3","min":[{mn0},{mn1},{mn2}],"max":[{mx0},{mx1},{mx2}]}},{{"bufferView":{bv_nrm},"componentType":5126,"count":{c},"type":"VEC3"}}"#, + c = a.count, + mn0 = a.min[0], mn1 = a.min[1], mn2 = a.min[2], + mx0 = a.max[0], mx1 = a.max[1], mx2 = a.max[2], + )); + materials.push_str(&format!( + r#"{{"pbrMetallicRoughness":{{"baseColorFactor":[{r},{g},{b},{al}],"metallicFactor":0.0,"roughnessFactor":0.85}},"doubleSided":true}}"#, + r = a.color[0], g = a.color[1], b = a.color[2], al = a.color[3], + )); + gltf_meshes.push_str(&format!( + r#"{{"primitives":[{{"attributes":{{"POSITION":{acc_pos},"NORMAL":{acc_nrm}}},"material":{i}}}]}}"#, + )); + nodes.push_str(&format!(r#"{{"mesh":{i}}}"#)); + node_idx.push_str(&i.to_string()); + } + + let b64 = base64(&buf); + format!( + r#"{{"asset":{{"version":"2.0","generator":"cadspec"}},"scene":0,"scenes":[{{"nodes":[{node_idx}]}}],"nodes":[{nodes}],"meshes":[{gltf_meshes}],"materials":[{materials}],"accessors":[{accessors}],"bufferViews":[{buffer_views}],"buffers":[{{"byteLength":{len},"uri":"data:application/octet-stream;base64,{b64}"}}]}}"#, + len = buf.len(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_known_vectors() { + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Ma"), "TWE="); + assert_eq!(base64(b"M"), "TQ=="); + } + + #[test] + fn color_parses_hex() { + assert_eq!(color_rgba("#ff0000"), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(color_rgba("#000"), [0.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn single_triangle_exports_valid_gltf() { + let mesh = Mesh { + tris: vec![[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]], + }; + let doc = scene_to_gltf(&[(mesh, "#6ec6e6".to_string())]); + // One triangle = 3 vertices; positions + normals = 3 * 2 * 12 = 72 bytes. + assert!(doc.contains(r#""byteLength":72"#)); + assert!(doc.contains(r#""version":"2.0""#)); + assert!(doc.contains(r#""count":3"#)); + assert!(doc.contains(r#""POSITION""#) && doc.contains(r#""NORMAL""#)); + // Z-up→Y-up: cad y=1 becomes gltf z=-1, so the POSITION min z is -1. + assert!(doc.contains(r#""min":[0,0,-1]"#)); + } + + #[test] + fn empty_scene_is_still_valid_json() { + let doc = scene_to_gltf(&[]); + assert!(doc.contains(r#""byteLength":0"#)); + assert!(doc.contains(r#""meshes":[]"#)); + } +} diff --git a/src/importer.rs b/src/importer.rs index 306b384..e96ec3c 100644 --- a/src/importer.rs +++ b/src/importer.rs @@ -1,15 +1,16 @@ -//! DXF importer — converts DXF layers/entities into CADforge `.cf` + `project.toml`. +//! DXF importer — converts DXF layers/entities into CADspec `.cf` + `project.toml`. use crate::color::aci_to_hex; +use crate::dxf_writer::HATCH_XDATA_APP; use anyhow::{anyhow, Context, Result}; use dxf::entities::EntityType; -use dxf::Drawing; +use dxf::{Drawing, XData, XDataItem}; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; /// Per-entity style recovered from DXF (true color, lineweight, line type). -#[derive(Default)] +#[derive(Default, Clone)] struct StyleAttrs { color: Option, weight: Option, @@ -21,11 +22,7 @@ impl StyleAttrs { let color = if common.color_24_bit > 0 { Some(format!("#{:06X}", common.color_24_bit)) } else { - common - .color - .index() - .filter(|i| (1..=9).contains(i)) - .map(|i| aci_to_hex(i).to_string()) + common.color.index().filter(|i| *i > 0).map(aci_to_hex) }; let weight = (common.lineweight_enum_value > 0) .then(|| f64::from(common.lineweight_enum_value) / 100.0); @@ -78,6 +75,7 @@ enum Shape { position: [f64; 2], content: String, size: f64, + rotation: f64, }, Point { position: [f64; 2], @@ -87,6 +85,64 @@ enum Shape { to: [f64; 2], offset: f64, }, + /// A filled region. Imported from one or more SOLID entities; adjacent + /// SOLID triangles/quads on the same layer are fused into a single fill. + Fill { + points: Vec<[f64; 2]>, + }, + /// A hatched region, recovered from the pattern lines it expanded into by + /// reading the `CADSPEC_HATCH` XDATA those lines carry. + Hatch { + points: Vec<[f64; 2]>, + angle: f64, + scale: f64, + pattern: String, + }, +} + +/// Hatch parameters recovered from one pattern line's `CADSPEC_HATCH` XDATA. +struct HatchMeta { + group: i32, + angle: f64, + scale: f64, + pattern: String, + points: Vec<[f64; 2]>, +} + +/// Read the `CADSPEC_HATCH` XDATA payload (group id, angle, scale, pattern, then +/// the boundary polygon vertices) off a line entity. Returns `None` for any line +/// that this tool did not emit as part of a hatch, so foreign DXF lines are never +/// mistaken for hatch pattern. +fn parse_hatch_xdata(xdata: &[XData]) -> Option { + let x = xdata + .iter() + .find(|x| x.application_name == HATCH_XDATA_APP)?; + let mut group = None; + let mut angle = None; + let mut scale = None; + let mut pattern = None; + let mut points = Vec::new(); + for item in &x.items { + match item { + XDataItem::Long(v) if group.is_none() => group = Some(*v), + XDataItem::Real(v) if angle.is_none() => angle = Some(*v), + XDataItem::Real(v) if scale.is_none() => scale = Some(*v), + XDataItem::Str(s) if pattern.is_none() => pattern = Some(s.clone()), + XDataItem::WorldSpacePosition(p) => points.push([p.x, p.y]), + _ => {} + } + } + // A hatch needs at least a triangle of boundary to be reconstructable. + if points.len() < 3 { + return None; + } + Some(HatchMeta { + group: group?, + angle: angle?, + scale: scale?, + pattern: pattern.unwrap_or_else(|| "ansi31".to_string()), + points, + }) } struct Imported { @@ -114,15 +170,15 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - let mut layers: BTreeMap = BTreeMap::new(); let mut layer_colors: BTreeMap = BTreeMap::new(); let mut unsupported = 0usize; + // Pattern lines of the same hatch (same XDATA group id) are collected here + // and re-fused into one `[[hatch]]` after the entity pass, keyed by group id. + let mut hatch_groups: BTreeMap = BTreeMap::new(); match Drawing::load_file(input) { Ok(drawing) => { for layer in drawing.layers() { if let Some(index) = layer.color.index() { - layer_colors.insert( - normalize_layer_name(&layer.name), - aci_to_hex(index).to_string(), - ); + layer_colors.insert(normalize_layer_name(&layer.name), aci_to_hex(index)); } } @@ -135,6 +191,30 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - } let style = StyleAttrs::from_common(&entity.common); + + // A line stamped with CADSPEC_HATCH XDATA is one of a hatch's + // pattern lines: register its group (once) and drop the line, so + // the round-trip yields one `[[hatch]]` instead of N `[[line]]`. + if matches!(&entity.specific, EntityType::Line(_)) { + if let Some(meta) = parse_hatch_xdata(&entity.common.x_data) { + hatch_groups.entry(meta.group).or_insert_with(|| { + ( + layer_name.clone(), + Imported { + shape: Shape::Hatch { + points: meta.points, + angle: meta.angle, + scale: meta.scale, + pattern: meta.pattern, + }, + style: style.clone(), + }, + ) + }); + continue; + } + } + let shape = match &entity.specific { EntityType::Line(e) => Some(Shape::Line { from: [e.p1.x, e.p1.y], @@ -158,6 +238,7 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - position: [e.location.x, e.location.y], content: e.value.clone(), size: e.text_height.max(0.1), + rotation: e.rotation, }), EntityType::ModelPoint(e) => Some(Shape::Point { position: [e.location.x, e.location.y], @@ -168,6 +249,10 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - dim_offset(from, to, [e.insertion_point.x, e.insertion_point.y]) .map(|offset| Shape::Dim { from, to, offset }) } + EntityType::Solid(e) => { + let ring = solid_ring(e); + (ring.len() >= 3).then_some(Shape::Fill { points: ring }) + } _ => { unsupported += 1; None @@ -182,8 +267,19 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - } } + // Emit one hatch per collected group into its layer (ordered by + // group id for deterministic output). + for (_group, (layer_name, imported)) in hatch_groups { + layers + .entry(layer_name) + .or_default() + .entities + .push(imported); + } + for layer in layers.values_mut() { remove_dim_companions(&mut layer.entities); + fuse_solids(&mut layer.entities); } } Err(_) => { @@ -310,7 +406,7 @@ fn dim_offset(from: [f64; 2], to: [f64; 2], insertion: [f64; 2]) -> Option Some((insertion[0] - mid[0]) * nx + (insertion[1] - mid[1]) * ny) } -/// Drop the extension/dimension lines and label text that `cadforge build` +/// Drop the extension/dimension lines and label text that `cadspec build` /// emits alongside each DIMENSION entity for viewer compatibility; the /// re-created `[[dim]]` regenerates all of them. Foreign DXFs are unaffected /// (their dimension graphics live in blocks, not loose entities). @@ -340,27 +436,263 @@ fn remove_dim_companions(entities: &mut Vec) { let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; for (cf, ct) in [(from, a), (to, b), (a, b)] { - if let Some(i) = (0..entities.len()).find(|&i| { - keep[i] - && matches!(entities[i].shape, Shape::Line { from: lf, to: lt } - if (close(lf, cf) && close(lt, ct)) || (close(lf, ct) && close(lt, cf))) - }) { - keep[i] = false; - } - } - if let Some(i) = (0..entities.len()).find(|&i| { - keep[i] - && matches!(entities[i].shape, Shape::Text { position, size, .. } - if close(position, [mid[0] + nx * size * 0.5, mid[1] + ny * size * 0.5])) - }) { - keep[i] = false; + mark_line_companion(entities, &mut keep, cf, ct, close); } + mark_text_companion(entities, &mut keep, mid, nx, ny, close); } let mut it = keep.into_iter(); entities.retain(|_| it.next().unwrap_or(true)); } +fn mark_line_companion( + entities: &[Imported], + keep: &mut [bool], + from: [f64; 2], + to: [f64; 2], + close: impl Fn([f64; 2], [f64; 2]) -> bool, +) { + if let Some(i) = (0..entities.len()).find(|&i| { + keep[i] + && matches!(entities[i].shape, Shape::Line { from: lf, to: lt } + if (close(lf, from) && close(lt, to)) || (close(lf, to) && close(lt, from))) + }) { + keep[i] = false; + } +} + +fn mark_text_companion( + entities: &[Imported], + keep: &mut [bool], + mid: [f64; 2], + nx: f64, + ny: f64, + close: impl Fn([f64; 2], [f64; 2]) -> bool, +) { + if let Some(i) = (0..entities.len()).find(|&i| { + keep[i] + && matches!(entities[i].shape, Shape::Text { position, size, .. } + if close(position, [mid[0] + nx * size * 0.5, mid[1] + ny * size * 0.5])) + }) { + keep[i] = false; + } +} + +/// Recover the polygon ring of a DXF SOLID entity. SOLID vertices render in +/// the order first → second → fourth → third; adjacent duplicates (the writer +/// sets the fourth corner equal to the third for triangles) are collapsed. +fn solid_ring(e: &dxf::entities::Solid) -> Vec<[f64; 2]> { + let raw = [ + [e.first_corner.x, e.first_corner.y], + [e.second_corner.x, e.second_corner.y], + [e.fourth_corner.x, e.fourth_corner.y], + [e.third_corner.x, e.third_corner.y], + ]; + let mut ring: Vec<[f64; 2]> = Vec::with_capacity(4); + for p in raw { + if ring.last().is_none_or(|q| !pts_eq(*q, p)) { + ring.push(p); + } + } + // Drop a closing duplicate if the last equals the first. + if ring.len() > 1 && pts_eq(ring[0], ring[ring.len() - 1]) { + ring.pop(); + } + ring +} + +/// Quantized key for a point so that geometrically equal vertices from +/// different SOLID entities hash together despite float round-trip noise. +type PtKey = (i64, i64); + +fn pt_key(p: [f64; 2]) -> PtKey { + ((p[0] * 1e6).round() as i64, (p[1] * 1e6).round() as i64) +} + +fn pts_eq(a: [f64; 2], b: [f64; 2]) -> bool { + (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6 +} + +/// Undirected edge key with a canonical (min, max) endpoint order. +fn edge_key(a: PtKey, b: PtKey) -> (PtKey, PtKey) { + if a <= b { + (a, b) + } else { + (b, a) + } +} + +/// Fuse adjacent SOLID-derived fills into single `[[fill]]` regions. +/// +/// `cadspec build` fan-triangulates a solid fill into several SOLID entities; +/// importing each one separately would inflate the entity count and lose the +/// `[[fill]]` semantics. Fills that share an edge belong to the same region: +/// group them, drop the shared (internal) edges, and stitch the remaining +/// boundary edges back into one polygon. Foreign SOLIDs that don't tile a +/// region are left as individual fills. +fn fuse_solids(entities: &mut Vec) { + let fill_positions: Vec = entities + .iter() + .enumerate() + .filter(|(_, e)| matches!(e.shape, Shape::Fill { .. })) + .map(|(i, _)| i) + .collect(); + if fill_positions.len() < 2 { + return; // nothing to merge + } + + let ring_of = |pos: usize| -> &[[f64; 2]] { + match &entities[pos].shape { + Shape::Fill { points } => points, + _ => unreachable!("filtered to fills"), + } + }; + + // Union-find over fills, joined when they share an edge. + let mut parent: Vec = (0..fill_positions.len()).collect(); + fn find(parent: &mut [usize], mut x: usize) -> usize { + while parent[x] != x { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + x + } + + let mut edge_owner: std::collections::HashMap<(PtKey, PtKey), usize> = + std::collections::HashMap::new(); + for (idx, &pos) in fill_positions.iter().enumerate() { + for (a, b) in ring_edges(ring_of(pos)) { + let key = edge_key(pt_key(a), pt_key(b)); + if let Some(&other) = edge_owner.get(&key) { + let (ra, rb) = (find(&mut parent, idx), find(&mut parent, other)); + parent[ra] = rb; + } else { + edge_owner.insert(key, idx); + } + } + } + + // Group fill indices by component root. + let mut components: BTreeMap> = BTreeMap::new(); + for idx in 0..fill_positions.len() { + let root = find(&mut parent, idx); + components.entry(root).or_default().push(idx); + } + + // Build replacement fills and track which original positions to drop. + let mut fused: Vec = Vec::new(); + let mut drop: Vec = vec![false; entities.len()]; + for members in components.values() { + for &idx in members { + drop[fill_positions[idx]] = true; + } + let style_pos = fill_positions[members[0]]; + let style = entities[style_pos].style.clone(); + + if members.len() == 1 { + // A lone fill: keep its own ring. + let points = ring_of(fill_positions[members[0]]).to_vec(); + fused.push(Imported { + shape: Shape::Fill { points }, + style, + }); + continue; + } + + let rings: Vec<&[[f64; 2]]> = members + .iter() + .map(|&idx| ring_of(fill_positions[idx])) + .collect(); + match merge_rings(&rings) { + Some(points) => fused.push(Imported { + shape: Shape::Fill { points }, + style, + }), + None => { + // Couldn't stitch a clean boundary: keep each fill as-is. + for &idx in members { + fused.push(Imported { + shape: Shape::Fill { + points: ring_of(fill_positions[idx]).to_vec(), + }, + style: entities[fill_positions[idx]].style.clone(), + }); + } + } + } + } + + let mut it = drop.into_iter(); + entities.retain(|_| !it.next().unwrap_or(false)); + entities.extend(fused); +} + +/// Iterate the closed ring's undirected edges as consecutive point pairs. +fn ring_edges(ring: &[[f64; 2]]) -> Vec<([f64; 2], [f64; 2])> { + let n = ring.len(); + (0..n).map(|i| (ring[i], ring[(i + 1) % n])).collect() +} + +/// Merge a set of triangle/quad rings that tile one region into a single +/// boundary polygon: edges shared by two rings are interior and dropped; the +/// remaining boundary edges are chained into one ring. Returns `None` if the +/// boundary is not a single simple loop. +fn merge_rings(rings: &[&[[f64; 2]]]) -> Option> { + let mut edge_count: std::collections::HashMap<(PtKey, PtKey), usize> = + std::collections::HashMap::new(); + let mut coord: std::collections::HashMap = std::collections::HashMap::new(); + for ring in rings { + for (a, b) in ring_edges(ring) { + coord.entry(pt_key(a)).or_insert(a); + coord.entry(pt_key(b)).or_insert(b); + *edge_count + .entry(edge_key(pt_key(a), pt_key(b))) + .or_insert(0) += 1; + } + } + + // Boundary edges appear exactly once. + let boundary: Vec<(PtKey, PtKey)> = edge_count + .iter() + .filter(|(_, &c)| c == 1) + .map(|(&e, _)| e) + .collect(); + if boundary.len() < 3 { + return None; + } + + // Adjacency; a clean loop has every vertex at degree 2. + let mut adj: std::collections::HashMap> = std::collections::HashMap::new(); + for (a, b) in &boundary { + adj.entry(*a).or_default().push(*b); + adj.entry(*b).or_default().push(*a); + } + if adj.values().any(|v| v.len() != 2) { + return None; + } + + // Walk the loop from a deterministic start. + let start = *adj.keys().min()?; + let mut ring_keys = vec![start]; + let mut prev = start; + let mut cur = adj[&start][0]; + while cur != start { + ring_keys.push(cur); + let nbrs = &adj[&cur]; + let next = if nbrs[0] == prev { nbrs[1] } else { nbrs[0] }; + prev = cur; + cur = next; + if ring_keys.len() > boundary.len() { + return None; // did not close cleanly + } + } + if ring_keys.len() != boundary.len() { + return None; // disconnected boundary (e.g. a hole) + } + + Some(ring_keys.iter().map(|k| coord[k]).collect()) +} + /// Render a shape body (without `[[header]]`/`id`); returns (id prefix, body). fn emit_shape(shape: &Shape) -> (&'static str, String) { match shape { @@ -411,16 +743,20 @@ fn emit_shape(shape: &Shape) -> (&'static str, String) { position, content, size, - } => ( - "tx", - format!( + rotation, + } => { + let mut body = format!( "position = [{}, {}]\ncontent = \"{}\"\nsize = {}\n", n(position[0]), n(position[1]), escape_string(content), n(*size) - ), - ), + ); + if *rotation != 0.0 { + body.push_str(&format!("rotation = {}\n", n(*rotation))); + } + ("tx", body) + } Shape::Point { position } => ( "pt", format!("position = [{}, {}]\n", n(position[0]), n(position[1])), @@ -436,6 +772,36 @@ fn emit_shape(shape: &Shape) -> (&'static str, String) { n(*offset) ), ), + Shape::Fill { points } => { + let pts = points + .iter() + .map(|p| format!("[{}, {}]", n(p[0]), n(p[1]))) + .collect::>() + .join(", "); + ("fl", format!("points = [{}]\n", pts)) + } + Shape::Hatch { + points, + angle, + scale, + pattern, + } => { + let pts = points + .iter() + .map(|p| format!("[{}, {}]", n(p[0]), n(p[1]))) + .collect::>() + .join(", "); + ( + "ht", + format!( + "points = [{}]\npattern = \"{}\"\nangle = {}\nscale = {}\n", + pts, + escape_string(pattern), + n(*angle), + n(*scale) + ), + ) + } } } @@ -448,6 +814,8 @@ fn header_for(prefix: &str) -> &'static str { "tx" => "text", "pt" => "point", "dm" => "dim", + "fl" => "fill", + "ht" => "hatch", _ => "line", } } @@ -526,3 +894,66 @@ fn collect_layer_names_from_layer_table(content: &str) -> Vec { } names } + +#[cfg(test)] +mod tests { + use super::*; + + fn fill(points: Vec<[f64; 2]>) -> Imported { + Imported { + shape: Shape::Fill { points }, + style: StyleAttrs::default(), + } + } + + fn ring_of(e: &Imported) -> &[[f64; 2]] { + match &e.shape { + Shape::Fill { points } => points, + _ => panic!("expected fill"), + } + } + + #[test] + fn merge_rings_stitches_fan_triangles_into_rectangle() { + // Fan triangulation of rect [0,0]-[2,2] from vertex [0,0]. + let t1: &[[f64; 2]] = &[[0.0, 0.0], [2.0, 0.0], [2.0, 2.0]]; + let t2: &[[f64; 2]] = &[[0.0, 0.0], [2.0, 2.0], [0.0, 2.0]]; + let ring = merge_rings(&[t1, t2]).expect("should stitch a clean loop"); + assert_eq!(ring.len(), 4, "shared diagonal edge is dropped"); + // All four rectangle corners present regardless of winding/start. + for corner in [[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]] { + assert!( + ring.iter().any(|p| pts_eq(*p, corner)), + "missing corner {corner:?}" + ); + } + } + + #[test] + fn fuse_solids_merges_adjacent_and_keeps_disjoint_separate() { + let mut entities = vec![ + // Region A: two triangles tiling rect [0,0]-[2,2]. + fill(vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0]]), + fill(vec![[0.0, 0.0], [2.0, 2.0], [0.0, 2.0]]), + // Region B: a lone triangle far away. + fill(vec![[10.0, 10.0], [11.0, 10.0], [10.5, 11.0]]), + ]; + fuse_solids(&mut entities); + let fills: Vec<&Imported> = entities + .iter() + .filter(|e| matches!(e.shape, Shape::Fill { .. })) + .collect(); + assert_eq!(fills.len(), 2, "adjacent pair fuses, disjoint stays"); + let mut lens: Vec = fills.iter().map(|e| ring_of(e).len()).collect(); + lens.sort_unstable(); + assert_eq!(lens, vec![3, 4], "one rectangle (4) and one triangle (3)"); + } + + #[test] + fn fuse_solids_leaves_single_fill_untouched() { + let mut entities = vec![fill(vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]])]; + fuse_solids(&mut entities); + assert_eq!(entities.len(), 1); + assert_eq!(ring_of(&entities[0]).len(), 3); + } +} diff --git a/src/lib.rs b/src/lib.rs index 022de0a..348194f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! cadforge — deterministic geometry engine for reproducible architectural design. +//! cadspec — deterministic geometry engine for reproducible architectural design. //! //! Pipeline: `.cf` (TOML) → intermediate model → DXF output. @@ -7,10 +7,14 @@ pub mod compiler; pub mod config; pub mod dxf_writer; pub mod fmt; +pub mod gltf; pub mod importer; +pub mod mesh; pub mod model; pub mod parser; +pub mod planos; pub mod preview; +pub mod render3d; pub mod scaffold; pub mod schema; pub mod serve; diff --git a/src/main.rs b/src/main.rs index bd4e412..438302b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,24 @@ use anyhow::{bail, Result}; -use cadforge::compiler::{check_project, compile_project, list_layers, project_report}; -use cadforge::config::{config_set, config_show}; -use cadforge::fmt::format_project; -use cadforge::importer::import_dxf; -use cadforge::preview::{generate_preview, PreviewOutputs}; -use cadforge::scaffold::{create_project, init_project}; -use cadforge::schema::print_schema; -use cadforge::serve::serve_project; -use cadforge::viewer::view_project; -use cadforge::watch::watch_project; +use cadspec::compiler::{check_project, compile_project, list_layers, project_report}; +use cadspec::config::{config_set, config_show}; +use cadspec::fmt::format_project; +use cadspec::importer::import_dxf; +use cadspec::preview::{ + generate_gltf, generate_plano, generate_preview, PreviewOutputs, PreviewView, +}; +use cadspec::scaffold::{create_project, init_project}; +use cadspec::schema::print_schema; +use cadspec::serve::{serve_daemon, serve_project, serve_stop}; +use cadspec::viewer::view_project; +use cadspec::watch::watch_project; use clap::{Parser, Subcommand, ValueEnum}; use std::path::PathBuf; #[derive(Parser)] #[command( - name = "cadforge", + name = "cadspec", version, - about = "Architecture as Code — declarative geometry → DXF" + about = "CAD as code — declarative geometry → DXF" )] struct Cli { #[command(subcommand)] @@ -25,12 +27,12 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Create a new CADforge project + /// Create a new CADspec project New { /// Project name (creates a directory with this name) name: String, }, - /// Initialize CADforge in the current directory + /// Initialize CADspec in the current directory Init, /// Compile project (.cf files) → DXF output Build { @@ -85,8 +87,15 @@ enum Commands { /// Highlight entities by id (comma-separated) with labeled markers #[arg(long, value_delimiter = ',')] highlight: Vec, + /// Render the extruded 3D view instead of the flat plan + #[arg(long = "3d")] + three_d: bool, + /// Render a named plano (drawing sheet) defined in project.toml + #[arg(long)] + plano: Option, }, - /// Live preview server — browser auto-reloads when .cf files change + /// Live preview server — browser auto-reloads when .cf files change. + /// Runs detached in the background by default; use --foreground to stay attached. Serve { /// Project directory (defaults to current dir) #[arg(short, long)] @@ -97,6 +106,12 @@ enum Commands { /// Open the browser automatically #[arg(long)] open: bool, + /// Stay in the foreground (stream logs, stop with Ctrl+C) instead of daemonizing + #[arg(short = 'f', long)] + foreground: bool, + /// Stop the background server running for this project + #[arg(long)] + stop: bool, }, /// Print the .cf language reference (markdown, for humans and AI agents) Schema, @@ -115,7 +130,7 @@ enum Commands { #[arg(short, long)] path: Option, }, - /// Import a DXF file into CADforge project files + /// Import a DXF file into CADspec project files Import { /// Input DXF file input: PathBuf, @@ -135,7 +150,7 @@ enum Commands { #[arg(short, long)] layer: Option, }, - /// Global cadforge configuration + /// Global cadspec configuration Config { #[command(subcommand)] command: ConfigCommands, @@ -150,6 +165,8 @@ enum PreviewFormat { Svg, /// Both PNG and SVG All, + /// Self-contained glTF of the 3D solids (`scene.gltf`) + Gltf, } #[derive(Subcommand)] @@ -219,17 +236,52 @@ fn main() -> Result<()> { layer, format, highlight, + three_d, + plano, } => { let dir = resolve_project_dir(path)?; - let outputs = PreviewOutputs { - png: matches!(format, PreviewFormat::Png | PreviewFormat::All), - svg: matches!(format, PreviewFormat::Svg | PreviewFormat::All), - }; - generate_preview(&dir, width, height, layer.as_deref(), &highlight, outputs) + if matches!(format, PreviewFormat::Gltf) { + generate_gltf(&dir, layer.as_deref()) + } else { + let outputs = PreviewOutputs { + png: matches!(format, PreviewFormat::Png | PreviewFormat::All), + svg: matches!(format, PreviewFormat::Svg | PreviewFormat::All), + }; + if let Some(name) = plano { + generate_plano(&dir, &name, width, height, outputs) + } else { + let view = if three_d { + PreviewView::ThreeD + } else { + PreviewView::Plan + }; + generate_preview( + &dir, + width, + height, + layer.as_deref(), + &highlight, + outputs, + view, + ) + } + } } - Commands::Serve { path, port, open } => { + Commands::Serve { + path, + port, + open, + foreground, + stop, + } => { let dir = resolve_project_dir(path)?; - serve_project(&dir, port, open) + if stop { + serve_stop(&dir, port) + } else if foreground { + serve_project(&dir, port, open) + } else { + serve_daemon(&dir, port, open) + } } Commands::Schema => { print_schema(); @@ -266,7 +318,7 @@ fn resolve_project_dir(path: Option) -> Result { let dir = path.unwrap_or_else(|| PathBuf::from(".")); if !dir.join("project.toml").exists() { bail!( - "No project.toml found in '{}'. Run `cadforge new` to create a project.", + "No project.toml found in '{}'. Run `cadspec new` to create a project.", dir.display() ); } diff --git a/src/mesh.rs b/src/mesh.rs new file mode 100644 index 0000000..b13c6e3 --- /dev/null +++ b/src/mesh.rs @@ -0,0 +1,484 @@ +//! Triangle meshes + CSG booleans for the solid 3D view. +//! +//! Solids are triangle meshes. Booleans (`union`/`difference`/`intersection`) +//! use a BSP-tree algorithm (the classic csg.js approach) — no dependencies, +//! deterministic. Primitives (`box`, `cylinder`, extruded `prism`) tessellate +//! to meshes; [`render3d`](crate::render3d) projects and shades the result. + +/// A 3D point / vector. +pub type V3 = [f64; 3]; + +fn add(a: V3, b: V3) -> V3 { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn sub(a: V3, b: V3) -> V3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn scale(a: V3, s: f64) -> V3 { + [a[0] * s, a[1] * s, a[2] * s] +} +fn dot(a: V3, b: V3) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn cross(a: V3, b: V3) -> V3 { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +fn normalize(a: V3) -> V3 { + let l = dot(a, a).sqrt(); + if l < 1e-12 { + a + } else { + scale(a, 1.0 / l) + } +} +fn lerp(a: V3, b: V3, t: f64) -> V3 { + add(a, scale(sub(b, a), t)) +} + +/// A triangle mesh (the only solid representation we keep). +#[derive(Clone, Default)] +pub struct Mesh { + pub tris: Vec<[V3; 3]>, +} + +impl Mesh { + fn from_polys(polys: &[Poly]) -> Mesh { + let mut tris = Vec::new(); + for p in polys { + // Fan-triangulate (BSP output polygons are convex). + for i in 1..p.verts.len().saturating_sub(1) { + tris.push([p.verts[0], p.verts[i], p.verts[i + 1]]); + } + } + Mesh { tris } + } + + fn to_polys(&self) -> Vec { + self.tris.iter().map(|t| Poly::new(t.to_vec())).collect() + } + + /// Boolean union (`self` ∪ `other`). + pub fn union(&self, other: &Mesh) -> Mesh { + Mesh::from_polys(&csg_union(self.to_polys(), other.to_polys())) + } + + /// Boolean difference (`self` − `other`). + pub fn difference(&self, other: &Mesh) -> Mesh { + Mesh::from_polys(&csg_difference(self.to_polys(), other.to_polys())) + } + + /// Boolean intersection (`self` ∩ `other`). + pub fn intersection(&self, other: &Mesh) -> Mesh { + Mesh::from_polys(&csg_intersection(self.to_polys(), other.to_polys())) + } +} + +// ── Primitive builders ────────────────────────────────────────────────── + +/// Axis-aligned box with its minimum corner at `at` and the given `size`. +pub fn box_solid(at: V3, size: V3) -> Mesh { + let [x, y, z] = at; + let [sx, sy, sz] = size; + let c = [ + [x, y, z], // 0 + [x + sx, y, z], // 1 + [x + sx, y + sy, z], // 2 + [x, y + sy, z], // 3 + [x, y, z + sz], // 4 + [x + sx, y, z + sz], // 5 + [x + sx, y + sy, z + sz], // 6 + [x, y + sy, z + sz], // 7 + ]; + // Each face as two triangles, wound CCW as seen from outside (consistent + // outward normals — the CSG BSP relies on it). + let faces = [ + ([0, 3, 2], [0, 2, 1]), // bottom -Z + ([4, 5, 6], [4, 6, 7]), // top +Z + ([0, 1, 5], [0, 5, 4]), // -Y + ([1, 2, 6], [1, 6, 5]), // +X + ([2, 3, 7], [2, 7, 6]), // +Y + ([3, 0, 4], [3, 4, 7]), // -X + ]; + let mut tris = Vec::with_capacity(12); + for (t1, t2) in faces { + tris.push([c[t1[0]], c[t1[1]], c[t1[2]]]); + tris.push([c[t2[0]], c[t2[1]], c[t2[2]]]); + } + Mesh { tris } +} + +/// Vertical cylinder: base circle centered at `at`, radius `r`, height `h`. +pub fn cylinder_solid(at: V3, r: f64, h: f64, segments: usize) -> Mesh { + let segs = segments.max(3); + let [cx, cy, cz] = at; + let top_c = [cx, cy, cz + h]; + let bot_c = [cx, cy, cz]; + let ring = |z: f64| -> Vec { + (0..segs) + .map(|i| { + let a = (i as f64 / segs as f64) * std::f64::consts::TAU; + [cx + r * a.cos(), cy + r * a.sin(), z] + }) + .collect() + }; + let bot = ring(cz); + let top = ring(cz + h); + let mut tris = Vec::with_capacity(segs * 4); + for i in 0..segs { + let j = (i + 1) % segs; + // Side (two triangles), outward. + tris.push([bot[i], bot[j], top[j]]); + tris.push([bot[i], top[j], top[i]]); + // Bottom fan (facing -Z) and top fan (facing +Z). + tris.push([bot_c, bot[j], bot[i]]); + tris.push([top_c, top[i], top[j]]); + } + Mesh { tris } +} + +/// Extrude a 2D footprint from `base` to `base + height`. `closed` adds caps +/// (a solid prism); otherwise only side walls are produced (an open wall). +pub fn prism(footprint: &[[f64; 2]], base: f64, height: f64, closed: bool) -> Mesh { + let n = footprint.len(); + if n < 2 { + return Mesh::default(); + } + let top = base + height; + let mut tris = Vec::new(); + let edges = if closed { n } else { n - 1 }; + for i in 0..edges { + let a = footprint[i]; + let b = footprint[(i + 1) % n]; + let (a0, a1) = ([a[0], a[1], base], [a[0], a[1], top]); + let (b0, b1) = ([b[0], b[1], base], [b[0], b[1], top]); + tris.push([a0, b0, b1]); + tris.push([a0, b1, a1]); + } + if closed && n >= 3 { + // Caps (fan). Bottom faces -Z, top faces +Z. + for i in 1..n - 1 { + let t0 = [footprint[0][0], footprint[0][1], top]; + let ti = [footprint[i][0], footprint[i][1], top]; + let tj = [footprint[i + 1][0], footprint[i + 1][1], top]; + tris.push([t0, ti, tj]); + let b0 = [footprint[0][0], footprint[0][1], base]; + let bi = [footprint[i][0], footprint[i][1], base]; + let bj = [footprint[i + 1][0], footprint[i + 1][1], base]; + tris.push([b0, bj, bi]); + } + } + Mesh { tris } +} + +// ── BSP CSG (port of csg.js) ────────────────────────────────────────────── + +const EPS: f64 = 1e-5; + +#[derive(Clone)] +struct Plane { + normal: V3, + w: f64, +} + +impl Plane { + fn from_points(a: V3, b: V3, c: V3) -> Plane { + let normal = normalize(cross(sub(b, a), sub(c, a))); + Plane { + w: dot(normal, a), + normal, + } + } + fn flip(&mut self) { + self.normal = scale(self.normal, -1.0); + self.w = -self.w; + } +} + +#[derive(Clone)] +struct Poly { + verts: Vec, + plane: Plane, +} + +impl Poly { + fn new(verts: Vec) -> Poly { + let plane = Plane::from_points(verts[0], verts[1], verts[2]); + Poly { verts, plane } + } + fn flip(&mut self) { + self.verts.reverse(); + self.plane.flip(); + } +} + +const COPLANAR: u8 = 0; +const FRONT: u8 = 1; +const BACK: u8 = 2; +const SPANNING: u8 = 3; + +/// Split `poly` by `plane`, appending the pieces to the appropriate buckets. +fn split_polygon( + plane: &Plane, + poly: &Poly, + coplanar_front: &mut Vec, + coplanar_back: &mut Vec, + front: &mut Vec, + back: &mut Vec, +) { + let mut poly_type = 0u8; + let mut types = Vec::with_capacity(poly.verts.len()); + for v in &poly.verts { + let t = dot(plane.normal, *v) - plane.w; + let ty = if t < -EPS { + BACK + } else if t > EPS { + FRONT + } else { + COPLANAR + }; + poly_type |= ty; + types.push(ty); + } + match poly_type { + COPLANAR => { + if dot(plane.normal, poly.plane.normal) > 0.0 { + coplanar_front.push(poly.clone()); + } else { + coplanar_back.push(poly.clone()); + } + } + FRONT => front.push(poly.clone()), + BACK => back.push(poly.clone()), + _ => { + let mut fv = Vec::new(); + let mut bv = Vec::new(); + let n = poly.verts.len(); + for i in 0..n { + let j = (i + 1) % n; + let (ti, tj) = (types[i], types[j]); + let (vi, vj) = (poly.verts[i], poly.verts[j]); + if ti != BACK { + fv.push(vi); + } + if ti != FRONT { + bv.push(vi); + } + if (ti | tj) == SPANNING { + let denom = dot(plane.normal, sub(vj, vi)); + let t = if denom.abs() < 1e-12 { + 0.0 + } else { + (plane.w - dot(plane.normal, vi)) / denom + }; + let v = lerp(vi, vj, t); + fv.push(v); + bv.push(v); + } + } + if fv.len() >= 3 { + front.push(Poly::new(fv)); + } + if bv.len() >= 3 { + back.push(Poly::new(bv)); + } + } + } +} + +#[derive(Default)] +struct Node { + plane: Option, + front: Option>, + back: Option>, + polygons: Vec, +} + +impl Node { + fn build(&mut self, polygons: Vec) { + if polygons.is_empty() { + return; + } + if self.plane.is_none() { + self.plane = Some(polygons[0].plane.clone()); + } + let plane = self.plane.clone().unwrap(); + let mut front = Vec::new(); + let mut back = Vec::new(); + let mut cf = Vec::new(); + let mut cb = Vec::new(); + for p in &polygons { + split_polygon(&plane, p, &mut cf, &mut cb, &mut front, &mut back); + } + // Both coplanar buckets stay on this node (csg.js Node.build). + self.polygons.extend(cf); + self.polygons.extend(cb); + if !front.is_empty() { + self.front.get_or_insert_with(Default::default).build(front); + } + if !back.is_empty() { + self.back.get_or_insert_with(Default::default).build(back); + } + } + + fn invert(&mut self) { + for p in &mut self.polygons { + p.flip(); + } + if let Some(pl) = &mut self.plane { + pl.flip(); + } + if let Some(f) = &mut self.front { + f.invert(); + } + if let Some(b) = &mut self.back { + b.invert(); + } + std::mem::swap(&mut self.front, &mut self.back); + } + + fn clip_polygons(&self, polygons: Vec) -> Vec { + let Some(plane) = &self.plane else { + return polygons; + }; + let mut front = Vec::new(); + let mut back = Vec::new(); + let mut cf = Vec::new(); + let mut cb = Vec::new(); + for p in &polygons { + split_polygon(plane, p, &mut cf, &mut cb, &mut front, &mut back); + } + // Coplanar pieces follow csg.js: front-facing with front, back with back. + front.extend(cf); + back.extend(cb); + let mut front = match &self.front { + Some(n) => n.clip_polygons(front), + None => front, + }; + if let Some(n) = &self.back { + front.extend(n.clip_polygons(back)); + } + front + } + + fn clip_to(&mut self, other: &Node) { + self.polygons = other.clip_polygons(std::mem::take(&mut self.polygons)); + if let Some(f) = &mut self.front { + f.clip_to(other); + } + if let Some(b) = &mut self.back { + b.clip_to(other); + } + } + + fn all_polygons(&self) -> Vec { + let mut out = self.polygons.clone(); + if let Some(f) = &self.front { + out.extend(f.all_polygons()); + } + if let Some(b) = &self.back { + out.extend(b.all_polygons()); + } + out + } +} + +fn node_of(polys: Vec) -> Node { + let mut n = Node::default(); + n.build(polys); + n +} + +fn csg_union(a: Vec, b: Vec) -> Vec { + let mut a = node_of(a); + let mut b = node_of(b); + a.clip_to(&b); + b.clip_to(&a); + b.invert(); + b.clip_to(&a); + b.invert(); + a.build(b.all_polygons()); + a.all_polygons() +} + +fn csg_difference(a: Vec, b: Vec) -> Vec { + let mut a = node_of(a); + let mut b = node_of(b); + a.invert(); + a.clip_to(&b); + b.clip_to(&a); + b.invert(); + b.clip_to(&a); + b.invert(); + a.build(b.all_polygons()); + a.invert(); + a.all_polygons() +} + +fn csg_intersection(a: Vec, b: Vec) -> Vec { + let mut a = node_of(a); + let mut b = node_of(b); + a.invert(); + b.clip_to(&a); + b.invert(); + a.clip_to(&b); + b.clip_to(&a); + a.build(b.all_polygons()); + a.invert(); + a.all_polygons() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn signed_volume(m: &Mesh) -> f64 { + // Sum of signed tetrahedron volumes (origin apex) — |result| = volume. + m.tris + .iter() + .map(|t| dot(t[0], cross(t[1], t[2])) / 6.0) + .sum::() + .abs() + } + + #[test] + fn box_volume_is_correct() { + let b = box_solid([0.0, 0.0, 0.0], [2.0, 3.0, 4.0]); + assert_eq!(b.tris.len(), 12); + assert!((signed_volume(&b) - 24.0).abs() < 1e-6); + } + + #[test] + fn difference_removes_material() { + // 4×4×4 cube minus a 2×2 column straight through it: volume 64 - 16 = 48. + let cube = box_solid([0.0, 0.0, 0.0], [4.0, 4.0, 4.0]); + let bore = box_solid([1.0, 1.0, -1.0], [2.0, 2.0, 6.0]); + let holed = cube.difference(&bore); + assert!(!holed.tris.is_empty()); + assert!( + (signed_volume(&holed) - 48.0).abs() < 1e-4, + "expected 48, got {}", + signed_volume(&holed) + ); + } + + #[test] + fn union_merges_volume() { + // Two disjoint unit cubes: total volume 2. + let a = box_solid([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]); + let b = box_solid([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]); + assert!((signed_volume(&a.union(&b)) - 2.0).abs() < 1e-4); + } + + #[test] + fn cylinder_and_prism_build() { + assert!(!cylinder_solid([0.0, 0.0, 0.0], 1.0, 2.0, 24) + .tris + .is_empty()); + let sq = [[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]]; + assert!((signed_volume(&prism(&sq, 0.0, 3.0, true)) - 12.0).abs() < 1e-6); + } +} diff --git a/src/model.rs b/src/model.rs index 16eb5ef..f7e0fb6 100644 --- a/src/model.rs +++ b/src/model.rs @@ -15,6 +15,11 @@ pub struct CommonAttrs { pub visible: bool, #[serde(default)] pub locked: bool, + /// Extrusion height in world units for the 3D view. `None`/0 stays flat; + /// a closed shape becomes a solid, a line/open polyline becomes a wall. + pub extrude: Option, + /// Base elevation (Z) for the 3D view. Defaults to 0 (the ground plane). + pub elevation: Option, } fn default_true() -> bool { @@ -83,6 +88,10 @@ pub struct CfText { #[serde(default = "default_text_size")] pub size: f64, pub align: Option, + pub font: Option, + pub rotation: Option, + pub bold: Option, + pub italic: Option, #[serde(flatten)] pub common: CommonAttrs, } @@ -138,7 +147,12 @@ pub enum DimType { #[derive(Debug, Clone, Deserialize)] pub struct CfHatch { - pub boundary: String, + /// Reference to a closed polyline or rect id, or inline points. + #[serde(default)] + pub boundary: Option, + /// Inline points (alternative to boundary reference). Used by DXF import to + /// round-trip a hatch's region without depending on a separate boundary id. + pub points: Option>, #[serde(default = "default_pattern")] pub pattern: String, #[serde(default = "default_scale")] @@ -220,6 +234,41 @@ pub struct CfFill { pub common: CommonAttrs, } +// ── 3D solids (CSG) ──────────────────────────────────────────────────── + +/// A named 3D primitive solid for the extruded/CSG view. Referenced by id from +/// `[[boolean]]`. 3D-only — solids do not appear in the 2D plan or DXF. +#[derive(Debug, Clone, Deserialize)] +pub struct CfSolid { + pub id: String, + /// `box` | `cylinder`. + pub shape: String, + /// Placement: box minimum corner, or cylinder base-circle center. Default origin. + pub at: Option<[f64; 3]>, + /// Box dimensions [sx, sy, sz]. + pub size: Option<[f64; 3]>, + /// Cylinder radius. + pub radius: Option, + /// Cylinder height. + pub height: Option, + /// Cylinder facet count (default 40). + pub segments: Option, + pub color: Option, +} + +/// A CSG operation combining named solids. The result is rendered; the solids +/// it consumes (`base` + `tools`) are not drawn on their own. +#[derive(Debug, Clone, Deserialize)] +pub struct CfBoolean { + pub id: Option, + /// `difference` | `union` | `intersection`. + pub op: String, + pub base: String, + #[serde(default)] + pub tools: Vec, + pub color: Option, +} + // ── Layer-level metadata ─────────────────────────────────────────────── #[derive(Debug, Clone, Deserialize, Default)] @@ -265,4 +314,8 @@ pub struct CfFile { pub arrays: Vec, #[serde(default, rename = "mirror")] pub mirrors: Vec, + #[serde(default, rename = "solid")] + pub solids: Vec, + #[serde(default, rename = "boolean")] + pub booleans: Vec, } diff --git a/src/parser.rs b/src/parser.rs index ac5fe2f..3557a97 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -14,6 +14,37 @@ pub struct ProjectFile { pub layers: IndexMap, #[serde(default)] pub constraints: Option, + /// Drawing sheets ("planos"): named views of the model with a title block. + #[serde(default, rename = "plano")] + pub planos: Vec, +} + +/// A drawing sheet: a named view of the model at a paper size, with a title +/// block (rótulo). Views: `plan` (top), `iso`, `front`/`back`/`left`/`right` +/// elevations, or `section` (a cut). +#[derive(Debug, Clone, Deserialize)] +pub struct Plano { + pub name: String, + #[serde(default = "default_view")] + pub view: String, + /// Sheet size [width, height] in mm. Default A3 landscape. + pub size: Option<[f64; 2]>, + /// Drawing scale label for the title block (e.g. "1:50"). + pub scale: Option, + /// Title shown in the rótulo (defaults to the plano name). + pub title: Option, + /// Path to a `.cf` file drawn as a custom title block instead of the default. + pub rotulo: Option, + /// Section only: cut axis `x` | `y` | `z`. + pub cut_axis: Option, + /// Section only: position of the cut plane along `cut_axis`. + pub cut_at: Option, + /// Section only: which side to keep, `min` (default) or `max`. + pub keep: Option, +} + +fn default_view() -> String { + "plan".to_string() } #[derive(Debug, Clone, Deserialize)] diff --git a/src/planos.rs b/src/planos.rs new file mode 100644 index 0000000..de3d801 --- /dev/null +++ b/src/planos.rs @@ -0,0 +1,289 @@ +//! Planos (drawing sheets) — render a named view of the model onto a sheet +//! with a title block (rótulo). +//! +//! A "plano" is the CAD notion of a sheet/viewport: it reuses the one model and +//! presents it through a chosen view (plan, iso, the four elevations, or a +//! section cut), framed at a paper size with a title block. The title block is +//! either auto-generated from the project metadata or drawn by the user in a +//! `.cf` file referenced via `rotulo` — same primitives as everything else. + +use crate::parser::{parse_cf, Plano}; +use crate::render3d::{render_view, Camera, Cut}; +use crate::svg::{load_project_layers, render_scene_from}; +use anyhow::Result; +use std::fmt::Write as _; +use std::path::Path; + +const INNER_W: u32 = 1400; +const BG: &str = "#0d0d0d"; +const FRAME: &str = "#9a9a9a"; +const INK: &str = "#e0e0e0"; +const MUTED: &str = "#9a9a9a"; + +/// A rendered sheet SVG plus its pixel size. +pub struct Sheet { + pub svg: String, + pub width_px: f64, + pub height_px: f64, +} + +/// Render a single plano to a framed sheet SVG. +pub fn render_plano(project_dir: &Path, plano: &Plano, target_width: u32) -> Result { + let (project, layers) = load_project_layers(project_dir, None)?; + + // 1. Render the model through the requested view. + let (inner_svg, iw, ih) = match plano.view.as_str() { + "plan" => { + let s = render_scene_from( + &project.project.name, + &project.project.units, + &layers, + INNER_W, + &[], + ); + (s.svg, s.width_px, s.height_px) + } + "section" => { + let axis = match plano.cut_axis.as_deref() { + Some("x") => 0, + Some("y") => 1, + _ => 2, // default: horizontal cut (z) + }; + let cam = match axis { + 0 => Camera::right_side(), + 1 => Camera::front(), + _ => Camera::top(), + }; + let cut = Cut { + axis, + at: plano.cut_at.unwrap_or(0.0), + keep_max: plano.keep.as_deref() == Some("max"), + }; + let r = render_view(&layers, INNER_W, &cam, Some(&cut)); + (r.svg, r.width_px, r.height_px) + } + other => { + let cam = match other { + "front" => Camera::front(), + "back" => Camera::back(), + "left" => Camera::left_side(), + "right" => Camera::right_side(), + "top" => Camera::top(), + _ => Camera::iso(), + }; + let r = render_view(&layers, INNER_W, &cam, None); + (r.svg, r.width_px, r.height_px) + } + }; + + // 2. Sheet geometry (paper size in mm → pixels). + let [mm_w, mm_h] = plano.size.unwrap_or([420.0, 297.0]); // A3 landscape + let sheet_w = target_width as f64; + let sheet_h = sheet_w * (mm_h / mm_w).max(0.1); + let margin = sheet_w * 0.02; + + // Title block: bottom-right box. + let tb_w = (sheet_w * 0.34).min(sheet_w - 2.0 * margin); + let tb_h = (sheet_h * 0.16).min(sheet_h - 2.0 * margin); + let tb_x = sheet_w - margin - tb_w; + let tb_y = sheet_h - margin - tb_h; + + // Drawing area: inside the frame, above the title block. + let draw_x = margin + margin; + let draw_y = margin + margin; + let draw_w = sheet_w - 2.0 * draw_x; + let draw_h = (tb_y - draw_y - margin).max(1.0); + + // 3. Compose the sheet SVG. + let mut out = String::with_capacity(inner_svg.len() + 4096); + let _ = write!( + out, + r#""#, + w = sheet_w, + h = sheet_h, + ); + let _ = write!(out, r#""#); + // Outer frame border. + let _ = write!( + out, + r#""#, + x = margin, + w = sheet_w - 2.0 * margin, + h = sheet_h - 2.0 * margin, + ); + + // The model view, fitted into the drawing area (nested SVG keeps aspect). + let _ = write!( + out, + r#"{body}"#, + dx = draw_x, + dy = draw_y, + dw = draw_w, + dh = draw_h, + body = svg_inner(&inner_svg), + ); + + // 4. Title block (rótulo): custom .cf or generated default. + out.push_str(&title_block( + project_dir, + plano, + &project.project.name, + &project.project.units, + project.project.scale.as_str(), + tb_x, + tb_y, + tb_w, + tb_h, + )); + + out.push_str(""); + Ok(Sheet { + svg: out, + width_px: sheet_w, + height_px: sheet_h, + }) +} + +/// The content between an SVG's outer `` and `` tags. +fn svg_inner(svg: &str) -> &str { + let start = svg.find('>').map(|i| i + 1).unwrap_or(0); + let end = svg.rfind("").unwrap_or(svg.len()); + if start <= end { + &svg[start..end] + } else { + "" + } +} + +#[allow(clippy::too_many_arguments)] +fn title_block( + project_dir: &Path, + plano: &Plano, + project_name: &str, + units: &str, + project_scale: &str, + x: f64, + y: f64, + w: f64, + h: f64, +) -> String { + // Custom rótulo: render the referenced .cf, fitted into the title block. + if let Some(rotulo) = &plano.rotulo { + if let Ok(cf) = parse_cf(&project_dir.join(rotulo)) { + let scene = render_scene_from( + project_name, + units, + &[("rotulo".to_string(), cf)], + INNER_W, + &[], + ); + return format!( + r#""#, + ) + &format!( + r#"{body}"#, + iw = scene.width_px, + ih = scene.height_px, + body = svg_inner(&scene.svg), + ); + } + } + + // Default rótulo. + let title = plano.title.as_deref().unwrap_or(&plano.name); + let scale = plano.scale.as_deref().unwrap_or(project_scale); + let pad = w * 0.04; + let row = h / 4.0; + let mut s = String::new(); + let _ = write!( + s, + r#""#, + ); + // Divider under the title. + let _ = write!( + s, + r#""#, + ly = y + row * 1.6, + x2 = x + w, + ); + let fs_title = (row * 0.62).max(8.0); + let fs = (row * 0.40).max(7.0); + let _ = write!( + s, + r#"{title}"#, + tx = x + pad, + ty = y + row * 1.05, + title = xml_escape(title), + ); + let _ = write!( + s, + r#"{n}"#, + tx = x + pad, + ty = y + row * 2.35, + n = xml_escape(project_name), + ); + let _ = write!( + s, + r#"Escala {scale} · {units} · {view}"#, + tx = x + pad, + ty = y + row * 3.25, + view = xml_escape(&plano.view), + scale = xml_escape(scale), + units = xml_escape(units), + ); + let _ = write!( + s, + r#"cadspec"#, + tx = x + w - pad, + ty = y + h - row * 0.35, + ); + s +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::Plano; + + fn plano(view: &str) -> Plano { + Plano { + name: "P-01".into(), + view: view.into(), + size: Some([420.0, 297.0]), + scale: Some("1:50".into()), + title: Some("Planta baja".into()), + rotulo: None, + cut_axis: None, + cut_at: None, + keep: None, + } + } + + #[test] + fn renders_plan_sheet_with_title_block() { + let dir = Path::new("examples/vivienda"); + let s = render_plano(dir, &plano("plan"), 1200).unwrap(); + assert!(s.svg.contains(r#"data-view="plano""#)); + assert!(s.svg.contains("Planta baja")); + assert!(s.svg.contains("Escala 1:50")); + // The model view is nested inside the sheet. + assert!(s.svg.matches("= 2); + assert!((s.width_px - 1200.0).abs() < 1.0 && s.height_px > 0.0); + } + + #[test] + fn renders_section_sheet() { + let dir = Path::new("examples/vivienda"); + let mut p = plano("section"); + p.cut_axis = Some("z".into()); + p.cut_at = Some(1.0); + let s = render_plano(dir, &p, 1000).unwrap(); + assert!(s.svg.contains(r#"data-view="plano""#)); + } +} diff --git a/src/preview.rs b/src/preview.rs index da7f5ad..33720dc 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -2,11 +2,14 @@ //! //! The PNG is a faithful raster of the SVG renderer (real text, measured //! dimensions, hatches, line styles), so what an agent *sees* in the image is -//! exactly what `cadforge serve` shows a human. The metadata JSON maps every +//! exactly what `cadspec serve` shows a human. The metadata JSON maps every //! entity to world and pixel bounding boxes so agents can locate geometry in //! the image. use crate::model::CfFile; +use crate::parser::parse_project; +use crate::planos::render_plano; +use crate::render3d::render_scene_3d; use crate::svg::{ enumerate_entities, layer_display_color, load_project_layers, render_scene_from, Scene, }; @@ -70,6 +73,15 @@ pub struct PreviewOutputs { pub svg: bool, } +/// Which projection to render. +#[derive(Clone, Copy, PartialEq)] +pub enum PreviewView { + /// Flat 2D plan (the default). + Plan, + /// Extruded axonometric 3D view. + ThreeD, +} + /// Generate preview artifacts (PNG + metadata JSON, and/or SVG) for the project. /// /// Everything is produced from a single parse + render pass. @@ -82,7 +94,11 @@ pub fn generate_preview( layer_filter: Option<&str>, highlight: &[String], outputs: PreviewOutputs, + view: PreviewView, ) -> Result<()> { + if view == PreviewView::ThreeD { + return generate_preview_3d(project_dir, width, height, layer_filter, outputs); + } let (project, layers) = load_project_layers(project_dir, layer_filter)?; let scene = render_scene_from( &project.project.name, @@ -134,6 +150,111 @@ pub fn generate_preview( Ok(()) } +/// Render the extruded 3D view. Geometry is projected axonometrically, so there +/// is no world→pixel mapping to emit — we write the PNG (and optional SVG) only. +fn generate_preview_3d( + project_dir: &Path, + width: u32, + height: u32, + layer_filter: Option<&str>, + outputs: PreviewOutputs, +) -> Result<()> { + let (_project, layers) = load_project_layers(project_dir, layer_filter)?; + let scene = render_scene_3d(&layers, width); + + if outputs.svg { + let svg_path = project_dir.join("preview.svg"); + std::fs::write(&svg_path, &scene.svg) + .with_context(|| format!("Cannot write {}", svg_path.display()))?; + println!("✓ SVG (3D): {}", svg_path.display()); + } + + if !outputs.png { + return Ok(()); + } + + let fit = (height as f64 / scene.height_px).min(1.0); + let pixmap = rasterize(&scene.svg, fit as f32)?; + let png_path = project_dir.join("preview.png"); + pixmap + .save_png(&png_path) + .map_err(|e| anyhow::anyhow!("Failed to save PNG: {}", e))?; + println!( + "✓ Preview (3D): {} ({}x{})", + png_path.display(), + pixmap.width(), + pixmap.height() + ); + Ok(()) +} + +/// Export the scene's 3D solids to a self-contained glTF (`scene.gltf`) — the +/// same meshes the 3D view renders, for an interactive viewer or interchange. +pub fn generate_gltf(project_dir: &Path, layer_filter: Option<&str>) -> Result<()> { + let (_project, layers) = load_project_layers(project_dir, layer_filter)?; + let meshes = crate::render3d::scene_meshes(&layers); + if meshes.is_empty() { + anyhow::bail!( + "no 3D geometry to export — declare [[solid]]/[[boolean]] or set `extrude` on a primitive" + ); + } + let tris: usize = meshes.iter().map(|(m, _)| m.tris.len()).sum(); + let doc = crate::gltf::scene_to_gltf(&meshes); + let out = project_dir.join("scene.gltf"); + std::fs::write(&out, &doc).with_context(|| format!("Cannot write {}", out.display()))?; + println!( + "✓ glTF: {} ({} meshes, {} triangles)", + out.display(), + meshes.len(), + tris + ); + Ok(()) +} + +/// Render a named plano (drawing sheet) to `preview.png` (and/or `preview.svg`). +pub fn generate_plano( + project_dir: &Path, + name: &str, + width: u32, + height: u32, + outputs: PreviewOutputs, +) -> Result<()> { + let project = parse_project(&project_dir.join("project.toml"))?; + let plano = project + .planos + .iter() + .find(|p| p.name == name) + .ok_or_else(|| { + let names: Vec<&str> = project.planos.iter().map(|p| p.name.as_str()).collect(); + anyhow::anyhow!("no plano named '{}' (have: {})", name, names.join(", ")) + })?; + let sheet = render_plano(project_dir, plano, width)?; + + if outputs.svg { + let svg_path = project_dir.join("preview.svg"); + std::fs::write(&svg_path, &sheet.svg) + .with_context(|| format!("Cannot write {}", svg_path.display()))?; + println!("✓ SVG (plano '{}'): {}", name, svg_path.display()); + } + if !outputs.png { + return Ok(()); + } + let fit = (height as f64 / sheet.height_px).min(1.0); + let pixmap = rasterize(&sheet.svg, fit as f32)?; + let png_path = project_dir.join("preview.png"); + pixmap + .save_png(&png_path) + .map_err(|e| anyhow::anyhow!("Failed to save PNG: {}", e))?; + println!( + "✓ Preview (plano '{}'): {} ({}x{})", + name, + png_path.display(), + pixmap.width(), + pixmap.height() + ); + Ok(()) +} + // ── Internal ──────────────────────────────────────────────────────────── fn build_meta( diff --git a/src/render3d.rs b/src/render3d.rs new file mode 100644 index 0000000..8003d93 --- /dev/null +++ b/src/render3d.rs @@ -0,0 +1,664 @@ +//! 3D view renderer — extrudes `.cf` geometry into solids and projects them. +//! +//! cadspec geometry is declared in 2D plan coordinates. Any primitive can +//! carry an `extrude` height (and optional `elevation`): a closed shape becomes +//! a solid prism, a line or open polyline becomes a vertical wall. This module +//! projects that geometry with a fixed axonometric (isometric) camera, sorts +//! the faces back-to-front (painter's algorithm) and shades them, producing a +//! single self-contained SVG — the same backend story as the 2D renderer, so +//! it rasterizes to PNG and streams to the live viewer identically. + +use crate::mesh; +use crate::model::{CfFile, CommonAttrs}; +use crate::svg::layer_display_color; +use std::fmt::Write as _; + +const BG_COLOR: &str = "#141414"; +const PADDING_PX: f64 = 48.0; +const CIRCLE_SEGMENTS: usize = 40; + +/// A rendered 3D SVG plus its pixel size (for the PNG rasterizer). +pub struct Render3d { + pub svg: String, + pub width_px: f64, + pub height_px: f64, +} + +/// One projected, shaded polygon ready to emit, with its camera depth. +struct Face { + /// Projected screen-space points (pre-fit): (horizontal, vertical-up). + proj: Vec<(f64, f64)>, + fill: Option, + stroke: String, + stroke_w: f64, + /// Larger = closer to the camera (drawn later, on top). + depth: f64, + layer: String, + id: Option, +} + +/// An orthographic/axonometric camera: screen-horizontal (`right`), screen-up +/// (`up`) and the viewing direction (`view`, pointing from the scene toward the +/// camera — used for depth sorting and back-face culling). Project a world +/// point by dotting it against `right`/`up`. +#[derive(Clone, Copy)] +pub struct Camera { + right: V3, + up: V3, + view: V3, +} + +impl Camera { + /// Standard 2:1 isometric (the default 3D look). + pub fn iso() -> Camera { + const C: f64 = 0.866_025_403_784_438_6; + const S: f64 = 0.5; + Camera { + right: [C, -C, 0.0], + up: [-S, -S, 1.0], + view: [0.577_350_27, 0.577_350_27, 0.577_350_27], + } + } + /// Looking straight down (-Z): a plan / planta. + pub fn top() -> Camera { + Camera { + right: [1.0, 0.0, 0.0], + up: [0.0, 1.0, 0.0], + view: [0.0, 0.0, 1.0], + } + } + pub fn front() -> Camera { + Camera { + right: [1.0, 0.0, 0.0], + up: [0.0, 0.0, 1.0], + view: [0.0, -1.0, 0.0], + } + } + pub fn back() -> Camera { + Camera { + right: [-1.0, 0.0, 0.0], + up: [0.0, 0.0, 1.0], + view: [0.0, 1.0, 0.0], + } + } + pub fn right_side() -> Camera { + Camera { + right: [0.0, -1.0, 0.0], + up: [0.0, 0.0, 1.0], + view: [1.0, 0.0, 0.0], + } + } + pub fn left_side() -> Camera { + Camera { + right: [0.0, 1.0, 0.0], + up: [0.0, 0.0, 1.0], + view: [-1.0, 0.0, 0.0], + } + } + fn project(&self, p: V3) -> (f64, f64) { + (v_dot(p, self.right), v_dot(p, self.up)) + } + fn depth(&self, p: V3) -> f64 { + v_dot(p, self.view) + } +} + +/// A section cut: keep one half-space of the model, revealing the interior at +/// the cut plane. `axis` is 0=x, 1=y, 2=z; `keep_max` keeps the side where the +/// coordinate is ≥ `at` (otherwise ≤ `at`). +#[derive(Clone, Copy)] +pub struct Cut { + pub axis: usize, + pub at: f64, + pub keep_max: bool, +} + +impl Cut { + /// A huge box covering the discarded half-space (to subtract from solids). + fn discard_box(&self) -> mesh::Mesh { + const BIG: f64 = 1.0e5; + let mut at = [-BIG, -BIG, -BIG]; + let mut size = [2.0 * BIG, 2.0 * BIG, 2.0 * BIG]; + if self.keep_max { + at[self.axis] = -BIG; // discard coord < at + size[self.axis] = self.at + BIG; + } else { + at[self.axis] = self.at; // discard coord > at + size[self.axis] = BIG; + } + mesh::box_solid(at, size) + } +} + +/// Render already-parsed layers to an isometric 3D [`Render3d`]. +pub fn render_scene_3d(layers: &[(String, CfFile)], width: u32) -> Render3d { + render_view(layers, width, &Camera::iso(), None) +} + +/// Render already-parsed layers through an arbitrary [`Camera`], optionally +/// sectioned by `cut`. +pub fn render_view( + layers: &[(String, CfFile)], + width: u32, + cam: &Camera, + cut: Option<&Cut>, +) -> Render3d { + let mut faces: Vec = Vec::new(); + + for (idx, (layer_name, cf)) in layers.iter().enumerate() { + let visible = cf.layer_meta.as_ref().map(|m| m.visible).unwrap_or(true); + if !visible { + continue; + } + let layer_color = layer_display_color(cf, idx); + collect_layer_faces(&mut faces, layer_name, cf, &layer_color, cam, cut); + } + + // Project bounds across every face to size the canvas. + let (mut min_h, mut min_v, mut max_h, mut max_v) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for f in &faces { + for &(h, v) in &f.proj { + min_h = min_h.min(h); + min_v = min_v.min(v); + max_h = max_h.max(h); + max_v = max_v.max(v); + } + } + if faces.is_empty() { + min_h = 0.0; + min_v = 0.0; + max_h = 10.0; + max_v = 10.0; + } + + let span_h = (max_h - min_h).max(1e-6); + let span_v = (max_v - min_v).max(1e-6); + let width_px = width as f64; + let scale = (width_px - 2.0 * PADDING_PX).max(1.0) / span_h; + let height_px = span_v * scale + 2.0 * PADDING_PX; + + // World→screen: flip vertical (SVG y grows down) so up is up. + let to_px = |(h, v): (f64, f64)| -> (f64, f64) { + ( + (h - min_h) * scale + PADDING_PX, + (max_v - v) * scale + PADDING_PX, + ) + }; + + // Painter's algorithm: farthest first. + faces.sort_by(|a, b| { + a.depth + .partial_cmp(&b.depth) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut out = String::with_capacity(16 * 1024 + faces.len() * 96); + let _ = write!( + out, + r#""#, + w = width_px, + h = height_px, + ); + let _ = write!( + out, + r#""#, + BG_COLOR + ); + + for f in &faces { + let pts: String = f + .proj + .iter() + .map(|&p| { + let (x, y) = to_px(p); + format!("{x:.2},{y:.2}") + }) + .collect::>() + .join(" "); + let layer_attr = format!(r#" data-layer="{}""#, xml_escape(&f.layer)); + let id_attr = match &f.id { + Some(id) => format!(r#" data-id="{}""#, xml_escape(id)), + None => String::new(), + }; + match &f.fill { + Some(fill) => { + let _ = write!( + out, + r#""#, + stroke = f.stroke, + sw = f.stroke_w, + ); + } + None => { + let _ = write!( + out, + r#""#, + stroke = f.stroke, + sw = f.stroke_w, + ); + } + } + } + + out.push_str(""); + Render3d { + svg: out, + width_px, + height_px, + } +} + +/// A 2D footprint extracted from a primitive, ready to extrude. +struct Footprint<'a> { + points: Vec<[f64; 2]>, + closed: bool, + common: &'a CommonAttrs, +} + +/// Every primitive's 2D footprint in a layer (rects, polylines, lines, circles, +/// arcs) — the input to extrusion and outlining. +fn footprints(cf: &CfFile) -> Vec> { + let mut prints: Vec = Vec::new(); + for e in &cf.rects { + let [ox, oy] = e.origin; + prints.push(Footprint { + points: vec![ + [ox, oy], + [ox + e.width, oy], + [ox + e.width, oy + e.height], + [ox, oy + e.height], + ], + closed: true, + common: &e.common, + }); + } + for e in &cf.polylines { + prints.push(Footprint { + points: e.points.clone(), + closed: e.closed, + common: &e.common, + }); + } + for e in &cf.lines { + prints.push(Footprint { + points: vec![e.from, e.to], + closed: false, + common: &e.common, + }); + } + for e in &cf.circles { + prints.push(Footprint { + points: polygonize_arc(e.center, e.radius, 0.0, 360.0, CIRCLE_SEGMENTS), + closed: true, + common: &e.common, + }); + } + for e in &cf.arcs { + let segs = ((e.to_angle - e.from_angle).abs() / 360.0 * CIRCLE_SEGMENTS as f64) + .ceil() + .max(2.0) as usize; + prints.push(Footprint { + points: polygonize_arc(e.center, e.radius, e.from_angle, e.to_angle, segs), + closed: false, + common: &e.common, + }); + } + prints +} + +/// World-space solid meshes for the whole scene — extruded footprints plus +/// resolved solids/booleans, each with its display colour. This is the same +/// geometry the 3D view renders (same builders), but unprojected and unsectioned, +/// so it can be exported to a mesh format like glTF. +pub fn scene_meshes(layers: &[(String, CfFile)]) -> Vec<(mesh::Mesh, String)> { + let mut out: Vec<(mesh::Mesh, String)> = Vec::new(); + + for (idx, (_name, cf)) in layers.iter().enumerate() { + let visible = cf.layer_meta.as_ref().map(|m| m.visible).unwrap_or(true); + if !visible { + continue; + } + let layer_color = layer_display_color(cf, idx); + + // Extruded footprints → prism/wall meshes. + for fp in footprints(cf) { + if fp.points.len() < 2 { + continue; + } + let h = fp.common.extrude.unwrap_or(0.0); + if h <= 0.0 { + continue; + } + let base = fp.common.elevation.unwrap_or(0.0); + let color = fp + .common + .color + .clone() + .unwrap_or_else(|| layer_color.clone()); + out.push((mesh::prism(&fp.points, base, h, fp.closed), color)); + } + + // Solids + booleans (mirrors collect_solid_faces, without projection). + let resolved = resolve_booleans(cf, &layer_color); + out.extend(resolved.into_iter().map(|(m, c, _id)| (m, c))); + } + out +} + +fn collect_layer_faces( + faces: &mut Vec, + layer_name: &str, + cf: &CfFile, + layer_color: &str, + cam: &Camera, + cut: Option<&Cut>, +) { + for fp in footprints(cf) { + if fp.points.len() < 2 { + continue; + } + let base = fp.common.elevation.unwrap_or(0.0); + let h = fp.common.extrude.unwrap_or(0.0); + let color = fp + .common + .color + .clone() + .unwrap_or_else(|| layer_color.to_string()); + let id = fp.common.id.clone(); + + if h > 0.0 { + // Extruded footprint → a real prism/wall mesh. + let mesh = apply_cut(mesh::prism(&fp.points, base, h, fp.closed), cut); + emit_mesh(faces, &mesh, &color, layer_name, &id, cam); + } else if cut.is_none() { + // Flat footprint lying on the ground plane (the plan, projected). + // Skipped in section views (there is no solid to cut). + emit_outline(faces, &fp, base, &color, layer_name, &id, cam); + } + } + + collect_solid_faces(faces, layer_name, cf, layer_color, cam, cut); +} + +/// Subtract the cut's discarded half-space from `m`, if a cut is active. +fn apply_cut(m: mesh::Mesh, cut: Option<&Cut>) -> mesh::Mesh { + match cut { + Some(c) => m.difference(&c.discard_box()), + None => m, + } +} + +/// Build named solids, apply boolean operations, return resolved meshes with colors and IDs. +/// Used by both `scene_meshes` (glTF export) and `collect_solid_faces` (SVG render). +fn resolve_booleans(cf: &CfFile, layer_color: &str) -> Vec<(mesh::Mesh, String, Option)> { + use std::collections::{HashMap, HashSet}; + + let mut meshes: HashMap<&str, mesh::Mesh> = HashMap::new(); + let mut colors: HashMap<&str, String> = HashMap::new(); + for s in &cf.solids { + meshes.insert(s.id.as_str(), build_solid(s)); + colors.insert( + s.id.as_str(), + s.color.clone().unwrap_or_else(|| layer_color.to_string()), + ); + } + + let mut consumed: HashSet<&str> = HashSet::new(); + for b in &cf.booleans { + consumed.insert(b.base.as_str()); + for t in &b.tools { + consumed.insert(t.as_str()); + } + } + + let mut out = Vec::new(); + for b in &cf.booleans { + let Some(base) = meshes.get(b.base.as_str()) else { + continue; + }; + let mut acc = base.clone(); + for t in &b.tools { + if let Some(tool) = meshes.get(t.as_str()) { + acc = match b.op.as_str() { + "union" => acc.union(tool), + "intersection" => acc.intersection(tool), + _ => acc.difference(tool), + }; + } + } + let color = b + .color + .clone() + .or_else(|| colors.get(b.base.as_str()).cloned()) + .unwrap_or_else(|| layer_color.to_string()); + out.push((acc, color, b.id.clone())); + } + for s in &cf.solids { + if consumed.contains(s.id.as_str()) { + continue; + } + if let (Some(m), Some(c)) = (meshes.get(s.id.as_str()), colors.get(s.id.as_str())) { + out.push((m.clone(), c.clone(), Some(s.id.clone()))); + } + } + out +} + +/// Build the named [`mesh`] solids, apply each `[[boolean]]`, and emit the +/// results. Solids consumed by a boolean are not drawn on their own. +fn collect_solid_faces( + faces: &mut Vec, + layer_name: &str, + cf: &CfFile, + layer_color: &str, + cam: &Camera, + cut: Option<&Cut>, +) { + for (mesh, color, id) in resolve_booleans(cf, layer_color) { + emit_mesh(faces, &apply_cut(mesh, cut), &color, layer_name, &id, cam); + } +} + +fn build_solid(s: &crate::model::CfSolid) -> mesh::Mesh { + let at = s.at.unwrap_or([0.0, 0.0, 0.0]); + match s.shape.as_str() { + "cylinder" => mesh::cylinder_solid( + at, + s.radius.unwrap_or(1.0), + s.height.unwrap_or(1.0), + s.segments.unwrap_or(40), + ), + _ => mesh::box_solid(at, s.size.unwrap_or([1.0, 1.0, 1.0])), + } +} + +// ── Mesh → shaded faces ──────────────────────────────────────────────── + +/// Light direction (from the upper front). +const LIGHT: V3 = [0.32, 0.24, 0.92]; +const AMBIENT: f64 = 0.40; +const DIFFUSE: f64 = 0.60; + +type V3 = [f64; 3]; + +fn v_sub(a: V3, b: V3) -> V3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn v_cross(a: V3, b: V3) -> V3 { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +fn v_dot(a: V3, b: V3) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn v_norm(a: V3) -> V3 { + let l = v_dot(a, a).sqrt(); + if l < 1e-12 { + a + } else { + [a[0] / l, a[1] / l, a[2] / l] + } +} + +/// Project a mesh: cull back faces, flat-shade by the surface normal, and push +/// one [`Face`] per visible triangle. +fn emit_mesh( + faces: &mut Vec, + m: &mesh::Mesh, + color: &str, + layer: &str, + id: &Option, + cam: &Camera, +) { + for t in &m.tris { + let normal = v_norm(v_cross(v_sub(t[1], t[0]), v_sub(t[2], t[0]))); + if v_dot(normal, cam.view) <= 0.0 { + continue; // back face — hidden + } + let lit = AMBIENT + DIFFUSE * v_dot(normal, LIGHT).max(0.0); + let proj: Vec<(f64, f64)> = t.iter().map(|&p| cam.project(p)).collect(); + let cx = (t[0][0] + t[1][0] + t[2][0]) / 3.0; + let cy = (t[0][1] + t[1][1] + t[2][1]) / 3.0; + let cz = (t[0][2] + t[1][2] + t[2][2]) / 3.0; + // Stroke == fill: coplanar triangles (same shade) merge seamlessly so + // the internal triangulation never shows; the small width closes the + // anti-alias cracks between adjacent triangles. Face-to-face contrast + // (different normals → different shade) still defines the real edges. + let fill = scale_color(color, lit.clamp(0.0, 1.0)); + faces.push(Face { + proj, + stroke: fill.clone(), + fill: Some(fill), + stroke_w: 1.0, + depth: cam.depth([cx, cy, cz]), + layer: layer.to_string(), + id: id.clone(), + }); + } +} + +/// A flat (non-extruded) footprint, drawn as an outline on the ground plane. +fn emit_outline( + faces: &mut Vec, + fp: &Footprint, + base: f64, + color: &str, + layer: &str, + id: &Option, + cam: &Camera, +) { + let mut ring: Vec<[f64; 3]> = fp.points.iter().map(|p| [p[0], p[1], base]).collect(); + if fp.closed { + if let Some(&first) = ring.first() { + ring.push(first); // close the outline visually + } + } + let mut proj = Vec::with_capacity(ring.len()); + let mut d = f64::MIN; + for &p in &ring { + proj.push(cam.project(p)); + d = d.max(cam.depth(p)); + } + faces.push(Face { + proj, + fill: None, + stroke: color.to_string(), + stroke_w: 1.4, + depth: d - 0.01, // ground outlines sit just behind solids at the same spot + layer: layer.to_string(), + id: id.clone(), + }); +} + +/// Sample points along an arc (degrees, counterclockwise). For a full circle +/// pass 0→360; the duplicate end point is dropped for closed footprints. +fn polygonize_arc(center: [f64; 2], radius: f64, from: f64, to: f64, segs: usize) -> Vec<[f64; 2]> { + let segs = segs.max(2); + let full = (to - from).abs() >= 359.999; + let count = if full { segs } else { segs + 1 }; + let mut pts = Vec::with_capacity(count); + for i in 0..count { + let t = i as f64 / segs as f64; + let ang = (from + (to - from) * t).to_radians(); + pts.push([ + center[0] + radius * ang.cos(), + center[1] + radius * ang.sin(), + ]); + } + pts +} + +/// Multiply each channel of `#RRGGBB` by `factor`, clamped to a valid color. +fn scale_color(hex: &str, factor: f64) -> String { + let h = hex.trim_start_matches('#'); + let parse = |s: &str| u8::from_str_radix(s, 16).unwrap_or(180); + let (r, g, b) = if h.len() == 6 { + (parse(&h[0..2]), parse(&h[2..4]), parse(&h[4..6])) + } else { + (180, 180, 180) + }; + let scale = |c: u8| ((c as f64) * factor).round().clamp(0.0, 255.0) as u8; + format!("#{:02X}{:02X}{:02X}", scale(r), scale(g), scale(b)) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::CfFile; + + #[test] + fn extruded_rect_produces_solid_faces() { + let cf: CfFile = toml::from_str( + r#" +[[rect]] +id = "rc-1" +origin = [0.0, 0.0] +width = 4.0 +height = 3.0 +extrude = 2.5 +"#, + ) + .unwrap(); + let r = render_scene_3d(&[("l".to_string(), cf)], 800); + // A box is 12 triangles; back-face culling leaves the visible front + // faces (top + the sides facing the camera) — several shaded polygons. + let polys = r.svg.matches(" 0.0 && r.height_px > 0.0); + } + + #[test] + fn flat_geometry_renders_as_ground_outline() { + let cf: CfFile = toml::from_str( + r#" +[[rect]] +origin = [0.0, 0.0] +width = 4.0 +height = 3.0 +"#, + ) + .unwrap(); + let r = render_scene_3d(&[("l".to_string(), cf)], 800); + assert_eq!(r.svg.matches(" Result<()> { let project_dir = parent.join(name); if project_dir.exists() { @@ -16,21 +16,20 @@ pub fn create_project(name: &str, parent: &Path) -> Result<()> { println!("✓ Project '{}' created at {}", name, project_dir.display()); println!(" → project.toml"); - println!(" → muros.cf"); - println!(" → puertas.cf"); - println!(" → mobiliario.cf"); - println!(" → cotas.cf"); + println!(" → shapes.cf"); + println!(" → curves.cf"); + println!(" → annotations.cf"); println!(" → .gitignore"); println!( - "\n Run `cadforge serve --path {}` for a live preview,", + "\n Run `cadspec serve --path {}` for a live preview,", name ); - println!(" or `cadforge build --path {}` to compile to DXF.", name); - println!(" `cadforge schema` prints the .cf language reference."); + println!(" or `cadspec build --path {}` to compile to DXF.", name); + println!(" `cadspec schema` prints the .cf language reference."); Ok(()) } -/// Initialize a CADforge project in the current directory. +/// Initialize a CADspec project in the current directory. pub fn init_project(dir: &Path) -> Result<()> { if dir.join("project.toml").exists() { bail!("project.toml already exists in '{}'", dir.display()); @@ -42,15 +41,14 @@ pub fn init_project(dir: &Path) -> Result<()> { .unwrap_or("project"); write_project_files(dir, name)?; - println!("✓ Initialized CADforge project in {}", dir.display()); + println!("✓ Initialized CADspec project in {}", dir.display()); println!(" → project.toml"); - println!(" → muros.cf"); - println!(" → puertas.cf"); - println!(" → mobiliario.cf"); - println!(" → cotas.cf"); + println!(" → shapes.cf"); + println!(" → curves.cf"); + println!(" → annotations.cf"); println!(" → .gitignore"); - println!("\n Run `cadforge serve` for a live preview."); - println!(" `cadforge schema` prints the .cf language reference."); + println!("\n Run `cadspec serve` for a live preview."); + println!(" `cadspec schema` prints the .cf language reference."); Ok(()) } @@ -62,127 +60,115 @@ scale = "1:100" units = "m" [layers] -muros = {{ file = "muros.cf", locked = false }} -puertas = {{ file = "puertas.cf", locked = false }} -mobiliario = {{ file = "mobiliario.cf", locked = false }} -cotas = {{ file = "cotas.cf", locked = false }} +shapes = {{ file = "shapes.cf", locked = false }} +curves = {{ file = "curves.cf", locked = false }} +annotations = {{ file = "annotations.cf", locked = false }} "# ); fs::write(project_dir.join("project.toml"), project_toml)?; - let gitignore = "# CADforge output\noutput.dxf\npreview.png\npreview.svg\npreview.meta.json\n\n# Rust build artifacts\ntarget/\n"; + let gitignore = "# CADspec output\noutput.dxf\npreview.png\npreview.svg\npreview.meta.json\n\n# CADspec serve daemon (pid + logs)\n.cadspec/\n\n# Rust build artifacts\ntarget/\n"; fs::write(project_dir.join(".gitignore"), gitignore)?; - let muros_cf = r##"[layer] -name = "muros" + let shapes_cf = r##"[layer] +name = "shapes" color = "#FFFFFF" -line_weight = 0.50 +line_weight = 0.35 -# Perímetro exterior +# Rectangle from its bottom-left origin. +# `extrude` gives it height in the 3D view (the `3D` button) — here, a box. +[[rect]] +id = "rc-001" +origin = [0.0, 0.0] +width = 4.0 +height = 4.0 +extrude = 3.0 + +# Closed polyline (a polygon) — also used as a hatch boundary [[polyline]] -id = "pl-perimetro" -points = [[0.0, 0.0], [8.0, 0.0], [8.0, 6.0], [0.0, 6.0]] +id = "pl-001" +points = [[6.0, 0.0], [10.0, 0.0], [11.0, 2.0], [8.0, 4.0], [6.0, 2.0]] closed = true -weight = 0.50 - -# Muro divisorio horizontal -[[line]] -id = "ln-div-h" -from = [0.0, 3.5] -to = [5.0, 3.5] -weight = 0.35 - -# Muro divisorio vertical -[[line]] -id = "ln-div-v" -from = [5.0, 0.0] -to = [5.0, 6.0] -weight = 0.35 -"##; - fs::write(project_dir.join("muros.cf"), muros_cf)?; - - let puertas_cf = r##"[layer] -name = "puertas" -color = "#00CC44" - -# Puerta principal -[[arc]] -id = "ar-puerta-principal" -center = [0.0, 2.5] -radius = 0.9 -from_angle = 0.0 -to_angle = 90.0 -# Puerta interior -[[arc]] -id = "ar-puerta-int" -center = [5.0, 4.5] -radius = 0.8 -from_angle = 90.0 -to_angle = 180.0 +# Reference marker (drawn as a cross) +[[point]] +id = "pt-001" +position = [2.0, 2.0] + +# Pattern fill inside the polygon above +[[hatch]] +id = "ht-001" +boundary = "pl-001" +pattern = "ansi31" +angle = 45.0 "##; - fs::write(project_dir.join("puertas.cf"), puertas_cf)?; + fs::write(project_dir.join("shapes.cf"), shapes_cf)?; - let mobiliario_cf = r##"[layer] -name = "mobiliario" + let curves_cf = r##"[layer] +name = "curves" color = "#4488FF" +line_weight = 0.25 -# Mesa sala -[[rect]] -id = "rc-mesa" -origin = [1.5, 4.5] -width = 2.0 -height = 1.0 - -# Cama dormitorio -[[rect]] -id = "rc-cama" -origin = [5.5, 4.0] -width = 2.0 -height = 1.5 - -# Etiquetas -[[text]] -id = "tx-sala" -position = [2.0, 5.0] -content = "SALA" -size = 0.25 +# Full circle — extruded into a cylinder in the 3D view +[[circle]] +id = "ci-001" +center = [2.0, 8.0] +radius = 1.5 +extrude = 2.0 -[[text]] -id = "tx-dorm" -position = [6.0, 5.0] -content = "DORMITORIO" -size = 0.20 +# Half arc (angles in degrees, counterclockwise from +X) +[[arc]] +id = "ar-001" +center = [6.0, 8.0] +radius = 1.5 +from_angle = 0.0 +to_angle = 180.0 -[[text]] -id = "tx-cocina" -position = [2.0, 1.5] -content = "COCINA" -size = 0.20 +# A small circle repeated around a center (polar array). +# The array expands at build time into ci-sat, ci-sat@1, ci-sat@2, … +[[circle]] +id = "ci-sat" +center = [11.5, 8.0] +radius = 0.4 + +[[array]] +target = "ci-sat" +mode = "polar" +count = 6 +center = [10.0, 8.0] +step_angle = 60.0 "##; - fs::write(project_dir.join("mobiliario.cf"), mobiliario_cf)?; + fs::write(project_dir.join("curves.cf"), curves_cf)?; - let cotas_cf = r##"[layer] -name = "cotas" + let annotations_cf = r##"[layer] +name = "annotations" color = "#FF4444" +line_weight = 0.18 -# Cota horizontal total +# Linear dimension — the measured distance is labeled automatically [[dim]] -id = "dm-ancho" +id = "dm-001" type = "linear" from = [0.0, 0.0] -to = [8.0, 0.0] +to = [4.0, 0.0] offset = -0.8 -# Cota vertical total -[[dim]] -id = "dm-alto" -type = "linear" -from = [0.0, 0.0] -to = [0.0, 6.0] -offset = -0.8 +# Text labels (size is in world units, not points) +[[text]] +id = "tx-shapes" +position = [2.0, 4.4] +content = "shapes" +size = 0.4 +align = "center" + +[[text]] +id = "tx-curves" +position = [4.0, 10.2] +content = "curves" +size = 0.4 +align = "center" "##; - fs::write(project_dir.join("cotas.cf"), cotas_cf)?; + fs::write(project_dir.join("annotations.cf"), annotations_cf)?; Ok(()) } @@ -194,7 +180,7 @@ mod tests { #[test] fn creates_project_structure() { - let tmp = PathBuf::from("/tmp/cadforge_test_new"); + let tmp = PathBuf::from("/tmp/cadspec_test_new"); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(&tmp).unwrap(); @@ -202,16 +188,15 @@ mod tests { let project_dir = tmp.join("mi-proyecto"); assert!(project_dir.join("project.toml").exists()); - assert!(project_dir.join("muros.cf").exists()); - assert!(project_dir.join("puertas.cf").exists()); - assert!(project_dir.join("mobiliario.cf").exists()); - assert!(project_dir.join("cotas.cf").exists()); + assert!(project_dir.join("shapes.cf").exists()); + assert!(project_dir.join("curves.cf").exists()); + assert!(project_dir.join("annotations.cf").exists()); assert!(!project_dir.join("AGENTS.md").exists()); assert!(project_dir.join(".gitignore").exists()); let content = fs::read_to_string(project_dir.join("project.toml")).unwrap(); assert!(content.contains("mi-proyecto")); - assert!(content.contains("muros.cf")); + assert!(content.contains("shapes.cf")); let gitignore = fs::read_to_string(project_dir.join(".gitignore")).unwrap(); assert!(gitignore.contains("output.dxf")); @@ -223,7 +208,7 @@ mod tests { #[test] fn fails_if_dir_exists() { - let tmp = PathBuf::from("/tmp/cadforge_test_exists"); + let tmp = PathBuf::from("/tmp/cadspec_test_exists"); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(tmp.join("existing")).unwrap(); @@ -235,17 +220,16 @@ mod tests { #[test] fn init_in_existing_dir() { - let tmp = PathBuf::from("/tmp/cadforge_test_init"); + let tmp = PathBuf::from("/tmp/cadspec_test_init"); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(&tmp).unwrap(); init_project(&tmp).unwrap(); assert!(tmp.join("project.toml").exists()); - assert!(tmp.join("muros.cf").exists()); - assert!(tmp.join("puertas.cf").exists()); - assert!(tmp.join("mobiliario.cf").exists()); - assert!(tmp.join("cotas.cf").exists()); + assert!(tmp.join("shapes.cf").exists()); + assert!(tmp.join("curves.cf").exists()); + assert!(tmp.join("annotations.cf").exists()); assert!(tmp.join(".gitignore").exists()); let _ = fs::remove_dir_all(&tmp); @@ -253,7 +237,7 @@ mod tests { #[test] fn init_fails_if_project_exists() { - let tmp = PathBuf::from("/tmp/cadforge_test_init_exists"); + let tmp = PathBuf::from("/tmp/cadspec_test_init_exists"); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(&tmp).unwrap(); fs::write(tmp.join("project.toml"), "").unwrap(); diff --git a/src/schema.rs b/src/schema.rs index dc5e0a1..d061681 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,13 +1,13 @@ -//! Schema — the `.cf` language reference, printable via `cadforge schema`. +//! Schema — the `.cf` language reference, printable via `cadspec schema`. //! //! This is the self-discovery entry point for AI agents and humans alike: one //! command dumps the complete format so any agent can generate valid `.cf` //! files without prior training. /// Complete `.cf` + `project.toml` reference in markdown. -pub const CF_REFERENCE: &str = r##"# CADforge `.cf` Language Reference +pub const CF_REFERENCE: &str = r##"# CADspec `.cf` Language Reference -CADforge projects are plain TOML. A project is a directory with a `project.toml` +CADspec projects are plain TOML. A project is a directory with a `project.toml` plus one `.cf` file per layer. Geometry is declared, never drawn: the same files always compile to the same DXF. @@ -28,6 +28,24 @@ puertas = { file = "puertas.cf", locked = false } puertas.parent = "muros" # child bbox must fit inside parent bbox cotas.belongs_to = "muros" # child primitives reference parent ids via belongs_to "muros → puertas" = "spatial_dependency" # movement warning (informational) + +# Planos (drawing sheets): named views of the model with a title block. +# Render with `cadspec preview --plano `, or pick them in the viewer's +# Planos panel (under Layers). +[[plano]] +name = "P-01" +view = "plan" # plan | iso | front | back | left | right | top | section +size = [420.0, 297.0] # sheet size in mm (default A3 landscape) +scale = "1:50" # label shown in the rótulo (defaults to project scale) +title = "Planta baja" # rótulo title (defaults to name) +rotulo = "rotulo.cf" # optional: a .cf drawn as a custom title block + +[[plano]] +name = "C-01" +view = "section" # a cut, viewed along the cut axis +cut_axis = "z" # x | y | z +cut_at = 1.5 # plane position along the axis +keep = "min" # min (default) keeps the ≤ side, max keeps the ≥ side ``` ## Layer files (`.cf`) @@ -53,8 +71,14 @@ style = "solid" # solid | dashed | dotted | dashdot visible = true locked = false belongs_to = "id" # reference to a primitive id in the parent layer +extrude = 3.0 # 3D view: extrusion height (world units). closed shape → solid, + # line / open polyline → wall. 0/omitted = flat +elevation = 0.0 # 3D view: base height (Z) the shape sits at (default 0) ``` +The 2D plan is unaffected by `extrude`/`elevation`; they only shape the +extruded 3D view (`cadspec preview --3d`, or the viewer's `3D` button). + ### Primitives ```toml @@ -86,6 +110,10 @@ position = [4.0, 3.0] content = "SALA" size = 0.25 # text height in world units align = "center" # left | center | right +font = "monospace" # CSS font-family, SVG/PNG preview only (default: monospace) +rotation = 0.0 # degrees, counterclockwise, about the anchor point (DXF-compatible) +bold = false # SVG/PNG preview only +italic = false # SVG/PNG preview only [[point]] # reference marker (drawn as a cross) position = [3.0, 3.0] @@ -140,6 +168,37 @@ targets = ["pl-nave", "ar-puerta"] axis = [[2.5, 0.0], [2.5, 1.0]] ``` +### 3D solids (CSG) + +For the 3D view you can also declare true solids and combine them with boolean +operations (constructive solid geometry). Solids and booleans are **3D-only** — +they do not appear in the 2D plan or the DXF. (For simple extrusions, prefer +`extrude`/`elevation` on a 2D primitive; use solids when you need booleans.) + +```toml +[[solid]] # a named 3D primitive (referenced by booleans via id) +id = "cubo" +shape = "box" # box | cylinder +at = [0.0, 0.0, 0.0] # box: minimum corner; cylinder: base-circle center +size = [4.0, 4.0, 4.0] # box dimensions [sx, sy, sz] + +[[solid]] +id = "broca" +shape = "cylinder" +at = [2.0, 2.0, -0.5] +radius = 1.2 +height = 5.0 +segments = 48 # facet count (default 40) + +[[boolean]] # combine solids — the result is rendered, the inputs are not +id = "cubo-perforado" +op = "difference" # difference (cut) | union | intersection +base = "cubo" +tools = ["broca"] # subtracted from / merged with the base, in order +``` + +A cube with a hole = a `box` minus a `cylinder` via `op = "difference"`. + ## Conventions - Coordinates are world units (see `units`), Y grows upward, origin at [0, 0]. @@ -151,27 +210,27 @@ axis = [[2.5, 0.0], [2.5, 1.0]] ## Workflow ```bash -cadforge serve # live preview in the browser (auto-reloads on save) -cadforge build # compile to output.dxf -cadforge check --json # machine-readable validation report -cadforge layers --json # machine-readable layer listing -cadforge preview # PNG + metadata JSON (--format svg for vector) -cadforge preview --highlight ln-001,tx-002 # amber markers around those ids -cadforge fmt # normalize .cf formatting +cadspec serve # live preview in the browser (auto-reloads on save) +cadspec build # compile to output.dxf +cadspec check --json # machine-readable validation report +cadspec layers --json # machine-readable layer listing +cadspec preview # PNG + metadata JSON (--format svg for vector) +cadspec preview --highlight ln-001,tx-002 # amber markers around those ids +cadspec fmt # normalize .cf formatting ``` -The feedback loop for agents: edit `.cf` → run `cadforge check --json` to -validate → run `cadforge preview` and **look at `preview.png`** — it is a +The feedback loop for agents: edit `.cf` → run `cadspec check --json` to +validate → run `cadspec preview` and **look at `preview.png`** — it is a faithful render (real text, measured dimension labels, hatches, line styles). `preview.meta.json` maps every entity id to world and pixel bounding boxes. After editing specific entities, re-render with -`cadforge preview --highlight ` to visually confirm the change landed +`cadspec preview --highlight ` to visually confirm the change landed where intended (highlighted entities get labeled amber markers). -For humans, `cadforge serve` adds: click any entity to inspect its source +For humans, `cadspec serve` adds: click any entity to inspect its source TOML block (copyable as an agent prompt for targeted edits), a layer panel -with on/ghost/off states (trace one floor over another), and a 3D stacked -view of the layers. +with on/ghost/off states (trace one floor over another), and a `3D` button +that renders the extruded view (see `extrude`/`elevation` above). "##; /// Print the `.cf` language reference to stdout. diff --git a/src/serve.rs b/src/serve.rs index 27d86cf..ea407f3 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -1,4 +1,4 @@ -//! Live preview server — `cadforge serve`. +//! Live preview server — `cadspec serve`. //! //! Watches the project files and serves an auto-reloading SVG preview in the //! browser. The vibecoding loop: an agent (or human) edits `.cf` files, the @@ -6,20 +6,23 @@ //! //! Viewer features: pan/zoom, click-to-inspect any entity (shows its source //! TOML block, copyable for targeted agent edits), per-layer visibility with -//! a ghost mode for tracing over other floors, and a 3D stacked-layers view. +//! a ghost mode for tracing over other floors, and an extruded 3D view. //! //! Plain `std::net` HTTP — this is a localhost dev server, no framework needed. use crate::parser::parse_project; +use crate::render3d::render_scene_3d; use crate::svg::{layer_display_color, load_project_layers, render_scene_from}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::fs::{self, File}; use std::io::{BufRead, BufReader, Write}; -use std::net::{TcpListener, TcpStream}; +use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::mpsc; use std::sync::{Arc, Condvar, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; const SVG_WIDTH: u32 = 1600; const DEBOUNCE: Duration = Duration::from_millis(80); @@ -28,11 +31,15 @@ const SSE_KEEPALIVE: Duration = Duration::from_secs(15); struct LiveState { /// Arc so request handlers serve the SVG without copying it. svg: Arc, + /// Extruded axonometric 3D render of the same scene. + svg3d: Arc, error: Option, version: u64, project_name: String, /// (name, color) per layer, for the layer panel. layers: Vec<(String, String)>, + /// (name, view, title) per plano, for the planos panel. + planos: Vec<(String, String, String)>, } /// Shared state plus a condvar so SSE clients are woken the instant a rebuild @@ -55,10 +62,12 @@ pub fn serve_project(project_dir: &Path, port: u16, open: bool) -> Result<()> { let state: Shared = Arc::new(Live { state: Mutex::new(LiveState { svg: Arc::new(String::new()), + svg3d: Arc::new(String::new()), error: None, version: 0, project_name: project.project.name.clone(), layers: Vec::new(), + planos: Vec::new(), }), changed: Condvar::new(), project_dir: project_dir.clone(), @@ -70,7 +79,7 @@ pub fn serve_project(project_dir: &Path, port: u16, open: bool) -> Result<()> { .with_context(|| format!("Cannot bind 127.0.0.1:{} (port in use?)", port))?; let url = format!("http://127.0.0.1:{}", port); - println!("◉ cadforge serve — {}", project.project.name); + println!("◉ cadspec serve — {}", project.project.name); println!(" Preview: {}", url); println!(" Watching: {}", project_dir.display()); println!(); @@ -94,8 +103,162 @@ pub fn serve_project(project_dir: &Path, port: u16, open: bool) -> Result<()> { Ok(()) } +// ── Background daemon ────────────────────────────────────────────────────── +// +// `serve` runs detached by default: a parent process validates the project and +// the port, spawns the real (foreground) server in its own process group with +// its output redirected to a log file, waits until the port actually accepts a +// connection, then prints the URL and exits. Waiting for real readiness means +// we never claim "running" for a server that failed to come up. + +fn runtime_dir(project_dir: &Path) -> PathBuf { + project_dir.join(".cadspec") +} + +fn pid_path(project_dir: &Path) -> PathBuf { + runtime_dir(project_dir).join("serve.pid") +} + +fn log_path(project_dir: &Path) -> PathBuf { + runtime_dir(project_dir).join("serve.log") +} + +/// True if `pid` refers to a live process (`kill -0`). +fn process_alive(pid: u32) -> bool { + Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// The recorded daemon pid for this project, but only if it is still alive. +fn running_pid(project_dir: &Path) -> Option { + let pid: u32 = fs::read_to_string(pid_path(project_dir)) + .ok()? + .trim() + .parse() + .ok()?; + process_alive(pid).then_some(pid) +} + +/// Block until the server accepts a connection on `port`, or `timeout` elapses. +fn wait_until_ready(port: u16, timeout: Duration) -> bool { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() { + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + false +} + +/// Start the live preview server detached in the background (the default). +pub fn serve_daemon(project_dir: &Path, port: u16, open: bool) -> Result<()> { + // Validate the project up front so config errors surface here, not in a log. + parse_project(&project_dir.join("project.toml"))?; + let project_dir = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.to_path_buf()); + let url = format!("http://127.0.0.1:{}", port); + + if let Some(pid) = running_pid(&project_dir) { + println!("◉ cadspec serve already running (pid {pid})"); + println!(" Preview: {url}"); + println!(" Stop with: cadspec serve --stop"); + if open { + open_browser(&url); + } + return Ok(()); + } + + // Fail fast on a busy port instead of letting the detached child die quietly. + match TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => drop(listener), + Err(e) => bail!("Cannot bind 127.0.0.1:{port} (port in use?): {e}"), + } + + fs::create_dir_all(runtime_dir(&project_dir))?; + let log = log_path(&project_dir); + let log_file = File::create(&log)?; + + let exe = std::env::current_exe().context("cannot locate cadspec executable")?; + let mut cmd = Command::new(exe); + cmd.arg("serve") + .arg("--foreground") + .arg("--path") + .arg(&project_dir) + .arg("--port") + .arg(port.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file.try_clone()?)) + .stderr(Stdio::from(log_file)); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Own process group: survives the parent shell / agent command exiting. + cmd.process_group(0); + } + let child = cmd.spawn().context("failed to spawn background server")?; + let pid = child.id(); + fs::write(pid_path(&project_dir), pid.to_string())?; + + if wait_until_ready(port, Duration::from_secs(5)) { + println!("◉ cadspec serve — running in background (pid {pid})"); + println!(" Preview: {url}"); + println!(" Logs: {}", log.display()); + println!(" Stop with: cadspec serve --stop"); + if open { + open_browser(&url); + } + Ok(()) + } else { + let _ = fs::remove_file(pid_path(&project_dir)); + let tail = fs::read_to_string(&log).unwrap_or_default(); + bail!( + "server did not come up within 5s. Log:\n{}", + tail.trim_end() + ); + } +} + +/// Stop the background server running for this project. +pub fn serve_stop(project_dir: &Path, _port: u16) -> Result<()> { + let project_dir = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.to_path_buf()); + let pid_file = pid_path(&project_dir); + + let Some(pid) = running_pid(&project_dir) else { + let _ = fs::remove_file(&pid_file); // clean up any stale pidfile + println!("No cadspec serve daemon running for this project."); + return Ok(()); + }; + + let stopped = Command::new("kill") + .arg(pid.to_string()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let _ = fs::remove_file(&pid_file); + + if stopped { + println!("✓ Stopped cadspec serve (pid {pid})."); + Ok(()) + } else { + bail!("failed to stop process {pid}") + } +} + fn rebuild(project_dir: &Path, state: &Shared) { - let result = (|| -> Result<(String, Vec<(String, String)>)> { + type PlanoInfo = Vec<(String, String, String)>; + type Built = (String, String, Vec<(String, String)>, PlanoInfo); + let result = (|| -> Result { let (project, layers) = load_project_layers(project_dir, None)?; let scene = render_scene_from( &project.project.name, @@ -104,19 +267,33 @@ fn rebuild(project_dir: &Path, state: &Shared) { SVG_WIDTH, &[], ); + let scene3d = render_scene_3d(&layers, SVG_WIDTH); let layer_info = layers .iter() .enumerate() .map(|(i, (name, cf))| (name.clone(), layer_display_color(cf, i))) .collect(); - Ok((scene.svg, layer_info)) + let plano_info = project + .planos + .iter() + .map(|p| { + ( + p.name.clone(), + p.view.clone(), + p.title.clone().unwrap_or_else(|| p.name.clone()), + ) + }) + .collect(); + Ok((scene.svg, scene3d.svg, layer_info, plano_info)) })(); let mut st = state.state.lock().unwrap(); match result { - Ok((svg, layers)) => { + Ok((svg, svg3d, layers, planos)) => { st.svg = Arc::new(svg); + st.svg3d = Arc::new(svg3d); st.layers = layers; + st.planos = planos; st.error = None; } Err(e) => { @@ -128,6 +305,24 @@ fn rebuild(project_dir: &Path, state: &Shared) { state.changed.notify_all(); } +/// Build the scene's 3D solids as a glTF document (for the WebGL viewer). +fn scene_gltf(project_dir: &Path) -> Result { + let (_project, layers) = load_project_layers(project_dir, None)?; + let meshes = crate::render3d::scene_meshes(&layers); + Ok(crate::gltf::scene_to_gltf(&meshes)) +} + +/// Render a plano by name to SVG (on demand, for the `/plano.svg` endpoint). +fn render_named_plano(project_dir: &Path, name: &str) -> Result { + let project = parse_project(&project_dir.join("project.toml"))?; + let plano = project + .planos + .iter() + .find(|p| p.name == name) + .with_context(|| format!("no plano named '{name}'"))?; + Ok(crate::planos::render_plano(project_dir, plano, SVG_WIDTH)?.svg) +} + fn spawn_watcher(project_dir: PathBuf, state: Shared) -> Result<()> { let (tx, rx) = mpsc::channel(); let mut watcher = RecommendedWatcher::new( @@ -203,6 +398,27 @@ fn handle_connection(stream: TcpStream, state: &Shared) -> std::io::Result<()> { let svg = Arc::clone(&state.state.lock().unwrap().svg); respond(stream, "200 OK", "image/svg+xml", svg.as_bytes()) } + "/preview3d.svg" => { + let svg = Arc::clone(&state.state.lock().unwrap().svg3d); + respond(stream, "200 OK", "image/svg+xml", svg.as_bytes()) + } + "/scene.gltf" => { + let body = + scene_gltf(&state.project_dir).unwrap_or_else(|_| crate::gltf::scene_to_gltf(&[])); + respond(stream, "200 OK", "model/gltf+json", body.as_bytes()) + } + "/plano.svg" => { + // Rendered on demand (sections run CSG, so we don't precompute all). + let name = query_param(query, "name").unwrap_or_default(); + let dir = &state.project_dir; + let svg = render_named_plano(dir, &name).unwrap_or_else(|e| { + format!( + r##"plano error: {}"##, + html_escape(&format!("{e:#}")) + ) + }); + respond(stream, "200 OK", "image/svg+xml", svg.as_bytes()) + } "/state" => { let st = state.state.lock().unwrap(); let layers: Vec<_> = st @@ -210,11 +426,19 @@ fn handle_connection(stream: TcpStream, state: &Shared) -> std::io::Result<()> { .iter() .map(|(name, color)| serde_json::json!({"name": name, "color": color})) .collect(); + let planos: Vec<_> = st + .planos + .iter() + .map(|(name, view, title)| { + serde_json::json!({"name": name, "view": view, "title": title}) + }) + .collect(); let body = serde_json::json!({ "version": st.version, "project": st.project_name, "error": st.error, "layers": layers, + "planos": planos, }) .to_string(); drop(st); @@ -225,11 +449,98 @@ fn handle_connection(stream: TcpStream, state: &Shared) -> std::io::Result<()> { let body = entity_block_json(&state.project_dir, &id); respond(stream, "200 OK", "application/json", body.as_bytes()) } + // ── Built-in .cf editor ──────────────────────────────────────────── + "/files" => { + let mut names: Vec = std::fs::read_dir(&state.project_dir) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".cf")) + .collect() + }) + .unwrap_or_default(); + names.sort(); + let body = serde_json::json!({ "files": names }).to_string(); + respond(stream, "200 OK", "application/json", body.as_bytes()) + } + "/file" => { + let name = query_param(query, "name").unwrap_or_default(); + match safe_cf_name(&name) { + Some(n) => match std::fs::read_to_string(state.project_dir.join(&n)) { + Ok(s) => respond(stream, "200 OK", "text/plain; charset=utf-8", s.as_bytes()), + Err(_) => respond(stream, "404 Not Found", "text/plain", b"not found"), + }, + None => respond(stream, "400 Bad Request", "text/plain", b"bad name"), + } + } + "/save" => { + let name = query_param(query, "name").unwrap_or_default(); + let body = read_request_body(&mut reader); + match safe_cf_name(&name) { + Some(n) => match std::fs::write(state.project_dir.join(&n), &body) { + Ok(()) => { + rebuild(&state.project_dir, state); + let st = state.state.lock().unwrap(); + let resp = serde_json::json!({ + "ok": st.error.is_none(), + "version": st.version, + "error": st.error, + }) + .to_string(); + drop(st); + respond(stream, "200 OK", "application/json", resp.as_bytes()) + } + Err(e) => respond( + stream, + "500 Internal Server Error", + "text/plain", + format!("write error: {e}").as_bytes(), + ), + }, + None => respond(stream, "400 Bad Request", "text/plain", b"bad name"), + } + } "/events" => serve_events(stream, state), + "/favicon.svg" => respond(stream, "200 OK", "image/svg+xml", FAVICON_SVG.as_bytes()), _ => respond(stream, "404 Not Found", "text/plain", b"not found"), } } +/// Accept only a bare `*.cf` filename (no path traversal) for the editor. +fn safe_cf_name(name: &str) -> Option { + if name.is_empty() + || name.contains('/') + || name.contains('\\') + || name.contains("..") + || !name.ends_with(".cf") + { + return None; + } + Some(name.to_string()) +} + +/// Read the remaining request headers, then the body of `Content-Length` bytes. +fn read_request_body(reader: &mut BufReader) -> Vec { + let mut len = 0usize; + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + let t = line.trim_end(); + if t.is_empty() { + break; + } + if let Some(v) = t.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + let _ = std::io::Read::read_exact(reader, &mut body); + body +} + fn query_param(query: &str, key: &str) -> Option { query.split('&').find_map(|pair| { let (k, v) = pair.split_once('=')?; @@ -430,72 +741,147 @@ fn html_escape(s: &str) -> String { .replace('>', ">") } +const FAVICON_SVG: &str = r##"cs"##; + const INDEX_HTML: &str = r##" -{{PROJECT_NAME}} — cadforge live +{{PROJECT_NAME}} — cadspec live +
{{PROJECT_NAME}} - cadforge live + cadspec live v0 - + + edit .cf files — preview updates automatically
- + +
+ +
+