From 3d2660d418a2bef771b6e5ebea9229a507ffb4e1 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Fri, 12 Jun 2026 18:07:19 -0500 Subject: [PATCH 01/39] docs: add docs folder and align readme with univerlab system --- README.md | 28 +++++++++- docs/agents.md | 70 +++++++++++++++++++++++++ docs/building-and-export.md | 68 ++++++++++++++++++++++++ docs/cf-format.md | 100 ++++++++++++++++++++++++++++++++++++ docs/cli-reference.md | 66 ++++++++++++++++++++++++ docs/index.md | 65 +++++++++++++++++++++++ docs/installation.md | 49 ++++++++++++++++++ docs/live-preview.md | 45 ++++++++++++++++ docs/quickstart.md | 66 ++++++++++++++++++++++++ 9 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 docs/agents.md create mode 100644 docs/building-and-export.md create mode 100644 docs/cf-format.md create mode 100644 docs/cli-reference.md create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/live-preview.md create mode 100644 docs/quickstart.md diff --git a/README.md b/README.md index ebe8a10..194897c 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,31 @@ Agents get first-class support: --- +## Installation + +**Linux / macOS:** + +```bash +curl -fsSL https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.sh | sh +``` + +**Windows (PowerShell):** + +```powershell +irm https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.ps1 | iex +``` + +Or via cargo: `cargo install cadforge` β€” see [`docs/installation.md`](docs/installation.md) for all methods. + +## Documentation + +Full documentation lives in [`docs/`](docs/): installation, quick start, the +`.cf` format, live preview, building & export, working with agents, and the +complete CLI reference. + +--- + + ## Features ### 🎯 Core Platform @@ -294,4 +319,5 @@ MIT β€” see [LICENSE](LICENSE) for details. --- -Made with ❀️ by [JheisonMB](https://github.com/JheisonMB) and [UniverLab](https://github.com/UniverLab) \ No newline at end of file +An experiment of [UniverLab](https://github.com/UniverLab) β€” an open computational laboratory. +Made with ❀️ by [JheisonMB](https://github.com/JheisonMB) diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..f267b01 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,70 @@ +--- +title: Working with Agents +description: The agent-facing toolchain β€” schema discovery, JSON reports, visual previews with metadata. +order: 7 +--- + +# Working with Agents + +Cadforge is designed for **humans and AI agents working together** on the +same project. Everything an agent needs is exposed through the CLI. + +## Self-discovery β€” `cadforge schema` + +```bash +cadforge schema +``` + +Prints the complete `.cf` language reference as markdown. An agent with +shell access can learn the entire format in one command β€” no prior +training on cadforge required. + +## Machine-readable state + +```bash +cadforge check --json # validation report +cadforge layers --json # layers, entity counts, colors +``` + +## Visual grounding β€” `cadforge preview` + +```bash +cadforge preview # PNG + preview.meta.json +cadforge preview --format svg # same render as vector SVG +cadforge preview --width 1024 -H 768 # custom resolution +cadforge preview --layer muros # single layer +``` + +The PNG is a faithful render β€” real text, measured dimension labels, +hatches, line styles β€” and `preview.meta.json` contains per-entity +bounding boxes in **world and pixel coordinates**. A multimodal agent can +look at the image and locate every entity in it. + +Rendering is deterministic on any machine (an embedded monospace font is +used), including fontless containers. + +## Confirming edits β€” `--highlight` + +```bash +cadforge preview --highlight ln-001,tx-002 +``` + +Draws labeled amber markers around the listed entities, so an agent can +visually confirm its edit landed exactly where intended. + +## Targeted edit prompts from the browser + +In [live preview](live-preview.md), clicking an entity and pressing +**copy for agent** produces a ready-made prompt with the entity's id, +source TOML and file β€” paste it into your agent and ask for the change. + +## A typical agent loop + +```bash +cadforge schema # 1. learn the language +cadforge layers --json # 2. inspect the project +# ... edit .cf files ... +cadforge check --json # 3. validate +cadforge preview --highlight # 4. visually confirm +cadforge build # 5. emit the DXF +``` diff --git a/docs/building-and-export.md b/docs/building-and-export.md new file mode 100644 index 0000000..3f0f693 --- /dev/null +++ b/docs/building-and-export.md @@ -0,0 +1,68 @@ +--- +title: Building & Export +description: Deterministic DXF compilation, watch mode, validation, and DXF import. +order: 6 +--- + +# Building & Export + +## Compile to DXF + +```bash +cadforge build # default output.dxf +cadforge build --output plano.dxf # custom output path +cadforge build --layer muros # compile a single layer +cadforge build --check # validate only, no DXF +``` + +The output is deterministic: identical input produces a bit-identical +DXF. Export uses proper AutoCAD-compatible entities β€” LWPOLYLINE for +polylines, HATCH for solid fills, MTEXT for annotations β€” with full +layer/color/lineweight mapping. + +## Validation + +```bash +cadforge check # geometry + constraints, human-readable +cadforge check --json # machine-readable report +cadforge layers # list layers with entity counts and colors +cadforge layers --json # machine-readable layer listing +``` + +## Watch mode + +```bash +cadforge watch +``` + +Monitors `.cf` and `.toml` files and rebuilds the DXF on changes with a +300 ms debounce. + +## Formatting + +```bash +cadforge fmt # normalize .cf files in place +cadforge fmt --check # CI-friendly check mode +``` + +## Importing existing drawings + +```bash +cadforge import plano.dxf # all layers +cadforge import plano.dxf --layer muros # a single DXF layer +``` + +Import migrates an existing DXF into `.cf` layer files plus a +`project.toml`, so legacy drawings can join the declarative workflow. + +## Pipeline + +``` +.cf file (TOML) + β”‚ + β–Ό + Parser ──▢ Resolver ──▢ Compiler ──▢ DXF Emit + β”‚ + β–Ό + Boundary Resolver ──▢ Preview PNG/SVG + JSON meta +``` diff --git a/docs/cf-format.md b/docs/cf-format.md new file mode 100644 index 0000000..65d0da8 --- /dev/null +++ b/docs/cf-format.md @@ -0,0 +1,100 @@ +--- +title: The .cf Format +description: Layers, primitives, styled dimensions, construction tools and constraints. +order: 4 +--- + +# The .cf Format + +A cadforge project is a `project.toml` plus one `.cf` file per layer. +`.cf` files are TOML: a `[layer]` header followed by arrays of entity +tables. + +> The authoritative reference is always `cadforge schema` β€” it prints the +> complete language specification (markdown) for the exact version you +> have installed. + +## Layers + +```toml +[layer] +name = "muros" +color = "#FFFFFF" +``` + +Layers organize geometry with custom names, colors and line weights. You +can compile or preview single layers (`--layer muros`) or the whole +project. + +## Primitives + +Supported primitives: `line`, `polyline`, `rect`, `circle`, `arc`, +`text`, `point`, `dim`, `hatch`, `fill`, `group`. + +```toml +[[line]] +id = "ln-001" +from = [0.0, 0.0] +to = [8.5, 0.0] +weight = 0.50 + +[[rect]] +id = "rc-001" +origin = [1.0, 1.0] +width = 3.5 +height = 4.0 + +[[circle]] +id = "ci-001" +center = [4.0, 3.0] +radius = 0.5 + +[[arc]] +id = "ac-001" +center = [2.0, 2.0] +radius = 0.9 +from_angle = 0.0 +to_angle = 90.0 + +[[polyline]] +id = "pl-001" +points = [[0.0, 0.0], [5.0, 0.0], [5.0, 3.0], [0.0, 3.0]] +closed = true + +[[text]] +id = "tx-001" +position = [4.0, 3.0] +content = "SALA" +size = 0.2 +``` + +Every entity has a stable `id` β€” that is what makes plans diffable and +lets agents target precise edits. + +## Styled dimensions + +`dim` entities are auto-measured β€” the label is computed from the +geometry, never typed by hand: + +```toml +[[dim]] +id = "dm-001" +from = [0, 0] +to = [5, 0] +offset = 0.5 +``` + +Configurable per dimension: `text_size`, `precision`, `show_units`, +`offset`. + +## Construction tools + +`[[array]]` (linear and polar β€” spiral staircases, gear teeth, repeated +columns) and `[[mirror]]` expand into concrete primitives at build time. +Copies get derived ids: `base@1`, `base@2`, … and `base@m` for mirrors. + +## Constraints + +Layers can declare relationships: `parent`, `belongs_to` and +`spatial_dependency`. Violations are warnings by default and +build-blocking with `strict = true` in `project.toml`. diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..6b2f86f --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,66 @@ +--- +title: CLI Reference +description: Every cadforge command and flag. +order: 8 +--- + +# CLI Reference + +``` +cadforge [options] +``` + +## Project lifecycle + +| Command | Description | +|---------|-------------| +| `cadforge new ` | Create a new project with multi-layer scaffold | +| `cadforge init` | Initialize cadforge in the 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 a specific layer only | +| `cadforge watch` | Auto-rebuild on file changes (300 ms debounce) | + +## Preview + +| Command | Description | +|---------|-------------| +| `cadforge serve` | Live preview server β€” browser auto-reloads on save | +| `cadforge serve --open --port

` | Open browser automatically on a custom port | +| `cadforge preview` | Faithful PNG render + `preview.meta.json` | +| `cadforge preview --format svg` | Vector SVG preview (same renderer) | +| `cadforge preview --highlight ` | Amber markers around specific entities | +| `cadforge preview --width -H ` | Custom resolution | +| `cadforge preview --layer ` | Preview a specific layer only | +| `cadforge view` | Open the project in the configured viewer | +| `cadforge view --layer ` | Open only one layer in the viewer | + +## Inspection & quality + +| Command | Description | +|---------|-------------| +| `cadforge check` | Validate with project metadata and layer colors | +| `cadforge check --json` | Machine-readable validation report | +| `cadforge layers` | List layers with entity counts and colors | +| `cadforge layers --json` | Machine-readable layer listing | +| `cadforge schema` | Print the full `.cf` language reference (markdown) | +| `cadforge fmt` | Format `.cf` files (normalize whitespace) | +| `cadforge fmt --check` | Check formatting without modifying (CI) | + +## Import & config + +| Command | Description | +|---------|-------------| +| `cadforge import ` | Import DXF into `.cf` layers + `project.toml` | +| `cadforge import --layer ` | Import only one DXF layer | +| `cadforge config set ` | Set global defaults (`author`, `units`) | +| `cadforge config show` | Show global defaults | + +## Output files + +| File | Description | +|------|-------------| +| `output.dxf` | Default build output | +| `preview.png` / `preview.svg` | Preview renders | +| `preview.meta.json` | Per-entity bounding boxes (world + pixel coordinates) | diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..886e64d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,65 @@ +--- +title: Cadforge +description: Architecture as Code β€” declarative 2D CAD in TOML, live browser preview, deterministic DXF output. +order: 1 +--- + +# Cadforge + +Cadforge is an **Architecture as Code** CLI tool and Rust library for +declarative 2D CAD modeling. Write geometry as code in `.cf` TOML files, +watch it live in the browser, and compile to AutoCAD-compatible DXF β€” +built for humans and AI agents working together. + +## The plan is not drawn β€” it is declared + +Traditional CAD drawings are collections of lines without semantics: +impossible to version, diff or automate. Cadforge treats an architectural +plan like source code: + +- Same input β†’ **bit-identical output**, every time. +- `git diff` works on floor plans. +- AI agents can generate and modify designs with surgical precision. + +## The core loop + +```bash +cadforge new casa && cd casa +cadforge serve --open # live preview in the browser +``` + +Edit any `.cf` file β€” by hand or by asking an AI agent β€” and the browser +updates on every save. Parse errors and constraint violations appear as an +overlay instead of a crash. When the design is right, `cadforge build` +emits a deterministic DXF. + +## Built for agents + +Agents get first-class support: + +- `cadforge schema` β€” the full `.cf` language reference in one command; + agents self-discover the format without prior training. +- `cadforge check --json` / `cadforge layers --json` β€” machine-readable + reports. +- `cadforge preview` β€” a faithful PNG render plus `preview.meta.json` + with per-entity bounding boxes, so multimodal agents can *look* at the + plan and locate every entity. +- `cadforge preview --highlight ln-001,tx-002` β€” labeled markers to + visually confirm an edit landed where intended. + +## How the documentation is organized + +- [Installation](installation.md) β€” install, update and uninstall. +- [Quick Start](quickstart.md) β€” from zero to DXF. +- [The .cf Format](cf-format.md) β€” layers, primitives, construction tools. +- [Live Preview](live-preview.md) β€” `cadforge serve` and its controls. +- [Building & Export](building-and-export.md) β€” build, watch, DXF import/export. +- [Working with Agents](agents.md) β€” the agent-facing toolchain. +- [CLI Reference](cli-reference.md) β€” every command and flag. + +## Part of UniverLab + +Cadforge is an experiment of [UniverLab](https://github.com/UniverLab), +an open computational laboratory. It follows the lab's engineering +principles: one tool one job, reproducibility first, offline-friendly +design. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..21dfb5a --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,49 @@ +--- +title: Installation +description: Install cadforge with the quick installer, cargo, or from source. +order: 2 +--- + +# Installation + +> Cadforge is currently in **beta** (`0.1.0-beta.x`). Interfaces may still +> change before 1.0. + +## Quick install + +**Linux / macOS:** + +```bash +curl -fsSL https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.sh | sh +``` + +**Windows (PowerShell):** + +```powershell +irm https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.ps1 | iex +``` + +## Via cargo + +```bash +cargo install cadforge +``` + +Available on [crates.io](https://crates.io/crates/cadforge). + +## From source + +```bash +git clone https://github.com/UniverLab/cadforge.git +cd cadforge +cargo build --release +# Binary at target/release/cadforge +``` + +## Uninstall + +```bash +rm -f ~/.local/bin/cadforge +``` + +Projects are plain directories of TOML files β€” nothing else to clean up. diff --git a/docs/live-preview.md b/docs/live-preview.md new file mode 100644 index 0000000..9ff502d --- /dev/null +++ b/docs/live-preview.md @@ -0,0 +1,45 @@ +--- +title: Live Preview +description: The cadforge serve loop β€” pan/zoom, inspector, layer states, 3D view, error overlay. +order: 5 +--- + +# Live Preview + +```bash +cadforge serve # start the local preview server +cadforge serve --open --port 4377 # open browser, custom port +``` + +`cadforge serve` runs a zero-config local server with auto-reload on +save (SSE). Edit a `.cf` file and the browser updates instantly. + +## Controls + +| Action | Effect | +|---|---| +| Scroll | Zoom (centered on cursor) | +| Drag | Pan | +| Double-click / `F` | Fit to content | +| Click an entity | Inspector with its source TOML block | +| Layer panel / keys `1`–`9` | Cycle each layer **on β†’ ghost β†’ off** | +| `3D` button / key `3` | Stacked exploded view of the layers | + +## Inspector and "copy for agent" + +Clicking any entity opens an inspector showing its source TOML. The +**copy for agent** button produces a ready-made targeted-edit prompt you +can paste into an AI agent β€” the entity id, its current definition, and +the file it lives in. + +## Ghost mode + +Cycling a layer to *ghost* renders it as a faint trace β€” useful for +drawing one floor plan over another, or checking alignment between +structural and furniture layers. + +## Error overlay + +Build errors (parse errors, constraint violations) render as an overlay +with file and line detail instead of crashing the server. Fix the file, +save, and the view recovers β€” the loop never breaks. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..174506a --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,66 @@ +--- +title: Quick Start +description: Scaffold a project, preview it live, and compile to DXF. +order: 3 +--- + +# Quick Start + +## 1. Create a project + +```bash +cadforge new my-house +cd my-house +``` + +`cadforge new` scaffolds a complete multi-layer project (walls, doors, +furniture, dimensions) with meaningful architectural examples. To adopt +cadforge in an existing directory use `cadforge init`. + +## 2. Preview it live + +```bash +cadforge serve --open +``` + +A local server opens the plan in your browser. Edit any `.cf` file and +the view refreshes on save; build errors render as an overlay with +file/line detail, so the loop never breaks. See +[Live Preview](live-preview.md) for the full controls. + +## 3. Edit geometry + +Geometry lives in `.cf` files β€” TOML with one table per entity: + +```toml +[layer] +name = "muros" +color = "#FFFFFF" + +[[line]] +id = "ln-001" +from = [0.0, 0.0] +to = [8.5, 0.0] +weight = 0.50 +``` + +Run `cadforge schema` for the complete language reference, or read +[The .cf Format](cf-format.md). + +## 4. Validate and format + +```bash +cadforge check # validate geometry and constraints, no output files +cadforge fmt # normalize .cf files +``` + +## 5. Compile to DXF + +```bash +cadforge build # default output.dxf +cadforge build --output plano.dxf # custom output path +cadforge build --layer muros # single layer +``` + +The output is deterministic and AutoCAD-compatible. `cadforge watch` +rebuilds automatically while you edit. From 7e3aad675c21d26770f313af114f7d0cb8c7c795 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Sun, 14 Jun 2026 16:30:08 -0500 Subject: [PATCH 02/39] feat: install the cadforge agent skill from the install scripts --- scripts/install.ps1 | 23 +++++++++++++++++++++++ scripts/install.sh | 24 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 943467b..303eea4 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -76,6 +76,29 @@ if ($userPath -notlike "*$InstallDir*") { # --- cleanup --- Remove-Item $Tmp -Recurse -Force +# --- install the cadforge agent skill (optional) --- +# Teaches AI agents how to drive cadforge. Skipped when npx is unavailable or +# $env:SKIP_SKILL is set; a failure here never fails the binary install above. +$Skill = "cadforge" +$SkillsRepo = "https://github.com/UniverLab/skills" +if ($env:SKIP_SKILL) { + Info "skill" "skipped (SKIP_SKILL set)" +} elseif (Get-Command npx -ErrorAction SilentlyContinue) { + Info "skill" "adding '$Skill' (npx skills add)" + try { + & npx -y skills add $SkillsRepo --skill $Skill + if ($LASTEXITCODE -eq 0) { + Info "skill" "installed" + } else { + Info "skill" "skipped - add later with: npx skills add $SkillsRepo --skill $Skill" + } + } catch { + Info "skill" "skipped - add later with: npx skills add $SkillsRepo --skill $Skill" + } +} else { + Info "skill" "npx not found - add later with: npx skills add $SkillsRepo --skill $Skill" +} + # --- verify --- $ver = & "$InstallDir\$Binary" --version 2>$null Info "done" $ver diff --git a/scripts/install.sh b/scripts/install.sh index 044488a..e75f7ce 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -97,7 +97,29 @@ if [ -n "$PATHS_TO_ADD" ]; then fi # ============================================================ -# 3. Verify +# 3. Install the cadforge agent skill (optional) +# ============================================================ +# Teaches AI agents how to drive cadforge. Skipped when npx is unavailable or +# SKIP_SKILL is set; a failure here never fails the binary install above. + +SKILL="cadforge" +SKILLS_REPO="https://github.com/UniverLab/skills" + +if [ -n "${SKIP_SKILL:-}" ]; then + info "skill" "skipped (SKIP_SKILL set)" +elif command -v npx >/dev/null 2>&1; then + info "skill" "adding '$SKILL' (npx skills add)" + if npx -y skills add "$SKILLS_REPO" --skill "$SKILL" /dev/null || echo "$BINARY installed")" From 7dca8da3430c51c1c981e439e9992952ecd44019 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Sun, 14 Jun 2026 16:30:08 -0500 Subject: [PATCH 03/39] docs: describe cadforge as CAD as code, not architecture as code --- Cargo.toml | 2 +- README.md | 2 +- docs/index.md | 6 +++--- src/main.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6ce5be2..a4ecf47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "cadforge" version = "0.1.0-beta.2" edition = "2021" -description = "Architecture as Code β€” deterministic geometry engine for reproducible architectural design" +description = "CAD as code β€” deterministic geometry engine for reproducible CAD drawings" license = "MIT" homepage = "https://github.com/UniverLab/cadforge" repository = "https://github.com/UniverLab/cadforge" diff --git a/README.md b/README.md index 194897c..34cd733 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ License

