diff --git a/.gitignore b/.gitignore index de105c7..92e211d 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,9 @@ Cargo.lock # End of https://www.toptal.com/developers/gitignore/api/rust output.dxf preview.png +preview.svg preview.meta.json + +# AI agent instruction files — not part of this repo +CLAUDE.md +AGENTS.md diff --git a/Cargo.toml b/Cargo.toml index 3a5e78a..6ce5be2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,5 +18,11 @@ serde_json = "1.0" toml = "0.8" toml_edit = "0.22" indexmap = { version = "2", features = ["serde"] } -tiny-skia = "0.11" notify = "6.1" +resvg = "0.47.0" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/README.md b/README.md index 1bf094b..ebe8a10 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,16 @@ -██████ ████████ ██████ ████████ ██ ██ ██ ███████ ████████ -██░░███ ░░███░░███░░░░░███ ░░███░░███░███ ░███ ██░███░░░░░░░███░ -░███ ░░░ ░███ ░███ ███████ ░███ ░░░ ░███ ░██████░░█████ ░███ -░███ ███ ░███ ░███ ███░░███ ░███ ░███ ░███░░░ ░███░░█ ░███ -░░██████ ░███████░░████████ ░███ ░███████████ ███████ ░████████ - ░░░░░░ ░░░░░░░ ░░░░░░░░ ░░░ ░░░░░░░░░░░ ░░░░░░ ░░░░░░░ +```text + █████ ███████████ + ░░███ ░░███░░░░░░█ + ██████ ██████ ███████ ░███ █ ░ ██████ ████████ ███████ ██████ + ███░░███ ░░░░░███ ███░░███ ░███████ ███░░███░░███░░███ ███░░███ ███░░███ +░███ ░░░ ███████ ░███ ░███ ░███░░░█ ░███ ░███ ░███ ░░░ ░███ ░███░███████ +░███ ███ ███░░███ ░███ ░███ ░███ ░ ░███ ░███ ░███ ░███ ░███░███░░░ +░░██████ ░░████████░░████████ █████ ░░██████ █████ ░░███████░░██████ + ░░░░░░ ░░░░░░░░ ░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░███ ░░░░░░ + ███ ░███ + ░░██████ + ░░░░░░ +```

