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
+ █████ ███████████
+ ░░███ ░░███░░░░░░█
+ ██████ ██████ ███████ ░███ █ ░ ██████ ████████ ███████ ██████
+ ███░░███ ░░░░░███ ███░░███ ░███████ ███░░███░░███░░███ ███░░███ ███░░███
+░███ ░░░ ███████ ░███ ░███ ░███░░░█ ░███ ░███ ░███ ░░░ ░███ ░███░███████
+░███ ███ ███░░███ ░███ ░███ ░███ ░ ░███ ░███ ░███ ░███ ░███░███░░░
+░░██████ ░░████████░░████████ █████ ░░██████ █████ ░░███████░░██████
+ ░░░░░░ ░░░░░░░░ ░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░███ ░░░░░░
+ ███ ░███
+ ░░██████
+ ░░░░░░
+```
@@ -12,7 +19,38 @@
-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
+
+
+
+
+
+
+
+
+
+
+
+"##;
+
+#[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#"");
+ 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("