-cadforge is an **Architecture as Code** CLI tool and Rust library for declarative 2D CAD modeling. Write geometry as code in `.cf` TOML format, watch it live in the browser, and compile to DXF β€” built for humans and AI agents working together. +cadforge is a **CAD as code** CLI tool and Rust library for declarative CAD modeling. Write geometry as code in `.cf` TOML format, watch it live in the browser, and compile to DXF β€” built for humans and AI agents working together. --- diff --git a/docs/index.md b/docs/index.md index 886e64d..e14208d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,13 @@ --- title: Cadforge -description: Architecture as Code β€” declarative 2D CAD in TOML, live browser preview, deterministic DXF output. +description: CAD as code β€” declarative CAD in TOML, live browser preview, deterministic DXF output. order: 1 --- # Cadforge -Cadforge is an **Architecture as Code** CLI tool and Rust library for -declarative 2D CAD modeling. Write geometry as code in `.cf` TOML files, +Cadforge is a **CAD as code** CLI tool and Rust library for +declarative CAD modeling. Write geometry as code in `.cf` TOML files, watch it live in the browser, and compile to AutoCAD-compatible DXF β€” built for humans and AI agents working together. diff --git a/src/main.rs b/src/main.rs index bd4e412..a46e58a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; #[command( name = "cadforge", version, - about = "Architecture as Code β€” declarative geometry β†’ DXF" + about = "CAD as code β€” declarative geometry β†’ DXF" )] struct Cli { #[command(subcommand)] From d103e5c6e4ad07987b64bf60d8040be25d82fd60 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 18 Jun 2026 22:49:10 -0500 Subject: [PATCH 04/39] docs: add a DemoStage demo score for the cadforge live-preview demo --- demo/README.md | 21 ++++++++++ demo/demo.toml | 105 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 demo/README.md create mode 100644 demo/demo.toml diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..db00daa --- /dev/null +++ b/demo/README.md @@ -0,0 +1,21 @@ +# cadforge demo + +The cadforge demo as code, recorded with [DemoStage](https://github.com/UniverLab/demo-stage). +It shows the terminal scaffolding a CAD project and starting the live preview, with +the rendered drawing in a browser pane. + +## Regenerate + +```sh +demo check demo/demo.toml +demo export demo/demo.toml --target gif # β†’ demo/dist/cadforge.gif +# or --target mp4 +``` + +- `gif`/`mp4` of a terminal + browser pane need a Chromium (auto-downloaded by + DemoStage on first use, or use a system install) and, for mp4, ffmpeg. +- The browser pane is captured after the terminal run, so this score leaves the + `cadforge serve` server running; stop it afterwards with + `cd /tmp/cadforge-demo/bracket && cadforge serve --stop`. + +Edit `demo/demo.toml` to change the commands, captions, layout or timing. diff --git a/demo/demo.toml b/demo/demo.toml new file mode 100644 index 0000000..d4d2031 --- /dev/null +++ b/demo/demo.toml @@ -0,0 +1,105 @@ +# cadforge demo β€” terminal (CAD as code) + the live SVG preview in a browser pane. +# +# Generate the GIF with DemoStage (https://github.com/UniverLab/demo-stage): +# demo check demo/demo.toml +# demo export demo/demo.toml --target gif # β†’ demo/dist/cadforge.gif +# +# NOTE: browser panes are captured *after* the terminal run, so the +# `cadforge serve` background server must still be up at capture time β€” this demo +# deliberately does NOT stop it. Afterwards: `cd /tmp/cadforge-demo/bracket && +# cadforge serve --stop`. Adjust commands/url to your build if they drift. + +[demo] +name = "cadforge" +output_dir = "demo/dist" + +[typing] +base_ms = 70 +salt_ms = 14 +seed = 7 + +[env] +isolated = true +setup_script = "rm -rf /tmp/cadforge-demo && mkdir -p /tmp/cadforge-demo && cd /tmp/cadforge-demo" + +[layout] +width = 1280 +height = 720 +fps = 12 +background = "#0b0f14" + + [[layout.panes]] + id = "console" + type = "terminal" + x = 0 + y = 0 + width = 760 + height = 720 + font_size = 15 + + [[layout.panes]] + id = "preview" + type = "browser" + x = 760 + y = 0 + width = 520 + height = 720 + url = "http://127.0.0.1:4377" + +[[timeline]] +action = "focus" +pane = "console" + +[[timeline]] +action = "caption" +text = "1 β€” scaffold a CAD project" + +[[timeline]] +action = "type" +text = "cadforge new bracket && cd bracket" +human_salt = true + +[[timeline]] +action = "keypress" +key = "enter" + +[[timeline]] +action = "wait" +duration_ms = 1500 + +[[timeline]] +action = "caption" +text = "2 β€” start the live preview" + +[[timeline]] +action = "type" +text = "cadforge serve" +human_salt = true + +[[timeline]] +action = "keypress" +key = "enter" + +[[timeline]] +action = "wait_for_stdout" +match = "127.0.0.1:4377" + +[[timeline]] +action = "wait" +duration_ms = 1500 + +[[timeline]] +action = "caption" +text = "3 β€” the drawing renders in the browser" + +[[timeline]] +action = "focus" +pane = "preview" + +[[timeline]] +action = "scroll" +direction = "down" +duration_ms = 2500 + +[[timeline]] +action = "terminate" From 40d8f88d7eb69ad84ab7de6839425d95ba8291af Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Sun, 21 Jun 2026 10:37:49 -0500 Subject: [PATCH 05/39] =?UTF-8?q?feat:=20add=20a=203D=20solid=20engine=20?= =?UTF-8?q?=E2=80=94=20mesh=20CSG=20(extrude=20+=20boolean),=20camera=20vi?= =?UTF-8?q?ews=20and=20derived=20plans=20wired=20into=20the=20live=20previ?= =?UTF-8?q?ew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- project.toml | 20 ++ src/lib.rs | 3 + src/main.rs | 56 +++- src/mesh.rs | 484 +++++++++++++++++++++++++++++++++ src/model.rs | 44 +++ src/parser.rs | 31 +++ src/planos.rs | 289 ++++++++++++++++++++ src/preview.rs | 98 +++++++ src/render3d.rs | 620 +++++++++++++++++++++++++++++++++++++++++++ src/scaffold.rs | 206 +++++++------- src/schema.rs | 59 +++- src/serve.rs | 403 ++++++++++++++++++++++++---- tests/integration.rs | 1 + 14 files changed, 2144 insertions(+), 172 deletions(-) create mode 100644 project.toml create mode 100644 src/mesh.rs create mode 100644 src/planos.rs create mode 100644 src/render3d.rs diff --git a/README.md b/README.md index 34cd733..fc330e2 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ complete CLI reference. ### πŸ—οΈ Project Management -- **Project Scaffolding** β€” `cadforge new` creates a complete multi-layer project (muros, puertas, mobiliario, cotas) with meaningful architectural examples. +- **Project Scaffolding** β€” `cadforge new` creates a multi-layer starter project (shapes, curves, annotations) that showcases the core `.cf` primitives. - **Multi-Layer Compilation** β€” Compile all layers or target specific layers with `--layer`. Custom output path with `--output`. - **Auto-Rebuild** β€” `cadforge watch` monitors `.cf` and `.toml` files and auto-rebuilds DXF on changes with 300ms debounce. - **Code Formatting** β€” `cadforge fmt` normalizes `.cf` files. `--check` mode for CI validation. diff --git a/project.toml b/project.toml new file mode 100644 index 0000000..4758097 --- /dev/null +++ b/project.toml @@ -0,0 +1,20 @@ + +[[plano]] +name = "P-01" +view = "iso" +size = [420.0, 297.0] +scale = "1:50" +title = "Vista isometrica" + +[[plano]] +name = "P-02" +view = "front" +title = "Alzado frontal" + +[[plano]] +name = "P-03" +view = "section" +cut_axis = "z" +cut_at = 1.5 +keep = "min" +title = "Corte horizontal z=1.5" diff --git a/src/lib.rs b/src/lib.rs index 022de0a..67cd553 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,9 +8,12 @@ pub mod config; pub mod dxf_writer; pub mod fmt; pub mod importer; +pub mod mesh; pub mod model; pub mod parser; +pub mod planos; pub mod preview; +pub mod render3d; pub mod scaffold; pub mod schema; pub mod serve; diff --git a/src/main.rs b/src/main.rs index a46e58a..bf310d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,10 +3,10 @@ use cadforge::compiler::{check_project, compile_project, list_layers, project_re use cadforge::config::{config_set, config_show}; use cadforge::fmt::format_project; use cadforge::importer::import_dxf; -use cadforge::preview::{generate_preview, PreviewOutputs}; +use cadforge::preview::{generate_plano, generate_preview, PreviewOutputs, PreviewView}; use cadforge::scaffold::{create_project, init_project}; use cadforge::schema::print_schema; -use cadforge::serve::serve_project; +use cadforge::serve::{serve_daemon, serve_project, serve_stop}; use cadforge::viewer::view_project; use cadforge::watch::watch_project; use clap::{Parser, Subcommand, ValueEnum}; @@ -85,8 +85,15 @@ enum Commands { /// Highlight entities by id (comma-separated) with labeled markers #[arg(long, value_delimiter = ',')] highlight: Vec, + /// Render the extruded 3D view instead of the flat plan + #[arg(long = "3d")] + three_d: bool, + /// Render a named plano (drawing sheet) defined in project.toml + #[arg(long)] + plano: Option, }, - /// Live preview server β€” browser auto-reloads when .cf files change + /// Live preview server β€” browser auto-reloads when .cf files change. + /// Runs detached in the background by default; use --foreground to stay attached. Serve { /// Project directory (defaults to current dir) #[arg(short, long)] @@ -97,6 +104,12 @@ enum Commands { /// Open the browser automatically #[arg(long)] open: bool, + /// Stay in the foreground (stream logs, stop with Ctrl+C) instead of daemonizing + #[arg(short = 'f', long)] + foreground: bool, + /// Stop the background server running for this project + #[arg(long)] + stop: bool, }, /// Print the .cf language reference (markdown, for humans and AI agents) Schema, @@ -219,17 +232,48 @@ fn main() -> Result<()> { layer, format, highlight, + three_d, + plano, } => { let dir = resolve_project_dir(path)?; let outputs = PreviewOutputs { png: matches!(format, PreviewFormat::Png | PreviewFormat::All), svg: matches!(format, PreviewFormat::Svg | PreviewFormat::All), }; - generate_preview(&dir, width, height, layer.as_deref(), &highlight, outputs) + if let Some(name) = plano { + generate_plano(&dir, &name, width, height, outputs) + } else { + let view = if three_d { + PreviewView::ThreeD + } else { + PreviewView::Plan + }; + generate_preview( + &dir, + width, + height, + layer.as_deref(), + &highlight, + outputs, + view, + ) + } } - Commands::Serve { path, port, open } => { + Commands::Serve { + path, + port, + open, + foreground, + stop, + } => { let dir = resolve_project_dir(path)?; - serve_project(&dir, port, open) + if stop { + serve_stop(&dir, port) + } else if foreground { + serve_project(&dir, port, open) + } else { + serve_daemon(&dir, port, open) + } } Commands::Schema => { print_schema(); diff --git a/src/mesh.rs b/src/mesh.rs new file mode 100644 index 0000000..b13c6e3 --- /dev/null +++ b/src/mesh.rs @@ -0,0 +1,484 @@ +//! Triangle meshes + CSG booleans for the solid 3D view. +//! +//! Solids are triangle meshes. Booleans (`union`/`difference`/`intersection`) +//! use a BSP-tree algorithm (the classic csg.js approach) β€” no dependencies, +//! deterministic. Primitives (`box`, `cylinder`, extruded `prism`) tessellate +//! to meshes; [`render3d`](crate::render3d) projects and shades the result. + +/// A 3D point / vector. +pub type V3 = [f64; 3]; + +fn add(a: V3, b: V3) -> V3 { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn sub(a: V3, b: V3) -> V3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn scale(a: V3, s: f64) -> V3 { + [a[0] * s, a[1] * s, a[2] * s] +} +fn dot(a: V3, b: V3) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn cross(a: V3, b: V3) -> V3 { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +fn normalize(a: V3) -> V3 { + let l = dot(a, a).sqrt(); + if l < 1e-12 { + a + } else { + scale(a, 1.0 / l) + } +} +fn lerp(a: V3, b: V3, t: f64) -> V3 { + add(a, scale(sub(b, a), t)) +} + +/// A triangle mesh (the only solid representation we keep). +#[derive(Clone, Default)] +pub struct Mesh { + pub tris: Vec<[V3; 3]>, +} + +impl Mesh { + fn from_polys(polys: &[Poly]) -> Mesh { + let mut tris = Vec::new(); + for p in polys { + // Fan-triangulate (BSP output polygons are convex). + for i in 1..p.verts.len().saturating_sub(1) { + tris.push([p.verts[0], p.verts[i], p.verts[i + 1]]); + } + } + Mesh { tris } + } + + fn to_polys(&self) -> Vec { + self.tris.iter().map(|t| Poly::new(t.to_vec())).collect() + } + + /// Boolean union (`self` βˆͺ `other`). + pub fn union(&self, other: &Mesh) -> Mesh { + Mesh::from_polys(&csg_union(self.to_polys(), other.to_polys())) + } + + /// Boolean difference (`self` βˆ’ `other`). + pub fn difference(&self, other: &Mesh) -> Mesh { + Mesh::from_polys(&csg_difference(self.to_polys(), other.to_polys())) + } + + /// Boolean intersection (`self` ∩ `other`). + pub fn intersection(&self, other: &Mesh) -> Mesh { + Mesh::from_polys(&csg_intersection(self.to_polys(), other.to_polys())) + } +} + +// ── Primitive builders ────────────────────────────────────────────────── + +/// Axis-aligned box with its minimum corner at `at` and the given `size`. +pub fn box_solid(at: V3, size: V3) -> Mesh { + let [x, y, z] = at; + let [sx, sy, sz] = size; + let c = [ + [x, y, z], // 0 + [x + sx, y, z], // 1 + [x + sx, y + sy, z], // 2 + [x, y + sy, z], // 3 + [x, y, z + sz], // 4 + [x + sx, y, z + sz], // 5 + [x + sx, y + sy, z + sz], // 6 + [x, y + sy, z + sz], // 7 + ]; + // Each face as two triangles, wound CCW as seen from outside (consistent + // outward normals β€” the CSG BSP relies on it). + let faces = [ + ([0, 3, 2], [0, 2, 1]), // bottom -Z + ([4, 5, 6], [4, 6, 7]), // top +Z + ([0, 1, 5], [0, 5, 4]), // -Y + ([1, 2, 6], [1, 6, 5]), // +X + ([2, 3, 7], [2, 7, 6]), // +Y + ([3, 0, 4], [3, 4, 7]), // -X + ]; + let mut tris = Vec::with_capacity(12); + for (t1, t2) in faces { + tris.push([c[t1[0]], c[t1[1]], c[t1[2]]]); + tris.push([c[t2[0]], c[t2[1]], c[t2[2]]]); + } + Mesh { tris } +} + +/// Vertical cylinder: base circle centered at `at`, radius `r`, height `h`. +pub fn cylinder_solid(at: V3, r: f64, h: f64, segments: usize) -> Mesh { + let segs = segments.max(3); + let [cx, cy, cz] = at; + let top_c = [cx, cy, cz + h]; + let bot_c = [cx, cy, cz]; + let ring = |z: f64| -> Vec { + (0..segs) + .map(|i| { + let a = (i as f64 / segs as f64) * std::f64::consts::TAU; + [cx + r * a.cos(), cy + r * a.sin(), z] + }) + .collect() + }; + let bot = ring(cz); + let top = ring(cz + h); + let mut tris = Vec::with_capacity(segs * 4); + for i in 0..segs { + let j = (i + 1) % segs; + // Side (two triangles), outward. + tris.push([bot[i], bot[j], top[j]]); + tris.push([bot[i], top[j], top[i]]); + // Bottom fan (facing -Z) and top fan (facing +Z). + tris.push([bot_c, bot[j], bot[i]]); + tris.push([top_c, top[i], top[j]]); + } + Mesh { tris } +} + +/// Extrude a 2D footprint from `base` to `base + height`. `closed` adds caps +/// (a solid prism); otherwise only side walls are produced (an open wall). +pub fn prism(footprint: &[[f64; 2]], base: f64, height: f64, closed: bool) -> Mesh { + let n = footprint.len(); + if n < 2 { + return Mesh::default(); + } + let top = base + height; + let mut tris = Vec::new(); + let edges = if closed { n } else { n - 1 }; + for i in 0..edges { + let a = footprint[i]; + let b = footprint[(i + 1) % n]; + let (a0, a1) = ([a[0], a[1], base], [a[0], a[1], top]); + let (b0, b1) = ([b[0], b[1], base], [b[0], b[1], top]); + tris.push([a0, b0, b1]); + tris.push([a0, b1, a1]); + } + if closed && n >= 3 { + // Caps (fan). Bottom faces -Z, top faces +Z. + for i in 1..n - 1 { + let t0 = [footprint[0][0], footprint[0][1], top]; + let ti = [footprint[i][0], footprint[i][1], top]; + let tj = [footprint[i + 1][0], footprint[i + 1][1], top]; + tris.push([t0, ti, tj]); + let b0 = [footprint[0][0], footprint[0][1], base]; + let bi = [footprint[i][0], footprint[i][1], base]; + let bj = [footprint[i + 1][0], footprint[i + 1][1], base]; + tris.push([b0, bj, bi]); + } + } + Mesh { tris } +} + +// ── BSP CSG (port of csg.js) ────────────────────────────────────────────── + +const EPS: f64 = 1e-5; + +#[derive(Clone)] +struct Plane { + normal: V3, + w: f64, +} + +impl Plane { + fn from_points(a: V3, b: V3, c: V3) -> Plane { + let normal = normalize(cross(sub(b, a), sub(c, a))); + Plane { + w: dot(normal, a), + normal, + } + } + fn flip(&mut self) { + self.normal = scale(self.normal, -1.0); + self.w = -self.w; + } +} + +#[derive(Clone)] +struct Poly { + verts: Vec, + plane: Plane, +} + +impl Poly { + fn new(verts: Vec) -> Poly { + let plane = Plane::from_points(verts[0], verts[1], verts[2]); + Poly { verts, plane } + } + fn flip(&mut self) { + self.verts.reverse(); + self.plane.flip(); + } +} + +const COPLANAR: u8 = 0; +const FRONT: u8 = 1; +const BACK: u8 = 2; +const SPANNING: u8 = 3; + +/// Split `poly` by `plane`, appending the pieces to the appropriate buckets. +fn split_polygon( + plane: &Plane, + poly: &Poly, + coplanar_front: &mut Vec, + coplanar_back: &mut Vec, + front: &mut Vec, + back: &mut Vec, +) { + let mut poly_type = 0u8; + let mut types = Vec::with_capacity(poly.verts.len()); + for v in &poly.verts { + let t = dot(plane.normal, *v) - plane.w; + let ty = if t < -EPS { + BACK + } else if t > EPS { + FRONT + } else { + COPLANAR + }; + poly_type |= ty; + types.push(ty); + } + match poly_type { + COPLANAR => { + if dot(plane.normal, poly.plane.normal) > 0.0 { + coplanar_front.push(poly.clone()); + } else { + coplanar_back.push(poly.clone()); + } + } + FRONT => front.push(poly.clone()), + BACK => back.push(poly.clone()), + _ => { + let mut fv = Vec::new(); + let mut bv = Vec::new(); + let n = poly.verts.len(); + for i in 0..n { + let j = (i + 1) % n; + let (ti, tj) = (types[i], types[j]); + let (vi, vj) = (poly.verts[i], poly.verts[j]); + if ti != BACK { + fv.push(vi); + } + if ti != FRONT { + bv.push(vi); + } + if (ti | tj) == SPANNING { + let denom = dot(plane.normal, sub(vj, vi)); + let t = if denom.abs() < 1e-12 { + 0.0 + } else { + (plane.w - dot(plane.normal, vi)) / denom + }; + let v = lerp(vi, vj, t); + fv.push(v); + bv.push(v); + } + } + if fv.len() >= 3 { + front.push(Poly::new(fv)); + } + if bv.len() >= 3 { + back.push(Poly::new(bv)); + } + } + } +} + +#[derive(Default)] +struct Node { + plane: Option, + front: Option>, + back: Option>, + polygons: Vec, +} + +impl Node { + fn build(&mut self, polygons: Vec) { + if polygons.is_empty() { + return; + } + if self.plane.is_none() { + self.plane = Some(polygons[0].plane.clone()); + } + let plane = self.plane.clone().unwrap(); + let mut front = Vec::new(); + let mut back = Vec::new(); + let mut cf = Vec::new(); + let mut cb = Vec::new(); + for p in &polygons { + split_polygon(&plane, p, &mut cf, &mut cb, &mut front, &mut back); + } + // Both coplanar buckets stay on this node (csg.js Node.build). + self.polygons.extend(cf); + self.polygons.extend(cb); + if !front.is_empty() { + self.front.get_or_insert_with(Default::default).build(front); + } + if !back.is_empty() { + self.back.get_or_insert_with(Default::default).build(back); + } + } + + fn invert(&mut self) { + for p in &mut self.polygons { + p.flip(); + } + if let Some(pl) = &mut self.plane { + pl.flip(); + } + if let Some(f) = &mut self.front { + f.invert(); + } + if let Some(b) = &mut self.back { + b.invert(); + } + std::mem::swap(&mut self.front, &mut self.back); + } + + fn clip_polygons(&self, polygons: Vec) -> Vec { + let Some(plane) = &self.plane else { + return polygons; + }; + let mut front = Vec::new(); + let mut back = Vec::new(); + let mut cf = Vec::new(); + let mut cb = Vec::new(); + for p in &polygons { + split_polygon(plane, p, &mut cf, &mut cb, &mut front, &mut back); + } + // Coplanar pieces follow csg.js: front-facing with front, back with back. + front.extend(cf); + back.extend(cb); + let mut front = match &self.front { + Some(n) => n.clip_polygons(front), + None => front, + }; + if let Some(n) = &self.back { + front.extend(n.clip_polygons(back)); + } + front + } + + fn clip_to(&mut self, other: &Node) { + self.polygons = other.clip_polygons(std::mem::take(&mut self.polygons)); + if let Some(f) = &mut self.front { + f.clip_to(other); + } + if let Some(b) = &mut self.back { + b.clip_to(other); + } + } + + fn all_polygons(&self) -> Vec { + let mut out = self.polygons.clone(); + if let Some(f) = &self.front { + out.extend(f.all_polygons()); + } + if let Some(b) = &self.back { + out.extend(b.all_polygons()); + } + out + } +} + +fn node_of(polys: Vec) -> Node { + let mut n = Node::default(); + n.build(polys); + n +} + +fn csg_union(a: Vec, b: Vec) -> Vec { + let mut a = node_of(a); + let mut b = node_of(b); + a.clip_to(&b); + b.clip_to(&a); + b.invert(); + b.clip_to(&a); + b.invert(); + a.build(b.all_polygons()); + a.all_polygons() +} + +fn csg_difference(a: Vec, b: Vec) -> Vec { + let mut a = node_of(a); + let mut b = node_of(b); + a.invert(); + a.clip_to(&b); + b.clip_to(&a); + b.invert(); + b.clip_to(&a); + b.invert(); + a.build(b.all_polygons()); + a.invert(); + a.all_polygons() +} + +fn csg_intersection(a: Vec, b: Vec) -> Vec { + let mut a = node_of(a); + let mut b = node_of(b); + a.invert(); + b.clip_to(&a); + b.invert(); + a.clip_to(&b); + b.clip_to(&a); + a.build(b.all_polygons()); + a.invert(); + a.all_polygons() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn signed_volume(m: &Mesh) -> f64 { + // Sum of signed tetrahedron volumes (origin apex) β€” |result| = volume. + m.tris + .iter() + .map(|t| dot(t[0], cross(t[1], t[2])) / 6.0) + .sum::() + .abs() + } + + #[test] + fn box_volume_is_correct() { + let b = box_solid([0.0, 0.0, 0.0], [2.0, 3.0, 4.0]); + assert_eq!(b.tris.len(), 12); + assert!((signed_volume(&b) - 24.0).abs() < 1e-6); + } + + #[test] + fn difference_removes_material() { + // 4Γ—4Γ—4 cube minus a 2Γ—2 column straight through it: volume 64 - 16 = 48. + let cube = box_solid([0.0, 0.0, 0.0], [4.0, 4.0, 4.0]); + let bore = box_solid([1.0, 1.0, -1.0], [2.0, 2.0, 6.0]); + let holed = cube.difference(&bore); + assert!(!holed.tris.is_empty()); + assert!( + (signed_volume(&holed) - 48.0).abs() < 1e-4, + "expected 48, got {}", + signed_volume(&holed) + ); + } + + #[test] + fn union_merges_volume() { + // Two disjoint unit cubes: total volume 2. + let a = box_solid([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]); + let b = box_solid([5.0, 0.0, 0.0], [1.0, 1.0, 1.0]); + assert!((signed_volume(&a.union(&b)) - 2.0).abs() < 1e-4); + } + + #[test] + fn cylinder_and_prism_build() { + assert!(!cylinder_solid([0.0, 0.0, 0.0], 1.0, 2.0, 24) + .tris + .is_empty()); + let sq = [[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]]; + assert!((signed_volume(&prism(&sq, 0.0, 3.0, true)) - 12.0).abs() < 1e-6); + } +} diff --git a/src/model.rs b/src/model.rs index 16eb5ef..a2490d9 100644 --- a/src/model.rs +++ b/src/model.rs @@ -15,6 +15,11 @@ pub struct CommonAttrs { pub visible: bool, #[serde(default)] pub locked: bool, + /// Extrusion height in world units for the 3D view. `None`/0 stays flat; + /// a closed shape becomes a solid, a line/open polyline becomes a wall. + pub extrude: Option, + /// Base elevation (Z) for the 3D view. Defaults to 0 (the ground plane). + pub elevation: Option, } fn default_true() -> bool { @@ -220,6 +225,41 @@ pub struct CfFill { pub common: CommonAttrs, } +// ── 3D solids (CSG) ──────────────────────────────────────────────────── + +/// A named 3D primitive solid for the extruded/CSG view. Referenced by id from +/// `[[boolean]]`. 3D-only β€” solids do not appear in the 2D plan or DXF. +#[derive(Debug, Clone, Deserialize)] +pub struct CfSolid { + pub id: String, + /// `box` | `cylinder`. + pub shape: String, + /// Placement: box minimum corner, or cylinder base-circle center. Default origin. + pub at: Option<[f64; 3]>, + /// Box dimensions [sx, sy, sz]. + pub size: Option<[f64; 3]>, + /// Cylinder radius. + pub radius: Option, + /// Cylinder height. + pub height: Option, + /// Cylinder facet count (default 40). + pub segments: Option, + pub color: Option, +} + +/// A CSG operation combining named solids. The result is rendered; the solids +/// it consumes (`base` + `tools`) are not drawn on their own. +#[derive(Debug, Clone, Deserialize)] +pub struct CfBoolean { + pub id: Option, + /// `difference` | `union` | `intersection`. + pub op: String, + pub base: String, + #[serde(default)] + pub tools: Vec, + pub color: Option, +} + // ── Layer-level metadata ─────────────────────────────────────────────── #[derive(Debug, Clone, Deserialize, Default)] @@ -265,4 +305,8 @@ pub struct CfFile { pub arrays: Vec, #[serde(default, rename = "mirror")] pub mirrors: Vec, + #[serde(default, rename = "solid")] + pub solids: Vec, + #[serde(default, rename = "boolean")] + pub booleans: Vec, } diff --git a/src/parser.rs b/src/parser.rs index ac5fe2f..3557a97 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -14,6 +14,37 @@ pub struct ProjectFile { pub layers: IndexMap, #[serde(default)] pub constraints: Option, + /// Drawing sheets ("planos"): named views of the model with a title block. + #[serde(default, rename = "plano")] + pub planos: Vec, +} + +/// A drawing sheet: a named view of the model at a paper size, with a title +/// block (rΓ³tulo). Views: `plan` (top), `iso`, `front`/`back`/`left`/`right` +/// elevations, or `section` (a cut). +#[derive(Debug, Clone, Deserialize)] +pub struct Plano { + pub name: String, + #[serde(default = "default_view")] + pub view: String, + /// Sheet size [width, height] in mm. Default A3 landscape. + pub size: Option<[f64; 2]>, + /// Drawing scale label for the title block (e.g. "1:50"). + pub scale: Option, + /// Title shown in the rΓ³tulo (defaults to the plano name). + pub title: Option, + /// Path to a `.cf` file drawn as a custom title block instead of the default. + pub rotulo: Option, + /// Section only: cut axis `x` | `y` | `z`. + pub cut_axis: Option, + /// Section only: position of the cut plane along `cut_axis`. + pub cut_at: Option, + /// Section only: which side to keep, `min` (default) or `max`. + pub keep: Option, +} + +fn default_view() -> String { + "plan".to_string() } #[derive(Debug, Clone, Deserialize)] diff --git a/src/planos.rs b/src/planos.rs new file mode 100644 index 0000000..64589af --- /dev/null +++ b/src/planos.rs @@ -0,0 +1,289 @@ +//! Planos (drawing sheets) β€” render a named view of the model onto a sheet +//! with a title block (rΓ³tulo). +//! +//! A "plano" is the CAD notion of a sheet/viewport: it reuses the one model and +//! presents it through a chosen view (plan, iso, the four elevations, or a +//! section cut), framed at a paper size with a title block. The title block is +//! either auto-generated from the project metadata or drawn by the user in a +//! `.cf` file referenced via `rotulo` β€” same primitives as everything else. + +use crate::parser::{parse_cf, Plano}; +use crate::render3d::{render_view, Camera, Cut}; +use crate::svg::{load_project_layers, render_scene_from}; +use anyhow::Result; +use std::fmt::Write as _; +use std::path::Path; + +const INNER_W: u32 = 1400; +const BG: &str = "#0d0d0d"; +const FRAME: &str = "#9a9a9a"; +const INK: &str = "#e0e0e0"; +const MUTED: &str = "#9a9a9a"; + +/// A rendered sheet SVG plus its pixel size. +pub struct Sheet { + pub svg: String, + pub width_px: f64, + pub height_px: f64, +} + +/// Render a single plano to a framed sheet SVG. +pub fn render_plano(project_dir: &Path, plano: &Plano, target_width: u32) -> Result { + let (project, layers) = load_project_layers(project_dir, None)?; + + // 1. Render the model through the requested view. + let (inner_svg, iw, ih) = match plano.view.as_str() { + "plan" => { + let s = render_scene_from( + &project.project.name, + &project.project.units, + &layers, + INNER_W, + &[], + ); + (s.svg, s.width_px, s.height_px) + } + "section" => { + let axis = match plano.cut_axis.as_deref() { + Some("x") => 0, + Some("y") => 1, + _ => 2, // default: horizontal cut (z) + }; + let cam = match axis { + 0 => Camera::right_side(), + 1 => Camera::front(), + _ => Camera::top(), + }; + let cut = Cut { + axis, + at: plano.cut_at.unwrap_or(0.0), + keep_max: plano.keep.as_deref() == Some("max"), + }; + let r = render_view(&layers, INNER_W, &cam, Some(&cut)); + (r.svg, r.width_px, r.height_px) + } + other => { + let cam = match other { + "front" => Camera::front(), + "back" => Camera::back(), + "left" => Camera::left_side(), + "right" => Camera::right_side(), + "top" => Camera::top(), + _ => Camera::iso(), + }; + let r = render_view(&layers, INNER_W, &cam, None); + (r.svg, r.width_px, r.height_px) + } + }; + + // 2. Sheet geometry (paper size in mm β†’ pixels). + let [mm_w, mm_h] = plano.size.unwrap_or([420.0, 297.0]); // A3 landscape + let sheet_w = target_width as f64; + let sheet_h = sheet_w * (mm_h / mm_w).max(0.1); + let margin = sheet_w * 0.02; + + // Title block: bottom-right box. + let tb_w = (sheet_w * 0.34).min(sheet_w - 2.0 * margin); + let tb_h = (sheet_h * 0.16).min(sheet_h - 2.0 * margin); + let tb_x = sheet_w - margin - tb_w; + let tb_y = sheet_h - margin - tb_h; + + // Drawing area: inside the frame, above the title block. + let draw_x = margin + margin; + let draw_y = margin + margin; + let draw_w = sheet_w - 2.0 * draw_x; + let draw_h = (tb_y - draw_y - margin).max(1.0); + + // 3. Compose the sheet SVG. + let mut out = String::with_capacity(inner_svg.len() + 4096); + let _ = write!( + out, + r#""#, + w = sheet_w, + h = sheet_h, + ); + let _ = write!(out, r#""#); + // Outer frame border. + let _ = write!( + out, + r#""#, + x = margin, + w = sheet_w - 2.0 * margin, + h = sheet_h - 2.0 * margin, + ); + + // The model view, fitted into the drawing area (nested SVG keeps aspect). + let _ = write!( + out, + r#"{body}"#, + dx = draw_x, + dy = draw_y, + dw = draw_w, + dh = draw_h, + body = svg_inner(&inner_svg), + ); + + // 4. Title block (rΓ³tulo): custom .cf or generated default. + out.push_str(&title_block( + project_dir, + plano, + &project.project.name, + &project.project.units, + project.project.scale.as_str(), + tb_x, + tb_y, + tb_w, + tb_h, + )); + + out.push_str(""); + Ok(Sheet { + svg: out, + width_px: sheet_w, + height_px: sheet_h, + }) +} + +/// The content between an SVG's outer `` and `` tags. +fn svg_inner(svg: &str) -> &str { + let start = svg.find('>').map(|i| i + 1).unwrap_or(0); + let end = svg.rfind("").unwrap_or(svg.len()); + if start <= end { + &svg[start..end] + } else { + "" + } +} + +#[allow(clippy::too_many_arguments)] +fn title_block( + project_dir: &Path, + plano: &Plano, + project_name: &str, + units: &str, + project_scale: &str, + x: f64, + y: f64, + w: f64, + h: f64, +) -> String { + // Custom rΓ³tulo: render the referenced .cf, fitted into the title block. + if let Some(rotulo) = &plano.rotulo { + if let Ok(cf) = parse_cf(&project_dir.join(rotulo)) { + let scene = render_scene_from( + project_name, + units, + &[("rotulo".to_string(), cf)], + INNER_W, + &[], + ); + return format!( + r#""#, + ) + &format!( + r#"{body}"#, + iw = scene.width_px, + ih = scene.height_px, + body = svg_inner(&scene.svg), + ); + } + } + + // Default rΓ³tulo. + let title = plano.title.as_deref().unwrap_or(&plano.name); + let scale = plano.scale.as_deref().unwrap_or(project_scale); + let pad = w * 0.04; + let row = h / 4.0; + let mut s = String::new(); + let _ = write!( + s, + r#""#, + ); + // Divider under the title. + let _ = write!( + s, + r#""#, + ly = y + row * 1.6, + x2 = x + w, + ); + let fs_title = (row * 0.62).max(8.0); + let fs = (row * 0.40).max(7.0); + let _ = write!( + s, + r#"{title}"#, + tx = x + pad, + ty = y + row * 1.05, + title = xml_escape(title), + ); + let _ = write!( + s, + r#"{n}"#, + tx = x + pad, + ty = y + row * 2.35, + n = xml_escape(project_name), + ); + let _ = write!( + s, + r#"Escala {scale} Β· {units} Β· {view}"#, + tx = x + pad, + ty = y + row * 3.25, + view = xml_escape(&plano.view), + scale = xml_escape(scale), + units = xml_escape(units), + ); + let _ = write!( + s, + r#"cadforge"#, + tx = x + w - pad, + ty = y + h - row * 0.35, + ); + s +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::Plano; + + fn plano(view: &str) -> Plano { + Plano { + name: "P-01".into(), + view: view.into(), + size: Some([420.0, 297.0]), + scale: Some("1:50".into()), + title: Some("Planta baja".into()), + rotulo: None, + cut_axis: None, + cut_at: None, + keep: None, + } + } + + #[test] + fn renders_plan_sheet_with_title_block() { + let dir = Path::new("examples/vivienda"); + let s = render_plano(dir, &plano("plan"), 1200).unwrap(); + assert!(s.svg.contains(r#"data-view="plano""#)); + assert!(s.svg.contains("Planta baja")); + assert!(s.svg.contains("Escala 1:50")); + // The model view is nested inside the sheet. + assert!(s.svg.matches("= 2); + assert!((s.width_px - 1200.0).abs() < 1.0 && s.height_px > 0.0); + } + + #[test] + fn renders_section_sheet() { + let dir = Path::new("examples/vivienda"); + let mut p = plano("section"); + p.cut_axis = Some("z".into()); + p.cut_at = Some(1.0); + let s = render_plano(dir, &p, 1000).unwrap(); + assert!(s.svg.contains(r#"data-view="plano""#)); + } +} diff --git a/src/preview.rs b/src/preview.rs index da7f5ad..55c670d 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -7,6 +7,9 @@ //! the image. use crate::model::CfFile; +use crate::parser::parse_project; +use crate::planos::render_plano; +use crate::render3d::render_scene_3d; use crate::svg::{ enumerate_entities, layer_display_color, load_project_layers, render_scene_from, Scene, }; @@ -70,6 +73,15 @@ pub struct PreviewOutputs { pub svg: bool, } +/// Which projection to render. +#[derive(Clone, Copy, PartialEq)] +pub enum PreviewView { + /// Flat 2D plan (the default). + Plan, + /// Extruded axonometric 3D view. + ThreeD, +} + /// Generate preview artifacts (PNG + metadata JSON, and/or SVG) for the project. /// /// Everything is produced from a single parse + render pass. @@ -82,7 +94,11 @@ pub fn generate_preview( layer_filter: Option<&str>, highlight: &[String], outputs: PreviewOutputs, + view: PreviewView, ) -> Result<()> { + if view == PreviewView::ThreeD { + return generate_preview_3d(project_dir, width, height, layer_filter, outputs); + } let (project, layers) = load_project_layers(project_dir, layer_filter)?; let scene = render_scene_from( &project.project.name, @@ -134,6 +150,88 @@ pub fn generate_preview( Ok(()) } +/// Render the extruded 3D view. Geometry is projected axonometrically, so there +/// is no worldβ†’pixel mapping to emit β€” we write the PNG (and optional SVG) only. +fn generate_preview_3d( + project_dir: &Path, + width: u32, + height: u32, + layer_filter: Option<&str>, + outputs: PreviewOutputs, +) -> Result<()> { + let (_project, layers) = load_project_layers(project_dir, layer_filter)?; + let scene = render_scene_3d(&layers, width); + + if outputs.svg { + let svg_path = project_dir.join("preview.svg"); + std::fs::write(&svg_path, &scene.svg) + .with_context(|| format!("Cannot write {}", svg_path.display()))?; + println!("βœ“ SVG (3D): {}", svg_path.display()); + } + + if !outputs.png { + return Ok(()); + } + + let fit = (height as f64 / scene.height_px).min(1.0); + let pixmap = rasterize(&scene.svg, fit as f32)?; + let png_path = project_dir.join("preview.png"); + pixmap + .save_png(&png_path) + .map_err(|e| anyhow::anyhow!("Failed to save PNG: {}", e))?; + println!( + "βœ“ Preview (3D): {} ({}x{})", + png_path.display(), + pixmap.width(), + pixmap.height() + ); + Ok(()) +} + +/// Render a named plano (drawing sheet) to `preview.png` (and/or `preview.svg`). +pub fn generate_plano( + project_dir: &Path, + name: &str, + width: u32, + height: u32, + outputs: PreviewOutputs, +) -> Result<()> { + let project = parse_project(&project_dir.join("project.toml"))?; + let plano = project + .planos + .iter() + .find(|p| p.name == name) + .ok_or_else(|| { + let names: Vec<&str> = project.planos.iter().map(|p| p.name.as_str()).collect(); + anyhow::anyhow!("no plano named '{}' (have: {})", name, names.join(", ")) + })?; + let sheet = render_plano(project_dir, plano, width)?; + + if outputs.svg { + let svg_path = project_dir.join("preview.svg"); + std::fs::write(&svg_path, &sheet.svg) + .with_context(|| format!("Cannot write {}", svg_path.display()))?; + println!("βœ“ SVG (plano '{}'): {}", name, svg_path.display()); + } + if !outputs.png { + return Ok(()); + } + let fit = (height as f64 / sheet.height_px).min(1.0); + let pixmap = rasterize(&sheet.svg, fit as f32)?; + let png_path = project_dir.join("preview.png"); + pixmap + .save_png(&png_path) + .map_err(|e| anyhow::anyhow!("Failed to save PNG: {}", e))?; + println!( + "βœ“ Preview (plano '{}'): {} ({}x{})", + name, + png_path.display(), + pixmap.width(), + pixmap.height() + ); + Ok(()) +} + // ── Internal ──────────────────────────────────────────────────────────── fn build_meta( diff --git a/src/render3d.rs b/src/render3d.rs new file mode 100644 index 0000000..1195585 --- /dev/null +++ b/src/render3d.rs @@ -0,0 +1,620 @@ +//! 3D view renderer β€” extrudes `.cf` geometry into solids and projects them. +//! +//! cadforge geometry is declared in 2D plan coordinates. Any primitive can +//! carry an `extrude` height (and optional `elevation`): a closed shape becomes +//! a solid prism, a line or open polyline becomes a vertical wall. This module +//! projects that geometry with a fixed axonometric (isometric) camera, sorts +//! the faces back-to-front (painter's algorithm) and shades them, producing a +//! single self-contained SVG β€” the same backend story as the 2D renderer, so +//! it rasterizes to PNG and streams to the live viewer identically. + +use crate::mesh; +use crate::model::{CfFile, CommonAttrs}; +use crate::svg::layer_display_color; +use std::fmt::Write as _; + +const BG_COLOR: &str = "#141414"; +const PADDING_PX: f64 = 48.0; +const CIRCLE_SEGMENTS: usize = 40; + +/// A rendered 3D SVG plus its pixel size (for the PNG rasterizer). +pub struct Render3d { + pub svg: String, + pub width_px: f64, + pub height_px: f64, +} + +/// One projected, shaded polygon ready to emit, with its camera depth. +struct Face { + /// Projected screen-space points (pre-fit): (horizontal, vertical-up). + proj: Vec<(f64, f64)>, + fill: Option, + stroke: String, + stroke_w: f64, + /// Larger = closer to the camera (drawn later, on top). + depth: f64, + layer: String, + id: Option, +} + +/// An orthographic/axonometric camera: screen-horizontal (`right`), screen-up +/// (`up`) and the viewing direction (`view`, pointing from the scene toward the +/// camera β€” used for depth sorting and back-face culling). Project a world +/// point by dotting it against `right`/`up`. +#[derive(Clone, Copy)] +pub struct Camera { + right: V3, + up: V3, + view: V3, +} + +impl Camera { + /// Standard 2:1 isometric (the default 3D look). + pub fn iso() -> Camera { + const C: f64 = 0.866_025_403_784_438_6; + const S: f64 = 0.5; + Camera { + right: [C, -C, 0.0], + up: [-S, -S, 1.0], + view: [0.577_350_27, 0.577_350_27, 0.577_350_27], + } + } + /// Looking straight down (-Z): a plan / planta. + pub fn top() -> Camera { + Camera { + right: [1.0, 0.0, 0.0], + up: [0.0, 1.0, 0.0], + view: [0.0, 0.0, 1.0], + } + } + pub fn front() -> Camera { + Camera { + right: [1.0, 0.0, 0.0], + up: [0.0, 0.0, 1.0], + view: [0.0, -1.0, 0.0], + } + } + pub fn back() -> Camera { + Camera { + right: [-1.0, 0.0, 0.0], + up: [0.0, 0.0, 1.0], + view: [0.0, 1.0, 0.0], + } + } + pub fn right_side() -> Camera { + Camera { + right: [0.0, -1.0, 0.0], + up: [0.0, 0.0, 1.0], + view: [1.0, 0.0, 0.0], + } + } + pub fn left_side() -> Camera { + Camera { + right: [0.0, 1.0, 0.0], + up: [0.0, 0.0, 1.0], + view: [-1.0, 0.0, 0.0], + } + } + fn project(&self, p: V3) -> (f64, f64) { + (v_dot(p, self.right), v_dot(p, self.up)) + } + fn depth(&self, p: V3) -> f64 { + v_dot(p, self.view) + } +} + +/// A section cut: keep one half-space of the model, revealing the interior at +/// the cut plane. `axis` is 0=x, 1=y, 2=z; `keep_max` keeps the side where the +/// coordinate is β‰₯ `at` (otherwise ≀ `at`). +#[derive(Clone, Copy)] +pub struct Cut { + pub axis: usize, + pub at: f64, + pub keep_max: bool, +} + +impl Cut { + /// A huge box covering the discarded half-space (to subtract from solids). + fn discard_box(&self) -> mesh::Mesh { + const BIG: f64 = 1.0e5; + let mut at = [-BIG, -BIG, -BIG]; + let mut size = [2.0 * BIG, 2.0 * BIG, 2.0 * BIG]; + if self.keep_max { + at[self.axis] = -BIG; // discard coord < at + size[self.axis] = self.at + BIG; + } else { + at[self.axis] = self.at; // discard coord > at + size[self.axis] = BIG; + } + mesh::box_solid(at, size) + } +} + +/// Render already-parsed layers to an isometric 3D [`Render3d`]. +pub fn render_scene_3d(layers: &[(String, CfFile)], width: u32) -> Render3d { + render_view(layers, width, &Camera::iso(), None) +} + +/// Render already-parsed layers through an arbitrary [`Camera`], optionally +/// sectioned by `cut`. +pub fn render_view( + layers: &[(String, CfFile)], + width: u32, + cam: &Camera, + cut: Option<&Cut>, +) -> Render3d { + let mut faces: Vec = Vec::new(); + + for (idx, (layer_name, cf)) in layers.iter().enumerate() { + let visible = cf.layer_meta.as_ref().map(|m| m.visible).unwrap_or(true); + if !visible { + continue; + } + let layer_color = layer_display_color(cf, idx); + collect_layer_faces(&mut faces, layer_name, cf, &layer_color, cam, cut); + } + + // Project bounds across every face to size the canvas. + let (mut min_h, mut min_v, mut max_h, mut max_v) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for f in &faces { + for &(h, v) in &f.proj { + min_h = min_h.min(h); + min_v = min_v.min(v); + max_h = max_h.max(h); + max_v = max_v.max(v); + } + } + if faces.is_empty() { + min_h = 0.0; + min_v = 0.0; + max_h = 10.0; + max_v = 10.0; + } + + let span_h = (max_h - min_h).max(1e-6); + let span_v = (max_v - min_v).max(1e-6); + let width_px = width as f64; + let scale = (width_px - 2.0 * PADDING_PX).max(1.0) / span_h; + let height_px = span_v * scale + 2.0 * PADDING_PX; + + // Worldβ†’screen: flip vertical (SVG y grows down) so up is up. + let to_px = |(h, v): (f64, f64)| -> (f64, f64) { + ( + (h - min_h) * scale + PADDING_PX, + (max_v - v) * scale + PADDING_PX, + ) + }; + + // Painter's algorithm: farthest first. + faces.sort_by(|a, b| { + a.depth + .partial_cmp(&b.depth) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut out = String::with_capacity(16 * 1024 + faces.len() * 96); + let _ = write!( + out, + r#""#, + w = width_px, + h = height_px, + ); + let _ = write!( + out, + r#""#, + BG_COLOR + ); + + for f in &faces { + let pts: String = f + .proj + .iter() + .map(|&p| { + let (x, y) = to_px(p); + format!("{x:.2},{y:.2}") + }) + .collect::>() + .join(" "); + let layer_attr = format!(r#" data-layer="{}""#, xml_escape(&f.layer)); + let id_attr = match &f.id { + Some(id) => format!(r#" data-id="{}""#, xml_escape(id)), + None => String::new(), + }; + match &f.fill { + Some(fill) => { + let _ = write!( + out, + r#""#, + stroke = f.stroke, + sw = f.stroke_w, + ); + } + None => { + let _ = write!( + out, + r#""#, + stroke = f.stroke, + sw = f.stroke_w, + ); + } + } + } + + out.push_str(""); + Render3d { + svg: out, + width_px, + height_px, + } +} + +/// A 2D footprint extracted from a primitive, ready to extrude. +struct Footprint<'a> { + points: Vec<[f64; 2]>, + closed: bool, + common: &'a CommonAttrs, +} + +fn collect_layer_faces( + faces: &mut Vec, + layer_name: &str, + cf: &CfFile, + layer_color: &str, + cam: &Camera, + cut: Option<&Cut>, +) { + let mut prints: Vec = Vec::new(); + + for e in &cf.rects { + let [ox, oy] = e.origin; + prints.push(Footprint { + points: vec![ + [ox, oy], + [ox + e.width, oy], + [ox + e.width, oy + e.height], + [ox, oy + e.height], + ], + closed: true, + common: &e.common, + }); + } + for e in &cf.polylines { + prints.push(Footprint { + points: e.points.clone(), + closed: e.closed, + common: &e.common, + }); + } + for e in &cf.lines { + prints.push(Footprint { + points: vec![e.from, e.to], + closed: false, + common: &e.common, + }); + } + for e in &cf.circles { + prints.push(Footprint { + points: polygonize_arc(e.center, e.radius, 0.0, 360.0, CIRCLE_SEGMENTS), + closed: true, + common: &e.common, + }); + } + for e in &cf.arcs { + let segs = ((e.to_angle - e.from_angle).abs() / 360.0 * CIRCLE_SEGMENTS as f64) + .ceil() + .max(2.0) as usize; + prints.push(Footprint { + points: polygonize_arc(e.center, e.radius, e.from_angle, e.to_angle, segs), + closed: false, + common: &e.common, + }); + } + + for fp in prints { + if fp.points.len() < 2 { + continue; + } + let base = fp.common.elevation.unwrap_or(0.0); + let h = fp.common.extrude.unwrap_or(0.0); + let color = fp + .common + .color + .clone() + .unwrap_or_else(|| layer_color.to_string()); + let id = fp.common.id.clone(); + + if h > 0.0 { + // Extruded footprint β†’ a real prism/wall mesh. + let mesh = apply_cut(mesh::prism(&fp.points, base, h, fp.closed), cut); + emit_mesh(faces, &mesh, &color, layer_name, &id, cam); + } else if cut.is_none() { + // Flat footprint lying on the ground plane (the plan, projected). + // Skipped in section views (there is no solid to cut). + emit_outline(faces, &fp, base, &color, layer_name, &id, cam); + } + } + + collect_solid_faces(faces, layer_name, cf, layer_color, cam, cut); +} + +/// Subtract the cut's discarded half-space from `m`, if a cut is active. +fn apply_cut(m: mesh::Mesh, cut: Option<&Cut>) -> mesh::Mesh { + match cut { + Some(c) => m.difference(&c.discard_box()), + None => m, + } +} + +/// Build the named [`mesh`] solids, apply each `[[boolean]]`, and emit the +/// results. Solids consumed by a boolean are not drawn on their own. +fn collect_solid_faces( + faces: &mut Vec, + layer_name: &str, + cf: &CfFile, + layer_color: &str, + cam: &Camera, + cut: Option<&Cut>, +) { + use std::collections::{HashMap, HashSet}; + + let mut meshes: HashMap<&str, mesh::Mesh> = HashMap::new(); + let mut colors: HashMap<&str, String> = HashMap::new(); + for s in &cf.solids { + meshes.insert(s.id.as_str(), build_solid(s)); + colors.insert( + s.id.as_str(), + s.color.clone().unwrap_or_else(|| layer_color.to_string()), + ); + } + + let mut consumed: HashSet<&str> = HashSet::new(); + for b in &cf.booleans { + consumed.insert(b.base.as_str()); + for t in &b.tools { + consumed.insert(t.as_str()); + } + } + + for b in &cf.booleans { + let Some(base) = meshes.get(b.base.as_str()) else { + continue; // unknown base β€” skip rather than fail the whole render + }; + let mut acc = base.clone(); + for t in &b.tools { + if let Some(tool) = meshes.get(t.as_str()) { + acc = match b.op.as_str() { + "union" => acc.union(tool), + "intersection" => acc.intersection(tool), + _ => acc.difference(tool), // default: difference (cut) + }; + } + } + let color = b + .color + .clone() + .or_else(|| colors.get(b.base.as_str()).cloned()) + .unwrap_or_else(|| layer_color.to_string()); + emit_mesh(faces, &apply_cut(acc, cut), &color, layer_name, &b.id, cam); + } + + for s in &cf.solids { + if consumed.contains(s.id.as_str()) { + continue; + } + let (Some(m), Some(c)) = (meshes.get(s.id.as_str()), colors.get(s.id.as_str())) else { + continue; + }; + emit_mesh( + faces, + &apply_cut(m.clone(), cut), + c, + layer_name, + &Some(s.id.clone()), + cam, + ); + } +} + +fn build_solid(s: &crate::model::CfSolid) -> mesh::Mesh { + let at = s.at.unwrap_or([0.0, 0.0, 0.0]); + match s.shape.as_str() { + "cylinder" => mesh::cylinder_solid( + at, + s.radius.unwrap_or(1.0), + s.height.unwrap_or(1.0), + s.segments.unwrap_or(40), + ), + _ => mesh::box_solid(at, s.size.unwrap_or([1.0, 1.0, 1.0])), + } +} + +// ── Mesh β†’ shaded faces ──────────────────────────────────────────────── + +/// Light direction (from the upper front). +const LIGHT: V3 = [0.32, 0.24, 0.92]; +const AMBIENT: f64 = 0.40; +const DIFFUSE: f64 = 0.60; + +type V3 = [f64; 3]; + +fn v_sub(a: V3, b: V3) -> V3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn v_cross(a: V3, b: V3) -> V3 { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +fn v_dot(a: V3, b: V3) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn v_norm(a: V3) -> V3 { + let l = v_dot(a, a).sqrt(); + if l < 1e-12 { + a + } else { + [a[0] / l, a[1] / l, a[2] / l] + } +} + +/// Project a mesh: cull back faces, flat-shade by the surface normal, and push +/// one [`Face`] per visible triangle. +fn emit_mesh( + faces: &mut Vec, + m: &mesh::Mesh, + color: &str, + layer: &str, + id: &Option, + cam: &Camera, +) { + for t in &m.tris { + let normal = v_norm(v_cross(v_sub(t[1], t[0]), v_sub(t[2], t[0]))); + if v_dot(normal, cam.view) <= 0.0 { + continue; // back face β€” hidden + } + let lit = AMBIENT + DIFFUSE * v_dot(normal, LIGHT).max(0.0); + let proj: Vec<(f64, f64)> = t.iter().map(|&p| cam.project(p)).collect(); + let cx = (t[0][0] + t[1][0] + t[2][0]) / 3.0; + let cy = (t[0][1] + t[1][1] + t[2][1]) / 3.0; + let cz = (t[0][2] + t[1][2] + t[2][2]) / 3.0; + // Stroke == fill: coplanar triangles (same shade) merge seamlessly so + // the internal triangulation never shows; the small width closes the + // anti-alias cracks between adjacent triangles. Face-to-face contrast + // (different normals β†’ different shade) still defines the real edges. + let fill = scale_color(color, lit.clamp(0.0, 1.0)); + faces.push(Face { + proj, + stroke: fill.clone(), + fill: Some(fill), + stroke_w: 1.0, + depth: cam.depth([cx, cy, cz]), + layer: layer.to_string(), + id: id.clone(), + }); + } +} + +/// A flat (non-extruded) footprint, drawn as an outline on the ground plane. +fn emit_outline( + faces: &mut Vec, + fp: &Footprint, + base: f64, + color: &str, + layer: &str, + id: &Option, + cam: &Camera, +) { + let mut ring: Vec<[f64; 3]> = fp.points.iter().map(|p| [p[0], p[1], base]).collect(); + if fp.closed { + if let Some(&first) = ring.first() { + ring.push(first); // close the outline visually + } + } + let mut proj = Vec::with_capacity(ring.len()); + let mut d = f64::MIN; + for &p in &ring { + proj.push(cam.project(p)); + d = d.max(cam.depth(p)); + } + faces.push(Face { + proj, + fill: None, + stroke: color.to_string(), + stroke_w: 1.4, + depth: d - 0.01, // ground outlines sit just behind solids at the same spot + layer: layer.to_string(), + id: id.clone(), + }); +} + +/// Sample points along an arc (degrees, counterclockwise). For a full circle +/// pass 0β†’360; the duplicate end point is dropped for closed footprints. +fn polygonize_arc(center: [f64; 2], radius: f64, from: f64, to: f64, segs: usize) -> Vec<[f64; 2]> { + let segs = segs.max(2); + let full = (to - from).abs() >= 359.999; + let count = if full { segs } else { segs + 1 }; + let mut pts = Vec::with_capacity(count); + for i in 0..count { + let t = i as f64 / segs as f64; + let ang = (from + (to - from) * t).to_radians(); + pts.push([ + center[0] + radius * ang.cos(), + center[1] + radius * ang.sin(), + ]); + } + pts +} + +/// Multiply each channel of `#RRGGBB` by `factor`, clamped to a valid color. +fn scale_color(hex: &str, factor: f64) -> String { + let h = hex.trim_start_matches('#'); + let parse = |s: &str| u8::from_str_radix(s, 16).unwrap_or(180); + let (r, g, b) = if h.len() == 6 { + (parse(&h[0..2]), parse(&h[2..4]), parse(&h[4..6])) + } else { + (180, 180, 180) + }; + let scale = |c: u8| ((c as f64) * factor).round().clamp(0.0, 255.0) as u8; + format!("#{:02X}{:02X}{:02X}", scale(r), scale(g), scale(b)) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::CfFile; + + #[test] + fn extruded_rect_produces_solid_faces() { + let cf: CfFile = toml::from_str( + r#" +[[rect]] +id = "rc-1" +origin = [0.0, 0.0] +width = 4.0 +height = 3.0 +extrude = 2.5 +"#, + ) + .unwrap(); + let r = render_scene_3d(&[("l".to_string(), cf)], 800); + // A box is 12 triangles; back-face culling leaves the visible front + // faces (top + the sides facing the camera) β€” several shaded polygons. + let polys = r.svg.matches(" 0.0 && r.height_px > 0.0); + } + + #[test] + fn flat_geometry_renders_as_ground_outline() { + let cf: CfFile = toml::from_str( + r#" +[[rect]] +origin = [0.0, 0.0] +width = 4.0 +height = 3.0 +"#, + ) + .unwrap(); + let r = render_scene_3d(&[("l".to_string(), cf)], 800); + assert_eq!(r.svg.matches(" Result<()> { println!("βœ“ Project '{}' created at {}", name, project_dir.display()); println!(" β†’ project.toml"); - println!(" β†’ muros.cf"); - println!(" β†’ puertas.cf"); - println!(" β†’ mobiliario.cf"); - println!(" β†’ cotas.cf"); + println!(" β†’ shapes.cf"); + println!(" β†’ curves.cf"); + println!(" β†’ annotations.cf"); println!(" β†’ .gitignore"); println!( "\n Run `cadforge serve --path {}` for a live preview,", @@ -44,10 +43,9 @@ pub fn init_project(dir: &Path) -> Result<()> { println!("βœ“ Initialized CADforge project in {}", dir.display()); println!(" β†’ project.toml"); - println!(" β†’ muros.cf"); - println!(" β†’ puertas.cf"); - println!(" β†’ mobiliario.cf"); - println!(" β†’ cotas.cf"); + println!(" β†’ shapes.cf"); + println!(" β†’ curves.cf"); + println!(" β†’ annotations.cf"); println!(" β†’ .gitignore"); println!("\n Run `cadforge serve` for a live preview."); println!(" `cadforge schema` prints the .cf language reference."); @@ -62,127 +60,115 @@ scale = "1:100" units = "m" [layers] -muros = {{ file = "muros.cf", locked = false }} -puertas = {{ file = "puertas.cf", locked = false }} -mobiliario = {{ file = "mobiliario.cf", locked = false }} -cotas = {{ file = "cotas.cf", locked = false }} +shapes = {{ file = "shapes.cf", locked = false }} +curves = {{ file = "curves.cf", locked = false }} +annotations = {{ file = "annotations.cf", locked = false }} "# ); fs::write(project_dir.join("project.toml"), project_toml)?; - let gitignore = "# CADforge output\noutput.dxf\npreview.png\npreview.svg\npreview.meta.json\n\n# Rust build artifacts\ntarget/\n"; + let gitignore = "# CADforge output\noutput.dxf\npreview.png\npreview.svg\npreview.meta.json\n\n# CADforge serve daemon (pid + logs)\n.cadforge/\n\n# Rust build artifacts\ntarget/\n"; fs::write(project_dir.join(".gitignore"), gitignore)?; - let muros_cf = r##"[layer] -name = "muros" + let shapes_cf = r##"[layer] +name = "shapes" color = "#FFFFFF" -line_weight = 0.50 +line_weight = 0.35 -# PerΓ­metro exterior +# Rectangle from its bottom-left origin. +# `extrude` gives it height in the 3D view (the `3D` button) β€” here, a box. +[[rect]] +id = "rc-001" +origin = [0.0, 0.0] +width = 4.0 +height = 4.0 +extrude = 3.0 + +# Closed polyline (a polygon) β€” also used as a hatch boundary [[polyline]] -id = "pl-perimetro" -points = [[0.0, 0.0], [8.0, 0.0], [8.0, 6.0], [0.0, 6.0]] +id = "pl-001" +points = [[6.0, 0.0], [10.0, 0.0], [11.0, 2.0], [8.0, 4.0], [6.0, 2.0]] closed = true -weight = 0.50 - -# Muro divisorio horizontal -[[line]] -id = "ln-div-h" -from = [0.0, 3.5] -to = [5.0, 3.5] -weight = 0.35 - -# Muro divisorio vertical -[[line]] -id = "ln-div-v" -from = [5.0, 0.0] -to = [5.0, 6.0] -weight = 0.35 -"##; - fs::write(project_dir.join("muros.cf"), muros_cf)?; - - let puertas_cf = r##"[layer] -name = "puertas" -color = "#00CC44" - -# Puerta principal -[[arc]] -id = "ar-puerta-principal" -center = [0.0, 2.5] -radius = 0.9 -from_angle = 0.0 -to_angle = 90.0 -# Puerta interior -[[arc]] -id = "ar-puerta-int" -center = [5.0, 4.5] -radius = 0.8 -from_angle = 90.0 -to_angle = 180.0 +# Reference marker (drawn as a cross) +[[point]] +id = "pt-001" +position = [2.0, 2.0] + +# Pattern fill inside the polygon above +[[hatch]] +id = "ht-001" +boundary = "pl-001" +pattern = "ansi31" +angle = 45.0 "##; - fs::write(project_dir.join("puertas.cf"), puertas_cf)?; + fs::write(project_dir.join("shapes.cf"), shapes_cf)?; - let mobiliario_cf = r##"[layer] -name = "mobiliario" + let curves_cf = r##"[layer] +name = "curves" color = "#4488FF" +line_weight = 0.25 -# Mesa sala -[[rect]] -id = "rc-mesa" -origin = [1.5, 4.5] -width = 2.0 -height = 1.0 - -# Cama dormitorio -[[rect]] -id = "rc-cama" -origin = [5.5, 4.0] -width = 2.0 -height = 1.5 - -# Etiquetas -[[text]] -id = "tx-sala" -position = [2.0, 5.0] -content = "SALA" -size = 0.25 +# Full circle β€” extruded into a cylinder in the 3D view +[[circle]] +id = "ci-001" +center = [2.0, 8.0] +radius = 1.5 +extrude = 2.0 -[[text]] -id = "tx-dorm" -position = [6.0, 5.0] -content = "DORMITORIO" -size = 0.20 +# Half arc (angles in degrees, counterclockwise from +X) +[[arc]] +id = "ar-001" +center = [6.0, 8.0] +radius = 1.5 +from_angle = 0.0 +to_angle = 180.0 -[[text]] -id = "tx-cocina" -position = [2.0, 1.5] -content = "COCINA" -size = 0.20 +# A small circle repeated around a center (polar array). +# The array expands at build time into ci-sat, ci-sat@1, ci-sat@2, … +[[circle]] +id = "ci-sat" +center = [11.5, 8.0] +radius = 0.4 + +[[array]] +target = "ci-sat" +mode = "polar" +count = 6 +center = [10.0, 8.0] +step_angle = 60.0 "##; - fs::write(project_dir.join("mobiliario.cf"), mobiliario_cf)?; + fs::write(project_dir.join("curves.cf"), curves_cf)?; - let cotas_cf = r##"[layer] -name = "cotas" + let annotations_cf = r##"[layer] +name = "annotations" color = "#FF4444" +line_weight = 0.18 -# Cota horizontal total +# Linear dimension β€” the measured distance is labeled automatically [[dim]] -id = "dm-ancho" +id = "dm-001" type = "linear" from = [0.0, 0.0] -to = [8.0, 0.0] +to = [4.0, 0.0] offset = -0.8 -# Cota vertical total -[[dim]] -id = "dm-alto" -type = "linear" -from = [0.0, 0.0] -to = [0.0, 6.0] -offset = -0.8 +# Text labels (size is in world units, not points) +[[text]] +id = "tx-shapes" +position = [2.0, 4.4] +content = "shapes" +size = 0.4 +align = "center" + +[[text]] +id = "tx-curves" +position = [4.0, 10.2] +content = "curves" +size = 0.4 +align = "center" "##; - fs::write(project_dir.join("cotas.cf"), cotas_cf)?; + fs::write(project_dir.join("annotations.cf"), annotations_cf)?; Ok(()) } @@ -202,16 +188,15 @@ mod tests { let project_dir = tmp.join("mi-proyecto"); assert!(project_dir.join("project.toml").exists()); - assert!(project_dir.join("muros.cf").exists()); - assert!(project_dir.join("puertas.cf").exists()); - assert!(project_dir.join("mobiliario.cf").exists()); - assert!(project_dir.join("cotas.cf").exists()); + assert!(project_dir.join("shapes.cf").exists()); + assert!(project_dir.join("curves.cf").exists()); + assert!(project_dir.join("annotations.cf").exists()); assert!(!project_dir.join("AGENTS.md").exists()); assert!(project_dir.join(".gitignore").exists()); let content = fs::read_to_string(project_dir.join("project.toml")).unwrap(); assert!(content.contains("mi-proyecto")); - assert!(content.contains("muros.cf")); + assert!(content.contains("shapes.cf")); let gitignore = fs::read_to_string(project_dir.join(".gitignore")).unwrap(); assert!(gitignore.contains("output.dxf")); @@ -242,10 +227,9 @@ mod tests { init_project(&tmp).unwrap(); assert!(tmp.join("project.toml").exists()); - assert!(tmp.join("muros.cf").exists()); - assert!(tmp.join("puertas.cf").exists()); - assert!(tmp.join("mobiliario.cf").exists()); - assert!(tmp.join("cotas.cf").exists()); + assert!(tmp.join("shapes.cf").exists()); + assert!(tmp.join("curves.cf").exists()); + assert!(tmp.join("annotations.cf").exists()); assert!(tmp.join(".gitignore").exists()); let _ = fs::remove_dir_all(&tmp); diff --git a/src/schema.rs b/src/schema.rs index dc5e0a1..a50100a 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -28,6 +28,24 @@ puertas = { file = "puertas.cf", locked = false } puertas.parent = "muros" # child bbox must fit inside parent bbox cotas.belongs_to = "muros" # child primitives reference parent ids via belongs_to "muros β†’ puertas" = "spatial_dependency" # movement warning (informational) + +# Planos (drawing sheets): named views of the model with a title block. +# Render with `cadforge preview --plano `, or pick them in the viewer's +# Planos panel (under Layers). +[[plano]] +name = "P-01" +view = "plan" # plan | iso | front | back | left | right | top | section +size = [420.0, 297.0] # sheet size in mm (default A3 landscape) +scale = "1:50" # label shown in the rΓ³tulo (defaults to project scale) +title = "Planta baja" # rΓ³tulo title (defaults to name) +rotulo = "rotulo.cf" # optional: a .cf drawn as a custom title block + +[[plano]] +name = "C-01" +view = "section" # a cut, viewed along the cut axis +cut_axis = "z" # x | y | z +cut_at = 1.5 # plane position along the axis +keep = "min" # min (default) keeps the ≀ side, max keeps the β‰₯ side ``` ## Layer files (`.cf`) @@ -53,8 +71,14 @@ style = "solid" # solid | dashed | dotted | dashdot visible = true locked = false belongs_to = "id" # reference to a primitive id in the parent layer +extrude = 3.0 # 3D view: extrusion height (world units). closed shape β†’ solid, + # line / open polyline β†’ wall. 0/omitted = flat +elevation = 0.0 # 3D view: base height (Z) the shape sits at (default 0) ``` +The 2D plan is unaffected by `extrude`/`elevation`; they only shape the +extruded 3D view (`cadforge preview --3d`, or the viewer's `3D` button). + ### Primitives ```toml @@ -140,6 +164,37 @@ targets = ["pl-nave", "ar-puerta"] axis = [[2.5, 0.0], [2.5, 1.0]] ``` +### 3D solids (CSG) + +For the 3D view you can also declare true solids and combine them with boolean +operations (constructive solid geometry). Solids and booleans are **3D-only** β€” +they do not appear in the 2D plan or the DXF. (For simple extrusions, prefer +`extrude`/`elevation` on a 2D primitive; use solids when you need booleans.) + +```toml +[[solid]] # a named 3D primitive (referenced by booleans via id) +id = "cubo" +shape = "box" # box | cylinder +at = [0.0, 0.0, 0.0] # box: minimum corner; cylinder: base-circle center +size = [4.0, 4.0, 4.0] # box dimensions [sx, sy, sz] + +[[solid]] +id = "broca" +shape = "cylinder" +at = [2.0, 2.0, -0.5] +radius = 1.2 +height = 5.0 +segments = 48 # facet count (default 40) + +[[boolean]] # combine solids β€” the result is rendered, the inputs are not +id = "cubo-perforado" +op = "difference" # difference (cut) | union | intersection +base = "cubo" +tools = ["broca"] # subtracted from / merged with the base, in order +``` + +A cube with a hole = a `box` minus a `cylinder` via `op = "difference"`. + ## Conventions - Coordinates are world units (see `units`), Y grows upward, origin at [0, 0]. @@ -170,8 +225,8 @@ where intended (highlighted entities get labeled amber markers). For humans, `cadforge serve` adds: click any entity to inspect its source TOML block (copyable as an agent prompt for targeted edits), a layer panel -with on/ghost/off states (trace one floor over another), and a 3D stacked -view of the layers. +with on/ghost/off states (trace one floor over another), and a `3D` button +that renders the extruded view (see `extrude`/`elevation` above). "##; /// Print the `.cf` language reference to stdout. diff --git a/src/serve.rs b/src/serve.rs index 27d86cf..e50cdb2 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -6,20 +6,23 @@ //! //! Viewer features: pan/zoom, click-to-inspect any entity (shows its source //! TOML block, copyable for targeted agent edits), per-layer visibility with -//! a ghost mode for tracing over other floors, and a 3D stacked-layers view. +//! a ghost mode for tracing over other floors, and an extruded 3D view. //! //! Plain `std::net` HTTP β€” this is a localhost dev server, no framework needed. use crate::parser::parse_project; +use crate::render3d::render_scene_3d; use crate::svg::{layer_display_color, load_project_layers, render_scene_from}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::fs::{self, File}; use std::io::{BufRead, BufReader, Write}; -use std::net::{TcpListener, TcpStream}; +use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::mpsc; use std::sync::{Arc, Condvar, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; const SVG_WIDTH: u32 = 1600; const DEBOUNCE: Duration = Duration::from_millis(80); @@ -28,11 +31,15 @@ const SSE_KEEPALIVE: Duration = Duration::from_secs(15); struct LiveState { /// Arc so request handlers serve the SVG without copying it. svg: Arc, + /// Extruded axonometric 3D render of the same scene. + svg3d: Arc, error: Option, version: u64, project_name: String, /// (name, color) per layer, for the layer panel. layers: Vec<(String, String)>, + /// (name, view, title) per plano, for the planos panel. + planos: Vec<(String, String, String)>, } /// Shared state plus a condvar so SSE clients are woken the instant a rebuild @@ -55,10 +62,12 @@ pub fn serve_project(project_dir: &Path, port: u16, open: bool) -> Result<()> { let state: Shared = Arc::new(Live { state: Mutex::new(LiveState { svg: Arc::new(String::new()), + svg3d: Arc::new(String::new()), error: None, version: 0, project_name: project.project.name.clone(), layers: Vec::new(), + planos: Vec::new(), }), changed: Condvar::new(), project_dir: project_dir.clone(), @@ -94,8 +103,162 @@ pub fn serve_project(project_dir: &Path, port: u16, open: bool) -> Result<()> { Ok(()) } +// ── Background daemon ────────────────────────────────────────────────────── +// +// `serve` runs detached by default: a parent process validates the project and +// the port, spawns the real (foreground) server in its own process group with +// its output redirected to a log file, waits until the port actually accepts a +// connection, then prints the URL and exits. Waiting for real readiness means +// we never claim "running" for a server that failed to come up. + +fn runtime_dir(project_dir: &Path) -> PathBuf { + project_dir.join(".cadforge") +} + +fn pid_path(project_dir: &Path) -> PathBuf { + runtime_dir(project_dir).join("serve.pid") +} + +fn log_path(project_dir: &Path) -> PathBuf { + runtime_dir(project_dir).join("serve.log") +} + +/// True if `pid` refers to a live process (`kill -0`). +fn process_alive(pid: u32) -> bool { + Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// The recorded daemon pid for this project, but only if it is still alive. +fn running_pid(project_dir: &Path) -> Option { + let pid: u32 = fs::read_to_string(pid_path(project_dir)) + .ok()? + .trim() + .parse() + .ok()?; + process_alive(pid).then_some(pid) +} + +/// Block until the server accepts a connection on `port`, or `timeout` elapses. +fn wait_until_ready(port: u16, timeout: Duration) -> bool { + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() { + return true; + } + std::thread::sleep(Duration::from_millis(100)); + } + false +} + +/// Start the live preview server detached in the background (the default). +pub fn serve_daemon(project_dir: &Path, port: u16, open: bool) -> Result<()> { + // Validate the project up front so config errors surface here, not in a log. + parse_project(&project_dir.join("project.toml"))?; + let project_dir = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.to_path_buf()); + let url = format!("http://127.0.0.1:{}", port); + + if let Some(pid) = running_pid(&project_dir) { + println!("β—‰ cadforge serve already running (pid {pid})"); + println!(" Preview: {url}"); + println!(" Stop with: cadforge serve --stop"); + if open { + open_browser(&url); + } + return Ok(()); + } + + // Fail fast on a busy port instead of letting the detached child die quietly. + match TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => drop(listener), + Err(e) => bail!("Cannot bind 127.0.0.1:{port} (port in use?): {e}"), + } + + fs::create_dir_all(runtime_dir(&project_dir))?; + let log = log_path(&project_dir); + let log_file = File::create(&log)?; + + let exe = std::env::current_exe().context("cannot locate cadforge executable")?; + let mut cmd = Command::new(exe); + cmd.arg("serve") + .arg("--foreground") + .arg("--path") + .arg(&project_dir) + .arg("--port") + .arg(port.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file.try_clone()?)) + .stderr(Stdio::from(log_file)); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Own process group: survives the parent shell / agent command exiting. + cmd.process_group(0); + } + let child = cmd.spawn().context("failed to spawn background server")?; + let pid = child.id(); + fs::write(pid_path(&project_dir), pid.to_string())?; + + if wait_until_ready(port, Duration::from_secs(5)) { + println!("β—‰ cadforge serve β€” running in background (pid {pid})"); + println!(" Preview: {url}"); + println!(" Logs: {}", log.display()); + println!(" Stop with: cadforge serve --stop"); + if open { + open_browser(&url); + } + Ok(()) + } else { + let _ = fs::remove_file(pid_path(&project_dir)); + let tail = fs::read_to_string(&log).unwrap_or_default(); + bail!( + "server did not come up within 5s. Log:\n{}", + tail.trim_end() + ); + } +} + +/// Stop the background server running for this project. +pub fn serve_stop(project_dir: &Path, _port: u16) -> Result<()> { + let project_dir = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.to_path_buf()); + let pid_file = pid_path(&project_dir); + + let Some(pid) = running_pid(&project_dir) else { + let _ = fs::remove_file(&pid_file); // clean up any stale pidfile + println!("No cadforge serve daemon running for this project."); + return Ok(()); + }; + + let stopped = Command::new("kill") + .arg(pid.to_string()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let _ = fs::remove_file(&pid_file); + + if stopped { + println!("βœ“ Stopped cadforge serve (pid {pid})."); + Ok(()) + } else { + bail!("failed to stop process {pid}") + } +} + fn rebuild(project_dir: &Path, state: &Shared) { - let result = (|| -> Result<(String, Vec<(String, String)>)> { + type PlanoInfo = Vec<(String, String, String)>; + type Built = (String, String, Vec<(String, String)>, PlanoInfo); + let result = (|| -> Result { let (project, layers) = load_project_layers(project_dir, None)?; let scene = render_scene_from( &project.project.name, @@ -104,19 +267,33 @@ fn rebuild(project_dir: &Path, state: &Shared) { SVG_WIDTH, &[], ); + let scene3d = render_scene_3d(&layers, SVG_WIDTH); let layer_info = layers .iter() .enumerate() .map(|(i, (name, cf))| (name.clone(), layer_display_color(cf, i))) .collect(); - Ok((scene.svg, layer_info)) + let plano_info = project + .planos + .iter() + .map(|p| { + ( + p.name.clone(), + p.view.clone(), + p.title.clone().unwrap_or_else(|| p.name.clone()), + ) + }) + .collect(); + Ok((scene.svg, scene3d.svg, layer_info, plano_info)) })(); let mut st = state.state.lock().unwrap(); match result { - Ok((svg, layers)) => { + Ok((svg, svg3d, layers, planos)) => { st.svg = Arc::new(svg); + st.svg3d = Arc::new(svg3d); st.layers = layers; + st.planos = planos; st.error = None; } Err(e) => { @@ -128,6 +305,17 @@ fn rebuild(project_dir: &Path, state: &Shared) { state.changed.notify_all(); } +/// Render a plano by name to SVG (on demand, for the `/plano.svg` endpoint). +fn render_named_plano(project_dir: &Path, name: &str) -> Result { + let project = parse_project(&project_dir.join("project.toml"))?; + let plano = project + .planos + .iter() + .find(|p| p.name == name) + .with_context(|| format!("no plano named '{name}'"))?; + Ok(crate::planos::render_plano(project_dir, plano, SVG_WIDTH)?.svg) +} + fn spawn_watcher(project_dir: PathBuf, state: Shared) -> Result<()> { let (tx, rx) = mpsc::channel(); let mut watcher = RecommendedWatcher::new( @@ -203,6 +391,22 @@ fn handle_connection(stream: TcpStream, state: &Shared) -> std::io::Result<()> { let svg = Arc::clone(&state.state.lock().unwrap().svg); respond(stream, "200 OK", "image/svg+xml", svg.as_bytes()) } + "/preview3d.svg" => { + let svg = Arc::clone(&state.state.lock().unwrap().svg3d); + respond(stream, "200 OK", "image/svg+xml", svg.as_bytes()) + } + "/plano.svg" => { + // Rendered on demand (sections run CSG, so we don't precompute all). + let name = query_param(query, "name").unwrap_or_default(); + let dir = &state.project_dir; + let svg = render_named_plano(dir, &name).unwrap_or_else(|e| { + format!( + r##"plano error: {}"##, + html_escape(&format!("{e:#}")) + ) + }); + respond(stream, "200 OK", "image/svg+xml", svg.as_bytes()) + } "/state" => { let st = state.state.lock().unwrap(); let layers: Vec<_> = st @@ -210,11 +414,19 @@ fn handle_connection(stream: TcpStream, state: &Shared) -> std::io::Result<()> { .iter() .map(|(name, color)| serde_json::json!({"name": name, "color": color})) .collect(); + let planos: Vec<_> = st + .planos + .iter() + .map(|(name, view, title)| { + serde_json::json!({"name": name, "view": view, "title": title}) + }) + .collect(); let body = serde_json::json!({ "version": st.version, "project": st.project_name, "error": st.error, "layers": layers, + "planos": planos, }) .to_string(); drop(st); @@ -448,15 +660,27 @@ const INDEX_HTML: &str = r##" button.active { color: #FFB300; border-color: #FFB300; } #hint { margin-left: auto; font-size: 11px; color: #666; } main { flex: 1; display: flex; overflow: hidden; } - /* layer panel */ - #layers { width: 170px; flex: none; background: #121212; border-right: 1px solid #222; padding: 8px; overflow-y: auto; user-select: none; } - #layers h3 { font-size: 10px; color: #666; text-transform: uppercase; letter-spacing: 1px; margin: 2px 0 8px 4px; } + /* left sidebar: two stacked panes (Layers over Planos), IntelliJ-style */ + #sidebar { width: 200px; min-width: 130px; max-width: 560px; flex: none; background: #121212; border-right: 1px solid #222; display: flex; flex-direction: column; overflow: hidden; user-select: none; } + #layers-pane { flex: 1 1 auto; overflow-y: auto; padding: 8px; min-height: 48px; } + #planos-pane { flex: none; height: 40%; overflow-y: auto; padding: 8px; min-height: 48px; } + /* horizontal divider between the two panes */ + #pane-divider { height: 6px; flex: none; cursor: row-resize; background: #161616; border-top: 1px solid #222; border-bottom: 1px solid #222; transition: background .15s; } + #pane-divider:hover, #pane-divider.dragging { background: #FFB300; } + /* drag handle to resize the whole sidebar width */ + #layers-resizer { width: 6px; flex: none; cursor: col-resize; background: transparent; transition: background .15s; } + #layers-resizer:hover, #layers-resizer.dragging { background: #FFB300; } + #sidebar h3 { font-size: 10px; color: #666; text-transform: uppercase; letter-spacing: 1px; margin: 2px 0 8px 4px; } .layer-row { display: flex; align-items: center; gap: 7px; padding: 5px 6px; border-radius: 4px; cursor: pointer; font-size: 12px; } - .layer-row:hover { background: #1c1c1c; } + .layer-row:hover, .plano-row:hover { background: #1c1c1c; } .layer-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; } .layer-row .st { margin-left: auto; font-size: 10px; color: #666; } .layer-row.ghost { color: #777; } .layer-row.off { color: #4a4a4a; } + .plano-row { display: flex; align-items: baseline; gap: 7px; padding: 5px 6px; border-radius: 4px; cursor: pointer; font-size: 12px; } + .plano-row .pv { margin-left: auto; font-size: 9px; color: #666; text-transform: uppercase; } + .plano-row.active { background: #2a2410; color: #FFB300; } + #planos-pane .empty { font-size: 11px; color: #555; padding: 4px 6px; line-height: 1.5; } /* viewport */ #viewport { flex: 1; overflow: hidden; position: relative; cursor: grab; perspective: 2200px; background: #0d0d0d; } #viewport.panning { cursor: grabbing; } @@ -488,12 +712,17 @@ const INDEX_HTML: &str = r##" {{PROJECT_NAME}} cadforge live v0 - + edit .cf files β€” preview updates automatically
- + +

