diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 070f1a6..3f91eeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,6 @@ name: CI on: pull_request: workflow_dispatch: - push: - branches: [main] jobs: rust-ci: 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/.gitignore b/.gitignore index de105c7..6c83c0f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,9 +41,7 @@ skills-lock.json debug/ target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock + # These are backup files generated by rustfmt @@ -52,4 +50,11 @@ Cargo.lock # End of https://www.toptal.com/developers/gitignore/api/rust output.dxf preview.png +preview.svg preview.meta.json + +# AI agent instruction files — not part of this repo +CLAUDE.md +AGENTS.md + +.mimocode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index f40a8e7..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,53 +0,0 @@ -# Contributing to cadforge - -Thank you for your interest in contributing! - -## Development workflow - -```bash -git clone https://github.com/UniverLab/cadforge.git -cd cadforge -cargo build -cargo test -``` - -Branch off `develop` for all changes. PRs go to `develop`, not `main`. - -## CI/CD and required secrets - -This project uses GitHub Actions for CI (`.github/workflows/ci.yml`) and automated releases (`.github/workflows/release.yml`). - -### Required repository secrets - -| Secret | Description | Where to get it | -|---|---|---| -| `CARGO_REGISTRY_TOKEN` | API token to publish crates to [crates.io](https://crates.io) | [crates.io/me](https://crates.io/me) → API Tokens → New Token | - -Configure secrets at: -`https://github.com/UniverLab/cadforge/settings/secrets/actions` - -> **Tip:** If you use [ghscaff](https://github.com/UniverLab/ghscaff), you can run `ghscaff apply` to configure missing secrets interactively, or set the env var before running: -> ```bash -> export CARGO_REGISTRY_TOKEN= -> ghscaff apply -> ``` - -## Release process - -Releases are automated via the `release.yml` workflow. To cut a release: - -1. Bump the version in `Cargo.toml` -2. Commit: `chore: release vX.Y.Z` -3. Tag: `git tag vX.Y.Z && git push origin vX.Y.Z` - -The workflow builds binaries for Linux, macOS, and Windows, publishes to crates.io, and creates a GitHub Release. - -## Code style - -```bash -cargo fmt # format -cargo clippy -- -D warnings # lint -cargo test # test -``` - -All three must pass before merging. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..79dc6b8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1514 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cadspec" +version = "0.1.0-beta.3" +dependencies = [ + "anyhow", + "clap", + "dxf", + "indexmap", + "notify", + "resvg", + "serde", + "serde_json", + "toml", + "toml_edit", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits 0.2.19", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "dxf" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb070bbb077a936e2bdf95d4b39aa83e865b38c3a7054f71d704e0796da1821" +dependencies = [ + "byteorder", + "chrono", + "encoding_rs", + "enum_primitive", + "image", + "itertools", + "num", + "uuid", + "xmltree", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum_primitive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4551092f4d519593039259a9ed8daedf0da12e5109c5280338073eaeb81180" +dependencies = [ + "num-traits 0.1.43", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits 0.2.19", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits 0.2.19", + "pxfm", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits 0.2.19", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits 0.2.19", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits 0.2.19", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits 0.2.19", +] + +[[package]] +name = "num-traits" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31" +dependencies = [ + "num-traits 0.2.19", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "resvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be183ad6a216aa96f33e4c8033b0988b8b3ea6fd2359d19af5bac4643fd8e81" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "usvg" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "ttf-parser", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b5d5751be424d8fc0eff399d8e762d4201eefc70fa780895059b9d68d73018" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 3a5e78a..06ce678 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "cadforge" -version = "0.1.0-beta.2" +name = "cadspec" +version = "0.1.0-beta.3" 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" +homepage = "https://github.com/UniverLab/cadspec" +repository = "https://github.com/UniverLab/cadspec" publish = true exclude = ["crates/**"] @@ -18,5 +18,11 @@ serde_json = "1.0" toml = "0.8" toml_edit = "0.22" indexmap = { version = "2", features = ["serde"] } -tiny-skia = "0.11" notify = "6.1" +resvg = "0.47.0" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = "symbols" diff --git a/LICENSE b/LICENSE index 19e48cf..f50fef1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,21 @@ -# MIT License +MIT License -See https://opensource.org/licenses/MIT for the full license text. +Copyright (c) 2026 Jheison Morales + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1bf094b..bfecc63 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,108 @@ -██████ ████████ ██████ ████████ ██ ██ ██ ███████ ████████ -██░░███ ░░███░░███░░░░░███ ░░███░░███░███ ░███ ██░███░░░░░░░███░ -░███ ░░░ ░███ ░███ ███████ ░███ ░░░ ░███ ░██████░░█████ ░███ -░███ ███ ░███ ░███ ███░░███ ░███ ░███ ░███░░░ ░███░░█ ░███ -░░██████ ░███████░░████████ ░███ ░███████████ ███████ ░████████ - ░░░░░░ ░░░░░░░ ░░░░░░░░ ░░░ ░░░░░░░░░░░ ░░░░░░ ░░░░░░░ +```text + █████ █████████ + ░░███ ███░░░░░███ + ██████ ██████ ███████ ░███ ░░░ ████████ ██████ ██████ + ███░░███ ░░░░░███ ███░░███ ░░█████████ ░░███░░███ ███░░███ ███░░███ +░███ ░░░ ███████ ░███ ░███ ░░░░░░░░███ ░███ ░███░███████ ░███ ░░░ +░███ ███ ███░░███ ░███ ░███ ███ ░███ ░███ ░███░███░░░ ░███ ███ +░░██████ ░░████████░░████████░░█████████ ░███████ ░░██████ ░░██████ + ░░░░░░ ░░░░░░░░ ░░░░░░░░ ░░░░░░░░░ ░███░░░ ░░░░░░ ░░░░░░ + ░███ + █████ + ░░░░░ +```

- CI - Crates.io + CI + Crates.io Status License

-cadforge is an **Architecture as Code** CLI tool and Rust library for declarative 2D CAD modeling. Write geometry as code in `.cf` TOML format, compile to DXF, and generate PNG previews for AI agents. +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. --- ## Features -### 🎯 Core Platform - - **📐 Declarative Geometry** — Define architectural elements (lines, rects, circles, arcs, polylines, text, dimensions) in TOML `.cf` files. Deterministic, reproducible, version-controlled. -- **🔗 Layer System** — Organize geometry by layer with custom names, colors, and line weights. Compile single layers or full projects. -- **📄 DXF Export** — Compile `.cf` → DXF (AutoCAD-compatible). Full layer support, LWPOLYLINE for polylines, HATCH for solid fills, MTEXT for annotations. -- **🖼️ PNG Preview** — Generate raster previews with metadata JSON for AI agent integration. Renders fills, hatches, strokes, and text with boundary resolution. Configurable resolution and layer filtering. -- **✅ Validation Engine** — `cadforge check` validates geometry without generating output. Shows project metadata, layer colors, and entity counts. +- **🛠️ Construction Tools** — `[[array]]` (linear and polar) and `[[mirror]]` expand into concrete primitives at build time; copies get derived ids. +- **📏 Styled Dimensions** — Auto-measured labels with configurable `text_size`, `precision`, `show_units`, and `offset`. +- **🔴 Live Preview** — `cadspec serve` runs a local server with pan/zoom, auto-reload on save (SSE), click-to-inspect, per-layer ghost/hide, 3D view, and build-error overlay. +- **🔗 Layer System** — Organize geometry by layer with custom names, colors, and line weights. +- **📄 DXF Export** — Compile `.cf` → DXF (AutoCAD-compatible). Full layer support, LWPOLYLINE polygons, LINE/CIRCLE/ARC geometry, TEXT annotations, DIMENSION entities, and SOLID-filled regions. +- **🖼️ Previews for Agents** — Raster PNG + metadata JSON (entity bounding boxes) and full-fidelity SVG with real text, dimensions, line styles, and highlights. +- **✅ Validation Engine** — `cadspec check` validates geometry and constraints; `--json` for tooling. +- **🔄 Formatting** — `cadspec fmt` normalizes `.cf` files. `--check` mode for CI. +- **🔍 DXF Import** — `cadspec import drawing.dxf` migrates existing drawings into `.cf` layers. + +--- + +## Installation + +### Quick install (recommended) + +**Linux / macOS:** + +```bash +curl -fsSL https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.sh | sh +``` + +**Windows (PowerShell):** + +```powershell +irm https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.ps1 | iex +``` + +### Via cargo + +```bash +cargo install cadspec +``` + +Available on [crates.io](https://crates.io/crates/cadspec). + +### From source + +```bash +git clone https://github.com/UniverLab/cadspec.git +cd cadspec +cargo build --release +# Binary at target/release/cadspec +``` + +### GitHub Releases + +Check the [Releases](https://github.com/UniverLab/cadspec/releases) page for precompiled binaries (Linux x86_64, macOS x86_64/ARM64, Windows x86_64). + +### Uninstall + +```bash +rm -f ~/.local/bin/cadspec +``` + +--- -### 🏗️ Project Management +## Quick Start -- **Project Scaffolding** — `cadforge new` creates a complete multi-layer project (muros, puertas, mobiliario, cotas) with meaningful architectural examples. -- **Multi-Layer Compilation** — Compile all layers or target specific layers with `--layer`. Custom output path with `--output`. -- **Auto-Rebuild** — `cadforge watch` monitors `.cf` and `.toml` files and auto-rebuilds on changes with 300ms debounce. -- **Code Formatting** — `cadforge fmt` normalizes `.cf` files. `--check` mode for CI validation. -- **Boundary Resolution** — Automatic detection of closed boundaries for hatch generation. Shared boundary resolution across overlapping entities. -- **Polyline Support** — Full LWPOLYLINE support with bulge factors for arcs. Proper vertex handling and closure detection. +```bash +cadspec new casa && cd casa +cadspec serve --open # live preview in the browser +``` + +Edit any `.cf` file — the browser updates on every save. When the design is right: + +```bash +cadspec build # default output.dxf +cadspec build --output plano.dxf # custom output path +cadspec build --layer muros # compile single layer +``` -### 🔧 Architecture +## Documentation -- **Compiler Pipeline** — Parse → Resolve → Compile → Emit. Modular design for easy extension. -- **DXF Writer** — Direct DXF entity writing with proper AutoCAD compatibility. Layer/color/lineweight mapping. -- **Preview Renderer** — Tiny-skia based raster rendering with anti-aliasing. PNG + JSON metadata output. -- **Error Reporting** — Structured errors with file, line, and context. Fast-fail on validation errors. +Full documentation lives in [`docs/`](docs/): installation, quick start, the +`.cf` format, live preview, building & export, working with agents, and the +complete CLI reference. --- @@ -48,39 +110,42 @@ cadforge is an **Architecture as Code** CLI tool and Rust library for declarativ | Command | Description | |---------|-------------| -| `cadforge new ` | Create a new project with multi-layer scaffold | -| `cadforge init` | Initialize CADforge in current directory | -| `cadforge build` | Compile project to DXF | -| `cadforge build --check` | Validate project and constraints without generating DXF | -| `cadforge build --output ` | Compile to custom output path | -| `cadforge build --layer ` | Compile specific layer only | -| `cadforge check` | Validate with project metadata and layer colors | -| `cadforge layers` | List layers with entity counts and colors | -| `cadforge preview` | Generate PNG preview + metadata JSON | -| `cadforge preview --width 1024 --height 768` | Custom resolution preview | -| `cadforge preview --layer ` | Preview specific layer only | -| `cadforge fmt` | Format .cf files (normalize whitespace) | -| `cadforge fmt --check` | Check formatting without modifying (CI) | -| `cadforge watch` | Auto-rebuild on file changes | -| `cadforge import ` | Import DXF into `.cf` layers + `project.toml` | -| `cadforge import --layer ` | Import only one DXF layer | -| `cadforge view` | Open the 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 | - -### Viewer controls (MVP) - -- HUD flotante en pantalla con proyecto, vista, distancia, capas, selección y ayuda de atajos -- `T` / `F` / `V` / `R` → top / front / right / isometric preset views -- `Q` / `E` / `W` / `S` → orbit camera -- Mouse left-drag → orbit -- Mouse right-drag / arrows → pan -- Mouse wheel / `+` / `-` → zoom -- `1`..`9` → toggle layer visibility -- Click entity edge → select primitive id -- Selected entity is highlighted in amber in the viewport HUD context -- `C` → copy selected id to clipboard +| `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 +- Layer panel (or keys `1`-`9`) → cycle each layer **on → ghost → off**; ghost mode traces one floor plan over another +- `3D` button (or key `3`) → stacked exploded view of the layers +- Browser auto-reloads on every `.cf` / `project.toml` save (SSE) +- Build errors render as an overlay with file/line detail — the loop never breaks --- @@ -117,7 +182,7 @@ to_angle = 90.0 [[polyline]] id = "pl-001" -vertices = [[0, 0], [5, 0], [5, 3], [0, 3]] +points = [[0.0, 0.0], [5.0, 0.0], [5.0, 3.0], [0.0, 3.0]] closed = true [[text]] @@ -125,6 +190,8 @@ id = "tx-001" position = [4.0, 3.0] content = "SALA" size = 0.2 +font = "serif" +rotation = 15.0 [[dim]] id = "dm-001" @@ -135,7 +202,9 @@ offset = 0.5 ### Supported Primitives -`line`, `polyline`, `rect`, `circle`, `arc`, `text`, `point`, `dim`, `hatch`, `solid` +`line`, `polyline`, `rect`, `circle`, `arc`, `text`, `point`, `dim`, `hatch`, `fill`, `group` + +Run `cadspec schema` for the complete reference with all attributes. --- @@ -160,21 +229,7 @@ offset = 0.5 - **Resolver** — Layer dependency resolution, coordinate validation, boundary detection - **Compiler** — Entity compilation to DXF format, hatch generation, polyline closure - **DXF Writer** — Direct DXF entity emission with proper layer/color/lineweight mapping -- **Preview Renderer** — Tiny-skia raster rendering with hatch/fill support - ---- - -## Main Modules - -- `compiler/` — Project compilation pipeline, layer targeting, validation, build stats -- `dxf_writer/` — DXF entity writing, LWPOLYLINE, HATCH, MTEXT generation -- `preview/` — PNG rendering with configurable resolution, layer filtering, metadata JSON -- `parser/` — TOML parsing, primitive extraction, array-of-tables handling -- `model/` — Data structures: Layer, Primitive, Project -- `scaffold/` — Multi-layer project creation with architectural examples -- `fmt/` — .cf file formatting and normalization -- `watch/` — File system watcher with auto-rebuild and debounce -- `color/` — Color parsing and DXF color mapping +- **SVG Renderer** — Single vector backend: text, measured dims, hatches, highlights; PNG previews are resvg rasterizations of it --- @@ -183,53 +238,23 @@ offset = 0.5 | Data | Location | Format | |------|----------|--------| | Project files | `./` | TOML (`.cf` + `project.toml`) | -| Build output | `output/` | DXF | -| Preview output | `output/preview.png` | PNG | -| Preview metadata | `output/preview.json` | JSON | -| Build cache | `target/` | Cargo build | - ---- - -## Usage - -**Create a new project:** -```bash -cadforge new mi-proyecto -cd mi-proyecto -``` - -**Edit `.cf` files** (TOML format with your geometry) - -**Format and validate:** -```bash -cadforge fmt # normalize .cf files -cadforge 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 -``` - -**Preview:** -```bash -cadforge preview # default 2048x1536 -cadforge preview --width 1024 --height 768 # custom resolution -cadforge preview --layer muros # single layer preview -``` - -**Auto-rebuild on changes:** -```bash -cadforge watch # monitors .cf and .toml files -``` +| Build output | `./output.dxf` | DXF | +| Preview output | `./preview.png`, `./preview.svg` | PNG / SVG | +| Preview metadata | `./preview.meta.json` | JSON | +| Language reference | `cadspec schema` (stdout) | Markdown | --- ## Tech Stack -| Rust 2021 | clap | toml | toml_edit | tiny-skia | dxf | notify | anyhow | serde | +| Concern | Crate | +|---|---| +| CLI parsing | `clap` (derive) | +| Error handling | `anyhow` | +| Serialization | `serde` + `toml` + `toml_edit` | +| DXF output | `dxf` | +| File watching | `notify` | +| PNG rasterization | `resvg` | --- @@ -239,4 +264,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/assets/fonts/DejaVuSansMono.ttf b/assets/fonts/DejaVuSansMono.ttf new file mode 100644 index 0000000..538ee27 Binary files /dev/null and b/assets/fonts/DejaVuSansMono.ttf differ diff --git a/assets/fonts/LICENSE-DejaVuSansMono.txt b/assets/fonts/LICENSE-DejaVuSansMono.txt new file mode 100644 index 0000000..b3d93a1 --- /dev/null +++ b/assets/fonts/LICENSE-DejaVuSansMono.txt @@ -0,0 +1,78 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: DejaVu fonts +Upstream-Author: Stepan Roh (original author), + see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list +Source: https://dejavu-fonts.github.io/ + +Files: * +Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. + Bitstream Vera is a trademark of Bitstream, Inc. + DejaVu changes are in public domain. +License: bitstream-vera + Permission is hereby granted, free of charge, to any person obtaining a copy + of the fonts accompanying this license ("Fonts") and associated + documentation files (the "Font Software"), to reproduce and distribute the + Font Software, including without limitation the rights to use, copy, merge, + publish, distribute, and/or sell copies of the Font Software, and to permit + persons to whom the Font Software is furnished to do so, subject to the + following conditions: + . + The above copyright and trademark notices and this permission notice shall + be included in all copies of one or more of the Font Software typefaces. + . + The Font Software may be modified, altered, or added to, and in particular + the designs of glyphs or characters in the Fonts may be modified and + additional glyphs or characters may be added to the Fonts, only if the fonts + are renamed to names not containing either the words "Bitstream" or the word + "Vera". + . + This License becomes null and void to the extent applicable to Fonts or Font + Software that has been modified and is distributed under the "Bitstream + Vera" names. + . + The Font Software may be sold as part of a larger software package but no + copy of one or more of the Font Software typefaces may be sold by itself. + . + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, + TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME + FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING + ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF + THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE + FONT SOFTWARE. + . + Except as contained in this notice, the names of Gnome, the Gnome + Foundation, and Bitstream Inc., shall not be used in advertising or + otherwise to promote the sale, use or other dealings in this Font Software + without prior written authorization from the Gnome Foundation or Bitstream + Inc., respectively. For further information, contact: fonts at gnome dot + org. + +Files: debian/* +Copyright: (C) 2005-2006 Peter Cernak + (C) 2006-2011 Davide Viti + (C) 2011-2013 Christian Perrier + (C) 2013 Fabian Greffrath +License: GPL-2+ + This program is free software; you can redistribute it + and/or modify it under the terms of the GNU General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later + version. + . + This program is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + PURPOSE. See the GNU General Public License for more + details. + . + You should have received a copy of the GNU General Public + License along with this package; if not, write to the Free + Software Foundation, Inc., 51 Franklin St, Fifth Floor, + Boston, MA 02110-1301 USA + . + On Debian systems, the full text of the GNU General Public + License version 2 can be found in the file + /usr/share/common-licenses/GPL-2'. diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..73cc637 --- /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 + +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 — `cadspec schema` + +```bash +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 cadspec required. + +## Machine-readable state + +```bash +cadspec check --json # validation report +cadspec layers --json # layers, entity counts, colors +``` + +## Visual grounding — `cadspec preview` + +```bash +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, +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 +cadspec 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 +cadspec schema # 1. learn the language +cadspec layers --json # 2. inspect the project +# ... edit .cf files ... +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 new file mode 100644 index 0000000..9bebda5 --- /dev/null +++ b/docs/building-and-export.md @@ -0,0 +1,113 @@ +--- +title: Building & Export +description: Deterministic DXF compilation, watch mode, validation, and DXF import. +order: 6 +--- + +# Building & Export + +## Compile to DXF + +```bash +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 +DXF. Export uses proper AutoCAD-compatible entities — LWPOLYLINE for +polylines and rectangles, LINE/CIRCLE/ARC for open geometry, TEXT for +annotations, DIMENSION for measured dims, and SOLID (fan-triangulated) +for filled regions. Hatch patterns are emitted as clipped LINE segments +(the `dxf` crate has no native HATCH entity). All entities carry full +layer/color/lineweight mapping. + +## Drawing sheets (planos) + +A *plano* is a named view of the model rendered onto a paper-sized sheet +with a frame and a title block (rótulo). Declare sheets in `project.toml` +and render them with `cadspec preview --plano `: + +```toml +[[plano]] +name = "general" +view = "plan" # plan | iso | front | back | left | right | top | section +scale = "1:100" # label shown in the title block +size = [420.0, 297.0] # paper size in mm (default A3 landscape) +title = "Planta general" # defaults to the plano name + +[[plano]] +name = "corte-a" +view = "section" +cut_axis = "x" # section only: x | y | z +cut_at = 4.0 # position of the cut plane along cut_axis +keep = "min" # which side to keep: min (default) | max +``` + +```bash +cadspec preview --plano general # render one sheet +cadspec preview --plano corte-a --format svg +``` + +The title block is generated from the project metadata; set `rotulo = +"titleblock.cf"` on a plano to draw a custom one with the same `.cf` +primitives as the rest of the drawing. The live preview (`cadspec +serve`) lists all declared planos in a panel. + +## Validation + +```bash +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 +cadspec watch +``` + +Monitors `.cf` and `.toml` files and rebuilds the DXF on changes with a +300 ms debounce. + +## Formatting + +```bash +cadspec fmt # normalize .cf files in place +cadspec fmt --check # CI-friendly check mode +``` + +## Importing existing drawings + +```bash +cadspec import plano.dxf # all layers +cadspec import plano.dxf --layer muros # a single DXF layer +cadspec import plano.dxf --output casa # into a specific directory +``` + +Import migrates an existing DXF into `.cf` layer files plus a +`project.toml`, so legacy drawings can join the declarative workflow. +Lines, polylines, circles, arcs, text, points and dimensions map back to +their `.cf` primitives. Filled and hatched regions are reconstructed too: +adjacent SOLID entities on a layer (such as the fan-triangulated fills that +`cadspec build` emits) are fused back into a single `[[fill]]`, and the +clipped LINE segments a hatch expands into — marked with `CADSPEC_HATCH` +XDATA on export — are re-fused into a single `[[hatch]]` carrying its region +and pattern/angle/scale. So an export/import round-trip does not inflate the +entity count, and only lines this tool emitted are ever folded back into a +hatch (a foreign DXF's ordinary lines are left untouched). + +## 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..6fbf3af --- /dev/null +++ b/docs/cf-format.md @@ -0,0 +1,165 @@ +--- +title: The .cf Format +description: Layers, primitives, styled dimensions, construction tools and constraints. +order: 4 +--- + +# The .cf Format + +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 `cadspec 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 +align = "center" +font = "serif" +rotation = 15.0 +bold = true +italic = false +``` + +Every entity has a stable `id` — that is what makes plans diffable and +lets agents target precise edits. + +### Text styling fields + +`[[text]]` entities accept four optional styling fields, all of which +default to a plain, unrotated, monospace label when omitted: + +- `font` — a CSS `font-family` value, passed through verbatim to the SVG + renderer (default: `"monospace"`). No fonts are downloaded; the value + is resolved by the browser/renderer against generic CSS families and + whatever fonts are installed locally. +- `rotation` — degrees, counter-clockwise, around the text anchor point + (default: `0`). +- `bold` — `true`/`false` (default: `false`). +- `italic` — `true`/`false` (default: `false`). + +Two limitations to keep in mind: + +1. `font`, `bold` and `italic` only affect the SVG/PNG preview. They are + **not** written to DXF — DXF has no simple equivalent short of + managing a `STYLE` table, which is out of scope. Only `rotation` is + carried through to the DXF `TEXT` entity (group code 50) and survives + a `build` → `import` roundtrip. +2. Text bounding boxes and `align` anchoring are approximated using + monospace glyph proportions. This approximation is increasingly + inaccurate for non-monospace `font` values and does not account for + `rotation` at all, so treat computed text extents as approximate + whenever either field is set. + +## Filled and hatched regions + +`fill` (solid color) and `hatch` (parallel pattern lines) mark an area. The +region can come from either a reference to a closed `polyline`/`rect` `id` in +the **same layer file** (`boundary`) or inline `points`: + +```toml +[[fill]] +id = "fl-closet" +points = [[0.0, 7.0], [1.2, 7.0], [1.2, 9.0], [0.0, 9.0]] +color = "#E0E0E0" + +[[hatch]] +id = "ht-bano" +boundary = "pl-bano" # id of a closed polyline/rect in this file +pattern = "ansi31" +scale = 2.0 +angle = 45.0 +``` + +Boundaries resolve per layer file: a `boundary` id defined in another layer +will not resolve. This is by design — layer files are the resolution +boundary, not an oversight to fix — and both `build` and `preview`/`serve` +warn on stderr and skip the region rather than failing or dropping it +silently. Use inline `points` to reference geometry from another layer. + +At build time a `hatch` expands into DXF `LINE`s (there is no native HATCH +entity in the DXF format this tool targets). Those pattern lines carry a +`CADSPEC_HATCH` marker so `cadspec import` re-fuses them back into a single +`[[hatch]]` — with inline `points` — instead of dozens of stray `[[line]]`s. +Only lines this tool emits carry the marker, so importing a foreign DXF never +mistakes ordinary lines for a hatch. + +## 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..e51dafb --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,74 @@ +--- +title: CLI Reference +description: Every cadspec command and flag. +order: 8 +--- + +# CLI Reference + +``` +cadspec [options] +``` + +## Project lifecycle + +| Command | Description | +|---------|-------------| +| `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 | +|---------|-------------| +| `cadspec serve` | Live preview server (background) — browser auto-reloads on save | +| `cadspec serve --open --port