CI @@ -12,7 +19,38 @@ 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, compile to DXF, and generate PNG previews for AI agents. +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. + +--- + +## Vibecoding CAD + +The core loop: **describe geometry in TOML, see it instantly, iterate.** + +```bash +cadforge new casa && cd casa +cadforge serve --open # live preview in the browser +``` + +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. --- @@ -21,25 +59,28 @@ cadforge is an **Architecture as Code** CLI tool and Rust library for declarativ ### 🎯 Core Platform - **📐 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. -- **🖼️ PNG Preview** — Generate raster previews with metadata JSON for AI agent integration. Renders fills, hatches, strokes, and text with boundary resolution. Configurable resolution and layer filtering. -- **✅ Validation Engine** — `cadforge check` validates geometry without generating output. Shows project metadata, layer colors, and entity counts. +- **🖼️ 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 on changes with 300ms debounce. +- **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. -- **Boundary Resolution** — Automatic detection of closed boundaries for hatch generation. Shared boundary resolution across overlapping entities. -- **Polyline Support** — Full LWPOLYLINE support with bulge factors for arcs. Proper vertex handling and closure detection. +- **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. -- **Preview Renderer** — Tiny-skia based raster rendering with anti-aliasing. PNG + JSON metadata output. +- **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. --- @@ -54,10 +95,17 @@ cadforge is an **Architecture as Code** CLI tool and Rust library for declarativ | `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 preview` | Generate PNG preview + metadata JSON | -| `cadforge preview --width 1024 --height 768` | Custom resolution preview | +| `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) | @@ -69,18 +117,14 @@ cadforge is an **Architecture as Code** CLI tool and Rust library for declarativ | `cadforge config set ` | Set global defaults (`author`, `units`) | | `cadforge config show` | Show global defaults | -### Viewer controls (MVP) +### Live preview controls (`cadforge serve`) -- HUD flotante en pantalla con proyecto, vista, distancia, capas, selección y ayuda de atajos -- `T` / `F` / `V` / `R` → top / front / right / isometric preset views -- `Q` / `E` / `W` / `S` → orbit camera -- Mouse left-drag → orbit -- Mouse right-drag / arrows → pan -- Mouse wheel / `+` / `-` → zoom -- `1`..`9` → toggle layer visibility -- Click entity edge → select primitive id -- Selected entity is highlighted in amber in the viewport HUD context -- `C` → copy selected id to clipboard +- 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 +- Layer panel (or keys `1`-`9`) → cycle each layer **on → ghost → off**; ghost mode traces one floor plan over another +- `3D` button (or key `3`) → stacked exploded view of the layers +- Browser auto-reloads on every `.cf` / `project.toml` save (SSE) +- Build errors render as an overlay with file/line detail — the loop never breaks --- @@ -117,7 +161,7 @@ to_angle = 90.0 [[polyline]] id = "pl-001" -vertices = [[0, 0], [5, 0], [5, 3], [0, 3]] +points = [[0.0, 0.0], [5.0, 0.0], [5.0, 3.0], [0.0, 3.0]] closed = true [[text]] @@ -135,7 +179,9 @@ offset = 0.5 ### Supported Primitives -`line`, `polyline`, `rect`, `circle`, `arc`, `text`, `point`, `dim`, `hatch`, `solid` +`line`, `polyline`, `rect`, `circle`, `arc`, `text`, `point`, `dim`, `hatch`, `fill`, `group` + +Run `cadforge schema` for the complete reference with all attributes. --- @@ -160,20 +206,24 @@ offset = 0.5 - **Resolver** — Layer dependency resolution, coordinate validation, boundary detection - **Compiler** — Entity compilation to DXF format, hatch generation, polyline closure - **DXF Writer** — Direct DXF entity emission with proper layer/color/lineweight mapping -- **Preview Renderer** — Tiny-skia raster rendering with hatch/fill support +- **SVG Renderer** — Single vector backend: text, measured dims, hatches, highlights; PNG previews are resvg rasterizations of it --- ## Main Modules -- `compiler/` — Project compilation pipeline, layer targeting, validation, build stats +- `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 --- @@ -183,10 +233,10 @@ offset = 0.5 | Data | Location | Format | |------|----------|--------| | Project files | `./` | TOML (`.cf` + `project.toml`) | -| Build output | `output/` | DXF | -| Preview output | `output/preview.png` | PNG | -| Preview metadata | `output/preview.json` | JSON | -| Build cache | `target/` | Cargo build | +| 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 | --- @@ -198,7 +248,12 @@ cadforge new mi-proyecto cd mi-proyecto ``` -**Edit `.cf` files** (TOML format with your geometry) +**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 @@ -215,7 +270,7 @@ cadforge build --layer muros # compile single layer **Preview:** ```bash -cadforge preview # default 2048x1536 +cadforge preview # default 1600x1200 (fits content aspect) cadforge preview --width 1024 --height 768 # custom resolution cadforge preview --layer muros # single layer preview ``` @@ -229,7 +284,7 @@ cadforge watch # monitors .cf and .toml files ## Tech Stack -| Rust 2021 | clap | toml | toml_edit | tiny-skia | dxf | notify | anyhow | serde | +| Rust 2021 | clap | toml | toml_edit | resvg | dxf | notify | anyhow | serde | --- diff --git a/assets/fonts/DejaVuSansMono.ttf b/assets/fonts/DejaVuSansMono.ttf new file mode 100644 index 0000000..538ee27 Binary files /dev/null and b/assets/fonts/DejaVuSansMono.ttf differ diff --git a/assets/fonts/LICENSE-DejaVuSansMono.txt b/assets/fonts/LICENSE-DejaVuSansMono.txt new file mode 100644 index 0000000..b3d93a1 --- /dev/null +++ b/assets/fonts/LICENSE-DejaVuSansMono.txt @@ -0,0 +1,78 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: DejaVu fonts +Upstream-Author: Stepan Roh (original author), + see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list +Source: https://dejavu-fonts.github.io/ + +Files: * +Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. + Bitstream Vera is a trademark of Bitstream, Inc. + DejaVu changes are in public domain. +License: bitstream-vera + Permission is hereby granted, free of charge, to any person obtaining a copy + of the fonts accompanying this license ("Fonts") and associated + documentation files (the "Font Software"), to reproduce and distribute the + Font Software, including without limitation the rights to use, copy, merge, + publish, distribute, and/or sell copies of the Font Software, and to permit + persons to whom the Font Software is furnished to do so, subject to the + following conditions: + . + The above copyright and trademark notices and this permission notice shall + be included in all copies of one or more of the Font Software typefaces. + . + The Font Software may be modified, altered, or added to, and in particular + the designs of glyphs or characters in the Fonts may be modified and + additional glyphs or characters may be added to the Fonts, only if the fonts + are renamed to names not containing either the words "Bitstream" or the word + "Vera". + . + This License becomes null and void to the extent applicable to Fonts or Font + Software that has been modified and is distributed under the "Bitstream + Vera" names. + . + The Font Software may be sold as part of a larger software package but no + copy of one or more of the Font Software typefaces may be sold by itself. + . + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, + TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME + FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING + ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF + THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE + FONT SOFTWARE. + . + Except as contained in this notice, the names of Gnome, the Gnome + Foundation, and Bitstream Inc., shall not be used in advertising or + otherwise to promote the sale, use or other dealings in this Font Software + without prior written authorization from the Gnome Foundation or Bitstream + Inc., respectively. For further information, contact: fonts at gnome dot + org. + +Files: debian/* +Copyright: (C) 2005-2006 Peter Cernak + (C) 2006-2011 Davide Viti + (C) 2011-2013 Christian Perrier + (C) 2013 Fabian Greffrath +License: GPL-2+ + This program is free software; you can redistribute it + and/or modify it under the terms of the GNU General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later + version. + . + This program is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + PURPOSE. See the GNU General Public License for more + details. + . + You should have received a copy of the GNU General Public + License along with this package; if not, write to the Free + Software Foundation, Inc., 51 Franklin St, Fifth Floor, + Boston, MA 02110-1301 USA + . + On Debian systems, the full text of the GNU General Public + License version 2 can be found in the file + /usr/share/common-licenses/GPL-2'. diff --git a/cadforge-spec.md b/cadforge-spec.md new file mode 100644 index 0000000..0abb8d8 --- /dev/null +++ b/cadforge-spec.md @@ -0,0 +1,418 @@ +# 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/examples/taller/cotas.cf b/examples/taller/cotas.cf new file mode 100644 index 0000000..a83397e --- /dev/null +++ b/examples/taller/cotas.cf @@ -0,0 +1,30 @@ +[layer] +name = "cotas" +color = "#FF5050" + +# Diámetro exterior de la escalera — etiqueta grande, 1 decimal +[[dim]] +id = "dm-escalera" +from = [-1.6, 0.0] +to = [1.6, 0.0] +offset = -2.2 +text_size = 0.28 +precision = 1 + +# Ancho del engranaje — sin unidades, 3 decimales, letra pequeña +[[dim]] +id = "dm-engranaje" +from = [3.55, 0.0] +to = [6.45, 0.0] +offset = -2.2 +text_size = 0.16 +precision = 3 +show_units = false + +# Ancho total de la nave (geometría espejada: 0 → 5) +[[dim]] +id = "dm-nave" +from = [0.0, -4.6] +to = [5.0, -4.6] +offset = -0.6 +text_size = 0.22 diff --git a/examples/taller/engranaje.cf b/examples/taller/engranaje.cf new file mode 100644 index 0000000..740d8b0 --- /dev/null +++ b/examples/taller/engranaje.cf @@ -0,0 +1,43 @@ +[layer] +name = "engranaje" +color = "#50C8FF" +line_weight = 0.35 + +# Diente base; el array polar completa la corona de 16 dientes +[[polyline]] +id = "pl-diente" +points = [ + [6.193, -0.125], + [6.448, -0.076], + [6.448, 0.076], + [6.193, 0.125], +] +closed = true + +[[array]] +id = "ar-corona" +target = "pl-diente" +mode = "polar" +count = 16 +center = [5.0, 0.0] +step_angle = 22.5 + +# Círculo primitivo (referencia, discontinuo) +[[circle]] +id = "ci-primitivo" +center = [5.0, 0.0] +radius = 1.2 +style = "dashed" +weight = 0.18 + +# Cuerpo y agujero del eje +[[circle]] +id = "ci-cuerpo" +center = [5.0, 0.0] +radius = 1.05 +weight = 0.50 + +[[circle]] +id = "ci-eje" +center = [5.0, 0.0] +radius = 0.3 diff --git a/examples/taller/escalera.cf b/examples/taller/escalera.cf new file mode 100644 index 0000000..56456f3 --- /dev/null +++ b/examples/taller/escalera.cf @@ -0,0 +1,37 @@ +[layer] +name = "escalera" +color = "#FFFFFF" +line_weight = 0.35 + +# Columna central +[[circle]] +id = "ci-columna" +center = [0.0, 0.0] +radius = 0.25 + +# Perímetro exterior de la escalera +[[circle]] +id = "ci-borde" +center = [0.0, 0.0] +radius = 1.6 +weight = 0.50 + +# Huella base (un escalón); el array polar genera la helicoidal completa +[[polyline]] +id = "pl-huella" +points = [ + [0.30, 0.0], + [1.55, 0.0], + [1.456, 0.530], + [0.282, 0.103], +] +closed = true +weight = 0.25 + +[[array]] +id = "ar-helicoidal" +target = "pl-huella" +mode = "polar" +count = 16 +center = [0.0, 0.0] +step_angle = 22.5 diff --git a/examples/taller/planta.cf b/examples/taller/planta.cf new file mode 100644 index 0000000..0f3688f --- /dev/null +++ b/examples/taller/planta.cf @@ -0,0 +1,53 @@ +[layer] +name = "planta" +color = "#50FF50" +line_weight = 0.35 + +# Media nave del taller: el espejo completa la planta simétrica respecto a x = 2.5 +[[polyline]] +id = "pl-nave" +points = [ + [0.0, -4.6], + [2.5, -4.6], + [2.5, -2.4], + [0.6, -2.4], + [0.6, -3.0], + [0.0, -3.0], +] +closed = false +weight = 0.50 + +# Puerta con su abatimiento +[[line]] +id = "ln-puerta" +from = [0.9, -2.4] +to = [1.7, -2.4] +color = "#FFC850" + +[[arc]] +id = "ar-puerta" +center = [0.9, -2.4] +radius = 0.8 +from_angle = 0.0 +to_angle = 90.0 +color = "#FFC850" + +# Bancos de trabajo en serie (array lineal) +[[rect]] +id = "rc-banco" +origin = [0.3, -4.4] +width = 0.8 +height = 0.5 +color = "#C878FF" + +[[array]] +id = "ar-bancos" +target = "rc-banco" +mode = "linear" +count = 3 +offset = [1.3, 0.0] + +[[mirror]] +id = "mr-nave" +targets = ["pl-nave", "ln-puerta", "ar-puerta"] +axis = [[2.5, 0.0], [2.5, 1.0]] diff --git a/examples/taller/project.toml b/examples/taller/project.toml new file mode 100644 index 0000000..10537aa --- /dev/null +++ b/examples/taller/project.toml @@ -0,0 +1,10 @@ +[project] +name = "Taller — arrays, espejo y cotas" +scale = "1:50" +units = "m" + +[layers] +escalera = { file = "escalera.cf", locked = false } +engranaje = { file = "engranaje.cf", locked = false } +planta = { file = "planta.cf", locked = false } +cotas = { file = "cotas.cf", locked = false } diff --git a/examples/vivienda/achurados.cf b/examples/vivienda/achurados.cf index c1f6105..b8b2dc8 100644 --- a/examples/vivienda/achurados.cf +++ b/examples/vivienda/achurados.cf @@ -67,4 +67,4 @@ from = [0.0, 3.5] to = [0.0, 4.5] style = "dashed" weight = 0.18 -color = "#00CC44" \ No newline at end of file +color = "#00CC44" diff --git a/examples/vivienda/cotas.cf b/examples/vivienda/cotas.cf index 8a4a4dd..6a6dc70 100644 --- a/examples/vivienda/cotas.cf +++ b/examples/vivienda/cotas.cf @@ -3,7 +3,6 @@ name = "cotas" color = "#FF4444" # ── Cotas exteriores ──────────────────────────────────────── - [[dim]] id = "dm-ancho-total" type = "linear" @@ -19,7 +18,6 @@ to = [0.0, 9.0] offset = -1.2 # ── Cotas interiores ─────────────────────────────────────── - [[dim]] id = "dm-sala-ancho" type = "linear" @@ -63,7 +61,6 @@ to = [0.0, 5.0] offset = -0.6 # ── Ejes de referencia ────────────────────────────────────── - [[line]] id = "ln-eje-v" from = [6.0, -0.5] @@ -76,4 +73,4 @@ id = "ln-eje-h" from = [-0.5, 5.0] to = [12.5, 5.0] style = "dashdot" -color = "#00CCCC" \ No newline at end of file +color = "#00CCCC" diff --git a/examples/vivienda/mobiliario.cf b/examples/vivienda/mobiliario.cf index a8b8ca5..7105ccc 100644 --- a/examples/vivienda/mobiliario.cf +++ b/examples/vivienda/mobiliario.cf @@ -3,7 +3,6 @@ name = "mobiliario" color = "#4488FF" # ── Sala ───────────────────────────────────────────────────── - # Sofa [[rect]] id = "rc-sofa" @@ -36,7 +35,6 @@ radius = 0.3 color = "#66AAFF" # ── Dormitorio 1 ──────────────────────────────────────────── - # Cama doble [[rect]] id = "rc-cama-d1" @@ -62,7 +60,6 @@ height = 0.5 color = "#66AAFF" # ── Dormitorio 2 ──────────────────────────────────────────── - # Cama individual [[rect]] id = "rc-cama-d2" @@ -87,7 +84,6 @@ radius = 0.25 color = "#66AAFF" # ── Cocina ────────────────────────────────────────────────── - # Cocina (mesada) [[rect]] id = "rc-mesada" @@ -112,7 +108,6 @@ height = 0.7 color = "#AADDFF" # ── Bano ───────────────────────────────────────────────────── - # Inodoro [[circle]] id = "ci-inodoro" @@ -136,7 +131,6 @@ height = 0.8 color = "#88CCFF" # ── Etiquetas de ambientes ────────────────────────────────── - [[text]] id = "tx-sala" position = [7.5, 6.8] @@ -165,4 +159,4 @@ size = 0.22 id = "tx-bano" position = [0.5, 3.8] content = "BANO" -size = 0.18 \ No newline at end of file +size = 0.18 diff --git a/examples/vivienda/muros.cf b/examples/vivienda/muros.cf index 6f4d37f..3043dcc 100644 --- a/examples/vivienda/muros.cf +++ b/examples/vivienda/muros.cf @@ -86,4 +86,4 @@ id = "rc-closet-d1" origin = [0.0, 7.0] width = 1.2 height = 2.0 -weight = 0.15 \ No newline at end of file +weight = 0.15 diff --git a/examples/vivienda/puertas.cf b/examples/vivienda/puertas.cf index 5fb8b13..f200d78 100644 --- a/examples/vivienda/puertas.cf +++ b/examples/vivienda/puertas.cf @@ -75,4 +75,4 @@ id = "ln-puerta-cocina" from = [8.0, 5.0] to = [8.8, 5.0] weight = 0.12 -style = "dashed" \ No newline at end of file +style = "dashed" diff --git a/src/color.rs b/src/color.rs index 9c97039..ba1da47 100644 --- a/src/color.rs +++ b/src/color.rs @@ -16,6 +16,22 @@ pub fn hex_to_aci(hex: &str) -> u8 { } } +/// 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 + } +} + /// Convert hex color string to 24-bit integer for DXF true color. pub fn hex_to_24bit(hex: &str) -> i32 { let hex = hex.trim_start_matches('#'); @@ -39,6 +55,14 @@ mod tests { assert_eq!(hex_to_aci("#123456"), 7); // unknown → white } + #[test] + fn aci_roundtrips_standard_palette() { + for index in 1..=9u8 { + assert_eq!(hex_to_aci(aci_to_hex(index)), index); + } + assert_eq!(aci_to_hex(42), "#FFFFFF"); // unknown → white + } + #[test] fn hex_to_24bit_parses_correctly() { assert_eq!(hex_to_24bit("#FF0000"), 0xFF0000); diff --git a/src/compiler.rs b/src/compiler.rs index 359635c..48f9518 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -4,8 +4,10 @@ use crate::color::{hex_to_24bit, hex_to_aci, weight_to_dxf}; use crate::dxf_writer::{DxfWriter, EntityStyle}; use crate::model::{CfFile, CommonAttrs, LineStyle}; use crate::parser::{parse_cf, parse_project, LayerEntry, ProjectFile}; +use crate::transform::expand_cf; use anyhow::{bail, Context, Result}; use indexmap::IndexMap; +use serde::Serialize; use std::collections::HashSet; use std::path::Path; @@ -81,7 +83,7 @@ fn load_layers( for (name, entry) in layers { let cf_path = project_dir.join(&entry.file); let cf = parse_cf(&cf_path).with_context(|| format!("Failed to parse layer '{}'", name))?; - loaded.insert(name.clone(), cf); + loaded.insert(name.clone(), expand_cf(&cf)); } Ok(loaded) } @@ -444,7 +446,7 @@ pub fn list_layers(project_dir: &Path) -> Result<()> { for (name, entry) in &project.layers { let cf_path = project_dir.join(&entry.file); let (status, color) = if cf_path.exists() { - let cf = parse_cf(&cf_path)?; + let cf = expand_cf(&parse_cf(&cf_path)?); let count = entity_count(&cf); let col = cf .layer_meta @@ -464,6 +466,77 @@ pub fn list_layers(project_dir: &Path) -> Result<()> { Ok(()) } +// ── Machine-readable report (for AI agents / tooling) ────────────────── + +#[derive(Serialize)] +pub struct LayerReport { + pub name: String, + pub file: String, + pub entities: Option, + pub color: Option, + pub locked: bool, + pub missing: bool, +} + +#[derive(Serialize)] +pub struct ProjectReport { + pub name: String, + pub scale: String, + pub units: String, + pub strict: bool, + pub total_entities: usize, + pub layers: Vec, + pub issues: Vec, +} + +/// Build a structured validation report of the project (used by `--json` flags). +pub fn project_report(project_dir: &Path) -> Result { + let project = parse_project(&project_dir.join("project.toml"))?; + let mut loaded: IndexMap = IndexMap::new(); + let mut layers = Vec::with_capacity(project.layers.len()); + let mut total = 0usize; + + for (name, entry) in &project.layers { + let cf_path = project_dir.join(&entry.file); + if cf_path.exists() { + let cf = expand_cf( + &parse_cf(&cf_path).with_context(|| format!("Failed to parse layer '{}'", name))?, + ); + let count = entity_count(&cf); + total += count; + layers.push(LayerReport { + name: name.clone(), + file: entry.file.clone(), + entities: Some(count), + color: cf.layer_meta.as_ref().and_then(|m| m.color.clone()), + locked: entry.locked, + missing: false, + }); + loaded.insert(name.clone(), cf); + } else { + layers.push(LayerReport { + name: name.clone(), + file: entry.file.clone(), + entities: None, + color: None, + locked: entry.locked, + missing: true, + }); + } + } + + let issues = validate_constraints(&project, &loaded); + Ok(ProjectReport { + name: project.project.name.clone(), + scale: project.project.scale.clone(), + units: project.project.units.clone(), + strict: is_strict(&project), + total_entities: total, + layers, + issues, + }) +} + // ── Internal ──────────────────────────────────────────────────────────── fn entity_count(cf: &CfFile) -> usize { @@ -576,12 +649,19 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { for e in &cf.dims { let style = resolve_style(&e.common); + let dist = ((e.to[0] - e.from[0]).powi(2) + (e.to[1] - e.from[1]).powi(2)).sqrt(); + let label = + crate::svg::format_dim_label(dist, e.precision.unwrap_or(2) as usize, e.show_units, "") + .trim_end() + .to_string(); writer.dim_linear( e.from[0], e.from[1], e.to[0], e.to[1], e.offset, + &label, + e.text_size.unwrap_or(0.25), resolve_layer(&e.common, default_layer), &style, ); diff --git a/src/dxf_writer.rs b/src/dxf_writer.rs index 2d7d5dc..e3f4dd3 100644 --- a/src/dxf_writer.rs +++ b/src/dxf_writer.rs @@ -179,6 +179,7 @@ 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, @@ -187,31 +188,47 @@ impl DxfWriter { x2: f64, y2: f64, offset: f64, + label: &str, + text_height: f64, layer: &str, style: &EntityStyle, ) { + let dx = x2 - x1; + let dy = y2 - y1; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-9 { + return; + } + // Offset along the normal, matching the preview renderer + let (nx, ny) = (-dy / len, dx / len); + let (ax, ay) = (x1 + nx * offset, y1 + ny * offset); + let (bx, by) = (x2 + nx * offset, y2 + ny * offset); + let dim = dxf::entities::RotatedDimension { definition_point_2: Point::new(x1, y1, 0.0), definition_point_3: Point::new(x2, y2, 0.0), - insertion_point: Point::new((x1 + x2) / 2.0, y1 + offset, 0.0), + insertion_point: Point::new((ax + bx) / 2.0, (ay + by) / 2.0, 0.0), + rotation_angle: dy.atan2(dx).to_degrees(), ..Default::default() }; self.add_entity(EntityType::RotatedDimension(dim), layer, style); // Also emit dimension lines and text as explicit entities for compatibility - let dist = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt(); - let text_val = format!("{:.2}", dist); - let mid_x = (x1 + x2) / 2.0; - let mid_y = (y1 + y2) / 2.0 + offset; - // Extension lines - self.line(x1, y1, x1, y1 + offset, layer, style); - self.line(x2, y2, x2, y2 + offset, layer, style); + self.line(x1, y1, ax, ay, layer, style); + self.line(x2, y2, bx, by, layer, style); // Dimension line - self.line(x1, y1 + offset, x2, y2 + offset, layer, style); - // Dimension text + self.line(ax, ay, bx, by, layer, style); + // Dimension text, raised half its height off the dimension line let text_style = EntityStyle::default(); - self.text(mid_x, mid_y + 0.05, 0.1, &text_val, layer, &text_style); + self.text( + (ax + bx) / 2.0 + nx * text_height * 0.5, + (ay + by) / 2.0 + ny * text_height * 0.5, + text_height, + label, + layer, + &text_style, + ); } /// Save the drawing to a DXF file. diff --git a/src/fmt.rs b/src/fmt.rs index 70197f3..8f5bb4d 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -1,57 +1,282 @@ -//! Formatter — normalizes .cf files (sort keys, consistent spacing). +//! Formatter — normalizes `.cf` and `project.toml` files, analogous to +//! `terraform fmt`: canonical `key = value` spacing, exactly one blank line +//! between blocks, tidy arrays and inline tables. Comments are preserved. +//! Files that fail to parse — or whose formatted output would not reparse to +//! the same values — are left untouched. use crate::parser::parse_project; use anyhow::Result; -use std::path::Path; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use toml_edit::{Array, Decor, DocumentMut, InlineTable, Item, KeyMut, RawString, Table, Value}; -/// Format all .cf files in a project. +/// Format `project.toml` and every `.cf` file in a project. pub fn format_project(project_dir: &Path, check_only: bool) -> Result<()> { let project = parse_project(&project_dir.join("project.toml"))?; - let mut changed = 0; - for (_name, entry) in &project.layers { - let cf_path = project_dir.join(&entry.file); - if !cf_path.exists() { - continue; + let mut files: BTreeSet = BTreeSet::new(); + files.insert(project_dir.join("project.toml")); + for entry in project.layers.values() { + files.insert(project_dir.join(&entry.file)); + } + if let Ok(dir) = std::fs::read_dir(project_dir) { + for entry in dir.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "cf") { + files.insert(path); + } } + } - let original = std::fs::read_to_string(&cf_path)?; - let formatted = format_cf(&original); - - if original != formatted { - if check_only { - println!("✗ {} — needs formatting", entry.file); - changed += 1; - } else { - std::fs::write(&cf_path, &formatted)?; - println!("✓ {} — formatted", entry.file); - changed += 1; - } + let mut changed = 0; + for path in files { + if !path.exists() { + continue; + } + let display = path + .strip_prefix(project_dir) + .unwrap_or(&path) + .display() + .to_string(); + let original = std::fs::read_to_string(&path)?; + let formatted = format_source(&original); + if original == formatted { + println!(" {display} — ok"); + } else if check_only { + println!("✗ {display} — needs formatting"); + changed += 1; } else { - println!(" {} — ok", entry.file); + std::fs::write(&path, &formatted)?; + println!("✓ {display} — formatted"); + changed += 1; } } if check_only && changed > 0 { - anyhow::bail!( - "{} file(s) need formatting. Run `cadforge fmt` to fix.", - changed - ); + anyhow::bail!("{changed} file(s) need formatting. Run `cadforge fmt` to fix."); } - if !check_only { - println!("✓ {} file(s) formatted", changed); + println!("✓ {changed} file(s) formatted"); } Ok(()) } -/// Format a single .cf file content. -fn format_cf(content: &str) -> String { - let doc: toml_edit::DocumentMut = match content.parse() { - Ok(d) => d, - Err(_) => return content.to_string(), +/// Format TOML source, guaranteeing the result parses to the same values. +fn format_source(content: &str) -> String { + let formatted = format_toml(content); + let before: Result = toml::from_str(content); + let after: Result = toml::from_str(&formatted); + match (before, after) { + (Ok(b), Ok(a)) if b == a => formatted, + _ => content.to_string(), + } +} + +fn format_toml(content: &str) -> String { + let Ok(mut doc) = content.parse::() else { + return content.to_string(); }; - doc.to_string() + + let mut first = true; + for (mut key, item) in doc.as_table_mut().iter_mut() { + match item { + Item::Table(table) => { + normalize_header(table.decor_mut(), first); + normalize_table(table); + } + Item::ArrayOfTables(tables) => { + for table in tables.iter_mut() { + normalize_header(table.decor_mut(), first); + normalize_table(table); + first = false; + } + } + Item::Value(value) => { + normalize_key(&mut key); + normalize_value(value); + } + Item::None => {} + } + first = false; + } + + let trailing = comments_only(&raw_text(Some(doc.trailing())), true); + doc.set_trailing(trailing); + + let out = doc.to_string(); + let trimmed = out.trim_end_matches('\n'); + if trimmed.is_empty() { + out + } else { + format!("{trimmed}\n") + } +} + +fn normalize_table(table: &mut Table) { + for (mut key, item) in table.iter_mut() { + match item { + Item::Value(value) => { + normalize_key(&mut key); + normalize_value(value); + } + Item::Table(child) => { + if child.is_dotted() { + // dotted key (e.g. `cotas.parent = "muros"`): only touch values + normalize_dotted(child); + } else { + normalize_header(child.decor_mut(), false); + normalize_table(child); + } + } + Item::ArrayOfTables(tables) => { + for child in tables.iter_mut() { + normalize_header(child.decor_mut(), false); + normalize_table(child); + } + } + Item::None => {} + } + } +} + +fn normalize_dotted(table: &mut Table) { + for (_, item) in table.iter_mut() { + match item { + Item::Value(value) => normalize_value(value), + Item::Table(child) if child.is_dotted() => normalize_dotted(child), + _ => {} + } + } +} + +/// `[header]` / `[[header]]` prefix: one blank line between blocks (none for +/// the first), preceding comments preserved one per line. +fn normalize_header(decor: &mut Decor, first: bool) { + let prefix = comments_only(&raw_text(decor.prefix()), !first); + let suffix = trailing_comment(decor.suffix()); + decor.set_prefix(prefix); + decor.set_suffix(suffix); +} + +/// Key of a `key = value` pair: comments kept, a single leading blank line +/// kept if the author separated the pair from the previous one. +fn normalize_key(key: &mut KeyMut) { + let raw = raw_text(key.leaf_decor().prefix()); + let blank = raw.split('#').next().unwrap_or("").contains('\n'); + let prefix = comments_only(&raw, blank); + let decor = key.leaf_decor_mut(); + decor.set_prefix(prefix); + decor.set_suffix(" "); +} + +fn normalize_value(value: &mut Value) { + let suffix = trailing_comment(value.decor().suffix()); + match value { + Value::Array(array) => { + normalize_array(array); + } + Value::InlineTable(table) => { + normalize_inline_table(table); + } + _ => {} + } + let decor = value.decor_mut(); + decor.set_prefix(" "); + decor.set_suffix(suffix); +} + +/// Arrays keep the author's single-line vs multi-line choice; multi-line +/// arrays get one element per line, four-space indent and a trailing comma. +/// Arrays containing comments are left untouched. +fn normalize_array(array: &mut Array) { + if array_has_comment(array) { + return; + } + let multiline = raw_text(Some(array.trailing())).contains('\n') + || array.iter().any(|v| { + raw_text(v.decor().prefix()).contains('\n') + || raw_text(v.decor().suffix()).contains('\n') + }); + + if multiline { + for value in array.iter_mut() { + if let Value::Array(inner) = value { + normalize_inline_array(inner); + } + let decor = value.decor_mut(); + decor.set_prefix("\n "); + decor.set_suffix(""); + } + array.set_trailing("\n"); + array.set_trailing_comma(true); + } else { + normalize_inline_array(array); + } +} + +fn normalize_inline_array(array: &mut Array) { + if array_has_comment(array) { + return; + } + let mut first = true; + for value in array.iter_mut() { + if let Value::Array(inner) = value { + normalize_inline_array(inner); + } + let decor = value.decor_mut(); + decor.set_prefix(if first { "" } else { " " }); + decor.set_suffix(""); + first = false; + } + array.set_trailing(""); + array.set_trailing_comma(false); +} + +fn normalize_inline_table(table: &mut InlineTable) { + let len = table.len(); + for (i, (mut key, value)) in table.iter_mut().enumerate() { + let decor = key.leaf_decor_mut(); + decor.set_prefix(" "); + decor.set_suffix(" "); + let decor = value.decor_mut(); + decor.set_prefix(" "); + decor.set_suffix(if i + 1 == len { " " } else { "" }); + } +} + +fn array_has_comment(array: &Array) -> bool { + raw_text(Some(array.trailing())).contains('#') + || array.iter().any(|v| { + raw_text(v.decor().prefix()).contains('#') || raw_text(v.decor().suffix()).contains('#') + }) +} + +/// Extract `#` comment lines from raw decor text, optionally preceded by one +/// blank line; everything else (stray whitespace, extra blanks) is dropped. +fn comments_only(raw: &str, leading_blank: bool) -> String { + let mut out = String::new(); + if leading_blank { + out.push('\n'); + } + for line in raw.lines().map(str::trim).filter(|l| l.starts_with('#')) { + out.push_str(line); + out.push('\n'); + } + out +} + +/// Keep a same-line trailing comment (` # like this`), drop plain whitespace. +fn trailing_comment(raw: Option<&RawString>) -> String { + let text = raw_text(raw); + if text.contains('#') { + format!(" {}", text.trim()) + } else { + String::new() + } +} + +fn raw_text(raw: Option<&RawString>) -> String { + raw.and_then(|r| r.as_str()).unwrap_or("").to_string() } #[cfg(test)] @@ -59,17 +284,71 @@ mod tests { use super::*; #[test] - fn format_preserves_valid_toml() { - let input = "[layer]\nname = \"test\"\ncolor = \"#FFFFFF\"\n\n[[line]]\nid = \"ln-001\"\nfrom = [0.0, 0.0]\nto = [10.0, 0.0]\n"; - let output = format_cf(input); - assert!(output.contains("[layer]")); - assert!(output.contains("[[line]]")); + fn normalizes_spacing_and_blank_lines() { + let input = "[layer]\nname=\"test\"\ncolor = \"#FFFFFF\"\n\n\n\n[[line]]\nid=\"ln-001\"\nfrom=[0.0,0.0]\nto = [ 10.0 , 0.0 ]\n[[line]]\nid = \"ln-002\"\nfrom = [0.0, 1.0]\nto = [10.0, 1.0]\n"; + let expected = "[layer]\nname = \"test\"\ncolor = \"#FFFFFF\"\n\n[[line]]\nid = \"ln-001\"\nfrom = [0.0, 0.0]\nto = [10.0, 0.0]\n\n[[line]]\nid = \"ln-002\"\nfrom = [0.0, 1.0]\nto = [10.0, 1.0]\n"; + assert_eq!(format_source(input), expected); + } + + #[test] + fn preserves_comments() { + let input = "[layer]\nname = \"muros\"\n\n# Perímetro exterior\n[[polyline]]\nid = \"pl-001\" # principal\npoints = [[0.0, 0.0], [5.0, 0.0]]\n"; + let output = format_source(input); + assert!(output.contains("# Perímetro exterior\n[[polyline]]")); + assert!(output.contains("id = \"pl-001\" # principal")); + } + + #[test] + fn normalizes_multiline_point_arrays() { + let input = "[[polyline]]\nid = \"pl-001\"\npoints = [\n [0.30,0.0],\n [1.55, 0.0],\n [1.456, 0.530]\n]\nclosed = true\n"; + let expected = "[[polyline]]\nid = \"pl-001\"\npoints = [\n [0.30, 0.0],\n [1.55, 0.0],\n [1.456, 0.530],\n]\nclosed = true\n"; + assert_eq!(format_source(input), expected); + } + + #[test] + fn normalizes_inline_tables() { + let input = + "[project]\nname = \"x\"\n\n[layers]\nmuros = {file=\"muros.cf\",locked=false}\n"; + let output = format_source(input); + assert!(output.contains("muros = { file = \"muros.cf\", locked = false }")); + } + + #[test] + fn keeps_dotted_constraint_keys_intact() { + let input = "[constraints]\ncotas.parent = \"muros\"\ncotas.belongs_to = \"muros\"\n"; + let output = format_source(input); + assert!(output.contains("cotas.parent = \"muros\"")); + assert!(output.contains("cotas.belongs_to = \"muros\"")); + } + + #[test] + fn keeps_blank_line_groups_inside_blocks() { + let input = "[layer]\nname = \"x\"\n\n\ncolor = \"#FFFFFF\"\n"; + let output = format_source(input); + assert!(output.contains("name = \"x\"\n\ncolor")); + } + + #[test] + fn is_idempotent_and_meaning_preserving() { + for path in [ + concat!(env!("CARGO_MANIFEST_DIR"), "/examples/taller/escalera.cf"), + concat!(env!("CARGO_MANIFEST_DIR"), "/examples/taller/planta.cf"), + concat!(env!("CARGO_MANIFEST_DIR"), "/examples/taller/cotas.cf"), + concat!(env!("CARGO_MANIFEST_DIR"), "/examples/taller/project.toml"), + ] { + let original = std::fs::read_to_string(path).unwrap(); + let once = format_source(&original); + let twice = format_source(&once); + assert_eq!(once, twice, "fmt not idempotent for {path}"); + let before: toml::Value = toml::from_str(&original).unwrap(); + let after: toml::Value = toml::from_str(&once).unwrap(); + assert_eq!(before, after, "fmt changed meaning of {path}"); + } } #[test] - fn format_returns_original_on_parse_error() { + fn returns_original_on_parse_error() { let input = "invalid [[[ toml"; - let output = format_cf(input); - assert_eq!(output, input); + assert_eq!(format_source(input), input); } } diff --git a/src/importer.rs b/src/importer.rs index 88ff6a4..306b384 100644 --- a/src/importer.rs +++ b/src/importer.rs @@ -1,5 +1,6 @@ //! DXF importer — converts DXF layers/entities into CADforge `.cf` + `project.toml`. +use crate::color::aci_to_hex; use anyhow::{anyhow, Context, Result}; use dxf::entities::EntityType; use dxf::Drawing; @@ -7,23 +8,98 @@ 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)] -struct LayerFile { - entities: Vec, - counters: BTreeMap<&'static str, usize>, +struct StyleAttrs { + color: Option, + weight: Option, + line_style: Option<&'static str>, } -impl LayerFile { - fn next_id(&mut self, prefix: &'static str) -> String { - let n = self - .counters - .entry(prefix) - .and_modify(|v| *v += 1) - .or_insert(1); - format!("{prefix}-{n:03}") +impl StyleAttrs { + fn from_common(common: &dxf::entities::EntityCommon) -> Self { + 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()) + }; + let weight = (common.lineweight_enum_value > 0) + .then(|| f64::from(common.lineweight_enum_value) / 100.0); + let line_style = match common.line_type_name.to_ascii_uppercase().as_str() { + "DASHED" => Some("dashed"), + "DOTTED" => Some("dotted"), + "DASHDOT" => Some("dashdot"), + _ => None, + }; + StyleAttrs { + color, + weight, + line_style, + } + } + + fn emit(&self, out: &mut String) { + if let Some(c) = &self.color { + out.push_str(&format!("color = \"{}\"\n", c)); + } + if let Some(w) = self.weight { + out.push_str(&format!("weight = {}\n", w)); + } + if let Some(s) = self.line_style { + out.push_str(&format!("style = \"{}\"\n", s)); + } } } +enum Shape { + Line { + from: [f64; 2], + to: [f64; 2], + }, + Polyline { + points: Vec<[f64; 2]>, + closed: bool, + }, + Circle { + center: [f64; 2], + radius: f64, + }, + Arc { + center: [f64; 2], + radius: f64, + from_angle: f64, + to_angle: f64, + }, + Text { + position: [f64; 2], + content: String, + size: f64, + }, + Point { + position: [f64; 2], + }, + Dim { + from: [f64; 2], + to: [f64; 2], + offset: f64, + }, +} + +struct Imported { + shape: Shape, + style: StyleAttrs, +} + +#[derive(Default)] +struct LayerFile { + entities: Vec, + counters: BTreeMap<&'static str, usize>, +} + pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) -> Result<()> { if !input.exists() { return Err(anyhow!( @@ -36,10 +112,20 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - .with_context(|| format!("Cannot create output dir {}", output_dir.display()))?; let mut layers: BTreeMap = BTreeMap::new(); + let mut layer_colors: BTreeMap = BTreeMap::new(); let mut unsupported = 0usize; 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(), + ); + } + } + for entity in drawing.entities() { let layer_name = normalize_layer_name(&entity.common.layer); if let Some(filter) = layer_filter { @@ -48,100 +134,57 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - } } - let layer = layers.entry(layer_name.clone()).or_default(); - match &entity.specific { - EntityType::Line(e) => { - let id = layer.next_id("ln"); - layer.entities.push(format!( - "[[line]]\nid = \"{}\"\nfrom = [{}, {}]\nto = [{}, {}]\n", - id, - n(e.p1.x), - n(e.p1.y), - n(e.p2.x), - n(e.p2.y) - )); - } - EntityType::LwPolyline(e) => { - if e.vertices.len() >= 2 { - let id = layer.next_id("pl"); - let points = e - .vertices - .iter() - .map(|v| format!("[{}, {}]", n(v.x), n(v.y))) - .collect::>() - .join(", "); - layer.entities.push(format!( - "[[polyline]]\nid = \"{}\"\npoints = [{}]\nclosed = {}\n", - id, - points, - e.is_closed() - )); - } - } - EntityType::Circle(e) => { - let id = layer.next_id("ci"); - layer.entities.push(format!( - "[[circle]]\nid = \"{}\"\ncenter = [{}, {}]\nradius = {}\n", - id, - n(e.center.x), - n(e.center.y), - n(e.radius) - )); - } - EntityType::Arc(e) => { - let id = layer.next_id("ar"); - layer.entities.push(format!( - "[[arc]]\nid = \"{}\"\ncenter = [{}, {}]\nradius = {}\nfrom_angle = {}\nto_angle = {}\n", - id, - n(e.center.x), - n(e.center.y), - n(e.radius), - n(e.start_angle), - n(e.end_angle) - )); - } - EntityType::Text(e) => { - let id = layer.next_id("tx"); - layer.entities.push(format!( - "[[text]]\nid = \"{}\"\nposition = [{}, {}]\ncontent = \"{}\"\nsize = {}\n", - id, - n(e.location.x), - n(e.location.y), - escape_string(&e.value), - n(e.text_height.max(0.1)) - )); - } - EntityType::ModelPoint(e) => { - let id = layer.next_id("pt"); - layer.entities.push(format!( - "[[point]]\nid = \"{}\"\nposition = [{}, {}]\n", - id, - n(e.location.x), - n(e.location.y) - )); - } + let style = StyleAttrs::from_common(&entity.common); + let shape = match &entity.specific { + EntityType::Line(e) => Some(Shape::Line { + from: [e.p1.x, e.p1.y], + to: [e.p2.x, e.p2.y], + }), + EntityType::LwPolyline(e) => (e.vertices.len() >= 2).then(|| Shape::Polyline { + points: e.vertices.iter().map(|v| [v.x, v.y]).collect(), + closed: e.is_closed(), + }), + EntityType::Circle(e) => Some(Shape::Circle { + center: [e.center.x, e.center.y], + radius: e.radius, + }), + EntityType::Arc(e) => Some(Shape::Arc { + center: [e.center.x, e.center.y], + radius: e.radius, + from_angle: e.start_angle, + to_angle: e.end_angle, + }), + EntityType::Text(e) => Some(Shape::Text { + position: [e.location.x, e.location.y], + content: e.value.clone(), + size: e.text_height.max(0.1), + }), + EntityType::ModelPoint(e) => Some(Shape::Point { + position: [e.location.x, e.location.y], + }), EntityType::RotatedDimension(e) => { - let id = layer.next_id("dm"); - let from_x = e.definition_point_2.x; - let from_y = e.definition_point_2.y; - let to_x = e.definition_point_3.x; - let to_y = e.definition_point_3.y; - let offset = e.insertion_point.y - (from_y + to_y) / 2.0; - layer.entities.push(format!( - "[[dim]]\nid = \"{}\"\ntype = \"linear\"\nfrom = [{}, {}]\nto = [{}, {}]\noffset = {}\n", - id, - n(from_x), - n(from_y), - n(to_x), - n(to_y), - n(offset) - )); + let from = [e.definition_point_2.x, e.definition_point_2.y]; + let to = [e.definition_point_3.x, e.definition_point_3.y]; + dim_offset(from, to, [e.insertion_point.x, e.insertion_point.y]) + .map(|offset| Shape::Dim { from, to, offset }) } _ => { unsupported += 1; + None } + }; + if let Some(shape) = shape { + layers + .entry(layer_name) + .or_default() + .entities + .push(Imported { shape, style }); } } + + for layer in layers.values_mut() { + remove_dim_companions(&mut layer.entities); + } } Err(_) => { let content = fs::read_to_string(input) @@ -197,23 +240,42 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - ); let mut imported_layers = 0usize; - for (layer_name, layer_file) in &layers { + for (layer_name, layer_file) in &mut layers { imported_layers += 1; let file_name = format!("{}.cf", sanitize_for_filename(layer_name)); + let color = layer_colors + .get(layer_name) + .map(String::as_str) + .unwrap_or("#FFFFFF"); let mut cf = format!( - "[layer]\nname = \"{}\"\ncolor = \"#FFFFFF\"\n\n", - escape_string(layer_name) + "[layer]\nname = \"{}\"\ncolor = \"{}\"\n\n", + escape_string(layer_name), + color ); if layer_file.entities.is_empty() { cf.push_str("[[line]]\nfrom = [0.0, 0.0]\nto = [1.0, 0.0]\n"); } else { - for e in &layer_file.entities { - cf.push_str(e); + for entity in &layer_file.entities { + let (prefix, body) = emit_shape(&entity.shape); + let count = layer_file + .counters + .entry(prefix) + .and_modify(|v| *v += 1) + .or_insert(1); + cf.push_str(&format!( + "[[{}]]\nid = \"{prefix}-{count:03}\"\n", + header_for(prefix) + )); + cf.push_str(&body); + entity.style.emit(&mut cf); cf.push('\n'); } } - fs::write(output_dir.join(&file_name), cf) - .with_context(|| format!("Cannot write layer file {}", file_name))?; + fs::write( + output_dir.join(&file_name), + cf.trim_end().to_string() + "\n", + ) + .with_context(|| format!("Cannot write layer file {}", file_name))?; project_toml.push_str(&format!( "\"{}\" = {{ file = \"{}\", locked = false }}\n", escape_string(layer_name), @@ -235,6 +297,161 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - Ok(()) } +/// Recover the perpendicular dimension offset from the insertion point. +fn dim_offset(from: [f64; 2], to: [f64; 2], insertion: [f64; 2]) -> Option { + let dx = to[0] - from[0]; + let dy = to[1] - from[1]; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-9 { + return None; + } + let (nx, ny) = (-dy / len, dx / len); + let mid = [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0]; + Some((insertion[0] - mid[0]) * nx + (insertion[1] - mid[1]) * ny) +} + +/// Drop the extension/dimension lines and label text that `cadforge 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). +fn remove_dim_companions(entities: &mut Vec) { + const TOL: f64 = 1e-6; + let close = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < TOL && (a[1] - b[1]).abs() < TOL; + + let dims: Vec<([f64; 2], [f64; 2], f64)> = entities + .iter() + .filter_map(|e| match e.shape { + Shape::Dim { from, to, offset } => Some((from, to, offset)), + _ => None, + }) + .collect(); + + let mut keep = vec![true; entities.len()]; + for (from, to, offset) in dims { + let dx = to[0] - from[0]; + let dy = to[1] - from[1]; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-9 { + continue; + } + let (nx, ny) = (-dy / len, dx / len); + let a = [from[0] + nx * offset, from[1] + ny * offset]; + let b = [to[0] + nx * offset, to[1] + ny * offset]; + 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; + } + } + + let mut it = keep.into_iter(); + entities.retain(|_| it.next().unwrap_or(true)); +} + +/// Render a shape body (without `[[header]]`/`id`); returns (id prefix, body). +fn emit_shape(shape: &Shape) -> (&'static str, String) { + match shape { + Shape::Line { from, to } => ( + "ln", + format!( + "from = [{}, {}]\nto = [{}, {}]\n", + n(from[0]), + n(from[1]), + n(to[0]), + n(to[1]) + ), + ), + Shape::Polyline { points, closed } => { + let pts = points + .iter() + .map(|p| format!("[{}, {}]", n(p[0]), n(p[1]))) + .collect::>() + .join(", "); + ("pl", format!("points = [{}]\nclosed = {}\n", pts, closed)) + } + Shape::Circle { center, radius } => ( + "ci", + format!( + "center = [{}, {}]\nradius = {}\n", + n(center[0]), + n(center[1]), + n(*radius) + ), + ), + Shape::Arc { + center, + radius, + from_angle, + to_angle, + } => ( + "ar", + format!( + "center = [{}, {}]\nradius = {}\nfrom_angle = {}\nto_angle = {}\n", + n(center[0]), + n(center[1]), + n(*radius), + n(*from_angle), + n(*to_angle) + ), + ), + Shape::Text { + position, + content, + size, + } => ( + "tx", + format!( + "position = [{}, {}]\ncontent = \"{}\"\nsize = {}\n", + n(position[0]), + n(position[1]), + escape_string(content), + n(*size) + ), + ), + Shape::Point { position } => ( + "pt", + format!("position = [{}, {}]\n", n(position[0]), n(position[1])), + ), + Shape::Dim { from, to, offset } => ( + "dm", + format!( + "type = \"linear\"\nfrom = [{}, {}]\nto = [{}, {}]\noffset = {}\n", + n(from[0]), + n(from[1]), + n(to[0]), + n(to[1]), + n(*offset) + ), + ), + } +} + +fn header_for(prefix: &str) -> &'static str { + match prefix { + "ln" => "line", + "pl" => "polyline", + "ci" => "circle", + "ar" => "arc", + "tx" => "text", + "pt" => "point", + "dm" => "dim", + _ => "line", + } +} + fn n(v: f64) -> String { format!("{:.4}", v) } diff --git a/src/lib.rs b/src/lib.rs index 3c6a921..022de0a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,5 +12,9 @@ pub mod model; pub mod parser; pub mod preview; pub mod scaffold; +pub mod schema; +pub mod serve; +pub mod svg; +pub mod transform; pub mod viewer; pub mod watch; diff --git a/src/main.rs b/src/main.rs index cfd0516..bd4e412 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,15 @@ use anyhow::{bail, Result}; -use cadforge::compiler::{check_project, compile_project, list_layers}; +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; +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 clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use std::path::PathBuf; #[derive(Parser)] @@ -50,28 +52,54 @@ enum Commands { /// Project directory (defaults to current dir) #[arg(short, long)] path: Option, + /// Emit a machine-readable JSON report + #[arg(long)] + json: bool, }, /// List project layers with status Layers { /// Project directory (defaults to current dir) #[arg(short, long)] path: Option, + /// Emit a machine-readable JSON report + #[arg(long)] + json: bool, }, - /// Generate PNG preview + metadata JSON for AI agents + /// Generate preview (PNG + metadata JSON, or SVG) for AI agents Preview { /// Project directory (defaults to current dir) #[arg(short, long)] path: Option, /// Image width in pixels - #[arg(short, long, default_value = "2048")] + #[arg(short, long, default_value = "1600")] width: u32, - /// Image height in pixels - #[arg(short, long, default_value = "1536")] + /// Image height in pixels (PNG only; SVG derives it from content) + #[arg(short = 'H', long, default_value = "1200")] height: u32, /// Render only a specific layer #[arg(short, long)] layer: Option, + /// Output format + #[arg(short, long, value_enum, default_value_t = PreviewFormat::Png)] + format: PreviewFormat, + /// Highlight entities by id (comma-separated) with labeled markers + #[arg(long, value_delimiter = ',')] + highlight: Vec, }, + /// Live preview server — browser auto-reloads when .cf files change + Serve { + /// Project directory (defaults to current dir) + #[arg(short, long)] + path: Option, + /// Port to listen on + #[arg(long, default_value = "4377")] + port: u16, + /// Open the browser automatically + #[arg(long)] + open: bool, + }, + /// Print the .cf language reference (markdown, for humans and AI agents) + Schema, /// Format .cf files (sort keys, normalize whitespace) Fmt { /// Project directory (defaults to current dir) @@ -114,6 +142,16 @@ enum Commands { }, } +#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)] +enum PreviewFormat { + /// Raster PNG + preview.meta.json + Png, + /// Vector SVG (real text, dimensions, hatches) + Svg, + /// Both PNG and SVG + All, +} + #[derive(Subcommand)] enum ConfigCommands { /// Set global default value @@ -147,23 +185,55 @@ fn main() -> Result<()> { compile_project(&dir, layer.as_deref(), output.as_deref()) } } - Commands::Check { path } => { + Commands::Check { path, json } => { let dir = resolve_project_dir(path)?; - check_project(&dir)?; - Ok(()) + if json { + let report = project_report(&dir)?; + println!("{}", serde_json::to_string_pretty(&report)?); + if report.strict && !report.issues.is_empty() { + bail!( + "Check failed: {} constraint violation(s) with strict = true", + report.issues.len() + ); + } + Ok(()) + } else { + check_project(&dir)?; + Ok(()) + } } - Commands::Layers { path } => { + Commands::Layers { path, json } => { let dir = resolve_project_dir(path)?; - list_layers(&dir) + if json { + let report = project_report(&dir)?; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) + } else { + list_layers(&dir) + } } Commands::Preview { path, width, height, layer, + format, + highlight, } => { let dir = resolve_project_dir(path)?; - generate_preview(&dir, width, height, layer.as_deref()) + 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) + } + Commands::Serve { path, port, open } => { + let dir = resolve_project_dir(path)?; + serve_project(&dir, port, open) + } + Commands::Schema => { + print_schema(); + Ok(()) } Commands::Fmt { path, check } => { let dir = resolve_project_dir(path)?; diff --git a/src/model.rs b/src/model.rs index 0d6dd2e..16eb5ef 100644 --- a/src/model.rs +++ b/src/model.rs @@ -114,6 +114,12 @@ pub struct CfDim { pub to: [f64; 2], #[serde(default = "default_offset")] pub offset: f64, + /// Label height in world units (default 0.25). + pub text_size: Option, + /// Decimal places for the measured value (default 2). + pub precision: Option, + /// Append the project units to the label (default true). + pub show_units: Option, #[serde(flatten)] pub common: CommonAttrs, } @@ -160,6 +166,50 @@ pub struct CfGroup { pub common: CommonAttrs, } +#[derive(Debug, Clone, Copy, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ArrayMode { + Linear, + Polar, +} + +/// Repeats target primitives: linear (offset per copy) or polar (rotation +/// around a center — spiral stairs, gear teeth, radial columns). +#[derive(Debug, Clone, Deserialize)] +pub struct CfArray { + /// Single target id (alternative to `targets`). + pub target: Option, + /// Multiple target ids. + pub targets: Option>, + pub mode: ArrayMode, + /// Total number of instances, including the original. + pub count: usize, + /// Linear: displacement per copy. + pub offset: Option<[f64; 2]>, + /// Polar: rotation center. + pub center: Option<[f64; 2]>, + /// Polar: degrees per copy (counterclockwise). + pub step_angle: Option, + /// Polar: rotate each copy's geometry (true) or only orbit it (false). + #[serde(default = "default_true")] + pub rotate_items: bool, + #[serde(flatten)] + pub common: CommonAttrs, +} + +/// Mirrors target primitives across an axis defined by two points. +#[derive(Debug, Clone, Deserialize)] +pub struct CfMirror { + /// Single target id (alternative to `targets`). + pub target: Option, + /// Multiple target ids. + pub targets: Option>, + /// Mirror axis: two points [[x1, y1], [x2, y2]]. + pub axis: [[f64; 2]; 2], + #[serde(flatten)] + pub common: CommonAttrs, +} + #[derive(Debug, Clone, Deserialize)] pub struct CfFill { /// Reference to a closed polyline or rect id, or inline points. @@ -211,4 +261,8 @@ pub struct CfFile { pub fills: Vec, #[serde(default, rename = "group")] pub groups: Vec, + #[serde(default, rename = "array")] + pub arrays: Vec, + #[serde(default, rename = "mirror")] + pub mirrors: Vec, } diff --git a/src/preview.rs b/src/preview.rs index 370b225..da7f5ad 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -1,24 +1,22 @@ -//! Preview — renders project to PNG + metadata JSON for multimodal AI agents. - -use crate::compiler::resolve_boundary; -use crate::model::{CfFile, CommonAttrs}; -use crate::parser::{parse_cf, parse_project}; +//! Preview — rasterizes the SVG scene to PNG + metadata JSON for multimodal AI agents. +//! +//! 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 +//! entity to world and pixel bounding boxes so agents can locate geometry in +//! the image. + +use crate::model::CfFile; +use crate::svg::{ + enumerate_entities, layer_display_color, load_project_layers, render_scene_from, Scene, +}; use anyhow::{Context, Result}; +use resvg::{tiny_skia, usvg}; use serde::Serialize; use std::path::Path; -use tiny_skia::{Color, Paint, PathBuilder, Pixmap, Stroke, Transform}; - -// ── Configuration ─────────────────────────────────────────────────────── - -const PADDING: f64 = 0.5; // world units padding around content -const STROKE_WIDTH: f32 = 1.5; -const TEXT_MARKER: f64 = 0.05; - -fn bg_color() -> Color { - Color::from_rgba8(20, 20, 20, 255) -} +use std::sync::{Arc, OnceLock}; -// ── Bounds accumulator (DRY: one place to track min/max) ──────────────── +// ── Metadata structures (for the agent) ───────────────────────────────── #[derive(Serialize, Clone, Copy)] pub struct WorldBounds { @@ -28,43 +26,6 @@ pub struct WorldBounds { pub max_y: f64, } -impl WorldBounds { - fn empty() -> Self { - Self { - min_x: f64::MAX, - min_y: f64::MAX, - max_x: f64::MIN, - max_y: f64::MIN, - } - } - - fn add(&mut self, x: f64, y: f64) { - self.min_x = self.min_x.min(x); - self.min_y = self.min_y.min(y); - self.max_x = self.max_x.max(x); - self.max_y = self.max_y.max(y); - } - - fn is_empty(&self) -> bool { - self.min_x > self.max_x - } - - fn as_bbox(&self) -> [f64; 4] { - [self.min_x, self.min_y, self.max_x, self.max_y] - } -} - -/// Compute the bounding box of a set of points. -fn points_bounds(points: &[(f64, f64)]) -> WorldBounds { - let mut b = WorldBounds::empty(); - for &(x, y) in points { - b.add(x, y); - } - b -} - -// ── Metadata structures (for the agent) ───────────────────────────────── - #[derive(Serialize)] pub struct PreviewMeta { pub project_name: String, @@ -72,9 +33,12 @@ pub struct PreviewMeta { pub width_px: u32, pub height_px: u32, pub world_bounds: WorldBounds, + /// Pixels per world unit in the output image. pub scale: f64, + pub units: String, pub layers: Vec, pub entities: Vec, + pub highlighted: Vec, } #[derive(Serialize)] @@ -89,445 +53,182 @@ pub struct EntityInfo { pub id: Option, pub entity_type: String, pub layer: String, + /// Text content (only for `text` entities). + pub content: Option, pub bbox: [f64; 4], // [min_x, min_y, max_x, max_y] in world coords pub pixel_bbox: [u32; 4], // [x, y, w, h] in image coords } -// ── Renderer ──────────────────────────────────────────────────────────── - -struct Renderer { - pixmap: Pixmap, - scale: f64, - offset_x: f64, - offset_y: f64, - world_height: f64, -} - -impl Renderer { - fn new(width: u32, height: u32, bounds: &WorldBounds) -> Result { - let world_w = bounds.max_x - bounds.min_x + 2.0 * PADDING; - let world_h = bounds.max_y - bounds.min_y + 2.0 * PADDING; - - let scale = (width as f64 / world_w).min(height as f64 / world_h); - - let mut pixmap = Pixmap::new(width, height) - .ok_or_else(|| anyhow::anyhow!("Invalid image dimensions {}x{}", width, height))?; - pixmap.fill(bg_color()); - - Ok(Self { - pixmap, - scale, - offset_x: bounds.min_x - PADDING, - offset_y: bounds.min_y - PADDING, - world_height: world_h, - }) - } - - fn world_to_px(&self, x: f64, y: f64) -> (f32, f32) { - let px = ((x - self.offset_x) * self.scale) as f32; - // Flip Y: world Y goes up, pixel Y goes down - let py = ((self.world_height - (y - self.offset_y)) * self.scale) as f32; - (px, py) - } - - /// Single stroke entry point — all draw_* methods funnel through here (DRY). - fn stroke(&mut self, path: tiny_skia::Path, color: Color, width: f32) { - let mut paint = Paint::default(); - paint.set_color(color); - paint.anti_alias = true; - let stroke = Stroke { - width, - ..Default::default() - }; - self.pixmap - .stroke_path(&path, &paint, &stroke, Transform::identity(), None); - } - - fn draw_line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, color: Color, width: f32) { - let (px1, py1) = self.world_to_px(x1, y1); - let (px2, py2) = self.world_to_px(x2, y2); - let mut pb = PathBuilder::new(); - pb.move_to(px1, py1); - pb.line_to(px2, py2); - if let Some(path) = pb.finish() { - self.stroke(path, color, width); - } - } - - fn draw_circle(&mut self, cx: f64, cy: f64, radius: f64, color: Color, width: f32) { - let (pcx, pcy) = self.world_to_px(cx, cy); - let pr = (radius * self.scale) as f32; - let mut pb = PathBuilder::new(); - pb.push_circle(pcx, pcy, pr); - if let Some(path) = pb.finish() { - self.stroke(path, color, width); - } - } - - fn draw_arc(&mut self, arc: ArcSpec, color: Color, width: f32) { - const STEPS: usize = 32; - let start = arc.start_deg.to_radians(); - let delta = (arc.end_deg.to_radians() - start) / STEPS as f64; - - let mut pb = PathBuilder::new(); - for i in 0..=STEPS { - let angle = start + delta * i as f64; - let (px, py) = self.world_to_px( - arc.cx + arc.radius * angle.cos(), - arc.cy + arc.radius * angle.sin(), - ); - if i == 0 { - pb.move_to(px, py); - } else { - pb.line_to(px, py); - } - } - if let Some(path) = pb.finish() { - self.stroke(path, color, width); - } - } - - fn draw_polyline(&mut self, points: &[(f64, f64)], closed: bool, color: Color, width: f32) { - let Some((first, rest)) = points.split_first() else { - return; - }; - let mut pb = PathBuilder::new(); - let (px, py) = self.world_to_px(first.0, first.1); - pb.move_to(px, py); - for &(x, y) in rest { - let (px, py) = self.world_to_px(x, y); - pb.line_to(px, py); - } - if closed { - pb.close(); - } - if let Some(path) = pb.finish() { - self.stroke(path, color, width); - } - } - - fn fill_polygon(&mut self, points: &[(f64, f64)], color: Color) { - let Some((first, rest)) = points.split_first() else { - return; - }; - let mut pb = PathBuilder::new(); - let (px, py) = self.world_to_px(first.0, first.1); - pb.move_to(px, py); - for &(x, y) in rest { - let (px, py) = self.world_to_px(x, y); - pb.line_to(px, py); - } - pb.close(); - if let Some(path) = pb.finish() { - let mut paint = Paint::default(); - // Semi-transparent fill so underlying geometry stays visible - paint.set_color( - Color::from_rgba(color.red(), color.green(), color.blue(), 0.35).unwrap(), - ); - paint.anti_alias = true; - self.pixmap.fill_path( - &path, - &paint, - tiny_skia::FillRule::Winding, - Transform::identity(), - None, - ); - } - } - - fn save_png(&self, path: &Path) -> Result<()> { - self.pixmap - .save_png(path) - .map_err(|e| anyhow::anyhow!("Failed to save PNG: {}", e)) - } - - fn entity_info( - &self, - common: &CommonAttrs, - entity_type: &str, - layer: &str, - bounds: WorldBounds, - ) -> EntityInfo { - let (px1, py1) = self.world_to_px(bounds.min_x, bounds.max_y); // top-left - let (px2, py2) = self.world_to_px(bounds.max_x, bounds.min_y); // bottom-right - EntityInfo { - id: common.id.clone(), - entity_type: entity_type.to_string(), - layer: layer.to_string(), - bbox: bounds.as_bbox(), - pixel_bbox: [ - px1 as u32, - py1 as u32, - (px2 - px1) as u32, - (py2 - py1) as u32, - ], - } - } -} +// ── Public API ────────────────────────────────────────────────────────── +/// Which preview artifacts to write. #[derive(Clone, Copy)] -struct ArcSpec { - cx: f64, - cy: f64, - radius: f64, - start_deg: f64, - end_deg: f64, -} - -// ── Layer color mapping ───────────────────────────────────────────────── - -fn layer_color(index: usize) -> Color { - const PALETTE: &[(u8, u8, u8)] = &[ - (255, 255, 255), // white - (255, 80, 80), // red - (80, 255, 80), // green - (80, 200, 255), // cyan - (255, 200, 80), // yellow - (200, 120, 255), // purple - (255, 150, 50), // orange - ]; - let (r, g, b) = PALETTE[index % PALETTE.len()]; - Color::from_rgba8(r, g, b, 255) +pub struct PreviewOutputs { + /// Write `preview.png` + `preview.meta.json`. + pub png: bool, + /// Write `preview.svg`. + pub svg: bool, } -fn color_to_hex(c: Color) -> String { - format!( - "#{:02X}{:02X}{:02X}", - (c.red() * 255.0) as u8, - (c.green() * 255.0) as u8, - (c.blue() * 255.0) as u8, - ) -} - -// ── Public API ────────────────────────────────────────────────────────── - -/// Generate a preview PNG + metadata JSON for the project. +/// Generate preview artifacts (PNG + metadata JSON, and/or SVG) for the project. +/// +/// Everything is produced from a single parse + render pass. +/// `width`/`height` are treated as a bounding box: the image keeps the +/// project's aspect ratio and fits inside it. pub fn generate_preview( project_dir: &Path, width: u32, height: u32, layer_filter: Option<&str>, + highlight: &[String], + outputs: PreviewOutputs, ) -> Result<()> { - let project = parse_project(&project_dir.join("project.toml"))?; - - // Parse all layer files once - let layers: Vec<(String, CfFile)> = project - .layers - .iter() - .filter(|(name, _)| layer_filter.is_none_or(|f| f == *name)) - .map(|(name, entry)| { - let cf = parse_cf(&project_dir.join(&entry.file)) - .with_context(|| format!("Failed to parse layer '{}'", name))?; - Ok((name.clone(), cf)) - }) - .collect::>()?; - - let bounds = compute_bounds(&layers); - let mut renderer = Renderer::new(width, height, &bounds)?; - let mut entities: Vec = Vec::new(); - let mut layer_infos: Vec = Vec::new(); + let (project, layers) = load_project_layers(project_dir, layer_filter)?; + let scene = render_scene_from( + &project.project.name, + &project.project.units, + &layers, + width, + highlight, + ); + + 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: {}", svg_path.display()); + } - for (idx, (layer_name, cf)) in layers.iter().enumerate() { - let color = layer_color(idx); - let count = render_layer(&mut renderer, cf, layer_name, color, &mut entities); - layer_infos.push(LayerInfo { - name: layer_name.clone(), - entity_count: count, - color: color_to_hex(color), - }); + if !outputs.png { + return Ok(()); } - let png_path = project_dir.join("preview.png"); - renderer.save_png(&png_path)?; - - let meta = PreviewMeta { - project_name: project.project.name, - image_file: "preview.png".to_string(), - width_px: width, - height_px: height, - world_bounds: bounds, - scale: renderer.scale, - layers: layer_infos, - entities, - }; + // Fit the scene inside the requested width × height box. + 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))?; + + let meta = build_meta( + &project.project.name, + &project.project.units, + &layers, + &scene, + fit, + highlight, + &pixmap, + ); let json_path = project_dir.join("preview.meta.json"); std::fs::write(&json_path, serde_json::to_string_pretty(&meta)?)?; - println!("✓ Preview: {} ({}x{})", png_path.display(), width, height); + println!( + "✓ Preview: {} ({}x{})", + png_path.display(), + pixmap.width(), + pixmap.height() + ); println!("✓ Metadata: {}", json_path.display()); Ok(()) } -// ── Rendering per layer ────────────────────────────────────────────────── - -fn render_layer( - r: &mut Renderer, - cf: &CfFile, - layer: &str, - color: Color, - out: &mut Vec, -) -> usize { - let mut count = 0; - - for e in &cf.lines { - r.draw_line(e.from[0], e.from[1], e.to[0], e.to[1], color, STROKE_WIDTH); - let bounds = points_bounds(&[(e.from[0], e.from[1]), (e.to[0], e.to[1])]); - out.push(r.entity_info(&e.common, "line", layer, bounds)); - count += 1; - } - - for e in &cf.polylines { - let pts: Vec<(f64, f64)> = e.points.iter().map(|p| (p[0], p[1])).collect(); - r.draw_polyline(&pts, e.closed, color, STROKE_WIDTH); - out.push(r.entity_info(&e.common, "polyline", layer, points_bounds(&pts))); - count += 1; - } - - for e in &cf.rects { - let pts = rect_points(e.origin[0], e.origin[1], e.width, e.height); - r.draw_polyline(&pts, true, color, STROKE_WIDTH); - out.push(r.entity_info(&e.common, "rect", layer, points_bounds(&pts))); - count += 1; - } - - for e in &cf.circles { - r.draw_circle(e.center[0], e.center[1], e.radius, color, STROKE_WIDTH); - out.push(r.entity_info( - &e.common, - "circle", - layer, - circle_bounds(e.center, e.radius), - )); - count += 1; - } - - for e in &cf.arcs { - r.draw_arc( - ArcSpec { - cx: e.center[0], - cy: e.center[1], - radius: e.radius, - start_deg: e.from_angle, - end_deg: e.to_angle, - }, - color, - STROKE_WIDTH, - ); - out.push(r.entity_info(&e.common, "arc", layer, circle_bounds(e.center, e.radius))); - count += 1; - } - - for e in &cf.texts { - // Render text position as a small marker - r.draw_line( - e.position[0] - TEXT_MARKER, - e.position[1], - e.position[0] + TEXT_MARKER, - e.position[1], - color, - 1.0, - ); - let mut b = WorldBounds::empty(); - b.add(e.position[0], e.position[1]); - b.add(e.position[0] + 0.5, e.position[1] + 0.2); - out.push(r.entity_info(&e.common, "text", layer, b)); - count += 1; - } +// ── Internal ──────────────────────────────────────────────────────────── - // Solid fills — render as semi-transparent filled polygons - for e in &cf.fills { - let pts = fill_points(e, cf); - if let Some(pts) = pts { - r.fill_polygon(&pts, color); - out.push(r.entity_info(&e.common, "fill", layer, points_bounds(&pts))); - count += 1; - } - } +fn build_meta( + project_name: &str, + units: &str, + layers: &[(String, CfFile)], + scene: &Scene, + fit: f64, + highlight: &[String], + pixmap: &tiny_skia::Pixmap, +) -> PreviewMeta { + let mut entities = Vec::new(); + let mut layer_infos = Vec::new(); - // Hatches — render boundary outline (pattern detail omitted in preview) - for e in &cf.hatches { - if let Some(pts) = resolve_boundary(&e.boundary, cf) { - r.draw_polyline(&pts, true, color, 1.0); - out.push(r.entity_info(&e.common, "hatch", layer, points_bounds(&pts))); - count += 1; + for (idx, (layer_name, cf)) in layers.iter().enumerate() { + let records = enumerate_entities(cf); + layer_infos.push(LayerInfo { + name: layer_name.clone(), + entity_count: records.len(), + color: layer_display_color(cf, idx), + }); + for rec in records { + let (x1, y1) = scene.world_to_px(rec.bbox[0], rec.bbox[3]); // top-left + let (x2, y2) = scene.world_to_px(rec.bbox[2], rec.bbox[1]); // bottom-right + entities.push(EntityInfo { + id: rec.id, + entity_type: rec.kind.to_string(), + layer: layer_name.clone(), + content: rec.content, + bbox: rec.bbox, + pixel_bbox: [ + (x1 * fit) as u32, + (y1 * fit) as u32, + ((x2 - x1) * fit) as u32, + ((y2 - y1) * fit) as u32, + ], + }); } } - count -} - -/// Resolve a fill's geometry from inline points or a boundary reference. -fn fill_points(e: &crate::model::CfFill, cf: &CfFile) -> Option> { - if let Some(boundary_id) = &e.boundary { - resolve_boundary(boundary_id, cf) - } else { - e.points - .as_ref() - .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + PreviewMeta { + project_name: project_name.to_string(), + image_file: "preview.png".to_string(), + width_px: pixmap.width(), + height_px: pixmap.height(), + world_bounds: WorldBounds { + min_x: scene.world_bounds[0], + min_y: scene.world_bounds[1], + max_x: scene.world_bounds[2], + max_y: scene.world_bounds[3], + }, + scale: scene.px_per_unit * fit, + units: units.to_string(), + layers: layer_infos, + entities, + highlighted: highlight.to_vec(), } } -// ── Geometry helpers ───────────────────────────────────────────────────── - -fn rect_points(x: f64, y: f64, w: f64, h: f64) -> [(f64, f64); 4] { - [(x, y), (x + w, y), (x + w, y + h), (x, y + h)] -} - -fn circle_bounds(center: [f64; 2], radius: f64) -> WorldBounds { - let mut b = WorldBounds::empty(); - b.add(center[0] - radius, center[1] - radius); - b.add(center[0] + radius, center[1] + radius); - b +/// Embedded monospace font: zero font-scan latency and identical, deterministic +/// text rendering on any machine — including containers with no fonts at all. +const EMBEDDED_FONT: &[u8] = include_bytes!("../assets/fonts/DejaVuSansMono.ttf"); + +fn fontdb() -> Arc { + static FONTDB: OnceLock> = OnceLock::new(); + FONTDB + .get_or_init(|| { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(EMBEDDED_FONT.to_vec()); + db.set_monospace_family("DejaVu Sans Mono"); + Arc::new(db) + }) + .clone() } -fn compute_bounds(layers: &[(String, CfFile)]) -> WorldBounds { - let mut b = WorldBounds::empty(); - - for (_, cf) in layers { - for e in &cf.lines { - b.add(e.from[0], e.from[1]); - b.add(e.to[0], e.to[1]); - } - for e in &cf.polylines { - for p in &e.points { - b.add(p[0], p[1]); - } - } - for e in &cf.rects { - b.add(e.origin[0], e.origin[1]); - b.add(e.origin[0] + e.width, e.origin[1] + e.height); - } - for e in &cf.circles { - b.add(e.center[0] - e.radius, e.center[1] - e.radius); - b.add(e.center[0] + e.radius, e.center[1] + e.radius); - } - for e in &cf.arcs { - b.add(e.center[0] - e.radius, e.center[1] - e.radius); - b.add(e.center[0] + e.radius, e.center[1] + e.radius); - } - for e in &cf.texts { - b.add(e.position[0], e.position[1]); - } - for e in &cf.fills { - if let Some(points) = &e.points { - for p in points { - b.add(p[0], p[1]); - } - } - } - } +/// Rasterize an SVG string at the given scale factor. +fn rasterize(svg: &str, scale: f32) -> Result { + let opt = usvg::Options { + fontdb: fontdb(), + ..Default::default() + }; - if b.is_empty() { - WorldBounds { - min_x: 0.0, - min_y: 0.0, - max_x: 10.0, - max_y: 10.0, - } - } else { - b - } + let tree = usvg::Tree::from_str(svg, &opt).context("Failed to parse generated SVG")?; + let size = tree.size(); + let w = ((size.width() * scale).ceil() as u32).max(1); + let h = ((size.height() * scale).ceil() as u32).max(1); + + let mut pixmap = tiny_skia::Pixmap::new(w, h) + .ok_or_else(|| anyhow::anyhow!("Invalid image dimensions {}x{}", w, h))?; + resvg::render( + &tree, + tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + Ok(pixmap) } #[cfg(test)] @@ -535,23 +236,21 @@ mod tests { use super::*; #[test] - fn bounds_accumulates_correctly() { - let mut b = WorldBounds::empty(); - assert!(b.is_empty()); - b.add(1.0, 2.0); - b.add(5.0, -1.0); - assert_eq!(b.as_bbox(), [1.0, -1.0, 5.0, 2.0]); - assert!(!b.is_empty()); - } + fn rasterize_produces_scaled_pixmap() { + let svg = r##""##; + let pixmap = rasterize(svg, 1.0).unwrap(); + assert_eq!((pixmap.width(), pixmap.height()), (200, 100)); - #[test] - fn circle_bounds_is_square() { - let b = circle_bounds([5.0, 5.0], 2.0); - assert_eq!(b.as_bbox(), [3.0, 3.0, 7.0, 7.0]); + let half = rasterize(svg, 0.5).unwrap(); + assert_eq!((half.width(), half.height()), (100, 50)); + + // Background must be painted (not transparent) + let px = pixmap.pixel(5, 50).unwrap(); + assert!(px.alpha() == 255); } #[test] - fn color_to_hex_formats() { - assert_eq!(color_to_hex(Color::from_rgba8(255, 0, 128, 255)), "#FF0080"); + fn rasterize_rejects_invalid_svg() { + assert!(rasterize("not an svg", 1.0).is_err()); } } diff --git a/src/scaffold.rs b/src/scaffold.rs index afc05e5..158c0a7 100644 --- a/src/scaffold.rs +++ b/src/scaffold.rs @@ -21,7 +21,12 @@ pub fn create_project(name: &str, parent: &Path) -> Result<()> { println!(" → mobiliario.cf"); println!(" → cotas.cf"); println!(" → .gitignore"); - println!("\n Run `cadforge build --path {}` to compile.", name); + println!( + "\n Run `cadforge 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."); Ok(()) } @@ -44,6 +49,8 @@ pub fn init_project(dir: &Path) -> Result<()> { println!(" → mobiliario.cf"); println!(" → cotas.cf"); println!(" → .gitignore"); + println!("\n Run `cadforge serve` for a live preview."); + println!(" `cadforge schema` prints the .cf language reference."); Ok(()) } @@ -63,7 +70,7 @@ cotas = {{ file = "cotas.cf", locked = false }} ); fs::write(project_dir.join("project.toml"), project_toml)?; - let gitignore = "# CADforge output\noutput.dxf\npreview.png\npreview.meta.json\n\n# Rust build artifacts\ntarget/\n"; + let gitignore = "# CADforge output\noutput.dxf\npreview.png\npreview.svg\npreview.meta.json\n\n# Rust build artifacts\ntarget/\n"; fs::write(project_dir.join(".gitignore"), gitignore)?; let muros_cf = r##"[layer] @@ -199,6 +206,7 @@ mod tests { 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("AGENTS.md").exists()); assert!(project_dir.join(".gitignore").exists()); let content = fs::read_to_string(project_dir.join("project.toml")).unwrap(); @@ -207,6 +215,7 @@ mod tests { let gitignore = fs::read_to_string(project_dir.join(".gitignore")).unwrap(); assert!(gitignore.contains("output.dxf")); + assert!(gitignore.contains("preview.svg")); assert!(gitignore.contains("target/")); let _ = fs::remove_dir_all(&tmp); diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..dc5e0a1 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,226 @@ +//! Schema — the `.cf` language reference, printable via `cadforge 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 + +CADforge 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. + +## project.toml + +```toml +[project] +name = "Vivienda Lote 12" +scale = "1:100" # drawing scale (metadata) +units = "m" # unit label used in dimension labels +strict = false # true: constraint violations fail the build + +[layers] # order defines draw order (later = on top) +muros = { file = "muros.cf", locked = false } +puertas = { file = "puertas.cf", locked = false } + +[constraints] # optional, validated on build/check +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) +``` + +## Layer files (`.cf`) + +Each `.cf` may start with optional layer metadata: + +```toml +[layer] +name = "muros" +color = "#FFFFFF" # default color for the layer +line_weight = 0.35 # default stroke weight in mm +visible = true +locked = false +``` + +### Common attributes (valid on every primitive, all optional) + +```toml +id = "ln-001" # unique within the layer; needed for hatch boundaries / belongs_to +color = "#FF5050" # overrides layer color +weight = 0.50 # line weight in mm (0.13 thin … 0.70 thick) +style = "solid" # solid | dashed | dotted | dashdot +visible = true +locked = false +belongs_to = "id" # reference to a primitive id in the parent layer +``` + +### Primitives + +```toml +[[line]] # straight segment +from = [0.0, 0.0] +to = [8.5, 0.0] + +[[polyline]] # multi-vertex path; closed = true makes it a polygon +points = [[0.0, 0.0], [8.5, 0.0], [8.5, 6.0], [0.0, 6.0]] +closed = true + +[[rect]] # axis-aligned rectangle from bottom-left origin +origin = [1.0, 1.0] +width = 3.5 +height = 4.0 + +[[circle]] +center = [4.0, 3.0] +radius = 0.5 + +[[arc]] # angles in degrees, counterclockwise from +X +center = [2.0, 2.0] +radius = 0.9 +from_angle = 0.0 +to_angle = 90.0 + +[[text]] +position = [4.0, 3.0] +content = "SALA" +size = 0.25 # text height in world units +align = "center" # left | center | right + +[[point]] # reference marker (drawn as a cross) +position = [3.0, 3.0] + +[[dim]] # dimension; the measured distance is labeled automatically +type = "linear" # linear | angular | radial +from = [0.0, 0.0] +to = [8.5, 0.0] +offset = -0.8 # distance from the measured element (sign = side) +text_size = 0.25 # label height in world units (optional) +precision = 2 # decimals in the measured value (default 2) +show_units = true # append project units to the label (default true) + +[[hatch]] # pattern fill inside a closed boundary +boundary = "pl-001" # id of a closed polyline or rect in the same file +pattern = "ansi31" # ansi31 | ansi32 | ansi33 | ansi34 | solid | none +scale = 1.0 +angle = 45.0 + +[[fill]] # solid fill; boundary id or inline points +points = [[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [0.0, 3.0]] +color = "#808080" + +[[group]] # logical grouping of primitives by id +members = ["ln-001", "rc-001"] +``` + +### Construction tools + +Arrays and mirrors expand into concrete primitives at build time. Copies get +derived ids — `pl-001@1`, `pl-001@2`, … (array) and `pl-001@m` (mirror) — so +they can be highlighted or referenced. Edit the base entity or the +`[[array]]`/`[[mirror]]` block to change all copies at once. + +```toml +[[array]] # repeat targets in a line or around a center +target = "pl-huella" # or targets = ["id-a", "id-b"] +mode = "polar" # polar: spiral stairs, gear teeth, radial columns +count = 16 # total instances, including the original +center = [0.0, 0.0] # polar: rotation center +step_angle = 22.5 # polar: degrees per copy, counterclockwise +rotate_items = true # polar: false = orbit only, keep orientation + +[[array]] +target = "rc-banco" +mode = "linear" # linear: equally spaced series +count = 3 +offset = [1.3, 0.0] # displacement per copy + +[[mirror]] # mirror targets across an axis (two points) +targets = ["pl-nave", "ar-puerta"] +axis = [[2.5, 0.0], [2.5, 1.0]] +``` + +## Conventions + +- Coordinates are world units (see `units`), Y grows upward, origin at [0, 0]. +- Use floats (`8.5`, `0.0`) for all coordinates. +- Give every primitive a short prefixed id: `ln-` lines, `pl-` polylines, + `rc-` rects, `ci-` circles, `ar-` arcs, `tx-` text, `dm-` dims, `ht-` hatches. +- Walls are typically `weight = 0.50`, furniture `0.25`, annotations `0.18`. + +## 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 +``` + +The feedback loop for agents: edit `.cf` → run `cadforge check --json` to +validate → run `cadforge 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 +where intended (highlighted entities get labeled amber markers). + +For humans, `cadforge 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. +"##; + +/// Print the `.cf` language reference to stdout. +pub fn print_schema() { + println!("{}", CF_REFERENCE); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reference_covers_all_primitives() { + for primitive in [ + "[[line]]", + "[[polyline]]", + "[[rect]]", + "[[circle]]", + "[[arc]]", + "[[text]]", + "[[point]]", + "[[dim]]", + "[[hatch]]", + "[[fill]]", + "[[group]]", + "[[array]]", + "[[mirror]]", + ] { + assert!(CF_REFERENCE.contains(primitive), "missing {}", primitive); + } + } + + #[test] + fn reference_examples_are_valid_toml() { + // Every fenced toml block in the reference must parse. + let mut in_block = false; + let mut block = String::new(); + for line in CF_REFERENCE.lines() { + if line.starts_with("```toml") { + in_block = true; + block.clear(); + } else if line.starts_with("```") && in_block { + in_block = false; + let parsed: Result = toml::from_str(&block); + assert!(parsed.is_ok(), "invalid TOML block:\n{}", block); + } else if in_block { + block.push_str(line); + block.push('\n'); + } + } + } +} diff --git a/src/serve.rs b/src/serve.rs new file mode 100644 index 0000000..27d86cf --- /dev/null +++ b/src/serve.rs @@ -0,0 +1,849 @@ +//! Live preview server — `cadforge 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 +//! browser refreshes instantly, build errors show as an overlay. +//! +//! 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. +//! +//! Plain `std::net` HTTP — this is a localhost dev server, no framework needed. + +use crate::parser::parse_project; +use crate::svg::{layer_display_color, load_project_layers, render_scene_from}; +use anyhow::{Context, Result}; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +const SVG_WIDTH: u32 = 1600; +const DEBOUNCE: Duration = Duration::from_millis(80); +const SSE_KEEPALIVE: Duration = Duration::from_secs(15); + +struct LiveState { + /// Arc so request handlers serve the SVG without copying it. + svg: Arc, + error: Option, + version: u64, + project_name: String, + /// (name, color) per layer, for the layer panel. + layers: Vec<(String, String)>, +} + +/// Shared state plus a condvar so SSE clients are woken the instant a rebuild +/// lands, instead of polling. +struct Live { + state: Mutex, + changed: Condvar, + project_dir: PathBuf, +} + +type Shared = Arc; + +/// Start the live preview server (blocks until killed). +pub fn serve_project(project_dir: &Path, port: u16, open: bool) -> Result<()> { + let project = parse_project(&project_dir.join("project.toml"))?; + let project_dir = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.to_path_buf()); + + let state: Shared = Arc::new(Live { + state: Mutex::new(LiveState { + svg: Arc::new(String::new()), + error: None, + version: 0, + project_name: project.project.name.clone(), + layers: Vec::new(), + }), + changed: Condvar::new(), + project_dir: project_dir.clone(), + }); + + rebuild(&project_dir, &state); + + let listener = TcpListener::bind(("127.0.0.1", port)) + .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!(" Preview: {}", url); + println!(" Watching: {}", project_dir.display()); + println!(); + println!(" Edit .cf files — the browser updates automatically."); + println!(" Click an entity in the viewer to inspect/copy its TOML."); + println!(" Press Ctrl+C to stop."); + + spawn_watcher(project_dir.clone(), Arc::clone(&state))?; + + if open { + open_browser(&url); + } + + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let state = Arc::clone(&state); + std::thread::spawn(move || { + let _ = handle_connection(stream, &state); + }); + } + Ok(()) +} + +fn rebuild(project_dir: &Path, state: &Shared) { + let result = (|| -> Result<(String, Vec<(String, String)>)> { + let (project, layers) = load_project_layers(project_dir, None)?; + let scene = render_scene_from( + &project.project.name, + &project.project.units, + &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 mut st = state.state.lock().unwrap(); + match result { + Ok((svg, layers)) => { + st.svg = Arc::new(svg); + st.layers = layers; + st.error = None; + } + Err(e) => { + st.error = Some(format!("{:#}", e)); + } + } + st.version += 1; + drop(st); + state.changed.notify_all(); +} + +fn spawn_watcher(project_dir: PathBuf, state: Shared) -> Result<()> { + let (tx, rx) = mpsc::channel(); + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + if let Ok(event) = res { + let _ = tx.send(event); + } + }, + notify::Config::default(), + )?; + watcher.watch(&project_dir, RecursiveMode::NonRecursive)?; + + std::thread::spawn(move || { + // Keep the watcher alive inside the thread. + let _watcher = watcher; + while let Ok(event) = rx.recv() { + if !is_relevant(&event) { + continue; + } + // Debounce: absorb the burst of events an editor save produces. + std::thread::sleep(DEBOUNCE); + while rx.try_recv().is_ok() {} + + rebuild(&project_dir, &state); + let st = state.state.lock().unwrap(); + match &st.error { + None => println!("⟳ rebuilt (v{})", st.version), + Some(e) => println!("✗ build error (v{}): {}", st.version, e), + } + } + }); + Ok(()) +} + +fn is_relevant(event: &Event) -> bool { + match event.kind { + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {} + _ => return false, + } + event.paths.iter().any(|p| { + p.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e == "cf" || e == "toml") + }) +} + +// ── HTTP ──────────────────────────────────────────────────────────────── + +fn handle_connection(stream: TcpStream, state: &Shared) -> std::io::Result<()> { + // Small localhost responses: Nagle's algorithm only adds latency here. + let _ = stream.set_nodelay(true); + let mut reader = BufReader::new(stream.try_clone()?); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + + let target = request_line.split_whitespace().nth(1).unwrap_or("/"); + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p, q), + None => (target, ""), + }; + + match path { + "/" => { + let html = index_html(&state.state.lock().unwrap().project_name); + respond( + stream, + "200 OK", + "text/html; charset=utf-8", + html.as_bytes(), + ) + } + "/preview.svg" => { + let svg = Arc::clone(&state.state.lock().unwrap().svg); + respond(stream, "200 OK", "image/svg+xml", svg.as_bytes()) + } + "/state" => { + let st = state.state.lock().unwrap(); + let layers: Vec<_> = st + .layers + .iter() + .map(|(name, color)| serde_json::json!({"name": name, "color": color})) + .collect(); + let body = serde_json::json!({ + "version": st.version, + "project": st.project_name, + "error": st.error, + "layers": layers, + }) + .to_string(); + drop(st); + respond(stream, "200 OK", "application/json", body.as_bytes()) + } + "/entity" => { + let id = query_param(query, "id").unwrap_or_default(); + let body = entity_block_json(&state.project_dir, &id); + respond(stream, "200 OK", "application/json", body.as_bytes()) + } + "/events" => serve_events(stream, state), + _ => respond(stream, "404 Not Found", "text/plain", b"not found"), + } +} + +fn query_param(query: &str, key: &str) -> Option { + query.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=')?; + (k == key).then(|| percent_decode(v)) + }) +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'%' if i + 2 < bytes.len() => { + if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) { + out.push(h * 16 + l); + i += 3; + } else { + out.push(b'%'); + i += 1; + } + } + b'+' => { + out.push(b' '); + i += 1; + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// Find the raw TOML block that defines `id` and return it as JSON, so the +/// viewer can hand an agent the exact source to edit. +fn entity_block_json(project_dir: &Path, id: &str) -> String { + // Generated copies (array/mirror) carry an @ suffix; their source is the base id. + let base = id.split('@').next().unwrap_or(id); + + let lookup = || -> Option<(String, String, String)> { + let project = parse_project(&project_dir.join("project.toml")).ok()?; + for (layer, entry) in &project.layers { + let path = project_dir.join(&entry.file); + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if let Some(block) = find_block(&text, base) { + return Some((layer.clone(), entry.file.clone(), block)); + } + } + None + }; + + match lookup() { + Some((layer, file, block)) => serde_json::json!({ + "id": id, + "base_id": base, + "generated": id != base, + "layer": layer, + "file": file, + "block": block, + }) + .to_string(), + None => serde_json::json!({ "id": id, "error": "not found" }).to_string(), + } +} + +/// Extract the `[[...]]` block (with leading comments) that contains `id = ""`. +/// +/// Uses toml_edit spans, so multi-line values (e.g. `points = [` …) are kept +/// intact instead of being cut at lines that merely look like TOML headers. +fn find_block(text: &str, id: &str) -> Option { + let doc = toml_edit::ImDocument::parse(text).ok()?; + let mut span: Option> = None; + for (_key, item) in doc.iter() { + if let toml_edit::Item::ArrayOfTables(tables) = item { + for table in tables.iter() { + if table.get("id").and_then(|v| v.as_str()) == Some(id) { + span = table.span(); + } + } + } + } + let span = span?; + + let lines: Vec<&str> = text.lines().collect(); + let span_start_line = text[..span.start.min(text.len())].matches('\n').count(); + let span_end_line = text[..span.end.min(text.len())] + .matches('\n') + .count() + .min(lines.len().saturating_sub(1)); + + // The span covers the key/value pairs; step back to the [[header]] line + // and pull in any comment lines directly above it. + let header = lines[..=span_start_line.min(lines.len().saturating_sub(1))] + .iter() + .rposition(|l| l.trim_start().starts_with("[["))?; + let mut start = header; + while start > 0 && lines[start - 1].trim_start().starts_with('#') { + start -= 1; + } + + Some( + lines[start..=span_end_line] + .join("\n") + .trim_end() + .to_string(), + ) +} + +fn respond( + mut stream: TcpStream, + status: &str, + content_type: &str, + body: &[u8], +) -> std::io::Result<()> { + write!( + stream, + "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", + status, + content_type, + body.len() + )?; + stream.write_all(body)?; + stream.flush() +} + +/// Server-sent events: the condvar wakes us the instant a rebuild lands, so +/// the browser is notified with sub-millisecond latency instead of polling. +fn serve_events(mut stream: TcpStream, state: &Shared) -> std::io::Result<()> { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\nConnection: keep-alive\r\n\r\n" + )?; + stream.flush()?; + + let mut last = 0u64; + loop { + let current = { + let mut st = state.state.lock().unwrap(); + while st.version == last { + let (guard, timeout) = state + .changed + .wait_timeout(st, SSE_KEEPALIVE) + .map_err(|_| std::io::Error::other("state poisoned"))?; + st = guard; + if timeout.timed_out() && st.version == last { + drop(st); + // Keep-alive comment so dead clients are detected. + write!(stream, ": ping\n\n")?; + stream.flush()?; + st = state.state.lock().unwrap(); + } + } + st.version + }; + last = current; + write!(stream, "data: {}\n\n", current)?; + stream.flush()?; + } +} + +fn open_browser(url: &str) { + let result = if cfg!(target_os = "macos") { + std::process::Command::new("open").arg(url).spawn() + } else if cfg!(target_os = "windows") { + std::process::Command::new("cmd") + .args(["/C", "start", url]) + .spawn() + } else { + std::process::Command::new("xdg-open").arg(url).spawn() + }; + if result.is_err() { + println!(" (could not open browser automatically)"); + } +} + +// ── Frontend ──────────────────────────────────────────────────────────── + +fn index_html(project_name: &str) -> String { + INDEX_HTML.replace("{{PROJECT_NAME}}", &html_escape(project_name)) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +const INDEX_HTML: &str = r##" + + + +{{PROJECT_NAME}} — cadforge live + + + +
+ + {{PROJECT_NAME}} + cadforge live + v0 + + + edit .cf files — preview updates automatically +
+
+ +
+
+