@@ -520,6 +749,7 @@ const dot = document.getElementById('dot');
 const errBox = document.getElementById('error');
 const versionTag = document.getElementById('version');
 const layerList = document.getElementById('layerlist');
+const planosList = document.getElementById('planoslist');
 const inspector = document.getElementById('inspector');
 const toast = document.getElementById('toast');
 
@@ -527,14 +757,19 @@ let scale = 1, tx = 0, ty = 0;
 let fitted = false;
 let mode3d = false;
 let svgText = '';
+let svg3dText = '';
 let layersInfo = [];                 // [{name, color}]
+let planosInfo = [];                 // [{name, view, title}]
+let currentPlano = null;             // active plano name, or null for the model
+let planoSvg = '';
 const layerState = {};               // name β†’ 'on' | 'ghost' | 'off'
 let selectedId = null;
 
 // ── transform / view ────────────────────────────────────────────────
 function applyTransform() {
-  const tilt = mode3d ? ' rotateX(55deg) rotateZ(-38deg)' : '';
-  canvas.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})${tilt}`;
+  // The 3D view is a real axonometric projection baked into the SVG, so the
+  // canvas only ever needs pan + zoom (no CSS tilt).
+  canvas.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
 }
 function svgSize() {
   const svg = canvas.querySelector('svg');
@@ -545,57 +780,29 @@ function fitToView() {
   const s = svgSize();
   if (!s) return;
   const vw = viewport.clientWidth, vh = viewport.clientHeight;
-  scale = Math.min(vw / s.w, vh / s.h) * (mode3d ? 0.7 : 0.96);
+  scale = Math.min(vw / s.w, vh / s.h) * 0.96;
   tx = (vw - s.w * scale) / 2;
-  ty = (vh - s.h * scale) / 2 + (mode3d ? vh * 0.08 : 0);
+  ty = (vh - s.h * scale) / 2;
   applyTransform();
   fitted = true;
 }
 
 // ── rendering ───────────────────────────────────────────────────────
 function renderCanvas() {
-  if (!svgText) return;
-  if (!mode3d) {
-    canvas.innerHTML = svgText;
-  } else {
-    canvas.innerHTML = '';
-    const tpl = document.createElement('div');
-    tpl.innerHTML = svgText;
-    const names = [...tpl.querySelectorAll('g[data-layer]')].map(g => g.dataset.layer);
-    names.forEach((name, i) => {
-      const div = document.createElement('div');
-      div.className = 'plane';
-      div.style.transform = `translateZ(${i * 70}px)`;
-      div.innerHTML = svgText;
-      const svg = div.querySelector('svg');
-      svg.querySelectorAll('g[data-layer]').forEach(g => { if (g.dataset.layer !== name) g.remove(); });
-      if (i > 0) {
-        svg.querySelector('g[data-grid]')?.remove();
-        svg.querySelector('rect')?.remove();        // background only on base plane
-      } else {
-        svg.querySelector('rect')?.setAttribute('fill-opacity', '0.85');
-      }
-      canvas.appendChild(div);
-    });
-  }
+  const content = currentPlano ? planoSvg : (mode3d ? svg3dText : svgText);
+  if (!content) return;
+  canvas.innerHTML = content;
   applyLayerStates();
   applySelection();
 }
 
 function applyLayerStates() {
-  canvas.querySelectorAll('g[data-layer]').forEach(g => {
+  // 2D tags layers on ; the 3D view tags each projected face β€” match both.
+  canvas.querySelectorAll('[data-layer]').forEach(g => {
     const st = layerState[g.dataset.layer] || 'on';
     g.style.opacity = st === 'on' ? '' : st === 'ghost' ? '0.16' : '0';
     g.style.pointerEvents = st === 'on' ? '' : 'none';
   });
-  if (mode3d) {
-    canvas.querySelectorAll('.plane').forEach(p => {
-      const g = p.querySelector('g[data-layer]');
-      if (!g) return;
-      const st = layerState[g.dataset.layer] || 'on';
-      p.style.display = st === 'off' ? 'none' : '';
-    });
-  }
 }
 
 function renderLayerPanel() {
@@ -617,6 +824,37 @@ function cycleLayer(name) {
   applyLayerStates();
 }
 
+// ── planos panel ────────────────────────────────────────────────────
+function renderPlanosPanel() {
+  planosList.innerHTML = '';
+  if (!planosInfo.length) {
+    planosList.innerHTML = '
no planos β€” add [[plano]] to project.toml
'; + return; + } + planosInfo.forEach(p => { + const row = document.createElement('div'); + row.className = 'plano-row' + (currentPlano === p.name ? ' active' : ''); + row.innerHTML = `${p.title || p.name}${p.view}`; + row.onclick = () => openPlano(p.name); + planosList.appendChild(row); + }); +} +async function fetchPlano(name) { + return (await fetch('/plano.svg?name=' + encodeURIComponent(name) + '&t=' + Date.now())).text(); +} +async function openPlano(name) { + if (currentPlano === name) { // toggle off β†’ back to the model + currentPlano = null; planoSvg = ''; + renderPlanosPanel(); renderCanvas(); fitToView(); + return; + } + currentPlano = name; + renderPlanosPanel(); + planoSvg = await fetchPlano(name); + renderCanvas(); + fitToView(); +} + // ── selection / inspector ─────────────────────────────────────────── function applySelection() { canvas.querySelectorAll('.sel').forEach(n => n.classList.remove('sel')); @@ -666,13 +904,15 @@ document.getElementById('close-ins').onclick = deselect; // ── data refresh ──────────────────────────────────────────────────── async function refresh() { - const [stateRes, svgRes] = await Promise.all([ - fetch('/state'), fetch('/preview.svg?t=' + Date.now()) + const [stateRes, svgRes, svg3dRes] = await Promise.all([ + fetch('/state'), fetch('/preview.svg?t=' + Date.now()), fetch('/preview3d.svg?t=' + Date.now()) ]); const state = await stateRes.json(); versionTag.textContent = 'v' + state.version; layersInfo = state.layers || []; + planosInfo = state.planos || []; renderLayerPanel(); + renderPlanosPanel(); if (state.error) { dot.classList.add('err'); errBox.textContent = state.error; @@ -681,6 +921,12 @@ async function refresh() { dot.classList.remove('err'); errBox.classList.remove('show'); svgText = await svgRes.text(); + svg3dText = await svg3dRes.text(); + // The active plano may reference changed geometry β€” re-render it too. + if (currentPlano) { + if (planosInfo.some(p => p.name === currentPlano)) planoSvg = await fetchPlano(currentPlano); + else { currentPlano = null; planoSvg = ''; } // plano was removed + } renderCanvas(); if (!fitted) fitToView(); } @@ -743,6 +989,59 @@ window.addEventListener('keydown', e => { } }); +// ── resizable sidebar (width) ─────────────────────────────────────── +(function () { + const sidebar = document.getElementById('sidebar'); + const rz = document.getElementById('layers-resizer'); + const KEY = 'cadforge.layersWidth', MIN = 130, MAX = 560, DEF = 200; + const saved = parseInt(localStorage.getItem(KEY) || '', 10); + if (saved >= MIN && saved <= MAX) sidebar.style.width = saved + 'px'; + let dragging = false; + rz.addEventListener('mousedown', e => { + dragging = true; rz.classList.add('dragging'); + document.body.style.cursor = 'col-resize'; e.preventDefault(); + }); + window.addEventListener('mousemove', e => { + if (!dragging) return; + const w = Math.min(MAX, Math.max(MIN, e.clientX - sidebar.getBoundingClientRect().left)); + sidebar.style.width = w + 'px'; + }); + window.addEventListener('mouseup', () => { + if (!dragging) return; + dragging = false; rz.classList.remove('dragging'); document.body.style.cursor = ''; + localStorage.setItem(KEY, parseInt(sidebar.style.width, 10)); + }); + rz.addEventListener('dblclick', () => { + sidebar.style.width = DEF + 'px'; localStorage.removeItem(KEY); + }); +})(); + +// ── stacked panes: drag the Layers/Planos divider (height) ────────── +(function () { + const sidebar = document.getElementById('sidebar'); + const pane = document.getElementById('planos-pane'); + const div = document.getElementById('pane-divider'); + const KEY = 'cadforge.planosHeight'; + const saved = parseInt(localStorage.getItem(KEY) || '', 10); + if (saved >= 48) pane.style.height = saved + 'px'; + let dragging = false; + div.addEventListener('mousedown', e => { + dragging = true; div.classList.add('dragging'); + document.body.style.cursor = 'row-resize'; e.preventDefault(); + }); + window.addEventListener('mousemove', e => { + if (!dragging) return; + const r = sidebar.getBoundingClientRect(); + const h = Math.min(r.height - 60, Math.max(48, r.bottom - e.clientY)); + pane.style.height = h + 'px'; + }); + window.addEventListener('mouseup', () => { + if (!dragging) return; + dragging = false; div.classList.remove('dragging'); document.body.style.cursor = ''; + localStorage.setItem(KEY, parseInt(pane.style.height, 10)); + }); +})(); + const events = new EventSource('/events'); events.onmessage = refresh; events.onerror = () => dot.classList.add('err'); diff --git a/tests/integration.rs b/tests/integration.rs index cce4778..9ad1f2f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -387,6 +387,7 @@ offset = -0.6 png: true, svg: true, }, + cadforge::preview::PreviewView::Plan, ) .unwrap(); From 052693042de427a420406ba310f398e6d8e8df06 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Sun, 21 Jun 2026 10:39:40 -0500 Subject: [PATCH 06/39] refactor: rename the crate, binary and CLI from cadforge to cadspec (GitHub repo URLs kept until the repo is renamed) --- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 4 +- Cargo.toml | 2 +- README.md | 120 ++++++++++++++-------------- cadforge-spec.md => cadspec-spec.md | 80 +++++++++---------- demo/README.md | 10 +-- demo/demo.toml | 18 ++--- docs/agents.md | 34 ++++---- docs/building-and-export.md | 26 +++--- docs/cf-format.md | 4 +- docs/cli-reference.md | 58 +++++++------- docs/index.md | 26 +++--- docs/installation.md | 14 ++-- docs/live-preview.md | 8 +- docs/quickstart.md | 22 ++--- scripts/install.ps1 | 16 ++-- scripts/install.sh | 16 ++-- src/config.rs | 4 +- src/dxf_writer.rs | 4 +- src/fmt.rs | 2 +- src/importer.rs | 4 +- src/lib.rs | 2 +- src/main.rs | 32 ++++---- src/planos.rs | 2 +- src/preview.rs | 2 +- src/render3d.rs | 2 +- src/scaffold.rs | 28 +++---- src/schema.rs | 32 ++++---- src/serve.rs | 28 +++---- src/svg.rs | 2 +- src/viewer.rs | 6 +- tests/integration.rs | 58 +++++++------- 32 files changed, 334 insertions(+), 334 deletions(-) rename cadforge-spec.md => cadspec-spec.md (77%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00965b8..256a1be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,5 +15,5 @@ jobs: if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' uses: UniverLab/workflows/.github/workflows/rust-release.yml@main with: - binary-name: "cadforge" + binary-name: "cadspec" secrets: inherit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f40a8e7..e306226 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to cadforge +# Contributing to cadspec Thank you for your interest in contributing! @@ -6,7 +6,7 @@ Thank you for your interest in contributing! ```bash git clone https://github.com/UniverLab/cadforge.git -cd cadforge +cd cadspec cargo build cargo test ``` diff --git a/Cargo.toml b/Cargo.toml index a4ecf47..193ae4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "cadforge" +name = "cadspec" version = "0.1.0-beta.2" edition = "2021" description = "CAD as code β€” deterministic geometry engine for reproducible CAD drawings" diff --git a/README.md b/README.md index fc330e2..e03c851 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@

