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 - -> +██████ ████████ ██████ ████████ ██ ██ ██ ███████ ████████ +██░░███ ░░███░░███░░░░░███ ░░███░░███░███ ░███ ██░███░░░░░░░███░ +░███ ░░░ ░███ ░███ ███████ ░███ ░░░ ░███ ░██████░░█████ ░███ +░███ ███ ░███ ░███ ███░░███ ░███ ░███ ░███░░░ ░███░░█ ░███ +░░██████ ░███████░░████████ ░███ ░███████████ ███████ ░████████ + ░░░░░░ ░░░░░░░ ░░░░░░░░ ░░░ ░░░░░░░░░░░ ░░░░░░ ░░░░░░░ + +

+ CI + Crates.io + Status + License +

+ +cadforge is an **Architecture as Code** CLI tool and Rust library for declarative 2D CAD modeling. Write geometry as code in `.cf` TOML format, 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); +}