+  
+ +
+
+
click: inspect entity · scroll: zoom · drag: pan · double-click: fit · 1-9: cycle layer (on/ghost/off) · 3: 3D · Esc: deselect
+ + + +"##; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn index_html_injects_project_name() { + let html = index_html("Casa "); + assert!(html.contains("Casa <Lote 12>")); + assert!(!html.contains("{{PROJECT_NAME}}")); + } + + #[test] + fn relevant_events_filter_by_extension() { + use notify::event::{CreateKind, EventAttributes}; + let mut event = Event { + kind: EventKind::Create(CreateKind::File), + paths: vec![PathBuf::from("/p/muros.cf")], + attrs: EventAttributes::new(), + }; + assert!(is_relevant(&event)); + event.paths = vec![PathBuf::from("/p/output.dxf")]; + assert!(!is_relevant(&event)); + event.paths = vec![PathBuf::from("/p/preview.svg")]; + assert!(!is_relevant(&event)); + } + + #[test] + fn find_block_extracts_entity_with_comments() { + let text = r#"[layer] +name = "muros" + +# Puerta principal +[[arc]] +id = "ar-puerta" +center = [0.0, 2.5] +radius = 0.9 + +[[line]] +id = "ln-otro" +from = [0.0, 0.0] +to = [1.0, 0.0] +"#; + let block = find_block(text, "ar-puerta").unwrap(); + assert!(block.starts_with("# Puerta principal")); + assert!(block.contains("[[arc]]")); + assert!(block.contains("radius = 0.9")); + assert!(!block.contains("ln-otro")); + + let last = find_block(text, "ln-otro").unwrap(); + assert!(last.contains("[[line]]")); + assert!(last.contains("to = [1.0, 0.0]")); + + assert!(find_block(text, "missing").is_none()); + } + + #[test] + fn find_block_keeps_multiline_arrays_intact() { + let text = r#"[[polyline]] +id = "pl-huella" +points = [ + [0.30, 0.0], + [1.55, 0.0], +] +closed = true + +[[circle]] +id = "ci-otro" +center = [0.0, 0.0] +radius = 1.0 +"#; + let block = find_block(text, "pl-huella").unwrap(); + assert!(block.contains("[1.55, 0.0],"), "block: {}", block); + assert!(block.contains("closed = true"), "block: {}", block); + assert!(!block.contains("ci-otro")); + } + + #[test] + fn find_block_does_not_match_belongs_to() { + let text = r#"[[rect]] +id = "real" +belongs_to = "fake" +width = 1.0 +"#; + assert!(find_block(text, "fake").is_none()); + assert!(find_block(text, "real").is_some()); + } + + #[test] + fn query_param_decodes_percent_encoding() { + assert_eq!(query_param("id=ln%2D001", "id").as_deref(), Some("ln-001")); + assert_eq!(query_param("a=1&id=tx+1", "id").as_deref(), Some("tx 1")); + assert_eq!(query_param("a=1", "id"), None); + } +} diff --git a/src/svg.rs b/src/svg.rs new file mode 100644 index 0000000..003b2ef --- /dev/null +++ b/src/svg.rs @@ -0,0 +1,1068 @@ +//! SVG renderer — full-fidelity vector rendering of a project. +//! +//! Renders real text, dimension lines with measured values, hatch patterns +//! clipped to their boundary, line styles, and optional highlight markers. +//! It is the single rendering backend: `cadforge serve` displays the SVG +//! directly and the PNG preview rasterizes it. + +use crate::compiler::resolve_boundary; +use crate::model::{CfFile, CommonAttrs, LineStyle, TextAlign}; +use crate::parser::{parse_cf, parse_project}; +use crate::transform::expand_cf; +use anyhow::{Context, Result}; +use std::fmt::Write as _; +use std::path::Path; + +const PADDING: f64 = 1.0; // world units around content +const MAX_HEIGHT_PX: f64 = 4096.0; +const BG_COLOR: &str = "#141414"; +const GRID_COLOR: &str = "#232323"; +const AXIS_COLOR: &str = "#333333"; + +const LAYER_PALETTE: &[&str] = &[ + "#FFFFFF", "#FF5050", "#50FF50", "#50C8FF", "#FFC850", "#C878FF", "#FF9632", +]; + +// ── Public API ────────────────────────────────────────────────────────── + +/// A rendered SVG plus the world→pixel transform used to produce it, so +/// consumers (PNG rasterizer, metadata) can map world coordinates to pixels. +pub struct Scene { + pub svg: String, + /// Pixels per world unit. + pub px_per_unit: f64, + /// World X of the left canvas edge. + pub offset_x: f64, + /// World Y of the bottom canvas edge. + pub offset_y: f64, + /// Canvas height in world units (used for the Y flip). + pub world_h: f64, + pub width_px: f64, + pub height_px: f64, + /// Content bounds (without padding): [min_x, min_y, max_x, max_y]. + pub world_bounds: [f64; 4], +} + +impl Scene { + /// Map a world coordinate to image pixels. + pub fn world_to_px(&self, x: f64, y: f64) -> (f64, f64) { + ( + (x - self.offset_x) * self.px_per_unit, + (self.world_h - (y - self.offset_y)) * self.px_per_unit, + ) + } +} + +/// Parse `project.toml` and its (filtered) layer files in one pass, with +/// `[[array]]`/`[[mirror]]` constructions expanded into concrete primitives. +pub fn load_project_layers( + project_dir: &Path, + layer_filter: Option<&str>, +) -> Result<(crate::parser::ProjectFile, Vec<(String, CfFile)>)> { + let project = parse_project(&project_dir.join("project.toml"))?; + + let layers: Vec<(String, CfFile)> = project + .layers + .iter() + .filter(|(name, _)| layer_filter.is_none_or(|f| f == *name)) + .map(|(name, entry)| { + let cf = parse_cf(&project_dir.join(&entry.file)) + .with_context(|| format!("Failed to parse layer '{}'", name))?; + Ok((name.clone(), expand_cf(&cf))) + }) + .collect::>()?; + + Ok((project, layers)) +} + +/// Render already-parsed layers to a [`Scene`], optionally highlighting ids. +pub fn render_scene_from( + project_name: &str, + units: &str, + layers: &[(String, CfFile)], + width: u32, + highlight: &[String], +) -> Scene { + render_layers(project_name, units, layers, width, highlight) +} + +/// Render the project to a [`Scene`], optionally highlighting entities by id. +pub fn render_scene( + project_dir: &Path, + layer_filter: Option<&str>, + width: u32, + highlight: &[String], +) -> Result { + let (project, layers) = load_project_layers(project_dir, layer_filter)?; + Ok(render_layers( + &project.project.name, + &project.project.units, + &layers, + width, + highlight, + )) +} + +/// Render the project to an SVG string. +pub fn render_svg(project_dir: &Path, layer_filter: Option<&str>, width: u32) -> Result { + Ok(render_scene(project_dir, layer_filter, width, &[])?.svg) +} + +/// Display color of a layer: its declared color, or a palette color by index. +pub fn layer_display_color(cf: &CfFile, index: usize) -> String { + cf.layer_meta + .as_ref() + .and_then(|m| m.color.clone()) + .unwrap_or_else(|| LAYER_PALETTE[index % LAYER_PALETTE.len()].to_string()) +} + +// ── Canvas ────────────────────────────────────────────────────────────── + +struct Canvas { + out: String, + scale: f64, + offset_x: f64, + offset_y: f64, + world_h: f64, + width_px: f64, + height_px: f64, + clip_seq: usize, +} + +impl Canvas { + fn world_to_px(&self, x: f64, y: f64) -> (f64, f64) { + let px = (x - self.offset_x) * self.scale; + let py = (self.world_h - (y - self.offset_y)) * self.scale; + (px, py) + } + + fn points_attr(&self, points: &[(f64, f64)]) -> String { + let mut s = String::with_capacity(points.len() * 16); + for (i, &(x, y)) in points.iter().enumerate() { + if i > 0 { + s.push(' '); + } + let (px, py) = self.world_to_px(x, y); + let _ = write!(s, "{:.2},{:.2}", px, py); + } + s + } +} + +#[derive(Clone)] +struct Style { + color: String, + width_px: f64, + dash: Option<&'static str>, +} + +fn resolve_style(common: &CommonAttrs, layer_color: &str, default_weight: f64) -> Style { + let color = common + .color + .clone() + .unwrap_or_else(|| layer_color.to_string()); + let weight = common.weight.unwrap_or(default_weight); + let width_px = ((weight / 0.35) * 1.4).clamp(0.6, 6.0); + let dash = match common.style { + Some(LineStyle::Dashed) => Some("8,6"), + Some(LineStyle::Dotted) => Some("1.5,5"), + Some(LineStyle::Dashdot) => Some("10,4,1.5,4"), + _ => None, + }; + Style { + color, + width_px, + dash, + } +} + +fn stroke_attrs(s: &Style) -> String { + let mut a = format!( + r#"stroke="{}" stroke-width="{:.2}" fill="none""#, + s.color, s.width_px + ); + if let Some(dash) = s.dash { + let _ = write!(a, r#" stroke-dasharray="{}""#, dash); + } + a +} + +fn id_attr(common: &CommonAttrs) -> String { + match &common.id { + Some(id) => format!(r#" data-id="{}""#, xml_escape(id)), + None => String::new(), + } +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +// ── Bounds ────────────────────────────────────────────────────────────── + +struct Bounds { + min_x: f64, + min_y: f64, + max_x: f64, + max_y: f64, +} + +impl Bounds { + fn empty() -> Self { + Self { + min_x: f64::MAX, + min_y: f64::MAX, + max_x: f64::MIN, + max_y: f64::MIN, + } + } + + fn add(&mut self, x: f64, y: f64) { + self.min_x = self.min_x.min(x); + self.min_y = self.min_y.min(y); + self.max_x = self.max_x.max(x); + self.max_y = self.max_y.max(y); + } + + fn is_empty(&self) -> bool { + self.min_x > self.max_x + } +} + +fn compute_bounds(layers: &[(String, CfFile)]) -> Bounds { + let mut b = Bounds::empty(); + for (_, cf) in layers { + for e in &cf.lines { + b.add(e.from[0], e.from[1]); + b.add(e.to[0], e.to[1]); + } + for e in &cf.polylines { + for p in &e.points { + b.add(p[0], p[1]); + } + } + for e in &cf.rects { + b.add(e.origin[0], e.origin[1]); + b.add(e.origin[0] + e.width, e.origin[1] + e.height); + } + for e in &cf.circles { + b.add(e.center[0] - e.radius, e.center[1] - e.radius); + b.add(e.center[0] + e.radius, e.center[1] + e.radius); + } + for e in &cf.arcs { + b.add(e.center[0] - e.radius, e.center[1] - e.radius); + b.add(e.center[0] + e.radius, e.center[1] + e.radius); + } + for e in &cf.texts { + b.add(e.position[0], e.position[1]); + } + for e in &cf.points { + b.add(e.position[0], e.position[1]); + } + for e in &cf.dims { + b.add(e.from[0], e.from[1]); + b.add(e.to[0], e.to[1]); + } + for e in &cf.fills { + if let Some(points) = &e.points { + for p in points { + b.add(p[0], p[1]); + } + } + } + } + if b.is_empty() { + Bounds { + min_x: 0.0, + min_y: 0.0, + max_x: 10.0, + max_y: 10.0, + } + } else { + b + } +} + +// ── Rendering ─────────────────────────────────────────────────────────── + +fn render_layers( + project_name: &str, + units: &str, + layers: &[(String, CfFile)], + width: u32, + highlight: &[String], +) -> Scene { + let bounds = compute_bounds(layers); + let world_w = bounds.max_x - bounds.min_x + 2.0 * PADDING; + let world_h = bounds.max_y - bounds.min_y + 2.0 * PADDING; + + let width_px = width as f64; + let height_px = (width_px * world_h / world_w).min(MAX_HEIGHT_PX); + let scale = (width_px / world_w).min(height_px / world_h); + + let mut canvas = Canvas { + out: String::with_capacity(16 * 1024), + scale, + offset_x: bounds.min_x - PADDING, + offset_y: bounds.min_y - PADDING, + world_h, + width_px, + height_px, + clip_seq: 0, + }; + + let _ = write!( + canvas.out, + r#""#, + w = canvas.width_px, + h = canvas.height_px, + name = xml_escape(project_name), + ); + let _ = write!( + canvas.out, + r#""#, + BG_COLOR + ); + + draw_grid(&mut canvas, &bounds); + + for (idx, (layer_name, cf)) in layers.iter().enumerate() { + let layer_color = layer_display_color(cf, idx); + let default_weight = cf + .layer_meta + .as_ref() + .and_then(|m| m.line_weight) + .unwrap_or(0.35); + let visible = cf.layer_meta.as_ref().map(|m| m.visible).unwrap_or(true); + if !visible { + continue; + } + let _ = write!(canvas.out, r#""#, xml_escape(layer_name)); + render_layer(&mut canvas, cf, &layer_color, default_weight, units); + canvas.out.push_str(""); + } + + if !highlight.is_empty() { + draw_highlights(&mut canvas, layers, highlight); + } + + canvas.out.push_str(""); + Scene { + px_per_unit: canvas.scale, + offset_x: canvas.offset_x, + offset_y: canvas.offset_y, + world_h: canvas.world_h, + width_px: canvas.width_px, + height_px: canvas.height_px, + world_bounds: [bounds.min_x, bounds.min_y, bounds.max_x, bounds.max_y], + svg: canvas.out, + } +} + +// ── Highlights (visual verification markers for agents) ──────────────── + +const HIGHLIGHT_COLOR: &str = "#FFB300"; + +fn draw_highlights(c: &mut Canvas, layers: &[(String, CfFile)], highlight: &[String]) { + c.out.push_str(r#""#); + for (_, cf) in layers { + for rec in enumerate_entities(cf) { + let Some(id) = &rec.id else { continue }; + if !highlight.iter().any(|h| h == id) { + continue; + } + let (x1, y1) = c.world_to_px(rec.bbox[0], rec.bbox[3]); // top-left + let (x2, y2) = c.world_to_px(rec.bbox[2], rec.bbox[1]); // bottom-right + let margin = 8.0; + let _ = write!( + c.out, + r#""#, + x1 - margin, + y1 - margin, + (x2 - x1) + 2.0 * margin, + (y2 - y1) + 2.0 * margin, + HIGHLIGHT_COLOR, + xml_escape(id), + ); + let _ = write!( + c.out, + r#"{}"#, + x1 - margin, + y1 - margin - 6.0, + HIGHLIGHT_COLOR, + xml_escape(id), + ); + } + } + c.out.push_str(""); +} + +// ── Entity enumeration (shared with the PNG preview metadata) ─────────── + +/// A primitive with its world-space bounding box, for metadata and highlights. +pub struct EntityRecord { + pub id: Option, + pub kind: &'static str, + /// [min_x, min_y, max_x, max_y] in world units. + pub bbox: [f64; 4], + /// Text content, for `text` entities. + pub content: Option, +} + +/// Enumerate the drawable primitives of a layer with their world bounds. +pub fn enumerate_entities(cf: &CfFile) -> Vec { + fn bbox_of(points: &[(f64, f64)]) -> [f64; 4] { + let mut b = Bounds::empty(); + for &(x, y) in points { + b.add(x, y); + } + [b.min_x, b.min_y, b.max_x, b.max_y] + } + + let mut out = Vec::new(); + for e in &cf.lines { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "line", + bbox: bbox_of(&[(e.from[0], e.from[1]), (e.to[0], e.to[1])]), + content: None, + }); + } + for e in &cf.polylines { + let pts: Vec<(f64, f64)> = e.points.iter().map(|p| (p[0], p[1])).collect(); + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "polyline", + bbox: bbox_of(&pts), + content: None, + }); + } + for e in &cf.rects { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "rect", + bbox: [ + e.origin[0], + e.origin[1], + e.origin[0] + e.width, + e.origin[1] + e.height, + ], + content: None, + }); + } + for e in &cf.circles { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "circle", + bbox: [ + e.center[0] - e.radius, + e.center[1] - e.radius, + e.center[0] + e.radius, + e.center[1] + e.radius, + ], + content: None, + }); + } + for e in &cf.arcs { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "arc", + bbox: bbox_of(&arc_points( + e.center[0], + e.center[1], + e.radius, + e.from_angle, + e.to_angle, + )), + content: None, + }); + } + for e in &cf.texts { + // Approximate extent from monospace glyph proportions. + let w = 0.6 * e.size * e.content.chars().count() as f64; + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "text", + bbox: [ + e.position[0], + e.position[1], + e.position[0] + w, + e.position[1] + e.size, + ], + content: Some(e.content.clone()), + }); + } + for e in &cf.points { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "point", + bbox: [ + e.position[0] - 0.05, + e.position[1] - 0.05, + e.position[0] + 0.05, + e.position[1] + 0.05, + ], + content: None, + }); + } + for e in &cf.dims { + let dx = e.to[0] - e.from[0]; + let dy = e.to[1] - e.from[1]; + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let (nx, ny) = (-dy / len, dx / len); + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "dim", + bbox: bbox_of(&[ + (e.from[0], e.from[1]), + (e.to[0], e.to[1]), + (e.from[0] + nx * e.offset, e.from[1] + ny * e.offset), + (e.to[0] + nx * e.offset, e.to[1] + ny * e.offset), + ]), + content: None, + }); + } + for e in &cf.hatches { + if let Some(pts) = resolve_boundary(&e.boundary, cf) { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "hatch", + bbox: bbox_of(&pts), + content: None, + }); + } + } + for e in &cf.fills { + let pts = if let Some(boundary_id) = &e.boundary { + resolve_boundary(boundary_id, cf) + } else { + e.points + .as_ref() + .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + }; + if let Some(pts) = pts { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "fill", + bbox: bbox_of(&pts), + content: None, + }); + } + } + out +} + +fn grid_step(world_w: f64) -> f64 { + const STEPS: &[f64] = &[0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 500.0]; + for &s in STEPS { + if world_w / s <= 40.0 { + return s; + } + } + 1000.0 +} + +fn draw_grid(c: &mut Canvas, bounds: &Bounds) { + let step = grid_step(bounds.max_x - bounds.min_x + 2.0 * PADDING); + let x0 = ((bounds.min_x - PADDING) / step).floor() * step; + let x1 = bounds.max_x + PADDING; + let y0 = ((bounds.min_y - PADDING) / step).floor() * step; + let y1 = bounds.max_y + PADDING; + + c.out.push_str(r#""#); + let mut x = x0; + while x <= x1 { + let (px, _) = c.world_to_px(x, 0.0); + let color = if x.abs() < 1e-9 { + AXIS_COLOR + } else { + GRID_COLOR + }; + let _ = write!( + c.out, + r#""#, + h = c.height_px, + ); + x += step; + } + let mut y = y0; + while y <= y1 { + let (_, py) = c.world_to_px(0.0, y); + let color = if y.abs() < 1e-9 { + AXIS_COLOR + } else { + GRID_COLOR + }; + let _ = write!( + c.out, + r#""#, + w = c.width_px, + ); + y += step; + } + c.out.push_str(""); +} + +fn render_layer(c: &mut Canvas, cf: &CfFile, layer_color: &str, default_weight: f64, units: &str) { + for e in cf.lines.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (x1, y1) = c.world_to_px(e.from[0], e.from[1]); + let (x2, y2) = c.world_to_px(e.to[0], e.to[1]); + let _ = write!( + c.out, + r#""#, + x1, + y1, + x2, + y2, + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + for e in cf.polylines.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let pts: Vec<(f64, f64)> = e.points.iter().map(|p| (p[0], p[1])).collect(); + let tag = if e.closed { "polygon" } else { "polyline" }; + let _ = write!( + c.out, + r#"<{} points="{}" {}{}/>"#, + tag, + c.points_attr(&pts), + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + for e in cf.rects.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (px, py) = c.world_to_px(e.origin[0], e.origin[1] + e.height); + let _ = write!( + c.out, + r#""#, + px, + py, + e.width * c.scale, + e.height * c.scale, + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + for e in cf.circles.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (px, py) = c.world_to_px(e.center[0], e.center[1]); + let _ = write!( + c.out, + r#""#, + px, + py, + e.radius * c.scale, + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + for e in cf.arcs.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let pts = arc_points(e.center[0], e.center[1], e.radius, e.from_angle, e.to_angle); + let _ = write!( + c.out, + r#""#, + c.points_attr(&pts), + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + // Fills and hatches go before text so labels stay readable on top. + for e in cf.fills.iter().filter(|e| e.common.visible) { + let pts = if let Some(boundary_id) = &e.boundary { + resolve_boundary(boundary_id, cf) + } else { + e.points + .as_ref() + .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + }; + if let Some(pts) = pts { + let s = resolve_style(&e.common, layer_color, default_weight); + let _ = write!( + c.out, + r#""#, + c.points_attr(&pts), + s.color, + id_attr(&e.common) + ); + } + } + + for e in cf.hatches.iter().filter(|e| e.common.visible) { + if let Some(boundary) = resolve_boundary(&e.boundary, cf) { + let s = resolve_style(&e.common, layer_color, default_weight); + draw_hatch( + c, + &boundary, + e.pattern.as_str(), + e.angle, + 0.1 * e.scale, + &s, + &e.common, + ); + } + } + + for e in cf.dims.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + draw_dim(c, e, &s, units); + } + + for e in cf.points.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (px, py) = c.world_to_px(e.position[0], e.position[1]); + let _ = write!( + c.out, + r#""#, + x0 = px - 4.0, + x1 = px + 4.0, + y0 = py - 4.0, + y1 = py + 4.0, + x = px, + y = py, + attrs = stroke_attrs(&s), + id = id_attr(&e.common) + ); + } + + for e in cf.texts.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (px, py) = c.world_to_px(e.position[0], e.position[1]); + let anchor = match e.align { + Some(TextAlign::Center) => "middle", + Some(TextAlign::Right) => "end", + _ => "start", + }; + let font_px = (e.size * c.scale).max(1.0); + let _ = write!( + c.out, + r#"{}"#, + px, + py, + font_px, + anchor, + s.color, + id_attr(&e.common), + xml_escape(&e.content) + ); + } +} + +fn arc_points(cx: f64, cy: f64, radius: f64, from_deg: f64, to_deg: f64) -> Vec<(f64, f64)> { + const STEPS: usize = 48; + let start = from_deg.to_radians(); + let delta = (to_deg.to_radians() - start) / STEPS as f64; + (0..=STEPS) + .map(|i| { + let a = start + delta * i as f64; + (cx + radius * a.cos(), cy + radius * a.sin()) + }) + .collect() +} + +fn draw_dim(c: &mut Canvas, dim: &crate::model::CfDim, s: &Style, units: &str) { + let (from, to, offset) = (dim.from, dim.to, dim.offset); + let dx = to[0] - from[0]; + let dy = to[1] - from[1]; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-9 { + return; + } + let (ux, uy) = (dx / len, dy / len); + let (nx, ny) = (-uy, ux); + + // Dimension line endpoints, offset along the normal + let a = (from[0] + nx * offset, from[1] + ny * offset); + let b = (to[0] + nx * offset, to[1] + ny * offset); + + let (ax, ay) = c.world_to_px(a.0, a.1); + let (bx, by) = c.world_to_px(b.0, b.1); + let (fx, fy) = c.world_to_px(from[0], from[1]); + let (tx, ty) = c.world_to_px(to[0], to[1]); + + let _ = write!(c.out, r#""#, id_attr(&dim.common)); + // Extension lines + dimension line + let _ = write!( + c.out, + r#""#, + color = s.color, + w = (s.width_px * 0.8).max(0.6), + ); + // Tick marks (45° slashes) at both ends + let tick = 5.0; + for &(px, py) in &[(ax, ay), (bx, by)] { + let _ = write!( + c.out, + r#""#, + px - tick, + py + tick, + px + tick, + py - tick, + s.color, + (s.width_px * 0.8).max(0.6), + ); + } + // Measured value label at the midpoint + let text_size = dim.text_size.unwrap_or(0.0); + let label_gap = if text_size > 0.0 { + text_size * 0.6 + } else { + 0.15 + }; + let mid = ( + (a.0 + b.0) / 2.0 + nx * label_gap, + (a.1 + b.1) / 2.0 + ny * label_gap, + ); + let (mx, my) = c.world_to_px(mid.0, mid.1); + let font_px = if text_size > 0.0 { + (text_size * c.scale).max(1.0) + } else { + (0.22 * c.scale).clamp(9.0, 28.0) + }; + let label = format_dim_label( + len, + dim.precision.unwrap_or(2) as usize, + dim.show_units, + units, + ); + let _ = write!( + c.out, + r#"{}"#, + mx, + my, + font_px, + s.color, + xml_escape(&label), + ); + c.out.push_str(""); +} + +/// Format a dimension label: measured value with the configured precision, +/// optionally followed by the project units. +pub fn format_dim_label( + len: f64, + precision: usize, + show_units: Option, + units: &str, +) -> String { + if show_units.unwrap_or(true) { + format!("{:.prec$} {}", len, units, prec = precision) + } else { + format!("{:.prec$}", len, prec = precision) + } +} + +fn draw_hatch( + c: &mut Canvas, + boundary: &[(f64, f64)], + pattern: &str, + angle_deg: f64, + spacing: f64, + s: &Style, + common: &CommonAttrs, +) { + if boundary.is_empty() || spacing <= 0.0 { + return; + } + + if pattern == "solid" { + let _ = write!( + c.out, + r#""#, + c.points_attr(boundary), + s.color, + id_attr(common) + ); + return; + } + + // Bounding box of the boundary + let mut b = Bounds::empty(); + for &(x, y) in boundary { + b.add(x, y); + } + let cx = (b.min_x + b.max_x) / 2.0; + let cy = (b.min_y + b.max_y) / 2.0; + let half_diag = (((b.max_x - b.min_x).powi(2) + (b.max_y - b.min_y).powi(2)).sqrt()) / 2.0; + + let theta = angle_deg.to_radians(); + let (dx, dy) = (theta.cos(), theta.sin()); + let (nx, ny) = (-dy, dx); + + let n = ((half_diag / spacing).ceil() as i64).min(2000); + + c.clip_seq += 1; + let clip_id = format!("hatch-clip-{}", c.clip_seq); + let _ = write!( + c.out, + r#""#, + clip_id, + c.points_attr(boundary) + ); + let _ = write!( + c.out, + r#""#, + clip_id, + id_attr(common) + ); + let mut path = String::new(); + for k in -n..=n { + let ox = cx + nx * spacing * k as f64; + let oy = cy + ny * spacing * k as f64; + let (x1, y1) = c.world_to_px(ox - dx * half_diag, oy - dy * half_diag); + let (x2, y2) = c.world_to_px(ox + dx * half_diag, oy + dy * half_diag); + let _ = write!(path, "M {:.2} {:.2} L {:.2} {:.2} ", x1, y1, x2, y2); + } + let _ = write!( + c.out, + r#""#, + path.trim_end(), + s.color + ); + c.out.push_str(""); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_layers() -> Vec<(String, CfFile)> { + let toml = r##" +[layer] +name = "test" +color = "#FFFFFF" + +[[line]] +id = "ln-1" +from = [0.0, 0.0] +to = [10.0, 0.0] +style = "dashed" + +[[rect]] +id = "rc-1" +origin = [1.0, 1.0] +width = 3.0 +height = 2.0 + +[[circle]] +center = [5.0, 5.0] +radius = 1.5 + +[[arc]] +center = [2.0, 2.0] +radius = 1.0 +from_angle = 0.0 +to_angle = 90.0 + +[[text]] +position = [5.0, 5.0] +content = "SALA " +size = 0.3 +align = "center" + +[[dim]] +from = [0.0, 0.0] +to = [10.0, 0.0] +offset = -0.8 + +[[polyline]] +id = "pl-room" +points = [[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [0.0, 3.0]] +closed = true + +[[hatch]] +boundary = "pl-room" +pattern = "ansi31" +"##; + let cf: CfFile = toml::from_str(toml).unwrap(); + vec![("test".to_string(), cf)] + } + + #[test] + fn renders_all_primitives() { + let svg = render_layers("demo", "m", &sample_layers(), 1200, &[]).svg; + assert!(svg.starts_with("")); + assert!(svg.contains("")); + } + + #[test] + fn dim_label_shows_measured_length() { + let svg = render_layers("demo", "m", &sample_layers(), 1200, &[]).svg; + assert!(svg.contains("10.00 m")); + } + + #[test] + fn empty_project_renders_default_viewport() { + let svg = render_layers("empty", "m", &[], 800, &[]).svg; + assert!(svg.starts_with("rc-1")); + assert!(!scene.svg.contains(r#"data-highlight="missing-id""#)); + } + + #[test] + fn enumerate_entities_covers_bounds_and_content() { + let layers = sample_layers(); + let records = enumerate_entities(&layers[0].1); + // line, rect, circle, arc, text, dim, polyline, hatch + assert_eq!(records.len(), 8); + let text = records.iter().find(|r| r.kind == "text").unwrap(); + assert_eq!(text.content.as_deref(), Some("SALA ")); + let rect = records.iter().find(|r| r.kind == "rect").unwrap(); + assert_eq!(rect.bbox, [1.0, 1.0, 4.0, 3.0]); + } + + #[test] + fn scene_world_to_px_is_consistent_with_canvas() { + let scene = render_layers("demo", "m", &sample_layers(), 1200, &[]); + // Bottom-left content corner with padding maps inside the canvas + let (px, py) = scene.world_to_px(scene.world_bounds[0], scene.world_bounds[1]); + assert!(px > 0.0 && px < scene.width_px); + assert!(py > 0.0 && py <= scene.height_px); + } + + #[test] + fn grid_step_scales_with_world_size() { + assert_eq!(grid_step(10.0), 0.5); + assert_eq!(grid_step(30.0), 1.0); + assert_eq!(grid_step(300.0), 10.0); + } +} diff --git a/src/transform.rs b/src/transform.rs new file mode 100644 index 0000000..62c0bcc --- /dev/null +++ b/src/transform.rs @@ -0,0 +1,483 @@ +//! Transform pass — expands `[[array]]` and `[[mirror]]` constructions into +//! concrete primitives before compilation. +//! +//! A polar array of a tread polyline is a spiral staircase plan; a polar array +//! of a tooth profile is a gear; a mirror duplicates a wing of a building. +//! Expansion happens at load time, so DXF output, SVG/PNG previews, and entity +//! metadata all see the generated geometry with no special cases. +//! +//! Generated copies get derived ids: `tread@1`, `tread@2`, … for arrays and +//! `tread@m` for mirrors, so they can still be referenced (e.g. by hatches) +//! and highlighted. + +use crate::model::{ArrayMode, CfArray, CfFile, CfMirror}; +use std::collections::HashSet; + +// ── Point operations ──────────────────────────────────────────────────── + +#[derive(Clone, Copy)] +enum PointOp { + Translate { + dx: f64, + dy: f64, + }, + Rotate { + cx: f64, + cy: f64, + angle_rad: f64, + }, + /// Mirror across the line through (x0, y0) with direction angle `axis_rad`. + Mirror { + x0: f64, + y0: f64, + axis_rad: f64, + }, +} + +impl PointOp { + fn apply(&self, p: [f64; 2]) -> [f64; 2] { + match *self { + PointOp::Translate { dx, dy } => [p[0] + dx, p[1] + dy], + PointOp::Rotate { cx, cy, angle_rad } => { + let (s, c) = angle_rad.sin_cos(); + let (x, y) = (p[0] - cx, p[1] - cy); + [cx + x * c - y * s, cy + x * s + y * c] + } + PointOp::Mirror { x0, y0, axis_rad } => { + let (s, c) = (2.0 * axis_rad).sin_cos(); + let (x, y) = (p[0] - x0, p[1] - y0); + [x0 + x * c + y * s, y0 + x * s - y * c] + } + } + } + + /// How a direction angle (degrees) maps under this op. + fn apply_angle_deg(&self, deg: f64) -> f64 { + match *self { + PointOp::Translate { .. } => deg, + PointOp::Rotate { angle_rad, .. } => deg + angle_rad.to_degrees(), + PointOp::Mirror { axis_rad, .. } => 2.0 * axis_rad.to_degrees() - deg, + } + } + + fn flips_orientation(&self) -> bool { + matches!(self, PointOp::Mirror { .. }) + } +} + +// ── Expansion ─────────────────────────────────────────────────────────── + +/// Expand all `[[array]]` and `[[mirror]]` entries of a layer into concrete +/// primitives. The original constructions are consumed. +pub fn expand_cf(cf: &CfFile) -> CfFile { + let mut out = cf.clone(); + let arrays = std::mem::take(&mut out.arrays); + let mirrors = std::mem::take(&mut out.mirrors); + + for array in &arrays { + expand_array(&mut out, array); + } + for mirror in &mirrors { + expand_mirror(&mut out, mirror); + } + out +} + +fn target_set(target: &Option, targets: &Option>) -> HashSet { + let mut set = HashSet::new(); + if let Some(t) = target { + set.insert(t.clone()); + } + if let Some(ts) = targets { + set.extend(ts.iter().cloned()); + } + set +} + +fn expand_array(out: &mut CfFile, array: &CfArray) { + let targets = target_set(&array.target, &array.targets); + if targets.is_empty() || array.count < 2 { + return; + } + + for k in 1..array.count { + let op = match array.mode { + ArrayMode::Linear => { + let [dx, dy] = array.offset.unwrap_or([0.0, 0.0]); + PointOp::Translate { + dx: dx * k as f64, + dy: dy * k as f64, + } + } + ArrayMode::Polar => { + let [cx, cy] = array.center.unwrap_or([0.0, 0.0]); + PointOp::Rotate { + cx, + cy, + angle_rad: (array.step_angle.unwrap_or(0.0) * k as f64).to_radians(), + } + } + }; + let orbit_only = array.mode == ArrayMode::Polar && !array.rotate_items; + copy_targets(out, &targets, op, orbit_only, &format!("@{}", k)); + } +} + +fn expand_mirror(out: &mut CfFile, mirror: &CfMirror) { + let targets = target_set(&mirror.target, &mirror.targets); + if targets.is_empty() { + return; + } + let [a, b] = mirror.axis; + let axis_rad = (b[1] - a[1]).atan2(b[0] - a[0]); + let op = PointOp::Mirror { + x0: a[0], + y0: a[1], + axis_rad, + }; + copy_targets(out, &targets, op, false, "@m"); +} + +/// Clone every targeted primitive, transform it, suffix its id, and append it. +fn copy_targets( + out: &mut CfFile, + targets: &HashSet, + op: PointOp, + orbit_only: bool, + suffix: &str, +) { + fn hit(id: &Option, targets: &HashSet) -> bool { + id.as_deref().is_some_and(|i| targets.contains(i)) + } + fn suffixed(id: &Option, suffix: &str) -> Option { + id.as_ref().map(|i| format!("{}{}", i, suffix)) + } + + let mut new_lines = Vec::new(); + for e in out.lines.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.from = op.apply(c.from); + c.to = op.apply(c.to); + c.common.id = suffixed(&e.common.id, suffix); + new_lines.push(c); + } + out.lines.extend(new_lines); + + let mut new_polys = Vec::new(); + for e in out.polylines.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + for p in &mut c.points { + *p = op.apply(*p); + } + c.common.id = suffixed(&e.common.id, suffix); + new_polys.push(c); + } + out.polylines.extend(new_polys); + + // Rects: a translated copy stays a rect; a rotated or mirrored copy + // becomes a closed polyline (rects are axis-aligned by definition). + let mut rect_polys = Vec::new(); + let mut new_rects = Vec::new(); + for e in out.rects.iter().filter(|e| hit(&e.common.id, targets)) { + let corners = [ + e.origin, + [e.origin[0] + e.width, e.origin[1]], + [e.origin[0] + e.width, e.origin[1] + e.height], + [e.origin[0], e.origin[1] + e.height], + ]; + let keeps_shape = matches!(op, PointOp::Translate { .. }) || orbit_only; + if keeps_shape { + let mut c = e.clone(); + if orbit_only { + // Orbit the rect center, keep the rect axis-aligned. + let center = [e.origin[0] + e.width / 2.0, e.origin[1] + e.height / 2.0]; + let moved = op.apply(center); + c.origin = [moved[0] - e.width / 2.0, moved[1] - e.height / 2.0]; + } else { + c.origin = op.apply(c.origin); + } + c.common.id = suffixed(&e.common.id, suffix); + new_rects.push(c); + } else { + rect_polys.push(crate::model::CfPolyline { + points: corners.iter().map(|&p| op.apply(p)).collect(), + closed: true, + common: crate::model::CommonAttrs { + id: suffixed(&e.common.id, suffix), + ..e.common.clone() + }, + }); + } + } + out.rects.extend(new_rects); + out.polylines.extend(rect_polys); + + let mut new_circles = Vec::new(); + for e in out.circles.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.center = op.apply(c.center); + c.common.id = suffixed(&e.common.id, suffix); + new_circles.push(c); + } + out.circles.extend(new_circles); + + let mut new_arcs = Vec::new(); + for e in out.arcs.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.center = op.apply(c.center); + if !orbit_only { + if op.flips_orientation() { + // Reflected sweep: endpoints swap to keep the arc CCW. + let from = op.apply_angle_deg(e.to_angle); + let to = op.apply_angle_deg(e.from_angle); + c.from_angle = from; + c.to_angle = to; + } else { + c.from_angle = op.apply_angle_deg(e.from_angle); + c.to_angle = op.apply_angle_deg(e.to_angle); + } + } + c.common.id = suffixed(&e.common.id, suffix); + new_arcs.push(c); + } + out.arcs.extend(new_arcs); + + // Texts stay upright: only the anchor moves. + let mut new_texts = Vec::new(); + for e in out.texts.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.position = op.apply(c.position); + c.common.id = suffixed(&e.common.id, suffix); + new_texts.push(c); + } + out.texts.extend(new_texts); + + let mut new_points = Vec::new(); + for e in out.points.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.position = op.apply(c.position); + c.common.id = suffixed(&e.common.id, suffix); + new_points.push(c); + } + out.points.extend(new_points); + + let mut new_dims = Vec::new(); + for e in out.dims.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.from = op.apply(c.from); + c.to = op.apply(c.to); + if op.flips_orientation() { + c.offset = -c.offset; + } + c.common.id = suffixed(&e.common.id, suffix); + new_dims.push(c); + } + out.dims.extend(new_dims); + + // Hatches/fills follow their boundary: if the boundary was copied too, + // the copy references the copied boundary id. + let mut new_hatches = Vec::new(); + for e in out.hatches.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + if targets.contains(&e.boundary) { + c.boundary = format!("{}{}", e.boundary, suffix); + } + c.common.id = suffixed(&e.common.id, suffix); + new_hatches.push(c); + } + out.hatches.extend(new_hatches); + + let mut new_fills = Vec::new(); + for e in out.fills.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + if let Some(boundary) = &e.boundary { + if targets.contains(boundary) { + c.boundary = Some(format!("{}{}", boundary, suffix)); + } + } + if let Some(points) = &mut c.points { + for p in points { + *p = op.apply(*p); + } + } + c.common.id = suffixed(&e.common.id, suffix); + new_fills.push(c); + } + out.fills.extend(new_fills); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(toml: &str) -> CfFile { + toml::from_str(toml).unwrap() + } + + fn assert_close(actual: [f64; 2], expected: [f64; 2]) { + assert!( + (actual[0] - expected[0]).abs() < 1e-9 && (actual[1] - expected[1]).abs() < 1e-9, + "expected {:?}, got {:?}", + expected, + actual + ); + } + + #[test] + fn linear_array_translates_copies() { + let cf = parse( + r#" +[[rect]] +id = "col" +origin = [0.0, 0.0] +width = 0.3 +height = 0.3 + +[[array]] +target = "col" +mode = "linear" +count = 4 +offset = [2.0, 0.0] +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.rects.len(), 4); + assert!(out.arrays.is_empty()); + assert_eq!(out.rects[3].origin, [6.0, 0.0]); + assert_eq!(out.rects[3].common.id.as_deref(), Some("col@3")); + } + + #[test] + fn polar_array_rotates_polyline_like_a_gear() { + let cf = parse( + r#" +[[polyline]] +id = "tooth" +points = [[10.0, 0.0], [11.0, 0.5], [11.0, -0.5]] +closed = true + +[[array]] +target = "tooth" +mode = "polar" +count = 12 +center = [0.0, 0.0] +step_angle = 30.0 +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.polylines.len(), 12); + // Copy 3 is rotated 90°: (10, 0) → (0, 10) + let p = out.polylines[3].points[0]; + assert!((p[0]).abs() < 1e-9 && (p[1] - 10.0).abs() < 1e-9); + } + + #[test] + fn polar_orbit_keeps_rect_axis_aligned() { + let cf = parse( + r#" +[[rect]] +id = "silla" +origin = [4.0, -0.5] +width = 1.0 +height = 1.0 + +[[array]] +target = "silla" +mode = "polar" +count = 4 +center = [0.0, 0.0] +step_angle = 90.0 +rotate_items = false +"#, + ); + let out = expand_cf(&cf); + // Rect stays a rect when orbiting + assert_eq!(out.rects.len(), 4); + // Center (4.5, 0) rotated 90° → (0, 4.5); origin = center - half size + assert_close(out.rects[1].origin, [-0.5, 4.0]); + } + + #[test] + fn polar_rotation_converts_rect_to_polyline() { + let cf = parse( + r#" +[[rect]] +id = "huella" +origin = [1.0, 0.0] +width = 1.2 +height = 0.3 + +[[array]] +target = "huella" +mode = "polar" +count = 3 +center = [0.0, 0.0] +step_angle = 20.0 +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.rects.len(), 1, "original rect stays"); + assert_eq!(out.polylines.len(), 2, "rotated copies become polylines"); + assert!(out.polylines.iter().all(|p| p.closed)); + } + + #[test] + fn mirror_reflects_and_inverts_arc_sweep() { + let cf = parse( + r#" +[[line]] +id = "muro" +from = [1.0, 0.0] +to = [1.0, 5.0] + +[[arc]] +id = "puerta" +center = [1.0, 2.0] +radius = 0.9 +from_angle = 0.0 +to_angle = 90.0 + +[[mirror]] +targets = ["muro", "puerta"] +axis = [[3.0, 0.0], [3.0, 1.0]] +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.lines.len(), 2); + assert_eq!(out.arcs.len(), 2); + // Vertical axis at x=3: x=1 → x=5 + assert_close(out.lines[1].from, [5.0, 0.0]); + let m = &out.arcs[1]; + assert_close(m.center, [5.0, 2.0]); + // Vertical-axis mirror maps θ → 180−θ, endpoints swapped: [90°,180°] + assert!((m.from_angle - 90.0).abs() < 1e-9); + assert!((m.to_angle - 180.0).abs() < 1e-9); + assert_eq!(m.common.id.as_deref(), Some("puerta@m")); + } + + #[test] + fn array_remaps_hatch_boundary_to_copied_polyline() { + let cf = parse( + r#" +[[polyline]] +id = "zona" +points = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] +closed = true + +[[hatch]] +id = "zona-hatch" +boundary = "zona" + +[[array]] +targets = ["zona", "zona-hatch"] +mode = "linear" +count = 2 +offset = [3.0, 0.0] +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.hatches.len(), 2); + assert_eq!(out.hatches[1].boundary, "zona@1"); + assert_eq!(out.polylines[1].points[0], [3.0, 0.0]); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index 19016fe..cce4778 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -282,6 +282,129 @@ fn compile_fails_on_constraint_violation_when_strict() { let _ = fs::remove_dir_all(dir); } +#[test] +fn render_svg_on_example_project() { + let svg = cadforge::svg::render_svg(Path::new("examples/vivienda"), None, 1600).unwrap(); + assert!(svg.starts_with("")); + // Layer groups for every project layer + for layer in ["muros", "puertas", "mobiliario", "cotas", "achurados"] { + assert!( + svg.contains(&format!(r#"data-layer="{}""#, layer)), + "missing layer group {}", + layer + ); + } + // Dimensions are labeled with the measured value in project units + assert!(svg.contains(" m"), "dim labels should include units"); +} + +#[test] +fn project_report_is_serializable_and_complete() { + let report = cadforge::compiler::project_report(Path::new("examples/vivienda")).unwrap(); + assert!(report.total_entities > 0); + assert_eq!(report.layers.len(), 5); + assert!(report.layers.iter().all(|l| !l.missing)); + + let json = serde_json::to_string(&report).unwrap(); + assert!(json.contains("\"total_entities\"")); + assert!(json.contains("\"issues\"")); +} + +#[test] +fn taller_example_expands_arrays_and_mirrors() { + let dir = Path::new("examples/taller"); + + // Expanded entity counts: 16 treads + 16 teeth + mirrored geometry + let report = cadforge::compiler::project_report(dir).unwrap(); + assert_eq!(report.total_entities, 49); + + let svg = cadforge::svg::render_svg(dir, None, 1200).unwrap(); + // 15 generated tread copies with derived ids + let tread_copies = svg.matches(r#"data-id="pl-huella@"#).count(); + assert_eq!(tread_copies, 15); + // Mirrored door arc exists + assert!(svg.contains(r#"data-id="ar-puerta@m""#)); + // Styled dims: 1 decimal with units, 3 decimals without units + assert!(svg.contains("3.2 m")); + assert!(svg.contains(">2.900<")); + + // The DXF compiles with the expanded geometry + let out = Path::new("/tmp/cadforge_taller.dxf"); + let _ = fs::remove_file(out); + compile_project(dir, None, Some(out)).unwrap(); + assert!(out.exists()); + let _ = fs::remove_file(out); +} + +#[test] +fn preview_renders_faithful_png_with_metadata_and_highlights() { + let dir = Path::new("/tmp/cadforge_preview_test"); + let _ = fs::remove_dir_all(dir); + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("project.toml"), + r#"[project] +name = "preview-fixture" +units = "m" + +[layers] +plano = { file = "plano.cf", locked = false } +"#, + ) + .unwrap(); + fs::write( + dir.join("plano.cf"), + r##"[[rect]] +id = "rc-room" +origin = [0.0, 0.0] +width = 6.0 +height = 4.0 + +[[text]] +id = "tx-label" +position = [3.0, 2.0] +content = "SALA" +size = 0.4 +align = "center" + +[[dim]] +id = "dm-width" +from = [0.0, 0.0] +to = [6.0, 0.0] +offset = -0.6 +"##, + ) + .unwrap(); + + cadforge::preview::generate_preview( + dir, + 800, + 800, + None, + &["rc-room".to_string()], + cadforge::preview::PreviewOutputs { + png: true, + svg: true, + }, + ) + .unwrap(); + + assert!(dir.join("preview.png").exists()); + assert!(dir.join("preview.svg").exists()); + let meta = fs::read_to_string(dir.join("preview.meta.json")).unwrap(); + assert!(meta.contains(r#""content": "SALA""#)); + assert!(meta.contains(r#""entity_type": "dim""#)); + assert!(meta.contains(r#""highlighted""#)); + assert!(meta.contains("rc-room")); + + // The PNG must fit within the requested box and be non-trivial + let png = fs::read(dir.join("preview.png")).unwrap(); + assert!(png.len() > 1000, "PNG should contain rendered content"); + + let _ = fs::remove_dir_all(dir); +} + #[test] fn import_generated_dxf_creates_cadforge_project() { let source = Path::new("examples/vivienda"); @@ -303,3 +426,91 @@ fn import_generated_dxf_creates_cadforge_project() { let _ = fs::remove_dir_all(imported); } + +#[test] +fn import_roundtrip_recovers_dims_styles_and_colors() { + let dir = Path::new("/tmp/cadforge_roundtrip_fidelity"); + let _ = fs::remove_dir_all(dir); + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("project.toml"), + "[project]\nname = \"rt\"\nunits = \"m\"\n\n[layers]\nplano = { file = \"plano.cf\", locked = false }\n", + ) + .unwrap(); + fs::write( + dir.join("plano.cf"), + r##"[layer] +name = "plano" +color = "#FF0000" + +[[line]] +id = "ln-base" +from = [0.0, 0.0] +to = [8.0, 0.0] +color = "#FF5050" +weight = 0.5 +style = "dashed" + +[[text]] +id = "tx-sala" +position = [4.0, 3.0] +content = "SALA" +size = 0.25 + +[[dim]] +id = "dm-h" +from = [0.0, 0.0] +to = [8.0, 0.0] +offset = -0.8 + +[[dim]] +id = "dm-v" +from = [0.0, 0.0] +to = [0.0, 6.0] +offset = -1.2 +"##, + ) + .unwrap(); + + let dxf_path = dir.join("output.dxf"); + compile_project(dir, None, Some(&dxf_path)).unwrap(); + + let imported = Path::new("/tmp/cadforge_roundtrip_fidelity_out"); + let _ = fs::remove_dir_all(imported); + import_dxf(&dxf_path, imported, None).unwrap(); + let cf = fs::read_to_string(imported.join("plano.cf")).unwrap(); + + // Layer color survives via the DXF layer table (ACI) + assert!( + cf.contains("color = \"#FF0000\""), + "layer color lost:\n{cf}" + ); + // Entity style survives via true color, lineweight and line type + assert!( + cf.contains("color = \"#FF5050\""), + "entity color lost:\n{cf}" + ); + assert!(cf.contains("weight = 0.5"), "entity weight lost:\n{cf}"); + assert!(cf.contains("style = \"dashed\""), "line style lost:\n{cf}"); + // Both dims come back as dims with their perpendicular offsets intact + assert_eq!(cf.matches("[[dim]]").count(), 2, "dims lost:\n{cf}"); + assert!(cf.contains("offset = -0.8000"), "horizontal offset:\n{cf}"); + assert!(cf.contains("offset = -1.2000"), "vertical offset:\n{cf}"); + // Companion graphics (3 lines + 1 label per dim) are deduplicated + assert_eq!( + cf.matches("[[line]]").count(), + 1, + "dim companion lines not deduped:\n{cf}" + ); + assert_eq!( + cf.matches("[[text]]").count(), + 1, + "dim label texts not deduped:\n{cf}" + ); + + // The reimported project must still compile + compile_project(imported, None, None).unwrap(); + + let _ = fs::remove_dir_all(dir); + let _ = fs::remove_dir_all(imported); +}