` | Open browser automatically on a custom port | +| `cadspec serve --foreground` | Stay attached: stream logs, stop with Ctrl+C | +| `cadspec serve --stop` | Stop the background server running for this project | +| `cadspec preview` | Faithful PNG render + `preview.meta.json` | +| `cadspec preview --format svg` | Vector SVG preview (same renderer) | +| `cadspec preview --format all` | Both PNG and SVG in one pass | +| `cadspec preview --3d` | Axonometric 3D view (extrusions + solids) | +| `cadspec preview --format gltf` | Export the 3D solids to `scene.gltf` (interactive 3D / interchange) | +| `cadspec preview --highlight ` | Amber markers around specific entities | +| `cadspec preview --width -H ` | Custom resolution | +| `cadspec preview --layer ` | Preview a specific layer only | +| `cadspec preview --plano ` | Render a named drawing sheet (`[[plano]]` in `project.toml`) | +| `cadspec view` | Open the project in the configured viewer | +| `cadspec view --layer ` | Open only one layer in the viewer | + +## Inspection & quality + +| Command | Description | +|---------|-------------| +| `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 | +|---------|-------------| +| `cadspec import ` | Import DXF into `.cf` layers + `project.toml` | +| `cadspec import --layer ` | Import only one DXF layer | +| `cadspec import --output

` | Import into a specific directory | +| `cadspec config set ` | Set global defaults (`author`, `units`) | +| `cadspec config show` | Show global defaults | + +## Output files + +| File | Description | +|------|-------------| +| `output.dxf` | Default build output | +| `preview.png` / `preview.svg` | Preview renders | +| `scene.gltf` | 3D scene export (glTF 2.0, self-contained) | +| `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..549dc89 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,65 @@ +--- +title: Cadspec +description: CAD as code — declarative CAD in TOML, live browser preview, deterministic DXF output. +order: 1 +--- + +# Cadspec + +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. + +## The plan is not drawn — it is declared + +Traditional CAD drawings are collections of lines without semantics: +impossible to version, diff or automate. Cadspec 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 +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, `cadspec build` +emits a deterministic DXF. + +## Built for agents + +Agents get first-class support: + +- `cadspec schema` — the full `.cf` language reference in one command; + agents self-discover the format without prior training. +- `cadspec check --json` / `cadspec layers --json` — machine-readable + reports. +- `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. +- `cadspec 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) — `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 + +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 new file mode 100644 index 0000000..25e9df6 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,49 @@ +--- +title: Installation +description: Install cadspec with the quick installer, cargo, or from source. +order: 2 +--- + +# Installation + +> Cadspec 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/cadspec/main/scripts/install.sh | sh +``` + +**Windows (PowerShell):** + +```powershell +irm https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.ps1 | iex +``` + +## Via cargo + +```bash +cargo install cadspec +``` + +Available on [crates.io](https://crates.io/crates/cadspec). + +## From source + +```bash +git clone https://github.com/UniverLab/cadspec.git +cd cadspec +cargo build --release +# Binary at target/release/cadspec +``` + +## Uninstall + +```bash +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 new file mode 100644 index 0000000..6fa2694 --- /dev/null +++ b/docs/live-preview.md @@ -0,0 +1,49 @@ +--- +title: Live Preview +description: The cadspec serve loop — pan/zoom, inspector, layer states, 3D view, error overlay. +order: 5 +--- + +# Live Preview + +```bash +cadspec serve # start the local preview server (background) +cadspec serve --open --port 4377 # open browser, custom port +cadspec serve --foreground # stay attached, stream logs, Ctrl+C to stop +cadspec serve --stop # stop the background server for this project +``` + +`cadspec serve` runs a zero-config local server with auto-reload on +save (SSE). Edit a `.cf` file and the browser updates instantly. By +default it detaches into the background and keeps the terminal free; +use `--foreground` to stay attached or `--stop` to shut it down. + +## 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..4b4c466 --- /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 +cadspec new my-house +cd my-house +``` + +`cadspec new` scaffolds a complete multi-layer project (walls, doors, +furniture, dimensions) with meaningful architectural examples. To adopt +cadspec in an existing directory use `cadspec init`. + +## 2. Preview it live + +```bash +cadspec 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 `cadspec schema` for the complete language reference, or read +[The .cf Format](cf-format.md). + +## 4. Validate and format + +```bash +cadspec check # validate geometry and constraints, no output files +cadspec fmt # normalize .cf files +``` + +## 5. Compile to DXF + +```bash +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. `cadspec watch` +rebuilds automatically while you edit. diff --git a/examples/taller/cotas.cf b/examples/taller/cotas.cf new file mode 100644 index 0000000..a83397e --- /dev/null +++ b/examples/taller/cotas.cf @@ -0,0 +1,30 @@ +[layer] +name = "cotas" +color = "#FF5050" + +# Diámetro exterior de la escalera — etiqueta grande, 1 decimal +[[dim]] +id = "dm-escalera" +from = [-1.6, 0.0] +to = [1.6, 0.0] +offset = -2.2 +text_size = 0.28 +precision = 1 + +# Ancho del engranaje — sin unidades, 3 decimales, letra pequeña +[[dim]] +id = "dm-engranaje" +from = [3.55, 0.0] +to = [6.45, 0.0] +offset = -2.2 +text_size = 0.16 +precision = 3 +show_units = false + +# Ancho total de la nave (geometría espejada: 0 → 5) +[[dim]] +id = "dm-nave" +from = [0.0, -4.6] +to = [5.0, -4.6] +offset = -0.6 +text_size = 0.22 diff --git a/examples/taller/engranaje.cf b/examples/taller/engranaje.cf new file mode 100644 index 0000000..740d8b0 --- /dev/null +++ b/examples/taller/engranaje.cf @@ -0,0 +1,43 @@ +[layer] +name = "engranaje" +color = "#50C8FF" +line_weight = 0.35 + +# Diente base; el array polar completa la corona de 16 dientes +[[polyline]] +id = "pl-diente" +points = [ + [6.193, -0.125], + [6.448, -0.076], + [6.448, 0.076], + [6.193, 0.125], +] +closed = true + +[[array]] +id = "ar-corona" +target = "pl-diente" +mode = "polar" +count = 16 +center = [5.0, 0.0] +step_angle = 22.5 + +# Círculo primitivo (referencia, discontinuo) +[[circle]] +id = "ci-primitivo" +center = [5.0, 0.0] +radius = 1.2 +style = "dashed" +weight = 0.18 + +# Cuerpo y agujero del eje +[[circle]] +id = "ci-cuerpo" +center = [5.0, 0.0] +radius = 1.05 +weight = 0.50 + +[[circle]] +id = "ci-eje" +center = [5.0, 0.0] +radius = 0.3 diff --git a/examples/taller/escalera.cf b/examples/taller/escalera.cf new file mode 100644 index 0000000..56456f3 --- /dev/null +++ b/examples/taller/escalera.cf @@ -0,0 +1,37 @@ +[layer] +name = "escalera" +color = "#FFFFFF" +line_weight = 0.35 + +# Columna central +[[circle]] +id = "ci-columna" +center = [0.0, 0.0] +radius = 0.25 + +# Perímetro exterior de la escalera +[[circle]] +id = "ci-borde" +center = [0.0, 0.0] +radius = 1.6 +weight = 0.50 + +# Huella base (un escalón); el array polar genera la helicoidal completa +[[polyline]] +id = "pl-huella" +points = [ + [0.30, 0.0], + [1.55, 0.0], + [1.456, 0.530], + [0.282, 0.103], +] +closed = true +weight = 0.25 + +[[array]] +id = "ar-helicoidal" +target = "pl-huella" +mode = "polar" +count = 16 +center = [0.0, 0.0] +step_angle = 22.5 diff --git a/examples/taller/planta.cf b/examples/taller/planta.cf new file mode 100644 index 0000000..0f3688f --- /dev/null +++ b/examples/taller/planta.cf @@ -0,0 +1,53 @@ +[layer] +name = "planta" +color = "#50FF50" +line_weight = 0.35 + +# Media nave del taller: el espejo completa la planta simétrica respecto a x = 2.5 +[[polyline]] +id = "pl-nave" +points = [ + [0.0, -4.6], + [2.5, -4.6], + [2.5, -2.4], + [0.6, -2.4], + [0.6, -3.0], + [0.0, -3.0], +] +closed = false +weight = 0.50 + +# Puerta con su abatimiento +[[line]] +id = "ln-puerta" +from = [0.9, -2.4] +to = [1.7, -2.4] +color = "#FFC850" + +[[arc]] +id = "ar-puerta" +center = [0.9, -2.4] +radius = 0.8 +from_angle = 0.0 +to_angle = 90.0 +color = "#FFC850" + +# Bancos de trabajo en serie (array lineal) +[[rect]] +id = "rc-banco" +origin = [0.3, -4.4] +width = 0.8 +height = 0.5 +color = "#C878FF" + +[[array]] +id = "ar-bancos" +target = "rc-banco" +mode = "linear" +count = 3 +offset = [1.3, 0.0] + +[[mirror]] +id = "mr-nave" +targets = ["pl-nave", "ln-puerta", "ar-puerta"] +axis = [[2.5, 0.0], [2.5, 1.0]] diff --git a/examples/taller/project.toml b/examples/taller/project.toml new file mode 100644 index 0000000..10537aa --- /dev/null +++ b/examples/taller/project.toml @@ -0,0 +1,10 @@ +[project] +name = "Taller — arrays, espejo y cotas" +scale = "1:50" +units = "m" + +[layers] +escalera = { file = "escalera.cf", locked = false } +engranaje = { file = "engranaje.cf", locked = false } +planta = { file = "planta.cf", locked = false } +cotas = { file = "cotas.cf", locked = false } diff --git a/examples/vivienda/achurados.cf b/examples/vivienda/achurados.cf index c1f6105..9f49727 100644 --- a/examples/vivienda/achurados.cf +++ b/examples/vivienda/achurados.cf @@ -17,9 +17,11 @@ scale = 2.0 angle = 45.0 # ── Relleno solido del closet dormitorio 1 ───────────────── +# Coincide con el rect rc-closet-d1 de muros.cf (origen [0,7], 1.2 x 2.0). +# Se usan puntos en linea porque los boundary se resuelven por archivo de capa. [[fill]] id = "fl-closet-d1" -boundary = "rc-closet-d1" +points = [[0.0, 7.0], [1.2, 7.0], [1.2, 9.0], [0.0, 9.0]] color = "#E0E0E0" # ── Achurado de la cocina ─────────────────────────────────── @@ -67,4 +69,4 @@ from = [0.0, 3.5] to = [0.0, 4.5] style = "dashed" weight = 0.18 -color = "#00CC44" \ No newline at end of file +color = "#00CC44" diff --git a/examples/vivienda/cotas.cf b/examples/vivienda/cotas.cf index 8a4a4dd..6a6dc70 100644 --- a/examples/vivienda/cotas.cf +++ b/examples/vivienda/cotas.cf @@ -3,7 +3,6 @@ name = "cotas" color = "#FF4444" # ── Cotas exteriores ──────────────────────────────────────── - [[dim]] id = "dm-ancho-total" type = "linear" @@ -19,7 +18,6 @@ to = [0.0, 9.0] offset = -1.2 # ── Cotas interiores ─────────────────────────────────────── - [[dim]] id = "dm-sala-ancho" type = "linear" @@ -63,7 +61,6 @@ to = [0.0, 5.0] offset = -0.6 # ── Ejes de referencia ────────────────────────────────────── - [[line]] id = "ln-eje-v" from = [6.0, -0.5] @@ -76,4 +73,4 @@ id = "ln-eje-h" from = [-0.5, 5.0] to = [12.5, 5.0] style = "dashdot" -color = "#00CCCC" \ No newline at end of file +color = "#00CCCC" diff --git a/examples/vivienda/mobiliario.cf b/examples/vivienda/mobiliario.cf index a8b8ca5..7105ccc 100644 --- a/examples/vivienda/mobiliario.cf +++ b/examples/vivienda/mobiliario.cf @@ -3,7 +3,6 @@ name = "mobiliario" color = "#4488FF" # ── Sala ───────────────────────────────────────────────────── - # Sofa [[rect]] id = "rc-sofa" @@ -36,7 +35,6 @@ radius = 0.3 color = "#66AAFF" # ── Dormitorio 1 ──────────────────────────────────────────── - # Cama doble [[rect]] id = "rc-cama-d1" @@ -62,7 +60,6 @@ height = 0.5 color = "#66AAFF" # ── Dormitorio 2 ──────────────────────────────────────────── - # Cama individual [[rect]] id = "rc-cama-d2" @@ -87,7 +84,6 @@ radius = 0.25 color = "#66AAFF" # ── Cocina ────────────────────────────────────────────────── - # Cocina (mesada) [[rect]] id = "rc-mesada" @@ -112,7 +108,6 @@ height = 0.7 color = "#AADDFF" # ── Bano ───────────────────────────────────────────────────── - # Inodoro [[circle]] id = "ci-inodoro" @@ -136,7 +131,6 @@ height = 0.8 color = "#88CCFF" # ── Etiquetas de ambientes ────────────────────────────────── - [[text]] id = "tx-sala" position = [7.5, 6.8] @@ -165,4 +159,4 @@ size = 0.22 id = "tx-bano" position = [0.5, 3.8] content = "BANO" -size = 0.18 \ No newline at end of file +size = 0.18 diff --git a/examples/vivienda/muros.cf b/examples/vivienda/muros.cf index 6f4d37f..3043dcc 100644 --- a/examples/vivienda/muros.cf +++ b/examples/vivienda/muros.cf @@ -86,4 +86,4 @@ id = "rc-closet-d1" origin = [0.0, 7.0] width = 1.2 height = 2.0 -weight = 0.15 \ No newline at end of file +weight = 0.15 diff --git a/examples/vivienda/puertas.cf b/examples/vivienda/puertas.cf index 5fb8b13..f200d78 100644 --- a/examples/vivienda/puertas.cf +++ b/examples/vivienda/puertas.cf @@ -75,4 +75,4 @@ id = "ln-puerta-cocina" from = [8.0, 5.0] to = [8.8, 5.0] weight = 0.12 -style = "dashed" \ No newline at end of file +style = "dashed" diff --git a/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/scripts/install.ps1 b/scripts/install.ps1 index 943467b..fae492a 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,5 +1,5 @@ -# install.ps1 — download and install cadforge on Windows -# Usage: irm https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.ps1 | iex +# install.ps1 — download and install cadspec on Windows +# Usage: irm https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.ps1 | iex # # Options (set as env vars before running): # $env:VERSION = "0.1.0" # pin a specific version @@ -7,8 +7,8 @@ $ErrorActionPreference = "Stop" -$Repo = "UniverLab/cadforge" -$Binary = "cadforge.exe" +$Repo = "UniverLab/cadspec" +$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 @@ -55,6 +55,26 @@ try { Fail "Download failed: $_`nURL: $Url" } +# --- verify checksum --- +# Match against SHA256SUMS.txt from the same release. Missing sums file (older +# releases) -> skip; a present-but-mismatched checksum is fatal. +$SumsUrl = "https://github.com/$Repo/releases/download/$Tag/SHA256SUMS.txt" +$SumsOk = $false +try { + Invoke-WebRequest -Uri $SumsUrl -OutFile "$Tmp\SHA256SUMS.txt" -UseBasicParsing + $SumsOk = $true +} catch { + Info "checksum" "SHA256SUMS.txt not found for $Tag - skipping verification" +} +if ($SumsOk) { + $line = Select-String -Path "$Tmp\SHA256SUMS.txt" -Pattern ([regex]::Escape($Archive)) | Select-Object -First 1 + if (-not $line) { Fail "No checksum listed for $Archive in SHA256SUMS.txt" } + $expected = ($line.Line -split '\s+')[0].ToLower() + $actual = (Get-FileHash "$Tmp\$Archive" -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { Fail "Checksum mismatch for $Archive (expected $expected, got $actual)" } + Info "checksum" "verified" +} + # --- extract --- Expand-Archive -Path "$Tmp\$Archive" -DestinationPath $Tmp -Force $extracted = Join-Path $Tmp $Binary @@ -76,8 +96,31 @@ if ($userPath -notlike "*$InstallDir*") { # --- cleanup --- Remove-Item $Tmp -Recurse -Force +# --- 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 = "cadspec" +$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 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 044488a..dc63bea 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,10 +1,10 @@ #!/bin/sh -# install.sh — download and install cadforge from GitHub Releases -# Usage: curl -fsSL https://raw.githubusercontent.com/UniverLab/cadforge/main/scripts/install.sh | sh +# install.sh — download and install cadspec from GitHub Releases +# Usage: curl -fsSL https://raw.githubusercontent.com/UniverLab/cadspec/main/scripts/install.sh | sh set -eu -REPO="UniverLab/cadforge" -BINARY="cadforge" +REPO="UniverLab/cadspec" +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 --- @@ -59,6 +59,29 @@ info "download" "$URL" HTTP_CODE=$(curl -fSL -w '%{http_code}' -o "$TMPDIR/$ARCHIVE" "$URL" 2>/dev/null) || true [ "$HTTP_CODE" = "200" ] || error "Download failed (HTTP $HTTP_CODE). Check that $TAG exists for $TARGET at:\n $URL" +# --- verify checksum --- +# Match against SHA256SUMS.txt from the same release. Missing sums file (older +# releases) or no sha256 tool → skip; a present-but-mismatched checksum is fatal. +SUMS_URL="https://github.com/$REPO/releases/download/${TAG}/SHA256SUMS.txt" +if curl -fsSL -o "$TMPDIR/SHA256SUMS.txt" "$SUMS_URL" 2>/dev/null; then + EXPECTED=$(awk -v f="$ARCHIVE" '$2 == f { print $1 }' "$TMPDIR/SHA256SUMS.txt" | head -1) + [ -n "$EXPECTED" ] || error "No checksum listed for $ARCHIVE in SHA256SUMS.txt" + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "$TMPDIR/$ARCHIVE" | awk '{ print $1 }') + elif command -v shasum >/dev/null 2>&1; then + ACTUAL=$(shasum -a 256 "$TMPDIR/$ARCHIVE" | awk '{ print $1 }') + else + ACTUAL="" + info "checksum" "no sha256 tool found — skipping verification" + fi + if [ -n "$ACTUAL" ]; then + [ "$ACTUAL" = "$EXPECTED" ] || error "Checksum mismatch for $ARCHIVE (expected $EXPECTED, got $ACTUAL)" + info "checksum" "verified" + fi +else + info "checksum" "SHA256SUMS.txt not found for $TAG — skipping verification" +fi + # --- extract --- tar xzf "$TMPDIR/$ARCHIVE" -C "$TMPDIR" [ -f "$TMPDIR/$BINARY" ] || error "Binary not found in archive" @@ -88,7 +111,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,9 +120,31 @@ if [ -n "$PATHS_TO_ADD" ]; then fi # ============================================================ -# 3. Verify +# 3. Install the cadspec agent skill (optional) +# ============================================================ +# 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="cadspec" +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")" echo "" -info "ready" "Run 'cadforge --help' to get started!" +info "ready" "Run 'cadspec --help' to get started!" diff --git a/src/color.rs b/src/color.rs index 9c97039..8d53aa5 100644 --- a/src/color.rs +++ b/src/color.rs @@ -1,19 +1,299 @@ //! Color and weight conversion utilities for DXF output. -/// ACI color index from hex string (best-effort mapping to standard palette). +/// Full AutoCAD Color Index (ACI) palette, indices 1-255, as RGB triples. +/// This is the standard DXF color table (same values used by AutoCAD, +/// LibreCAD, dxflib and ezdxf), not something specific to cadspec. +const ACI_PALETTE: [(u8, (u8, u8, u8)); 255] = [ + (1, (0xFF, 0x00, 0x00)), + (2, (0xFF, 0xFF, 0x00)), + (3, (0x00, 0xFF, 0x00)), + (4, (0x00, 0xFF, 0xFF)), + (5, (0x00, 0x00, 0xFF)), + (6, (0xFF, 0x00, 0xFF)), + (7, (0xFF, 0xFF, 0xFF)), + (8, (0x80, 0x80, 0x80)), + (9, (0xC0, 0xC0, 0xC0)), + (10, (0xFF, 0x00, 0x00)), + (11, (0xFF, 0x7F, 0x7F)), + (12, (0xA5, 0x00, 0x00)), + (13, (0xA5, 0x52, 0x52)), + (14, (0x7F, 0x00, 0x00)), + (15, (0x7F, 0x3F, 0x3F)), + (16, (0x4C, 0x00, 0x00)), + (17, (0x4C, 0x26, 0x26)), + (18, (0x26, 0x00, 0x00)), + (19, (0x26, 0x13, 0x13)), + (20, (0xFF, 0x3F, 0x00)), + (21, (0xFF, 0x9F, 0x7F)), + (22, (0xA5, 0x29, 0x00)), + (23, (0xA5, 0x67, 0x52)), + (24, (0x7F, 0x1F, 0x00)), + (25, (0x7F, 0x4F, 0x3F)), + (26, (0x4C, 0x13, 0x00)), + (27, (0x4C, 0x2F, 0x26)), + (28, (0x26, 0x09, 0x00)), + (29, (0x26, 0x17, 0x13)), + (30, (0xFF, 0x7F, 0x00)), + (31, (0xFF, 0xBF, 0x7F)), + (32, (0xA5, 0x52, 0x00)), + (33, (0xA5, 0x7C, 0x52)), + (34, (0x7F, 0x3F, 0x00)), + (35, (0x7F, 0x5F, 0x3F)), + (36, (0x4C, 0x26, 0x00)), + (37, (0x4C, 0x39, 0x26)), + (38, (0x26, 0x13, 0x00)), + (39, (0x26, 0x1C, 0x13)), + (40, (0xFF, 0xBF, 0x00)), + (41, (0xFF, 0xDF, 0x7F)), + (42, (0xA5, 0x7C, 0x00)), + (43, (0xA5, 0x91, 0x52)), + (44, (0x7F, 0x5F, 0x00)), + (45, (0x7F, 0x6F, 0x3F)), + (46, (0x4C, 0x39, 0x00)), + (47, (0x4C, 0x42, 0x26)), + (48, (0x26, 0x1C, 0x00)), + (49, (0x26, 0x21, 0x13)), + (50, (0xFF, 0xFF, 0x00)), + (51, (0xFF, 0xFF, 0x7F)), + (52, (0xA5, 0xA5, 0x00)), + (53, (0xA5, 0xA5, 0x52)), + (54, (0x7F, 0x7F, 0x00)), + (55, (0x7F, 0x7F, 0x3F)), + (56, (0x4C, 0x4C, 0x00)), + (57, (0x4C, 0x4C, 0x26)), + (58, (0x26, 0x26, 0x00)), + (59, (0x26, 0x26, 0x13)), + (60, (0xBF, 0xFF, 0x00)), + (61, (0xDF, 0xFF, 0x7F)), + (62, (0x7C, 0xA5, 0x00)), + (63, (0x91, 0xA5, 0x52)), + (64, (0x5F, 0x7F, 0x00)), + (65, (0x6F, 0x7F, 0x3F)), + (66, (0x39, 0x4C, 0x00)), + (67, (0x42, 0x4C, 0x26)), + (68, (0x1C, 0x26, 0x00)), + (69, (0x21, 0x26, 0x13)), + (70, (0x7F, 0xFF, 0x00)), + (71, (0xBF, 0xFF, 0x7F)), + (72, (0x52, 0xA5, 0x00)), + (73, (0x7C, 0xA5, 0x52)), + (74, (0x3F, 0x7F, 0x00)), + (75, (0x5F, 0x7F, 0x3F)), + (76, (0x26, 0x4C, 0x00)), + (77, (0x39, 0x4C, 0x26)), + (78, (0x13, 0x26, 0x00)), + (79, (0x1C, 0x26, 0x13)), + (80, (0x3F, 0xFF, 0x00)), + (81, (0x9F, 0xFF, 0x7F)), + (82, (0x29, 0xA5, 0x00)), + (83, (0x67, 0xA5, 0x52)), + (84, (0x1F, 0x7F, 0x00)), + (85, (0x4F, 0x7F, 0x3F)), + (86, (0x13, 0x4C, 0x00)), + (87, (0x2F, 0x4C, 0x26)), + (88, (0x09, 0x26, 0x00)), + (89, (0x17, 0x26, 0x13)), + (90, (0x00, 0xFF, 0x00)), + (91, (0x7F, 0xFF, 0x7F)), + (92, (0x00, 0xA5, 0x00)), + (93, (0x52, 0xA5, 0x52)), + (94, (0x00, 0x7F, 0x00)), + (95, (0x3F, 0x7F, 0x3F)), + (96, (0x00, 0x4C, 0x00)), + (97, (0x26, 0x4C, 0x26)), + (98, (0x00, 0x26, 0x00)), + (99, (0x13, 0x26, 0x13)), + (100, (0x00, 0xFF, 0x3F)), + (101, (0x7F, 0xFF, 0x9F)), + (102, (0x00, 0xA5, 0x29)), + (103, (0x52, 0xA5, 0x67)), + (104, (0x00, 0x7F, 0x1F)), + (105, (0x3F, 0x7F, 0x4F)), + (106, (0x00, 0x4C, 0x13)), + (107, (0x26, 0x4C, 0x2F)), + (108, (0x00, 0x26, 0x09)), + (109, (0x13, 0x58, 0x17)), + (110, (0x00, 0xFF, 0x7F)), + (111, (0x7F, 0xFF, 0xBF)), + (112, (0x00, 0xA5, 0x52)), + (113, (0x52, 0xA5, 0x7C)), + (114, (0x00, 0x7F, 0x3F)), + (115, (0x3F, 0x7F, 0x5F)), + (116, (0x00, 0x4C, 0x26)), + (117, (0x26, 0x4C, 0x39)), + (118, (0x00, 0x26, 0x13)), + (119, (0x13, 0x58, 0x1C)), + (120, (0x00, 0xFF, 0xBF)), + (121, (0x7F, 0xFF, 0xDF)), + (122, (0x00, 0xA5, 0x7C)), + (123, (0x52, 0xA5, 0x91)), + (124, (0x00, 0x7F, 0x5F)), + (125, (0x3F, 0x7F, 0x6F)), + (126, (0x00, 0x4C, 0x39)), + (127, (0x26, 0x4C, 0x42)), + (128, (0x00, 0x26, 0x1C)), + (129, (0x13, 0x58, 0x58)), + (130, (0x00, 0xFF, 0xFF)), + (131, (0x7F, 0xFF, 0xFF)), + (132, (0x00, 0xA5, 0xA5)), + (133, (0x52, 0xA5, 0xA5)), + (134, (0x00, 0x7F, 0x7F)), + (135, (0x3F, 0x7F, 0x7F)), + (136, (0x00, 0x4C, 0x4C)), + (137, (0x26, 0x4C, 0x4C)), + (138, (0x00, 0x26, 0x26)), + (139, (0x13, 0x58, 0x58)), + (140, (0x00, 0xBF, 0xFF)), + (141, (0x7F, 0xDF, 0xFF)), + (142, (0x00, 0x7C, 0xA5)), + (143, (0x52, 0x91, 0xA5)), + (144, (0x00, 0x5F, 0x7F)), + (145, (0x3F, 0x6F, 0x7F)), + (146, (0x00, 0x39, 0x4C)), + (147, (0x26, 0x42, 0x7E)), + (148, (0x00, 0x1C, 0x26)), + (149, (0x13, 0x58, 0x58)), + (150, (0x00, 0x7F, 0xFF)), + (151, (0x7F, 0xBF, 0xFF)), + (152, (0x00, 0x52, 0xA5)), + (153, (0x52, 0x7C, 0xA5)), + (154, (0x00, 0x3F, 0x7F)), + (155, (0x3F, 0x5F, 0x7F)), + (156, (0x00, 0x26, 0x4C)), + (157, (0x26, 0x39, 0x7E)), + (158, (0x00, 0x13, 0x26)), + (159, (0x13, 0x1C, 0x58)), + (160, (0x00, 0x3F, 0xFF)), + (161, (0x7F, 0x9F, 0xFF)), + (162, (0x00, 0x29, 0xA5)), + (163, (0x52, 0x67, 0xA5)), + (164, (0x00, 0x1F, 0x7F)), + (165, (0x3F, 0x4F, 0x7F)), + (166, (0x00, 0x13, 0x4C)), + (167, (0x26, 0x2F, 0x7E)), + (168, (0x00, 0x09, 0x26)), + (169, (0x13, 0x17, 0x58)), + (170, (0x00, 0x00, 0xFF)), + (171, (0x7F, 0x7F, 0xFF)), + (172, (0x00, 0x00, 0xA5)), + (173, (0x52, 0x52, 0xA5)), + (174, (0x00, 0x00, 0x7F)), + (175, (0x3F, 0x3F, 0x7F)), + (176, (0x00, 0x00, 0x4C)), + (177, (0x26, 0x26, 0x7E)), + (178, (0x00, 0x00, 0x26)), + (179, (0x13, 0x13, 0x58)), + (180, (0x3F, 0x00, 0xFF)), + (181, (0x9F, 0x7F, 0xFF)), + (182, (0x29, 0x00, 0xA5)), + (183, (0x67, 0x52, 0xA5)), + (184, (0x1F, 0x00, 0x7F)), + (185, (0x4F, 0x3F, 0x7F)), + (186, (0x13, 0x00, 0x4C)), + (187, (0x2F, 0x26, 0x7E)), + (188, (0x09, 0x00, 0x26)), + (189, (0x17, 0x13, 0x58)), + (190, (0x7F, 0x00, 0xFF)), + (191, (0xBF, 0x7F, 0xFF)), + (192, (0x52, 0x00, 0xA5)), + (193, (0x7C, 0x52, 0xA5)), + (194, (0x3F, 0x00, 0x7F)), + (195, (0x5F, 0x3F, 0x7F)), + (196, (0x26, 0x00, 0x4C)), + (197, (0x39, 0x26, 0x7E)), + (198, (0x13, 0x00, 0x26)), + (199, (0x1C, 0x13, 0x58)), + (200, (0xBF, 0x00, 0xFF)), + (201, (0xDF, 0x7F, 0xFF)), + (202, (0x7C, 0x00, 0xA5)), + (203, (0x91, 0x52, 0xA5)), + (204, (0x5F, 0x00, 0x7F)), + (205, (0x6F, 0x3F, 0x7F)), + (206, (0x39, 0x00, 0x4C)), + (207, (0x42, 0x26, 0x4C)), + (208, (0x1C, 0x00, 0x26)), + (209, (0x58, 0x13, 0x58)), + (210, (0xFF, 0x00, 0xFF)), + (211, (0xFF, 0x7F, 0xFF)), + (212, (0xA5, 0x00, 0xA5)), + (213, (0xA5, 0x52, 0xA5)), + (214, (0x7F, 0x00, 0x7F)), + (215, (0x7F, 0x3F, 0x7F)), + (216, (0x4C, 0x00, 0x4C)), + (217, (0x4C, 0x26, 0x4C)), + (218, (0x26, 0x00, 0x26)), + (219, (0x58, 0x13, 0x58)), + (220, (0xFF, 0x00, 0xBF)), + (221, (0xFF, 0x7F, 0xDF)), + (222, (0xA5, 0x00, 0x7C)), + (223, (0xA5, 0x52, 0x91)), + (224, (0x7F, 0x00, 0x5F)), + (225, (0x7F, 0x3F, 0x6F)), + (226, (0x4C, 0x00, 0x39)), + (227, (0x4C, 0x26, 0x42)), + (228, (0x26, 0x00, 0x1C)), + (229, (0x58, 0x13, 0x58)), + (230, (0xFF, 0x00, 0x7F)), + (231, (0xFF, 0x7F, 0xBF)), + (232, (0xA5, 0x00, 0x52)), + (233, (0xA5, 0x52, 0x7C)), + (234, (0x7F, 0x00, 0x3F)), + (235, (0x7F, 0x3F, 0x5F)), + (236, (0x4C, 0x00, 0x26)), + (237, (0x4C, 0x26, 0x39)), + (238, (0x26, 0x00, 0x13)), + (239, (0x58, 0x13, 0x1C)), + (240, (0xFF, 0x00, 0x3F)), + (241, (0xFF, 0x7F, 0x9F)), + (242, (0xA5, 0x00, 0x29)), + (243, (0xA5, 0x52, 0x67)), + (244, (0x7F, 0x00, 0x1F)), + (245, (0x7F, 0x3F, 0x4F)), + (246, (0x4C, 0x00, 0x13)), + (247, (0x4C, 0x26, 0x2F)), + (248, (0x26, 0x00, 0x09)), + (249, (0x58, 0x13, 0x17)), + (250, (0x00, 0x00, 0x00)), + (251, (0x65, 0x65, 0x65)), + (252, (0x66, 0x66, 0x66)), + (253, (0x99, 0x99, 0x99)), + (254, (0xCC, 0xCC, 0xCC)), + (255, (0xFF, 0xFF, 0xFF)), +]; + +/// ACI color index from hex string: nearest color in the full 255-entry +/// ACI palette (unparseable input falls back to 7, white). pub fn hex_to_aci(hex: &str) -> u8 { - match hex.to_uppercase().trim_start_matches('#') { - "FF0000" => 1, // red - "FFFF00" => 2, // yellow - "00FF00" => 3, // green - "00FFFF" => 4, // cyan - "0000FF" => 5, // blue - "FF00FF" => 6, // magenta - "FFFFFF" => 7, // white - "808080" => 8, // dark grey - "C0C0C0" => 9, // light grey - _ => 7, // default white + let hex = hex.trim_start_matches('#'); + let Ok(rgb) = u32::from_str_radix(hex, 16) else { + return 7; + }; + if hex.len() != 6 { + return 7; } + let (r, g, b) = ( + (rgb >> 16) as i32, + ((rgb >> 8) & 0xFF) as i32, + (rgb & 0xFF) as i32, + ); + ACI_PALETTE + .iter() + .min_by_key(|(_, (pr, pg, pb))| { + let (dr, dg, db) = (r - *pr as i32, g - *pg as i32, b - *pb as i32); + dr * dr + dg * dg + db * db + }) + .map(|(index, _)| *index) + .unwrap_or(7) +} + +/// Hex color from an ACI color index (inverse of `hex_to_aci`). Indices +/// outside 1-255 (e.g. 0, BYBLOCK) fall back to white. +pub fn aci_to_hex(index: u8) -> String { + ACI_PALETTE + .iter() + .find(|(i, _)| *i == index) + .map(|(_, (r, g, b))| format!("#{r:02X}{g:02X}{b:02X}")) + .unwrap_or_else(|| "#FFFFFF".to_string()) } /// Convert hex color string to 24-bit integer for DXF true color. @@ -36,7 +316,40 @@ mod tests { assert_eq!(hex_to_aci("#FF0000"), 1); assert_eq!(hex_to_aci("#00FF00"), 3); assert_eq!(hex_to_aci("#FFFFFF"), 7); - assert_eq!(hex_to_aci("#123456"), 7); // unknown → white + } + + #[test] + fn hex_to_aci_maps_arbitrary_colors_to_nearest() { + assert_eq!(hex_to_aci("#FF4444"), 20); // reddish → nearest ACI red-orange shade + assert_eq!(hex_to_aci("#00CC44"), 112); // greenish → nearest ACI green-cyan shade + assert_eq!(hex_to_aci("#2244CC"), 152); // bluish → nearest ACI blue shade + assert_eq!(hex_to_aci("#123456"), 146); // dark muted → nearest ACI dark blue shade + assert_eq!(hex_to_aci("#invalid"), 7); // unparseable → white + assert_eq!(hex_to_aci("#FFF"), 7); // wrong length → white + } + + #[test] + fn aci_roundtrips_standard_palette() { + for index in 1..=9u8 { + assert_eq!(hex_to_aci(&aci_to_hex(index)), index); + } + assert_eq!(aci_to_hex(0), "#FFFFFF"); // out of range (e.g. BYBLOCK) → white + } + + #[test] + fn aci_roundtrips_full_palette_by_color() { + // Some ACI indices share the exact same RGB value (e.g. several dark + // greys), so hex_to_aci may not return the original index — but it + // must always land on an index with the identical color. + for index in 1..=255u8 { + let hex = aci_to_hex(index); + let roundtripped = hex_to_aci(&hex); + assert_eq!( + aci_to_hex(roundtripped), + hex, + "index {index} ({hex}) drifted to a different color on roundtrip" + ); + } } #[test] diff --git a/src/compiler.rs b/src/compiler.rs index 359635c..97b1a9a 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -4,8 +4,10 @@ use crate::color::{hex_to_24bit, hex_to_aci, weight_to_dxf}; use crate::dxf_writer::{DxfWriter, EntityStyle}; use crate::model::{CfFile, CommonAttrs, LineStyle}; use crate::parser::{parse_cf, parse_project, LayerEntry, ProjectFile}; +use crate::transform::expand_cf; use anyhow::{bail, Context, Result}; use indexmap::IndexMap; +use serde::Serialize; use std::collections::HashSet; use std::path::Path; @@ -81,7 +83,7 @@ fn load_layers( for (name, entry) in layers { let cf_path = project_dir.join(&entry.file); let cf = parse_cf(&cf_path).with_context(|| format!("Failed to parse layer '{}'", name))?; - loaded.insert(name.clone(), cf); + loaded.insert(name.clone(), expand_cf(&cf)); } Ok(loaded) } @@ -331,11 +333,15 @@ pub fn compile_project( let project = parse_project(&project_dir.join("project.toml"))?; let mut writer = DxfWriter::new(); + let loaded_layers = load_layers(project_dir, &project.layers)?; for name in project.layers.keys() { - writer.add_layer(name, 7); + let color = loaded_layers + .get(name) + .and_then(|cf| cf.layer_meta.as_ref()) + .and_then(|meta| meta.color.as_deref()) + .unwrap_or("#FFFFFF"); + writer.add_layer(name, hex_to_aci(color)); } - - let loaded_layers = load_layers(project_dir, &project.layers)?; let issues = validate_constraints(&project, &loaded_layers); let strict = is_strict(&project); if !issues.is_empty() { @@ -367,14 +373,14 @@ pub fn compile_project( .unwrap_or_else(|| project_dir.join("output.dxf")); writer.save(&output_path)?; - println!("✓ DXF generado: {}", output_path.display()); + println!("✓ DXF generated: {}", output_path.display()); println!( - " {} entidades en {} capas", + " {} entities in {} layers", total_entities, layer_stats.len() ); for (name, count) in &layer_stats { - println!(" {}: {} entidades", name, count); + println!(" {}: {} entities", name, count); } Ok(()) } @@ -444,7 +450,7 @@ pub fn list_layers(project_dir: &Path) -> Result<()> { for (name, entry) in &project.layers { let cf_path = project_dir.join(&entry.file); let (status, color) = if cf_path.exists() { - let cf = parse_cf(&cf_path)?; + let cf = expand_cf(&parse_cf(&cf_path)?); let count = entity_count(&cf); let col = cf .layer_meta @@ -464,6 +470,77 @@ pub fn list_layers(project_dir: &Path) -> Result<()> { Ok(()) } +// ── Machine-readable report (for AI agents / tooling) ────────────────── + +#[derive(Serialize)] +pub struct LayerReport { + pub name: String, + pub file: String, + pub entities: Option, + pub color: Option, + pub locked: bool, + pub missing: bool, +} + +#[derive(Serialize)] +pub struct ProjectReport { + pub name: String, + pub scale: String, + pub units: String, + pub strict: bool, + pub total_entities: usize, + pub layers: Vec, + pub issues: Vec, +} + +/// Build a structured validation report of the project (used by `--json` flags). +pub fn project_report(project_dir: &Path) -> Result { + let project = parse_project(&project_dir.join("project.toml"))?; + let mut loaded: IndexMap = IndexMap::new(); + let mut layers = Vec::with_capacity(project.layers.len()); + let mut total = 0usize; + + for (name, entry) in &project.layers { + let cf_path = project_dir.join(&entry.file); + if cf_path.exists() { + let cf = expand_cf( + &parse_cf(&cf_path).with_context(|| format!("Failed to parse layer '{}'", name))?, + ); + let count = entity_count(&cf); + total += count; + layers.push(LayerReport { + name: name.clone(), + file: entry.file.clone(), + entities: Some(count), + color: cf.layer_meta.as_ref().and_then(|m| m.color.clone()), + locked: entry.locked, + missing: false, + }); + loaded.insert(name.clone(), cf); + } else { + layers.push(LayerReport { + name: name.clone(), + file: entry.file.clone(), + entities: None, + color: None, + locked: entry.locked, + missing: true, + }); + } + } + + let issues = validate_constraints(&project, &loaded); + Ok(ProjectReport { + name: project.project.name.clone(), + scale: project.project.scale.clone(), + units: project.project.units.clone(), + strict: is_strict(&project), + total_entities: total, + layers, + issues, + }) +} + // ── Internal ──────────────────────────────────────────────────────────── fn entity_count(cf: &CfFile) -> usize { @@ -559,6 +636,7 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { e.position[1], e.size, &e.content, + e.rotation.unwrap_or(0.0), resolve_layer(&e.common, default_layer), &style, ); @@ -576,12 +654,19 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { for e in &cf.dims { let style = resolve_style(&e.common); + let dist = ((e.to[0] - e.from[0]).powi(2) + (e.to[1] - e.from[1]).powi(2)).sqrt(); + let label = + crate::svg::format_dim_label(dist, e.precision.unwrap_or(2) as usize, e.show_units, "") + .trim_end() + .to_string(); writer.dim_linear( e.from[0], e.from[1], e.to[0], e.to[1], e.offset, + &label, + e.text_size.unwrap_or(0.25), resolve_layer(&e.common, default_layer), &style, ); @@ -593,8 +678,27 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { let style = resolve_style(&e.common); let spacing = 0.1 * e.scale; // base spacing scaled - if let Some(boundary) = resolve_boundary(&e.boundary, cf) { - writer.hatch(&boundary, e.angle, spacing, layer, &style); + let boundary = if let Some(ref boundary_id) = e.boundary { + let resolved = resolve_boundary(boundary_id, cf); + if resolved.is_none() { + warn_unresolved_boundary( + "hatch", + e.common.id.as_deref(), + boundary_id, + default_layer, + ); + } + resolved + } else { + e.points + .as_ref() + .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + }; + + if let Some(boundary) = boundary { + writer.hatch( + &boundary, e.angle, spacing, e.scale, &e.pattern, layer, &style, + ); } } @@ -604,7 +708,16 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { let style = resolve_style(&e.common); let pts = if let Some(ref boundary_id) = e.boundary { - resolve_boundary(boundary_id, cf) + let resolved = resolve_boundary(boundary_id, cf); + if resolved.is_none() { + warn_unresolved_boundary( + "fill", + e.common.id.as_deref(), + boundary_id, + default_layer, + ); + } + resolved } else { e.points .as_ref() @@ -617,6 +730,26 @@ fn compile_cf(writer: &mut DxfWriter, cf: &CfFile, default_layer: &str) { } } +/// Warn (without failing the build) when a hatch/fill references a boundary id +/// that does not resolve to any closed polyline or rect in the same layer file. +/// Boundaries are resolved per layer file; a reference to an id defined in a +/// different layer will not resolve and the region is skipped. Shared with +/// `svg.rs` so preview/SVG rendering warns on the same condition as `build`. +pub(crate) fn warn_unresolved_boundary( + kind: &str, + entity_id: Option<&str>, + boundary: &str, + layer: &str, +) { + let who = entity_id + .map(|id| format!("'{}'", id)) + .unwrap_or_else(|| "".to_string()); + eprintln!( + "warning: {kind} {who} in layer '{layer}' references boundary '{boundary}', \ + which is not a closed polyline or rect in this layer — region skipped" + ); +} + /// Resolve a boundary id to a list of (x,y) points from polylines or rects in the file. pub fn resolve_boundary(id: &str, cf: &CfFile) -> Option> { // Search polylines diff --git a/src/config.rs b/src/config.rs index dca785c..d9cdd14 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,18 +1,18 @@ -//! Global configuration for cadforge CLI defaults. +//! Global configuration for cadspec CLI defaults. use anyhow::{anyhow, Context, Result}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use toml_edit::{value, DocumentMut, Item, Table}; const SUPPORTED_KEYS: &[&str] = &["author", "units"]; fn config_path() -> Result { let home = std::env::var("HOME").context("HOME environment variable is not set")?; - Ok(PathBuf::from(home).join(".cadforge").join("config.toml")) + Ok(PathBuf::from(home).join(".cadspec").join("config.toml")) } -fn load_document(path: &PathBuf) -> Result { +fn load_document(path: &Path) -> Result { if !path.exists() { return Ok(DocumentMut::new()); } @@ -34,6 +34,10 @@ fn ensure_defaults_table(doc: &mut DocumentMut) -> Result<&mut Table> { } pub fn config_set(key: &str, val: &str) -> Result<()> { + config_set_at(&config_path()?, key, val) +} + +fn config_set_at(path: &Path, key: &str, val: &str) -> Result<()> { if !SUPPORTED_KEYS.contains(&key) { let options = SUPPORTED_KEYS.join(", "); return Err(anyhow!( @@ -43,26 +47,27 @@ pub fn config_set(key: &str, val: &str) -> Result<()> { )); } - let path = config_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("Cannot create {}", parent.display()))?; } - let mut doc = load_document(&path)?; + let mut doc = load_document(path)?; let defaults = ensure_defaults_table(&mut doc)?; defaults[key] = value(val); - fs::write(&path, doc.to_string()) - .with_context(|| format!("Cannot write {}", path.display()))?; + fs::write(path, doc.to_string()).with_context(|| format!("Cannot write {}", path.display()))?; println!("✓ config {} = {}", key, val); println!(" {}", path.display()); Ok(()) } pub fn config_show() -> Result<()> { - let path = config_path()?; - let doc = load_document(&path)?; + config_show_at(&config_path()?) +} + +fn config_show_at(path: &Path) -> Result<()> { + let doc = load_document(path)?; println!("Global config: {}", path.display()); if let Some(defaults) = doc.get("defaults").and_then(Item::as_table) { for key in SUPPORTED_KEYS { @@ -73,3 +78,65 @@ pub fn config_show() -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_path(name: &str) -> PathBuf { + let dir = PathBuf::from("/tmp").join(name); + let _ = fs::remove_dir_all(&dir); + dir.join("config.toml") + } + + #[test] + fn config_set_rejects_unsupported_key() { + let path = scratch_path("cadspec_test_config_bad_key"); + + let err = config_set_at(&path, "bogus", "x").unwrap_err(); + + assert!(err.to_string().contains("Unsupported config key 'bogus'")); + assert!(!path.exists()); + } + + #[test] + fn config_set_creates_parent_dir_and_writes_value() { + let path = scratch_path("cadspec_test_config_new"); + + config_set_at(&path, "author", "Jheison").unwrap(); + + let doc = load_document(&path).unwrap(); + assert_eq!( + doc["defaults"]["author"].as_str(), + Some("Jheison"), + "config file: {}", + fs::read_to_string(&path).unwrap() + ); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn config_set_preserves_other_keys_and_overwrites_target() { + let path = scratch_path("cadspec_test_config_overwrite"); + + config_set_at(&path, "author", "Jheison").unwrap(); + config_set_at(&path, "units", "mm").unwrap(); + config_set_at(&path, "author", "Someone Else").unwrap(); + + let doc = load_document(&path).unwrap(); + assert_eq!(doc["defaults"]["author"].as_str(), Some("Someone Else")); + assert_eq!(doc["defaults"]["units"].as_str(), Some("mm")); + + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn config_show_on_missing_file_does_not_error() { + let path = scratch_path("cadspec_test_config_missing"); + + config_show_at(&path).unwrap(); + + assert!(!path.exists()); + } +} diff --git a/src/dxf_writer.rs b/src/dxf_writer.rs index 2d7d5dc..9ca919b 100644 --- a/src/dxf_writer.rs +++ b/src/dxf_writer.rs @@ -3,10 +3,16 @@ use anyhow::Result; use dxf::entities::{Entity, EntityType, LwPolyline}; use dxf::enums::AcadVersion; -use dxf::tables::Layer; -use dxf::{Color, Drawing, LwPolylineVertex, Point}; +use dxf::tables::{AppId, Layer}; +use dxf::{Color, Drawing, LwPolylineVertex, Point, XData, XDataItem}; use std::path::Path; +/// XDATA application name stamped on the pattern lines a hatch expands into, so +/// the DXF importer can recognize and re-fuse them back into a single `[[hatch]]` +/// (instead of dozens of stray `[[line]]`s). Only lines this tool emits carry +/// the tag, so import never mistakes a foreign DXF's lines for a hatch. +pub const HATCH_XDATA_APP: &str = "CADSPEC_HATCH"; + /// Optional visual attributes applied to any entity. #[derive(Default, Clone)] pub struct EntityStyle { @@ -24,6 +30,9 @@ impl EntityStyle { /// Builder for constructing a DXF drawing from primitives. pub struct DxfWriter { drawing: Drawing, + /// Monotonic id assigned to each hatch so its expanded pattern lines can be + /// grouped back together on import. + hatch_group_seq: i32, } impl DxfWriter { @@ -36,7 +45,17 @@ impl DxfWriter { drawing.add_line_type(Self::make_line_type("DOTTED", &[0.0, -0.25])); drawing.add_line_type(Self::make_line_type("DASHDOT", &[0.5, -0.25, 0.0, -0.25])); - Self { drawing } + // Register the APPID our hatch pattern lines stamp XDATA under, so + // strict readers don't have to fall back on undeclared-app tolerance. + drawing.add_app_id(AppId { + name: HATCH_XDATA_APP.to_string(), + ..Default::default() + }); + + Self { + drawing, + hatch_group_seq: 0, + } } fn make_line_type(name: &str, pattern: &[f64]) -> dxf::tables::LineType { @@ -51,7 +70,13 @@ impl DxfWriter { } /// Add a named layer with an ACI color index (1-255). + /// Re-adding an existing layer updates its color instead of duplicating + /// the LAYER table record. pub fn add_layer(&mut self, name: &str, color_index: u8) { + if let Some(existing) = self.drawing.layers_mut().find(|l| l.name == name) { + existing.color = Color::from_index(color_index); + return; + } let layer = Layer { name: name.to_string(), color: Color::from_index(color_index), @@ -62,7 +87,8 @@ impl DxfWriter { // ── Single entry point for adding entities ───────────────────────── - fn add_entity(&mut self, entity_type: EntityType, layer: &str, style: &EntityStyle) { + /// Build a styled entity (layer + color/weight/line-type) without adding it. + fn styled_entity(entity_type: EntityType, layer: &str, style: &EntityStyle) -> Entity { let mut entity = Entity::new(entity_type); entity.common.layer = layer.to_string(); if let Some(c) = style.color_24bit { @@ -74,7 +100,12 @@ impl DxfWriter { if let Some(lt) = &style.line_type { entity.common.line_type_name = lt.clone(); } - self.drawing.add_entity(entity); + entity + } + + fn add_entity(&mut self, entity_type: EntityType, layer: &str, style: &EntityStyle) { + self.drawing + .add_entity(Self::styled_entity(entity_type, layer, style)); } // ── Public primitive methods ─────────────────────────────────────── @@ -153,12 +184,14 @@ impl DxfWriter { self.polyline(&points, true, layer, style); } + #[allow(clippy::too_many_arguments)] pub fn text( &mut self, x: f64, y: f64, height: f64, content: &str, + rotation: f64, layer: &str, style: &EntityStyle, ) { @@ -166,6 +199,7 @@ impl DxfWriter { location: Point::new(x, y, 0.0), text_height: height, value: content.to_string(), + rotation, ..Default::default() }; self.add_entity(EntityType::Text(text), layer, style); @@ -187,31 +221,48 @@ impl DxfWriter { x2: f64, y2: f64, offset: f64, + label: &str, + text_height: f64, layer: &str, style: &EntityStyle, ) { + let dx = x2 - x1; + let dy = y2 - y1; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-9 { + return; + } + // Offset along the normal, matching the preview renderer + let (nx, ny) = (-dy / len, dx / len); + let (ax, ay) = (x1 + nx * offset, y1 + ny * offset); + let (bx, by) = (x2 + nx * offset, y2 + ny * offset); + let dim = dxf::entities::RotatedDimension { definition_point_2: Point::new(x1, y1, 0.0), definition_point_3: Point::new(x2, y2, 0.0), - insertion_point: Point::new((x1 + x2) / 2.0, y1 + offset, 0.0), + insertion_point: Point::new((ax + bx) / 2.0, (ay + by) / 2.0, 0.0), + rotation_angle: dy.atan2(dx).to_degrees(), ..Default::default() }; self.add_entity(EntityType::RotatedDimension(dim), layer, style); // Also emit dimension lines and text as explicit entities for compatibility - let dist = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt(); - let text_val = format!("{:.2}", dist); - let mid_x = (x1 + x2) / 2.0; - let mid_y = (y1 + y2) / 2.0 + offset; - // Extension lines - self.line(x1, y1, x1, y1 + offset, layer, style); - self.line(x2, y2, x2, y2 + offset, layer, style); + self.line(x1, y1, ax, ay, layer, style); + self.line(x2, y2, bx, by, layer, style); // Dimension line - self.line(x1, y1 + offset, x2, y2 + offset, layer, style); - // Dimension text + self.line(ax, ay, bx, by, layer, style); + // Dimension text, raised half its height off the dimension line let text_style = EntityStyle::default(); - self.text(mid_x, mid_y + 0.05, 0.1, &text_val, layer, &text_style); + self.text( + (ax + bx) / 2.0 + nx * text_height * 0.5, + (ay + by) / 2.0 + ny * text_height * 0.5, + text_height, + label, + 0.0, + layer, + &text_style, + ); } /// Save the drawing to a DXF file. @@ -241,14 +292,21 @@ impl DxfWriter { } } - /// Generate hatch pattern lines within a rectangular boundary. + /// Generate hatch pattern lines within a boundary polygon. /// `boundary` is a list of (x,y) points forming a closed polygon. /// `angle` is in degrees, `spacing` is distance between lines. + /// + /// `scale` and `pattern` are the source `[[hatch]]` values, carried on each + /// generated line as XDATA (alongside the boundary and a group id) so the + /// importer can re-fuse the lines into one `[[hatch]]` on a DXF round-trip. + #[allow(clippy::too_many_arguments)] pub fn hatch( &mut self, boundary: &[(f64, f64)], angle: f64, spacing: f64, + scale: f64, + pattern: &str, layer: &str, style: &EntityStyle, ) { @@ -256,6 +314,24 @@ impl DxfWriter { return; } + // XDATA stamped on every pattern line of this hatch: group id, source + // angle/scale/pattern, then the boundary polygon vertices. All lines of + // one hatch share the same payload, so any of them can rebuild it. + self.hatch_group_seq += 1; + let mut items = vec![ + XDataItem::Long(self.hatch_group_seq), + XDataItem::Real(angle), + XDataItem::Real(scale), + XDataItem::Str(pattern.to_string()), + ]; + for &(x, y) in boundary { + items.push(XDataItem::WorldSpacePosition(Point::new(x, y, 0.0))); + } + let xdata = XData { + application_name: HATCH_XDATA_APP.to_string(), + items, + }; + // Compute bounding box let (min_x, max_x, min_y, max_y) = bounding_box(boundary); @@ -284,7 +360,11 @@ impl DxfWriter { // Clip line to boundary polygon if let Some((cx1, cy1, cx2, cy2)) = clip_line_to_polygon(x1, y1, x2, y2, boundary) { - self.line(cx1, cy1, cx2, cy2, layer, style); + let line = + dxf::entities::Line::new(Point::new(cx1, cy1, 0.0), Point::new(cx2, cy2, 0.0)); + let mut entity = Self::styled_entity(EntityType::Line(line), layer, style); + entity.common.x_data.push(xdata.clone()); + self.drawing.add_entity(entity); } } } @@ -401,7 +481,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()); } @@ -417,7 +497,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 70197f3..9bac02c 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -1,57 +1,282 @@ -//! Formatter — normalizes .cf files (sort keys, consistent spacing). +//! Formatter — normalizes `.cf` and `project.toml` files, analogous to +//! `terraform fmt`: canonical `key = value` spacing, exactly one blank line +//! between blocks, tidy arrays and inline tables. Comments are preserved. +//! Files that fail to parse — or whose formatted output would not reparse to +//! the same values — are left untouched. use crate::parser::parse_project; use anyhow::Result; -use std::path::Path; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use toml_edit::{Array, Decor, DocumentMut, InlineTable, Item, KeyMut, RawString, Table, Value}; -/// Format all .cf files in a project. +/// Format `project.toml` and every `.cf` file in a project. pub fn format_project(project_dir: &Path, check_only: bool) -> Result<()> { let project = parse_project(&project_dir.join("project.toml"))?; - let mut changed = 0; - for (_name, entry) in &project.layers { - let cf_path = project_dir.join(&entry.file); - if !cf_path.exists() { - continue; + let mut files: BTreeSet = BTreeSet::new(); + files.insert(project_dir.join("project.toml")); + for entry in project.layers.values() { + files.insert(project_dir.join(&entry.file)); + } + if let Ok(dir) = std::fs::read_dir(project_dir) { + for entry in dir.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "cf") { + files.insert(path); + } } + } - let original = std::fs::read_to_string(&cf_path)?; - let formatted = format_cf(&original); - - if original != formatted { - if check_only { - println!("✗ {} — needs formatting", entry.file); - changed += 1; - } else { - std::fs::write(&cf_path, &formatted)?; - println!("✓ {} — formatted", entry.file); - changed += 1; - } + let mut changed = 0; + for path in files { + if !path.exists() { + continue; + } + let display = path + .strip_prefix(project_dir) + .unwrap_or(&path) + .display() + .to_string(); + let original = std::fs::read_to_string(&path)?; + let formatted = format_source(&original); + if original == formatted { + println!(" {display} — ok"); + } else if check_only { + println!("✗ {display} — needs formatting"); + changed += 1; } else { - println!(" {} — ok", entry.file); + std::fs::write(&path, &formatted)?; + println!("✓ {display} — formatted"); + changed += 1; } } if check_only && changed > 0 { - anyhow::bail!( - "{} file(s) need formatting. Run `cadforge fmt` to fix.", - changed - ); + anyhow::bail!("{changed} file(s) need formatting. Run `cadspec fmt` to fix."); } - if !check_only { - println!("✓ {} file(s) formatted", changed); + println!("✓ {changed} file(s) formatted"); } Ok(()) } -/// Format a single .cf file content. -fn format_cf(content: &str) -> String { - let doc: toml_edit::DocumentMut = match content.parse() { - Ok(d) => d, - Err(_) => return content.to_string(), +/// Format TOML source, guaranteeing the result parses to the same values. +fn format_source(content: &str) -> String { + let formatted = format_toml(content); + let before: Result = toml::from_str(content); + let after: Result = toml::from_str(&formatted); + match (before, after) { + (Ok(b), Ok(a)) if b == a => formatted, + _ => content.to_string(), + } +} + +fn format_toml(content: &str) -> String { + let Ok(mut doc) = content.parse::() else { + return content.to_string(); }; - doc.to_string() + + let mut first = true; + for (mut key, item) in doc.as_table_mut().iter_mut() { + match item { + Item::Table(table) => { + normalize_header(table.decor_mut(), first); + normalize_table(table); + } + Item::ArrayOfTables(tables) => { + for table in tables.iter_mut() { + normalize_header(table.decor_mut(), first); + normalize_table(table); + first = false; + } + } + Item::Value(value) => { + normalize_key(&mut key); + normalize_value(value); + } + Item::None => {} + } + first = false; + } + + let trailing = comments_only(&raw_text(Some(doc.trailing())), true); + doc.set_trailing(trailing); + + let out = doc.to_string(); + let trimmed = out.trim_end_matches('\n'); + if trimmed.is_empty() { + out + } else { + format!("{trimmed}\n") + } +} + +fn normalize_table(table: &mut Table) { + for (mut key, item) in table.iter_mut() { + match item { + Item::Value(value) => { + normalize_key(&mut key); + normalize_value(value); + } + Item::Table(child) => { + if child.is_dotted() { + // dotted key (e.g. `cotas.parent = "muros"`): only touch values + normalize_dotted(child); + } else { + normalize_header(child.decor_mut(), false); + normalize_table(child); + } + } + Item::ArrayOfTables(tables) => { + for child in tables.iter_mut() { + normalize_header(child.decor_mut(), false); + normalize_table(child); + } + } + Item::None => {} + } + } +} + +fn normalize_dotted(table: &mut Table) { + for (_, item) in table.iter_mut() { + match item { + Item::Value(value) => normalize_value(value), + Item::Table(child) if child.is_dotted() => normalize_dotted(child), + _ => {} + } + } +} + +/// `[header]` / `[[header]]` prefix: one blank line between blocks (none for +/// the first), preceding comments preserved one per line. +fn normalize_header(decor: &mut Decor, first: bool) { + let prefix = comments_only(&raw_text(decor.prefix()), !first); + let suffix = trailing_comment(decor.suffix()); + decor.set_prefix(prefix); + decor.set_suffix(suffix); +} + +/// Key of a `key = value` pair: comments kept, a single leading blank line +/// kept if the author separated the pair from the previous one. +fn normalize_key(key: &mut KeyMut) { + let raw = raw_text(key.leaf_decor().prefix()); + let blank = raw.split('#').next().unwrap_or("").contains('\n'); + let prefix = comments_only(&raw, blank); + let decor = key.leaf_decor_mut(); + decor.set_prefix(prefix); + decor.set_suffix(" "); +} + +fn normalize_value(value: &mut Value) { + let suffix = trailing_comment(value.decor().suffix()); + match value { + Value::Array(array) => { + normalize_array(array); + } + Value::InlineTable(table) => { + normalize_inline_table(table); + } + _ => {} + } + let decor = value.decor_mut(); + decor.set_prefix(" "); + decor.set_suffix(suffix); +} + +/// Arrays keep the author's single-line vs multi-line choice; multi-line +/// arrays get one element per line, four-space indent and a trailing comma. +/// Arrays containing comments are left untouched. +fn normalize_array(array: &mut Array) { + if array_has_comment(array) { + return; + } + let multiline = raw_text(Some(array.trailing())).contains('\n') + || array.iter().any(|v| { + raw_text(v.decor().prefix()).contains('\n') + || raw_text(v.decor().suffix()).contains('\n') + }); + + if multiline { + for value in array.iter_mut() { + if let Value::Array(inner) = value { + normalize_inline_array(inner); + } + let decor = value.decor_mut(); + decor.set_prefix("\n "); + decor.set_suffix(""); + } + array.set_trailing("\n"); + array.set_trailing_comma(true); + } else { + normalize_inline_array(array); + } +} + +fn normalize_inline_array(array: &mut Array) { + if array_has_comment(array) { + return; + } + let mut first = true; + for value in array.iter_mut() { + if let Value::Array(inner) = value { + normalize_inline_array(inner); + } + let decor = value.decor_mut(); + decor.set_prefix(if first { "" } else { " " }); + decor.set_suffix(""); + first = false; + } + array.set_trailing(""); + array.set_trailing_comma(false); +} + +fn normalize_inline_table(table: &mut InlineTable) { + let len = table.len(); + for (i, (mut key, value)) in table.iter_mut().enumerate() { + let decor = key.leaf_decor_mut(); + decor.set_prefix(" "); + decor.set_suffix(" "); + let decor = value.decor_mut(); + decor.set_prefix(" "); + decor.set_suffix(if i + 1 == len { " " } else { "" }); + } +} + +fn array_has_comment(array: &Array) -> bool { + raw_text(Some(array.trailing())).contains('#') + || array.iter().any(|v| { + raw_text(v.decor().prefix()).contains('#') || raw_text(v.decor().suffix()).contains('#') + }) +} + +/// Extract `#` comment lines from raw decor text, optionally preceded by one +/// blank line; everything else (stray whitespace, extra blanks) is dropped. +fn comments_only(raw: &str, leading_blank: bool) -> String { + let mut out = String::new(); + if leading_blank { + out.push('\n'); + } + for line in raw.lines().map(str::trim).filter(|l| l.starts_with('#')) { + out.push_str(line); + out.push('\n'); + } + out +} + +/// Keep a same-line trailing comment (` # like this`), drop plain whitespace. +fn trailing_comment(raw: Option<&RawString>) -> String { + let text = raw_text(raw); + if text.contains('#') { + format!(" {}", text.trim()) + } else { + String::new() + } +} + +fn raw_text(raw: Option<&RawString>) -> String { + raw.and_then(|r| r.as_str()).unwrap_or("").to_string() } #[cfg(test)] @@ -59,17 +284,90 @@ mod tests { use super::*; #[test] - fn format_preserves_valid_toml() { - let input = "[layer]\nname = \"test\"\ncolor = \"#FFFFFF\"\n\n[[line]]\nid = \"ln-001\"\nfrom = [0.0, 0.0]\nto = [10.0, 0.0]\n"; - let output = format_cf(input); - assert!(output.contains("[layer]")); - assert!(output.contains("[[line]]")); + fn normalizes_spacing_and_blank_lines() { + let input = "[layer]\nname=\"test\"\ncolor = \"#FFFFFF\"\n\n\n\n[[line]]\nid=\"ln-001\"\nfrom=[0.0,0.0]\nto = [ 10.0 , 0.0 ]\n[[line]]\nid = \"ln-002\"\nfrom = [0.0, 1.0]\nto = [10.0, 1.0]\n"; + let expected = "[layer]\nname = \"test\"\ncolor = \"#FFFFFF\"\n\n[[line]]\nid = \"ln-001\"\nfrom = [0.0, 0.0]\nto = [10.0, 0.0]\n\n[[line]]\nid = \"ln-002\"\nfrom = [0.0, 1.0]\nto = [10.0, 1.0]\n"; + assert_eq!(format_source(input), expected); } #[test] - fn format_returns_original_on_parse_error() { + fn preserves_comments() { + let input = "[layer]\nname = \"muros\"\n\n# Perímetro exterior\n[[polyline]]\nid = \"pl-001\" # principal\npoints = [[0.0, 0.0], [5.0, 0.0]]\n"; + let output = format_source(input); + assert!(output.contains("# Perímetro exterior\n[[polyline]]")); + assert!(output.contains("id = \"pl-001\" # principal")); + } + + #[test] + fn normalizes_multiline_point_arrays() { + let input = "[[polyline]]\nid = \"pl-001\"\npoints = [\n [0.30,0.0],\n [1.55, 0.0],\n [1.456, 0.530]\n]\nclosed = true\n"; + let expected = "[[polyline]]\nid = \"pl-001\"\npoints = [\n [0.30, 0.0],\n [1.55, 0.0],\n [1.456, 0.530],\n]\nclosed = true\n"; + assert_eq!(format_source(input), expected); + } + + #[test] + fn normalizes_inline_tables() { + let input = + "[project]\nname = \"x\"\n\n[layers]\nmuros = {file=\"muros.cf\",locked=false}\n"; + let output = format_source(input); + assert!(output.contains("muros = { file = \"muros.cf\", locked = false }")); + } + + #[test] + fn keeps_dotted_constraint_keys_intact() { + let input = "[constraints]\ncotas.parent = \"muros\"\ncotas.belongs_to = \"muros\"\n"; + let output = format_source(input); + assert!(output.contains("cotas.parent = \"muros\"")); + assert!(output.contains("cotas.belongs_to = \"muros\"")); + } + + #[test] + fn keeps_blank_line_groups_inside_blocks() { + let input = "[layer]\nname = \"x\"\n\n\ncolor = \"#FFFFFF\"\n"; + let output = format_source(input); + assert!(output.contains("name = \"x\"\n\ncolor")); + } + + #[test] + fn is_idempotent_and_meaning_preserving() { + for path in [ + concat!(env!("CARGO_MANIFEST_DIR"), "/examples/taller/escalera.cf"), + concat!(env!("CARGO_MANIFEST_DIR"), "/examples/taller/planta.cf"), + concat!(env!("CARGO_MANIFEST_DIR"), "/examples/taller/cotas.cf"), + concat!(env!("CARGO_MANIFEST_DIR"), "/examples/taller/project.toml"), + ] { + let original = std::fs::read_to_string(path).unwrap(); + let once = format_source(&original); + let twice = format_source(&once); + assert_eq!(once, twice, "fmt not idempotent for {path}"); + let before: toml::Value = toml::from_str(&original).unwrap(); + let after: toml::Value = toml::from_str(&once).unwrap(); + assert_eq!(before, after, "fmt changed meaning of {path}"); + } + } + + #[test] + fn returns_original_on_parse_error() { let input = "invalid [[[ toml"; - let output = format_cf(input); - assert_eq!(output, input); + assert_eq!(format_source(input), input); + } + + #[test] + fn preserves_text_font_rotation_bold_italic() { + let input = "[layer]\nname = \"x\"\n\n[[text]]\nid = \"tx-1\"\nposition = [1.0, 2.0]\ncontent = \"hi\"\nsize = 0.3\nfont = \"serif\"\nrotation = 30.0\nbold = true\nitalic = true\n"; + let once = format_source(input); + let twice = format_source(&once); + assert_eq!(once, twice, "fmt not idempotent for text styling fields"); + for needle in [ + "font = \"serif\"", + "rotation = 30.0", + "bold = true", + "italic = true", + ] { + assert!( + once.contains(needle), + "expected {needle:?} to survive fmt, got:\n{once}" + ); + } } } diff --git a/src/gltf.rs b/src/gltf.rs new file mode 100644 index 0000000..7d365cf --- /dev/null +++ b/src/gltf.rs @@ -0,0 +1,224 @@ +//! Minimal glTF 2.0 exporter. +//! +//! Turns the scene's solid meshes (from [`crate::render3d::scene_meshes`]) into a +//! single self-contained `.gltf`: JSON with the binary buffer base64-embedded as +//! a data URI. Pure Rust, deterministic, no extra dependencies. +//! +//! cadspec is Z-up (height is +Z); glTF is Y-up and right-handed, so every +//! position and normal is remapped `(x, y, z) → (x, z, -y)` (a proper rotation). +//! Triangles are flat-shaded (a per-face normal on each of its three vertices) +//! and materials are double-sided, since boolean output can have mixed winding. + +use crate::mesh::Mesh; + +type V3 = [f64; 3]; + +fn sub(a: V3, b: V3) -> V3 { + [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 norm(a: V3) -> V3 { + let l = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt(); + if l < 1e-12 { + [0.0, 0.0, 1.0] + } else { + [a[0] / l, a[1] / l, a[2] / l] + } +} +/// Z-up (cadspec) → Y-up (glTF). +fn to_gltf(p: V3) -> [f32; 3] { + [p[0] as f32, p[2] as f32, -p[1] as f32] +} + +/// Parse `#rgb` / `#rrggbb` into linear-ish `[r, g, b, 1]` floats (0..1). +fn color_rgba(hex: &str) -> [f32; 4] { + let h = hex.trim().trim_start_matches('#'); + let parse = |s: &str| u8::from_str_radix(s, 16).unwrap_or(160) as f32 / 255.0; + match h.len() { + 3 => { + let r = parse(&h[0..1].repeat(2)); + let g = parse(&h[1..2].repeat(2)); + let b = parse(&h[2..3].repeat(2)); + [r, g, b, 1.0] + } + 6 => [parse(&h[0..2]), parse(&h[2..4]), parse(&h[4..6]), 1.0], + _ => [0.63, 0.63, 0.63, 1.0], + } +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn base64(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32); + out.push(B64[((n >> 18) & 63) as usize] as char); + out.push(B64[((n >> 12) & 63) as usize] as char); + out.push(if chunk.len() > 1 { + B64[((n >> 6) & 63) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + B64[(n & 63) as usize] as char + } else { + '=' + }); + } + out +} + +/// Serialize the scene meshes to a self-contained glTF 2.0 document. +pub fn scene_to_gltf(meshes: &[(Mesh, String)]) -> String { + // Binary buffer: per mesh, all vertex positions then all normals (FLOAT VEC3). + let mut buf: Vec = Vec::new(); + // Per-mesh accessor metadata gathered while writing the buffer. + struct Acc { + pos_off: usize, + nrm_off: usize, + count: usize, + min: [f32; 3], + max: [f32; 3], + color: [f32; 4], + } + let mut accs: Vec = Vec::new(); + + for (mesh, color) in meshes { + if mesh.tris.is_empty() { + continue; + } + let pos_off = buf.len(); + let mut min = [f32::MAX; 3]; + let mut max = [f32::MIN; 3]; + // Positions. + for t in &mesh.tris { + for v in t { + let p = to_gltf(*v); + for k in 0..3 { + min[k] = min[k].min(p[k]); + max[k] = max[k].max(p[k]); + buf.extend_from_slice(&p[k].to_le_bytes()); + } + } + } + let nrm_off = buf.len(); + // Flat normals (same for the three vertices of a triangle). + for t in &mesh.tris { + let n = norm(cross(sub(t[1], t[0]), sub(t[2], t[0]))); + let ng = to_gltf(n); + for _ in 0..3 { + for c in ng { + buf.extend_from_slice(&c.to_le_bytes()); + } + } + } + accs.push(Acc { + pos_off, + nrm_off, + count: mesh.tris.len() * 3, + min, + max, + color: color_rgba(color), + }); + } + + // Assemble the JSON. + let mut buffer_views = String::new(); + let mut accessors = String::new(); + let mut materials = String::new(); + let mut gltf_meshes = String::new(); + let mut nodes = String::new(); + let mut node_idx = String::new(); + + for (i, a) in accs.iter().enumerate() { + let bv_pos = i * 2; + let bv_nrm = i * 2 + 1; + let acc_pos = i * 2; + let acc_nrm = i * 2 + 1; + let bytes = a.count * 12; + if i > 0 { + buffer_views.push(','); + accessors.push(','); + materials.push(','); + gltf_meshes.push(','); + nodes.push(','); + node_idx.push(','); + } + buffer_views.push_str(&format!( + r#"{{"buffer":0,"byteOffset":{},"byteLength":{}}},{{"buffer":0,"byteOffset":{},"byteLength":{}}}"#, + a.pos_off, bytes, a.nrm_off, bytes + )); + accessors.push_str(&format!( + r#"{{"bufferView":{bv_pos},"componentType":5126,"count":{c},"type":"VEC3","min":[{mn0},{mn1},{mn2}],"max":[{mx0},{mx1},{mx2}]}},{{"bufferView":{bv_nrm},"componentType":5126,"count":{c},"type":"VEC3"}}"#, + c = a.count, + mn0 = a.min[0], mn1 = a.min[1], mn2 = a.min[2], + mx0 = a.max[0], mx1 = a.max[1], mx2 = a.max[2], + )); + materials.push_str(&format!( + r#"{{"pbrMetallicRoughness":{{"baseColorFactor":[{r},{g},{b},{al}],"metallicFactor":0.0,"roughnessFactor":0.85}},"doubleSided":true}}"#, + r = a.color[0], g = a.color[1], b = a.color[2], al = a.color[3], + )); + gltf_meshes.push_str(&format!( + r#"{{"primitives":[{{"attributes":{{"POSITION":{acc_pos},"NORMAL":{acc_nrm}}},"material":{i}}}]}}"#, + )); + nodes.push_str(&format!(r#"{{"mesh":{i}}}"#)); + node_idx.push_str(&i.to_string()); + } + + let b64 = base64(&buf); + format!( + r#"{{"asset":{{"version":"2.0","generator":"cadspec"}},"scene":0,"scenes":[{{"nodes":[{node_idx}]}}],"nodes":[{nodes}],"meshes":[{gltf_meshes}],"materials":[{materials}],"accessors":[{accessors}],"bufferViews":[{buffer_views}],"buffers":[{{"byteLength":{len},"uri":"data:application/octet-stream;base64,{b64}"}}]}}"#, + len = buf.len(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_known_vectors() { + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Ma"), "TWE="); + assert_eq!(base64(b"M"), "TQ=="); + } + + #[test] + fn color_parses_hex() { + assert_eq!(color_rgba("#ff0000"), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(color_rgba("#000"), [0.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn single_triangle_exports_valid_gltf() { + let mesh = Mesh { + tris: vec![[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]], + }; + let doc = scene_to_gltf(&[(mesh, "#6ec6e6".to_string())]); + // One triangle = 3 vertices; positions + normals = 3 * 2 * 12 = 72 bytes. + assert!(doc.contains(r#""byteLength":72"#)); + assert!(doc.contains(r#""version":"2.0""#)); + assert!(doc.contains(r#""count":3"#)); + assert!(doc.contains(r#""POSITION""#) && doc.contains(r#""NORMAL""#)); + // Z-up→Y-up: cad y=1 becomes gltf z=-1, so the POSITION min z is -1. + assert!(doc.contains(r#""min":[0,0,-1]"#)); + } + + #[test] + fn empty_scene_is_still_valid_json() { + let doc = scene_to_gltf(&[]); + assert!(doc.contains(r#""byteLength":0"#)); + assert!(doc.contains(r#""meshes":[]"#)); + } +} diff --git a/src/importer.rs b/src/importer.rs index 88ff6a4..e96ec3c 100644 --- a/src/importer.rs +++ b/src/importer.rs @@ -1,29 +1,161 @@ -//! 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 crate::dxf_writer::HATCH_XDATA_APP; use anyhow::{anyhow, Context, Result}; use dxf::entities::EntityType; -use dxf::Drawing; +use dxf::{Drawing, XData, XDataItem}; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; -#[derive(Default)] -struct LayerFile { - entities: Vec, - counters: BTreeMap<&'static str, usize>, +/// Per-entity style recovered from DXF (true color, lineweight, line type). +#[derive(Default, Clone)] +struct StyleAttrs { + color: Option, + weight: Option, + line_style: Option<&'static str>, } -impl LayerFile { - fn next_id(&mut self, prefix: &'static str) -> String { - let n = self - .counters - .entry(prefix) - .and_modify(|v| *v += 1) - .or_insert(1); - format!("{prefix}-{n:03}") +impl StyleAttrs { + fn from_common(common: &dxf::entities::EntityCommon) -> Self { + let color = if common.color_24_bit > 0 { + Some(format!("#{:06X}", common.color_24_bit)) + } else { + common.color.index().filter(|i| *i > 0).map(aci_to_hex) + }; + let weight = (common.lineweight_enum_value > 0) + .then(|| f64::from(common.lineweight_enum_value) / 100.0); + let line_style = match common.line_type_name.to_ascii_uppercase().as_str() { + "DASHED" => Some("dashed"), + "DOTTED" => Some("dotted"), + "DASHDOT" => Some("dashdot"), + _ => None, + }; + StyleAttrs { + color, + weight, + line_style, + } + } + + fn emit(&self, out: &mut String) { + if let Some(c) = &self.color { + out.push_str(&format!("color = \"{}\"\n", c)); + } + if let Some(w) = self.weight { + out.push_str(&format!("weight = {}\n", w)); + } + if let Some(s) = self.line_style { + out.push_str(&format!("style = \"{}\"\n", s)); + } } } +enum Shape { + Line { + from: [f64; 2], + to: [f64; 2], + }, + Polyline { + points: Vec<[f64; 2]>, + closed: bool, + }, + Circle { + center: [f64; 2], + radius: f64, + }, + Arc { + center: [f64; 2], + radius: f64, + from_angle: f64, + to_angle: f64, + }, + Text { + position: [f64; 2], + content: String, + size: f64, + rotation: f64, + }, + Point { + position: [f64; 2], + }, + Dim { + from: [f64; 2], + to: [f64; 2], + offset: f64, + }, + /// A filled region. Imported from one or more SOLID entities; adjacent + /// SOLID triangles/quads on the same layer are fused into a single fill. + Fill { + points: Vec<[f64; 2]>, + }, + /// A hatched region, recovered from the pattern lines it expanded into by + /// reading the `CADSPEC_HATCH` XDATA those lines carry. + Hatch { + points: Vec<[f64; 2]>, + angle: f64, + scale: f64, + pattern: String, + }, +} + +/// Hatch parameters recovered from one pattern line's `CADSPEC_HATCH` XDATA. +struct HatchMeta { + group: i32, + angle: f64, + scale: f64, + pattern: String, + points: Vec<[f64; 2]>, +} + +/// Read the `CADSPEC_HATCH` XDATA payload (group id, angle, scale, pattern, then +/// the boundary polygon vertices) off a line entity. Returns `None` for any line +/// that this tool did not emit as part of a hatch, so foreign DXF lines are never +/// mistaken for hatch pattern. +fn parse_hatch_xdata(xdata: &[XData]) -> Option { + let x = xdata + .iter() + .find(|x| x.application_name == HATCH_XDATA_APP)?; + let mut group = None; + let mut angle = None; + let mut scale = None; + let mut pattern = None; + let mut points = Vec::new(); + for item in &x.items { + match item { + XDataItem::Long(v) if group.is_none() => group = Some(*v), + XDataItem::Real(v) if angle.is_none() => angle = Some(*v), + XDataItem::Real(v) if scale.is_none() => scale = Some(*v), + XDataItem::Str(s) if pattern.is_none() => pattern = Some(s.clone()), + XDataItem::WorldSpacePosition(p) => points.push([p.x, p.y]), + _ => {} + } + } + // A hatch needs at least a triangle of boundary to be reconstructable. + if points.len() < 3 { + return None; + } + Some(HatchMeta { + group: group?, + angle: angle?, + scale: scale?, + pattern: pattern.unwrap_or_else(|| "ansi31".to_string()), + points, + }) +} + +struct Imported { + shape: Shape, + style: StyleAttrs, +} + +#[derive(Default)] +struct LayerFile { + entities: Vec, + counters: BTreeMap<&'static str, usize>, +} + pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) -> Result<()> { if !input.exists() { return Err(anyhow!( @@ -36,10 +168,20 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - .with_context(|| format!("Cannot create output dir {}", output_dir.display()))?; let mut layers: BTreeMap = BTreeMap::new(); + let mut layer_colors: BTreeMap = BTreeMap::new(); let mut unsupported = 0usize; + // Pattern lines of the same hatch (same XDATA group id) are collected here + // and re-fused into one `[[hatch]]` after the entity pass, keyed by group id. + let mut hatch_groups: BTreeMap = BTreeMap::new(); match Drawing::load_file(input) { Ok(drawing) => { + for layer in drawing.layers() { + if let Some(index) = layer.color.index() { + layer_colors.insert(normalize_layer_name(&layer.name), aci_to_hex(index)); + } + } + for entity in drawing.entities() { let layer_name = normalize_layer_name(&entity.common.layer); if let Some(filter) = layer_filter { @@ -48,100 +190,97 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - } } - let layer = layers.entry(layer_name.clone()).or_default(); - match &entity.specific { - EntityType::Line(e) => { - let id = layer.next_id("ln"); - layer.entities.push(format!( - "[[line]]\nid = \"{}\"\nfrom = [{}, {}]\nto = [{}, {}]\n", - id, - n(e.p1.x), - n(e.p1.y), - n(e.p2.x), - n(e.p2.y) - )); - } - EntityType::LwPolyline(e) => { - if e.vertices.len() >= 2 { - let id = layer.next_id("pl"); - let points = e - .vertices - .iter() - .map(|v| format!("[{}, {}]", n(v.x), n(v.y))) - .collect::>() - .join(", "); - layer.entities.push(format!( - "[[polyline]]\nid = \"{}\"\npoints = [{}]\nclosed = {}\n", - id, - points, - e.is_closed() - )); - } - } - EntityType::Circle(e) => { - let id = layer.next_id("ci"); - layer.entities.push(format!( - "[[circle]]\nid = \"{}\"\ncenter = [{}, {}]\nradius = {}\n", - id, - n(e.center.x), - n(e.center.y), - n(e.radius) - )); - } - EntityType::Arc(e) => { - let id = layer.next_id("ar"); - layer.entities.push(format!( - "[[arc]]\nid = \"{}\"\ncenter = [{}, {}]\nradius = {}\nfrom_angle = {}\nto_angle = {}\n", - id, - n(e.center.x), - n(e.center.y), - n(e.radius), - n(e.start_angle), - n(e.end_angle) - )); - } - EntityType::Text(e) => { - let id = layer.next_id("tx"); - layer.entities.push(format!( - "[[text]]\nid = \"{}\"\nposition = [{}, {}]\ncontent = \"{}\"\nsize = {}\n", - id, - n(e.location.x), - n(e.location.y), - escape_string(&e.value), - n(e.text_height.max(0.1)) - )); - } - EntityType::ModelPoint(e) => { - let id = layer.next_id("pt"); - layer.entities.push(format!( - "[[point]]\nid = \"{}\"\nposition = [{}, {}]\n", - id, - n(e.location.x), - n(e.location.y) - )); + let style = StyleAttrs::from_common(&entity.common); + + // A line stamped with CADSPEC_HATCH XDATA is one of a hatch's + // pattern lines: register its group (once) and drop the line, so + // the round-trip yields one `[[hatch]]` instead of N `[[line]]`. + if matches!(&entity.specific, EntityType::Line(_)) { + if let Some(meta) = parse_hatch_xdata(&entity.common.x_data) { + hatch_groups.entry(meta.group).or_insert_with(|| { + ( + layer_name.clone(), + Imported { + shape: Shape::Hatch { + points: meta.points, + angle: meta.angle, + scale: meta.scale, + pattern: meta.pattern, + }, + style: style.clone(), + }, + ) + }); + continue; } + } + + let shape = match &entity.specific { + EntityType::Line(e) => Some(Shape::Line { + from: [e.p1.x, e.p1.y], + to: [e.p2.x, e.p2.y], + }), + EntityType::LwPolyline(e) => (e.vertices.len() >= 2).then(|| Shape::Polyline { + points: e.vertices.iter().map(|v| [v.x, v.y]).collect(), + closed: e.is_closed(), + }), + EntityType::Circle(e) => Some(Shape::Circle { + center: [e.center.x, e.center.y], + radius: e.radius, + }), + EntityType::Arc(e) => Some(Shape::Arc { + center: [e.center.x, e.center.y], + radius: e.radius, + from_angle: e.start_angle, + to_angle: e.end_angle, + }), + EntityType::Text(e) => Some(Shape::Text { + position: [e.location.x, e.location.y], + content: e.value.clone(), + size: e.text_height.max(0.1), + rotation: e.rotation, + }), + EntityType::ModelPoint(e) => Some(Shape::Point { + position: [e.location.x, e.location.y], + }), EntityType::RotatedDimension(e) => { - let id = layer.next_id("dm"); - let from_x = e.definition_point_2.x; - let from_y = e.definition_point_2.y; - let to_x = e.definition_point_3.x; - let to_y = e.definition_point_3.y; - let offset = e.insertion_point.y - (from_y + to_y) / 2.0; - layer.entities.push(format!( - "[[dim]]\nid = \"{}\"\ntype = \"linear\"\nfrom = [{}, {}]\nto = [{}, {}]\noffset = {}\n", - id, - n(from_x), - n(from_y), - n(to_x), - n(to_y), - n(offset) - )); + let from = [e.definition_point_2.x, e.definition_point_2.y]; + let to = [e.definition_point_3.x, e.definition_point_3.y]; + dim_offset(from, to, [e.insertion_point.x, e.insertion_point.y]) + .map(|offset| Shape::Dim { from, to, offset }) + } + EntityType::Solid(e) => { + let ring = solid_ring(e); + (ring.len() >= 3).then_some(Shape::Fill { points: ring }) } _ => { unsupported += 1; + None } + }; + if let Some(shape) = shape { + layers + .entry(layer_name) + .or_default() + .entities + .push(Imported { shape, style }); } } + + // Emit one hatch per collected group into its layer (ordered by + // group id for deterministic output). + for (_group, (layer_name, imported)) in hatch_groups { + layers + .entry(layer_name) + .or_default() + .entities + .push(imported); + } + + for layer in layers.values_mut() { + remove_dim_companions(&mut layer.entities); + fuse_solids(&mut layer.entities); + } } Err(_) => { let content = fs::read_to_string(input) @@ -197,23 +336,42 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - ); let mut imported_layers = 0usize; - for (layer_name, layer_file) in &layers { + for (layer_name, layer_file) in &mut layers { imported_layers += 1; let file_name = format!("{}.cf", sanitize_for_filename(layer_name)); + let color = layer_colors + .get(layer_name) + .map(String::as_str) + .unwrap_or("#FFFFFF"); let mut cf = format!( - "[layer]\nname = \"{}\"\ncolor = \"#FFFFFF\"\n\n", - escape_string(layer_name) + "[layer]\nname = \"{}\"\ncolor = \"{}\"\n\n", + escape_string(layer_name), + color ); if layer_file.entities.is_empty() { cf.push_str("[[line]]\nfrom = [0.0, 0.0]\nto = [1.0, 0.0]\n"); } else { - for e in &layer_file.entities { - cf.push_str(e); + for entity in &layer_file.entities { + let (prefix, body) = emit_shape(&entity.shape); + let count = layer_file + .counters + .entry(prefix) + .and_modify(|v| *v += 1) + .or_insert(1); + cf.push_str(&format!( + "[[{}]]\nid = \"{prefix}-{count:03}\"\n", + header_for(prefix) + )); + cf.push_str(&body); + entity.style.emit(&mut cf); cf.push('\n'); } } - fs::write(output_dir.join(&file_name), cf) - .with_context(|| format!("Cannot write layer file {}", file_name))?; + fs::write( + output_dir.join(&file_name), + cf.trim_end().to_string() + "\n", + ) + .with_context(|| format!("Cannot write layer file {}", file_name))?; project_toml.push_str(&format!( "\"{}\" = {{ file = \"{}\", locked = false }}\n", escape_string(layer_name), @@ -235,6 +393,433 @@ pub fn import_dxf(input: &Path, output_dir: &Path, layer_filter: Option<&str>) - Ok(()) } +/// Recover the perpendicular dimension offset from the insertion point. +fn dim_offset(from: [f64; 2], to: [f64; 2], insertion: [f64; 2]) -> Option { + let dx = to[0] - from[0]; + let dy = to[1] - from[1]; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-9 { + return None; + } + let (nx, ny) = (-dy / len, dx / len); + let mid = [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0]; + Some((insertion[0] - mid[0]) * nx + (insertion[1] - mid[1]) * ny) +} + +/// Drop the extension/dimension lines and label text that `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). +fn remove_dim_companions(entities: &mut Vec) { + const TOL: f64 = 1e-6; + let close = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < TOL && (a[1] - b[1]).abs() < TOL; + + let dims: Vec<([f64; 2], [f64; 2], f64)> = entities + .iter() + .filter_map(|e| match e.shape { + Shape::Dim { from, to, offset } => Some((from, to, offset)), + _ => None, + }) + .collect(); + + let mut keep = vec![true; entities.len()]; + for (from, to, offset) in dims { + let dx = to[0] - from[0]; + let dy = to[1] - from[1]; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-9 { + continue; + } + let (nx, ny) = (-dy / len, dx / len); + let a = [from[0] + nx * offset, from[1] + ny * offset]; + let b = [to[0] + nx * offset, to[1] + ny * offset]; + let mid = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]; + + for (cf, ct) in [(from, a), (to, b), (a, b)] { + mark_line_companion(entities, &mut keep, cf, ct, close); + } + mark_text_companion(entities, &mut keep, mid, nx, ny, close); + } + + let mut it = keep.into_iter(); + entities.retain(|_| it.next().unwrap_or(true)); +} + +fn mark_line_companion( + entities: &[Imported], + keep: &mut [bool], + from: [f64; 2], + to: [f64; 2], + close: impl Fn([f64; 2], [f64; 2]) -> bool, +) { + if let Some(i) = (0..entities.len()).find(|&i| { + keep[i] + && matches!(entities[i].shape, Shape::Line { from: lf, to: lt } + if (close(lf, from) && close(lt, to)) || (close(lf, to) && close(lt, from))) + }) { + keep[i] = false; + } +} + +fn mark_text_companion( + entities: &[Imported], + keep: &mut [bool], + mid: [f64; 2], + nx: f64, + ny: f64, + close: impl Fn([f64; 2], [f64; 2]) -> bool, +) { + if let Some(i) = (0..entities.len()).find(|&i| { + keep[i] + && matches!(entities[i].shape, Shape::Text { position, size, .. } + if close(position, [mid[0] + nx * size * 0.5, mid[1] + ny * size * 0.5])) + }) { + keep[i] = false; + } +} + +/// Recover the polygon ring of a DXF SOLID entity. SOLID vertices render in +/// the order first → second → fourth → third; adjacent duplicates (the writer +/// sets the fourth corner equal to the third for triangles) are collapsed. +fn solid_ring(e: &dxf::entities::Solid) -> Vec<[f64; 2]> { + let raw = [ + [e.first_corner.x, e.first_corner.y], + [e.second_corner.x, e.second_corner.y], + [e.fourth_corner.x, e.fourth_corner.y], + [e.third_corner.x, e.third_corner.y], + ]; + let mut ring: Vec<[f64; 2]> = Vec::with_capacity(4); + for p in raw { + if ring.last().is_none_or(|q| !pts_eq(*q, p)) { + ring.push(p); + } + } + // Drop a closing duplicate if the last equals the first. + if ring.len() > 1 && pts_eq(ring[0], ring[ring.len() - 1]) { + ring.pop(); + } + ring +} + +/// Quantized key for a point so that geometrically equal vertices from +/// different SOLID entities hash together despite float round-trip noise. +type PtKey = (i64, i64); + +fn pt_key(p: [f64; 2]) -> PtKey { + ((p[0] * 1e6).round() as i64, (p[1] * 1e6).round() as i64) +} + +fn pts_eq(a: [f64; 2], b: [f64; 2]) -> bool { + (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6 +} + +/// Undirected edge key with a canonical (min, max) endpoint order. +fn edge_key(a: PtKey, b: PtKey) -> (PtKey, PtKey) { + if a <= b { + (a, b) + } else { + (b, a) + } +} + +/// Fuse adjacent SOLID-derived fills into single `[[fill]]` regions. +/// +/// `cadspec build` fan-triangulates a solid fill into several SOLID entities; +/// importing each one separately would inflate the entity count and lose the +/// `[[fill]]` semantics. Fills that share an edge belong to the same region: +/// group them, drop the shared (internal) edges, and stitch the remaining +/// boundary edges back into one polygon. Foreign SOLIDs that don't tile a +/// region are left as individual fills. +fn fuse_solids(entities: &mut Vec) { + let fill_positions: Vec = entities + .iter() + .enumerate() + .filter(|(_, e)| matches!(e.shape, Shape::Fill { .. })) + .map(|(i, _)| i) + .collect(); + if fill_positions.len() < 2 { + return; // nothing to merge + } + + let ring_of = |pos: usize| -> &[[f64; 2]] { + match &entities[pos].shape { + Shape::Fill { points } => points, + _ => unreachable!("filtered to fills"), + } + }; + + // Union-find over fills, joined when they share an edge. + let mut parent: Vec = (0..fill_positions.len()).collect(); + fn find(parent: &mut [usize], mut x: usize) -> usize { + while parent[x] != x { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + x + } + + let mut edge_owner: std::collections::HashMap<(PtKey, PtKey), usize> = + std::collections::HashMap::new(); + for (idx, &pos) in fill_positions.iter().enumerate() { + for (a, b) in ring_edges(ring_of(pos)) { + let key = edge_key(pt_key(a), pt_key(b)); + if let Some(&other) = edge_owner.get(&key) { + let (ra, rb) = (find(&mut parent, idx), find(&mut parent, other)); + parent[ra] = rb; + } else { + edge_owner.insert(key, idx); + } + } + } + + // Group fill indices by component root. + let mut components: BTreeMap> = BTreeMap::new(); + for idx in 0..fill_positions.len() { + let root = find(&mut parent, idx); + components.entry(root).or_default().push(idx); + } + + // Build replacement fills and track which original positions to drop. + let mut fused: Vec = Vec::new(); + let mut drop: Vec = vec![false; entities.len()]; + for members in components.values() { + for &idx in members { + drop[fill_positions[idx]] = true; + } + let style_pos = fill_positions[members[0]]; + let style = entities[style_pos].style.clone(); + + if members.len() == 1 { + // A lone fill: keep its own ring. + let points = ring_of(fill_positions[members[0]]).to_vec(); + fused.push(Imported { + shape: Shape::Fill { points }, + style, + }); + continue; + } + + let rings: Vec<&[[f64; 2]]> = members + .iter() + .map(|&idx| ring_of(fill_positions[idx])) + .collect(); + match merge_rings(&rings) { + Some(points) => fused.push(Imported { + shape: Shape::Fill { points }, + style, + }), + None => { + // Couldn't stitch a clean boundary: keep each fill as-is. + for &idx in members { + fused.push(Imported { + shape: Shape::Fill { + points: ring_of(fill_positions[idx]).to_vec(), + }, + style: entities[fill_positions[idx]].style.clone(), + }); + } + } + } + } + + let mut it = drop.into_iter(); + entities.retain(|_| !it.next().unwrap_or(false)); + entities.extend(fused); +} + +/// Iterate the closed ring's undirected edges as consecutive point pairs. +fn ring_edges(ring: &[[f64; 2]]) -> Vec<([f64; 2], [f64; 2])> { + let n = ring.len(); + (0..n).map(|i| (ring[i], ring[(i + 1) % n])).collect() +} + +/// Merge a set of triangle/quad rings that tile one region into a single +/// boundary polygon: edges shared by two rings are interior and dropped; the +/// remaining boundary edges are chained into one ring. Returns `None` if the +/// boundary is not a single simple loop. +fn merge_rings(rings: &[&[[f64; 2]]]) -> Option> { + let mut edge_count: std::collections::HashMap<(PtKey, PtKey), usize> = + std::collections::HashMap::new(); + let mut coord: std::collections::HashMap = std::collections::HashMap::new(); + for ring in rings { + for (a, b) in ring_edges(ring) { + coord.entry(pt_key(a)).or_insert(a); + coord.entry(pt_key(b)).or_insert(b); + *edge_count + .entry(edge_key(pt_key(a), pt_key(b))) + .or_insert(0) += 1; + } + } + + // Boundary edges appear exactly once. + let boundary: Vec<(PtKey, PtKey)> = edge_count + .iter() + .filter(|(_, &c)| c == 1) + .map(|(&e, _)| e) + .collect(); + if boundary.len() < 3 { + return None; + } + + // Adjacency; a clean loop has every vertex at degree 2. + let mut adj: std::collections::HashMap> = std::collections::HashMap::new(); + for (a, b) in &boundary { + adj.entry(*a).or_default().push(*b); + adj.entry(*b).or_default().push(*a); + } + if adj.values().any(|v| v.len() != 2) { + return None; + } + + // Walk the loop from a deterministic start. + let start = *adj.keys().min()?; + let mut ring_keys = vec![start]; + let mut prev = start; + let mut cur = adj[&start][0]; + while cur != start { + ring_keys.push(cur); + let nbrs = &adj[&cur]; + let next = if nbrs[0] == prev { nbrs[1] } else { nbrs[0] }; + prev = cur; + cur = next; + if ring_keys.len() > boundary.len() { + return None; // did not close cleanly + } + } + if ring_keys.len() != boundary.len() { + return None; // disconnected boundary (e.g. a hole) + } + + Some(ring_keys.iter().map(|k| coord[k]).collect()) +} + +/// Render a shape body (without `[[header]]`/`id`); returns (id prefix, body). +fn emit_shape(shape: &Shape) -> (&'static str, String) { + match shape { + Shape::Line { from, to } => ( + "ln", + format!( + "from = [{}, {}]\nto = [{}, {}]\n", + n(from[0]), + n(from[1]), + n(to[0]), + n(to[1]) + ), + ), + Shape::Polyline { points, closed } => { + let pts = points + .iter() + .map(|p| format!("[{}, {}]", n(p[0]), n(p[1]))) + .collect::>() + .join(", "); + ("pl", format!("points = [{}]\nclosed = {}\n", pts, closed)) + } + Shape::Circle { center, radius } => ( + "ci", + format!( + "center = [{}, {}]\nradius = {}\n", + n(center[0]), + n(center[1]), + n(*radius) + ), + ), + Shape::Arc { + center, + radius, + from_angle, + to_angle, + } => ( + "ar", + format!( + "center = [{}, {}]\nradius = {}\nfrom_angle = {}\nto_angle = {}\n", + n(center[0]), + n(center[1]), + n(*radius), + n(*from_angle), + n(*to_angle) + ), + ), + Shape::Text { + position, + content, + size, + rotation, + } => { + let mut body = format!( + "position = [{}, {}]\ncontent = \"{}\"\nsize = {}\n", + n(position[0]), + n(position[1]), + escape_string(content), + n(*size) + ); + if *rotation != 0.0 { + body.push_str(&format!("rotation = {}\n", n(*rotation))); + } + ("tx", body) + } + Shape::Point { position } => ( + "pt", + format!("position = [{}, {}]\n", n(position[0]), n(position[1])), + ), + Shape::Dim { from, to, offset } => ( + "dm", + format!( + "type = \"linear\"\nfrom = [{}, {}]\nto = [{}, {}]\noffset = {}\n", + n(from[0]), + n(from[1]), + n(to[0]), + n(to[1]), + n(*offset) + ), + ), + Shape::Fill { points } => { + let pts = points + .iter() + .map(|p| format!("[{}, {}]", n(p[0]), n(p[1]))) + .collect::>() + .join(", "); + ("fl", format!("points = [{}]\n", pts)) + } + Shape::Hatch { + points, + angle, + scale, + pattern, + } => { + let pts = points + .iter() + .map(|p| format!("[{}, {}]", n(p[0]), n(p[1]))) + .collect::>() + .join(", "); + ( + "ht", + format!( + "points = [{}]\npattern = \"{}\"\nangle = {}\nscale = {}\n", + pts, + escape_string(pattern), + n(*angle), + n(*scale) + ), + ) + } + } +} + +fn header_for(prefix: &str) -> &'static str { + match prefix { + "ln" => "line", + "pl" => "polyline", + "ci" => "circle", + "ar" => "arc", + "tx" => "text", + "pt" => "point", + "dm" => "dim", + "fl" => "fill", + "ht" => "hatch", + _ => "line", + } +} + fn n(v: f64) -> String { format!("{:.4}", v) } @@ -309,3 +894,66 @@ fn collect_layer_names_from_layer_table(content: &str) -> Vec { } names } + +#[cfg(test)] +mod tests { + use super::*; + + fn fill(points: Vec<[f64; 2]>) -> Imported { + Imported { + shape: Shape::Fill { points }, + style: StyleAttrs::default(), + } + } + + fn ring_of(e: &Imported) -> &[[f64; 2]] { + match &e.shape { + Shape::Fill { points } => points, + _ => panic!("expected fill"), + } + } + + #[test] + fn merge_rings_stitches_fan_triangles_into_rectangle() { + // Fan triangulation of rect [0,0]-[2,2] from vertex [0,0]. + let t1: &[[f64; 2]] = &[[0.0, 0.0], [2.0, 0.0], [2.0, 2.0]]; + let t2: &[[f64; 2]] = &[[0.0, 0.0], [2.0, 2.0], [0.0, 2.0]]; + let ring = merge_rings(&[t1, t2]).expect("should stitch a clean loop"); + assert_eq!(ring.len(), 4, "shared diagonal edge is dropped"); + // All four rectangle corners present regardless of winding/start. + for corner in [[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]] { + assert!( + ring.iter().any(|p| pts_eq(*p, corner)), + "missing corner {corner:?}" + ); + } + } + + #[test] + fn fuse_solids_merges_adjacent_and_keeps_disjoint_separate() { + let mut entities = vec![ + // Region A: two triangles tiling rect [0,0]-[2,2]. + fill(vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0]]), + fill(vec![[0.0, 0.0], [2.0, 2.0], [0.0, 2.0]]), + // Region B: a lone triangle far away. + fill(vec![[10.0, 10.0], [11.0, 10.0], [10.5, 11.0]]), + ]; + fuse_solids(&mut entities); + let fills: Vec<&Imported> = entities + .iter() + .filter(|e| matches!(e.shape, Shape::Fill { .. })) + .collect(); + assert_eq!(fills.len(), 2, "adjacent pair fuses, disjoint stays"); + let mut lens: Vec = fills.iter().map(|e| ring_of(e).len()).collect(); + lens.sort_unstable(); + assert_eq!(lens, vec![3, 4], "one rectangle (4) and one triangle (3)"); + } + + #[test] + fn fuse_solids_leaves_single_fill_untouched() { + let mut entities = vec![fill(vec![[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]])]; + fuse_solids(&mut entities); + assert_eq!(entities.len(), 1); + assert_eq!(ring_of(&entities[0]).len(), 3); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3c6a921..348194f 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. @@ -7,10 +7,18 @@ pub mod compiler; pub mod config; pub mod dxf_writer; pub mod fmt; +pub mod gltf; 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; +pub mod svg; +pub mod transform; pub mod viewer; pub mod watch; diff --git a/src/main.rs b/src/main.rs index cfd0516..438302b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,24 @@ use anyhow::{bail, Result}; -use cadforge::compiler::{check_project, compile_project, list_layers}; -use cadforge::config::{config_set, config_show}; -use cadforge::fmt::format_project; -use cadforge::importer::import_dxf; -use cadforge::preview::generate_preview; -use cadforge::scaffold::{create_project, init_project}; -use cadforge::viewer::view_project; -use cadforge::watch::watch_project; -use clap::{Parser, Subcommand}; +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_gltf, 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 = "Architecture as Code — declarative geometry → DXF" + about = "CAD as code — declarative geometry → DXF" )] struct Cli { #[command(subcommand)] @@ -23,12 +27,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 { @@ -50,28 +54,67 @@ enum Commands { /// Project directory (defaults to current dir) #[arg(short, long)] path: Option, + /// Emit a machine-readable JSON report + #[arg(long)] + json: bool, }, /// List project layers with status Layers { /// Project directory (defaults to current dir) #[arg(short, long)] path: Option, + /// Emit a machine-readable JSON report + #[arg(long)] + json: bool, }, - /// Generate PNG preview + metadata JSON for AI agents + /// Generate preview (PNG + metadata JSON, or SVG) for AI agents Preview { /// Project directory (defaults to current dir) #[arg(short, long)] path: Option, /// Image width in pixels - #[arg(short, long, default_value = "2048")] + #[arg(short, long, default_value = "1600")] width: u32, - /// Image height in pixels - #[arg(short, long, default_value = "1536")] + /// Image height in pixels (PNG only; SVG derives it from content) + #[arg(short = 'H', long, default_value = "1200")] height: u32, /// Render only a specific layer #[arg(short, long)] layer: Option, + /// Output format + #[arg(short, long, value_enum, default_value_t = PreviewFormat::Png)] + format: PreviewFormat, + /// Highlight entities by id (comma-separated) with labeled markers + #[arg(long, value_delimiter = ',')] + highlight: Vec, + /// 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. + /// Runs detached in the background by default; use --foreground to stay attached. + Serve { + /// Project directory (defaults to current dir) + #[arg(short, long)] + path: Option, + /// Port to listen on + #[arg(long, default_value = "4377")] + port: u16, + /// Open the browser automatically + #[arg(long)] + open: bool, + /// 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, /// Format .cf files (sort keys, normalize whitespace) Fmt { /// Project directory (defaults to current dir) @@ -87,7 +130,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, @@ -107,13 +150,25 @@ enum Commands { #[arg(short, long)] layer: Option, }, - /// Global cadforge configuration + /// Global cadspec configuration Config { #[command(subcommand)] command: ConfigCommands, }, } +#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)] +enum PreviewFormat { + /// Raster PNG + preview.meta.json + Png, + /// Vector SVG (real text, dimensions, hatches) + Svg, + /// Both PNG and SVG + All, + /// Self-contained glTF of the 3D solids (`scene.gltf`) + Gltf, +} + #[derive(Subcommand)] enum ConfigCommands { /// Set global default value @@ -147,23 +202,90 @@ fn main() -> Result<()> { compile_project(&dir, layer.as_deref(), output.as_deref()) } } - Commands::Check { path } => { + Commands::Check { path, json } => { let dir = resolve_project_dir(path)?; - check_project(&dir)?; - Ok(()) + if json { + let report = project_report(&dir)?; + println!("{}", serde_json::to_string_pretty(&report)?); + if report.strict && !report.issues.is_empty() { + bail!( + "Check failed: {} constraint violation(s) with strict = true", + report.issues.len() + ); + } + Ok(()) + } else { + check_project(&dir)?; + Ok(()) + } } - Commands::Layers { path } => { + Commands::Layers { path, json } => { let dir = resolve_project_dir(path)?; - list_layers(&dir) + if json { + let report = project_report(&dir)?; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) + } else { + list_layers(&dir) + } } Commands::Preview { path, width, height, layer, + format, + highlight, + three_d, + plano, } => { let dir = resolve_project_dir(path)?; - generate_preview(&dir, width, height, layer.as_deref()) + if matches!(format, PreviewFormat::Gltf) { + generate_gltf(&dir, layer.as_deref()) + } else { + let outputs = PreviewOutputs { + png: matches!(format, PreviewFormat::Png | PreviewFormat::All), + svg: matches!(format, PreviewFormat::Svg | PreviewFormat::All), + }; + 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, + foreground, + stop, + } => { + let dir = resolve_project_dir(path)?; + if stop { + serve_stop(&dir, port) + } else if foreground { + serve_project(&dir, port, open) + } else { + serve_daemon(&dir, port, open) + } + } + Commands::Schema => { + print_schema(); + Ok(()) } Commands::Fmt { path, check } => { let dir = resolve_project_dir(path)?; @@ -196,7 +318,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/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 0d6dd2e..f7e0fb6 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 { @@ -83,6 +88,10 @@ pub struct CfText { #[serde(default = "default_text_size")] pub size: f64, pub align: Option, + pub font: Option, + pub rotation: Option, + pub bold: Option, + pub italic: Option, #[serde(flatten)] pub common: CommonAttrs, } @@ -114,6 +123,12 @@ pub struct CfDim { pub to: [f64; 2], #[serde(default = "default_offset")] pub offset: f64, + /// Label height in world units (default 0.25). + pub text_size: Option, + /// Decimal places for the measured value (default 2). + pub precision: Option, + /// Append the project units to the label (default true). + pub show_units: Option, #[serde(flatten)] pub common: CommonAttrs, } @@ -132,7 +147,12 @@ pub enum DimType { #[derive(Debug, Clone, Deserialize)] pub struct CfHatch { - pub boundary: String, + /// Reference to a closed polyline or rect id, or inline points. + #[serde(default)] + pub boundary: Option, + /// Inline points (alternative to boundary reference). Used by DXF import to + /// round-trip a hatch's region without depending on a separate boundary id. + pub points: Option>, #[serde(default = "default_pattern")] pub pattern: String, #[serde(default = "default_scale")] @@ -160,6 +180,50 @@ pub struct CfGroup { pub common: CommonAttrs, } +#[derive(Debug, Clone, Copy, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ArrayMode { + Linear, + Polar, +} + +/// Repeats target primitives: linear (offset per copy) or polar (rotation +/// around a center — spiral stairs, gear teeth, radial columns). +#[derive(Debug, Clone, Deserialize)] +pub struct CfArray { + /// Single target id (alternative to `targets`). + pub target: Option, + /// Multiple target ids. + pub targets: Option>, + pub mode: ArrayMode, + /// Total number of instances, including the original. + pub count: usize, + /// Linear: displacement per copy. + pub offset: Option<[f64; 2]>, + /// Polar: rotation center. + pub center: Option<[f64; 2]>, + /// Polar: degrees per copy (counterclockwise). + pub step_angle: Option, + /// Polar: rotate each copy's geometry (true) or only orbit it (false). + #[serde(default = "default_true")] + pub rotate_items: bool, + #[serde(flatten)] + pub common: CommonAttrs, +} + +/// Mirrors target primitives across an axis defined by two points. +#[derive(Debug, Clone, Deserialize)] +pub struct CfMirror { + /// Single target id (alternative to `targets`). + pub target: Option, + /// Multiple target ids. + pub targets: Option>, + /// Mirror axis: two points [[x1, y1], [x2, y2]]. + pub axis: [[f64; 2]; 2], + #[serde(flatten)] + pub common: CommonAttrs, +} + #[derive(Debug, Clone, Deserialize)] pub struct CfFill { /// Reference to a closed polyline or rect id, or inline points. @@ -170,6 +234,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)] @@ -211,4 +310,12 @@ pub struct CfFile { pub fills: Vec, #[serde(default, rename = "group")] pub groups: Vec, + #[serde(default, rename = "array")] + pub arrays: Vec, + #[serde(default, rename = "mirror")] + pub mirrors: Vec, + #[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..de3d801 --- /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#"cadspec"#, + 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 370b225..33720dc 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -1,24 +1,25 @@ -//! Preview — renders project to PNG + metadata JSON for multimodal AI agents. - -use crate::compiler::resolve_boundary; -use crate::model::{CfFile, CommonAttrs}; -use crate::parser::{parse_cf, parse_project}; +//! Preview — rasterizes the SVG scene to PNG + metadata JSON for multimodal AI agents. +//! +//! The PNG is a faithful raster of the SVG renderer (real text, measured +//! dimensions, hatches, line styles), so what an agent *sees* in the image is +//! exactly what `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. + +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, +}; use anyhow::{Context, Result}; +use resvg::{tiny_skia, usvg}; use serde::Serialize; use std::path::Path; -use tiny_skia::{Color, Paint, PathBuilder, Pixmap, Stroke, Transform}; - -// ── Configuration ─────────────────────────────────────────────────────── - -const PADDING: f64 = 0.5; // world units padding around content -const STROKE_WIDTH: f32 = 1.5; -const TEXT_MARKER: f64 = 0.05; - -fn bg_color() -> Color { - Color::from_rgba8(20, 20, 20, 255) -} +use std::sync::{Arc, OnceLock}; -// ── Bounds accumulator (DRY: one place to track min/max) ──────────────── +// ── Metadata structures (for the agent) ───────────────────────────────── #[derive(Serialize, Clone, Copy)] pub struct WorldBounds { @@ -28,43 +29,6 @@ pub struct WorldBounds { pub max_y: f64, } -impl WorldBounds { - fn empty() -> Self { - Self { - min_x: f64::MAX, - min_y: f64::MAX, - max_x: f64::MIN, - max_y: f64::MIN, - } - } - - fn add(&mut self, x: f64, y: f64) { - self.min_x = self.min_x.min(x); - self.min_y = self.min_y.min(y); - self.max_x = self.max_x.max(x); - self.max_y = self.max_y.max(y); - } - - fn is_empty(&self) -> bool { - self.min_x > self.max_x - } - - fn as_bbox(&self) -> [f64; 4] { - [self.min_x, self.min_y, self.max_x, self.max_y] - } -} - -/// Compute the bounding box of a set of points. -fn points_bounds(points: &[(f64, f64)]) -> WorldBounds { - let mut b = WorldBounds::empty(); - for &(x, y) in points { - b.add(x, y); - } - b -} - -// ── Metadata structures (for the agent) ───────────────────────────────── - #[derive(Serialize)] pub struct PreviewMeta { pub project_name: String, @@ -72,9 +36,12 @@ pub struct PreviewMeta { pub width_px: u32, pub height_px: u32, pub world_bounds: WorldBounds, + /// Pixels per world unit in the output image. pub scale: f64, + pub units: String, pub layers: Vec, pub entities: Vec, + pub highlighted: Vec, } #[derive(Serialize)] @@ -89,445 +56,300 @@ pub struct EntityInfo { pub id: Option, pub entity_type: String, pub layer: String, + /// Text content (only for `text` entities). + pub content: Option, pub bbox: [f64; 4], // [min_x, min_y, max_x, max_y] in world coords pub pixel_bbox: [u32; 4], // [x, y, w, h] in image coords } -// ── Renderer ──────────────────────────────────────────────────────────── - -struct Renderer { - pixmap: Pixmap, - scale: f64, - offset_x: f64, - offset_y: f64, - world_height: f64, -} - -impl Renderer { - fn new(width: u32, height: u32, bounds: &WorldBounds) -> Result { - let world_w = bounds.max_x - bounds.min_x + 2.0 * PADDING; - let world_h = bounds.max_y - bounds.min_y + 2.0 * PADDING; - - let scale = (width as f64 / world_w).min(height as f64 / world_h); - - let mut pixmap = Pixmap::new(width, height) - .ok_or_else(|| anyhow::anyhow!("Invalid image dimensions {}x{}", width, height))?; - pixmap.fill(bg_color()); - - Ok(Self { - pixmap, - scale, - offset_x: bounds.min_x - PADDING, - offset_y: bounds.min_y - PADDING, - world_height: world_h, - }) - } - - fn world_to_px(&self, x: f64, y: f64) -> (f32, f32) { - let px = ((x - self.offset_x) * self.scale) as f32; - // Flip Y: world Y goes up, pixel Y goes down - let py = ((self.world_height - (y - self.offset_y)) * self.scale) as f32; - (px, py) - } - - /// Single stroke entry point — all draw_* methods funnel through here (DRY). - fn stroke(&mut self, path: tiny_skia::Path, color: Color, width: f32) { - let mut paint = Paint::default(); - paint.set_color(color); - paint.anti_alias = true; - let stroke = Stroke { - width, - ..Default::default() - }; - self.pixmap - .stroke_path(&path, &paint, &stroke, Transform::identity(), None); - } - - fn draw_line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, color: Color, width: f32) { - let (px1, py1) = self.world_to_px(x1, y1); - let (px2, py2) = self.world_to_px(x2, y2); - let mut pb = PathBuilder::new(); - pb.move_to(px1, py1); - pb.line_to(px2, py2); - if let Some(path) = pb.finish() { - self.stroke(path, color, width); - } - } - - fn draw_circle(&mut self, cx: f64, cy: f64, radius: f64, color: Color, width: f32) { - let (pcx, pcy) = self.world_to_px(cx, cy); - let pr = (radius * self.scale) as f32; - let mut pb = PathBuilder::new(); - pb.push_circle(pcx, pcy, pr); - if let Some(path) = pb.finish() { - self.stroke(path, color, width); - } - } - - fn draw_arc(&mut self, arc: ArcSpec, color: Color, width: f32) { - const STEPS: usize = 32; - let start = arc.start_deg.to_radians(); - let delta = (arc.end_deg.to_radians() - start) / STEPS as f64; - - let mut pb = PathBuilder::new(); - for i in 0..=STEPS { - let angle = start + delta * i as f64; - let (px, py) = self.world_to_px( - arc.cx + arc.radius * angle.cos(), - arc.cy + arc.radius * angle.sin(), - ); - if i == 0 { - pb.move_to(px, py); - } else { - pb.line_to(px, py); - } - } - if let Some(path) = pb.finish() { - self.stroke(path, color, width); - } - } - - fn draw_polyline(&mut self, points: &[(f64, f64)], closed: bool, color: Color, width: f32) { - let Some((first, rest)) = points.split_first() else { - return; - }; - let mut pb = PathBuilder::new(); - let (px, py) = self.world_to_px(first.0, first.1); - pb.move_to(px, py); - for &(x, y) in rest { - let (px, py) = self.world_to_px(x, y); - pb.line_to(px, py); - } - if closed { - pb.close(); - } - if let Some(path) = pb.finish() { - self.stroke(path, color, width); - } - } - - fn fill_polygon(&mut self, points: &[(f64, f64)], color: Color) { - let Some((first, rest)) = points.split_first() else { - return; - }; - let mut pb = PathBuilder::new(); - let (px, py) = self.world_to_px(first.0, first.1); - pb.move_to(px, py); - for &(x, y) in rest { - let (px, py) = self.world_to_px(x, y); - pb.line_to(px, py); - } - pb.close(); - if let Some(path) = pb.finish() { - let mut paint = Paint::default(); - // Semi-transparent fill so underlying geometry stays visible - paint.set_color( - Color::from_rgba(color.red(), color.green(), color.blue(), 0.35).unwrap(), - ); - paint.anti_alias = true; - self.pixmap.fill_path( - &path, - &paint, - tiny_skia::FillRule::Winding, - Transform::identity(), - None, - ); - } - } - - fn save_png(&self, path: &Path) -> Result<()> { - self.pixmap - .save_png(path) - .map_err(|e| anyhow::anyhow!("Failed to save PNG: {}", e)) - } - - fn entity_info( - &self, - common: &CommonAttrs, - entity_type: &str, - layer: &str, - bounds: WorldBounds, - ) -> EntityInfo { - let (px1, py1) = self.world_to_px(bounds.min_x, bounds.max_y); // top-left - let (px2, py2) = self.world_to_px(bounds.max_x, bounds.min_y); // bottom-right - EntityInfo { - id: common.id.clone(), - entity_type: entity_type.to_string(), - layer: layer.to_string(), - bbox: bounds.as_bbox(), - pixel_bbox: [ - px1 as u32, - py1 as u32, - (px2 - px1) as u32, - (py2 - py1) as u32, - ], - } - } -} +// ── Public API ────────────────────────────────────────────────────────── +/// Which preview artifacts to write. #[derive(Clone, Copy)] -struct ArcSpec { - cx: f64, - cy: f64, - radius: f64, - start_deg: f64, - end_deg: f64, +pub struct PreviewOutputs { + /// Write `preview.png` + `preview.meta.json`. + pub png: bool, + /// Write `preview.svg`. + pub svg: bool, } -// ── Layer color mapping ───────────────────────────────────────────────── - -fn layer_color(index: usize) -> Color { - const PALETTE: &[(u8, u8, u8)] = &[ - (255, 255, 255), // white - (255, 80, 80), // red - (80, 255, 80), // green - (80, 200, 255), // cyan - (255, 200, 80), // yellow - (200, 120, 255), // purple - (255, 150, 50), // orange - ]; - let (r, g, b) = PALETTE[index % PALETTE.len()]; - Color::from_rgba8(r, g, b, 255) +/// Which projection to render. +#[derive(Clone, Copy, PartialEq)] +pub enum PreviewView { + /// Flat 2D plan (the default). + Plan, + /// Extruded axonometric 3D view. + ThreeD, } -fn color_to_hex(c: Color) -> String { - format!( - "#{:02X}{:02X}{:02X}", - (c.red() * 255.0) as u8, - (c.green() * 255.0) as u8, - (c.blue() * 255.0) as u8, - ) -} - -// ── Public API ────────────────────────────────────────────────────────── - -/// Generate a preview PNG + metadata JSON for the project. +/// Generate preview artifacts (PNG + metadata JSON, and/or SVG) for the project. +/// +/// Everything is produced from a single parse + render pass. +/// `width`/`height` are treated as a bounding box: the image keeps the +/// project's aspect ratio and fits inside it. pub fn generate_preview( project_dir: &Path, width: u32, height: u32, layer_filter: Option<&str>, + highlight: &[String], + outputs: PreviewOutputs, + view: PreviewView, ) -> Result<()> { - let project = parse_project(&project_dir.join("project.toml"))?; - - // Parse all layer files once - let layers: Vec<(String, CfFile)> = project - .layers - .iter() - .filter(|(name, _)| layer_filter.is_none_or(|f| f == *name)) - .map(|(name, entry)| { - let cf = parse_cf(&project_dir.join(&entry.file)) - .with_context(|| format!("Failed to parse layer '{}'", name))?; - Ok((name.clone(), cf)) - }) - .collect::>()?; - - let bounds = compute_bounds(&layers); - let mut renderer = Renderer::new(width, height, &bounds)?; - let mut entities: Vec = Vec::new(); - let mut layer_infos: Vec = Vec::new(); + 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, + &project.project.units, + &layers, + width, + highlight, + ); + + if outputs.svg { + let svg_path = project_dir.join("preview.svg"); + std::fs::write(&svg_path, &scene.svg) + .with_context(|| format!("Cannot write {}", svg_path.display()))?; + println!("✓ SVG: {}", svg_path.display()); + } - for (idx, (layer_name, cf)) in layers.iter().enumerate() { - let color = layer_color(idx); - let count = render_layer(&mut renderer, cf, layer_name, color, &mut entities); - layer_infos.push(LayerInfo { - name: layer_name.clone(), - entity_count: count, - color: color_to_hex(color), - }); + if !outputs.png { + return Ok(()); } - let png_path = project_dir.join("preview.png"); - renderer.save_png(&png_path)?; - - let meta = PreviewMeta { - project_name: project.project.name, - image_file: "preview.png".to_string(), - width_px: width, - height_px: height, - world_bounds: bounds, - scale: renderer.scale, - layers: layer_infos, - entities, - }; + // Fit the scene inside the requested width × height box. + let fit = (height as f64 / scene.height_px).min(1.0); + let pixmap = rasterize(&scene.svg, fit as f32)?; + let png_path = project_dir.join("preview.png"); + pixmap + .save_png(&png_path) + .map_err(|e| anyhow::anyhow!("Failed to save PNG: {}", e))?; + + let meta = build_meta( + &project.project.name, + &project.project.units, + &layers, + &scene, + fit, + highlight, + &pixmap, + ); let json_path = project_dir.join("preview.meta.json"); std::fs::write(&json_path, serde_json::to_string_pretty(&meta)?)?; - println!("✓ Preview: {} ({}x{})", png_path.display(), width, height); + println!( + "✓ Preview: {} ({}x{})", + png_path.display(), + pixmap.width(), + pixmap.height() + ); println!("✓ Metadata: {}", json_path.display()); Ok(()) } -// ── Rendering per layer ────────────────────────────────────────────────── - -fn render_layer( - r: &mut Renderer, - cf: &CfFile, - layer: &str, - color: Color, - out: &mut Vec, -) -> usize { - let mut count = 0; - - for e in &cf.lines { - r.draw_line(e.from[0], e.from[1], e.to[0], e.to[1], color, STROKE_WIDTH); - let bounds = points_bounds(&[(e.from[0], e.from[1]), (e.to[0], e.to[1])]); - out.push(r.entity_info(&e.common, "line", layer, bounds)); - count += 1; - } - - for e in &cf.polylines { - let pts: Vec<(f64, f64)> = e.points.iter().map(|p| (p[0], p[1])).collect(); - r.draw_polyline(&pts, e.closed, color, STROKE_WIDTH); - out.push(r.entity_info(&e.common, "polyline", layer, points_bounds(&pts))); - count += 1; +/// 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()); } - for e in &cf.rects { - let pts = rect_points(e.origin[0], e.origin[1], e.width, e.height); - r.draw_polyline(&pts, true, color, STROKE_WIDTH); - out.push(r.entity_info(&e.common, "rect", layer, points_bounds(&pts))); - count += 1; + if !outputs.png { + return Ok(()); } - for e in &cf.circles { - r.draw_circle(e.center[0], e.center[1], e.radius, color, STROKE_WIDTH); - out.push(r.entity_info( - &e.common, - "circle", - layer, - circle_bounds(e.center, e.radius), - )); - count += 1; - } + 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(()) +} - for e in &cf.arcs { - r.draw_arc( - ArcSpec { - cx: e.center[0], - cy: e.center[1], - radius: e.radius, - start_deg: e.from_angle, - end_deg: e.to_angle, - }, - color, - STROKE_WIDTH, +/// Export the scene's 3D solids to a self-contained glTF (`scene.gltf`) — the +/// same meshes the 3D view renders, for an interactive viewer or interchange. +pub fn generate_gltf(project_dir: &Path, layer_filter: Option<&str>) -> Result<()> { + let (_project, layers) = load_project_layers(project_dir, layer_filter)?; + let meshes = crate::render3d::scene_meshes(&layers); + if meshes.is_empty() { + anyhow::bail!( + "no 3D geometry to export — declare [[solid]]/[[boolean]] or set `extrude` on a primitive" ); - out.push(r.entity_info(&e.common, "arc", layer, circle_bounds(e.center, e.radius))); - count += 1; } + let tris: usize = meshes.iter().map(|(m, _)| m.tris.len()).sum(); + let doc = crate::gltf::scene_to_gltf(&meshes); + let out = project_dir.join("scene.gltf"); + std::fs::write(&out, &doc).with_context(|| format!("Cannot write {}", out.display()))?; + println!( + "✓ glTF: {} ({} meshes, {} triangles)", + out.display(), + meshes.len(), + tris + ); + Ok(()) +} - for e in &cf.texts { - // Render text position as a small marker - r.draw_line( - e.position[0] - TEXT_MARKER, - e.position[1], - e.position[0] + TEXT_MARKER, - e.position[1], - color, - 1.0, - ); - let mut b = WorldBounds::empty(); - b.add(e.position[0], e.position[1]); - b.add(e.position[0] + 0.5, e.position[1] + 0.2); - out.push(r.entity_info(&e.common, "text", layer, b)); - count += 1; +/// 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()); } - - // Solid fills — render as semi-transparent filled polygons - for e in &cf.fills { - let pts = fill_points(e, cf); - if let Some(pts) = pts { - r.fill_polygon(&pts, color); - out.push(r.entity_info(&e.common, "fill", layer, points_bounds(&pts))); - count += 1; - } + 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(()) +} - // Hatches — render boundary outline (pattern detail omitted in preview) - for e in &cf.hatches { - if let Some(pts) = resolve_boundary(&e.boundary, cf) { - r.draw_polyline(&pts, true, color, 1.0); - out.push(r.entity_info(&e.common, "hatch", layer, points_bounds(&pts))); - count += 1; - } - } +// ── Internal ──────────────────────────────────────────────────────────── - count -} +fn build_meta( + project_name: &str, + units: &str, + layers: &[(String, CfFile)], + scene: &Scene, + fit: f64, + highlight: &[String], + pixmap: &tiny_skia::Pixmap, +) -> PreviewMeta { + let mut entities = Vec::new(); + let mut layer_infos = Vec::new(); -/// Resolve a fill's geometry from inline points or a boundary reference. -fn fill_points(e: &crate::model::CfFill, cf: &CfFile) -> Option> { - if let Some(boundary_id) = &e.boundary { - resolve_boundary(boundary_id, cf) - } else { - e.points - .as_ref() - .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + for (idx, (layer_name, cf)) in layers.iter().enumerate() { + let records = enumerate_entities(cf); + layer_infos.push(LayerInfo { + name: layer_name.clone(), + entity_count: records.len(), + color: layer_display_color(cf, idx), + }); + for rec in records { + let (x1, y1) = scene.world_to_px(rec.bbox[0], rec.bbox[3]); // top-left + let (x2, y2) = scene.world_to_px(rec.bbox[2], rec.bbox[1]); // bottom-right + entities.push(EntityInfo { + id: rec.id, + entity_type: rec.kind.to_string(), + layer: layer_name.clone(), + content: rec.content, + bbox: rec.bbox, + pixel_bbox: [ + (x1 * fit) as u32, + (y1 * fit) as u32, + ((x2 - x1) * fit) as u32, + ((y2 - y1) * fit) as u32, + ], + }); + } } -} -// ── Geometry helpers ───────────────────────────────────────────────────── - -fn rect_points(x: f64, y: f64, w: f64, h: f64) -> [(f64, f64); 4] { - [(x, y), (x + w, y), (x + w, y + h), (x, y + h)] + PreviewMeta { + project_name: project_name.to_string(), + image_file: "preview.png".to_string(), + width_px: pixmap.width(), + height_px: pixmap.height(), + world_bounds: WorldBounds { + min_x: scene.world_bounds[0], + min_y: scene.world_bounds[1], + max_x: scene.world_bounds[2], + max_y: scene.world_bounds[3], + }, + scale: scene.px_per_unit * fit, + units: units.to_string(), + layers: layer_infos, + entities, + highlighted: highlight.to_vec(), + } } -fn circle_bounds(center: [f64; 2], radius: f64) -> WorldBounds { - let mut b = WorldBounds::empty(); - b.add(center[0] - radius, center[1] - radius); - b.add(center[0] + radius, center[1] + radius); - b +/// Embedded monospace font: zero font-scan latency and identical, deterministic +/// text rendering on any machine — including containers with no fonts at all. +const EMBEDDED_FONT: &[u8] = include_bytes!("../assets/fonts/DejaVuSansMono.ttf"); + +fn fontdb() -> Arc { + static FONTDB: OnceLock> = OnceLock::new(); + FONTDB + .get_or_init(|| { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(EMBEDDED_FONT.to_vec()); + db.set_monospace_family("DejaVu Sans Mono"); + Arc::new(db) + }) + .clone() } -fn compute_bounds(layers: &[(String, CfFile)]) -> WorldBounds { - let mut b = WorldBounds::empty(); - - for (_, cf) in layers { - for e in &cf.lines { - b.add(e.from[0], e.from[1]); - b.add(e.to[0], e.to[1]); - } - for e in &cf.polylines { - for p in &e.points { - b.add(p[0], p[1]); - } - } - for e in &cf.rects { - b.add(e.origin[0], e.origin[1]); - b.add(e.origin[0] + e.width, e.origin[1] + e.height); - } - for e in &cf.circles { - b.add(e.center[0] - e.radius, e.center[1] - e.radius); - b.add(e.center[0] + e.radius, e.center[1] + e.radius); - } - for e in &cf.arcs { - b.add(e.center[0] - e.radius, e.center[1] - e.radius); - b.add(e.center[0] + e.radius, e.center[1] + e.radius); - } - for e in &cf.texts { - b.add(e.position[0], e.position[1]); - } - for e in &cf.fills { - if let Some(points) = &e.points { - for p in points { - b.add(p[0], p[1]); - } - } - } - } +/// Rasterize an SVG string at the given scale factor. +fn rasterize(svg: &str, scale: f32) -> Result { + let opt = usvg::Options { + fontdb: fontdb(), + ..Default::default() + }; - if b.is_empty() { - WorldBounds { - min_x: 0.0, - min_y: 0.0, - max_x: 10.0, - max_y: 10.0, - } - } else { - b - } + let tree = usvg::Tree::from_str(svg, &opt).context("Failed to parse generated SVG")?; + let size = tree.size(); + let w = ((size.width() * scale).ceil() as u32).max(1); + let h = ((size.height() * scale).ceil() as u32).max(1); + + let mut pixmap = tiny_skia::Pixmap::new(w, h) + .ok_or_else(|| anyhow::anyhow!("Invalid image dimensions {}x{}", w, h))?; + resvg::render( + &tree, + tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + Ok(pixmap) } #[cfg(test)] @@ -535,23 +357,21 @@ mod tests { use super::*; #[test] - fn bounds_accumulates_correctly() { - let mut b = WorldBounds::empty(); - assert!(b.is_empty()); - b.add(1.0, 2.0); - b.add(5.0, -1.0); - assert_eq!(b.as_bbox(), [1.0, -1.0, 5.0, 2.0]); - assert!(!b.is_empty()); - } + fn rasterize_produces_scaled_pixmap() { + let svg = r##""##; + let pixmap = rasterize(svg, 1.0).unwrap(); + assert_eq!((pixmap.width(), pixmap.height()), (200, 100)); - #[test] - fn circle_bounds_is_square() { - let b = circle_bounds([5.0, 5.0], 2.0); - assert_eq!(b.as_bbox(), [3.0, 3.0, 7.0, 7.0]); + let half = rasterize(svg, 0.5).unwrap(); + assert_eq!((half.width(), half.height()), (100, 50)); + + // Background must be painted (not transparent) + let px = pixmap.pixel(5, 50).unwrap(); + assert!(px.alpha() == 255); } #[test] - fn color_to_hex_formats() { - assert_eq!(color_to_hex(Color::from_rgba8(255, 0, 128, 255)), "#FF0080"); + fn rasterize_rejects_invalid_svg() { + assert!(rasterize("not an svg", 1.0).is_err()); } } diff --git a/src/render3d.rs b/src/render3d.rs new file mode 100644 index 0000000..8003d93 --- /dev/null +++ b/src/render3d.rs @@ -0,0 +1,664 @@ +//! 3D view renderer — extrudes `.cf` geometry into solids and projects them. +//! +//! 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 +//! 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, +} + +/// Every primitive's 2D footprint in a layer (rects, polylines, lines, circles, +/// arcs) — the input to extrusion and outlining. +fn footprints(cf: &CfFile) -> Vec> { + 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, + }); + } + prints +} + +/// World-space solid meshes for the whole scene — extruded footprints plus +/// resolved solids/booleans, each with its display colour. This is the same +/// geometry the 3D view renders (same builders), but unprojected and unsectioned, +/// so it can be exported to a mesh format like glTF. +pub fn scene_meshes(layers: &[(String, CfFile)]) -> Vec<(mesh::Mesh, String)> { + let mut out: Vec<(mesh::Mesh, String)> = Vec::new(); + + for (idx, (_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); + + // Extruded footprints → prism/wall meshes. + for fp in footprints(cf) { + if fp.points.len() < 2 { + continue; + } + let h = fp.common.extrude.unwrap_or(0.0); + if h <= 0.0 { + continue; + } + let base = fp.common.elevation.unwrap_or(0.0); + let color = fp + .common + .color + .clone() + .unwrap_or_else(|| layer_color.clone()); + out.push((mesh::prism(&fp.points, base, h, fp.closed), color)); + } + + // Solids + booleans (mirrors collect_solid_faces, without projection). + let resolved = resolve_booleans(cf, &layer_color); + out.extend(resolved.into_iter().map(|(m, c, _id)| (m, c))); + } + out +} + +fn collect_layer_faces( + faces: &mut Vec, + layer_name: &str, + cf: &CfFile, + layer_color: &str, + cam: &Camera, + cut: Option<&Cut>, +) { + for fp in footprints(cf) { + 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 named solids, apply boolean operations, return resolved meshes with colors and IDs. +/// Used by both `scene_meshes` (glTF export) and `collect_solid_faces` (SVG render). +fn resolve_booleans(cf: &CfFile, layer_color: &str) -> Vec<(mesh::Mesh, String, Option)> { + 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()); + } + } + + let mut out = Vec::new(); + for b in &cf.booleans { + let Some(base) = meshes.get(b.base.as_str()) else { + continue; + }; + 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), + }; + } + } + let color = b + .color + .clone() + .or_else(|| colors.get(b.base.as_str()).cloned()) + .unwrap_or_else(|| layer_color.to_string()); + out.push((acc, color, b.id.clone())); + } + for s in &cf.solids { + if consumed.contains(s.id.as_str()) { + continue; + } + if let (Some(m), Some(c)) = (meshes.get(s.id.as_str()), colors.get(s.id.as_str())) { + out.push((m.clone(), c.clone(), Some(s.id.clone()))); + } + } + out +} + +/// 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>, +) { + for (mesh, color, id) in resolve_booleans(cf, layer_color) { + emit_mesh(faces, &apply_cut(mesh, cut), &color, layer_name, &id, 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<()> { let project_dir = parent.join(name); if project_dir.exists() { @@ -16,16 +16,20 @@ pub fn create_project(name: &str, parent: &Path) -> 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 build --path {}` to compile.", name); + println!( + "\n Run `cadspec serve --path {}` for a live preview,", + name + ); + 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()); @@ -37,13 +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!(" → 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 `cadspec serve` for a live preview."); + println!(" `cadspec schema` prints the .cf language reference."); Ok(()) } @@ -55,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.meta.json\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 muros_cf = r##"[layer] -name = "muros" + let shapes_cf = r##"[layer] +name = "shapes" color = "#FFFFFF" -line_weight = 0.50 +line_weight = 0.35 + +# 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 -# Perímetro exterior +# 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 +# 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 -# Cama dormitorio -[[rect]] -id = "rc-cama" -origin = [5.5, 4.0] -width = 2.0 -height = 1.5 - -# Etiquetas -[[text]] -id = "tx-sala" -position = [2.0, 5.0] -content = "SALA" -size = 0.25 - -[[text]] -id = "tx-dorm" -position = [6.0, 5.0] -content = "DORMITORIO" -size = 0.20 +# 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(()) } @@ -187,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(); @@ -195,18 +188,19 @@ 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")); + assert!(gitignore.contains("preview.svg")); assert!(gitignore.contains("target/")); let _ = fs::remove_dir_all(&tmp); @@ -214,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(); @@ -226,17 +220,16 @@ 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(); 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); @@ -244,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 new file mode 100644 index 0000000..d061681 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,285 @@ +//! 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##"# CADspec `.cf` Language Reference + +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. + +## project.toml + +```toml +[project] +name = "Vivienda Lote 12" +scale = "1:100" # drawing scale (metadata) +units = "m" # unit label used in dimension labels +strict = false # true: constraint violations fail the build + +[layers] # order defines draw order (later = on top) +muros = { file = "muros.cf", locked = false } +puertas = { file = "puertas.cf", locked = false } + +[constraints] # optional, validated on build/check +puertas.parent = "muros" # child bbox must fit inside parent bbox +cotas.belongs_to = "muros" # child primitives reference parent ids via belongs_to +"muros → puertas" = "spatial_dependency" # movement warning (informational) + +# Planos (drawing sheets): named views of the model with a title block. +# Render with `cadspec 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`) + +Each `.cf` may start with optional layer metadata: + +```toml +[layer] +name = "muros" +color = "#FFFFFF" # default color for the layer +line_weight = 0.35 # default stroke weight in mm +visible = true +locked = false +``` + +### Common attributes (valid on every primitive, all optional) + +```toml +id = "ln-001" # unique within the layer; needed for hatch boundaries / belongs_to +color = "#FF5050" # overrides layer color +weight = 0.50 # line weight in mm (0.13 thin … 0.70 thick) +style = "solid" # solid | dashed | dotted | dashdot +visible = true +locked = false +belongs_to = "id" # reference to a primitive id in the parent layer +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 (`cadspec preview --3d`, or the viewer's `3D` button). + +### Primitives + +```toml +[[line]] # straight segment +from = [0.0, 0.0] +to = [8.5, 0.0] + +[[polyline]] # multi-vertex path; closed = true makes it a polygon +points = [[0.0, 0.0], [8.5, 0.0], [8.5, 6.0], [0.0, 6.0]] +closed = true + +[[rect]] # axis-aligned rectangle from bottom-left origin +origin = [1.0, 1.0] +width = 3.5 +height = 4.0 + +[[circle]] +center = [4.0, 3.0] +radius = 0.5 + +[[arc]] # angles in degrees, counterclockwise from +X +center = [2.0, 2.0] +radius = 0.9 +from_angle = 0.0 +to_angle = 90.0 + +[[text]] +position = [4.0, 3.0] +content = "SALA" +size = 0.25 # text height in world units +align = "center" # left | center | right +font = "monospace" # CSS font-family, SVG/PNG preview only (default: monospace) +rotation = 0.0 # degrees, counterclockwise, about the anchor point (DXF-compatible) +bold = false # SVG/PNG preview only +italic = false # SVG/PNG preview only + +[[point]] # reference marker (drawn as a cross) +position = [3.0, 3.0] + +[[dim]] # dimension; the measured distance is labeled automatically +type = "linear" # linear | angular | radial +from = [0.0, 0.0] +to = [8.5, 0.0] +offset = -0.8 # distance from the measured element (sign = side) +text_size = 0.25 # label height in world units (optional) +precision = 2 # decimals in the measured value (default 2) +show_units = true # append project units to the label (default true) + +[[hatch]] # pattern fill inside a closed boundary +boundary = "pl-001" # id of a closed polyline or rect in the same file +pattern = "ansi31" # ansi31 | ansi32 | ansi33 | ansi34 | solid | none +scale = 1.0 +angle = 45.0 + +[[fill]] # solid fill; boundary id or inline points +points = [[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [0.0, 3.0]] +color = "#808080" + +[[group]] # logical grouping of primitives by id +members = ["ln-001", "rc-001"] +``` + +### Construction tools + +Arrays and mirrors expand into concrete primitives at build time. Copies get +derived ids — `pl-001@1`, `pl-001@2`, … (array) and `pl-001@m` (mirror) — so +they can be highlighted or referenced. Edit the base entity or the +`[[array]]`/`[[mirror]]` block to change all copies at once. + +```toml +[[array]] # repeat targets in a line or around a center +target = "pl-huella" # or targets = ["id-a", "id-b"] +mode = "polar" # polar: spiral stairs, gear teeth, radial columns +count = 16 # total instances, including the original +center = [0.0, 0.0] # polar: rotation center +step_angle = 22.5 # polar: degrees per copy, counterclockwise +rotate_items = true # polar: false = orbit only, keep orientation + +[[array]] +target = "rc-banco" +mode = "linear" # linear: equally spaced series +count = 3 +offset = [1.3, 0.0] # displacement per copy + +[[mirror]] # mirror targets across an axis (two points) +targets = ["pl-nave", "ar-puerta"] +axis = [[2.5, 0.0], [2.5, 1.0]] +``` + +### 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]. +- Use floats (`8.5`, `0.0`) for all coordinates. +- Give every primitive a short prefixed id: `ln-` lines, `pl-` polylines, + `rc-` rects, `ci-` circles, `ar-` arcs, `tx-` text, `dm-` dims, `ht-` hatches. +- Walls are typically `weight = 0.50`, furniture `0.25`, annotations `0.18`. + +## Workflow + +```bash +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 `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 +`cadspec preview --highlight ` to visually confirm the change landed +where intended (highlighted entities get labeled amber markers). + +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). +"##; + +/// Print the `.cf` language reference to stdout. +pub fn print_schema() { + println!("{}", CF_REFERENCE); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reference_covers_all_primitives() { + for primitive in [ + "[[line]]", + "[[polyline]]", + "[[rect]]", + "[[circle]]", + "[[arc]]", + "[[text]]", + "[[point]]", + "[[dim]]", + "[[hatch]]", + "[[fill]]", + "[[group]]", + "[[array]]", + "[[mirror]]", + ] { + assert!(CF_REFERENCE.contains(primitive), "missing {}", primitive); + } + } + + #[test] + fn reference_examples_are_valid_toml() { + // Every fenced toml block in the reference must parse. + let mut in_block = false; + let mut block = String::new(); + for line in CF_REFERENCE.lines() { + if line.starts_with("```toml") { + in_block = true; + block.clear(); + } else if line.starts_with("```") && in_block { + in_block = false; + let parsed: Result = toml::from_str(&block); + assert!(parsed.is_ok(), "invalid TOML block:\n{}", block); + } else if in_block { + block.push_str(line); + block.push('\n'); + } + } + } +} diff --git a/src/serve.rs b/src/serve.rs new file mode 100644 index 0000000..ea407f3 --- /dev/null +++ b/src/serve.rs @@ -0,0 +1,1541 @@ +//! 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 +//! browser refreshes instantly, build errors show as an overlay. +//! +//! Viewer features: pan/zoom, click-to-inspect any entity (shows its source +//! TOML block, copyable for targeted agent edits), per-layer visibility with +//! a ghost mode for tracing over other floors, and 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::{bail, Context, Result}; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, Write}; +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, Instant}; + +const SVG_WIDTH: u32 = 1600; +const DEBOUNCE: Duration = Duration::from_millis(80); +const SSE_KEEPALIVE: Duration = Duration::from_secs(15); + +struct LiveState { + /// Arc so request handlers serve the SVG without copying it. + svg: Arc, + /// 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 +/// lands, instead of polling. +struct Live { + state: Mutex, + changed: Condvar, + project_dir: PathBuf, +} + +type Shared = Arc; + +/// Start the live preview server (blocks until killed). +pub fn serve_project(project_dir: &Path, port: u16, open: bool) -> Result<()> { + let project = parse_project(&project_dir.join("project.toml"))?; + let project_dir = project_dir + .canonicalize() + .unwrap_or_else(|_| project_dir.to_path_buf()); + + let state: Shared = Arc::new(Live { + state: Mutex::new(LiveState { + svg: Arc::new(String::new()), + 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(), + }); + + rebuild(&project_dir, &state); + + let listener = TcpListener::bind(("127.0.0.1", port)) + .with_context(|| format!("Cannot bind 127.0.0.1:{} (port in use?)", port))?; + let url = format!("http://127.0.0.1:{}", port); + + println!("◉ cadspec serve — {}", project.project.name); + println!(" Preview: {}", url); + println!(" Watching: {}", project_dir.display()); + println!(); + println!(" Edit .cf files — the browser updates automatically."); + println!(" Click an entity in the viewer to inspect/copy its TOML."); + println!(" Press Ctrl+C to stop."); + + spawn_watcher(project_dir.clone(), Arc::clone(&state))?; + + if open { + open_browser(&url); + } + + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let state = Arc::clone(&state); + std::thread::spawn(move || { + let _ = handle_connection(stream, &state); + }); + } + Ok(()) +} + +// ── 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(".cadspec") +} + +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!("◉ cadspec serve already running (pid {pid})"); + println!(" Preview: {url}"); + println!(" Stop with: cadspec 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 cadspec 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!("◉ cadspec serve — running in background (pid {pid})"); + println!(" Preview: {url}"); + println!(" Logs: {}", log.display()); + println!(" Stop with: cadspec 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 cadspec 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 cadspec serve (pid {pid})."); + Ok(()) + } else { + bail!("failed to stop process {pid}") + } +} + +fn rebuild(project_dir: &Path, state: &Shared) { + 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, + &project.project.units, + &layers, + 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(); + 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, svg3d, layers, planos)) => { + st.svg = Arc::new(svg); + st.svg3d = Arc::new(svg3d); + st.layers = layers; + st.planos = planos; + st.error = None; + } + Err(e) => { + st.error = Some(format!("{:#}", e)); + } + } + st.version += 1; + drop(st); + 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"))?; + 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( + move |res: Result| { + if let Ok(event) = res { + let _ = tx.send(event); + } + }, + notify::Config::default(), + )?; + watcher.watch(&project_dir, RecursiveMode::NonRecursive)?; + + std::thread::spawn(move || { + // Keep the watcher alive inside the thread. + let _watcher = watcher; + while let Ok(event) = rx.recv() { + if !is_relevant(&event) { + continue; + } + // Debounce: absorb the burst of events an editor save produces. + std::thread::sleep(DEBOUNCE); + while rx.try_recv().is_ok() {} + + rebuild(&project_dir, &state); + let st = state.state.lock().unwrap(); + match &st.error { + None => println!("⟳ rebuilt (v{})", st.version), + Some(e) => println!("✗ build error (v{}): {}", st.version, e), + } + } + }); + Ok(()) +} + +fn is_relevant(event: &Event) -> bool { + match event.kind { + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {} + _ => return false, + } + event.paths.iter().any(|p| { + p.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e == "cf" || e == "toml") + }) +} + +// ── HTTP ──────────────────────────────────────────────────────────────── + +fn handle_connection(stream: TcpStream, state: &Shared) -> std::io::Result<()> { + // Small localhost responses: Nagle's algorithm only adds latency here. + let _ = stream.set_nodelay(true); + let mut reader = BufReader::new(stream.try_clone()?); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + + let target = request_line.split_whitespace().nth(1).unwrap_or("/"); + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p, q), + None => (target, ""), + }; + + match path { + "/" => { + let html = index_html(&state.state.lock().unwrap().project_name); + respond( + stream, + "200 OK", + "text/html; charset=utf-8", + html.as_bytes(), + ) + } + "/preview.svg" => { + let svg = Arc::clone(&state.state.lock().unwrap().svg); + respond(stream, "200 OK", "image/svg+xml", svg.as_bytes()) + } + "/preview3d.svg" => { + 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(); + 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 + .layers + .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); + respond(stream, "200 OK", "application/json", body.as_bytes()) + } + "/entity" => { + let id = query_param(query, "id").unwrap_or_default(); + let body = entity_block_json(&state.project_dir, &id); + respond(stream, "200 OK", "application/json", body.as_bytes()) + } + // ── Built-in .cf editor ──────────────────────────────────────────── + "/files" => { + let mut names: Vec = std::fs::read_dir(&state.project_dir) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".cf")) + .collect() + }) + .unwrap_or_default(); + names.sort(); + let body = serde_json::json!({ "files": names }).to_string(); + respond(stream, "200 OK", "application/json", body.as_bytes()) + } + "/file" => { + let name = query_param(query, "name").unwrap_or_default(); + match safe_cf_name(&name) { + Some(n) => match std::fs::read_to_string(state.project_dir.join(&n)) { + Ok(s) => respond(stream, "200 OK", "text/plain; charset=utf-8", s.as_bytes()), + Err(_) => respond(stream, "404 Not Found", "text/plain", b"not found"), + }, + None => respond(stream, "400 Bad Request", "text/plain", b"bad name"), + } + } + "/save" => { + let name = query_param(query, "name").unwrap_or_default(); + let body = read_request_body(&mut reader); + match safe_cf_name(&name) { + Some(n) => match std::fs::write(state.project_dir.join(&n), &body) { + Ok(()) => { + rebuild(&state.project_dir, state); + let st = state.state.lock().unwrap(); + let resp = serde_json::json!({ + "ok": st.error.is_none(), + "version": st.version, + "error": st.error, + }) + .to_string(); + drop(st); + respond(stream, "200 OK", "application/json", resp.as_bytes()) + } + Err(e) => respond( + stream, + "500 Internal Server Error", + "text/plain", + format!("write error: {e}").as_bytes(), + ), + }, + None => respond(stream, "400 Bad Request", "text/plain", b"bad name"), + } + } + "/events" => serve_events(stream, state), + "/favicon.svg" => respond(stream, "200 OK", "image/svg+xml", FAVICON_SVG.as_bytes()), + _ => respond(stream, "404 Not Found", "text/plain", b"not found"), + } +} + +/// Accept only a bare `*.cf` filename (no path traversal) for the editor. +fn safe_cf_name(name: &str) -> Option { + if name.is_empty() + || name.contains('/') + || name.contains('\\') + || name.contains("..") + || !name.ends_with(".cf") + { + return None; + } + Some(name.to_string()) +} + +/// Read the remaining request headers, then the body of `Content-Length` bytes. +fn read_request_body(reader: &mut BufReader) -> Vec { + let mut len = 0usize; + let mut line = String::new(); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + break; + } + let t = line.trim_end(); + if t.is_empty() { + break; + } + if let Some(v) = t.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + let _ = std::io::Read::read_exact(reader, &mut body); + body +} + +fn query_param(query: &str, key: &str) -> Option { + query.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=')?; + (k == key).then(|| percent_decode(v)) + }) +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'%' if i + 2 < bytes.len() => { + if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) { + out.push(h * 16 + l); + i += 3; + } else { + out.push(b'%'); + i += 1; + } + } + b'+' => { + out.push(b' '); + i += 1; + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// Find the raw TOML block that defines `id` and return it as JSON, so the +/// viewer can hand an agent the exact source to edit. +fn entity_block_json(project_dir: &Path, id: &str) -> String { + // Generated copies (array/mirror) carry an @ suffix; their source is the base id. + let base = id.split('@').next().unwrap_or(id); + + let lookup = || -> Option<(String, String, String)> { + let project = parse_project(&project_dir.join("project.toml")).ok()?; + for (layer, entry) in &project.layers { + let path = project_dir.join(&entry.file); + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + if let Some(block) = find_block(&text, base) { + return Some((layer.clone(), entry.file.clone(), block)); + } + } + None + }; + + match lookup() { + Some((layer, file, block)) => serde_json::json!({ + "id": id, + "base_id": base, + "generated": id != base, + "layer": layer, + "file": file, + "block": block, + }) + .to_string(), + None => serde_json::json!({ "id": id, "error": "not found" }).to_string(), + } +} + +/// Extract the `[[...]]` block (with leading comments) that contains `id = ""`. +/// +/// Uses toml_edit spans, so multi-line values (e.g. `points = [` …) are kept +/// intact instead of being cut at lines that merely look like TOML headers. +fn find_block(text: &str, id: &str) -> Option { + let doc = toml_edit::ImDocument::parse(text).ok()?; + let mut span: Option> = None; + for (_key, item) in doc.iter() { + if let toml_edit::Item::ArrayOfTables(tables) = item { + for table in tables.iter() { + if table.get("id").and_then(|v| v.as_str()) == Some(id) { + span = table.span(); + } + } + } + } + let span = span?; + + let lines: Vec<&str> = text.lines().collect(); + let span_start_line = text[..span.start.min(text.len())].matches('\n').count(); + let span_end_line = text[..span.end.min(text.len())] + .matches('\n') + .count() + .min(lines.len().saturating_sub(1)); + + // The span covers the key/value pairs; step back to the [[header]] line + // and pull in any comment lines directly above it. + let header = lines[..=span_start_line.min(lines.len().saturating_sub(1))] + .iter() + .rposition(|l| l.trim_start().starts_with("[["))?; + let mut start = header; + while start > 0 && lines[start - 1].trim_start().starts_with('#') { + start -= 1; + } + + Some( + lines[start..=span_end_line] + .join("\n") + .trim_end() + .to_string(), + ) +} + +fn respond( + mut stream: TcpStream, + status: &str, + content_type: &str, + body: &[u8], +) -> std::io::Result<()> { + write!( + stream, + "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", + status, + content_type, + body.len() + )?; + stream.write_all(body)?; + stream.flush() +} + +/// Server-sent events: the condvar wakes us the instant a rebuild lands, so +/// the browser is notified with sub-millisecond latency instead of polling. +fn serve_events(mut stream: TcpStream, state: &Shared) -> std::io::Result<()> { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\nConnection: keep-alive\r\n\r\n" + )?; + stream.flush()?; + + let mut last = 0u64; + loop { + let current = { + let mut st = state.state.lock().unwrap(); + while st.version == last { + let (guard, timeout) = state + .changed + .wait_timeout(st, SSE_KEEPALIVE) + .map_err(|_| std::io::Error::other("state poisoned"))?; + st = guard; + if timeout.timed_out() && st.version == last { + drop(st); + // Keep-alive comment so dead clients are detected. + write!(stream, ": ping\n\n")?; + stream.flush()?; + st = state.state.lock().unwrap(); + } + } + st.version + }; + last = current; + write!(stream, "data: {}\n\n", current)?; + stream.flush()?; + } +} + +fn open_browser(url: &str) { + let result = if cfg!(target_os = "macos") { + std::process::Command::new("open").arg(url).spawn() + } else if cfg!(target_os = "windows") { + std::process::Command::new("cmd") + .args(["/C", "start", url]) + .spawn() + } else { + std::process::Command::new("xdg-open").arg(url).spawn() + }; + if result.is_err() { + println!(" (could not open browser automatically)"); + } +} + +// ── Frontend ──────────────────────────────────────────────────────────── + +fn index_html(project_name: &str) -> String { + INDEX_HTML.replace("{{PROJECT_NAME}}", &html_escape(project_name)) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +const FAVICON_SVG: &str = r##"cs"##; + +const INDEX_HTML: &str = r##" + + + +{{PROJECT_NAME}} — cadspec live + + + + +
+ + {{PROJECT_NAME}} + cadspec live + v0 + + + + edit .cf files — preview updates automatically +
+
+ +
+ +
+
+
+ +