CI - Crates.io + Crates.io Status License

-cadforge is a **CAD as code** CLI tool and Rust library for declarative CAD modeling. Write geometry as code in `.cf` TOML format, watch it live in the browser, and compile to DXF β€” built for humans and AI agents working together. +cadspec is a **CAD as code** CLI tool and Rust library for declarative CAD modeling. Write geometry as code in `.cf` TOML format, watch it live in the browser, and compile to DXF β€” built for humans and AI agents working together. --- @@ -28,29 +28,29 @@ cadforge is a **CAD as code** CLI tool and Rust library for declarative CAD mode The core loop: **describe geometry in TOML, see it instantly, iterate.** ```bash -cadforge new casa && cd casa -cadforge serve --open # live preview in the browser +cadspec new casa && cd casa +cadspec serve --open # live preview in the browser ``` Now edit any `.cf` file β€” by hand or by asking an AI agent β€” and the browser updates on every save. Parse errors and constraint violations appear as an -overlay instead of a crash. When the design is right, `cadforge build` emits +overlay instead of a crash. When the design is right, `cadspec build` emits a deterministic, AutoCAD-compatible DXF. Agents get first-class support: -- `cadforge schema` β€” full `.cf` language reference in one command; agents +- `cadspec schema` β€” full `.cf` language reference in one command; agents self-discover the format without prior training. -- `cadforge check --json` / `cadforge layers --json` β€” machine-readable +- `cadspec check --json` / `cadspec layers --json` β€” machine-readable validation reports. -- `cadforge preview` β€” a faithful PNG render (real text, measured dimension +- `cadspec preview` β€” a faithful PNG render (real text, measured dimension labels, hatches, line styles) + `preview.meta.json` with per-entity bounding boxes in world and pixel coordinates, so multimodal agents can *look* at the plan and locate every entity in the image. -- `cadforge preview --highlight ln-001,tx-002` β€” labeled amber markers around +- `cadspec preview --highlight ln-001,tx-002` β€” labeled amber markers around specific entities, so an agent can visually confirm its edit landed where intended. -- `cadforge preview --format svg` β€” same render as vector SVG. +- `cadspec preview --format svg` β€” same render as vector SVG. --- @@ -68,7 +68,7 @@ curl -fsSL https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/ins irm https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.ps1 | iex ``` -Or via cargo: `cargo install cadforge` β€” see [`docs/installation.md`](docs/installation.md) for all methods. +Or via cargo: `cargo install cadspec` β€” see [`docs/installation.md`](docs/installation.md) for all methods. ## Documentation @@ -86,20 +86,20 @@ complete CLI reference. - **πŸ“ Declarative Geometry** β€” Define architectural elements (lines, rects, circles, arcs, polylines, text, dimensions) in TOML `.cf` files. Deterministic, reproducible, version-controlled. - **πŸ› οΈ Construction Tools** β€” `[[array]]` (linear and polar: spiral staircases, gear teeth, repeated columns) and `[[mirror]]` expand into concrete primitives at build time; copies get derived ids (`base@1`, `base@m`). - **πŸ“ Styled Dimensions** β€” Auto-measured labels with configurable `text_size`, `precision`, `show_units`, and `offset` per dimension. -- **πŸ”΄ Live Preview** β€” `cadforge serve` runs a local server with pan/zoom, auto-reload on save (SSE), click-to-inspect any entity (copy its source TOML as an agent prompt), per-layer ghost/hide states, a 3D stacked-layers view, and a build-error overlay. Zero config. +- **πŸ”΄ Live Preview** β€” `cadspec serve` runs a local server with pan/zoom, auto-reload on save (SSE), click-to-inspect any entity (copy its source TOML as an agent prompt), per-layer ghost/hide states, a 3D stacked-layers view, and a build-error overlay. Zero config. - **πŸ”— Layer System** β€” Organize geometry by layer with custom names, colors, and line weights. Compile single layers or full projects. - **πŸ“„ DXF Export** β€” Compile `.cf` β†’ DXF (AutoCAD-compatible). Full layer support, LWPOLYLINE for polylines, HATCH for solid fills, MTEXT for annotations. - **πŸ–ΌοΈ Previews for Agents** β€” Raster PNG + metadata JSON (entity bounding boxes) and full-fidelity SVG with real text, auto-measured dimensions, line styles, and clipped hatch patterns. -- **βœ… Validation Engine** β€” `cadforge check` validates geometry and constraints without generating output; `--json` for tooling. +- **βœ… Validation Engine** β€” `cadspec check` validates geometry and constraints without generating output; `--json` for tooling. ### πŸ—οΈ Project Management -- **Project Scaffolding** β€” `cadforge new` creates a multi-layer starter project (shapes, curves, annotations) that showcases the core `.cf` primitives. +- **Project Scaffolding** β€” `cadspec new` creates a multi-layer starter project (shapes, curves, annotations) that showcases the core `.cf` primitives. - **Multi-Layer Compilation** β€” Compile all layers or target specific layers with `--layer`. Custom output path with `--output`. -- **Auto-Rebuild** β€” `cadforge watch` monitors `.cf` and `.toml` files and auto-rebuilds DXF on changes with 300ms debounce. -- **Code Formatting** β€” `cadforge fmt` normalizes `.cf` files. `--check` mode for CI validation. +- **Auto-Rebuild** β€” `cadspec watch` monitors `.cf` and `.toml` files and auto-rebuilds DXF on changes with 300ms debounce. +- **Code Formatting** β€” `cadspec fmt` normalizes `.cf` files. `--check` mode for CI validation. - **Constraints** β€” `parent`, `belongs_to`, and `spatial_dependency` rules between layers; warnings by default, build-blocking with `strict = true`. -- **DXF Import** β€” `cadforge import plano.dxf` migrates existing drawings into `.cf` layers + `project.toml`. +- **DXF Import** β€” `cadspec import plano.dxf` migrates existing drawings into `.cf` layers + `project.toml`. ### πŸ”§ Architecture @@ -114,35 +114,35 @@ complete CLI reference. | Command | Description | |---------|-------------| -| `cadforge new ` | Create a new project with multi-layer scaffold | -| `cadforge init` | Initialize CADforge in current directory | -| `cadforge build` | Compile project to DXF | -| `cadforge build --check` | Validate project and constraints without generating DXF | -| `cadforge build --output ` | Compile to custom output path | -| `cadforge build --layer ` | Compile specific layer only | -| `cadforge serve` | Live preview server β€” browser auto-reloads on save | -| `cadforge serve --open --port 4377` | Open browser automatically on a custom port | -| `cadforge check` | Validate with project metadata and layer colors | -| `cadforge check --json` | Machine-readable validation report | -| `cadforge layers` | List layers with entity counts and colors | -| `cadforge layers --json` | Machine-readable layer listing | -| `cadforge schema` | Print the full `.cf` language reference (markdown) | -| `cadforge preview` | Faithful PNG render + metadata JSON | -| `cadforge preview --format svg` | Vector SVG preview (same renderer) | -| `cadforge preview --highlight ` | Amber markers around specific entities | -| `cadforge preview --width 1024 -H 768` | Custom resolution preview | -| `cadforge preview --layer ` | Preview specific layer only | -| `cadforge fmt` | Format .cf files (normalize whitespace) | -| `cadforge fmt --check` | Check formatting without modifying (CI) | -| `cadforge watch` | Auto-rebuild on file changes | -| `cadforge import ` | Import DXF into `.cf` layers + `project.toml` | -| `cadforge import --layer ` | Import only one DXF layer | -| `cadforge view` | Open the project in the configured viewer | -| `cadforge view --layer ` | Open only one layer in the viewer | -| `cadforge config set ` | Set global defaults (`author`, `units`) | -| `cadforge config show` | Show global defaults | - -### Live preview controls (`cadforge serve`) +| `cadspec new ` | Create a new project with multi-layer scaffold | +| `cadspec init` | Initialize CADspec in current directory | +| `cadspec build` | Compile project to DXF | +| `cadspec build --check` | Validate project and constraints without generating DXF | +| `cadspec build --output ` | Compile to custom output path | +| `cadspec build --layer ` | Compile specific layer only | +| `cadspec serve` | Live preview server β€” browser auto-reloads on save | +| `cadspec serve --open --port 4377` | Open browser automatically on a custom port | +| `cadspec check` | Validate with project metadata and layer colors | +| `cadspec check --json` | Machine-readable validation report | +| `cadspec layers` | List layers with entity counts and colors | +| `cadspec layers --json` | Machine-readable layer listing | +| `cadspec schema` | Print the full `.cf` language reference (markdown) | +| `cadspec preview` | Faithful PNG render + metadata JSON | +| `cadspec preview --format svg` | Vector SVG preview (same renderer) | +| `cadspec preview --highlight ` | Amber markers around specific entities | +| `cadspec preview --width 1024 -H 768` | Custom resolution preview | +| `cadspec preview --layer ` | Preview specific layer only | +| `cadspec fmt` | Format .cf files (normalize whitespace) | +| `cadspec fmt --check` | Check formatting without modifying (CI) | +| `cadspec watch` | Auto-rebuild on file changes | +| `cadspec import ` | Import DXF into `.cf` layers + `project.toml` | +| `cadspec import --layer ` | Import only one DXF layer | +| `cadspec view` | Open the project in the configured viewer | +| `cadspec view --layer ` | Open only one layer in the viewer | +| `cadspec config set ` | Set global defaults (`author`, `units`) | +| `cadspec config show` | Show global defaults | + +### Live preview controls (`cadspec serve`) - Scroll β†’ zoom (centered on cursor) Β· drag β†’ pan Β· double-click / `F` β†’ fit - **Click an entity** β†’ inspector with its source TOML block; `copy for agent` produces a ready-made targeted-edit prompt @@ -206,7 +206,7 @@ offset = 0.5 `line`, `polyline`, `rect`, `circle`, `arc`, `text`, `point`, `dim`, `hatch`, `fill`, `group` -Run `cadforge schema` for the complete reference with all attributes. +Run `cadspec schema` for the complete reference with all attributes. --- @@ -261,7 +261,7 @@ Run `cadforge schema` for the complete reference with all attributes. | Build output | `./output.dxf` | DXF | | Preview output | `./preview.png`, `./preview.svg` | PNG / SVG | | Preview metadata | `./preview.meta.json` | JSON | -| Language reference | `cadforge schema` (stdout) | Markdown | +| Language reference | `cadspec schema` (stdout) | Markdown | --- @@ -269,40 +269,40 @@ Run `cadforge schema` for the complete reference with all attributes. **Create a new project:** ```bash -cadforge new mi-proyecto +cadspec new mi-proyecto cd mi-proyecto ``` **Live preview while you edit:** ```bash -cadforge serve --open # browser refreshes on every save +cadspec serve --open # browser refreshes on every save ``` -**Edit `.cf` files** (TOML format with your geometry β€” run `cadforge schema` for the reference) +**Edit `.cf` files** (TOML format with your geometry β€” run `cadspec schema` for the reference) **Format and validate:** ```bash -cadforge fmt # normalize .cf files -cadforge check # validate without generating DXF +cadspec fmt # normalize .cf files +cadspec check # validate without generating DXF ``` **Compile to DXF:** ```bash -cadforge build # default output.dxf -cadforge build --output plano.dxf # custom output path -cadforge build --layer muros # compile single layer +cadspec build # default output.dxf +cadspec build --output plano.dxf # custom output path +cadspec build --layer muros # compile single layer ``` **Preview:** ```bash -cadforge preview # default 1600x1200 (fits content aspect) -cadforge preview --width 1024 --height 768 # custom resolution -cadforge preview --layer muros # single layer preview +cadspec preview # default 1600x1200 (fits content aspect) +cadspec preview --width 1024 --height 768 # custom resolution +cadspec preview --layer muros # single layer preview ``` **Auto-rebuild on changes:** ```bash -cadforge watch # monitors .cf and .toml files +cadspec watch # monitors .cf and .toml files ``` --- diff --git a/cadforge-spec.md b/cadspec-spec.md similarity index 77% rename from cadforge-spec.md rename to cadspec-spec.md index 0abb8d8..75ed8d6 100644 --- a/cadforge-spec.md +++ b/cadspec-spec.md @@ -1,4 +1,4 @@ -# CADforge β€” EspecificaciΓ³n y Roadmap v1.0 +# CADspec β€” EspecificaciΓ³n y Roadmap v1.0 > Arquitectura como CΓ³digo β€” motor determinista de geometrΓ­a descriptiva para diseΓ±o arquitectΓ³nico reproducible, versionable y potenciado por agentes de IA. @@ -6,13 +6,13 @@ ## 1. VisiΓ³n -El diseΓ±o arquitectΓ³nico actual sufre de **entropΓ­a grΓ‘fica**: los planos son colecciones de lΓ­neas sin semΓ‘ntica, imposibles de versionar, comparar o automatizar. CADforge propone un cambio de paradigma: +El diseΓ±o arquitectΓ³nico actual sufre de **entropΓ­a grΓ‘fica**: los planos son colecciones de lΓ­neas sin semΓ‘ntica, imposibles de versionar, comparar o automatizar. CADspec propone un cambio de paradigma: **El plano no se dibuja, se declara.** Al igual que el cΓ³digo fuente de software, un espacio arquitectΓ³nico es el resultado de un lenguaje estructurado. Si el cΓ³digo no cambia, el plano es idΓ©ntico bit a bit cada vez que se compila. Esto elimina la ambigΓΌedad del clic humano, habilita `git diff` sobre planos, y permite que los agentes de IA generen y modifiquen diseΓ±os con precisiΓ³n quirΓΊrgica. -CADforge no es un programa de dibujo. Es la infraestructura para que la arquitectura sea una **ciencia de datos reproducible**. +CADspec no es un programa de dibujo. Es la infraestructura para que la arquitectura sea una **ciencia de datos reproducible**. --- @@ -21,26 +21,26 @@ CADforge no es un programa de dibujo. Es la infraestructura para que la arquitec La arquitectura se divide en tres proyectos independientes que se integran entre sΓ­: ``` -cadforge β†’ motor de geometrΓ­a (librerΓ­a Rust, crates.io) -cadforge-cli β†’ interfaz de lΓ­nea de comandos (binario Rust, crates.io) -cadforge-view β†’ visor grΓ‘fico vectorial con modo calco (binario Rust) +cadspec β†’ motor de geometrΓ­a (librerΓ­a Rust, crates.io) +cadspec-cli β†’ interfaz de lΓ­nea de comandos (binario Rust, crates.io) +cadspec-view β†’ visor grΓ‘fico vectorial con modo calco (binario Rust) ``` ### SeparaciΓ³n de responsabilidades | Proyecto | Tipo | Responsabilidad | |---|---|---| -| `cadforge` | LibrerΓ­a | Parser `.cf`, compilador β†’ DXF, motor de geometrΓ­a, sistema de capas y constraints | -| `cadforge-cli` | Binario | Comandos, wizard, build, watch, import/export, integraciΓ³n con agentes | -| `cadforge-view` | Binario | Visor vectorial estilo consola, modo calco, ediciΓ³n bidireccional β†’ `.cf` | +| `cadspec` | LibrerΓ­a | Parser `.cf`, compilador β†’ DXF, motor de geometrΓ­a, sistema de capas y constraints | +| `cadspec-cli` | Binario | Comandos, wizard, build, watch, import/export, integraciΓ³n con agentes | +| `cadspec-view` | Binario | Visor vectorial estilo consola, modo calco, ediciΓ³n bidireccional β†’ `.cf` | -La librerΓ­a `cadforge` es reutilizable por cualquier proyecto Rust β€” el CLI y el visor son consumidores de ella. +La librerΓ­a `cadspec` es reutilizable por cualquier proyecto Rust β€” el CLI y el visor son consumidores de ella. --- ## 3. Formato de Proyecto -Un proyecto CADforge es un directorio con la siguiente estructura: +Un proyecto CADspec es un directorio con la siguiente estructura: ``` mi-proyecto/ @@ -50,7 +50,7 @@ mi-proyecto/ └── capa-c.cf ← capa de primitivos (ej: acabados y anotaciones) ``` -El nombre de cada `.cf` lo define el usuario β€” CADforge no impone nomenclatura ni semΓ‘ntica de capas. Una capa es simplemente un conjunto de primitivos geomΓ©tricos agrupados. +El nombre de cada `.cf` lo define el usuario β€” CADspec no impone nomenclatura ni semΓ‘ntica de capas. Una capa es simplemente un conjunto de primitivos geomΓ©tricos agrupados. ### project.toml @@ -84,7 +84,7 @@ capa-c.belongs_to = "capa-b" El formato `.cf` es TOML vΓ‘lido. Se eligiΓ³ TOML sobre JSON por ser mΓ‘s legible para humanos y agentes β€” menos contexto, mΓ‘s seΓ±al. Los agentes de IA que ya conocen TOML pueden generar y modificar archivos `.cf` sin entrenamiento adicional. -**Principio clave:** el lenguaje `.cf` trabaja exclusivamente con **primitivos geomΓ©tricos**. No existe concepto de "muro", "puerta" o "habitaciΓ³n" en el motor base. Esa semΓ‘ntica es responsabilidad del usuario o de capas de abstracciΓ³n futuras (`cadforge-arch` en v2+). El motor solo sabe de formas, posiciones, atributos visuales y relaciones espaciales. +**Principio clave:** el lenguaje `.cf` trabaja exclusivamente con **primitivos geomΓ©tricos**. No existe concepto de "muro", "puerta" o "habitaciΓ³n" en el motor base. Esa semΓ‘ntica es responsabilidad del usuario o de capas de abstracciΓ³n futuras (`cadspec-arch` en v2+). El motor solo sabe de formas, posiciones, atributos visuales y relaciones espaciales. ### Primitivos soportados en v1 @@ -206,7 +206,7 @@ Cada archivo `.cf` es una capa independiente. Las capas se orquestan desde `proj ### Comportamiento al compilar -Cuando `cadforge build` detecta una violaciΓ³n de constraints: +Cuando `cadspec build` detecta una violaciΓ³n de constraints: ``` ⚠ CONSTRAINT VIOLATION @@ -221,42 +221,42 @@ Las constraints no bloquean el build por defecto β€” emiten warnings. Se puede c --- -## 6. cadforge-cli β€” Comandos +## 6. cadspec-cli β€” Comandos ```bash # InicializaciΓ³n -cadforge new mi-proyecto # crea estructura de proyecto -cadforge init # inicializa en directorio existente +cadspec new mi-proyecto # crea estructura de proyecto +cadspec init # inicializa en directorio existente # CompilaciΓ³n -cadforge build # compila todas las capas β†’ DXF -cadforge build --layer muros # compila una capa especΓ­fica -cadforge build --check # valida constraints sin generar output +cadspec build # compila todas las capas β†’ DXF +cadspec build --layer muros # compila una capa especΓ­fica +cadspec build --check # valida constraints sin generar output # Desarrollo -cadforge watch # modo watch: recompila al guardar cualquier .cf +cadspec watch # modo watch: recompila al guardar cualquier .cf # ImportaciΓ³n / MigraciΓ³n -cadforge import archivo.dxf # convierte DXF existente β†’ .cf (por capas detectadas) -cadforge import archivo.dxf --layer muros # importa a capa especΓ­fica +cadspec import archivo.dxf # convierte DXF existente β†’ .cf (por capas detectadas) +cadspec import archivo.dxf --layer muros # importa a capa especΓ­fica # VisualizaciΓ³n -cadforge view # abre cadforge-view con el proyecto actual -cadforge view --layer muros # abre solo una capa +cadspec view # abre cadspec-view con el proyecto actual +cadspec view --layer muros # abre solo una capa # InformaciΓ³n -cadforge layers # lista capas del proyecto con estado -cadforge check # valida constraints y reporta conflictos +cadspec layers # lista capas del proyecto con estado +cadspec check # valida constraints y reporta conflictos # ConfiguraciΓ³n global -cadforge config set author "Arq. Nombre Apellido" -cadforge config set units m -cadforge config show +cadspec config set author "Arq. Nombre Apellido" +cadspec config set units m +cadspec config show ``` --- -## 7. cadforge-view β€” Visor Vectorial +## 7. cadspec-view β€” Visor Vectorial ### FilosofΓ­a de diseΓ±o @@ -303,7 +303,7 @@ Q β†’ cerrar visor Una de las propuestas de valor mΓ‘s importantes: **migrar lo que ya existe**. ```bash -cadforge import plano-existente.dxf +cadspec import plano-existente.dxf ``` El importador: @@ -314,7 +314,7 @@ El importador: 5. Genera un `project.toml` con las capas detectadas ``` -cadforge import plano.dxf +cadspec import plano.dxf βœ“ Detected 4 layers: MUROS, ESTRUCTURA, COTAS, TEXTO βœ“ muros.cf β€” 23 walls inferred, 4 openings @@ -345,7 +345,7 @@ Los agentes pueden leer y escribir archivos `.cf` directamente β€” TOML es un fo Y modificar coordenadas y orientaciones en `muros.cf` de forma precisa y auditable. ### ghscaff -Plantilla `cadforge` en `ghscaff` para inicializar nuevos proyectos CADforge con estructura de repo correcta desde el primer commit. +Plantilla `cadspec` en `ghscaff` para inicializar nuevos proyectos CADspec con estructura de repo correcta desde el primer commit. --- @@ -373,22 +373,22 @@ Plantilla `cadforge` en `ghscaff` para inicializar nuevos proyectos CADforge con - [ ] Compilador `.cf` β†’ DXF (2D, planos de planta) - [ ] Sistema de capas con `project.toml` - [ ] Constraints bΓ‘sicas: `parent`, `belongs_to` -- [ ] `cadforge build` y `cadforge watch` +- [ ] `cadspec build` y `cadspec watch` - [ ] Live preview vΓ­a visor externo (LibreCAD, FreeCAD) -- [ ] PublicaciΓ³n en crates.io: `cadforge` (librerΓ­a) + `cadforge-cli` +- [ ] PublicaciΓ³n en crates.io: `cadspec` (librerΓ­a) + `cadspec-cli` ### v1 - [ ] Importador DXF β†’ `.cf` con detecciΓ³n automΓ‘tica de primitivos -- [ ] `cadforge-view` β€” visor vectorial propio (fondo negro, lΓ­neas vectoriales) +- [ ] `cadspec-view` β€” visor vectorial propio (fondo negro, lΓ­neas vectoriales) - Modo lectura con zoom/pan y toggle de capas - Modo ediciΓ³n bΓ‘sico con escritura bidireccional al guardar - Modo calco con ventana semi-transparente - [ ] Constraints con warnings en build: `spatial_dependency` - [ ] Achurados estΓ‘ndar: ansi31, ansi32, ansi33, ansi34, solid -- [ ] PublicaciΓ³n `cadforge-view` en crates.io +- [ ] PublicaciΓ³n `cadspec-view` en crates.io ### v2 -- [ ] `cadforge-arch` β€” capa de abstracciΓ³n arquitectΓ³nica sobre primitivos +- [ ] `cadspec-arch` β€” capa de abstracciΓ³n arquitectΓ³nica sobre primitivos - Objetos semΓ‘nticos: `wall`, `opening`, `room`, `column`, `slab` - Construidos sobre primitivos del motor base - Publicado como crate independiente @@ -413,6 +413,6 @@ Plantilla `cadforge` en `ghscaff` para inicializar nuevos proyectos CADforge con ## 13. Contexto AcadΓ©mico -`cadforge` es el proyecto de tesis de especializaciΓ³n en IA con enfoque en diseΓ±o arquitectΓ³nico. La hipΓ³tesis central es que tratar la arquitectura como cΓ³digo β€” con determinismo, versionado y agentes β€” representa un cambio de paradigma en el flujo de trabajo del diseΓ±o arquitectΓ³nico. +`cadspec` es el proyecto de tesis de especializaciΓ³n en IA con enfoque en diseΓ±o arquitectΓ³nico. La hipΓ³tesis central es que tratar la arquitectura como cΓ³digo β€” con determinismo, versionado y agentes β€” representa un cambio de paradigma en el flujo de trabajo del diseΓ±o arquitectΓ³nico. El proyecto vive bajo [`univerlab`](https://github.com/univerlab) junto a `texforge`, `gitkit`, `ghscaff` y `agent-canopy`, siguiendo los mismos principios: binario standalone, offline first, sin scope creep. diff --git a/demo/README.md b/demo/README.md index db00daa..11b1ec7 100644 --- a/demo/README.md +++ b/demo/README.md @@ -1,6 +1,6 @@ -# cadforge demo +# cadspec demo -The cadforge demo as code, recorded with [DemoStage](https://github.com/UniverLab/demo-stage). +The cadspec demo as code, recorded with [DemoStage](https://github.com/UniverLab/demo-stage). It shows the terminal scaffolding a CAD project and starting the live preview, with the rendered drawing in a browser pane. @@ -8,14 +8,14 @@ the rendered drawing in a browser pane. ```sh demo check demo/demo.toml -demo export demo/demo.toml --target gif # β†’ demo/dist/cadforge.gif +demo export demo/demo.toml --target gif # β†’ demo/dist/cadspec.gif # or --target mp4 ``` - `gif`/`mp4` of a terminal + browser pane need a Chromium (auto-downloaded by DemoStage on first use, or use a system install) and, for mp4, ffmpeg. - The browser pane is captured after the terminal run, so this score leaves the - `cadforge serve` server running; stop it afterwards with - `cd /tmp/cadforge-demo/bracket && cadforge serve --stop`. + `cadspec serve` server running; stop it afterwards with + `cd /tmp/cadspec-demo/bracket && cadspec serve --stop`. Edit `demo/demo.toml` to change the commands, captions, layout or timing. diff --git a/demo/demo.toml b/demo/demo.toml index d4d2031..1c33d2a 100644 --- a/demo/demo.toml +++ b/demo/demo.toml @@ -1,16 +1,16 @@ -# cadforge demo β€” terminal (CAD as code) + the live SVG preview in a browser pane. +# cadspec demo β€” terminal (CAD as code) + the live SVG preview in a browser pane. # # Generate the GIF with DemoStage (https://github.com/UniverLab/demo-stage): # demo check demo/demo.toml -# demo export demo/demo.toml --target gif # β†’ demo/dist/cadforge.gif +# demo export demo/demo.toml --target gif # β†’ demo/dist/cadspec.gif # # NOTE: browser panes are captured *after* the terminal run, so the -# `cadforge serve` background server must still be up at capture time β€” this demo -# deliberately does NOT stop it. Afterwards: `cd /tmp/cadforge-demo/bracket && -# cadforge serve --stop`. Adjust commands/url to your build if they drift. +# `cadspec serve` background server must still be up at capture time β€” this demo +# deliberately does NOT stop it. Afterwards: `cd /tmp/cadspec-demo/bracket && +# cadspec serve --stop`. Adjust commands/url to your build if they drift. [demo] -name = "cadforge" +name = "cadspec" output_dir = "demo/dist" [typing] @@ -20,7 +20,7 @@ seed = 7 [env] isolated = true -setup_script = "rm -rf /tmp/cadforge-demo && mkdir -p /tmp/cadforge-demo && cd /tmp/cadforge-demo" +setup_script = "rm -rf /tmp/cadspec-demo && mkdir -p /tmp/cadspec-demo && cd /tmp/cadspec-demo" [layout] width = 1280 @@ -56,7 +56,7 @@ text = "1 β€” scaffold a CAD project" [[timeline]] action = "type" -text = "cadforge new bracket && cd bracket" +text = "cadspec new bracket && cd bracket" human_salt = true [[timeline]] @@ -73,7 +73,7 @@ text = "2 β€” start the live preview" [[timeline]] action = "type" -text = "cadforge serve" +text = "cadspec serve" human_salt = true [[timeline]] diff --git a/docs/agents.md b/docs/agents.md index f267b01..73cc637 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -6,33 +6,33 @@ order: 7 # Working with Agents -Cadforge is designed for **humans and AI agents working together** on the +Cadspec is designed for **humans and AI agents working together** on the same project. Everything an agent needs is exposed through the CLI. -## Self-discovery β€” `cadforge schema` +## Self-discovery β€” `cadspec schema` ```bash -cadforge schema +cadspec schema ``` Prints the complete `.cf` language reference as markdown. An agent with shell access can learn the entire format in one command β€” no prior -training on cadforge required. +training on cadspec required. ## Machine-readable state ```bash -cadforge check --json # validation report -cadforge layers --json # layers, entity counts, colors +cadspec check --json # validation report +cadspec layers --json # layers, entity counts, colors ``` -## Visual grounding β€” `cadforge preview` +## Visual grounding β€” `cadspec preview` ```bash -cadforge preview # PNG + preview.meta.json -cadforge preview --format svg # same render as vector SVG -cadforge preview --width 1024 -H 768 # custom resolution -cadforge preview --layer muros # single layer +cadspec preview # PNG + preview.meta.json +cadspec preview --format svg # same render as vector SVG +cadspec preview --width 1024 -H 768 # custom resolution +cadspec preview --layer muros # single layer ``` The PNG is a faithful render β€” real text, measured dimension labels, @@ -46,7 +46,7 @@ used), including fontless containers. ## Confirming edits β€” `--highlight` ```bash -cadforge preview --highlight ln-001,tx-002 +cadspec preview --highlight ln-001,tx-002 ``` Draws labeled amber markers around the listed entities, so an agent can @@ -61,10 +61,10 @@ source TOML and file β€” paste it into your agent and ask for the change. ## A typical agent loop ```bash -cadforge schema # 1. learn the language -cadforge layers --json # 2. inspect the project +cadspec schema # 1. learn the language +cadspec layers --json # 2. inspect the project # ... edit .cf files ... -cadforge check --json # 3. validate -cadforge preview --highlight # 4. visually confirm -cadforge build # 5. emit the DXF +cadspec check --json # 3. validate +cadspec preview --highlight # 4. visually confirm +cadspec build # 5. emit the DXF ``` diff --git a/docs/building-and-export.md b/docs/building-and-export.md index 3f0f693..1f2b42d 100644 --- a/docs/building-and-export.md +++ b/docs/building-and-export.md @@ -9,10 +9,10 @@ order: 6 ## Compile to DXF ```bash -cadforge build # default output.dxf -cadforge build --output plano.dxf # custom output path -cadforge build --layer muros # compile a single layer -cadforge build --check # validate only, no DXF +cadspec build # default output.dxf +cadspec build --output plano.dxf # custom output path +cadspec build --layer muros # compile a single layer +cadspec build --check # validate only, no DXF ``` The output is deterministic: identical input produces a bit-identical @@ -23,16 +23,16 @@ layer/color/lineweight mapping. ## Validation ```bash -cadforge check # geometry + constraints, human-readable -cadforge check --json # machine-readable report -cadforge layers # list layers with entity counts and colors -cadforge layers --json # machine-readable layer listing +cadspec check # geometry + constraints, human-readable +cadspec check --json # machine-readable report +cadspec layers # list layers with entity counts and colors +cadspec layers --json # machine-readable layer listing ``` ## Watch mode ```bash -cadforge watch +cadspec watch ``` Monitors `.cf` and `.toml` files and rebuilds the DXF on changes with a @@ -41,15 +41,15 @@ Monitors `.cf` and `.toml` files and rebuilds the DXF on changes with a ## Formatting ```bash -cadforge fmt # normalize .cf files in place -cadforge fmt --check # CI-friendly check mode +cadspec fmt # normalize .cf files in place +cadspec fmt --check # CI-friendly check mode ``` ## Importing existing drawings ```bash -cadforge import plano.dxf # all layers -cadforge import plano.dxf --layer muros # a single DXF layer +cadspec import plano.dxf # all layers +cadspec import plano.dxf --layer muros # a single DXF layer ``` Import migrates an existing DXF into `.cf` layer files plus a diff --git a/docs/cf-format.md b/docs/cf-format.md index 65d0da8..27c7502 100644 --- a/docs/cf-format.md +++ b/docs/cf-format.md @@ -6,11 +6,11 @@ order: 4 # The .cf Format -A cadforge project is a `project.toml` plus one `.cf` file per layer. +A cadspec project is a `project.toml` plus one `.cf` file per layer. `.cf` files are TOML: a `[layer]` header followed by arrays of entity tables. -> The authoritative reference is always `cadforge schema` β€” it prints the +> The authoritative reference is always `cadspec schema` β€” it prints the > complete language specification (markdown) for the exact version you > have installed. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6b2f86f..325010c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,61 +1,61 @@ --- title: CLI Reference -description: Every cadforge command and flag. +description: Every cadspec command and flag. order: 8 --- # CLI Reference ``` -cadforge [options] +cadspec [options] ``` ## Project lifecycle | Command | Description | |---------|-------------| -| `cadforge new ` | Create a new project with multi-layer scaffold | -| `cadforge init` | Initialize cadforge in the 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 a specific layer only | -| `cadforge watch` | Auto-rebuild on file changes (300 ms debounce) | +| `cadspec new ` | Create a new project with multi-layer scaffold | +| `cadspec init` | Initialize cadspec in the current directory | +| `cadspec build` | Compile project to DXF | +| `cadspec build --check` | Validate project and constraints without generating DXF | +| `cadspec build --output ` | Compile to custom output path | +| `cadspec build --layer ` | Compile a specific layer only | +| `cadspec watch` | Auto-rebuild on file changes (300 ms debounce) | ## Preview | Command | Description | |---------|-------------| -| `cadforge serve` | Live preview server β€” browser auto-reloads on save | -| `cadforge serve --open --port

` | Open browser automatically on a custom port | -| `cadforge preview` | Faithful PNG render + `preview.meta.json` | -| `cadforge preview --format svg` | Vector SVG preview (same renderer) | -| `cadforge preview --highlight ` | Amber markers around specific entities | -| `cadforge preview --width -H ` | Custom resolution | -| `cadforge preview --layer ` | Preview a specific layer only | -| `cadforge view` | Open the project in the configured viewer | -| `cadforge view --layer ` | Open only one layer in the viewer | +| `cadspec serve` | Live preview server β€” browser auto-reloads on save | +| `cadspec serve --open --port

` | Open browser automatically on a custom port | +| `cadspec preview` | Faithful PNG render + `preview.meta.json` | +| `cadspec preview --format svg` | Vector SVG preview (same renderer) | +| `cadspec preview --highlight ` | Amber markers around specific entities | +| `cadspec preview --width -H ` | Custom resolution | +| `cadspec preview --layer ` | Preview a specific layer only | +| `cadspec view` | Open the project in the configured viewer | +| `cadspec view --layer ` | Open only one layer in the viewer | ## Inspection & quality | Command | Description | |---------|-------------| -| `cadforge check` | Validate with project metadata and layer colors | -| `cadforge check --json` | Machine-readable validation report | -| `cadforge layers` | List layers with entity counts and colors | -| `cadforge layers --json` | Machine-readable layer listing | -| `cadforge schema` | Print the full `.cf` language reference (markdown) | -| `cadforge fmt` | Format `.cf` files (normalize whitespace) | -| `cadforge fmt --check` | Check formatting without modifying (CI) | +| `cadspec check` | Validate with project metadata and layer colors | +| `cadspec check --json` | Machine-readable validation report | +| `cadspec layers` | List layers with entity counts and colors | +| `cadspec layers --json` | Machine-readable layer listing | +| `cadspec schema` | Print the full `.cf` language reference (markdown) | +| `cadspec fmt` | Format `.cf` files (normalize whitespace) | +| `cadspec fmt --check` | Check formatting without modifying (CI) | ## Import & config | Command | Description | |---------|-------------| -| `cadforge import ` | Import DXF into `.cf` layers + `project.toml` | -| `cadforge import --layer ` | Import only one DXF layer | -| `cadforge config set ` | Set global defaults (`author`, `units`) | -| `cadforge config show` | Show global defaults | +| `cadspec import ` | Import DXF into `.cf` layers + `project.toml` | +| `cadspec import --layer ` | Import only one DXF layer | +| `cadspec config set ` | Set global defaults (`author`, `units`) | +| `cadspec config show` | Show global defaults | ## Output files diff --git a/docs/index.md b/docs/index.md index e14208d..549dc89 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,12 @@ --- -title: Cadforge +title: Cadspec description: CAD as code β€” declarative CAD in TOML, live browser preview, deterministic DXF output. order: 1 --- -# Cadforge +# Cadspec -Cadforge is a **CAD as code** CLI tool and Rust library for +Cadspec is a **CAD as code** CLI tool and Rust library for declarative CAD modeling. Write geometry as code in `.cf` TOML files, watch it live in the browser, and compile to AutoCAD-compatible DXF β€” built for humans and AI agents working together. @@ -14,7 +14,7 @@ built for humans and AI agents working together. ## The plan is not drawn β€” it is declared Traditional CAD drawings are collections of lines without semantics: -impossible to version, diff or automate. Cadforge treats an architectural +impossible to version, diff or automate. Cadspec treats an architectural plan like source code: - Same input β†’ **bit-identical output**, every time. @@ -24,27 +24,27 @@ plan like source code: ## The core loop ```bash -cadforge new casa && cd casa -cadforge serve --open # live preview in the browser +cadspec new casa && cd casa +cadspec serve --open # live preview in the browser ``` Edit any `.cf` file β€” by hand or by asking an AI agent β€” and the browser updates on every save. Parse errors and constraint violations appear as an -overlay instead of a crash. When the design is right, `cadforge build` +overlay instead of a crash. When the design is right, `cadspec build` emits a deterministic DXF. ## Built for agents Agents get first-class support: -- `cadforge schema` β€” the full `.cf` language reference in one command; +- `cadspec schema` β€” the full `.cf` language reference in one command; agents self-discover the format without prior training. -- `cadforge check --json` / `cadforge layers --json` β€” machine-readable +- `cadspec check --json` / `cadspec layers --json` β€” machine-readable reports. -- `cadforge preview` β€” a faithful PNG render plus `preview.meta.json` +- `cadspec preview` β€” a faithful PNG render plus `preview.meta.json` with per-entity bounding boxes, so multimodal agents can *look* at the plan and locate every entity. -- `cadforge preview --highlight ln-001,tx-002` β€” labeled markers to +- `cadspec preview --highlight ln-001,tx-002` β€” labeled markers to visually confirm an edit landed where intended. ## How the documentation is organized @@ -52,14 +52,14 @@ Agents get first-class support: - [Installation](installation.md) β€” install, update and uninstall. - [Quick Start](quickstart.md) β€” from zero to DXF. - [The .cf Format](cf-format.md) β€” layers, primitives, construction tools. -- [Live Preview](live-preview.md) β€” `cadforge serve` and its controls. +- [Live Preview](live-preview.md) β€” `cadspec serve` and its controls. - [Building & Export](building-and-export.md) β€” build, watch, DXF import/export. - [Working with Agents](agents.md) β€” the agent-facing toolchain. - [CLI Reference](cli-reference.md) β€” every command and flag. ## Part of UniverLab -Cadforge is an experiment of [UniverLab](https://github.com/UniverLab), +Cadspec is an experiment of [UniverLab](https://github.com/UniverLab), an open computational laboratory. It follows the lab's engineering principles: one tool one job, reproducibility first, offline-friendly design. diff --git a/docs/installation.md b/docs/installation.md index 21dfb5a..6581b62 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,12 +1,12 @@ --- title: Installation -description: Install cadforge with the quick installer, cargo, or from source. +description: Install cadspec with the quick installer, cargo, or from source. order: 2 --- # Installation -> Cadforge is currently in **beta** (`0.1.0-beta.x`). Interfaces may still +> Cadspec is currently in **beta** (`0.1.0-beta.x`). Interfaces may still > change before 1.0. ## Quick install @@ -26,24 +26,24 @@ irm https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.ps ## Via cargo ```bash -cargo install cadforge +cargo install cadspec ``` -Available on [crates.io](https://crates.io/crates/cadforge). +Available on [crates.io](https://crates.io/crates/cadspec). ## From source ```bash git clone https://github.com/UniverLab/cadforge.git -cd cadforge +cd cadspec cargo build --release -# Binary at target/release/cadforge +# Binary at target/release/cadspec ``` ## Uninstall ```bash -rm -f ~/.local/bin/cadforge +rm -f ~/.local/bin/cadspec ``` Projects are plain directories of TOML files β€” nothing else to clean up. diff --git a/docs/live-preview.md b/docs/live-preview.md index 9ff502d..56bf566 100644 --- a/docs/live-preview.md +++ b/docs/live-preview.md @@ -1,17 +1,17 @@ --- title: Live Preview -description: The cadforge serve loop β€” pan/zoom, inspector, layer states, 3D view, error overlay. +description: The cadspec serve loop β€” pan/zoom, inspector, layer states, 3D view, error overlay. order: 5 --- # Live Preview ```bash -cadforge serve # start the local preview server -cadforge serve --open --port 4377 # open browser, custom port +cadspec serve # start the local preview server +cadspec serve --open --port 4377 # open browser, custom port ``` -`cadforge serve` runs a zero-config local server with auto-reload on +`cadspec serve` runs a zero-config local server with auto-reload on save (SSE). Edit a `.cf` file and the browser updates instantly. ## Controls diff --git a/docs/quickstart.md b/docs/quickstart.md index 174506a..4b4c466 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -9,18 +9,18 @@ order: 3 ## 1. Create a project ```bash -cadforge new my-house +cadspec new my-house cd my-house ``` -`cadforge new` scaffolds a complete multi-layer project (walls, doors, +`cadspec new` scaffolds a complete multi-layer project (walls, doors, furniture, dimensions) with meaningful architectural examples. To adopt -cadforge in an existing directory use `cadforge init`. +cadspec in an existing directory use `cadspec init`. ## 2. Preview it live ```bash -cadforge serve --open +cadspec serve --open ``` A local server opens the plan in your browser. Edit any `.cf` file and @@ -44,23 +44,23 @@ to = [8.5, 0.0] weight = 0.50 ``` -Run `cadforge schema` for the complete language reference, or read +Run `cadspec schema` for the complete language reference, or read [The .cf Format](cf-format.md). ## 4. Validate and format ```bash -cadforge check # validate geometry and constraints, no output files -cadforge fmt # normalize .cf files +cadspec check # validate geometry and constraints, no output files +cadspec fmt # normalize .cf files ``` ## 5. Compile to DXF ```bash -cadforge build # default output.dxf -cadforge build --output plano.dxf # custom output path -cadforge build --layer muros # single layer +cadspec build # default output.dxf +cadspec build --output plano.dxf # custom output path +cadspec build --layer muros # single layer ``` -The output is deterministic and AutoCAD-compatible. `cadforge watch` +The output is deterministic and AutoCAD-compatible. `cadspec watch` rebuilds automatically while you edit. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 303eea4..cb014f1 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,4 +1,4 @@ -# install.ps1 β€” download and install cadforge on Windows +# install.ps1 β€” download and install cadspec on Windows # Usage: irm https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.ps1 | iex # # Options (set as env vars before running): @@ -8,7 +8,7 @@ $ErrorActionPreference = "Stop" $Repo = "UniverLab/cadforge" -$Binary = "cadforge.exe" +$Binary = "cadspec.exe" $Target = "x86_64-pc-windows-msvc" $InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { "$env:USERPROFILE\.local\bin" } @@ -43,9 +43,9 @@ if ($env:VERSION) { } # --- download --- -$Archive = "cadforge-$Tag-$Target.zip" +$Archive = "cadspec-$Tag-$Target.zip" $Url = "https://github.com/$Repo/releases/download/$Tag/$Archive" -$Tmp = Join-Path $env:TEMP "cadforge-install" +$Tmp = Join-Path $env:TEMP "cadspec-install" New-Item -ItemType Directory -Force -Path $Tmp | Out-Null Info "download" $Url @@ -76,10 +76,10 @@ if ($userPath -notlike "*$InstallDir*") { # --- cleanup --- Remove-Item $Tmp -Recurse -Force -# --- install the cadforge agent skill (optional) --- -# Teaches AI agents how to drive cadforge. Skipped when npx is unavailable or +# --- install the cadspec agent skill (optional) --- +# Teaches AI agents how to drive cadspec. Skipped when npx is unavailable or # $env:SKIP_SKILL is set; a failure here never fails the binary install above. -$Skill = "cadforge" +$Skill = "cadspec" $SkillsRepo = "https://github.com/UniverLab/skills" if ($env:SKIP_SKILL) { Info "skill" "skipped (SKIP_SKILL set)" @@ -103,4 +103,4 @@ if ($env:SKIP_SKILL) { $ver = & "$InstallDir\$Binary" --version 2>$null Info "done" $ver Write-Host "" -Info "ready" "Run 'cadforge --help' to get started!" +Info "ready" "Run 'cadspec --help' to get started!" diff --git a/scripts/install.sh b/scripts/install.sh index e75f7ce..d98cf33 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,10 +1,10 @@ #!/bin/sh -# install.sh β€” download and install cadforge from GitHub Releases +# install.sh β€” download and install cadspec from GitHub Releases # Usage: curl -fsSL https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.sh | sh set -eu REPO="UniverLab/cadforge" -BINARY="cadforge" +BINARY="cadspec" INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" info() { printf ' \033[1;34m%s\033[0m %s\n' "$1" "$2"; } @@ -33,7 +33,7 @@ TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT # ============================================================ -# 1. Install cadforge +# 1. Install cadspec # ============================================================ # --- resolve version --- @@ -88,7 +88,7 @@ if [ -n "$PATHS_TO_ADD" ]; then if [ -f "$profile" ]; then for dir in $PATHS_TO_ADD; do if ! grep -q "export PATH=\"$dir:\$PATH\"" "$profile" 2>/dev/null; then - printf '\n# Added by cadforge installer\nexport PATH="%s:$PATH"\n' "$dir" >> "$profile" + printf '\n# Added by cadspec installer\nexport PATH="%s:$PATH"\n' "$dir" >> "$profile" info "updated" "$profile" fi done @@ -97,12 +97,12 @@ if [ -n "$PATHS_TO_ADD" ]; then fi # ============================================================ -# 3. Install the cadforge agent skill (optional) +# 3. Install the cadspec agent skill (optional) # ============================================================ -# Teaches AI agents how to drive cadforge. Skipped when npx is unavailable or +# Teaches AI agents how to drive cadspec. Skipped when npx is unavailable or # SKIP_SKILL is set; a failure here never fails the binary install above. -SKILL="cadforge" +SKILL="cadspec" SKILLS_REPO="https://github.com/UniverLab/skills" if [ -n "${SKIP_SKILL:-}" ]; then @@ -124,4 +124,4 @@ fi info "done" "$($INSTALL_DIR/$BINARY --version 2>/dev/null || echo "$BINARY installed")" echo "" -info "ready" "Run 'cadforge --help' to get started!" +info "ready" "Run 'cadspec --help' to get started!" diff --git a/src/config.rs b/src/config.rs index dca785c..d1c296a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -//! Global configuration for cadforge CLI defaults. +//! Global configuration for cadspec CLI defaults. use anyhow::{anyhow, Context, Result}; use std::fs; @@ -9,7 +9,7 @@ const SUPPORTED_KEYS: &[&str] = &["author", "units"]; fn config_path() -> Result { let home = std::env::var("HOME").context("HOME environment variable is not set")?; - Ok(PathBuf::from(home).join(".cadforge").join("config.toml")) + Ok(PathBuf::from(home).join(".cadspec").join("config.toml")) } fn load_document(path: &PathBuf) -> Result { diff --git a/src/dxf_writer.rs b/src/dxf_writer.rs index e3f4dd3..d20487f 100644 --- a/src/dxf_writer.rs +++ b/src/dxf_writer.rs @@ -418,7 +418,7 @@ mod tests { w.circle(5.0, 5.0, 2.0, "MUROS", &s); w.rect(1.0, 1.0, 3.0, 4.0, "MUROS", &s); - let path = PathBuf::from("/tmp/cadforge_test_basic.dxf"); + let path = PathBuf::from("/tmp/cadspec_test_basic.dxf"); w.save(&path).unwrap(); assert!(path.exists()); } @@ -434,7 +434,7 @@ mod tests { }; w.line(0.0, 0.0, 1.0, 1.0, "TEST", &style); - let path = PathBuf::from("/tmp/cadforge_test_styled.dxf"); + let path = PathBuf::from("/tmp/cadspec_test_styled.dxf"); w.save(&path).unwrap(); assert!(path.exists()); } diff --git a/src/fmt.rs b/src/fmt.rs index 8f5bb4d..14a333b 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -53,7 +53,7 @@ pub fn format_project(project_dir: &Path, check_only: bool) -> Result<()> { } if check_only && changed > 0 { - anyhow::bail!("{changed} file(s) need formatting. Run `cadforge fmt` to fix."); + anyhow::bail!("{changed} file(s) need formatting. Run `cadspec fmt` to fix."); } if !check_only { println!("βœ“ {changed} file(s) formatted"); diff --git a/src/importer.rs b/src/importer.rs index 306b384..bc7037d 100644 --- a/src/importer.rs +++ b/src/importer.rs @@ -1,4 +1,4 @@ -//! DXF importer β€” converts DXF layers/entities into CADforge `.cf` + `project.toml`. +//! DXF importer β€” converts DXF layers/entities into CADspec `.cf` + `project.toml`. use crate::color::aci_to_hex; use anyhow::{anyhow, Context, Result}; @@ -310,7 +310,7 @@ fn dim_offset(from: [f64; 2], to: [f64; 2], insertion: [f64; 2]) -> Option Some((insertion[0] - mid[0]) * nx + (insertion[1] - mid[1]) * ny) } -/// Drop the extension/dimension lines and label text that `cadforge build` +/// Drop the extension/dimension lines and label text that `cadspec build` /// emits alongside each DIMENSION entity for viewer compatibility; the /// re-created `[[dim]]` regenerates all of them. Foreign DXFs are unaffected /// (their dimension graphics live in blocks, not loose entities). diff --git a/src/lib.rs b/src/lib.rs index 67cd553..b902819 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! cadforge β€” deterministic geometry engine for reproducible architectural design. +//! cadspec β€” deterministic geometry engine for reproducible architectural design. //! //! Pipeline: `.cf` (TOML) β†’ intermediate model β†’ DXF output. diff --git a/src/main.rs b/src/main.rs index bf310d0..0de9eaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,20 @@ use anyhow::{bail, Result}; -use cadforge::compiler::{check_project, compile_project, list_layers, project_report}; -use cadforge::config::{config_set, config_show}; -use cadforge::fmt::format_project; -use cadforge::importer::import_dxf; -use cadforge::preview::{generate_plano, generate_preview, PreviewOutputs, PreviewView}; -use cadforge::scaffold::{create_project, init_project}; -use cadforge::schema::print_schema; -use cadforge::serve::{serve_daemon, serve_project, serve_stop}; -use cadforge::viewer::view_project; -use cadforge::watch::watch_project; +use cadspec::compiler::{check_project, compile_project, list_layers, project_report}; +use cadspec::config::{config_set, config_show}; +use cadspec::fmt::format_project; +use cadspec::importer::import_dxf; +use cadspec::preview::{generate_plano, generate_preview, PreviewOutputs, PreviewView}; +use cadspec::scaffold::{create_project, init_project}; +use cadspec::schema::print_schema; +use cadspec::serve::{serve_daemon, serve_project, serve_stop}; +use cadspec::viewer::view_project; +use cadspec::watch::watch_project; use clap::{Parser, Subcommand, ValueEnum}; use std::path::PathBuf; #[derive(Parser)] #[command( - name = "cadforge", + name = "cadspec", version, about = "CAD as code β€” declarative geometry β†’ DXF" )] @@ -25,12 +25,12 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Create a new CADforge project + /// Create a new CADspec project New { /// Project name (creates a directory with this name) name: String, }, - /// Initialize CADforge in the current directory + /// Initialize CADspec in the current directory Init, /// Compile project (.cf files) β†’ DXF output Build { @@ -128,7 +128,7 @@ enum Commands { #[arg(short, long)] path: Option, }, - /// Import a DXF file into CADforge project files + /// Import a DXF file into CADspec project files Import { /// Input DXF file input: PathBuf, @@ -148,7 +148,7 @@ enum Commands { #[arg(short, long)] layer: Option, }, - /// Global cadforge configuration + /// Global cadspec configuration Config { #[command(subcommand)] command: ConfigCommands, @@ -310,7 +310,7 @@ fn resolve_project_dir(path: Option) -> Result { let dir = path.unwrap_or_else(|| PathBuf::from(".")); if !dir.join("project.toml").exists() { bail!( - "No project.toml found in '{}'. Run `cadforge new` to create a project.", + "No project.toml found in '{}'. Run `cadspec new` to create a project.", dir.display() ); } diff --git a/src/planos.rs b/src/planos.rs index 64589af..de3d801 100644 --- a/src/planos.rs +++ b/src/planos.rs @@ -232,7 +232,7 @@ fn title_block( ); let _ = write!( s, - r#"cadforge"#, + r#"cadspec"#, tx = x + w - pad, ty = y + h - row * 0.35, ); diff --git a/src/preview.rs b/src/preview.rs index 55c670d..57ab2c2 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -2,7 +2,7 @@ //! //! The PNG is a faithful raster of the SVG renderer (real text, measured //! dimensions, hatches, line styles), so what an agent *sees* in the image is -//! exactly what `cadforge serve` shows a human. The metadata JSON maps every +//! exactly what `cadspec serve` shows a human. The metadata JSON maps every //! entity to world and pixel bounding boxes so agents can locate geometry in //! the image. diff --git a/src/render3d.rs b/src/render3d.rs index 1195585..57d5a13 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1,6 +1,6 @@ //! 3D view renderer β€” extrudes `.cf` geometry into solids and projects them. //! -//! cadforge geometry is declared in 2D plan coordinates. Any primitive can +//! cadspec geometry is declared in 2D plan coordinates. Any primitive can //! carry an `extrude` height (and optional `elevation`): a closed shape becomes //! a solid prism, a line or open polyline becomes a vertical wall. This module //! projects that geometry with a fixed axonometric (isometric) camera, sorts diff --git a/src/scaffold.rs b/src/scaffold.rs index 0a7d966..e595ce8 100644 --- a/src/scaffold.rs +++ b/src/scaffold.rs @@ -1,10 +1,10 @@ -//! Scaffold β€” generates a new CADforge project structure. +//! Scaffold β€” generates a new CADspec project structure. use anyhow::{bail, Result}; use std::fs; use std::path::Path; -/// Create a new CADforge project in the given directory. +/// Create a new CADspec project in the given directory. pub fn create_project(name: &str, parent: &Path) -> Result<()> { let project_dir = parent.join(name); if project_dir.exists() { @@ -21,15 +21,15 @@ pub fn create_project(name: &str, parent: &Path) -> Result<()> { println!(" β†’ annotations.cf"); println!(" β†’ .gitignore"); println!( - "\n Run `cadforge serve --path {}` for a live preview,", + "\n Run `cadspec serve --path {}` for a live preview,", name ); - println!(" or `cadforge build --path {}` to compile to DXF.", name); - println!(" `cadforge schema` prints the .cf language reference."); + println!(" or `cadspec build --path {}` to compile to DXF.", name); + println!(" `cadspec schema` prints the .cf language reference."); Ok(()) } -/// Initialize a CADforge project in the current directory. +/// Initialize a CADspec project in the current directory. pub fn init_project(dir: &Path) -> Result<()> { if dir.join("project.toml").exists() { bail!("project.toml already exists in '{}'", dir.display()); @@ -41,14 +41,14 @@ pub fn init_project(dir: &Path) -> Result<()> { .unwrap_or("project"); write_project_files(dir, name)?; - println!("βœ“ Initialized CADforge project in {}", dir.display()); + println!("βœ“ Initialized CADspec project in {}", dir.display()); println!(" β†’ project.toml"); println!(" β†’ shapes.cf"); println!(" β†’ curves.cf"); println!(" β†’ annotations.cf"); println!(" β†’ .gitignore"); - println!("\n Run `cadforge serve` for a live preview."); - println!(" `cadforge schema` prints the .cf language reference."); + println!("\n Run `cadspec serve` for a live preview."); + println!(" `cadspec schema` prints the .cf language reference."); Ok(()) } @@ -67,7 +67,7 @@ annotations = {{ file = "annotations.cf", locked = false }} ); fs::write(project_dir.join("project.toml"), project_toml)?; - let gitignore = "# CADforge output\noutput.dxf\npreview.png\npreview.svg\npreview.meta.json\n\n# CADforge serve daemon (pid + logs)\n.cadforge/\n\n# Rust build artifacts\ntarget/\n"; + let gitignore = "# CADspec output\noutput.dxf\npreview.png\npreview.svg\npreview.meta.json\n\n# CADspec serve daemon (pid + logs)\n.cadspec/\n\n# Rust build artifacts\ntarget/\n"; fs::write(project_dir.join(".gitignore"), gitignore)?; let shapes_cf = r##"[layer] @@ -180,7 +180,7 @@ mod tests { #[test] fn creates_project_structure() { - let tmp = PathBuf::from("/tmp/cadforge_test_new"); + let tmp = PathBuf::from("/tmp/cadspec_test_new"); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(&tmp).unwrap(); @@ -208,7 +208,7 @@ mod tests { #[test] fn fails_if_dir_exists() { - let tmp = PathBuf::from("/tmp/cadforge_test_exists"); + let tmp = PathBuf::from("/tmp/cadspec_test_exists"); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(tmp.join("existing")).unwrap(); @@ -220,7 +220,7 @@ mod tests { #[test] fn init_in_existing_dir() { - let tmp = PathBuf::from("/tmp/cadforge_test_init"); + let tmp = PathBuf::from("/tmp/cadspec_test_init"); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(&tmp).unwrap(); @@ -237,7 +237,7 @@ mod tests { #[test] fn init_fails_if_project_exists() { - let tmp = PathBuf::from("/tmp/cadforge_test_init_exists"); + let tmp = PathBuf::from("/tmp/cadspec_test_init_exists"); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(&tmp).unwrap(); fs::write(tmp.join("project.toml"), "").unwrap(); diff --git a/src/schema.rs b/src/schema.rs index a50100a..90d9590 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,13 +1,13 @@ -//! Schema β€” the `.cf` language reference, printable via `cadforge schema`. +//! Schema β€” the `.cf` language reference, printable via `cadspec schema`. //! //! This is the self-discovery entry point for AI agents and humans alike: one //! command dumps the complete format so any agent can generate valid `.cf` //! files without prior training. /// Complete `.cf` + `project.toml` reference in markdown. -pub const CF_REFERENCE: &str = r##"# CADforge `.cf` Language Reference +pub const CF_REFERENCE: &str = r##"# CADspec `.cf` Language Reference -CADforge projects are plain TOML. A project is a directory with a `project.toml` +CADspec projects are plain TOML. A project is a directory with a `project.toml` plus one `.cf` file per layer. Geometry is declared, never drawn: the same files always compile to the same DXF. @@ -30,7 +30,7 @@ cotas.belongs_to = "muros" # child primitives reference parent ids via belo "muros β†’ puertas" = "spatial_dependency" # movement warning (informational) # Planos (drawing sheets): named views of the model with a title block. -# Render with `cadforge preview --plano `, or pick them in the viewer's +# Render with `cadspec preview --plano `, or pick them in the viewer's # Planos panel (under Layers). [[plano]] name = "P-01" @@ -77,7 +77,7 @@ elevation = 0.0 # 3D view: base height (Z) the shape sits at (default 0) ``` The 2D plan is unaffected by `extrude`/`elevation`; they only shape the -extruded 3D view (`cadforge preview --3d`, or the viewer's `3D` button). +extruded 3D view (`cadspec preview --3d`, or the viewer's `3D` button). ### Primitives @@ -206,24 +206,24 @@ A cube with a hole = a `box` minus a `cylinder` via `op = "difference"`. ## Workflow ```bash -cadforge serve # live preview in the browser (auto-reloads on save) -cadforge build # compile to output.dxf -cadforge check --json # machine-readable validation report -cadforge layers --json # machine-readable layer listing -cadforge preview # PNG + metadata JSON (--format svg for vector) -cadforge preview --highlight ln-001,tx-002 # amber markers around those ids -cadforge fmt # normalize .cf formatting +cadspec serve # live preview in the browser (auto-reloads on save) +cadspec build # compile to output.dxf +cadspec check --json # machine-readable validation report +cadspec layers --json # machine-readable layer listing +cadspec preview # PNG + metadata JSON (--format svg for vector) +cadspec preview --highlight ln-001,tx-002 # amber markers around those ids +cadspec fmt # normalize .cf formatting ``` -The feedback loop for agents: edit `.cf` β†’ run `cadforge check --json` to -validate β†’ run `cadforge preview` and **look at `preview.png`** β€” it is a +The feedback loop for agents: edit `.cf` β†’ run `cadspec check --json` to +validate β†’ run `cadspec preview` and **look at `preview.png`** β€” it is a faithful render (real text, measured dimension labels, hatches, line styles). `preview.meta.json` maps every entity id to world and pixel bounding boxes. After editing specific entities, re-render with -`cadforge preview --highlight ` to visually confirm the change landed +`cadspec preview --highlight ` to visually confirm the change landed where intended (highlighted entities get labeled amber markers). -For humans, `cadforge serve` adds: click any entity to inspect its source +For humans, `cadspec serve` adds: click any entity to inspect its source TOML block (copyable as an agent prompt for targeted edits), a layer panel with on/ghost/off states (trace one floor over another), and a `3D` button that renders the extruded view (see `extrude`/`elevation` above). diff --git a/src/serve.rs b/src/serve.rs index e50cdb2..8ba64ce 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -1,4 +1,4 @@ -//! Live preview server β€” `cadforge serve`. +//! Live preview server β€” `cadspec serve`. //! //! Watches the project files and serves an auto-reloading SVG preview in the //! browser. The vibecoding loop: an agent (or human) edits `.cf` files, the @@ -79,7 +79,7 @@ pub fn serve_project(project_dir: &Path, port: u16, open: bool) -> Result<()> { .with_context(|| format!("Cannot bind 127.0.0.1:{} (port in use?)", port))?; let url = format!("http://127.0.0.1:{}", port); - println!("β—‰ cadforge serve β€” {}", project.project.name); + println!("β—‰ cadspec serve β€” {}", project.project.name); println!(" Preview: {}", url); println!(" Watching: {}", project_dir.display()); println!(); @@ -112,7 +112,7 @@ pub fn serve_project(project_dir: &Path, port: u16, open: bool) -> Result<()> { // we never claim "running" for a server that failed to come up. fn runtime_dir(project_dir: &Path) -> PathBuf { - project_dir.join(".cadforge") + project_dir.join(".cadspec") } fn pid_path(project_dir: &Path) -> PathBuf { @@ -168,9 +168,9 @@ pub fn serve_daemon(project_dir: &Path, port: u16, open: bool) -> Result<()> { let url = format!("http://127.0.0.1:{}", port); if let Some(pid) = running_pid(&project_dir) { - println!("β—‰ cadforge serve already running (pid {pid})"); + println!("β—‰ cadspec serve already running (pid {pid})"); println!(" Preview: {url}"); - println!(" Stop with: cadforge serve --stop"); + println!(" Stop with: cadspec serve --stop"); if open { open_browser(&url); } @@ -187,7 +187,7 @@ pub fn serve_daemon(project_dir: &Path, port: u16, open: bool) -> Result<()> { let log = log_path(&project_dir); let log_file = File::create(&log)?; - let exe = std::env::current_exe().context("cannot locate cadforge executable")?; + let exe = std::env::current_exe().context("cannot locate cadspec executable")?; let mut cmd = Command::new(exe); cmd.arg("serve") .arg("--foreground") @@ -209,10 +209,10 @@ pub fn serve_daemon(project_dir: &Path, port: u16, open: bool) -> Result<()> { fs::write(pid_path(&project_dir), pid.to_string())?; if wait_until_ready(port, Duration::from_secs(5)) { - println!("β—‰ cadforge serve β€” running in background (pid {pid})"); + println!("β—‰ cadspec serve β€” running in background (pid {pid})"); println!(" Preview: {url}"); println!(" Logs: {}", log.display()); - println!(" Stop with: cadforge serve --stop"); + println!(" Stop with: cadspec serve --stop"); if open { open_browser(&url); } @@ -236,7 +236,7 @@ pub fn serve_stop(project_dir: &Path, _port: u16) -> Result<()> { let Some(pid) = running_pid(&project_dir) else { let _ = fs::remove_file(&pid_file); // clean up any stale pidfile - println!("No cadforge serve daemon running for this project."); + println!("No cadspec serve daemon running for this project."); return Ok(()); }; @@ -248,7 +248,7 @@ pub fn serve_stop(project_dir: &Path, _port: u16) -> Result<()> { let _ = fs::remove_file(&pid_file); if stopped { - println!("βœ“ Stopped cadforge serve (pid {pid})."); + println!("βœ“ Stopped cadspec serve (pid {pid})."); Ok(()) } else { bail!("failed to stop process {pid}") @@ -646,7 +646,7 @@ const INDEX_HTML: &str = r##" -{{PROJECT_NAME}} β€” cadforge live +{{PROJECT_NAME}} β€” cadspec live @@ -800,9 +855,20 @@ const INDEX_HTML: &str = r##" v0 + + edit .cf files β€” preview updates automatically

+ +
- +
+ + +
ready
@@ -1229,23 +1247,64 @@ refresh(); apply(mode()); })(); - // ── Built-in .cf editor ──────────────────────────────────────────────────── + // ── Built-in .cf editor: syntax highlight + debounced auto-save ───────────── (function () { var editor = document.getElementById('editor'); var resizer = document.getElementById('editor-resizer'); var sel = document.getElementById('ed-file'); var text = document.getElementById('ed-text'); + var hl = document.querySelector('#ed-hl code'); var saveBtn = document.getElementById('ed-save'); var status = document.getElementById('ed-status'); var toggle = document.getElementById('btneditor'); - var dirty = false; + var current = null; // the file currently loaded in the textarea + var timer = null; + var SAVE_DELAY = 600; function setStatus(msg, cls) { status.textContent = msg; status.className = cls || ''; } + function escHtml(s) { return s.replace(/&/g, '&').replace(//g, '>'); } + + // Lightweight .cf (TOML-ish) tokenizer β†’ coloured spans. + function highlight(code) { + return code.split('\n').map(function (line) { + var out = '', rest = escHtml(line); + var km = rest.match(/^(\s*)([A-Za-z0-9_.\-]+)(\s*=)/); + if (km) { out += km[1] + '' + km[2] + '' + km[3]; rest = rest.slice(km[0].length); } + out += rest.replace(/(#.*$)|("(?:[^"\\]|\\.)*")|(\[\[?[^\]]*\]\]?)|(-?\b\d+\.?\d*\b)|(\btrue\b|\bfalse\b)/g, + function (m, c, s, h, n, b) { + if (c) return '' + c + ''; + if (s) return '' + s + ''; + if (h) return '' + h + ''; + if (n) return '' + n + ''; + if (b) return '' + b + ''; + return m; + }); + return out; + }).join('\n'); + } + function paint() { hl.innerHTML = highlight(text.value) + '\n'; } + function syncScroll() { var p = hl.parentNode; p.scrollTop = text.scrollTop; p.scrollLeft = text.scrollLeft; } + function save() { + var name = current; // captured: stays correct even if the file switches + if (!name) return; + clearTimeout(timer); timer = null; + setStatus('saving…'); + fetch('/save?name=' + encodeURIComponent(name), { method: 'POST', body: text.value }) + .then(function (r) { return r.json(); }) + .then(function (j) { setStatus(j.ok ? 'saved Β· ' + name : 'saved Β· build error (see viewer)', j.ok ? 'ok' : 'err'); }) + .catch(function () { setStatus('save failed', 'err'); }); + } + function scheduleSave() { + clearTimeout(timer); + setStatus('● ' + current, 'dirty'); + timer = setTimeout(save, SAVE_DELAY); + } function loadFile(name) { + clearTimeout(timer); timer = null; fetch('/file?name=' + encodeURIComponent(name)) .then(function (r) { return r.text(); }) - .then(function (t) { text.value = t; dirty = false; setStatus(name); }) + .then(function (t) { current = name; text.value = t; paint(); syncScroll(); setStatus(name); }) .catch(function () { setStatus('cannot load ' + name, 'err'); }); } function loadFiles() { @@ -1256,28 +1315,16 @@ refresh(); o.value = f; o.textContent = f; sel.appendChild(o); }); if (sel.options.length) { loadFile(sel.value); } - else { text.value = ''; setStatus('no .cf files'); } + else { current = null; text.value = ''; paint(); setStatus('no .cf files'); } }).catch(function () { setStatus('cannot list files', 'err'); }); } - function save() { - var name = sel.value; - if (!name) return; - setStatus('saving…'); - fetch('/save?name=' + encodeURIComponent(name), { method: 'POST', body: text.value }) - .then(function (r) { return r.json(); }) - .then(function (j) { - if (j.ok) { dirty = false; setStatus('saved Β· ' + name, 'ok'); } - else { dirty = false; setStatus('saved Β· build error (see viewer)', 'err'); } - }) - .catch(function () { setStatus('save failed', 'err'); }); - } sel.addEventListener('change', function () { - if (!dirty || confirm('Discard unsaved changes?')) loadFile(sel.value); - }); - text.addEventListener('input', function () { - if (!dirty) { dirty = true; setStatus('● ' + sel.value + ' (unsaved)', 'dirty'); } + if (timer) save(); // flush the pending edit to the old file first + loadFile(sel.value); }); + text.addEventListener('input', function () { paint(); scheduleSave(); }); + text.addEventListener('scroll', syncScroll); saveBtn.addEventListener('click', save); // Keep editor keystrokes out of the viewer's shortcuts (3 / f / 1-9 / esc). text.addEventListener('keydown', function (e) { From c78dc7126bb9dc0a3ebb164df82e421e8ff6bb6e Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Sun, 21 Jun 2026 12:29:34 -0500 Subject: [PATCH 12/39] feat(serve): render the 3D view interactively with three.js and glTF (orbit, theme-aware, live-reload) --- src/serve.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/src/serve.rs b/src/serve.rs index 948fd5b..19b3432 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -305,6 +305,13 @@ fn rebuild(project_dir: &Path, state: &Shared) { state.changed.notify_all(); } +/// Build the scene's 3D solids as a glTF document (for the WebGL viewer). +fn scene_gltf(project_dir: &Path) -> Result { + let (_project, layers) = load_project_layers(project_dir, None)?; + let meshes = crate::render3d::scene_meshes(&layers); + Ok(crate::gltf::scene_to_gltf(&meshes)) +} + /// Render a plano by name to SVG (on demand, for the `/plano.svg` endpoint). fn render_named_plano(project_dir: &Path, name: &str) -> Result { let project = parse_project(&project_dir.join("project.toml"))?; @@ -395,6 +402,11 @@ fn handle_connection(stream: TcpStream, state: &Shared) -> std::io::Result<()> { let svg = Arc::clone(&state.state.lock().unwrap().svg3d); respond(stream, "200 OK", "image/svg+xml", svg.as_bytes()) } + "/scene.gltf" => { + let body = + scene_gltf(&state.project_dir).unwrap_or_else(|_| crate::gltf::scene_to_gltf(&[])); + respond(stream, "200 OK", "model/gltf+json", body.as_bytes()) + } "/plano.svg" => { // Rendered on demand (sections run CSG, so we don't precompute all). let name = query_param(query, "name").unwrap_or_default(); @@ -840,6 +852,10 @@ const INDEX_HTML: &str = r##" /* viewport */ #viewport { flex: 1; overflow: hidden; position: relative; cursor: grab; perspective: 2200px; background: var(--bg); } #viewport.panning { cursor: grabbing; } + #viewport.is3d { cursor: default; } + /* interactive WebGL (glTF) layer, shown in 3D mode */ + #gl { position: absolute; inset: 0; width: 100%; height: 100%; display: none; } + #gl.show { display: block; } #canvas { position: absolute; transform-origin: 0 0; will-change: transform; transform-style: preserve-3d; } #canvas svg { display: block; } .plane { position: absolute; left: 0; top: 0; } @@ -895,6 +911,7 @@ const INDEX_HTML: &str = r##"
+