diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a76c2f2..070f1a6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,17 @@ name: CI
on:
pull_request:
+ workflow_dispatch:
+ push:
+ branches: [main]
jobs:
rust-ci:
+ name: Format, Lint & Test
uses: UniverLab/workflows/.github/workflows/rust-ci.yml@main
+ with:
+ run-tests: true
+ run-clippy: true
+ check-fmt: true
+ publish-check: true
+ main-pr-checks: true
diff --git a/.gitignore b/.gitignore
index ad67955..de105c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,37 @@ target
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
+
+# AI coding agents
+.kiro/
+.cursor/
+.windsurf/
+.claude/
+.continue/
+.copilot/
+.kilocode/
+.zencoder/
+.qwen/
+.agents/
+skills-lock.json
+# Created by https://www.toptal.com/developers/gitignore/api/rust
+# Edit at https://www.toptal.com/developers/gitignore?templates=rust
+
+### Rust ###
+# Generated by Cargo
+# will have compiled files and executables
+debug/
+target/
+
+# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
+# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
+Cargo.lock
+
+# These are backup files generated by rustfmt
+
+# MSVC Windows builds of rustc generate these, which store debugging information
+
+# End of https://www.toptal.com/developers/gitignore/api/rust
+output.dxf
+preview.png
+preview.meta.json
diff --git a/Cargo.toml b/Cargo.toml
index 865cc88..dd9852f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,8 +1,22 @@
+[workspace]
+members = ["crates/cadforge-cli", "crates/cadforge-view"]
+resolver = "2"
+
[package]
name = "cadforge"
-version = "0.1.0"
+version = "0.1.0-beta.1"
edition = "2021"
-description = ""
-publish = false
+description = "Architecture as Code — deterministic geometry engine for reproducible architectural design"
+license = "MIT"
+publish = true
[dependencies]
+dxf = "0.6.1"
+anyhow = "1.0"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+toml = "0.8"
+toml_edit = "0.22"
+indexmap = { version = "2", features = ["serde"] }
+tiny-skia = "0.11"
+notify = "6.1"
diff --git a/README.md b/README.md
index e98cf5a..7e722fa 100644
--- a/README.md
+++ b/README.md
@@ -1,46 +1,242 @@
-# cadforge
-
->
+██████ ████████ ██████ ████████ ██ ██ ██ ███████ ████████
+██░░███ ░░███░░███░░░░░███ ░░███░░███░███ ░███ ██░███░░░░░░░███░
+░███ ░░░ ░███ ░███ ███████ ░███ ░░░ ░███ ░██████░░█████ ░███
+░███ ███ ░███ ░███ ███░░███ ░███ ░███ ░███░░░ ░███░░█ ░███
+░░██████ ░███████░░████████ ░███ ░███████████ ███████ ░████████
+ ░░░░░░ ░░░░░░░ ░░░░░░░░ ░░░ ░░░░░░░░░░░ ░░░░░░ ░░░░░░░
+
+
+
+
+
+
+
+
+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.
+
+---
+
+## Features
+
+### 🎯 Core Platform
+
+- **📐 Declarative Geometry** — Define architectural elements (lines, rects, circles, arcs, polylines, text, dimensions) in TOML `.cf` files. Deterministic, reproducible, version-controlled.
+- **🔗 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.
+
+### 🏗️ 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.
+- **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.
+
+### 🔧 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.
+- **Error Reporting** — Structured errors with file, line, and context. Fast-fail on validation errors.
+
+---
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `cadforge new ` | Create a new project with multi-layer scaffold |
+| `cadforge init` | Initialize CADforge in current directory |
+| `cadforge build` | Compile project to DXF |
+| `cadforge build --check` | Validate project and constraints without generating DXF |
+| `cadforge build --output ` | Compile to custom output path |
+| `cadforge build --layer ` | Compile specific layer only |
+| `cadforge check` | Validate with project metadata and layer colors |
+| `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 preview --layer ` | Preview specific layer only |
+| `cadforge fmt` | Format .cf files (normalize whitespace) |
+| `cadforge fmt --check` | Check formatting without modifying (CI) |
+| `cadforge watch` | Auto-rebuild on file changes |
+| `cadforge import ` | Import DXF into `.cf` layers + `project.toml` |
+| `cadforge import --layer ` | Import only one DXF layer |
+| `cadforge view` | Open the dedicated `cadforge-view` viewer |
+| `cadforge view --layer ` | Open only one layer in the viewer |
+| `cadforge config set ` | Set global defaults (`author`, `units`) |
+| `cadforge config show` | Show global defaults |
+
+### Viewer controls (MVP)
+
+- 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
+
+---
+
+## .cf Format
+
+```toml
+[layer]
+name = "muros"
+color = "#FFFFFF"
+
+[[line]]
+id = "ln-001"
+from = [0.0, 0.0]
+to = [8.5, 0.0]
+weight = 0.50
+
+[[rect]]
+id = "rc-001"
+origin = [1.0, 1.0]
+width = 3.5
+height = 4.0
+
+[[circle]]
+id = "ci-001"
+center = [4.0, 3.0]
+radius = 0.5
+
+[[arc]]
+id = "ac-001"
+center = [2.0, 2.0]
+radius = 0.9
+from_angle = 0.0
+to_angle = 90.0
+
+[[polyline]]
+id = "pl-001"
+vertices = [[0, 0], [5, 0], [5, 3], [0, 3]]
+closed = true
+
+[[text]]
+id = "tx-001"
+position = [4.0, 3.0]
+content = "SALA"
+size = 0.2
+
+[[dim]]
+id = "dm-001"
+from = [0, 0]
+to = [5, 0]
+offset = 0.5
+```
-## Getting started
+### Supported Primitives
-### Prerequisites
+`line`, `polyline`, `rect`, `circle`, `arc`, `text`, `point`, `dim`, `hatch`, `solid`
-- Rust 1.70+
-- Cargo
+---
-### Build
+## Architecture Overview
-```bash
-cargo build --release
+```
+.cf file (TOML)
+ │
+ ▼
+┌─────────┐ ┌──────────┐ ┌─────────┐ ┌─────────┐
+│ Parser │───▶│ Resolver │───▶│Compiler │───▶│ DXF Emit│
+└─────────┘ └──────────┘ └─────────┘ └─────────┘
+ │
+ ▼
+ ┌──────────┐ ┌─────────────┐
+ │ Boundary │───▶│ Preview PNG │
+ │ Resolver │ │ + JSON meta │
+ └──────────┘ └─────────────┘
```
-### Run
+- **Parser** — TOML parsing with custom array-of-tables detection, primitive validation
+- **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
-```bash
-cargo run
-```
+---
+
+## Main Modules
+
+- `compiler/` — Project compilation pipeline, layer targeting, validation, build stats
+- `dxf_writer/` — DXF entity writing, LWPOLYLINE, HATCH, MTEXT generation
+- `preview/` — PNG rendering with configurable resolution, layer filtering, metadata JSON
+- `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
+- `color/` — Color parsing and DXF color mapping
+
+---
+
+## Data Storage
+
+| 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 |
-### Test
+---
+## Usage
+
+**Create a new project:**
```bash
-cargo test
+cadforge new mi-proyecto
+cd mi-proyecto
```
-## Development
+**Edit `.cf` files** (TOML format with your geometry)
-### Format code
+**Format and validate:**
+```bash
+cadforge fmt # normalize .cf files
+cadforge check # validate without generating DXF
+```
+**Compile to DXF:**
```bash
-cargo fmt
+cadforge build # default output.dxf
+cadforge build --output plano.dxf # custom output path
+cadforge build --layer muros # compile single layer
```
-### Lint with Clippy
+**Preview:**
+```bash
+cadforge preview # default 2048x1536
+cadforge preview --width 1024 --height 768 # custom resolution
+cadforge preview --layer muros # single layer preview
+```
+**Auto-rebuild on changes:**
```bash
-cargo clippy -- -D warnings
+cadforge watch # monitors .cf and .toml files
```
+---
+
+## Tech Stack
+
+| Rust 2021 | clap | toml | toml_edit | tiny-skia | dxf | notify | anyhow | serde |
+
+---
+
## License
-This project is licensed under the MIT License — see LICENSE for details.
+MIT — see [LICENSE](LICENSE) for details.
+
+---
+
+Made with ❤️ by [JheisonMB](https://github.com/JheisonMB) and [UniverLab](https://github.com/UniverLab)
\ No newline at end of file
diff --git a/crates/cadforge-cli/Cargo.toml b/crates/cadforge-cli/Cargo.toml
new file mode 100644
index 0000000..4419455
--- /dev/null
+++ b/crates/cadforge-cli/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "cadforge-cli"
+version = "0.1.0-beta.1"
+edition = "2021"
+description = "CLI binary for cadforge"
+license = "MIT"
+publish = true
+
+[[bin]]
+name = "cadforge"
+path = "src/main.rs"
+
+[dependencies]
+anyhow = "1.0"
+clap = { version = "4.6", features = ["derive"] }
+cadforge = { path = "../.." }
+cadforge-view = { path = "../cadforge-view" }
diff --git a/crates/cadforge-cli/src/main.rs b/crates/cadforge-cli/src/main.rs
new file mode 100644
index 0000000..f21420a
--- /dev/null
+++ b/crates/cadforge-cli/src/main.rs
@@ -0,0 +1,204 @@
+use anyhow::{bail, Result};
+use cadforge::compiler::{check_project, compile_project, list_layers};
+use cadforge::config::{config_set, config_show};
+use cadforge::fmt::format_project;
+use cadforge::importer::import_dxf;
+use cadforge::preview::generate_preview;
+use cadforge::scaffold::{create_project, init_project};
+use cadforge::watch::watch_project;
+use cadforge_view::run_viewer;
+use clap::{Parser, Subcommand};
+use std::path::PathBuf;
+
+#[derive(Parser)]
+#[command(
+ name = "cadforge",
+ version,
+ about = "Architecture as Code — declarative geometry → DXF"
+)]
+struct Cli {
+ #[command(subcommand)]
+ command: Commands,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ /// Create a new CADforge project
+ New {
+ /// Project name (creates a directory with this name)
+ name: String,
+ },
+ /// Initialize CADforge in the current directory
+ Init,
+ /// Compile project (.cf files) → DXF output
+ Build {
+ /// Project directory (defaults to current dir)
+ #[arg(short, long)]
+ path: Option,
+ /// Compile only a specific layer
+ #[arg(short, long)]
+ layer: Option,
+ /// Output file path (defaults to output.dxf in project dir)
+ #[arg(short, long)]
+ output: Option,
+ /// Validate constraints and geometry without generating DXF
+ #[arg(long)]
+ check: bool,
+ },
+ /// Validate project without generating DXF
+ Check {
+ /// Project directory (defaults to current dir)
+ #[arg(short, long)]
+ path: Option,
+ },
+ /// List project layers with status
+ Layers {
+ /// Project directory (defaults to current dir)
+ #[arg(short, long)]
+ path: Option,
+ },
+ /// Generate PNG preview + metadata JSON 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")]
+ width: u32,
+ /// Image height in pixels
+ #[arg(short, long, default_value = "1536")]
+ height: u32,
+ /// Render only a specific layer
+ #[arg(short, long)]
+ layer: Option,
+ },
+ /// Format .cf files (sort keys, normalize whitespace)
+ Fmt {
+ /// Project directory (defaults to current dir)
+ #[arg(short, long)]
+ path: Option,
+ /// Check formatting without modifying files
+ #[arg(long)]
+ check: bool,
+ },
+ /// Watch project files and auto-rebuild on changes
+ Watch {
+ /// Project directory (defaults to current dir)
+ #[arg(short, long)]
+ path: Option,
+ },
+ /// Import a DXF file into CADforge project files
+ Import {
+ /// Input DXF file
+ input: PathBuf,
+ /// Output directory for generated project (defaults to current dir)
+ #[arg(short, long)]
+ output: Option,
+ /// Import only one DXF layer
+ #[arg(short, long)]
+ layer: Option,
+ },
+ /// Open project output in external viewer
+ View {
+ /// Project directory (defaults to current dir)
+ #[arg(short, long)]
+ path: Option,
+ /// View only one layer
+ #[arg(short, long)]
+ layer: Option,
+ },
+ /// Global cadforge configuration
+ Config {
+ #[command(subcommand)]
+ command: ConfigCommands,
+ },
+}
+
+#[derive(Subcommand)]
+enum ConfigCommands {
+ /// Set global default value
+ Set {
+ /// Config key (author | units)
+ key: String,
+ /// Value to store
+ value: String,
+ },
+ /// Show global configuration
+ Show,
+}
+
+fn main() -> Result<()> {
+ let cli = Cli::parse();
+
+ match cli.command {
+ Commands::New { name } => create_project(&name, &PathBuf::from(".")),
+ Commands::Init => init_project(&PathBuf::from(".")),
+ Commands::Build {
+ path,
+ layer,
+ output,
+ check,
+ } => {
+ let dir = resolve_project_dir(path)?;
+ if check {
+ check_project(&dir)?;
+ Ok(())
+ } else {
+ compile_project(&dir, layer.as_deref(), output.as_deref())
+ }
+ }
+ Commands::Check { path } => {
+ let dir = resolve_project_dir(path)?;
+ check_project(&dir)?;
+ Ok(())
+ }
+ Commands::Layers { path } => {
+ let dir = resolve_project_dir(path)?;
+ list_layers(&dir)
+ }
+ Commands::Preview {
+ path,
+ width,
+ height,
+ layer,
+ } => {
+ let dir = resolve_project_dir(path)?;
+ generate_preview(&dir, width, height, layer.as_deref())
+ }
+ Commands::Fmt { path, check } => {
+ let dir = resolve_project_dir(path)?;
+ format_project(&dir, check)
+ }
+ Commands::Watch { path } => {
+ let dir = resolve_project_dir(path)?;
+ watch_project(&dir)
+ }
+ Commands::Import {
+ input,
+ output,
+ layer,
+ } => {
+ let out_dir = output.unwrap_or_else(|| PathBuf::from("."));
+ import_dxf(&input, &out_dir, layer.as_deref())
+ }
+ Commands::View { path, layer } => {
+ let dir = resolve_project_dir(path)?;
+ run_viewer(&dir, layer.as_deref())
+ }
+ Commands::Config { command } => match command {
+ ConfigCommands::Set { key, value } => config_set(&key, &value),
+ ConfigCommands::Show => config_show(),
+ },
+ }
+}
+
+fn resolve_project_dir(path: Option) -> Result {
+ let dir = path.unwrap_or_else(|| PathBuf::from("."));
+ if !dir.join("project.toml").exists() {
+ bail!(
+ "No project.toml found in '{}'. Run `cadforge new` to create a project.",
+ dir.display()
+ );
+ }
+ Ok(dir)
+}
diff --git a/crates/cadforge-view/Cargo.toml b/crates/cadforge-view/Cargo.toml
new file mode 100644
index 0000000..8ccbee4
--- /dev/null
+++ b/crates/cadforge-view/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "cadforge-view"
+version = "0.1.0-beta.1"
+edition = "2021"
+description = "Vector viewer for cadforge projects"
+license = "MIT"
+publish = true
+
+[[bin]]
+name = "cadforge-view"
+path = "src/main.rs"
+
+[dependencies]
+anyhow = "1.0"
+arboard = "3.4"
+bytemuck = { version = "1.16", features = ["derive"] }
+cadforge = { path = "../.." }
+pollster = "0.3"
+wgpu = "0.20"
+winit = "0.30"
diff --git a/crates/cadforge-view/src/lib.rs b/crates/cadforge-view/src/lib.rs
new file mode 100644
index 0000000..256dda6
--- /dev/null
+++ b/crates/cadforge-view/src/lib.rs
@@ -0,0 +1,11 @@
+//! cadforge-view — vector viewer for cadforge projects.
+//!
+//! Stub crate; the wgpu-backed renderer lands in a follow-up commit.
+
+use anyhow::Result;
+use std::path::Path;
+
+pub fn run_viewer(project_dir: &Path, layer: Option<&str>) -> Result<()> {
+ let _ = (project_dir, layer);
+ Ok(())
+}
diff --git a/crates/cadforge-view/src/main.rs b/crates/cadforge-view/src/main.rs
new file mode 100644
index 0000000..9bd7b95
--- /dev/null
+++ b/crates/cadforge-view/src/main.rs
@@ -0,0 +1,28 @@
+use anyhow::Result;
+use cadforge_view::run_viewer;
+use std::env;
+use std::path::PathBuf;
+
+fn main() -> Result<()> {
+ let args: Vec = env::args().skip(1).collect();
+ let mut path: Option = None;
+ let mut layer: Option = None;
+
+ let mut iter = args.into_iter();
+ while let Some(arg) = iter.next() {
+ match arg.as_str() {
+ "--path" | "-p" => path = iter.next().map(PathBuf::from),
+ "--layer" | "-l" => layer = iter.next(),
+ other if other.starts_with("--path=") => {
+ path = Some(PathBuf::from(&other["--path=".len()..]));
+ }
+ other if other.starts_with("--layer=") => {
+ layer = Some(other["--layer=".len()..].to_string());
+ }
+ _ => {}
+ }
+ }
+
+ let dir = path.unwrap_or_else(|| PathBuf::from("."));
+ run_viewer(&dir, layer.as_deref())
+}
diff --git a/examples/vivienda/achurados.cf b/examples/vivienda/achurados.cf
new file mode 100644
index 0000000..c1f6105
--- /dev/null
+++ b/examples/vivienda/achurados.cf
@@ -0,0 +1,70 @@
+[layer]
+name = "achurados"
+color = "#C0C0C0"
+
+# ── Achurado del bano ───────────────────────────────────────
+[[polyline]]
+id = "pl-bano-boundary"
+points = [[0.0, 3.0], [2.0, 3.0], [2.0, 5.0], [0.0, 5.0]]
+closed = true
+style = "dotted"
+
+[[hatch]]
+id = "ht-bano"
+boundary = "pl-bano-boundary"
+pattern = "ansi31"
+scale = 2.0
+angle = 45.0
+
+# ── Relleno solido del closet dormitorio 1 ─────────────────
+[[fill]]
+id = "fl-closet-d1"
+boundary = "rc-closet-d1"
+color = "#E0E0E0"
+
+# ── Achurado de la cocina ───────────────────────────────────
+[[polyline]]
+id = "pl-cocina-boundary"
+points = [[5.0, 3.5], [12.0, 3.5], [12.0, 5.0], [5.0, 5.0]]
+closed = true
+style = "dotted"
+
+[[hatch]]
+id = "ht-cocina"
+boundary = "pl-cocina-boundary"
+pattern = "ansi31"
+scale = 3.0
+angle = 135.0
+
+# ── Ventanales (lineas punteadas en muros) ──────────────────
+[[line]]
+id = "ln-ventana-norte"
+from = [2.0, 9.0]
+to = [4.0, 9.0]
+style = "dashed"
+weight = 0.18
+color = "#00CC44"
+
+[[line]]
+id = "ln-ventana-sur-d2"
+from = [7.0, 0.0]
+to = [10.0, 0.0]
+style = "dashed"
+weight = 0.18
+color = "#00CC44"
+
+[[line]]
+id = "ln-ventana-este"
+from = [12.0, 6.0]
+to = [12.0, 8.0]
+style = "dashed"
+weight = 0.18
+color = "#00CC44"
+
+[[line]]
+id = "ln-ventana-oeste"
+from = [0.0, 3.5]
+to = [0.0, 4.5]
+style = "dashed"
+weight = 0.18
+color = "#00CC44"
\ No newline at end of file
diff --git a/examples/vivienda/cotas.cf b/examples/vivienda/cotas.cf
new file mode 100644
index 0000000..8a4a4dd
--- /dev/null
+++ b/examples/vivienda/cotas.cf
@@ -0,0 +1,79 @@
+[layer]
+name = "cotas"
+color = "#FF4444"
+
+# ── Cotas exteriores ────────────────────────────────────────
+
+[[dim]]
+id = "dm-ancho-total"
+type = "linear"
+from = [0.0, 0.0]
+to = [12.0, 0.0]
+offset = -1.2
+
+[[dim]]
+id = "dm-alto-total"
+type = "linear"
+from = [0.0, 0.0]
+to = [0.0, 9.0]
+offset = -1.2
+
+# ── Cotas interiores ───────────────────────────────────────
+
+[[dim]]
+id = "dm-sala-ancho"
+type = "linear"
+from = [5.0, 5.0]
+to = [12.0, 5.0]
+offset = 0.8
+
+[[dim]]
+id = "dm-sala-alto"
+type = "linear"
+from = [5.0, 5.0]
+to = [5.0, 9.0]
+offset = 0.6
+
+[[dim]]
+id = "dm-d1-ancho"
+type = "linear"
+from = [0.0, 5.0]
+to = [5.0, 5.0]
+offset = 0.8
+
+[[dim]]
+id = "dm-d2-ancho"
+type = "linear"
+from = [5.0, 0.0]
+to = [12.0, 0.0]
+offset = -0.6
+
+[[dim]]
+id = "dm-d2-alto"
+type = "linear"
+from = [5.0, 0.0]
+to = [5.0, 3.0]
+offset = 0.6
+
+[[dim]]
+id = "dm-bano-alto"
+type = "linear"
+from = [0.0, 3.0]
+to = [0.0, 5.0]
+offset = -0.6
+
+# ── Ejes de referencia ──────────────────────────────────────
+
+[[line]]
+id = "ln-eje-v"
+from = [6.0, -0.5]
+to = [6.0, 9.5]
+style = "dashdot"
+color = "#00CCCC"
+
+[[line]]
+id = "ln-eje-h"
+from = [-0.5, 5.0]
+to = [12.5, 5.0]
+style = "dashdot"
+color = "#00CCCC"
\ No newline at end of file
diff --git a/examples/vivienda/mobiliario.cf b/examples/vivienda/mobiliario.cf
new file mode 100644
index 0000000..a8b8ca5
--- /dev/null
+++ b/examples/vivienda/mobiliario.cf
@@ -0,0 +1,168 @@
+[layer]
+name = "mobiliario"
+color = "#4488FF"
+
+# ── Sala ─────────────────────────────────────────────────────
+
+# Sofa
+[[rect]]
+id = "rc-sofa"
+origin = [6.0, 7.5]
+width = 3.0
+height = 1.0
+color = "#4488FF"
+
+# Mesa de centro
+[[rect]]
+id = "rc-mesa-centro"
+origin = [7.0, 6.5]
+width = 1.5
+height = 0.8
+color = "#66AAFF"
+
+# TV
+[[rect]]
+id = "rc-tv"
+origin = [5.5, 5.5]
+width = 0.2
+height = 2.5
+color = "#333333"
+
+# Silla
+[[circle]]
+id = "ci-silla"
+center = [10.0, 7.0]
+radius = 0.3
+color = "#66AAFF"
+
+# ── Dormitorio 1 ────────────────────────────────────────────
+
+# Cama doble
+[[rect]]
+id = "rc-cama-d1"
+origin = [1.0, 6.2]
+width = 2.0
+height = 2.5
+color = "#4488FF"
+
+# Mesita de noche 1
+[[rect]]
+id = "rc-mesita1"
+origin = [0.2, 7.5]
+width = 0.6
+height = 0.5
+color = "#66AAFF"
+
+# Mesita de noche 2
+[[rect]]
+id = "rc-mesita2"
+origin = [3.2, 7.5]
+width = 0.6
+height = 0.5
+color = "#66AAFF"
+
+# ── Dormitorio 2 ────────────────────────────────────────────
+
+# Cama individual
+[[rect]]
+id = "rc-cama-d2"
+origin = [7.5, 0.5]
+width = 1.5
+height = 2.0
+color = "#4488FF"
+
+# Escritorio
+[[rect]]
+id = "rc-escritorio"
+origin = [10.0, 0.5]
+width = 1.5
+height = 0.8
+color = "#66AAFF"
+
+# Silla escritorio
+[[circle]]
+id = "ci-silla-esc"
+center = [10.75, 1.8]
+radius = 0.25
+color = "#66AAFF"
+
+# ── Cocina ──────────────────────────────────────────────────
+
+# Cocina (mesada)
+[[rect]]
+id = "rc-mesada"
+origin = [9.0, 4.0]
+width = 2.5
+height = 0.6
+color = "#88CCFF"
+
+# Bacha
+[[circle]]
+id = "ci-bacha"
+center = [10.5, 4.3]
+radius = 0.2
+color = "#4488FF"
+
+# Heladera
+[[rect]]
+id = "rc-heladera"
+origin = [5.5, 4.0]
+width = 0.7
+height = 0.7
+color = "#AADDFF"
+
+# ── Bano ─────────────────────────────────────────────────────
+
+# Inodoro
+[[circle]]
+id = "ci-inodoro"
+center = [1.0, 4.5]
+radius = 0.2
+color = "#4488FF"
+
+# Lavamanos
+[[circle]]
+id = "ci-lavamanos"
+center = [1.0, 3.5]
+radius = 0.2
+color = "#66AAFF"
+
+# Ducha
+[[rect]]
+id = "rc-ducha"
+origin = [0.1, 3.1]
+width = 0.8
+height = 0.8
+color = "#88CCFF"
+
+# ── Etiquetas de ambientes ──────────────────────────────────
+
+[[text]]
+id = "tx-sala"
+position = [7.5, 6.8]
+content = "SALA"
+size = 0.30
+
+[[text]]
+id = "tx-d1"
+position = [1.5, 7.2]
+content = "DORM 1"
+size = 0.25
+
+[[text]]
+id = "tx-d2"
+position = [7.5, 1.5]
+content = "DORM 2"
+size = 0.25
+
+[[text]]
+id = "tx-cocina"
+position = [8.0, 4.6]
+content = "COCINA"
+size = 0.22
+
+[[text]]
+id = "tx-bano"
+position = [0.5, 3.8]
+content = "BANO"
+size = 0.18
\ No newline at end of file
diff --git a/examples/vivienda/muros.cf b/examples/vivienda/muros.cf
new file mode 100644
index 0000000..6f4d37f
--- /dev/null
+++ b/examples/vivienda/muros.cf
@@ -0,0 +1,89 @@
+[layer]
+name = "muros"
+color = "#FFFFFF"
+line_weight = 0.50
+
+# ── Perimetro exterior ──────────────────────────────────────
+[[polyline]]
+id = "pl-perimetro"
+points = [[0.0, 0.0], [12.0, 0.0], [12.0, 9.0], [0.0, 9.0]]
+closed = true
+weight = 0.50
+
+# ── Muro horizontal cocina/sala ────────────────────────────
+[[polyline]]
+id = "pl-muro-h1"
+points = [[5.0, 5.0], [12.0, 5.0]]
+weight = 0.35
+
+# ── Muro vertical sala/corredor ────────────────────────────
+[[line]]
+id = "ln-muro-v1"
+from = [5.0, 5.0]
+to = [5.0, 9.0]
+weight = 0.35
+
+# ── Muros dormitorio 1 ─────────────────────────────────────
+[[line]]
+id = "ln-muro-d1-h"
+from = [0.0, 5.0]
+to = [2.0, 5.0]
+weight = 0.35
+
+[[line]]
+id = "ln-muro-d1-h2"
+from = [3.0, 5.0]
+to = [5.0, 5.0]
+weight = 0.35
+
+[[line]]
+id = "ln-muro-d1-v"
+from = [5.0, 5.0]
+to = [5.0, 7.0]
+weight = 0.35
+
+[[line]]
+id = "ln-muro-d1-v2"
+from = [5.0, 8.0]
+to = [5.0, 9.0]
+weight = 0.35
+
+# ── Muros banio ─────────────────────────────────────────────
+[[polyline]]
+id = "pl-bano"
+points = [[0.0, 5.0], [2.0, 5.0], [2.0, 3.0], [0.0, 3.0]]
+closed = true
+weight = 0.25
+
+[[line]]
+id = "ln-muro-bano-h"
+from = [2.0, 3.0]
+to = [2.0, 4.0]
+weight = 0.20
+
+[[line]]
+id = "ln-muro-bano-v1"
+from = [0.0, 4.0]
+to = [0.8, 4.0]
+weight = 0.20
+
+# ── Muro dormitorio 2 (abajo) ──────────────────────────────
+[[polyline]]
+id = "pl-muro-d2"
+points = [[5.0, 0.0], [12.0, 0.0], [12.0, 3.0], [5.0, 3.0]]
+closed = true
+weight = 0.35
+
+[[line]]
+id = "ln-muro-d2-puerta"
+from = [7.0, 3.0]
+to = [7.0, 2.0]
+weight = 0.15
+
+# ── Closet en dormitorio 1 ───────────────────────────────────
+[[rect]]
+id = "rc-closet-d1"
+origin = [0.0, 7.0]
+width = 1.2
+height = 2.0
+weight = 0.15
\ No newline at end of file
diff --git a/examples/vivienda/project.toml b/examples/vivienda/project.toml
new file mode 100644
index 0000000..b94b426
--- /dev/null
+++ b/examples/vivienda/project.toml
@@ -0,0 +1,13 @@
+[project]
+name = "Vivienda Unifamiliar Lote 12"
+scale = "1:100"
+units = "m"
+author = "Arq. Demo"
+version = "0.1.0"
+
+[layers]
+muros = { file = "muros.cf", locked = false }
+puertas = { file = "puertas.cf", locked = false }
+mobiliario = { file = "mobiliario.cf", locked = false }
+cotas = { file = "cotas.cf", locked = false }
+achurados = { file = "achurados.cf", locked = false }
diff --git a/examples/vivienda/puertas.cf b/examples/vivienda/puertas.cf
new file mode 100644
index 0000000..5fb8b13
--- /dev/null
+++ b/examples/vivienda/puertas.cf
@@ -0,0 +1,78 @@
+[layer]
+name = "puertas"
+color = "#00CC44"
+
+# ── Puerta principal (entrada) ──────────────────────────────
+[[arc]]
+id = "ar-puerta-principal"
+center = [12.0, 5.8]
+radius = 0.9
+from_angle = 90.0
+to_angle = 180.0
+
+[[line]]
+id = "ln-puerta-principal"
+from = [12.0, 5.8]
+to = [12.0, 4.9]
+weight = 0.12
+style = "dashed"
+
+# ── Puerta dormitorio 1 ─────────────────────────────────────
+[[arc]]
+id = "ar-puerta-d1"
+center = [5.0, 7.0]
+radius = 0.8
+from_angle = 180.0
+to_angle = 270.0
+
+[[line]]
+id = "ln-puerta-d1"
+from = [5.0, 7.0]
+to = [4.2, 7.0]
+weight = 0.12
+style = "dashed"
+
+# ── Puerta bano ─────────────────────────────────────────────
+[[arc]]
+id = "ar-puerta-bano"
+center = [2.0, 4.0]
+radius = 0.7
+from_angle = 0.0
+to_angle = 90.0
+
+[[line]]
+id = "ln-puerta-bano"
+from = [2.0, 4.0]
+to = [2.7, 4.0]
+weight = 0.12
+style = "dashed"
+
+# ── Puerta dormitorio 2 ─────────────────────────────────────
+[[arc]]
+id = "ar-puerta-d2"
+center = [7.0, 3.0]
+radius = 0.8
+from_angle = 90.0
+to_angle = 180.0
+
+[[line]]
+id = "ln-puerta-d2"
+from = [7.0, 3.0]
+to = [7.0, 2.2]
+weight = 0.12
+style = "dashed"
+
+# ── Puerta cocina ───────────────────────────────────────────
+[[arc]]
+id = "ar-puerta-cocina"
+center = [8.0, 5.0]
+radius = 0.8
+from_angle = 0.0
+to_angle = 90.0
+
+[[line]]
+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
diff --git a/src/color.rs b/src/color.rs
new file mode 100644
index 0000000..9c97039
--- /dev/null
+++ b/src/color.rs
@@ -0,0 +1,55 @@
+//! Color and weight conversion utilities for DXF output.
+
+/// ACI color index from hex string (best-effort mapping to standard palette).
+pub fn hex_to_aci(hex: &str) -> u8 {
+ match hex.to_uppercase().trim_start_matches('#') {
+ "FF0000" => 1, // red
+ "FFFF00" => 2, // yellow
+ "00FF00" => 3, // green
+ "00FFFF" => 4, // cyan
+ "0000FF" => 5, // blue
+ "FF00FF" => 6, // magenta
+ "FFFFFF" => 7, // white
+ "808080" => 8, // dark grey
+ "C0C0C0" => 9, // light grey
+ _ => 7, // default white
+ }
+}
+
+/// 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('#');
+ i32::from_str_radix(hex, 16).unwrap_or(0x00FF_FFFF)
+}
+
+/// Lineweight in mm → DXF lineweight enum value (hundredths of mm).
+pub fn weight_to_dxf(mm: f64) -> i16 {
+ (mm * 100.0) as i16
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn hex_to_aci_maps_standard_colors() {
+ assert_eq!(hex_to_aci("#FF0000"), 1);
+ assert_eq!(hex_to_aci("#00FF00"), 3);
+ assert_eq!(hex_to_aci("#FFFFFF"), 7);
+ assert_eq!(hex_to_aci("#123456"), 7); // unknown → white
+ }
+
+ #[test]
+ fn hex_to_24bit_parses_correctly() {
+ assert_eq!(hex_to_24bit("#FF0000"), 0xFF0000);
+ assert_eq!(hex_to_24bit("00FF00"), 0x00FF00);
+ assert_eq!(hex_to_24bit("#invalid"), 0x00FF_FFFF);
+ }
+
+ #[test]
+ fn weight_converts_mm_to_hundredths() {
+ assert_eq!(weight_to_dxf(0.35), 35);
+ assert_eq!(weight_to_dxf(0.50), 50);
+ assert_eq!(weight_to_dxf(1.0), 100);
+ }
+}
diff --git a/src/compiler.rs b/src/compiler.rs
new file mode 100644
index 0000000..359635c
--- /dev/null
+++ b/src/compiler.rs
@@ -0,0 +1,641 @@
+//! Compiler — transforms the intermediate model into a DXF file via DxfWriter.
+
+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 anyhow::{bail, Context, Result};
+use indexmap::IndexMap;
+use std::collections::HashSet;
+use std::path::Path;
+
+#[derive(Debug, Clone, Copy)]
+struct Bounds {
+ min_x: f64,
+ min_y: f64,
+ max_x: f64,
+ max_y: f64,
+}
+
+impl Bounds {
+ fn new(x: f64, y: f64) -> Self {
+ Self {
+ min_x: x,
+ min_y: y,
+ max_x: x,
+ max_y: y,
+ }
+ }
+
+ fn include_point(&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 contains(&self, other: &Bounds) -> bool {
+ other.min_x >= self.min_x
+ && other.min_y >= self.min_y
+ && other.max_x <= self.max_x
+ && other.max_y <= self.max_y
+ }
+}
+
+#[derive(Default)]
+struct ConstraintRules {
+ parent: Vec<(String, String)>,
+ belongs_to: Vec<(String, String)>,
+ spatial_dependency: Vec<(String, String)>,
+ strict: bool,
+}
+
+// ── Style resolution (DRY: one place to convert CommonAttrs → EntityStyle) ──
+
+fn resolve_style(common: &CommonAttrs) -> EntityStyle {
+ EntityStyle {
+ color_24bit: common.color.as_deref().map(hex_to_24bit),
+ lineweight: common.weight.map(weight_to_dxf),
+ line_type: common.style.as_ref().map(line_style_to_dxf_name),
+ }
+}
+
+fn line_style_to_dxf_name(style: &LineStyle) -> String {
+ match style {
+ LineStyle::Solid => "CONTINUOUS".to_string(),
+ LineStyle::Dashed => "DASHED".to_string(),
+ LineStyle::Dotted => "DOTTED".to_string(),
+ LineStyle::Dashdot => "DASHDOT".to_string(),
+ }
+}
+
+fn resolve_layer<'a>(common: &'a CommonAttrs, default: &'a str) -> &'a str {
+ common.layer.as_deref().unwrap_or(default)
+}
+
+fn load_layers(
+ project_dir: &Path,
+ layers: &IndexMap,
+) -> Result> {
+ let mut loaded = IndexMap::with_capacity(layers.len());
+ 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);
+ }
+ Ok(loaded)
+}
+
+fn extract_constraint_rules(project: &ProjectFile) -> ConstraintRules {
+ let mut rules = ConstraintRules::default();
+ let Some(toml::Value::Table(table)) = project.constraints.as_ref() else {
+ return rules;
+ };
+
+ for (key, value) in table {
+ if key == "strict" {
+ if let toml::Value::Boolean(strict) = value {
+ rules.strict = *strict;
+ }
+ continue;
+ }
+
+ if key.contains('→') {
+ if let toml::Value::String(kind) = value {
+ if kind == "spatial_dependency" {
+ let mut parts = key.split('→').map(|s| s.trim().to_string());
+ if let (Some(from), Some(to)) = (parts.next(), parts.next()) {
+ rules.spatial_dependency.push((from, to));
+ }
+ }
+ }
+ continue;
+ }
+
+ if let toml::Value::Table(child_rules) = value {
+ if let Some(toml::Value::String(parent)) = child_rules.get("parent") {
+ rules.parent.push((key.clone(), parent.clone()));
+ }
+ if let Some(toml::Value::String(parent)) = child_rules.get("belongs_to") {
+ rules.belongs_to.push((key.clone(), parent.clone()));
+ }
+ }
+ }
+
+ rules
+}
+
+fn layer_bbox(cf: &CfFile) -> Option {
+ let mut bounds: Option = None;
+ let mut include = |x: f64, y: f64| {
+ if let Some(b) = bounds.as_mut() {
+ b.include_point(x, y);
+ } else {
+ bounds = Some(Bounds::new(x, y));
+ }
+ };
+
+ for e in &cf.lines {
+ include(e.from[0], e.from[1]);
+ include(e.to[0], e.to[1]);
+ }
+ for e in &cf.polylines {
+ for p in &e.points {
+ include(p[0], p[1]);
+ }
+ }
+ for e in &cf.rects {
+ include(e.origin[0], e.origin[1]);
+ include(e.origin[0] + e.width, e.origin[1] + e.height);
+ }
+ for e in &cf.circles {
+ include(e.center[0] - e.radius, e.center[1] - e.radius);
+ include(e.center[0] + e.radius, e.center[1] + e.radius);
+ }
+ for e in &cf.arcs {
+ include(e.center[0] - e.radius, e.center[1] - e.radius);
+ include(e.center[0] + e.radius, e.center[1] + e.radius);
+ }
+ for e in &cf.texts {
+ include(e.position[0], e.position[1]);
+ }
+ for e in &cf.points {
+ include(e.position[0], e.position[1]);
+ }
+ for e in &cf.dims {
+ include(e.from[0], e.from[1]);
+ include(e.to[0], e.to[1]);
+ }
+ for e in &cf.fills {
+ if let Some(points) = &e.points {
+ for p in points {
+ include(p[0], p[1]);
+ }
+ }
+ }
+
+ bounds
+}
+
+fn collect_layer_ids(cf: &CfFile) -> HashSet {
+ let mut ids = HashSet::new();
+ for_each_common(cf, |common| {
+ if let Some(id) = &common.id {
+ ids.insert(id.clone());
+ }
+ });
+ ids
+}
+
+fn for_each_common(cf: &CfFile, mut f: impl FnMut(&CommonAttrs)) {
+ for e in &cf.lines {
+ f(&e.common);
+ }
+ for e in &cf.polylines {
+ f(&e.common);
+ }
+ for e in &cf.rects {
+ f(&e.common);
+ }
+ for e in &cf.circles {
+ f(&e.common);
+ }
+ for e in &cf.arcs {
+ f(&e.common);
+ }
+ for e in &cf.texts {
+ f(&e.common);
+ }
+ for e in &cf.points {
+ f(&e.common);
+ }
+ for e in &cf.dims {
+ f(&e.common);
+ }
+ for e in &cf.hatches {
+ f(&e.common);
+ }
+ for e in &cf.fills {
+ f(&e.common);
+ }
+ for e in &cf.groups {
+ f(&e.common);
+ }
+}
+
+fn validate_constraints(project: &ProjectFile, layers: &IndexMap) -> Vec {
+ let rules = extract_constraint_rules(project);
+ let mut issues = Vec::new();
+
+ for (child, parent) in &rules.parent {
+ match (layers.get(child), layers.get(parent)) {
+ (Some(child_cf), Some(parent_cf)) => {
+ let child_bbox = layer_bbox(child_cf);
+ let parent_bbox = layer_bbox(parent_cf);
+ match (child_bbox, parent_bbox) {
+ (Some(c), Some(p)) => {
+ if !p.contains(&c) {
+ issues.push(format!(
+ "Layer '{}' violates parent='{}': child bbox [{:.2}, {:.2}]->[{:.2}, {:.2}] is outside parent bbox [{:.2}, {:.2}]->[{:.2}, {:.2}]",
+ child, parent, c.min_x, c.min_y, c.max_x, c.max_y, p.min_x, p.min_y, p.max_x, p.max_y
+ ));
+ }
+ }
+ _ => {
+ issues.push(format!(
+ "Layer '{}' parent='{}' cannot be validated because one layer has no measurable geometry",
+ child, parent
+ ));
+ }
+ }
+ }
+ _ => issues.push(format!(
+ "Invalid parent constraint: '{}' or '{}' layer does not exist",
+ child, parent
+ )),
+ }
+ }
+
+ for (child, parent) in &rules.belongs_to {
+ match (layers.get(child), layers.get(parent)) {
+ (Some(child_cf), Some(parent_cf)) => {
+ let parent_ids = collect_layer_ids(parent_cf);
+ let mut total = 0usize;
+ let mut referenced = 0usize;
+
+ for_each_common(child_cf, |common| {
+ total += 1;
+ if let Some(reference) = &common.belongs_to {
+ referenced += 1;
+ if !parent_ids.contains(reference) {
+ issues.push(format!(
+ "Layer '{}' has belongs_to='{}' but id does not exist in parent layer '{}'",
+ child, reference, parent
+ ));
+ }
+ }
+ });
+
+ if total > 0 && referenced == 0 {
+ issues.push(format!(
+ "Layer '{}' has belongs_to='{}' constraint but no primitives define belongs_to references",
+ child, parent
+ ));
+ }
+ }
+ _ => issues.push(format!(
+ "Invalid belongs_to constraint: '{}' or '{}' layer does not exist",
+ child, parent
+ )),
+ }
+ }
+
+ for (from, to) in &rules.spatial_dependency {
+ if layers.contains_key(from) && layers.contains_key(to) {
+ issues.push(format!(
+ "spatial_dependency '{}' -> '{}' registered; dynamic movement tracking is not implemented yet (warning only)",
+ from, to
+ ));
+ } else {
+ issues.push(format!(
+ "Invalid spatial_dependency '{}' -> '{}': one layer does not exist",
+ from, to
+ ));
+ }
+ }
+
+ issues
+}
+
+fn is_strict(project: &ProjectFile) -> bool {
+ project.project.strict || extract_constraint_rules(project).strict
+}
+
+fn print_constraint_issues(issues: &[String]) {
+ for issue in issues {
+ println!("warning CONSTRAINT VIOLATION");
+ println!(" Detail: {}", issue);
+ println!(" Action: build continues with warning (set strict = true to fail)");
+ println!();
+ }
+}
+
+// ── Public API ──────────────────────────────────────────────────────────
+
+/// Compile a full project (project.toml + .cf files) into a single DXF.
+pub fn compile_project(
+ project_dir: &Path,
+ layer_filter: Option<&str>,
+ output: Option<&Path>,
+) -> Result<()> {
+ let project = parse_project(&project_dir.join("project.toml"))?;
+ let mut writer = DxfWriter::new();
+
+ for name in project.layers.keys() {
+ writer.add_layer(name, 7);
+ }
+
+ let loaded_layers = load_layers(project_dir, &project.layers)?;
+ let issues = validate_constraints(&project, &loaded_layers);
+ let strict = is_strict(&project);
+ if !issues.is_empty() {
+ print_constraint_issues(&issues);
+ if strict {
+ bail!(
+ "Build blocked: {} constraint violation(s) with strict = true",
+ issues.len()
+ );
+ }
+ }
+
+ let mut total_entities = 0usize;
+ let mut layer_stats: Vec<(String, usize)> = Vec::new();
+ for name in project.layers.keys() {
+ if layer_filter.is_none_or(|f| f == name) {
+ let cf = loaded_layers
+ .get(name)
+ .with_context(|| format!("Failed to load layer '{}'", name))?;
+ let count = entity_count(cf);
+ compile_cf(&mut writer, cf, name);
+ total_entities += count;
+ layer_stats.push((name.to_string(), count));
+ }
+ }
+
+ let output_path = output
+ .map(|p| p.to_path_buf())
+ .unwrap_or_else(|| project_dir.join("output.dxf"));
+ writer.save(&output_path)?;
+
+ println!("✓ DXF generado: {}", output_path.display());
+ println!(
+ " {} entidades en {} capas",
+ total_entities,
+ layer_stats.len()
+ );
+ for (name, count) in &layer_stats {
+ println!(" {}: {} entidades", name, count);
+ }
+ Ok(())
+}
+
+/// Validate a project without generating DXF output.
+pub fn check_project(project_dir: &Path) -> Result {
+ let project = parse_project(&project_dir.join("project.toml"))?;
+ let loaded_layers = load_layers(project_dir, &project.layers)?;
+ let issues = validate_constraints(&project, &loaded_layers);
+ let strict = is_strict(&project);
+ let mut total = 0usize;
+
+ println!("Project: {}", project.project.name);
+ println!(
+ "Scale: {} Units: {}",
+ project.project.scale, project.project.units
+ );
+ println!();
+
+ for (name, entry) in &project.layers {
+ let cf = loaded_layers
+ .get(name)
+ .with_context(|| format!("Failed to load layer '{}'", name))?;
+ let count = entity_count(cf);
+ let color = cf
+ .layer_meta
+ .as_ref()
+ .and_then(|m| m.color.as_deref())
+ .unwrap_or("#FFFFFF");
+ println!(" ✓ {} — {} entities [{}]", entry.file, count, color);
+ total += count;
+ }
+
+ if !issues.is_empty() {
+ println!();
+ print_constraint_issues(&issues);
+ if strict {
+ bail!(
+ "Check failed: {} constraint violation(s) with strict = true",
+ issues.len()
+ );
+ }
+ }
+
+ println!();
+ println!(
+ "✓ Valid: {} layers, {} total entities",
+ project.layers.len(),
+ total
+ );
+ Ok(total)
+}
+
+/// List layers in a project with their status.
+pub fn list_layers(project_dir: &Path) -> Result<()> {
+ let project = parse_project(&project_dir.join("project.toml"))?;
+
+ println!("Project: {}", project.project.name);
+ println!(
+ "Scale: {} Units: {}",
+ project.project.scale, project.project.units
+ );
+ println!();
+ println!("{:<20} {:<25} {:<10} Color", "Layer", "File", "Entities");
+ println!("{}", "-".repeat(65));
+
+ 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 count = entity_count(&cf);
+ let col = cf
+ .layer_meta
+ .as_ref()
+ .and_then(|m| m.color.as_deref())
+ .unwrap_or("#FFFFFF");
+ (format!("{}", count), col.to_string())
+ } else {
+ ("⚠ missing".to_string(), "-".to_string())
+ };
+ let lock = if entry.locked { " [locked]" } else { "" };
+ println!(
+ "{:<20} {:<25} {:<10} {}{}",
+ name, entry.file, status, color, lock
+ );
+ }
+ Ok(())
+}
+
+// ── Internal ────────────────────────────────────────────────────────────
+
+fn entity_count(cf: &CfFile) -> usize {
+ cf.lines.len()
+ + cf.polylines.len()
+ + cf.rects.len()
+ + cf.circles.len()
+ + cf.arcs.len()
+ + cf.texts.len()
+ + cf.points.len()
+ + cf.dims.len()
+ + cf.hatches.len()
+ + cf.fills.len()
+ + cf.groups.len()
+}
+
+/// Compile a single .cf file into the DxfWriter (public for integration tests).
+pub fn compile_cf_public(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) {
+ compile_cf(writer, cf, default_layer);
+}
+
+/// Compile a single .cf file into the DxfWriter.
+fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) {
+ if let Some(meta) = &cf.layer_meta {
+ if let Some(color) = &meta.color {
+ writer.add_layer(default_layer, hex_to_aci(color));
+ }
+ }
+
+ for e in &cf.lines {
+ let style = resolve_style(&e.common);
+ writer.line(
+ e.from[0],
+ e.from[1],
+ e.to[0],
+ e.to[1],
+ resolve_layer(&e.common, default_layer),
+ &style,
+ );
+ }
+
+ for e in &cf.polylines {
+ let style = resolve_style(&e.common);
+ let pts: Vec<(f64, f64)> = e.points.iter().map(|p| (p[0], p[1])).collect();
+ writer.polyline(
+ &pts,
+ e.closed,
+ resolve_layer(&e.common, default_layer),
+ &style,
+ );
+ }
+
+ for e in &cf.rects {
+ let style = resolve_style(&e.common);
+ writer.rect(
+ e.origin[0],
+ e.origin[1],
+ e.width,
+ e.height,
+ resolve_layer(&e.common, default_layer),
+ &style,
+ );
+ }
+
+ for e in &cf.circles {
+ let style = resolve_style(&e.common);
+ writer.circle(
+ e.center[0],
+ e.center[1],
+ e.radius,
+ resolve_layer(&e.common, default_layer),
+ &style,
+ );
+ }
+
+ for e in &cf.arcs {
+ let style = resolve_style(&e.common);
+ writer.arc(
+ e.center[0],
+ e.center[1],
+ e.radius,
+ e.from_angle,
+ e.to_angle,
+ resolve_layer(&e.common, default_layer),
+ &style,
+ );
+ }
+
+ for e in &cf.texts {
+ let style = resolve_style(&e.common);
+ writer.text(
+ e.position[0],
+ e.position[1],
+ e.size,
+ &e.content,
+ resolve_layer(&e.common, default_layer),
+ &style,
+ );
+ }
+
+ for e in &cf.points {
+ let style = resolve_style(&e.common);
+ writer.point(
+ e.position[0],
+ e.position[1],
+ resolve_layer(&e.common, default_layer),
+ &style,
+ );
+ }
+
+ for e in &cf.dims {
+ let style = resolve_style(&e.common);
+ writer.dim_linear(
+ e.from[0],
+ e.from[1],
+ e.to[0],
+ e.to[1],
+ e.offset,
+ resolve_layer(&e.common, default_layer),
+ &style,
+ );
+ }
+
+ // Hatches: resolve boundary by id, generate pattern lines
+ for e in &cf.hatches {
+ let layer = resolve_layer(&e.common, default_layer);
+ let style = resolve_style(&e.common);
+ let spacing = 0.1 * e.scale; // base spacing scaled
+
+ if let Some(boundary) = resolve_boundary(&e.boundary, cf) {
+ writer.hatch(&boundary, e.angle, spacing, layer, &style);
+ }
+ }
+
+ // Solid fills
+ for e in &cf.fills {
+ let layer = resolve_layer(&e.common, default_layer);
+ let style = resolve_style(&e.common);
+
+ let pts = if let Some(ref 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 {
+ writer.solid_fill(&pts, layer, &style);
+ }
+ }
+}
+
+/// Resolve a boundary id to a list of (x,y) points from polylines or rects in the file.
+pub fn resolve_boundary(id: &str, cf: &CfFile) -> Option> {
+ // Search polylines
+ for poly in &cf.polylines {
+ if poly.common.id.as_deref() == Some(id) && poly.closed {
+ return Some(poly.points.iter().map(|p| (p[0], p[1])).collect());
+ }
+ }
+ // Search rects
+ for rect in &cf.rects {
+ if rect.common.id.as_deref() == Some(id) {
+ let (x, y) = (rect.origin[0], rect.origin[1]);
+ return Some(vec![
+ (x, y),
+ (x + rect.width, y),
+ (x + rect.width, y + rect.height),
+ (x, y + rect.height),
+ ]);
+ }
+ }
+ None
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..dca785c
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,75 @@
+//! Global configuration for cadforge CLI defaults.
+
+use anyhow::{anyhow, Context, Result};
+use std::fs;
+use std::path::PathBuf;
+use toml_edit::{value, DocumentMut, Item, Table};
+
+const SUPPORTED_KEYS: &[&str] = &["author", "units"];
+
+fn config_path() -> Result {
+ let home = std::env::var("HOME").context("HOME environment variable is not set")?;
+ Ok(PathBuf::from(home).join(".cadforge").join("config.toml"))
+}
+
+fn load_document(path: &PathBuf) -> Result {
+ if !path.exists() {
+ return Ok(DocumentMut::new());
+ }
+ let raw =
+ fs::read_to_string(path).with_context(|| format!("Cannot read {}", path.display()))?;
+ let doc = raw
+ .parse::()
+ .with_context(|| format!("Invalid TOML in {}", path.display()))?;
+ Ok(doc)
+}
+
+fn ensure_defaults_table(doc: &mut DocumentMut) -> Result<&mut Table> {
+ if !doc.as_table().contains_key("defaults") {
+ doc["defaults"] = Item::Table(Table::new());
+ }
+ doc["defaults"]
+ .as_table_mut()
+ .ok_or_else(|| anyhow!("'defaults' is not a table in global config"))
+}
+
+pub fn config_set(key: &str, val: &str) -> Result<()> {
+ if !SUPPORTED_KEYS.contains(&key) {
+ let options = SUPPORTED_KEYS.join(", ");
+ return Err(anyhow!(
+ "Unsupported config key '{}'. Supported keys: {}",
+ key,
+ options
+ ));
+ }
+
+ let path = config_path()?;
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("Cannot create {}", parent.display()))?;
+ }
+
+ let mut doc = load_document(&path)?;
+ let defaults = ensure_defaults_table(&mut doc)?;
+ defaults[key] = value(val);
+
+ fs::write(&path, doc.to_string())
+ .with_context(|| format!("Cannot write {}", path.display()))?;
+ println!("✓ config {} = {}", key, val);
+ println!(" {}", path.display());
+ Ok(())
+}
+
+pub fn config_show() -> Result<()> {
+ let path = config_path()?;
+ let doc = load_document(&path)?;
+ println!("Global config: {}", path.display());
+ if let Some(defaults) = doc.get("defaults").and_then(Item::as_table) {
+ for key in SUPPORTED_KEYS {
+ if let Some(v) = defaults.get(key).and_then(Item::as_str) {
+ println!(" {} = {}", key, v);
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/src/dxf_writer.rs b/src/dxf_writer.rs
new file mode 100644
index 0000000..2d7d5dc
--- /dev/null
+++ b/src/dxf_writer.rs
@@ -0,0 +1,424 @@
+//! DXF generation module — converts geometric primitives to DXF entities.
+
+use anyhow::Result;
+use dxf::entities::{Entity, EntityType, LwPolyline};
+use dxf::enums::AcadVersion;
+use dxf::tables::Layer;
+use dxf::{Color, Drawing, LwPolylineVertex, Point};
+use std::path::Path;
+
+/// Optional visual attributes applied to any entity.
+#[derive(Default, Clone)]
+pub struct EntityStyle {
+ pub color_24bit: Option,
+ pub lineweight: Option,
+ pub line_type: Option,
+}
+
+impl EntityStyle {
+ pub fn is_empty(&self) -> bool {
+ self.color_24bit.is_none() && self.lineweight.is_none() && self.line_type.is_none()
+ }
+}
+
+/// Builder for constructing a DXF drawing from primitives.
+pub struct DxfWriter {
+ drawing: Drawing,
+}
+
+impl DxfWriter {
+ pub fn new() -> Self {
+ let mut drawing = Drawing::new();
+ drawing.header.version = AcadVersion::R2004;
+
+ // Register standard line types
+ drawing.add_line_type(Self::make_line_type("DASHED", &[0.5, -0.25]));
+ drawing.add_line_type(Self::make_line_type("DOTTED", &[0.0, -0.25]));
+ drawing.add_line_type(Self::make_line_type("DASHDOT", &[0.5, -0.25, 0.0, -0.25]));
+
+ Self { drawing }
+ }
+
+ fn make_line_type(name: &str, pattern: &[f64]) -> dxf::tables::LineType {
+ let mut lt = dxf::tables::LineType {
+ name: name.to_string(),
+ ..Default::default()
+ };
+ lt.element_count = pattern.len() as i32;
+ lt.total_pattern_length = pattern.iter().map(|v| v.abs()).sum();
+ lt.dash_dot_space_lengths = pattern.to_vec();
+ lt
+ }
+
+ /// Add a named layer with an ACI color index (1-255).
+ pub fn add_layer(&mut self, name: &str, color_index: u8) {
+ let layer = Layer {
+ name: name.to_string(),
+ color: Color::from_index(color_index),
+ ..Default::default()
+ };
+ self.drawing.add_layer(layer);
+ }
+
+ // ── Single entry point for adding entities ─────────────────────────
+
+ fn add_entity(&mut self, entity_type: EntityType, layer: &str, style: &EntityStyle) {
+ let mut entity = Entity::new(entity_type);
+ entity.common.layer = layer.to_string();
+ if let Some(c) = style.color_24bit {
+ entity.common.color_24_bit = c;
+ }
+ if let Some(lw) = style.lineweight {
+ entity.common.lineweight_enum_value = lw;
+ }
+ if let Some(lt) = &style.line_type {
+ entity.common.line_type_name = lt.clone();
+ }
+ self.drawing.add_entity(entity);
+ }
+
+ // ── Public primitive methods ───────────────────────────────────────
+
+ pub fn line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, layer: &str, style: &EntityStyle) {
+ let line = dxf::entities::Line::new(Point::new(x1, y1, 0.0), Point::new(x2, y2, 0.0));
+ self.add_entity(EntityType::Line(line), layer, style);
+ }
+
+ pub fn circle(&mut self, cx: f64, cy: f64, radius: f64, layer: &str, style: &EntityStyle) {
+ let circle = dxf::entities::Circle {
+ center: Point::new(cx, cy, 0.0),
+ radius,
+ ..Default::default()
+ };
+ self.add_entity(EntityType::Circle(circle), layer, style);
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn arc(
+ &mut self,
+ cx: f64,
+ cy: f64,
+ radius: f64,
+ start_angle: f64,
+ end_angle: f64,
+ layer: &str,
+ style: &EntityStyle,
+ ) {
+ let arc = dxf::entities::Arc {
+ center: Point::new(cx, cy, 0.0),
+ radius,
+ start_angle,
+ end_angle,
+ ..Default::default()
+ };
+ self.add_entity(EntityType::Arc(arc), layer, style);
+ }
+
+ pub fn polyline(
+ &mut self,
+ points: &[(f64, f64)],
+ closed: bool,
+ layer: &str,
+ style: &EntityStyle,
+ ) {
+ let mut poly = LwPolyline {
+ flags: i32::from(closed),
+ ..Default::default()
+ };
+ for &(x, y) in points {
+ poly.vertices.push(LwPolylineVertex {
+ x,
+ y,
+ ..Default::default()
+ });
+ }
+ self.add_entity(EntityType::LwPolyline(poly), layer, style);
+ }
+
+ pub fn rect(
+ &mut self,
+ x: f64,
+ y: f64,
+ width: f64,
+ height: f64,
+ layer: &str,
+ style: &EntityStyle,
+ ) {
+ let points = [
+ (x, y),
+ (x + width, y),
+ (x + width, y + height),
+ (x, y + height),
+ ];
+ self.polyline(&points, true, layer, style);
+ }
+
+ pub fn text(
+ &mut self,
+ x: f64,
+ y: f64,
+ height: f64,
+ content: &str,
+ layer: &str,
+ style: &EntityStyle,
+ ) {
+ let text = dxf::entities::Text {
+ location: Point::new(x, y, 0.0),
+ text_height: height,
+ value: content.to_string(),
+ ..Default::default()
+ };
+ self.add_entity(EntityType::Text(text), layer, style);
+ }
+
+ pub fn point(&mut self, x: f64, y: f64, layer: &str, style: &EntityStyle) {
+ let pt = dxf::entities::ModelPoint {
+ location: Point::new(x, y, 0.0),
+ ..Default::default()
+ };
+ self.add_entity(EntityType::ModelPoint(pt), layer, style);
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub fn dim_linear(
+ &mut self,
+ x1: f64,
+ y1: f64,
+ x2: f64,
+ y2: f64,
+ offset: f64,
+ layer: &str,
+ style: &EntityStyle,
+ ) {
+ 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),
+ ..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);
+ // Dimension line
+ self.line(x1, y1 + offset, x2, y2 + offset, layer, style);
+ // Dimension text
+ let text_style = EntityStyle::default();
+ self.text(mid_x, mid_y + 0.05, 0.1, &text_val, layer, &text_style);
+ }
+
+ /// Save the drawing to a DXF file.
+ pub fn save(&self, path: &Path) -> Result<()> {
+ self.drawing
+ .save_file(path.to_str().unwrap_or("output.dxf"))?;
+ Ok(())
+ }
+
+ /// Fill a polygon with solid color using DXF Solid entities (fan triangulation).
+ pub fn solid_fill(&mut self, points: &[(f64, f64)], layer: &str, style: &EntityStyle) {
+ if points.len() < 3 {
+ return;
+ }
+ // Fan triangulation from first vertex
+ let (ax, ay) = points[0];
+ for i in 1..points.len() - 1 {
+ let (bx, by) = points[i];
+ let (cx, cy) = points[i + 1];
+ let solid = dxf::entities::Solid::new(
+ Point::new(ax, ay, 0.0),
+ Point::new(bx, by, 0.0),
+ Point::new(cx, cy, 0.0),
+ Point::new(cx, cy, 0.0), // 4th = 3rd for triangle
+ );
+ self.add_entity(EntityType::Solid(solid), layer, style);
+ }
+ }
+
+ /// Generate hatch pattern lines within a rectangular boundary.
+ /// `boundary` is a list of (x,y) points forming a closed polygon.
+ /// `angle` is in degrees, `spacing` is distance between lines.
+ pub fn hatch(
+ &mut self,
+ boundary: &[(f64, f64)],
+ angle: f64,
+ spacing: f64,
+ layer: &str,
+ style: &EntityStyle,
+ ) {
+ if boundary.len() < 3 {
+ return;
+ }
+
+ // Compute bounding box
+ let (min_x, max_x, min_y, max_y) = bounding_box(boundary);
+
+ // Generate parallel lines at the given angle that cover the bbox
+ let rad = angle.to_radians();
+ let cos_a = rad.cos();
+ let sin_a = rad.sin();
+
+ // Diagonal of bbox determines how many lines we need
+ let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
+ let cx = (min_x + max_x) / 2.0;
+ let cy = (min_y + max_y) / 2.0;
+
+ let num_lines = (diag / spacing).ceil() as i32;
+
+ for i in -num_lines..=num_lines {
+ let offset = i as f64 * spacing;
+ // Line perpendicular offset from center
+ let px = cx + offset * sin_a;
+ let py = cy - offset * cos_a;
+ // Line endpoints extending beyond bbox
+ let x1 = px - diag * cos_a;
+ let y1 = py - diag * sin_a;
+ let x2 = px + diag * cos_a;
+ let y2 = py + diag * sin_a;
+
+ // Clip line to boundary polygon
+ if let Some((cx1, cy1, cx2, cy2)) = clip_line_to_polygon(x1, y1, x2, y2, boundary) {
+ self.line(cx1, cy1, cx2, cy2, layer, style);
+ }
+ }
+ }
+}
+
+impl Default for DxfWriter {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+// ── Geometry helpers for hatch ──────────────────────────────────────────
+
+fn bounding_box(pts: &[(f64, f64)]) -> (f64, f64, f64, f64) {
+ let mut min_x = f64::MAX;
+ let mut max_x = f64::MIN;
+ let mut min_y = f64::MAX;
+ let mut max_y = f64::MIN;
+ for &(x, y) in pts {
+ min_x = min_x.min(x);
+ max_x = max_x.max(x);
+ min_y = min_y.min(y);
+ max_y = max_y.max(y);
+ }
+ (min_x, max_x, min_y, max_y)
+}
+
+/// Clip a line segment to a convex/concave polygon using Cyrus-Beck-like approach.
+/// Returns the clipped segment or None if fully outside.
+fn clip_line_to_polygon(
+ x1: f64,
+ y1: f64,
+ x2: f64,
+ y2: f64,
+ polygon: &[(f64, f64)],
+) -> Option<(f64, f64, f64, f64)> {
+ // Find all intersections of the line with polygon edges
+ let mut params: Vec = Vec::new();
+ let n = polygon.len();
+
+ for i in 0..n {
+ let j = (i + 1) % n;
+ let (ex1, ey1) = polygon[i];
+ let (ex2, ey2) = polygon[j];
+
+ if let Some(t) = line_segment_intersection(x1, y1, x2, y2, ex1, ey1, ex2, ey2) {
+ params.push(t);
+ }
+ }
+
+ if params.len() < 2 {
+ return None;
+ }
+
+ params.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
+
+ let t_min = params[0];
+ let t_max = params[params.len() - 1];
+
+ let dx = x2 - x1;
+ let dy = y2 - y1;
+
+ Some((
+ x1 + t_min * dx,
+ y1 + t_min * dy,
+ x1 + t_max * dx,
+ y1 + t_max * dy,
+ ))
+}
+
+/// Find parameter t where line (x1,y1)→(x2,y2) intersects segment (ex1,ey1)→(ex2,ey2).
+#[allow(clippy::too_many_arguments)]
+fn line_segment_intersection(
+ x1: f64,
+ y1: f64,
+ x2: f64,
+ y2: f64,
+ ex1: f64,
+ ey1: f64,
+ ex2: f64,
+ ey2: f64,
+) -> Option {
+ let dx = x2 - x1;
+ let dy = y2 - y1;
+ let edx = ex2 - ex1;
+ let edy = ey2 - ey1;
+
+ let denom = dx * edy - dy * edx;
+ if denom.abs() < 1e-10 {
+ return None; // parallel
+ }
+
+ let t = ((ex1 - x1) * edy - (ey1 - y1) * edx) / denom;
+ let u = ((ex1 - x1) * dy - (ey1 - y1) * dx) / denom;
+
+ if (0.0..=1.0).contains(&u) {
+ Some(t)
+ } else {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::PathBuf;
+
+ #[test]
+ fn generates_basic_dxf() {
+ let mut w = DxfWriter::new();
+ let s = EntityStyle::default();
+ w.add_layer("MUROS", 7);
+ w.line(0.0, 0.0, 10.0, 0.0, "MUROS", &s);
+ w.circle(5.0, 5.0, 2.0, "MUROS", &s);
+ w.rect(1.0, 1.0, 3.0, 4.0, "MUROS", &s);
+
+ let path = PathBuf::from("/tmp/cadforge_test_basic.dxf");
+ w.save(&path).unwrap();
+ assert!(path.exists());
+ }
+
+ #[test]
+ fn entity_style_applies_color_and_weight() {
+ let mut w = DxfWriter::new();
+ w.add_layer("TEST", 7);
+ let style = EntityStyle {
+ color_24bit: Some(0xFF0000),
+ lineweight: Some(50),
+ line_type: None,
+ };
+ w.line(0.0, 0.0, 1.0, 1.0, "TEST", &style);
+
+ let path = PathBuf::from("/tmp/cadforge_test_styled.dxf");
+ w.save(&path).unwrap();
+ assert!(path.exists());
+ }
+}
diff --git a/src/fmt.rs b/src/fmt.rs
new file mode 100644
index 0000000..70197f3
--- /dev/null
+++ b/src/fmt.rs
@@ -0,0 +1,75 @@
+//! Formatter — normalizes .cf files (sort keys, consistent spacing).
+
+use crate::parser::parse_project;
+use anyhow::Result;
+use std::path::Path;
+
+/// Format all .cf files 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 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;
+ }
+ } else {
+ println!(" {} — ok", entry.file);
+ }
+ }
+
+ if check_only && changed > 0 {
+ anyhow::bail!(
+ "{} file(s) need formatting. Run `cadforge fmt` to fix.",
+ changed
+ );
+ }
+
+ if !check_only {
+ println!("✓ {} file(s) formatted", changed);
+ }
+ 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(),
+ };
+ doc.to_string()
+}
+
+#[cfg(test)]
+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]]"));
+ }
+
+ #[test]
+ fn format_returns_original_on_parse_error() {
+ let input = "invalid [[[ toml";
+ let output = format_cf(input);
+ assert_eq!(output, input);
+ }
+}
diff --git a/src/importer.rs b/src/importer.rs
new file mode 100644
index 0000000..88ff6a4
--- /dev/null
+++ b/src/importer.rs
@@ -0,0 +1,311 @@
+//! DXF importer — converts DXF layers/entities into CADforge `.cf` + `project.toml`.
+
+use anyhow::{anyhow, Context, Result};
+use dxf::entities::EntityType;
+use dxf::Drawing;
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+#[derive(Default)]
+struct LayerFile {
+ entities: Vec,
+ counters: BTreeMap<&'static str, usize>,
+}
+
+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}")
+ }
+}
+
+pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) -> Result<()> {
+ if !input.exists() {
+ return Err(anyhow!(
+ "Input DXF file does not exist: {}",
+ input.display()
+ ));
+ }
+
+ fs::create_dir_all(output_dir)
+ .with_context(|| format!("Cannot create output dir {}", output_dir.display()))?;
+
+ let mut layers: BTreeMap = BTreeMap::new();
+ let mut unsupported = 0usize;
+
+ match Drawing::load_file(input) {
+ Ok(drawing) => {
+ for entity in drawing.entities() {
+ let layer_name = normalize_layer_name(&entity.common.layer);
+ if let Some(filter) = layer_filter {
+ if filter != layer_name {
+ continue;
+ }
+ }
+
+ 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)
+ ));
+ }
+ 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)
+ ));
+ }
+ _ => {
+ unsupported += 1;
+ }
+ }
+ }
+ }
+ Err(_) => {
+ let content = fs::read_to_string(input)
+ .with_context(|| format!("Cannot read DXF text: {}", input.display()))?;
+ for layer_name in collect_layer_names_from_text(&content) {
+ if let Some(filter) = layer_filter {
+ if filter != layer_name {
+ continue;
+ }
+ }
+ layers.entry(layer_name).or_default();
+ }
+ }
+ }
+
+ if layers.is_empty() {
+ let content = fs::read_to_string(input)
+ .with_context(|| format!("Cannot read DXF text: {}", input.display()))?;
+ for layer_name in collect_layer_names_from_text(&content) {
+ if let Some(filter) = layer_filter {
+ if filter != layer_name {
+ continue;
+ }
+ }
+ layers.entry(layer_name).or_default();
+ }
+ if layers.is_empty() {
+ for layer_name in collect_layer_names_from_layer_table(&content) {
+ if let Some(filter) = layer_filter {
+ if filter != layer_name {
+ continue;
+ }
+ }
+ layers.entry(layer_name).or_default();
+ }
+ }
+ }
+
+ if layers.is_empty() {
+ return Err(anyhow!(
+ "No importable entities found in DXF (filter: {})",
+ layer_filter.unwrap_or("")
+ ));
+ }
+
+ let project_name = input
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("imported-project");
+ let mut project_toml = format!(
+ "[project]\nname = \"{}\"\nscale = \"1:100\"\nunits = \"m\"\n\n[layers]\n",
+ escape_string(project_name)
+ );
+
+ let mut imported_layers = 0usize;
+ for (layer_name, layer_file) in &layers {
+ imported_layers += 1;
+ let file_name = format!("{}.cf", sanitize_for_filename(layer_name));
+ let mut cf = format!(
+ "[layer]\nname = \"{}\"\ncolor = \"#FFFFFF\"\n\n",
+ escape_string(layer_name)
+ );
+ 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);
+ cf.push('\n');
+ }
+ }
+ fs::write(output_dir.join(&file_name), cf)
+ .with_context(|| format!("Cannot write layer file {}", file_name))?;
+ project_toml.push_str(&format!(
+ "\"{}\" = {{ file = \"{}\", locked = false }}\n",
+ escape_string(layer_name),
+ file_name
+ ));
+ }
+
+ let project_path: PathBuf = output_dir.join("project.toml");
+ fs::write(&project_path, project_toml)
+ .with_context(|| format!("Cannot write {}", project_path.display()))?;
+
+ println!("✓ Imported DXF: {}", input.display());
+ println!(" Layers: {}", imported_layers);
+ println!(
+ " Unsupported entities skipped: {} (kept import resilient)",
+ unsupported
+ );
+ println!(" Project: {}", project_path.display());
+ Ok(())
+}
+
+fn n(v: f64) -> String {
+ format!("{:.4}", v)
+}
+
+fn escape_string(s: &str) -> String {
+ s.replace('\\', "\\\\").replace('"', "\\\"")
+}
+
+fn sanitize_for_filename(name: &str) -> String {
+ let mut out = String::with_capacity(name.len());
+ for ch in name.chars() {
+ if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
+ out.push(ch.to_ascii_lowercase());
+ } else {
+ out.push('_');
+ }
+ }
+ if out.is_empty() {
+ "layer".to_string()
+ } else {
+ out
+ }
+}
+
+fn normalize_layer_name(name: &str) -> String {
+ let trimmed = name.trim();
+ if trimmed.is_empty() {
+ "default".to_string()
+ } else {
+ trimmed.to_string()
+ }
+}
+
+fn collect_layer_names_from_text(content: &str) -> Vec {
+ let mut names = Vec::new();
+ let mut lines = content.lines();
+ while let Some(code) = lines.next() {
+ let Some(value) = lines.next() else {
+ break;
+ };
+ if code.trim() == "8" {
+ let layer = normalize_layer_name(value);
+ if layer != "0" && !names.iter().any(|existing| existing == &layer) {
+ names.push(layer);
+ }
+ }
+ }
+ names
+}
+
+fn collect_layer_names_from_layer_table(content: &str) -> Vec {
+ let mut names = Vec::new();
+ let mut lines = content.lines();
+ let mut in_layer_record = false;
+ while let Some(code) = lines.next() {
+ let Some(value) = lines.next() else {
+ break;
+ };
+ let code = code.trim();
+ let value = value.trim();
+ if code == "100" && value == "AcDbLayerTableRecord" {
+ in_layer_record = true;
+ continue;
+ }
+ if in_layer_record && code == "2" {
+ let layer = normalize_layer_name(value);
+ if layer != "0" && !names.iter().any(|existing| existing == &layer) {
+ names.push(layer);
+ }
+ in_layer_record = false;
+ }
+ }
+ names
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..3c6a921
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,16 @@
+//! cadforge — deterministic geometry engine for reproducible architectural design.
+//!
+//! Pipeline: `.cf` (TOML) → intermediate model → DXF output.
+
+pub mod color;
+pub mod compiler;
+pub mod config;
+pub mod dxf_writer;
+pub mod fmt;
+pub mod importer;
+pub mod model;
+pub mod parser;
+pub mod preview;
+pub mod scaffold;
+pub mod viewer;
+pub mod watch;
diff --git a/src/main.rs b/src/main.rs
deleted file mode 100644
index e5b70a4..0000000
--- a/src/main.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-fn main() {
- println!("Hello from cadforge!");
-}
diff --git a/src/model.rs b/src/model.rs
new file mode 100644
index 0000000..0d6dd2e
--- /dev/null
+++ b/src/model.rs
@@ -0,0 +1,214 @@
+//! Intermediate model — structs that represent `.cf` file contents.
+
+use serde::Deserialize;
+
+/// Common visual attributes shared by all primitives.
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct CommonAttrs {
+ pub id: Option,
+ pub color: Option,
+ pub weight: Option,
+ pub style: Option,
+ pub layer: Option,
+ pub belongs_to: Option,
+ #[serde(default = "default_true")]
+ pub visible: bool,
+ #[serde(default)]
+ pub locked: bool,
+}
+
+fn default_true() -> bool {
+ true
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LineStyle {
+ Solid,
+ Dashed,
+ Dotted,
+ Dashdot,
+}
+
+// ── Primitives ─────────────────────────────────────────────────────────
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfLine {
+ pub from: [f64; 2],
+ pub to: [f64; 2],
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfPolyline {
+ pub points: Vec<[f64; 2]>,
+ #[serde(default)]
+ pub closed: bool,
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfRect {
+ pub origin: [f64; 2],
+ pub width: f64,
+ pub height: f64,
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfCircle {
+ pub center: [f64; 2],
+ pub radius: f64,
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfArc {
+ pub center: [f64; 2],
+ pub radius: f64,
+ pub from_angle: f64,
+ pub to_angle: f64,
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfText {
+ pub position: [f64; 2],
+ pub content: String,
+ #[serde(default = "default_text_size")]
+ pub size: f64,
+ pub align: Option,
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+fn default_text_size() -> f64 {
+ 2.5
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TextAlign {
+ Left,
+ Center,
+ Right,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfPoint {
+ pub position: [f64; 2],
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfDim {
+ #[serde(rename = "type")]
+ pub dim_type: Option,
+ pub from: [f64; 2],
+ pub to: [f64; 2],
+ #[serde(default = "default_offset")]
+ pub offset: f64,
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+fn default_offset() -> f64 {
+ 0.5
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum DimType {
+ Linear,
+ Angular,
+ Radial,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfHatch {
+ pub boundary: String,
+ #[serde(default = "default_pattern")]
+ pub pattern: String,
+ #[serde(default = "default_scale")]
+ pub scale: f64,
+ #[serde(default = "default_angle")]
+ pub angle: f64,
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+fn default_pattern() -> String {
+ "ansi31".to_string()
+}
+fn default_scale() -> f64 {
+ 1.0
+}
+fn default_angle() -> f64 {
+ 45.0
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfGroup {
+ pub members: Vec,
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct CfFill {
+ /// Reference to a closed polyline or rect id, or inline points.
+ pub boundary: Option,
+ /// Inline points (alternative to boundary reference).
+ pub points: Option>,
+ #[serde(flatten)]
+ pub common: CommonAttrs,
+}
+
+// ── Layer-level metadata ───────────────────────────────────────────────
+
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct LayerMeta {
+ pub name: Option,
+ pub color: Option,
+ pub line_weight: Option,
+ #[serde(default = "default_true")]
+ pub visible: bool,
+ #[serde(default)]
+ pub locked: bool,
+}
+
+// ── Top-level .cf file ─────────────────────────────────────────────────
+
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct CfFile {
+ #[serde(rename = "layer")]
+ pub layer_meta: Option,
+ #[serde(default, rename = "line")]
+ pub lines: Vec,
+ #[serde(default, rename = "polyline")]
+ pub polylines: Vec,
+ #[serde(default, rename = "rect")]
+ pub rects: Vec,
+ #[serde(default, rename = "circle")]
+ pub circles: Vec,
+ #[serde(default, rename = "arc")]
+ pub arcs: Vec,
+ #[serde(default, rename = "text")]
+ pub texts: Vec,
+ #[serde(default, rename = "point")]
+ pub points: Vec,
+ #[serde(default, rename = "dim")]
+ pub dims: Vec,
+ #[serde(default, rename = "hatch")]
+ pub hatches: Vec,
+ #[serde(default, rename = "fill")]
+ pub fills: Vec,
+ #[serde(default, rename = "group")]
+ pub groups: Vec,
+}
diff --git a/src/parser.rs b/src/parser.rs
new file mode 100644
index 0000000..ac5fe2f
--- /dev/null
+++ b/src/parser.rs
@@ -0,0 +1,145 @@
+//! Parser — reads `.cf` and `project.toml` files into the intermediate model.
+
+use crate::model::CfFile;
+use anyhow::{Context, Result};
+use indexmap::IndexMap;
+use serde::Deserialize;
+use std::path::Path;
+
+// ── project.toml structures ────────────────────────────────────────────
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct ProjectFile {
+ pub project: ProjectMeta,
+ pub layers: IndexMap,
+ #[serde(default)]
+ pub constraints: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct ProjectMeta {
+ pub name: String,
+ #[serde(default = "default_scale")]
+ pub scale: String,
+ #[serde(default = "default_units")]
+ pub units: String,
+ #[serde(default)]
+ pub strict: bool,
+ pub author: Option,
+ pub version: Option,
+}
+
+fn default_scale() -> String {
+ "1:100".to_string()
+}
+fn default_units() -> String {
+ "m".to_string()
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct LayerEntry {
+ pub file: String,
+ #[serde(default)]
+ pub locked: bool,
+}
+
+// ── Parsing functions ──────────────────────────────────────────────────
+
+/// Parse a `project.toml` file.
+pub fn parse_project(path: &Path) -> Result {
+ let content =
+ std::fs::read_to_string(path).with_context(|| format!("Cannot read {}", path.display()))?;
+ toml::from_str(&content).with_context(|| format!("Invalid TOML in {}", path.display()))
+}
+
+/// Parse a `.cf` layer file.
+pub fn parse_cf(path: &Path) -> Result {
+ let content =
+ std::fs::read_to_string(path).with_context(|| format!("Cannot read {}", path.display()))?;
+ toml::from_str(&content).with_context(|| {
+ format!(
+ "Invalid TOML in {}:\n Check syntax: keys must be quoted, arrays use [[name]], tables use [name]",
+ path.display()
+ )
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_cf_file() {
+ let toml = r##"
+[layer]
+name = "muros"
+color = "#FFFFFF"
+
+[[line]]
+id = "ln-001"
+from = [0.0, 0.0]
+to = [8.5, 0.0]
+weight = 0.50
+
+[[rect]]
+id = "rc-001"
+origin = [1.0, 1.0]
+width = 3.5
+height = 4.0
+
+[[circle]]
+id = "ci-001"
+center = [4.0, 3.0]
+radius = 0.5
+
+[[arc]]
+id = "ar-001"
+center = [2.0, 2.0]
+radius = 0.9
+from_angle = 0.0
+to_angle = 90.0
+
+[[text]]
+id = "tx-001"
+position = [4.0, 3.0]
+content = "SALA"
+size = 14.0
+"##;
+ let cf: CfFile = toml::from_str(toml).unwrap();
+ assert_eq!(cf.lines.len(), 1);
+ assert_eq!(cf.rects.len(), 1);
+ assert_eq!(cf.circles.len(), 1);
+ assert_eq!(cf.arcs.len(), 1);
+ assert_eq!(cf.texts.len(), 1);
+ assert_eq!(cf.layer_meta.unwrap().name.unwrap(), "muros");
+ }
+
+ #[test]
+ fn parses_project_toml() {
+ let toml = r#"
+[project]
+name = "Vivienda Unifamiliar"
+scale = "1:100"
+units = "m"
+strict = true
+author = "Arq. Test"
+
+[layers]
+muros = { file = "muros.cf", locked = false }
+puertas = { file = "puertas.cf", locked = false }
+
+[constraints]
+puertas.parent = "muros"
+"#;
+ let proj: ProjectFile = toml::from_str(toml).unwrap();
+ assert_eq!(proj.project.name, "Vivienda Unifamiliar");
+ assert!(proj.project.strict);
+ assert_eq!(proj.layers.len(), 2);
+ assert_eq!(proj.layers["muros"].file, "muros.cf");
+ assert!(proj
+ .constraints
+ .as_ref()
+ .and_then(|v| v.get("puertas"))
+ .is_some());
+ }
+}
diff --git a/src/preview.rs b/src/preview.rs
new file mode 100644
index 0000000..370b225
--- /dev/null
+++ b/src/preview.rs
@@ -0,0 +1,557 @@
+//! 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};
+use anyhow::{Context, Result};
+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)
+}
+
+// ── Bounds accumulator (DRY: one place to track min/max) ────────────────
+
+#[derive(Serialize, Clone, Copy)]
+pub struct WorldBounds {
+ pub min_x: f64,
+ pub min_y: f64,
+ pub max_x: f64,
+ 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,
+ pub image_file: String,
+ pub width_px: u32,
+ pub height_px: u32,
+ pub world_bounds: WorldBounds,
+ pub scale: f64,
+ pub layers: Vec,
+ pub entities: Vec,
+}
+
+#[derive(Serialize)]
+pub struct LayerInfo {
+ pub name: String,
+ pub entity_count: usize,
+ pub color: String,
+}
+
+#[derive(Serialize)]
+pub struct EntityInfo {
+ pub id: Option,
+ pub entity_type: String,
+ pub layer: String,
+ 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,
+ ],
+ }
+ }
+}
+
+#[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)
+}
+
+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.
+pub fn generate_preview(
+ project_dir: &Path,
+ width: u32,
+ height: u32,
+ layer_filter: Option<&str>,
+) -> 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();
+
+ 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),
+ });
+ }
+
+ 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,
+ };
+
+ 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!("✓ 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;
+ }
+
+ // 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;
+ }
+ }
+
+ // 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;
+ }
+ }
+
+ 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())
+ }
+}
+
+// ── 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
+}
+
+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]);
+ }
+ }
+ }
+ }
+
+ if b.is_empty() {
+ WorldBounds {
+ min_x: 0.0,
+ min_y: 0.0,
+ max_x: 10.0,
+ max_y: 10.0,
+ }
+ } else {
+ b
+ }
+}
+
+#[cfg(test)]
+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());
+ }
+
+ #[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]);
+ }
+
+ #[test]
+ fn color_to_hex_formats() {
+ assert_eq!(color_to_hex(Color::from_rgba8(255, 0, 128, 255)), "#FF0080");
+ }
+}
diff --git a/src/scaffold.rs b/src/scaffold.rs
new file mode 100644
index 0000000..afc05e5
--- /dev/null
+++ b/src/scaffold.rs
@@ -0,0 +1,257 @@
+//! Scaffold — generates a new CADforge project structure.
+
+use anyhow::{bail, Result};
+use std::fs;
+use std::path::Path;
+
+/// Create a new CADforge project in the given directory.
+pub fn create_project(name: &str, parent: &Path) -> Result<()> {
+ let project_dir = parent.join(name);
+ if project_dir.exists() {
+ bail!("Directory '{}' already exists", project_dir.display());
+ }
+
+ fs::create_dir_all(&project_dir)?;
+ write_project_files(&project_dir, name)?;
+
+ println!("✓ Project '{}' created at {}", name, project_dir.display());
+ println!(" → project.toml");
+ println!(" → muros.cf");
+ println!(" → puertas.cf");
+ println!(" → mobiliario.cf");
+ println!(" → cotas.cf");
+ println!(" → .gitignore");
+ println!("\n Run `cadforge build --path {}` to compile.", name);
+ Ok(())
+}
+
+/// Initialize a CADforge project in the current directory.
+pub fn init_project(dir: &Path) -> Result<()> {
+ if dir.join("project.toml").exists() {
+ bail!("project.toml already exists in '{}'", dir.display());
+ }
+
+ let name = dir
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("project");
+ write_project_files(dir, name)?;
+
+ println!("✓ Initialized CADforge project in {}", dir.display());
+ println!(" → project.toml");
+ println!(" → muros.cf");
+ println!(" → puertas.cf");
+ println!(" → mobiliario.cf");
+ println!(" → cotas.cf");
+ println!(" → .gitignore");
+ Ok(())
+}
+
+fn write_project_files(project_dir: &Path, name: &str) -> Result<()> {
+ let project_toml = format!(
+ r#"[project]
+name = "{name}"
+scale = "1:100"
+units = "m"
+
+[layers]
+muros = {{ file = "muros.cf", locked = false }}
+puertas = {{ file = "puertas.cf", locked = false }}
+mobiliario = {{ file = "mobiliario.cf", locked = false }}
+cotas = {{ file = "cotas.cf", locked = false }}
+"#
+ );
+ 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";
+ fs::write(project_dir.join(".gitignore"), gitignore)?;
+
+ let muros_cf = r##"[layer]
+name = "muros"
+color = "#FFFFFF"
+line_weight = 0.50
+
+# Perímetro exterior
+[[polyline]]
+id = "pl-perimetro"
+points = [[0.0, 0.0], [8.0, 0.0], [8.0, 6.0], [0.0, 6.0]]
+closed = true
+weight = 0.50
+
+# Muro divisorio horizontal
+[[line]]
+id = "ln-div-h"
+from = [0.0, 3.5]
+to = [5.0, 3.5]
+weight = 0.35
+
+# Muro divisorio vertical
+[[line]]
+id = "ln-div-v"
+from = [5.0, 0.0]
+to = [5.0, 6.0]
+weight = 0.35
+"##;
+ fs::write(project_dir.join("muros.cf"), muros_cf)?;
+
+ let puertas_cf = r##"[layer]
+name = "puertas"
+color = "#00CC44"
+
+# Puerta principal
+[[arc]]
+id = "ar-puerta-principal"
+center = [0.0, 2.5]
+radius = 0.9
+from_angle = 0.0
+to_angle = 90.0
+
+# Puerta interior
+[[arc]]
+id = "ar-puerta-int"
+center = [5.0, 4.5]
+radius = 0.8
+from_angle = 90.0
+to_angle = 180.0
+"##;
+ fs::write(project_dir.join("puertas.cf"), puertas_cf)?;
+
+ let mobiliario_cf = r##"[layer]
+name = "mobiliario"
+color = "#4488FF"
+
+# Mesa sala
+[[rect]]
+id = "rc-mesa"
+origin = [1.5, 4.5]
+width = 2.0
+height = 1.0
+
+# Cama dormitorio
+[[rect]]
+id = "rc-cama"
+origin = [5.5, 4.0]
+width = 2.0
+height = 1.5
+
+# Etiquetas
+[[text]]
+id = "tx-sala"
+position = [2.0, 5.0]
+content = "SALA"
+size = 0.25
+
+[[text]]
+id = "tx-dorm"
+position = [6.0, 5.0]
+content = "DORMITORIO"
+size = 0.20
+
+[[text]]
+id = "tx-cocina"
+position = [2.0, 1.5]
+content = "COCINA"
+size = 0.20
+"##;
+ fs::write(project_dir.join("mobiliario.cf"), mobiliario_cf)?;
+
+ let cotas_cf = r##"[layer]
+name = "cotas"
+color = "#FF4444"
+
+# Cota horizontal total
+[[dim]]
+id = "dm-ancho"
+type = "linear"
+from = [0.0, 0.0]
+to = [8.0, 0.0]
+offset = -0.8
+
+# Cota vertical total
+[[dim]]
+id = "dm-alto"
+type = "linear"
+from = [0.0, 0.0]
+to = [0.0, 6.0]
+offset = -0.8
+"##;
+ fs::write(project_dir.join("cotas.cf"), cotas_cf)?;
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::PathBuf;
+
+ #[test]
+ fn creates_project_structure() {
+ let tmp = PathBuf::from("/tmp/cadforge_test_new");
+ let _ = fs::remove_dir_all(&tmp);
+ fs::create_dir_all(&tmp).unwrap();
+
+ create_project("mi-proyecto", &tmp).unwrap();
+
+ let project_dir = tmp.join("mi-proyecto");
+ assert!(project_dir.join("project.toml").exists());
+ assert!(project_dir.join("muros.cf").exists());
+ assert!(project_dir.join("puertas.cf").exists());
+ assert!(project_dir.join("mobiliario.cf").exists());
+ assert!(project_dir.join("cotas.cf").exists());
+ assert!(project_dir.join(".gitignore").exists());
+
+ let content = fs::read_to_string(project_dir.join("project.toml")).unwrap();
+ assert!(content.contains("mi-proyecto"));
+ assert!(content.contains("muros.cf"));
+
+ let gitignore = fs::read_to_string(project_dir.join(".gitignore")).unwrap();
+ assert!(gitignore.contains("output.dxf"));
+ assert!(gitignore.contains("target/"));
+
+ let _ = fs::remove_dir_all(&tmp);
+ }
+
+ #[test]
+ fn fails_if_dir_exists() {
+ let tmp = PathBuf::from("/tmp/cadforge_test_exists");
+ let _ = fs::remove_dir_all(&tmp);
+ fs::create_dir_all(tmp.join("existing")).unwrap();
+
+ let result = create_project("existing", &tmp);
+ assert!(result.is_err());
+
+ let _ = fs::remove_dir_all(&tmp);
+ }
+
+ #[test]
+ fn init_in_existing_dir() {
+ let tmp = PathBuf::from("/tmp/cadforge_test_init");
+ let _ = fs::remove_dir_all(&tmp);
+ fs::create_dir_all(&tmp).unwrap();
+
+ init_project(&tmp).unwrap();
+
+ assert!(tmp.join("project.toml").exists());
+ assert!(tmp.join("muros.cf").exists());
+ assert!(tmp.join("puertas.cf").exists());
+ assert!(tmp.join("mobiliario.cf").exists());
+ assert!(tmp.join("cotas.cf").exists());
+ assert!(tmp.join(".gitignore").exists());
+
+ let _ = fs::remove_dir_all(&tmp);
+ }
+
+ #[test]
+ fn init_fails_if_project_exists() {
+ let tmp = PathBuf::from("/tmp/cadforge_test_init_exists");
+ let _ = fs::remove_dir_all(&tmp);
+ fs::create_dir_all(&tmp).unwrap();
+ fs::write(tmp.join("project.toml"), "").unwrap();
+
+ let result = init_project(&tmp);
+ assert!(result.is_err());
+
+ let _ = fs::remove_dir_all(&tmp);
+ }
+}
diff --git a/src/viewer.rs b/src/viewer.rs
new file mode 100644
index 0000000..40a1048
--- /dev/null
+++ b/src/viewer.rs
@@ -0,0 +1,89 @@
+//! External viewer launcher for DXF outputs.
+
+use crate::compiler::compile_project;
+use anyhow::{anyhow, Context, Result};
+use std::path::Path;
+use std::process::Command;
+
+pub fn view_project(project_dir: &Path, layer_filter: Option<&str>) -> Result<()> {
+ let output = if let Some(layer) = layer_filter {
+ let sanitized = layer
+ .chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
+ c
+ } else {
+ '_'
+ }
+ })
+ .collect::();
+ std::env::temp_dir().join(format!("cadforge-view-{}.dxf", sanitized))
+ } else {
+ project_dir.join("output.dxf")
+ };
+
+ compile_project(project_dir, layer_filter, Some(&output))?;
+ open_file(&output)?;
+ println!("✓ Viewer opened with {}", output.display());
+ Ok(())
+}
+
+fn open_file(path: &Path) -> Result<()> {
+ if let Ok(custom) = std::env::var("CADFORGE_VIEWER_CMD") {
+ let status = Command::new(&custom)
+ .arg(path)
+ .status()
+ .with_context(|| format!("Failed to run CADFORGE_VIEWER_CMD='{}'", custom))?;
+ if !status.success() {
+ return Err(anyhow!(
+ "Custom viewer command failed for {} (exit code: {:?})",
+ path.display(),
+ status.code()
+ ));
+ }
+ return Ok(());
+ }
+
+ let program = opener_program();
+ let status = if cfg!(target_os = "windows") {
+ Command::new(program.0)
+ .args([program.1, program.2, path.to_string_lossy().as_ref()])
+ .status()
+ .with_context(|| format!("Failed to run opener for {}", path.display()))?
+ } else {
+ Command::new(program.0)
+ .arg(path)
+ .status()
+ .with_context(|| format!("Failed to run opener for {}", path.display()))?
+ };
+
+ if !status.success() {
+ return Err(anyhow!(
+ "Viewer command failed for {} (exit code: {:?})",
+ path.display(),
+ status.code()
+ ));
+ }
+ Ok(())
+}
+
+fn opener_program() -> (&'static str, &'static str, &'static str) {
+ if cfg!(target_os = "macos") {
+ ("open", "", "")
+ } else if cfg!(target_os = "windows") {
+ ("cmd", "/C", "start")
+ } else {
+ ("xdg-open", "", "")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn opener_program_is_resolved() {
+ let (cmd, _, _) = opener_program();
+ assert!(!cmd.is_empty());
+ }
+}
diff --git a/src/watch.rs b/src/watch.rs
new file mode 100644
index 0000000..42c3e17
--- /dev/null
+++ b/src/watch.rs
@@ -0,0 +1,86 @@
+//! Watch — monitors project files and auto-rebuilds on changes.
+
+use crate::compiler::compile_project;
+use crate::parser::parse_project;
+use anyhow::Result;
+use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
+use std::path::Path;
+use std::sync::mpsc;
+use std::time::{Duration, Instant};
+
+const DEBOUNCE: Duration = Duration::from_millis(300);
+
+/// Watch project files and auto-rebuild on changes.
+pub fn watch_project(project_dir: &Path) -> Result<()> {
+ let project = parse_project(&project_dir.join("project.toml"))?;
+
+ println!("Watching project: {}", project.project.name);
+ println!(" Directory: {}", project_dir.display());
+ println!(" Layers: {}", project.layers.len());
+ println!();
+ println!("Press Ctrl+C to stop.");
+ println!();
+
+ 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)?;
+
+ let mut last_build = Instant::now();
+
+ loop {
+ match rx.recv() {
+ Ok(event) => {
+ if !is_relevant(&event) {
+ continue;
+ }
+ if last_build.elapsed() < DEBOUNCE {
+ continue;
+ }
+
+ let changed_files: Vec = event
+ .paths
+ .iter()
+ .filter_map(|p| {
+ p.file_name()
+ .and_then(|n| n.to_str())
+ .map(|s| s.to_string())
+ })
+ .collect();
+
+ println!("⟳ Change detected: {}", changed_files.join(", "));
+
+ match compile_project(project_dir, None, None) {
+ Ok(()) => println!(" ✓ Rebuild complete\n"),
+ Err(e) => println!(" ✗ Build failed: {}\n", e),
+ }
+
+ last_build = Instant::now();
+ }
+ Err(e) => {
+ anyhow::bail!("Watch error: {}", e);
+ }
+ }
+ }
+}
+
+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")
+ })
+}
diff --git a/tests/integration.rs b/tests/integration.rs
new file mode 100644
index 0000000..19016fe
--- /dev/null
+++ b/tests/integration.rs
@@ -0,0 +1,305 @@
+//! Integration tests — full pipeline from .cf files to DXF output.
+
+use cadforge::compiler::compile_project;
+use cadforge::importer::import_dxf;
+use std::fs;
+use std::path::Path;
+
+#[test]
+fn compile_example_project_produces_valid_dxf() {
+ let project_dir = Path::new("examples/vivienda");
+ let output = Path::new("/tmp/cadforge_compile_test_output.dxf");
+
+ // Remove previous output if exists
+ let _ = fs::remove_file(output);
+
+ compile_project(project_dir, None, Some(output)).unwrap();
+
+ assert!(output.exists(), "output.dxf should be created");
+
+ let content = fs::read_to_string(output).unwrap();
+
+ // Verify DXF structure
+ assert!(content.contains("SECTION"));
+ assert!(content.contains("HEADER"));
+ assert!(content.contains("ENTITIES"));
+ assert!(content.contains("EOF"));
+
+ // Verify version
+ assert!(content.contains("AC1018"));
+
+ // Verify layers are present
+ assert!(content.contains("muros"));
+ assert!(content.contains("puertas"));
+ assert!(content.contains("mobiliario"));
+ assert!(content.contains("cotas"));
+ assert!(content.contains("achurados"));
+
+ // Verify entity types exist
+ assert!(content.contains("LWPOLYLINE"));
+ assert!(content.contains("LINE"));
+ assert!(content.contains("ARC"));
+ assert!(content.contains("CIRCLE"));
+ assert!(content.contains("TEXT"));
+
+ // Verify line types are registered
+ assert!(content.contains("DASHED"));
+ assert!(content.contains("DASHDOT"));
+}
+
+#[test]
+fn compile_project_fails_on_missing_project_toml() {
+ let result = compile_project(Path::new("/tmp/nonexistent_cadforge_dir"), None, None);
+ assert!(result.is_err());
+}
+
+#[test]
+fn check_project_validates_without_generating_dxf() {
+ use cadforge::compiler::check_project;
+
+ let project_dir = Path::new("examples/vivienda");
+ let output = project_dir.join("check_should_not_exist.dxf");
+ let _ = std::fs::remove_file(&output);
+
+ let count = check_project(project_dir).unwrap();
+ assert!(count > 0, "project should have entities");
+ assert!(!output.exists());
+}
+
+#[test]
+fn parser_handles_all_primitives() {
+ let toml = r##"
+[layer]
+name = "test"
+
+[[line]]
+from = [0.0, 0.0]
+to = [1.0, 1.0]
+
+[[polyline]]
+points = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]
+closed = true
+weight = 0.35
+
+[[rect]]
+origin = [0.0, 0.0]
+width = 2.0
+height = 3.0
+
+[[circle]]
+center = [5.0, 5.0]
+radius = 1.0
+
+[[arc]]
+center = [0.0, 0.0]
+radius = 2.0
+from_angle = 0.0
+to_angle = 180.0
+
+[[text]]
+position = [1.0, 1.0]
+content = "Hello"
+
+[[point]]
+position = [3.0, 3.0]
+
+[[dim]]
+type = "linear"
+from = [0.0, 0.0]
+to = [8.5, 0.0]
+offset = 0.5
+"##;
+
+ let cf: cadforge::model::CfFile = toml::from_str(toml).unwrap();
+ assert_eq!(cf.lines.len(), 1);
+ assert_eq!(cf.polylines.len(), 1);
+ assert!(cf.polylines[0].common.weight.is_some());
+ assert_eq!(cf.rects.len(), 1);
+ assert_eq!(cf.circles.len(), 1);
+ assert_eq!(cf.arcs.len(), 1);
+ assert_eq!(cf.texts.len(), 1);
+ assert_eq!(cf.points.len(), 1);
+ assert_eq!(cf.dims.len(), 1);
+}
+
+#[test]
+fn line_styles_parsed_and_compiled() {
+ let toml = r##"
+[[line]]
+from = [0.0, 0.0]
+to = [10.0, 0.0]
+style = "dashed"
+
+[[line]]
+from = [0.0, 1.0]
+to = [10.0, 1.0]
+style = "dotted"
+
+[[line]]
+from = [0.0, 2.0]
+to = [10.0, 2.0]
+style = "dashdot"
+"##;
+
+ let cf: cadforge::model::CfFile = toml::from_str(toml).unwrap();
+ assert_eq!(cf.lines.len(), 3);
+ assert!(cf.lines[0].common.style.is_some());
+}
+
+#[test]
+fn hatch_resolves_boundary_from_polyline() {
+ let toml = r##"
+[[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"
+scale = 1.0
+angle = 45.0
+"##;
+
+ let cf: cadforge::model::CfFile = toml::from_str(toml).unwrap();
+ assert_eq!(cf.hatches.len(), 1);
+ assert_eq!(cf.hatches[0].boundary, "pl-room");
+
+ // Compile it to verify no panic
+ use cadforge::dxf_writer::DxfWriter;
+ let mut writer = DxfWriter::new();
+ writer.add_layer("test", 7);
+ cadforge::compiler::compile_cf_public(&mut writer, &cf, "test");
+}
+
+#[test]
+fn solid_fill_generates_triangles() {
+ let toml = r##"
+[[fill]]
+id = "fl-room"
+points = [[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [0.0, 3.0]]
+color = "#808080"
+"##;
+
+ let cf: cadforge::model::CfFile = toml::from_str(toml).unwrap();
+ assert_eq!(cf.fills.len(), 1);
+
+ // Compile and verify it produces a DXF with SOLID entities
+ use cadforge::dxf_writer::DxfWriter;
+ let mut writer = DxfWriter::new();
+ writer.add_layer("test", 7);
+ cadforge::compiler::compile_cf_public(&mut writer, &cf, "test");
+
+ let path = std::path::PathBuf::from("/tmp/cadforge_test_fill.dxf");
+ writer.save(&path).unwrap();
+ let content = fs::read_to_string(&path).unwrap();
+ assert!(content.contains("SOLID"));
+}
+
+fn write_constraints_fixture(base: &Path, strict: bool) {
+ fs::create_dir_all(base).unwrap();
+ fs::write(
+ base.join("project.toml"),
+ format!(
+ r#"[project]
+name = "constraints-fixture"
+scale = "1:100"
+units = "m"
+strict = {strict}
+
+[layers]
+parent = {{ file = "parent.cf", locked = false }}
+child = {{ file = "child.cf", locked = false }}
+
+[constraints]
+child.parent = "parent"
+child.belongs_to = "parent"
+"#
+ ),
+ )
+ .unwrap();
+
+ fs::write(
+ base.join("parent.cf"),
+ r#"[layer]
+name = "parent"
+
+[[rect]]
+id = "room-1"
+origin = [0.0, 0.0]
+width = 2.0
+height = 2.0
+"#,
+ )
+ .unwrap();
+
+ fs::write(
+ base.join("child.cf"),
+ r#"[layer]
+name = "child"
+
+[[rect]]
+id = "furn-1"
+origin = [3.0, 3.0]
+width = 1.0
+height = 1.0
+belongs_to = "room-1"
+"#,
+ )
+ .unwrap();
+}
+
+#[test]
+fn compile_allows_constraint_warnings_when_not_strict() {
+ let dir = Path::new("/tmp/cadforge_constraints_non_strict");
+ let _ = fs::remove_dir_all(dir);
+ write_constraints_fixture(dir, false);
+
+ let output = dir.join("output.dxf");
+ let _ = fs::remove_file(&output);
+
+ compile_project(dir, None, None).unwrap();
+ assert!(
+ output.exists(),
+ "output.dxf should be created in non-strict mode"
+ );
+
+ let _ = fs::remove_dir_all(dir);
+}
+
+#[test]
+fn compile_fails_on_constraint_violation_when_strict() {
+ let dir = Path::new("/tmp/cadforge_constraints_strict");
+ let _ = fs::remove_dir_all(dir);
+ write_constraints_fixture(dir, true);
+
+ let result = compile_project(dir, None, None);
+ assert!(
+ result.is_err(),
+ "strict mode should fail on constraint violation"
+ );
+
+ let _ = fs::remove_dir_all(dir);
+}
+
+#[test]
+fn import_generated_dxf_creates_cadforge_project() {
+ let source = Path::new("examples/vivienda");
+ let source_output = source.join("output.dxf");
+ let _ = fs::remove_file(&source_output);
+ compile_project(source, None, Some(&source_output)).unwrap();
+
+ let imported = Path::new("/tmp/cadforge_import_test");
+ let _ = fs::remove_dir_all(imported);
+ import_dxf(&source_output, imported, None).unwrap();
+
+ assert!(imported.join("project.toml").exists());
+ let project_toml = fs::read_to_string(imported.join("project.toml")).unwrap();
+ assert!(project_toml.contains("[layers]"));
+ assert!(project_toml.contains("muros"));
+
+ compile_project(imported, None, None).unwrap();
+ assert!(imported.join("output.dxf").exists());
+
+ let _ = fs::remove_dir_all(imported);
+}