+  
+ +
+
+
click: inspect entity · scroll: zoom · drag: pan · double-click: fit · 1-9: cycle layer (on/ghost/off) · 3: 3D · Esc: deselect
+ + + + + + +"##; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn index_html_injects_project_name() { + let html = index_html("Casa "); + assert!(html.contains("Casa <Lote 12>")); + assert!(!html.contains("{{PROJECT_NAME}}")); + } + + #[test] + fn index_html_has_favicon_and_branding() { + let html = index_html("proj"); + assert!(html.contains("favicon.svg")); + assert!(html.contains("cadspec")); + } + + #[test] + fn index_html_is_dark_only_no_theme_toggle() { + let html = index_html("proj"); + assert!(!html.contains("data-theme")); + assert!(!html.contains("btntheme")); + assert!(!html.contains("prefers-color-scheme")); + } + + #[test] + fn index_html_exposes_editor_reload_hook() { + let html = index_html("proj"); + assert!(html.contains("__reloadEditorIfClean")); + } + + #[test] + fn relevant_events_filter_by_extension() { + use notify::event::{CreateKind, EventAttributes}; + let mut event = Event { + kind: EventKind::Create(CreateKind::File), + paths: vec![PathBuf::from("/p/muros.cf")], + attrs: EventAttributes::new(), + }; + assert!(is_relevant(&event)); + event.paths = vec![PathBuf::from("/p/output.dxf")]; + assert!(!is_relevant(&event)); + event.paths = vec![PathBuf::from("/p/preview.svg")]; + assert!(!is_relevant(&event)); + } + + #[test] + fn find_block_extracts_entity_with_comments() { + let text = r#"[layer] +name = "muros" + +# Puerta principal +[[arc]] +id = "ar-puerta" +center = [0.0, 2.5] +radius = 0.9 + +[[line]] +id = "ln-otro" +from = [0.0, 0.0] +to = [1.0, 0.0] +"#; + let block = find_block(text, "ar-puerta").unwrap(); + assert!(block.starts_with("# Puerta principal")); + assert!(block.contains("[[arc]]")); + assert!(block.contains("radius = 0.9")); + assert!(!block.contains("ln-otro")); + + let last = find_block(text, "ln-otro").unwrap(); + assert!(last.contains("[[line]]")); + assert!(last.contains("to = [1.0, 0.0]")); + + assert!(find_block(text, "missing").is_none()); + } + + #[test] + fn find_block_keeps_multiline_arrays_intact() { + let text = r#"[[polyline]] +id = "pl-huella" +points = [ + [0.30, 0.0], + [1.55, 0.0], +] +closed = true + +[[circle]] +id = "ci-otro" +center = [0.0, 0.0] +radius = 1.0 +"#; + let block = find_block(text, "pl-huella").unwrap(); + assert!(block.contains("[1.55, 0.0],"), "block: {}", block); + assert!(block.contains("closed = true"), "block: {}", block); + assert!(!block.contains("ci-otro")); + } + + #[test] + fn find_block_does_not_match_belongs_to() { + let text = r#"[[rect]] +id = "real" +belongs_to = "fake" +width = 1.0 +"#; + assert!(find_block(text, "fake").is_none()); + assert!(find_block(text, "real").is_some()); + } + + #[test] + fn query_param_decodes_percent_encoding() { + assert_eq!(query_param("id=ln%2D001", "id").as_deref(), Some("ln-001")); + assert_eq!(query_param("a=1&id=tx+1", "id").as_deref(), Some("tx 1")); + assert_eq!(query_param("a=1", "id"), None); + } +} diff --git a/src/svg.rs b/src/svg.rs new file mode 100644 index 0000000..2c64660 --- /dev/null +++ b/src/svg.rs @@ -0,0 +1,1188 @@ +//! SVG renderer — full-fidelity vector rendering of a project. +//! +//! Renders real text, dimension lines with measured values, hatch patterns +//! clipped to their boundary, line styles, and optional highlight markers. +//! It is the single rendering backend: `cadspec serve` displays the SVG +//! directly and the PNG preview rasterizes it. + +use crate::compiler::resolve_boundary; +use crate::model::{CfFile, CommonAttrs, LineStyle, TextAlign}; +use crate::parser::{parse_cf, parse_project}; +use crate::transform::expand_cf; +use anyhow::{Context, Result}; +use std::fmt::Write as _; +use std::path::Path; + +const PADDING: f64 = 1.0; // world units around content +const MAX_HEIGHT_PX: f64 = 4096.0; +const BG_COLOR: &str = "#141414"; +const GRID_COLOR: &str = "#232323"; +const AXIS_COLOR: &str = "#333333"; + +const LAYER_PALETTE: &[&str] = &[ + "#FFFFFF", "#FF5050", "#50FF50", "#50C8FF", "#FFC850", "#C878FF", "#FF9632", +]; + +// ── Public API ────────────────────────────────────────────────────────── + +/// A rendered SVG plus the world→pixel transform used to produce it, so +/// consumers (PNG rasterizer, metadata) can map world coordinates to pixels. +pub struct Scene { + pub svg: String, + /// Pixels per world unit. + pub px_per_unit: f64, + /// World X of the left canvas edge. + pub offset_x: f64, + /// World Y of the bottom canvas edge. + pub offset_y: f64, + /// Canvas height in world units (used for the Y flip). + pub world_h: f64, + pub width_px: f64, + pub height_px: f64, + /// Content bounds (without padding): [min_x, min_y, max_x, max_y]. + pub world_bounds: [f64; 4], +} + +impl Scene { + /// Map a world coordinate to image pixels. + pub fn world_to_px(&self, x: f64, y: f64) -> (f64, f64) { + ( + (x - self.offset_x) * self.px_per_unit, + (self.world_h - (y - self.offset_y)) * self.px_per_unit, + ) + } +} + +/// Parse `project.toml` and its (filtered) layer files in one pass, with +/// `[[array]]`/`[[mirror]]` constructions expanded into concrete primitives. +pub fn load_project_layers( + project_dir: &Path, + layer_filter: Option<&str>, +) -> Result<(crate::parser::ProjectFile, Vec<(String, CfFile)>)> { + let project = parse_project(&project_dir.join("project.toml"))?; + + let layers: Vec<(String, CfFile)> = project + .layers + .iter() + .filter(|(name, _)| layer_filter.is_none_or(|f| f == *name)) + .map(|(name, entry)| { + let cf = parse_cf(&project_dir.join(&entry.file)) + .with_context(|| format!("Failed to parse layer '{}'", name))?; + Ok((name.clone(), expand_cf(&cf))) + }) + .collect::>()?; + + Ok((project, layers)) +} + +/// Render already-parsed layers to a [`Scene`], optionally highlighting ids. +pub fn render_scene_from( + project_name: &str, + units: &str, + layers: &[(String, CfFile)], + width: u32, + highlight: &[String], +) -> Scene { + render_layers(project_name, units, layers, width, highlight) +} + +/// Render the project to a [`Scene`], optionally highlighting entities by id. +pub fn render_scene( + project_dir: &Path, + layer_filter: Option<&str>, + width: u32, + highlight: &[String], +) -> Result { + let (project, layers) = load_project_layers(project_dir, layer_filter)?; + Ok(render_layers( + &project.project.name, + &project.project.units, + &layers, + width, + highlight, + )) +} + +/// Render the project to an SVG string. +pub fn render_svg(project_dir: &Path, layer_filter: Option<&str>, width: u32) -> Result { + Ok(render_scene(project_dir, layer_filter, width, &[])?.svg) +} + +/// Display color of a layer: its declared color, or a palette color by index. +pub fn layer_display_color(cf: &CfFile, index: usize) -> String { + cf.layer_meta + .as_ref() + .and_then(|m| m.color.clone()) + .unwrap_or_else(|| LAYER_PALETTE[index % LAYER_PALETTE.len()].to_string()) +} + +// ── Canvas ────────────────────────────────────────────────────────────── + +struct Canvas { + out: String, + scale: f64, + offset_x: f64, + offset_y: f64, + world_h: f64, + width_px: f64, + height_px: f64, + clip_seq: usize, +} + +impl Canvas { + fn world_to_px(&self, x: f64, y: f64) -> (f64, f64) { + let px = (x - self.offset_x) * self.scale; + let py = (self.world_h - (y - self.offset_y)) * self.scale; + (px, py) + } + + fn points_attr(&self, points: &[(f64, f64)]) -> String { + let mut s = String::with_capacity(points.len() * 16); + for (i, &(x, y)) in points.iter().enumerate() { + if i > 0 { + s.push(' '); + } + let (px, py) = self.world_to_px(x, y); + let _ = write!(s, "{:.2},{:.2}", px, py); + } + s + } +} + +#[derive(Clone)] +struct Style { + color: String, + width_px: f64, + dash: Option<&'static str>, +} + +fn resolve_style(common: &CommonAttrs, layer_color: &str, default_weight: f64) -> Style { + let color = common + .color + .clone() + .unwrap_or_else(|| layer_color.to_string()); + let weight = common.weight.unwrap_or(default_weight); + let width_px = ((weight / 0.35) * 1.4).clamp(0.6, 6.0); + let dash = match common.style { + Some(LineStyle::Dashed) => Some("8,6"), + Some(LineStyle::Dotted) => Some("1.5,5"), + Some(LineStyle::Dashdot) => Some("10,4,1.5,4"), + _ => None, + }; + Style { + color, + width_px, + dash, + } +} + +fn stroke_attrs(s: &Style) -> String { + let mut a = format!( + r#"stroke="{}" stroke-width="{:.2}" fill="none""#, + s.color, s.width_px + ); + if let Some(dash) = s.dash { + let _ = write!(a, r#" stroke-dasharray="{}""#, dash); + } + a +} + +fn id_attr(common: &CommonAttrs) -> String { + match &common.id { + Some(id) => format!(r#" data-id="{}""#, xml_escape(id)), + None => String::new(), + } +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +// ── Bounds ────────────────────────────────────────────────────────────── + +struct Bounds { + min_x: f64, + min_y: f64, + max_x: f64, + max_y: f64, +} + +impl Bounds { + fn empty() -> Self { + Self { + min_x: f64::MAX, + min_y: f64::MAX, + max_x: f64::MIN, + max_y: f64::MIN, + } + } + + fn add(&mut self, x: f64, y: f64) { + self.min_x = self.min_x.min(x); + self.min_y = self.min_y.min(y); + self.max_x = self.max_x.max(x); + self.max_y = self.max_y.max(y); + } + + fn is_empty(&self) -> bool { + self.min_x > self.max_x + } +} + +fn compute_bounds(layers: &[(String, CfFile)]) -> Bounds { + let mut b = Bounds::empty(); + for (_, cf) in layers { + for e in &cf.lines { + b.add(e.from[0], e.from[1]); + b.add(e.to[0], e.to[1]); + } + for e in &cf.polylines { + for p in &e.points { + b.add(p[0], p[1]); + } + } + for e in &cf.rects { + b.add(e.origin[0], e.origin[1]); + b.add(e.origin[0] + e.width, e.origin[1] + e.height); + } + for e in &cf.circles { + b.add(e.center[0] - e.radius, e.center[1] - e.radius); + b.add(e.center[0] + e.radius, e.center[1] + e.radius); + } + for e in &cf.arcs { + b.add(e.center[0] - e.radius, e.center[1] - e.radius); + b.add(e.center[0] + e.radius, e.center[1] + e.radius); + } + for e in &cf.texts { + b.add(e.position[0], e.position[1]); + } + for e in &cf.points { + b.add(e.position[0], e.position[1]); + } + for e in &cf.dims { + b.add(e.from[0], e.from[1]); + b.add(e.to[0], e.to[1]); + } + for e in &cf.fills { + if let Some(points) = &e.points { + for p in points { + b.add(p[0], p[1]); + } + } + } + } + if b.is_empty() { + Bounds { + min_x: 0.0, + min_y: 0.0, + max_x: 10.0, + max_y: 10.0, + } + } else { + b + } +} + +// ── Rendering ─────────────────────────────────────────────────────────── + +fn render_layers( + project_name: &str, + units: &str, + layers: &[(String, CfFile)], + width: u32, + highlight: &[String], +) -> Scene { + let bounds = compute_bounds(layers); + let world_w = bounds.max_x - bounds.min_x + 2.0 * PADDING; + let world_h = bounds.max_y - bounds.min_y + 2.0 * PADDING; + + let width_px = width as f64; + let height_px = (width_px * world_h / world_w).min(MAX_HEIGHT_PX); + let scale = (width_px / world_w).min(height_px / world_h); + + let mut canvas = Canvas { + out: String::with_capacity(16 * 1024), + scale, + offset_x: bounds.min_x - PADDING, + offset_y: bounds.min_y - PADDING, + world_h, + width_px, + height_px, + clip_seq: 0, + }; + + let _ = write!( + canvas.out, + r#""#, + w = canvas.width_px, + h = canvas.height_px, + name = xml_escape(project_name), + ); + let _ = write!( + canvas.out, + r#""#, + BG_COLOR + ); + + draw_grid(&mut canvas, &bounds); + + for (idx, (layer_name, cf)) in layers.iter().enumerate() { + let layer_color = layer_display_color(cf, idx); + let default_weight = cf + .layer_meta + .as_ref() + .and_then(|m| m.line_weight) + .unwrap_or(0.35); + let visible = cf.layer_meta.as_ref().map(|m| m.visible).unwrap_or(true); + if !visible { + continue; + } + let _ = write!(canvas.out, r#""#, xml_escape(layer_name)); + render_layer( + &mut canvas, + cf, + layer_name, + &layer_color, + default_weight, + units, + ); + canvas.out.push_str(""); + } + + if !highlight.is_empty() { + draw_highlights(&mut canvas, layers, highlight); + } + + canvas.out.push_str(""); + Scene { + px_per_unit: canvas.scale, + offset_x: canvas.offset_x, + offset_y: canvas.offset_y, + world_h: canvas.world_h, + width_px: canvas.width_px, + height_px: canvas.height_px, + world_bounds: [bounds.min_x, bounds.min_y, bounds.max_x, bounds.max_y], + svg: canvas.out, + } +} + +// ── Highlights (visual verification markers for agents) ──────────────── + +const HIGHLIGHT_COLOR: &str = "#FFB300"; + +fn draw_highlights(c: &mut Canvas, layers: &[(String, CfFile)], highlight: &[String]) { + c.out.push_str(r#""#); + for (_, cf) in layers { + for rec in enumerate_entities(cf) { + let Some(id) = &rec.id else { continue }; + if !highlight.iter().any(|h| h == id) { + continue; + } + let (x1, y1) = c.world_to_px(rec.bbox[0], rec.bbox[3]); // top-left + let (x2, y2) = c.world_to_px(rec.bbox[2], rec.bbox[1]); // bottom-right + let margin = 8.0; + let _ = write!( + c.out, + r#""#, + x1 - margin, + y1 - margin, + (x2 - x1) + 2.0 * margin, + (y2 - y1) + 2.0 * margin, + HIGHLIGHT_COLOR, + xml_escape(id), + ); + let _ = write!( + c.out, + r#"{}"#, + x1 - margin, + y1 - margin - 6.0, + HIGHLIGHT_COLOR, + xml_escape(id), + ); + } + } + c.out.push_str(""); +} + +// ── Entity enumeration (shared with the PNG preview metadata) ─────────── + +/// A primitive with its world-space bounding box, for metadata and highlights. +pub struct EntityRecord { + pub id: Option, + pub kind: &'static str, + /// [min_x, min_y, max_x, max_y] in world units. + pub bbox: [f64; 4], + /// Text content, for `text` entities. + pub content: Option, +} + +/// Enumerate the drawable primitives of a layer with their world bounds. +pub fn enumerate_entities(cf: &CfFile) -> Vec { + fn bbox_of(points: &[(f64, f64)]) -> [f64; 4] { + let mut b = Bounds::empty(); + for &(x, y) in points { + b.add(x, y); + } + [b.min_x, b.min_y, b.max_x, b.max_y] + } + + let mut out = Vec::new(); + for e in &cf.lines { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "line", + bbox: bbox_of(&[(e.from[0], e.from[1]), (e.to[0], e.to[1])]), + content: None, + }); + } + for e in &cf.polylines { + let pts: Vec<(f64, f64)> = e.points.iter().map(|p| (p[0], p[1])).collect(); + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "polyline", + bbox: bbox_of(&pts), + content: None, + }); + } + for e in &cf.rects { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "rect", + bbox: [ + e.origin[0], + e.origin[1], + e.origin[0] + e.width, + e.origin[1] + e.height, + ], + content: None, + }); + } + for e in &cf.circles { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "circle", + bbox: [ + e.center[0] - e.radius, + e.center[1] - e.radius, + e.center[0] + e.radius, + e.center[1] + e.radius, + ], + content: None, + }); + } + for e in &cf.arcs { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "arc", + bbox: bbox_of(&arc_points( + e.center[0], + e.center[1], + e.radius, + e.from_angle, + e.to_angle, + )), + content: None, + }); + } + for e in &cf.texts { + // Approximate extent from monospace glyph proportions. This is a rough + // estimate even for the default monospace font, and it is only more + // wrong when `font` names a non-monospace family or `rotation` is set + // (the bbox below is axis-aligned and ignores rotation entirely), so + // `align` anchoring and this bbox are approximate in those cases. + let w = 0.6 * e.size * e.content.chars().count() as f64; + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "text", + bbox: [ + e.position[0], + e.position[1], + e.position[0] + w, + e.position[1] + e.size, + ], + content: Some(e.content.clone()), + }); + } + for e in &cf.points { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "point", + bbox: [ + e.position[0] - 0.05, + e.position[1] - 0.05, + e.position[0] + 0.05, + e.position[1] + 0.05, + ], + content: None, + }); + } + for e in &cf.dims { + let dx = e.to[0] - e.from[0]; + let dy = e.to[1] - e.from[1]; + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let (nx, ny) = (-dy / len, dx / len); + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "dim", + bbox: bbox_of(&[ + (e.from[0], e.from[1]), + (e.to[0], e.to[1]), + (e.from[0] + nx * e.offset, e.from[1] + ny * e.offset), + (e.to[0] + nx * e.offset, e.to[1] + ny * e.offset), + ]), + content: None, + }); + } + for e in &cf.hatches { + let pts = if let Some(boundary_id) = &e.boundary { + resolve_boundary(boundary_id, cf) + } else { + e.points + .as_ref() + .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + }; + if let Some(pts) = pts { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "hatch", + bbox: bbox_of(&pts), + content: None, + }); + } + } + for e in &cf.fills { + let pts = if let Some(boundary_id) = &e.boundary { + resolve_boundary(boundary_id, cf) + } else { + e.points + .as_ref() + .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + }; + if let Some(pts) = pts { + out.push(EntityRecord { + id: e.common.id.clone(), + kind: "fill", + bbox: bbox_of(&pts), + content: None, + }); + } + } + out +} + +fn grid_step(world_w: f64) -> f64 { + const STEPS: &[f64] = &[0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0, 500.0]; + for &s in STEPS { + if world_w / s <= 40.0 { + return s; + } + } + 1000.0 +} + +fn draw_grid(c: &mut Canvas, bounds: &Bounds) { + let step = grid_step(bounds.max_x - bounds.min_x + 2.0 * PADDING); + let x0 = ((bounds.min_x - PADDING) / step).floor() * step; + let x1 = bounds.max_x + PADDING; + let y0 = ((bounds.min_y - PADDING) / step).floor() * step; + let y1 = bounds.max_y + PADDING; + + c.out.push_str(r#""#); + let mut x = x0; + while x <= x1 { + let (px, _) = c.world_to_px(x, 0.0); + let color = if x.abs() < 1e-9 { + AXIS_COLOR + } else { + GRID_COLOR + }; + let _ = write!( + c.out, + r#""#, + h = c.height_px, + ); + x += step; + } + let mut y = y0; + while y <= y1 { + let (_, py) = c.world_to_px(0.0, y); + let color = if y.abs() < 1e-9 { + AXIS_COLOR + } else { + GRID_COLOR + }; + let _ = write!( + c.out, + r#""#, + w = c.width_px, + ); + y += step; + } + c.out.push_str(""); +} + +fn render_layer( + c: &mut Canvas, + cf: &CfFile, + layer_name: &str, + layer_color: &str, + default_weight: f64, + units: &str, +) { + for e in cf.lines.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (x1, y1) = c.world_to_px(e.from[0], e.from[1]); + let (x2, y2) = c.world_to_px(e.to[0], e.to[1]); + let _ = write!( + c.out, + r#""#, + x1, + y1, + x2, + y2, + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + for e in cf.polylines.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let pts: Vec<(f64, f64)> = e.points.iter().map(|p| (p[0], p[1])).collect(); + let tag = if e.closed { "polygon" } else { "polyline" }; + let _ = write!( + c.out, + r#"<{} points="{}" {}{}/>"#, + tag, + c.points_attr(&pts), + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + for e in cf.rects.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (px, py) = c.world_to_px(e.origin[0], e.origin[1] + e.height); + let _ = write!( + c.out, + r#""#, + px, + py, + e.width * c.scale, + e.height * c.scale, + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + for e in cf.circles.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (px, py) = c.world_to_px(e.center[0], e.center[1]); + let _ = write!( + c.out, + r#""#, + px, + py, + e.radius * c.scale, + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + for e in cf.arcs.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let pts = arc_points(e.center[0], e.center[1], e.radius, e.from_angle, e.to_angle); + let _ = write!( + c.out, + r#""#, + c.points_attr(&pts), + stroke_attrs(&s), + id_attr(&e.common) + ); + } + + // Fills and hatches go before text so labels stay readable on top. + for e in cf.fills.iter().filter(|e| e.common.visible) { + let pts = if let Some(boundary_id) = &e.boundary { + let resolved = resolve_boundary(boundary_id, cf); + if resolved.is_none() { + crate::compiler::warn_unresolved_boundary( + "fill", + e.common.id.as_deref(), + boundary_id, + layer_name, + ); + } + resolved + } else { + e.points + .as_ref() + .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + }; + if let Some(pts) = pts { + let s = resolve_style(&e.common, layer_color, default_weight); + let _ = write!( + c.out, + r#""#, + c.points_attr(&pts), + s.color, + id_attr(&e.common) + ); + } + } + + for e in cf.hatches.iter().filter(|e| e.common.visible) { + let boundary = if let Some(boundary_id) = &e.boundary { + let resolved = resolve_boundary(boundary_id, cf); + if resolved.is_none() { + crate::compiler::warn_unresolved_boundary( + "hatch", + e.common.id.as_deref(), + boundary_id, + layer_name, + ); + } + resolved + } else { + e.points + .as_ref() + .map(|p| p.iter().map(|v| (v[0], v[1])).collect()) + }; + if let Some(boundary) = boundary { + let s = resolve_style(&e.common, layer_color, default_weight); + draw_hatch( + c, + &boundary, + e.pattern.as_str(), + e.angle, + 0.1 * e.scale, + &s, + &e.common, + ); + } + } + + for e in cf.dims.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + draw_dim(c, e, &s, units); + } + + for e in cf.points.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (px, py) = c.world_to_px(e.position[0], e.position[1]); + let _ = write!( + c.out, + r#""#, + x0 = px - 4.0, + x1 = px + 4.0, + y0 = py - 4.0, + y1 = py + 4.0, + x = px, + y = py, + attrs = stroke_attrs(&s), + id = id_attr(&e.common) + ); + } + + for e in cf.texts.iter().filter(|e| e.common.visible) { + let s = resolve_style(&e.common, layer_color, default_weight); + let (px, py) = c.world_to_px(e.position[0], e.position[1]); + let anchor = match e.align { + Some(TextAlign::Center) => "middle", + Some(TextAlign::Right) => "end", + _ => "start", + }; + let font_px = (e.size * c.scale).max(1.0); + let font_family = xml_escape(e.font.as_deref().unwrap_or("monospace")); + let mut extra_attrs = String::new(); + if e.bold == Some(true) { + extra_attrs.push_str(r#" font-weight="bold""#); + } + if e.italic == Some(true) { + extra_attrs.push_str(r#" font-style="italic""#); + } + if let Some(angle) = e.rotation { + if angle != 0.0 { + let _ = write!( + extra_attrs, + r#" transform="rotate({:.2}, {:.2}, {:.2})""#, + -angle, px, py + ); + } + } + let _ = write!( + c.out, + r#"{}"#, + px, + py, + font_px, + font_family, + anchor, + s.color, + extra_attrs, + id_attr(&e.common), + xml_escape(&e.content) + ); + } +} + +fn arc_points(cx: f64, cy: f64, radius: f64, from_deg: f64, to_deg: f64) -> Vec<(f64, f64)> { + const STEPS: usize = 48; + let start = from_deg.to_radians(); + let delta = (to_deg.to_radians() - start) / STEPS as f64; + (0..=STEPS) + .map(|i| { + let a = start + delta * i as f64; + (cx + radius * a.cos(), cy + radius * a.sin()) + }) + .collect() +} + +fn draw_dim(c: &mut Canvas, dim: &crate::model::CfDim, s: &Style, units: &str) { + let (from, to, offset) = (dim.from, dim.to, dim.offset); + let dx = to[0] - from[0]; + let dy = to[1] - from[1]; + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-9 { + return; + } + let (ux, uy) = (dx / len, dy / len); + let (nx, ny) = (-uy, ux); + + // Dimension line endpoints, offset along the normal + let a = (from[0] + nx * offset, from[1] + ny * offset); + let b = (to[0] + nx * offset, to[1] + ny * offset); + + let (ax, ay) = c.world_to_px(a.0, a.1); + let (bx, by) = c.world_to_px(b.0, b.1); + let (fx, fy) = c.world_to_px(from[0], from[1]); + let (tx, ty) = c.world_to_px(to[0], to[1]); + + let _ = write!(c.out, r#""#, id_attr(&dim.common)); + // Extension lines + dimension line + let _ = write!( + c.out, + r#""#, + color = s.color, + w = (s.width_px * 0.8).max(0.6), + ); + // Tick marks (45° slashes) at both ends + let tick = 5.0; + for &(px, py) in &[(ax, ay), (bx, by)] { + let _ = write!( + c.out, + r#""#, + px - tick, + py + tick, + px + tick, + py - tick, + s.color, + (s.width_px * 0.8).max(0.6), + ); + } + // Measured value label at the midpoint + let text_size = dim.text_size.unwrap_or(0.0); + let label_gap = if text_size > 0.0 { + text_size * 0.6 + } else { + 0.15 + }; + let mid = ( + (a.0 + b.0) / 2.0 + nx * label_gap, + (a.1 + b.1) / 2.0 + ny * label_gap, + ); + let (mx, my) = c.world_to_px(mid.0, mid.1); + let font_px = if text_size > 0.0 { + (text_size * c.scale).max(1.0) + } else { + (0.22 * c.scale).clamp(9.0, 28.0) + }; + let label = format_dim_label( + len, + dim.precision.unwrap_or(2) as usize, + dim.show_units, + units, + ); + let _ = write!( + c.out, + r#"{}"#, + mx, + my, + font_px, + s.color, + xml_escape(&label), + ); + c.out.push_str(""); +} + +/// Format a dimension label: measured value with the configured precision, +/// optionally followed by the project units. +pub fn format_dim_label( + len: f64, + precision: usize, + show_units: Option, + units: &str, +) -> String { + if show_units.unwrap_or(true) { + format!("{:.prec$} {}", len, units, prec = precision) + } else { + format!("{:.prec$}", len, prec = precision) + } +} + +fn draw_hatch( + c: &mut Canvas, + boundary: &[(f64, f64)], + pattern: &str, + angle_deg: f64, + spacing: f64, + s: &Style, + common: &CommonAttrs, +) { + if boundary.is_empty() || spacing <= 0.0 { + return; + } + + if pattern == "solid" { + let _ = write!( + c.out, + r#""#, + c.points_attr(boundary), + s.color, + id_attr(common) + ); + return; + } + + // Bounding box of the boundary + let mut b = Bounds::empty(); + for &(x, y) in boundary { + b.add(x, y); + } + let cx = (b.min_x + b.max_x) / 2.0; + let cy = (b.min_y + b.max_y) / 2.0; + let half_diag = (((b.max_x - b.min_x).powi(2) + (b.max_y - b.min_y).powi(2)).sqrt()) / 2.0; + + let theta = angle_deg.to_radians(); + let (dx, dy) = (theta.cos(), theta.sin()); + let (nx, ny) = (-dy, dx); + + let n = ((half_diag / spacing).ceil() as i64).min(2000); + + c.clip_seq += 1; + let clip_id = format!("hatch-clip-{}", c.clip_seq); + let _ = write!( + c.out, + r#""#, + clip_id, + c.points_attr(boundary) + ); + let _ = write!( + c.out, + r#""#, + clip_id, + id_attr(common) + ); + let mut path = String::new(); + for k in -n..=n { + let ox = cx + nx * spacing * k as f64; + let oy = cy + ny * spacing * k as f64; + let (x1, y1) = c.world_to_px(ox - dx * half_diag, oy - dy * half_diag); + let (x2, y2) = c.world_to_px(ox + dx * half_diag, oy + dy * half_diag); + let _ = write!(path, "M {:.2} {:.2} L {:.2} {:.2} ", x1, y1, x2, y2); + } + let _ = write!( + c.out, + r#""#, + path.trim_end(), + s.color + ); + c.out.push_str(""); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_layers() -> Vec<(String, CfFile)> { + let toml = r##" +[layer] +name = "test" +color = "#FFFFFF" + +[[line]] +id = "ln-1" +from = [0.0, 0.0] +to = [10.0, 0.0] +style = "dashed" + +[[rect]] +id = "rc-1" +origin = [1.0, 1.0] +width = 3.0 +height = 2.0 + +[[circle]] +center = [5.0, 5.0] +radius = 1.5 + +[[arc]] +center = [2.0, 2.0] +radius = 1.0 +from_angle = 0.0 +to_angle = 90.0 + +[[text]] +position = [5.0, 5.0] +content = "SALA " +size = 0.3 +align = "center" + +[[dim]] +from = [0.0, 0.0] +to = [10.0, 0.0] +offset = -0.8 + +[[polyline]] +id = "pl-room" +points = [[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [0.0, 3.0]] +closed = true + +[[hatch]] +boundary = "pl-room" +pattern = "ansi31" +"##; + let cf: CfFile = toml::from_str(toml).unwrap(); + vec![("test".to_string(), cf)] + } + + #[test] + fn renders_all_primitives() { + let svg = render_layers("demo", "m", &sample_layers(), 1200, &[]).svg; + assert!(svg.starts_with("")); + assert!(svg.contains("")); + } + + #[test] + fn dim_label_shows_measured_length() { + let svg = render_layers("demo", "m", &sample_layers(), 1200, &[]).svg; + assert!(svg.contains("10.00 m")); + } + + #[test] + fn empty_project_renders_default_viewport() { + let svg = render_layers("empty", "m", &[], 800, &[]).svg; + assert!(svg.starts_with("rc-1")); + assert!(!scene.svg.contains(r#"data-highlight="missing-id""#)); + } + + #[test] + fn enumerate_entities_covers_bounds_and_content() { + let layers = sample_layers(); + let records = enumerate_entities(&layers[0].1); + // line, rect, circle, arc, text, dim, polyline, hatch + assert_eq!(records.len(), 8); + let text = records.iter().find(|r| r.kind == "text").unwrap(); + assert_eq!(text.content.as_deref(), Some("SALA ")); + let rect = records.iter().find(|r| r.kind == "rect").unwrap(); + assert_eq!(rect.bbox, [1.0, 1.0, 4.0, 3.0]); + } + + #[test] + fn scene_world_to_px_is_consistent_with_canvas() { + let scene = render_layers("demo", "m", &sample_layers(), 1200, &[]); + // Bottom-left content corner with padding maps inside the canvas + let (px, py) = scene.world_to_px(scene.world_bounds[0], scene.world_bounds[1]); + assert!(px > 0.0 && px < scene.width_px); + assert!(py > 0.0 && py <= scene.height_px); + } + + #[test] + fn grid_step_scales_with_world_size() { + assert_eq!(grid_step(10.0), 0.5); + assert_eq!(grid_step(30.0), 1.0); + assert_eq!(grid_step(300.0), 10.0); + } + + fn text_layer(extra_fields: &str) -> Vec<(String, CfFile)> { + let toml = format!( + r##" +[layer] +name = "test" +color = "#FFFFFF" + +[[text]] +id = "tx-1" +position = [5.0, 5.0] +content = "HOLA" +size = 0.3 +{extra_fields} +"## + ); + let cf: CfFile = toml::from_str(&toml).unwrap(); + vec![("test".to_string(), cf)] + } + + #[test] + fn text_font_is_emitted_in_font_family() { + let svg = render_layers("demo", "m", &text_layer(r#"font = "serif""#), 1200, &[]).svg; + assert!(svg.contains(r#"font-family="serif""#)); + } + + #[test] + fn text_without_font_defaults_to_monospace() { + let svg = render_layers("demo", "m", &text_layer(""), 1200, &[]).svg; + assert!(svg.contains(r#"font-family="monospace""#)); + } + + #[test] + fn text_rotation_emits_negated_svg_transform() { + let svg = render_layers("demo", "m", &text_layer("rotation = 45.0"), 1200, &[]).svg; + assert!(svg.contains("transform=\"rotate(-45.00,")); + } + + #[test] + fn text_bold_and_italic_emit_style_attrs() { + let svg = render_layers( + "demo", + "m", + &text_layer("bold = true\nitalic = true"), + 1200, + &[], + ) + .svg; + assert!(svg.contains(r#"font-weight="bold""#)); + assert!(svg.contains(r#"font-style="italic""#)); + } +} diff --git a/src/transform.rs b/src/transform.rs new file mode 100644 index 0000000..63e3489 --- /dev/null +++ b/src/transform.rs @@ -0,0 +1,490 @@ +//! Transform pass — expands `[[array]]` and `[[mirror]]` constructions into +//! concrete primitives before compilation. +//! +//! A polar array of a tread polyline is a spiral staircase plan; a polar array +//! of a tooth profile is a gear; a mirror duplicates a wing of a building. +//! Expansion happens at load time, so DXF output, SVG/PNG previews, and entity +//! metadata all see the generated geometry with no special cases. +//! +//! Generated copies get derived ids: `tread@1`, `tread@2`, … for arrays and +//! `tread@m` for mirrors, so they can still be referenced (e.g. by hatches) +//! and highlighted. + +use crate::model::{ArrayMode, CfArray, CfFile, CfMirror}; +use std::collections::HashSet; + +// ── Point operations ──────────────────────────────────────────────────── + +#[derive(Clone, Copy)] +enum PointOp { + Translate { + dx: f64, + dy: f64, + }, + Rotate { + cx: f64, + cy: f64, + angle_rad: f64, + }, + /// Mirror across the line through (x0, y0) with direction angle `axis_rad`. + Mirror { + x0: f64, + y0: f64, + axis_rad: f64, + }, +} + +impl PointOp { + fn apply(&self, p: [f64; 2]) -> [f64; 2] { + match *self { + PointOp::Translate { dx, dy } => [p[0] + dx, p[1] + dy], + PointOp::Rotate { cx, cy, angle_rad } => { + let (s, c) = angle_rad.sin_cos(); + let (x, y) = (p[0] - cx, p[1] - cy); + [cx + x * c - y * s, cy + x * s + y * c] + } + PointOp::Mirror { x0, y0, axis_rad } => { + let (s, c) = (2.0 * axis_rad).sin_cos(); + let (x, y) = (p[0] - x0, p[1] - y0); + [x0 + x * c + y * s, y0 + x * s - y * c] + } + } + } + + /// How a direction angle (degrees) maps under this op. + fn apply_angle_deg(&self, deg: f64) -> f64 { + match *self { + PointOp::Translate { .. } => deg, + PointOp::Rotate { angle_rad, .. } => deg + angle_rad.to_degrees(), + PointOp::Mirror { axis_rad, .. } => 2.0 * axis_rad.to_degrees() - deg, + } + } + + fn flips_orientation(&self) -> bool { + matches!(self, PointOp::Mirror { .. }) + } +} + +// ── Expansion ─────────────────────────────────────────────────────────── + +/// Expand all `[[array]]` and `[[mirror]]` entries of a layer into concrete +/// primitives. The original constructions are consumed. +pub fn expand_cf(cf: &CfFile) -> CfFile { + let mut out = cf.clone(); + let arrays = std::mem::take(&mut out.arrays); + let mirrors = std::mem::take(&mut out.mirrors); + + for array in &arrays { + expand_array(&mut out, array); + } + for mirror in &mirrors { + expand_mirror(&mut out, mirror); + } + out +} + +fn target_set(target: &Option, targets: &Option>) -> HashSet { + let mut set = HashSet::new(); + if let Some(t) = target { + set.insert(t.clone()); + } + if let Some(ts) = targets { + set.extend(ts.iter().cloned()); + } + set +} + +fn expand_array(out: &mut CfFile, array: &CfArray) { + let targets = target_set(&array.target, &array.targets); + if targets.is_empty() || array.count < 2 { + return; + } + + for k in 1..array.count { + let op = match array.mode { + ArrayMode::Linear => { + let [dx, dy] = array.offset.unwrap_or([0.0, 0.0]); + PointOp::Translate { + dx: dx * k as f64, + dy: dy * k as f64, + } + } + ArrayMode::Polar => { + let [cx, cy] = array.center.unwrap_or([0.0, 0.0]); + PointOp::Rotate { + cx, + cy, + angle_rad: (array.step_angle.unwrap_or(0.0) * k as f64).to_radians(), + } + } + }; + let orbit_only = array.mode == ArrayMode::Polar && !array.rotate_items; + copy_targets(out, &targets, op, orbit_only, &format!("@{}", k)); + } +} + +fn expand_mirror(out: &mut CfFile, mirror: &CfMirror) { + let targets = target_set(&mirror.target, &mirror.targets); + if targets.is_empty() { + return; + } + let [a, b] = mirror.axis; + let axis_rad = (b[1] - a[1]).atan2(b[0] - a[0]); + let op = PointOp::Mirror { + x0: a[0], + y0: a[1], + axis_rad, + }; + copy_targets(out, &targets, op, false, "@m"); +} + +/// Clone every targeted primitive, transform it, suffix its id, and append it. +fn copy_targets( + out: &mut CfFile, + targets: &HashSet, + op: PointOp, + orbit_only: bool, + suffix: &str, +) { + fn hit(id: &Option, targets: &HashSet) -> bool { + id.as_deref().is_some_and(|i| targets.contains(i)) + } + fn suffixed(id: &Option, suffix: &str) -> Option { + id.as_ref().map(|i| format!("{}{}", i, suffix)) + } + + let mut new_lines = Vec::new(); + for e in out.lines.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.from = op.apply(c.from); + c.to = op.apply(c.to); + c.common.id = suffixed(&e.common.id, suffix); + new_lines.push(c); + } + out.lines.extend(new_lines); + + let mut new_polys = Vec::new(); + for e in out.polylines.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + for p in &mut c.points { + *p = op.apply(*p); + } + c.common.id = suffixed(&e.common.id, suffix); + new_polys.push(c); + } + out.polylines.extend(new_polys); + + // Rects: a translated copy stays a rect; a rotated or mirrored copy + // becomes a closed polyline (rects are axis-aligned by definition). + let mut rect_polys = Vec::new(); + let mut new_rects = Vec::new(); + for e in out.rects.iter().filter(|e| hit(&e.common.id, targets)) { + let corners = [ + e.origin, + [e.origin[0] + e.width, e.origin[1]], + [e.origin[0] + e.width, e.origin[1] + e.height], + [e.origin[0], e.origin[1] + e.height], + ]; + let keeps_shape = matches!(op, PointOp::Translate { .. }) || orbit_only; + if keeps_shape { + let mut c = e.clone(); + if orbit_only { + // Orbit the rect center, keep the rect axis-aligned. + let center = [e.origin[0] + e.width / 2.0, e.origin[1] + e.height / 2.0]; + let moved = op.apply(center); + c.origin = [moved[0] - e.width / 2.0, moved[1] - e.height / 2.0]; + } else { + c.origin = op.apply(c.origin); + } + c.common.id = suffixed(&e.common.id, suffix); + new_rects.push(c); + } else { + rect_polys.push(crate::model::CfPolyline { + points: corners.iter().map(|&p| op.apply(p)).collect(), + closed: true, + common: crate::model::CommonAttrs { + id: suffixed(&e.common.id, suffix), + ..e.common.clone() + }, + }); + } + } + out.rects.extend(new_rects); + out.polylines.extend(rect_polys); + + let mut new_circles = Vec::new(); + for e in out.circles.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.center = op.apply(c.center); + c.common.id = suffixed(&e.common.id, suffix); + new_circles.push(c); + } + out.circles.extend(new_circles); + + let mut new_arcs = Vec::new(); + for e in out.arcs.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.center = op.apply(c.center); + if !orbit_only { + if op.flips_orientation() { + // Reflected sweep: endpoints swap to keep the arc CCW. + let from = op.apply_angle_deg(e.to_angle); + let to = op.apply_angle_deg(e.from_angle); + c.from_angle = from; + c.to_angle = to; + } else { + c.from_angle = op.apply_angle_deg(e.from_angle); + c.to_angle = op.apply_angle_deg(e.to_angle); + } + } + c.common.id = suffixed(&e.common.id, suffix); + new_arcs.push(c); + } + out.arcs.extend(new_arcs); + + // Texts stay upright: only the anchor moves. + let mut new_texts = Vec::new(); + for e in out.texts.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.position = op.apply(c.position); + c.common.id = suffixed(&e.common.id, suffix); + new_texts.push(c); + } + out.texts.extend(new_texts); + + let mut new_points = Vec::new(); + for e in out.points.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.position = op.apply(c.position); + c.common.id = suffixed(&e.common.id, suffix); + new_points.push(c); + } + out.points.extend(new_points); + + let mut new_dims = Vec::new(); + for e in out.dims.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + c.from = op.apply(c.from); + c.to = op.apply(c.to); + if op.flips_orientation() { + c.offset = -c.offset; + } + c.common.id = suffixed(&e.common.id, suffix); + new_dims.push(c); + } + out.dims.extend(new_dims); + + // Hatches/fills follow their boundary: if the boundary was copied too, + // the copy references the copied boundary id. + let mut new_hatches = Vec::new(); + for e in out.hatches.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + if let Some(boundary) = &e.boundary { + if targets.contains(boundary) { + c.boundary = Some(format!("{}{}", boundary, suffix)); + } + } + if let Some(points) = &mut c.points { + for p in points { + *p = op.apply(*p); + } + } + c.common.id = suffixed(&e.common.id, suffix); + new_hatches.push(c); + } + out.hatches.extend(new_hatches); + + let mut new_fills = Vec::new(); + for e in out.fills.iter().filter(|e| hit(&e.common.id, targets)) { + let mut c = e.clone(); + if let Some(boundary) = &e.boundary { + if targets.contains(boundary) { + c.boundary = Some(format!("{}{}", boundary, suffix)); + } + } + if let Some(points) = &mut c.points { + for p in points { + *p = op.apply(*p); + } + } + c.common.id = suffixed(&e.common.id, suffix); + new_fills.push(c); + } + out.fills.extend(new_fills); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(toml: &str) -> CfFile { + toml::from_str(toml).unwrap() + } + + fn assert_close(actual: [f64; 2], expected: [f64; 2]) { + assert!( + (actual[0] - expected[0]).abs() < 1e-9 && (actual[1] - expected[1]).abs() < 1e-9, + "expected {:?}, got {:?}", + expected, + actual + ); + } + + #[test] + fn linear_array_translates_copies() { + let cf = parse( + r#" +[[rect]] +id = "col" +origin = [0.0, 0.0] +width = 0.3 +height = 0.3 + +[[array]] +target = "col" +mode = "linear" +count = 4 +offset = [2.0, 0.0] +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.rects.len(), 4); + assert!(out.arrays.is_empty()); + assert_eq!(out.rects[3].origin, [6.0, 0.0]); + assert_eq!(out.rects[3].common.id.as_deref(), Some("col@3")); + } + + #[test] + fn polar_array_rotates_polyline_like_a_gear() { + let cf = parse( + r#" +[[polyline]] +id = "tooth" +points = [[10.0, 0.0], [11.0, 0.5], [11.0, -0.5]] +closed = true + +[[array]] +target = "tooth" +mode = "polar" +count = 12 +center = [0.0, 0.0] +step_angle = 30.0 +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.polylines.len(), 12); + // Copy 3 is rotated 90°: (10, 0) → (0, 10) + let p = out.polylines[3].points[0]; + assert!((p[0]).abs() < 1e-9 && (p[1] - 10.0).abs() < 1e-9); + } + + #[test] + fn polar_orbit_keeps_rect_axis_aligned() { + let cf = parse( + r#" +[[rect]] +id = "silla" +origin = [4.0, -0.5] +width = 1.0 +height = 1.0 + +[[array]] +target = "silla" +mode = "polar" +count = 4 +center = [0.0, 0.0] +step_angle = 90.0 +rotate_items = false +"#, + ); + let out = expand_cf(&cf); + // Rect stays a rect when orbiting + assert_eq!(out.rects.len(), 4); + // Center (4.5, 0) rotated 90° → (0, 4.5); origin = center - half size + assert_close(out.rects[1].origin, [-0.5, 4.0]); + } + + #[test] + fn polar_rotation_converts_rect_to_polyline() { + let cf = parse( + r#" +[[rect]] +id = "huella" +origin = [1.0, 0.0] +width = 1.2 +height = 0.3 + +[[array]] +target = "huella" +mode = "polar" +count = 3 +center = [0.0, 0.0] +step_angle = 20.0 +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.rects.len(), 1, "original rect stays"); + assert_eq!(out.polylines.len(), 2, "rotated copies become polylines"); + assert!(out.polylines.iter().all(|p| p.closed)); + } + + #[test] + fn mirror_reflects_and_inverts_arc_sweep() { + let cf = parse( + r#" +[[line]] +id = "muro" +from = [1.0, 0.0] +to = [1.0, 5.0] + +[[arc]] +id = "puerta" +center = [1.0, 2.0] +radius = 0.9 +from_angle = 0.0 +to_angle = 90.0 + +[[mirror]] +targets = ["muro", "puerta"] +axis = [[3.0, 0.0], [3.0, 1.0]] +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.lines.len(), 2); + assert_eq!(out.arcs.len(), 2); + // Vertical axis at x=3: x=1 → x=5 + assert_close(out.lines[1].from, [5.0, 0.0]); + let m = &out.arcs[1]; + assert_close(m.center, [5.0, 2.0]); + // Vertical-axis mirror maps θ → 180−θ, endpoints swapped: [90°,180°] + assert!((m.from_angle - 90.0).abs() < 1e-9); + assert!((m.to_angle - 180.0).abs() < 1e-9); + assert_eq!(m.common.id.as_deref(), Some("puerta@m")); + } + + #[test] + fn array_remaps_hatch_boundary_to_copied_polyline() { + let cf = parse( + r#" +[[polyline]] +id = "zona" +points = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]] +closed = true + +[[hatch]] +id = "zona-hatch" +boundary = "zona" + +[[array]] +targets = ["zona", "zona-hatch"] +mode = "linear" +count = 2 +offset = [3.0, 0.0] +"#, + ); + let out = expand_cf(&cf); + assert_eq!(out.hatches.len(), 2); + assert_eq!(out.hatches[1].boundary.as_deref(), Some("zona@1")); + assert_eq!(out.polylines[1].points[0], [3.0, 0.0]); + } +} diff --git a/src/viewer.rs b/src/viewer.rs index 40a1048..1262ecf 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -17,7 +17,7 @@ pub fn view_project(project_dir: &Path, layer_filter: Option<&str>) -> Result<() } }) .collect::(); - std::env::temp_dir().join(format!("cadforge-view-{}.dxf", sanitized)) + std::env::temp_dir().join(format!("cadspec-view-{}.dxf", sanitized)) } else { project_dir.join("output.dxf") }; @@ -29,11 +29,11 @@ pub fn view_project(project_dir: &Path, layer_filter: Option<&str>) -> Result<() } fn open_file(path: &Path) -> Result<()> { - if let Ok(custom) = std::env::var("CADFORGE_VIEWER_CMD") { + if let Ok(custom) = std::env::var("CADSPEC_VIEWER_CMD") { let status = Command::new(&custom) .arg(path) .status() - .with_context(|| format!("Failed to run CADFORGE_VIEWER_CMD='{}'", custom))?; + .with_context(|| format!("Failed to run CADSPEC_VIEWER_CMD='{}'", custom))?; if !status.success() { return Err(anyhow!( "Custom viewer command failed for {} (exit code: {:?})", diff --git a/tests/integration.rs b/tests/integration.rs index 19016fe..a723abc 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,14 +1,14 @@ //! Integration tests — full pipeline from .cf files to DXF output. -use cadforge::compiler::compile_project; -use cadforge::importer::import_dxf; +use cadspec::compiler::compile_project; +use cadspec::importer::import_dxf; use std::fs; use std::path::Path; #[test] fn compile_example_project_produces_valid_dxf() { let project_dir = Path::new("examples/vivienda"); - let output = Path::new("/tmp/cadforge_compile_test_output.dxf"); + let output = Path::new("/tmp/cadspec_compile_test_output.dxf"); // Remove previous output if exists let _ = fs::remove_file(output); @@ -49,13 +49,13 @@ fn compile_example_project_produces_valid_dxf() { #[test] fn compile_project_fails_on_missing_project_toml() { - let result = compile_project(Path::new("/tmp/nonexistent_cadforge_dir"), None, None); + let result = compile_project(Path::new("/tmp/nonexistent_cadspec_dir"), None, None); assert!(result.is_err()); } #[test] fn check_project_validates_without_generating_dxf() { - use cadforge::compiler::check_project; + use cadspec::compiler::check_project; let project_dir = Path::new("examples/vivienda"); let output = project_dir.join("check_should_not_exist.dxf"); @@ -110,7 +110,7 @@ to = [8.5, 0.0] offset = 0.5 "##; - let cf: cadforge::model::CfFile = toml::from_str(toml).unwrap(); + let cf: cadspec::model::CfFile = toml::from_str(toml).unwrap(); assert_eq!(cf.lines.len(), 1); assert_eq!(cf.polylines.len(), 1); assert!(cf.polylines[0].common.weight.is_some()); @@ -141,7 +141,7 @@ to = [10.0, 2.0] style = "dashdot" "##; - let cf: cadforge::model::CfFile = toml::from_str(toml).unwrap(); + let cf: cadspec::model::CfFile = toml::from_str(toml).unwrap(); assert_eq!(cf.lines.len(), 3); assert!(cf.lines[0].common.style.is_some()); } @@ -161,15 +161,15 @@ scale = 1.0 angle = 45.0 "##; - let cf: cadforge::model::CfFile = toml::from_str(toml).unwrap(); + let cf: cadspec::model::CfFile = toml::from_str(toml).unwrap(); assert_eq!(cf.hatches.len(), 1); - assert_eq!(cf.hatches[0].boundary, "pl-room"); + assert_eq!(cf.hatches[0].boundary.as_deref(), Some("pl-room")); // Compile it to verify no panic - use cadforge::dxf_writer::DxfWriter; + use cadspec::dxf_writer::DxfWriter; let mut writer = DxfWriter::new(); writer.add_layer("test", 7); - cadforge::compiler::compile_cf_public(&mut writer, &cf, "test"); + cadspec::compiler::compile_cf_public(&mut writer, &cf, "test"); } #[test] @@ -181,16 +181,16 @@ points = [[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [0.0, 3.0]] color = "#808080" "##; - let cf: cadforge::model::CfFile = toml::from_str(toml).unwrap(); + let cf: cadspec::model::CfFile = toml::from_str(toml).unwrap(); assert_eq!(cf.fills.len(), 1); // Compile and verify it produces a DXF with SOLID entities - use cadforge::dxf_writer::DxfWriter; + use cadspec::dxf_writer::DxfWriter; let mut writer = DxfWriter::new(); writer.add_layer("test", 7); - cadforge::compiler::compile_cf_public(&mut writer, &cf, "test"); + cadspec::compiler::compile_cf_public(&mut writer, &cf, "test"); - let path = std::path::PathBuf::from("/tmp/cadforge_test_fill.dxf"); + let path = std::path::PathBuf::from("/tmp/cadspec_test_fill.dxf"); writer.save(&path).unwrap(); let content = fs::read_to_string(&path).unwrap(); assert!(content.contains("SOLID")); @@ -251,7 +251,7 @@ belongs_to = "room-1" #[test] fn compile_allows_constraint_warnings_when_not_strict() { - let dir = Path::new("/tmp/cadforge_constraints_non_strict"); + let dir = Path::new("/tmp/cadspec_constraints_non_strict"); let _ = fs::remove_dir_all(dir); write_constraints_fixture(dir, false); @@ -269,7 +269,7 @@ fn compile_allows_constraint_warnings_when_not_strict() { #[test] fn compile_fails_on_constraint_violation_when_strict() { - let dir = Path::new("/tmp/cadforge_constraints_strict"); + let dir = Path::new("/tmp/cadspec_constraints_strict"); let _ = fs::remove_dir_all(dir); write_constraints_fixture(dir, true); @@ -283,13 +283,137 @@ fn compile_fails_on_constraint_violation_when_strict() { } #[test] -fn import_generated_dxf_creates_cadforge_project() { +fn render_svg_on_example_project() { + let svg = cadspec::svg::render_svg(Path::new("examples/vivienda"), None, 1600).unwrap(); + assert!(svg.starts_with("")); + // Layer groups for every project layer + for layer in ["muros", "puertas", "mobiliario", "cotas", "achurados"] { + assert!( + svg.contains(&format!(r#"data-layer="{}""#, layer)), + "missing layer group {}", + layer + ); + } + // Dimensions are labeled with the measured value in project units + assert!(svg.contains(" m"), "dim labels should include units"); +} + +#[test] +fn project_report_is_serializable_and_complete() { + let report = cadspec::compiler::project_report(Path::new("examples/vivienda")).unwrap(); + assert!(report.total_entities > 0); + assert_eq!(report.layers.len(), 5); + assert!(report.layers.iter().all(|l| !l.missing)); + + let json = serde_json::to_string(&report).unwrap(); + assert!(json.contains("\"total_entities\"")); + assert!(json.contains("\"issues\"")); +} + +#[test] +fn taller_example_expands_arrays_and_mirrors() { + let dir = Path::new("examples/taller"); + + // Expanded entity counts: 16 treads + 16 teeth + mirrored geometry + let report = cadspec::compiler::project_report(dir).unwrap(); + assert_eq!(report.total_entities, 49); + + let svg = cadspec::svg::render_svg(dir, None, 1200).unwrap(); + // 15 generated tread copies with derived ids + let tread_copies = svg.matches(r#"data-id="pl-huella@"#).count(); + assert_eq!(tread_copies, 15); + // Mirrored door arc exists + assert!(svg.contains(r#"data-id="ar-puerta@m""#)); + // Styled dims: 1 decimal with units, 3 decimals without units + assert!(svg.contains("3.2 m")); + assert!(svg.contains(">2.900<")); + + // The DXF compiles with the expanded geometry + let out = Path::new("/tmp/cadspec_taller.dxf"); + let _ = fs::remove_file(out); + compile_project(dir, None, Some(out)).unwrap(); + assert!(out.exists()); + let _ = fs::remove_file(out); +} + +#[test] +fn preview_renders_faithful_png_with_metadata_and_highlights() { + let dir = Path::new("/tmp/cadspec_preview_test"); + let _ = fs::remove_dir_all(dir); + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("project.toml"), + r#"[project] +name = "preview-fixture" +units = "m" + +[layers] +plano = { file = "plano.cf", locked = false } +"#, + ) + .unwrap(); + fs::write( + dir.join("plano.cf"), + r##"[[rect]] +id = "rc-room" +origin = [0.0, 0.0] +width = 6.0 +height = 4.0 + +[[text]] +id = "tx-label" +position = [3.0, 2.0] +content = "SALA" +size = 0.4 +align = "center" + +[[dim]] +id = "dm-width" +from = [0.0, 0.0] +to = [6.0, 0.0] +offset = -0.6 +"##, + ) + .unwrap(); + + cadspec::preview::generate_preview( + dir, + 800, + 800, + None, + &["rc-room".to_string()], + cadspec::preview::PreviewOutputs { + png: true, + svg: true, + }, + cadspec::preview::PreviewView::Plan, + ) + .unwrap(); + + assert!(dir.join("preview.png").exists()); + assert!(dir.join("preview.svg").exists()); + let meta = fs::read_to_string(dir.join("preview.meta.json")).unwrap(); + assert!(meta.contains(r#""content": "SALA""#)); + assert!(meta.contains(r#""entity_type": "dim""#)); + assert!(meta.contains(r#""highlighted""#)); + assert!(meta.contains("rc-room")); + + // The PNG must fit within the requested box and be non-trivial + let png = fs::read(dir.join("preview.png")).unwrap(); + assert!(png.len() > 1000, "PNG should contain rendered content"); + + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn import_generated_dxf_creates_cadspec_project() { let source = Path::new("examples/vivienda"); let source_output = source.join("output.dxf"); let _ = fs::remove_file(&source_output); compile_project(source, None, Some(&source_output)).unwrap(); - let imported = Path::new("/tmp/cadforge_import_test"); + let imported = Path::new("/tmp/cadspec_import_test"); let _ = fs::remove_dir_all(imported); import_dxf(&source_output, imported, None).unwrap(); @@ -303,3 +427,581 @@ fn import_generated_dxf_creates_cadforge_project() { let _ = fs::remove_dir_all(imported); } + +#[test] +fn import_roundtrip_recovers_dims_styles_and_colors() { + let dir = Path::new("/tmp/cadspec_roundtrip_fidelity"); + let _ = fs::remove_dir_all(dir); + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("project.toml"), + "[project]\nname = \"rt\"\nunits = \"m\"\n\n[layers]\nplano = { file = \"plano.cf\", locked = false }\n", + ) + .unwrap(); + fs::write( + dir.join("plano.cf"), + r##"[layer] +name = "plano" +color = "#FF4444" + +[[line]] +id = "ln-base" +from = [0.0, 0.0] +to = [8.0, 0.0] +color = "#FF5050" +weight = 0.5 +style = "dashed" + +[[text]] +id = "tx-sala" +position = [4.0, 3.0] +content = "SALA" +size = 0.25 + +[[dim]] +id = "dm-h" +from = [0.0, 0.0] +to = [8.0, 0.0] +offset = -0.8 + +[[dim]] +id = "dm-v" +from = [0.0, 0.0] +to = [0.0, 6.0] +offset = -1.2 +"##, + ) + .unwrap(); + + let dxf_path = dir.join("output.dxf"); + compile_project(dir, None, Some(&dxf_path)).unwrap(); + + // Each layer appears exactly once in the DXF LAYER table ('0' + 'plano'), + // and 'plano' carries its ACI color instead of the default white. + let dxf_text = fs::read_to_string(&dxf_path).unwrap(); + assert_eq!( + dxf_text.matches("AcDbLayerTableRecord").count(), + 2, + "duplicate LAYER table records" + ); + + let imported = Path::new("/tmp/cadspec_roundtrip_fidelity_out"); + let _ = fs::remove_dir_all(imported); + import_dxf(&dxf_path, imported, None).unwrap(); + let cf = fs::read_to_string(imported.join("plano.cf")).unwrap(); + + // Layer color survives via the DXF layer table (ACI): #FF4444 is not + // itself an ACI color, so it comes back as the nearest ACI palette + // entry (#FF3F00, ACI 20). + assert!( + cf.contains("color = \"#FF3F00\""), + "layer color lost:\n{cf}" + ); + // Entity style survives via true color, lineweight and line type + assert!( + cf.contains("color = \"#FF5050\""), + "entity color lost:\n{cf}" + ); + assert!(cf.contains("weight = 0.5"), "entity weight lost:\n{cf}"); + assert!(cf.contains("style = \"dashed\""), "line style lost:\n{cf}"); + // Both dims come back as dims with their perpendicular offsets intact + assert_eq!(cf.matches("[[dim]]").count(), 2, "dims lost:\n{cf}"); + assert!(cf.contains("offset = -0.8000"), "horizontal offset:\n{cf}"); + assert!(cf.contains("offset = -1.2000"), "vertical offset:\n{cf}"); + // Companion graphics (3 lines + 1 label per dim) are deduplicated + assert_eq!( + cf.matches("[[line]]").count(), + 1, + "dim companion lines not deduped:\n{cf}" + ); + assert_eq!( + cf.matches("[[text]]").count(), + 1, + "dim label texts not deduped:\n{cf}" + ); + + // The reimported project must still compile + compile_project(imported, None, None).unwrap(); + + let _ = fs::remove_dir_all(dir); + let _ = fs::remove_dir_all(imported); +} + +#[test] +fn import_roundtrip_preserves_text_rotation() { + // `rotation` on [[text]] must survive a build -> import roundtrip via the + // DXF TEXT group-code-50 field. Unrotated text must not gain a spurious + // `rotation` key (today's DXF output has none, and roundtrip fidelity + // should not regress for the common case). + let dir = Path::new("/tmp/cadspec_text_rotation_roundtrip"); + let _ = fs::remove_dir_all(dir); + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("project.toml"), + "[project]\nname = \"rt\"\nunits = \"m\"\n\n[layers]\nplano = { file = \"plano.cf\", locked = false }\n", + ) + .unwrap(); + fs::write( + dir.join("plano.cf"), + r##"[layer] +name = "plano" +color = "#FFFFFF" + +[[text]] +id = "tx-rotated" +position = [1.0, 2.0] +content = "ROTATED" +size = 0.3 +rotation = 30.0 + +[[text]] +id = "tx-plain" +position = [4.0, 2.0] +content = "PLAIN" +size = 0.3 +"##, + ) + .unwrap(); + + let dxf_path = dir.join("output.dxf"); + compile_project(dir, None, Some(&dxf_path)).unwrap(); + + let imported = Path::new("/tmp/cadspec_text_rotation_roundtrip_out"); + let _ = fs::remove_dir_all(imported); + import_dxf(&dxf_path, imported, None).unwrap(); + let cf = fs::read_to_string(imported.join("plano.cf")).unwrap(); + + assert!( + cf.contains("rotation = 30"), + "rotated text lost its rotation on roundtrip:\n{cf}" + ); + let plain_block = cf.split("PLAIN").next().unwrap(); + let plain_tx_start = plain_block.rfind("[[text]]").unwrap(); + assert!( + !plain_block[plain_tx_start..].contains("rotation"), + "unrotated text should not gain a rotation key:\n{cf}" + ); + + compile_project(imported, None, None).unwrap(); + + let _ = fs::remove_dir_all(dir); + let _ = fs::remove_dir_all(imported); +} + +#[test] +fn import_refuses_hatch_lines_into_single_hatch() { + // A hatch expands into many DXF pattern lines. On import they must collapse + // back into one `[[hatch]]` (with its region + pattern/angle/scale), while a + // genuine standalone line on the same layer survives as a `[[line]]`. + let dir = Path::new("/tmp/cadspec_hatch_roundtrip"); + let _ = fs::remove_dir_all(dir); + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("project.toml"), + "[project]\nname = \"ht\"\nunits = \"m\"\n\n[layers]\nzona = { file = \"zona.cf\", locked = false }\n", + ) + .unwrap(); + fs::write( + dir.join("zona.cf"), + r##"[layer] +name = "zona" +color = "#C0C0C0" + +[[polyline]] +id = "pl-room" +points = [[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [0.0, 3.0]] +closed = true + +[[hatch]] +id = "ht-room" +boundary = "pl-room" +pattern = "ansi31" +scale = 2.0 +angle = 45.0 + +[[line]] +id = "ln-real" +from = [0.0, 0.0] +to = [4.0, 3.0] +"##, + ) + .unwrap(); + + let dxf_path = dir.join("output.dxf"); + compile_project(dir, None, Some(&dxf_path)).unwrap(); + // The hatch really did expand into several tagged pattern lines in the DXF. + let dxf_text = fs::read_to_string(&dxf_path).unwrap(); + assert!( + dxf_text.matches("CADSPEC_HATCH").count() > 3, + "expected the hatch to expand into multiple tagged pattern lines" + ); + + let imported = Path::new("/tmp/cadspec_hatch_roundtrip_out"); + let _ = fs::remove_dir_all(imported); + import_dxf(&dxf_path, imported, None).unwrap(); + let cf = fs::read_to_string(imported.join("zona.cf")).unwrap(); + + // Exactly one hatch comes back, carrying its region and source parameters. + assert_eq!( + cf.matches("[[hatch]]").count(), + 1, + "hatch not re-fused:\n{cf}" + ); + assert!(cf.contains("pattern = \"ansi31\""), "pattern lost:\n{cf}"); + assert!(cf.contains("angle = 45.0000"), "angle lost:\n{cf}"); + assert!(cf.contains("scale = 2.0000"), "scale lost:\n{cf}"); + // The genuine line survives; hatch pattern lines are not emitted as lines. + assert_eq!( + cf.matches("[[line]]").count(), + 1, + "hatch pattern lines leaked as lines:\n{cf}" + ); + + // The reimported hatch (now using inline points) still compiles and expands + // back into the same tagged pattern lines — a stable round-trip. + let dxf2 = imported.join("output2.dxf"); + compile_project(imported, None, Some(&dxf2)).unwrap(); + let dxf2_text = fs::read_to_string(&dxf2).unwrap(); + assert_eq!( + dxf_text.matches("CADSPEC_HATCH").count(), + dxf2_text.matches("CADSPEC_HATCH").count(), + "hatch pattern-line count drifted across a round-trip" + ); + + let _ = fs::remove_dir_all(dir); + let _ = fs::remove_dir_all(imported); +} + +#[test] +fn preview_warns_on_unresolved_hatch_and_fill_boundary() { + let dir = Path::new("/tmp/cadspec_preview_unresolved_boundary"); + let _ = fs::remove_dir_all(dir); + fs::create_dir_all(dir).unwrap(); + + fs::write( + dir.join("project.toml"), + r#"[project] +name = "boundary-fixture" +scale = "1:100" +units = "m" + +[layers] +main = { file = "main.cf" } +"#, + ) + .unwrap(); + + fs::write( + dir.join("main.cf"), + r##"[layer] +name = "main" + +[[hatch]] +id = "ht-missing" +boundary = "does-not-exist" +pattern = "ansi31" +scale = 1.0 +angle = 45.0 + +[[fill]] +id = "fl-missing" +boundary = "also-missing" +"##, + ) + .unwrap(); + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_cadspec")) + .args(["preview", "-p"]) + .arg(dir) + .args(["--format", "svg"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "preview should not fail on an unresolved boundary, it should warn and skip the region" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("hatch 'ht-missing'") && stderr.contains("does-not-exist"), + "expected a warning for the unresolved hatch boundary, got:\n{stderr}" + ); + assert!( + stderr.contains("fill 'fl-missing'") && stderr.contains("also-missing"), + "expected a warning for the unresolved fill boundary, got:\n{stderr}" + ); + + let _ = fs::remove_dir_all(dir); +} + +/// Copy a project's source files (project.toml + *.cf) into `dest`, skipping +/// generated/gitignored artifacts like output.dxf or preview.*. +fn copy_project_sources(src: &Path, dest: &Path) { + fs::create_dir_all(dest).unwrap(); + for entry in fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + let name = entry.file_name(); + let is_source = name == "project.toml" || path.extension().is_some_and(|e| e == "cf"); + if path.is_file() && is_source { + fs::copy(&path, dest.join(&name)).unwrap(); + } + } +} + +#[test] +fn fmt_is_idempotent_on_a_real_project() { + use cadspec::fmt::format_project; + + let dir = Path::new("/tmp/cadspec_fmt_real_project"); + let _ = fs::remove_dir_all(dir); + copy_project_sources(Path::new("examples/vivienda"), dir); + + // First pass may or may not need changes; either way it must succeed. + format_project(dir, false).unwrap(); + let after_first: Vec<(std::path::PathBuf, String)> = fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "cf") || p.ends_with("project.toml")) + .map(|p| { + let content = fs::read_to_string(&p).unwrap(); + (p, content) + }) + .collect(); + + // A formatted project must still compile to a valid DXF. + let dxf_path = dir.join("output.dxf"); + compile_project(dir, None, Some(&dxf_path)).unwrap(); + assert!(dxf_path.exists()); + + // `fmt --check` on an already-formatted project reports nothing to do. + format_project(dir, true).unwrap(); + + // A second `fmt` pass must be a no-op (idempotent) on every file. + format_project(dir, false).unwrap(); + for (path, before) in after_first { + let after = fs::read_to_string(&path).unwrap(); + assert_eq!( + before, + after, + "fmt was not idempotent on {}", + path.display() + ); + } + + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn generate_plano_renders_a_declared_sheet_end_to_end() { + use cadspec::preview::{generate_plano, PreviewOutputs}; + + let dir = Path::new("/tmp/cadspec_generate_plano_e2e"); + let _ = fs::remove_dir_all(dir); + copy_project_sources(Path::new("examples/vivienda"), dir); + + // examples/vivienda has no [[plano]] declared; append one so generate_plano + // (the CLI-facing `preview --plano` path) has a sheet to look up by name. + let mut project_toml = fs::read_to_string(dir.join("project.toml")).unwrap(); + project_toml.push_str( + r#" +[[plano]] +name = "P-01" +view = "plan" +size = [420.0, 297.0] +scale = "1:50" +title = "Planta baja" +"#, + ); + fs::write(dir.join("project.toml"), project_toml).unwrap(); + + generate_plano( + dir, + "P-01", + 1200, + 900, + PreviewOutputs { + png: true, + svg: true, + }, + ) + .unwrap(); + + assert!(dir.join("preview.png").exists()); + let svg = fs::read_to_string(dir.join("preview.svg")).unwrap(); + assert!(svg.contains(r#"data-view="plano""#)); + assert!(svg.contains("Planta baja")); + + // An unknown plano name is a clear error, not a panic. + let err = generate_plano( + dir, + "does-not-exist", + 1200, + 900, + PreviewOutputs { + png: true, + svg: true, + }, + ) + .unwrap_err(); + assert!( + err.to_string().contains("P-01"), + "error should list the available plano names: {err}" + ); + + let _ = fs::remove_dir_all(dir); +} + +/// An OS-assigned free port: bind to port 0, read back what the OS picked, +/// then drop the listener so `serve_project` can bind it itself. +fn free_local_port() -> u16 { + std::net::TcpListener::bind(("127.0.0.1", 0)) + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn wait_for_port(port: u16, timeout: std::time::Duration) -> bool { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let deadline = std::time::Instant::now() + timeout; + while std::time::Instant::now() < deadline { + if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)) + .is_ok() + { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + false +} + +/// GET `path` from the local `serve` instance and return (status, body). +/// Sends `Connection: close` so the server closes the socket once done and we +/// can just read to EOF instead of tracking Content-Length ourselves. +fn http_get(port: u16, path: &str) -> (u16, String) { + use std::io::{Read, Write}; + let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream + .write_all( + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .as_bytes(), + ) + .unwrap(); + stream.shutdown(std::net::Shutdown::Write).ok(); + let mut raw = String::new(); + stream.read_to_string(&mut raw).unwrap(); + let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw.as_str(), "")); + let status = head + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + (status, body.to_string()) +} + +fn state_version(port: u16) -> u64 { + let (status, body) = http_get(port, "/state"); + assert_eq!(status, 200, "GET /state failed: {body}"); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + json["version"].as_u64().unwrap() +} + +#[test] +fn serve_project_serves_state_and_rebuilds_on_file_change_e2e() { + let dir = Path::new("/tmp/cadspec_serve_e2e"); + let _ = fs::remove_dir_all(dir); + copy_project_sources(Path::new("examples/vivienda"), dir); + + let port = free_local_port(); + let serve_dir = dir.to_path_buf(); + std::thread::spawn(move || { + // Leaked on purpose: serve_project blocks forever in listener.incoming() + // (no shutdown hook exists); the thread dies with the test process. + let _ = cadspec::serve::serve_project(&serve_dir, port, false); + }); + assert!( + wait_for_port(port, std::time::Duration::from_secs(5)), + "serve did not start listening on 127.0.0.1:{port} in time" + ); + + let (status, body) = http_get(port, "/state"); + assert_eq!(status, 200); + assert!(body.contains("Vivienda Unifamiliar Lote 12")); + let initial_version = state_version(port); + + let (svg_status, svg_body) = http_get(port, "/preview.svg"); + assert_eq!(svg_status, 200); + assert!(svg_body.contains(" initial_version, + "expected a rebuild (version > {initial_version}) after editing muros.cf, got {rebuilt_version}" + ); + + let _ = fs::remove_dir_all(dir); +} + +#[test] +fn watch_project_rebuilds_dxf_on_file_change_e2e() { + let dir = Path::new("/tmp/cadspec_watch_e2e"); + let _ = fs::remove_dir_all(dir); + copy_project_sources(Path::new("examples/vivienda"), dir); + + let output = dir.join("output.dxf"); + assert!(!output.exists()); + + let watch_dir = dir.to_path_buf(); + std::thread::spawn(move || { + // Leaked on purpose: watch_project blocks forever on rx.recv() (no + // shutdown hook exists); the thread dies with the test process. + let _ = cadspec::watch::watch_project(&watch_dir); + }); + + // Give the watcher time to register and clear its own startup debounce + // window (a change within DEBOUNCE=300ms of start is silently dropped, + // since `last_build` is initialized before the watch loop even starts). + std::thread::sleep(std::time::Duration::from_millis(500)); + + let muros = dir.join("muros.cf"); + let mut contents = fs::read_to_string(&muros).unwrap(); + contents.push_str("\n# touched by watch_project_rebuilds_dxf_on_file_change_e2e\n"); + fs::write(&muros, contents).unwrap(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while std::time::Instant::now() < deadline && !output.exists() { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + assert!( + output.exists(), + "watch did not rebuild output.dxf after editing muros.cf" + ); + let dxf = fs::read_to_string(&output).unwrap(); + assert!( + dxf.contains("SECTION"), + "output.dxf does not look like a DXF file" + ); + + let _ = fs::remove_dir_all(dir); +}