commit 95c80ec6b1501860edcf11b50b40e92c8d33b697 Author: grabowski Date: Thu Oct 22 15:09:46 2026 +0700 isokit: engine core, theme and Rhai scene runtime with prefab library diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be16902 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +/dist/ +*.log +__pycache__/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3c960cb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,586 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "serde", + "static_assertions", + "zmij", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "isokit-cli" +version = "0.1.0" + +[[package]] +name = "isokit-core" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "isokit-script" +version = "0.1.0" +dependencies = [ + "isokit-core", + "rhai", + "serde", + "serde_json", +] + +[[package]] +name = "isokit-theme" +version = "0.1.0" +dependencies = [ + "toml", +] + +[[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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rhai" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3925b784c0bee6542637e425ed7511a63257bf11b7ecf51ffd845e2d24d9163" +dependencies = [ + "ahash", + "bitflags", + "compact_str", + "num-traits", + "once_cell", + "rhai_codegen", + "serde", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d9661edb4a4b8145b017582d634c5eb88ff1a5284a5aa701f83a28f0657a826" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thin-vec" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e" + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..41f21ac --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[workspace] +resolver = "2" +members = [ + "crates/isokit-core", + "crates/isokit-script", + "crates/isokit-theme", + "crates/isokit-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["buildfor.life"] +repository = "https://git.b4l.co.th/B4L/isokit" + +[workspace.dependencies] +isokit-core = { path = "crates/isokit-core" } +isokit-script = { path = "crates/isokit-script" } +isokit-theme = { path = "crates/isokit-theme" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = { version = "1", features = ["preserve_order"] } +rhai = { version = "1.27", default-features = true } +tiny_http = "0.12" +clap = { version = "4", features = ["derive"] } +anyhow = "1" + +[profile.release] +lto = "thin" +codegen-units = 4 diff --git a/crates/isokit-cli/Cargo.toml b/crates/isokit-cli/Cargo.toml new file mode 100644 index 0000000..e8c0db5 --- /dev/null +++ b/crates/isokit-cli/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "isokit-cli" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "isokit" +path = "src/main.rs" diff --git a/crates/isokit-cli/src/main.rs b/crates/isokit-cli/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/crates/isokit-cli/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/crates/isokit-core/Cargo.toml b/crates/isokit-core/Cargo.toml new file mode 100644 index 0000000..5cfd9a2 --- /dev/null +++ b/crates/isokit-core/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "isokit-core" +description = "Isometric scene engine: projection, primitives, sprites, depth sort, SVG output" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } diff --git a/crates/isokit-core/src/fmt.rs b/crates/isokit-core/src/fmt.rs new file mode 100644 index 0000000..9a821a2 --- /dev/null +++ b/crates/isokit-core/src/fmt.rs @@ -0,0 +1,109 @@ +//! Number and text formatting shared by the SVG writer and the scripts. + +use std::fmt::Write; + +/// Append `v` rounded to 0.1 the way the JS views did (`Math.round(v * 10) / 10`): +/// halves round up, no trailing `.0`, never `-0`. Non-finite values print as +/// `NaN` so the checks can find them in the output. +pub fn r1(out: &mut String, v: f64) { + if !v.is_finite() { + out.push_str("NaN"); + return; + } + let t = (v * 10.0 + 0.5).floor() as i64; + if t < 0 { + out.push('-'); + } + let a = t.unsigned_abs(); + let _ = write!(out, "{}", a / 10); + if a % 10 != 0 { + let _ = write!(out, ".{}", a % 10); + } +} + +/// [`r1`] into a new string. +pub fn r1s(v: f64) -> String { + let mut s = String::new(); + r1(&mut s, v); + s +} + +/// Round to 0.1 as a number. +pub fn round1(v: f64) -> f64 { + (v * 10.0 + 0.5).floor() / 10.0 +} + +/// Escape text for SVG/HTML content and attribute values. +pub fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + c => o.push(c), + } + } + o +} + +/// Deterministic pseudo-random number in [0, 1) from any number (the views' +/// `hash(i)`): scenes use it to vary trees, cars and delays without changing +/// from one build to the next. +pub fn hash(i: f64) -> f64 { + let x = (i * 12.9898 + 78.233).sin() * 43758.5453; + x - x.floor() +} + +/// Whole number with thousands separators: 12345.6 -> "12,346". +pub fn thousands(n: f64) -> String { + if !n.is_finite() { + return "NaN".into(); + } + let v = (n + 0.5).floor() as i64; + let s = v.unsigned_abs().to_string(); + let mut o = String::new(); + for (i, ch) in s.chars().enumerate() { + if i > 0 && (s.len() - i) % 3 == 0 { + o.push(','); + } + o.push(ch); + } + if v < 0 { + format!("-{o}") + } else { + o + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rounding_matches_js() { + assert_eq!(r1s(12.34), "12.3"); + assert_eq!(r1s(12.35), "12.4"); + assert_eq!(r1s(12.0), "12"); + assert_eq!(r1s(-0.04), "0"); + assert_eq!(r1s(-2.25), "-2.2"); // JS Math.round(-22.5) = -22 + assert_eq!(r1s(-7.0), "-7"); + assert_eq!(r1s(f64::NAN), "NaN"); + } + + #[test] + fn thousands_sep() { + assert_eq!(thousands(1234567.4), "1,234,567"); + assert_eq!(thousands(999.0), "999"); + assert_eq!(thousands(-1500.0), "-1,500"); + } + + #[test] + fn hash_is_stable() { + let a = hash(3.0); + assert!((0.0..1.0).contains(&a)); + assert_eq!(a, hash(3.0)); + assert_ne!(a, hash(4.0)); + } +} diff --git a/crates/isokit-core/src/lib.rs b/crates/isokit-core/src/lib.rs new file mode 100644 index 0000000..3a385c8 --- /dev/null +++ b/crates/isokit-core/src/lib.rs @@ -0,0 +1,65 @@ +//! isokit-core: the isometric engine. +//! +//! World coordinates are metres: x east, y north, z up. A [`Scene`] holds +//! [`Sprite`]s in [`Layer`]s; each sprite is a list of world-space +//! [`Prim`]itives plus the extent points used for the depth sort. [`render`] +//! projects a scene for one of four 90 degree view rotations, culls and +//! shades the faces, orders the sprites back to front and writes SVG. +//! +//! The engine is a port of the CNXIso / CNX Scuba / Mooban views (plain JS) +//! and keeps their conventions: faces get the classes `iso-t` (top), `iso-l`, +//! `iso-c`, `iso-r` (sides by the direction they face), materials are CSS +//! classes that set `--b`, tags are counter-scaled by the viewer. +//! +//! Nothing here knows about scripts, files or the browser: the script layer +//! (isokit-script) and the CLI build on this crate, and a Rust program can use +//! it directly. + +pub mod fmt; +pub mod prim; +pub mod proj; +pub mod render; +pub mod scene; +pub mod sort; +pub mod sprite; + +pub use prim::{Prim, Style, Zs, P2, P3}; +pub use proj::{shade, Proj, C, CZ}; +pub use render::{render, RenderOpts, Rendered}; +pub use scene::{Info, Layer, LayerOpts, Scene, Tag}; +pub use sprite::{Sprite, Xf}; + +/// Prefix every space-separated class token with `iso-` unless it already +/// has it. Scripts write `"wall"` or `"z-living t"`; the SVG gets +/// `iso-wall` / `iso-z-living iso-t`. A token starting with `!` is kept +/// verbatim without the `!` (for host-page classes). +pub fn classes(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 8); + for tok in s.split_whitespace() { + if !out.is_empty() { + out.push(' '); + } + if let Some(raw) = tok.strip_prefix('!') { + out.push_str(raw); + } else if tok.starts_with("iso-") { + out.push_str(tok); + } else { + out.push_str("iso-"); + out.push_str(tok); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::classes; + + #[test] + fn class_prefixing() { + assert_eq!(classes("wall"), "iso-wall"); + assert_eq!(classes("z-living t"), "iso-z-living iso-t"); + assert_eq!(classes("iso-roof !host-x"), "iso-roof host-x"); + assert_eq!(classes(""), ""); + } +} diff --git a/crates/isokit-core/src/prim.rs b/crates/isokit-core/src/prim.rs new file mode 100644 index 0000000..8099004 --- /dev/null +++ b/crates/isokit-core/src/prim.rs @@ -0,0 +1,118 @@ +//! World-space primitives. A sprite is a list of these; they are projected, +//! culled and shaded only when the scene is rendered for a rotation. + +use serde::{Deserialize, Serialize}; + +pub type P2 = [f64; 2]; +pub type P3 = [f64; 3]; + +/// Height of a prism's bottom or top: one value, or one per footprint vertex +/// (sloped roofs, stair flights). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Zs { + One(f64), + Each(Vec), +} + +impl Zs { + #[inline] + pub fn at(&self, i: usize) -> f64 { + match self { + Zs::One(z) => *z, + Zs::Each(v) => v.get(i).copied().unwrap_or(f64::NAN), + } + } +} + +impl From for Zs { + fn from(z: f64) -> Self { + Zs::One(z) + } +} + +/// Classes and switches of a solid. `mat` is the material class for every +/// face; `side` / `top` override it for the side faces / the top. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Style { + pub mat: String, + pub side: Option, + pub top: Option, + pub no_side: bool, + pub no_top: bool, + /// Pick id on the faces themselves (otherwise the sprite's id applies). + pub id: Option, +} + +impl Style { + pub fn mat(mat: impl Into) -> Self { + Style { mat: mat.into(), ..Default::default() } + } + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + pub fn top(mut self, top: impl Into) -> Self { + self.top = Some(top.into()); + self + } + pub fn side(mut self, side: impl Into) -> Self { + self.side = Some(side.into()); + self + } + pub fn no_top(mut self) -> Self { + self.no_top = true; + self + } + pub fn side_cls(&self) -> &str { + self.side.as_deref().unwrap_or(&self.mat) + } + pub fn top_cls(&self) -> &str { + self.top.as_deref().unwrap_or(&self.mat) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Prim { + /// Vertical prism over a convex counter-clockwise footprint. + Prism { pts: Vec, z0: Zs, z1: Zs, st: Style }, + /// Upright cylinder (two side faces and an elliptic top). + Cyl { c: P2, r: f64, z0: f64, z1: f64, st: Style }, + /// Frustum (cone) with n sides: radius r0 at z0, r1 at z1. + Frust { c: P2, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style }, + /// Openings (windows, doors) on the vertical face through a -> b; the + /// outward normal is on the right of a -> b. Each entry is + /// [u0, u1, z0, z1]: distance along the face and heights. + Open { a: P2, b: P2, list: Vec<[f64; 4]>, cls: String, id: Option }, + /// Flat polygon(s) at height z; later polygons cut holes (even-odd). + Flat { polys: Vec>, z: f64, cls: String, id: Option }, + /// Line segments [x0, y0, x1, y1] at height z. + Lines { segs: Vec<[f64; 4]>, z: f64, cls: String, id: Option }, + /// Flat ellipse (a circle on the ground plane) at height z. + Disc { c: P2, z: f64, r: f64, cls: String, id: Option }, + /// Planar polygon in 3D (roof slopes, gables, ramps), shaded by its + /// normal: mostly upward = top, else left/centre/right by direction. + /// One-sided faces (counter-clockwise seen from outside) are culled + /// when turned away; two-sided faces always draw. + Face { pts: Vec, mat: String, id: Option, two_sided: bool }, + /// 3D polygons merged into one path with a fixed class (PV modules, + /// signs, markings on slopes): no shading, no culling. + Poly { polys: Vec>, cls: String, id: Option }, + /// Screen-space circle of radius r (screen units) at a world point, + /// shifted by off (screen units): heads, lamps, LEDs, tree crowns. + Circle { at: P3, r: f64, off: P2, cls: String, id: Option }, + /// Screen-space vertical strip of half width w between two heights: + /// trunks, thin poles. + Stem { at: P2, z0: f64, z1: f64, w: f64, cls: String, id: Option }, + /// Rising steam or smoke puffs (CSS animation). + Puffs { at: P3, kind: String, n: u32, dur: f64 }, + /// Spinning fan lying in the plane at height at.z (CSS animation). + Fan { at: P3, r: f64 }, + /// Raw SVG translated to a world point (or untranslated without one). + /// `{uid}` in the text is replaced by the view's unique id. + Raw { at: Option, svg: String }, + /// `` around nested primitives (animation, ghosting). + Group { cls: String, style: String, items: Vec }, + /// Items drawn back to front by the depth of their plan anchor. + Seq { items: Vec<(P2, Vec)> }, +} diff --git a/crates/isokit-core/src/proj.rs b/crates/isokit-core/src/proj.rs new file mode 100644 index 0000000..e560662 --- /dev/null +++ b/crates/isokit-core/src/proj.rs @@ -0,0 +1,181 @@ +//! 2:1 isometric projection with four 90 degree view rotations. +//! +//! View coordinates (vx, vy): the viewer sits at +vx +vy, so faces facing +//! +vy show on the left (mid tone), faces facing +vx on the right (dark). + +use crate::fmt::r1; + +/// Screen units per metre along a ground axis. +pub const C: f64 = 4.0; +/// Screen units per metre of height. +pub const CZ: f64 = 4.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Proj { + /// 0: from the south-east, 1: south-west, 2: north-west, 3: north-east. + pub rot: u8, +} + +impl Proj { + pub fn new(rot: i64) -> Self { + Proj { rot: rot.rem_euclid(4) as u8 } + } + + /// World plan point to view coordinates. + #[inline] + pub fn r(&self, x: f64, y: f64) -> [f64; 2] { + match self.rot { + 0 => [x, -y], + 1 => [-y, -x], + 2 => [-x, y], + _ => [y, x], + } + } + + /// View coordinates back to the world plan. + #[inline] + pub fn r_inv(&self, vx: f64, vy: f64) -> [f64; 2] { + match self.rot { + 0 => [vx, -vy], + 1 => [-vy, -vx], + 2 => [-vx, vy], + _ => [vy, vx], + } + } + + /// World point to screen. + #[inline] + pub fn s(&self, x: f64, y: f64, z: f64) -> [f64; 2] { + let v = self.r(x, y); + [(v[0] - v[1]) * C, (v[0] + v[1]) * C / 2.0 - z * CZ] + } + + /// Screen point to the world plan at height z. + pub fn unproject(&self, sx: f64, sy: f64, z: f64) -> [f64; 2] { + let a = sx / C; // vx - vy + let b = (sy + z * CZ) * 2.0 / C; // vx + vy + self.r_inv((a + b) / 2.0, (b - a) / 2.0) + } + + /// The view vector of a face normal if the face is turned towards the + /// viewer, else None. + #[inline] + pub fn vis(&self, nx: f64, ny: f64) -> Option<[f64; 2]> { + let v = self.r(nx, ny); + if v[0] + v[1] > 1e-6 { + Some(v) + } else { + None + } + } + + /// Screen displacement of a world displacement (for CSS motion). + pub fn dv(&self, dx: f64, dy: f64, dz: f64) -> [f64; 2] { + let a = self.s(dx, dy, dz); + let b = self.s(0.0, 0.0, 0.0); + [a[0] - b[0], a[1] - b[1]] + } + + /// Distance towards the viewer of a plan point (larger = nearer). + #[inline] + pub fn depth(&self, x: f64, y: f64) -> f64 { + let v = self.r(x, y); + v[0] + v[1] + } + + /// True when world x maps onto view y in this rotation: a mover should + /// then run along x (the axis the depth sort compares second). + pub fn along_x(&self) -> bool { + self.r(1.0, 0.0)[0] == 0.0 + } + + /// Append an SVG path `M..L..Z` through the projected points. + pub fn d(&self, out: &mut String, pts: &[[f64; 3]], open: bool) { + for (i, p) in pts.iter().enumerate() { + let s = self.s(p[0], p[1], p[2]); + out.push(if i == 0 { 'M' } else { 'L' }); + r1(out, s[0]); + out.push(' '); + r1(out, s[1]); + } + if !open && !pts.is_empty() { + out.push('Z'); + } + } + + /// Angle of north on screen in degrees (for the compass needle). + pub fn north_deg(&self) -> f64 { + let n = self.r(0.0, 1.0); + ((n[0] + n[1]) / 2.0).atan2(n[0] - n[1]).to_degrees() + 90.0 + } +} + +/// Face class for a visible side face from its view-space normal. +pub fn shade(v: [f64; 2]) -> &'static str { + let a = v[1].atan2(v[0]).to_degrees(); + if a > 67.5 { + "iso-l" + } else if a < 22.5 { + "iso-r" + } else { + "iso-c" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rotation_zero_matches_js() { + let p = Proj::new(0); + // JS: ROT[0](x, y) = [x, -y]; s = [(vx - vy) * 4, (vx + vy) * 2 - z * 4] + assert_eq!(p.s(1.0, 0.0, 0.0), [4.0, 2.0]); + assert_eq!(p.s(0.0, 1.0, 0.0), [4.0, -2.0]); + assert_eq!(p.s(0.0, 0.0, 1.0), [0.0, -4.0]); + } + + #[test] + fn rotations_wrap() { + assert_eq!(Proj::new(5).rot, 1); + assert_eq!(Proj::new(-1).rot, 3); + } + + #[test] + fn unproject_round_trips() { + for rot in 0..4 { + let p = Proj::new(rot); + let s = p.s(12.5, -3.25, 2.0); + let w = p.unproject(s[0], s[1], 2.0); + assert!((w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9, "rot {rot}: {w:?}"); + } + } + + #[test] + fn exactly_two_of_four_walls_face_the_viewer() { + for rot in 0..4 { + let p = Proj::new(rot); + let n = [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] + .iter() + .filter(|(x, y)| p.vis(*x, *y).is_some()) + .count(); + assert_eq!(n, 2, "rot {rot}"); + } + } + + #[test] + fn faces_get_left_and_right_shades() { + let p = Proj::new(0); + // rotation 0 looks from the south-east: the south face (normal -y) is on the left + assert_eq!(shade(p.vis(0.0, -1.0).unwrap()), "iso-l"); + assert_eq!(shade(p.vis(1.0, 0.0).unwrap()), "iso-r"); + assert_eq!(shade(p.vis(1.0, -1.0).unwrap()), "iso-c"); + } + + #[test] + fn depth_grows_towards_viewer() { + let p = Proj::new(0); + assert!(p.depth(10.0, 0.0) > p.depth(0.0, 0.0)); // east is nearer from the south-east + assert!(p.depth(0.0, -10.0) > p.depth(0.0, 0.0)); // south is nearer + } +} diff --git a/crates/isokit-core/src/render.rs b/crates/isokit-core/src/render.rs new file mode 100644 index 0000000..69e40d4 --- /dev/null +++ b/crates/isokit-core/src/render.rs @@ -0,0 +1,705 @@ +//! Projection of a scene to SVG for one view rotation. + +use crate::fmt::{esc, hash, r1, r1s, round1}; +use crate::prim::{Prim, Style, P2}; +use crate::proj::{shade, Proj, C}; +use crate::scene::{Info, Scene}; +use crate::sort::{self, Bb, Sb}; +use crate::sprite::Sprite; +use serde::Serialize; +use std::collections::BTreeMap; +use std::fmt::Write; + +const SQ2: f64 = std::f64::consts::SQRT_2; + +#[derive(Clone, Debug)] +pub struct RenderOpts { + /// Unique id of the mounted view (`{uid}` in defs and raw SVG). + pub uid: u32, + /// Show plain tags (tags with a kind always show). + pub labels: bool, + /// Add `data-src` to sprites and keep the draw order (dev viewer). + pub dev: bool, +} + +impl Default for RenderOpts { + fn default() -> Self { + RenderOpts { uid: 0, labels: true, dev: false } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct OrderItem { + pub layer: String, + pub id: String, + pub bb: [f64; 6], + pub sb: [f64; 4], + #[serde(skip_serializing_if = "Option::is_none")] + pub src: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Rendered { + /// Inner SVG markup (defs, layers, tags). + pub html: String, + pub info: BTreeMap, + /// Frame of the drawing in screen units: [x, y, w, h]. + pub bbox: [f64; 4], + /// Screen point the view centres on when zoomed in by default. + pub focus: [f64; 2], + pub rot: u8, + /// Compass needle angle in degrees. + pub north: f64, + pub sprites: usize, + /// Number of SVG elements. + pub nodes: usize, + /// Depth-sort cycles that had to be broken (0 is ideal). + pub cycles: usize, + pub warnings: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub order: Vec, +} + +/// JS-style number: shortest round-trip form, no `-0`. +fn jn(out: &mut String, v: f64) { + if !v.is_finite() { + out.push_str("NaN"); + } else if v == 0.0 { + out.push('0'); + } else { + let _ = write!(out, "{v}"); + } +} + +fn did(out: &mut String, id: Option<&str>) { + if let Some(id) = id.filter(|s| !s.is_empty()) { + out.push_str(" data-id=\""); + out.push_str(&esc(id)); + out.push('"'); + } +} + +struct Ctx<'a> { + p: Proj, + uid: &'a str, + out: String, +} + +impl Ctx<'_> { + fn path_open(&mut self, cls: &str, id: Option<&str>) { + self.out.push_str(""); + } + } + if !st.no_top { + let tp: Vec<[f64; 3]> = pts.iter().enumerate().map(|(i, q)| [q[0], q[1], z1.at(i)]).collect(); + self.path_open(&format!("{} iso-t", st.top_cls()), id); + p.d(&mut self.out, &tp, false); + self.out.push_str("\"/>"); + } + } + + #[allow(clippy::too_many_arguments)] + fn cyl(&mut self, c: P2, r: f64, z0: f64, z1: f64, st: &Style) { + let p = self.p; + let b = p.s(c[0], c[1], z0); + let t = p.s(c[0], c[1], z1); + let rx = r * C * SQ2; + let ry = rx / 2.0; + let (bx, by, ty) = (r1s(b[0]), r1s(b[1]), r1s(t[1])); + let (l, rr, rxs, rys) = (r1s(b[0] - rx), r1s(b[0] + rx), r1s(rx), r1s(ry)); + let (fb, ft) = (r1s(b[1] + ry), r1s(t[1] + ry)); + let id = st.id.as_deref(); + let side = st.side_cls(); + if z1 > z0 && !st.no_side { + self.path_open(&format!("{side} iso-l"), id); + let _ = write!( + self.out, + "M{l} {ty}L{l} {by}A{rxs} {rys} 0 0 0 {bx} {fb}L{bx} {ft}A{rxs} {rys} 0 0 1 {l} {ty}Z\"/>" + ); + self.path_open(&format!("{side} iso-r"), id); + let _ = write!( + self.out, + "M{bx} {fb}A{rxs} {rys} 0 0 0 {rr} {by}L{rr} {ty}A{rxs} {rys} 0 0 1 {bx} {ft}Z\"/>" + ); + } + if !st.no_top { + let _ = write!(self.out, ""); + } + } + + #[allow(clippy::too_many_arguments)] + fn frust(&mut self, c: P2, r0: f64, r1_: f64, z0: f64, z1: f64, n: u32, st: &Style) { + let p = self.p; + let id = st.id.as_deref(); + let mut top = Vec::with_capacity(n as usize); + let tau = std::f64::consts::TAU; + for i in 0..n { + let a0 = i as f64 / n as f64 * tau; + let a1 = (i + 1) as f64 / n as f64 * tau; + let am = (a0 + a1) / 2.0; + top.push([c[0] + r1_ * a0.cos(), c[1] + r1_ * a0.sin(), z1]); + if st.no_side { + continue; + } + let Some(v) = p.vis(am.cos(), am.sin()) else { continue }; + self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id); + p.d( + &mut self.out, + &[ + [c[0] + r0 * a0.cos(), c[1] + r0 * a0.sin(), z0], + [c[0] + r0 * a1.cos(), c[1] + r0 * a1.sin(), z0], + [c[0] + r1_ * a1.cos(), c[1] + r1_ * a1.sin(), z1], + [c[0] + r1_ * a0.cos(), c[1] + r1_ * a0.sin(), z1], + ], + false, + ); + self.out.push_str("\"/>"); + } + if r1_ > 0.01 && !st.no_top { + self.path_open(&format!("{} iso-t", st.top_cls()), id); + p.d(&mut self.out, &top, false); + self.out.push_str("\"/>"); + } + } + + fn open(&mut self, a: P2, b: P2, list: &[[f64; 4]], cls: &str, id: Option<&str>) { + if list.is_empty() { + return; + } + let p = self.p; + let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { return }; + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l = (dx * dx + dy * dy).sqrt().max(1e-9); + let (ux, uy) = (dx / l, dy / l); + self.path_open(&format!("{cls} {}", shade(v)), id); + for q in list { + p.d( + &mut self.out, + &[ + [a[0] + ux * q[0], a[1] + uy * q[0], q[2]], + [a[0] + ux * q[1], a[1] + uy * q[1], q[2]], + [a[0] + ux * q[1], a[1] + uy * q[1], q[3]], + [a[0] + ux * q[0], a[1] + uy * q[0], q[3]], + ], + false, + ); + } + self.out.push_str("\"/>"); + } + + fn flat(&mut self, polys: &[Vec], z: f64, cls: &str, id: Option<&str>) { + let p = self.p; + let mut d = String::new(); + for poly in polys { + let p3: Vec<[f64; 3]> = poly.iter().map(|q| [q[0], q[1], z]).collect(); + p.d(&mut d, &p3, false); + } + if d.is_empty() { + return; + } + self.out.push_str(" 1 { + self.out.push_str(" fill-rule=\"evenodd\""); + } + self.out.push_str(" d=\""); + self.out.push_str(&d); + self.out.push_str("\"/>"); + } + + fn lines(&mut self, segs: &[[f64; 4]], z: f64, cls: &str, id: Option<&str>) { + if segs.is_empty() { + return; + } + let p = self.p; + self.path_open(cls, id); + for s in segs { + let a = p.s(s[0], s[1], z); + let b = p.s(s[2], s[3], z); + self.out.push('M'); + r1(&mut self.out, a[0]); + self.out.push(' '); + r1(&mut self.out, a[1]); + self.out.push('L'); + r1(&mut self.out, b[0]); + self.out.push(' '); + r1(&mut self.out, b[1]); + } + self.out.push_str("\"/>"); + } + + fn disc(&mut self, c: P2, z: f64, r: f64, cls: &str, id: Option<&str>) { + let s = self.p.s(c[0], c[1], z); + let rx = r * C * SQ2; + let _ = write!(self.out, "", r1s(s[0]), r1s(s[1]), r1s(rx), r1s(rx / 2.0)); + } + + /// Planar polygon shaded by its Newell normal. The view direction in + /// view space is (1, 1, 1) (C = CZ), so a face is turned towards the + /// viewer when vx + vy + nz > 0. + fn face(&mut self, pts: &[[f64; 3]], mat: &str, id: Option<&str>, two_sided: bool) { + let n = pts.len(); + if n < 3 { + return; + } + let mut nn = [0.0f64; 3]; + for i in 0..n { + let (a, b) = (pts[i], pts[(i + 1) % n]); + nn[0] += (a[1] - b[1]) * (a[2] + b[2]); + nn[1] += (a[2] - b[2]) * (a[0] + b[0]); + nn[2] += (a[0] - b[0]) * (a[1] + b[1]); + } + let len = (nn[0] * nn[0] + nn[1] * nn[1] + nn[2] * nn[2]).sqrt(); + if len < 1e-12 { + return; + } + let (mut nx, mut ny, mut nz) = (nn[0] / len, nn[1] / len, nn[2] / len); + let mut v = self.p.r(nx, ny); + let facing = v[0] + v[1] + nz; + if facing <= 1e-6 { + if !two_sided { + return; + } + nx = -nx; + ny = -ny; + nz = -nz; + v = self.p.r(nx, ny); + } + let face = if nz >= 0.5 || v[0] + v[1] <= 1e-6 { "iso-t" } else { shade(v) }; + self.path_open(&format!("{mat} {face}"), id); + let p = self.p; + p.d(&mut self.out, pts, false); + self.out.push_str("\"/>"); + } + + fn prim(&mut self, pr: &Prim) { + match pr { + Prim::Prism { pts, z0, z1, st } => self.prism(pts, z0, z1, st), + Prim::Cyl { c, r, z0, z1, st } => self.cyl(*c, *r, *z0, *z1, st), + Prim::Frust { c, r0, r1, z0, z1, n, st } => self.frust(*c, *r0, *r1, *z0, *z1, *n, st), + Prim::Open { a, b, list, cls, id } => self.open(*a, *b, list, cls, id.as_deref()), + Prim::Flat { polys, z, cls, id } => self.flat(polys, *z, cls, id.as_deref()), + Prim::Lines { segs, z, cls, id } => self.lines(segs, *z, cls, id.as_deref()), + Prim::Disc { c, z, r, cls, id } => self.disc(*c, *z, *r, cls, id.as_deref()), + Prim::Face { pts, mat, id, two_sided } => self.face(pts, mat, id.as_deref(), *two_sided), + Prim::Poly { polys, cls, id } => { + if polys.is_empty() { + return; + } + let p = self.p; + self.path_open(cls, id.as_deref()); + for q in polys { + p.d(&mut self.out, q, false); + } + self.out.push_str("\"/>"); + } + Prim::Circle { at, r, off, cls, id } => { + let s = self.p.s(at[0], at[1], at[2]); + let _ = write!(self.out, "", + r1s(s[0] + off[0]), + r1s(s[1] + off[1]), + r1s(*r) + ); + } + Prim::Stem { at, z0, z1, w, cls, id } => { + let b = self.p.s(at[0], at[1], *z0); + let t = self.p.s(at[0], at[1], *z1); + self.path_open(cls, id.as_deref()); + let _ = write!( + self.out, + "M{} {}L{} {}L{} {}L{} {}Z\"/>", + r1s(b[0] - w), + r1s(b[1]), + r1s(t[0] - w), + r1s(t[1]), + r1s(t[0] + w), + r1s(t[1]), + r1s(b[0] + w), + r1s(b[1]) + ); + } + Prim::Puffs { at, kind, n, dur } => { + let s = self.p.s(at[0], at[1], at[2]); + let _ = write!(self.out, "", r1s(s[0]), r1s(s[1])); + let r = if kind == "iso-smoke" { + 3.2 + } else if kind == "iso-puff2" { + 2.4 + } else { + 1.5 + }; + for i in 0..*n { + let delay = i as f64 * dur / *n as f64 + hash(at[0] + at[1] + i as f64) * 0.4; + let _ = write!(self.out, ""); + } + self.out.push_str(""); + } + Prim::Fan { at, r } => { + self.disc([at[0], at[1]], at[2], *r, "iso-fanring", None); + let s = self.p.s(at[0], at[1], at[2]); + let (a, w) = ((r * 0.85 * 1000.0).round() / 1000.0, 0.12); + let _ = write!( + self.out, + "", + C / 2.0, + -C, + C / 2.0, + r1s(s[0]), + r1s(s[1]), + -a, + -w, + -a, + -w, + -a, + -a + ); + } + Prim::Raw { at, svg } => { + let svg = svg.replace("{uid}", self.uid); + match at { + Some(at) => { + let s = self.p.s(at[0], at[1], at[2]); + let _ = write!(self.out, "{svg}", r1s(s[0]), r1s(s[1])); + } + None => self.out.push_str(&svg), + } + } + Prim::Group { cls, style, items } => { + self.out.push_str("'); + for q in items { + self.prim(q); + } + self.out.push_str(""); + } + Prim::Seq { items } => { + let p = self.p; + let mut idx: Vec = (0..items.len()).collect(); + idx.sort_by(|&a, &b| { + let (pa, pb) = (items[a].0, items[b].0); + p.depth(pa[0], pa[1]).total_cmp(&p.depth(pb[0], pb[1])) + }); + for i in idx { + for q in &items[i].1 { + self.prim(q); + } + } + } + } + } +} + +/// Sort box of a sprite in a rotation: (view box, screen box, has extent). +pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) { + let mut bb: Bb = [1e9, 1e9, -1e9, -1e9, 1e9, -1e9]; + let mut sb: Sb = [1e9, 1e9, -1e9, -1e9]; + for e in &s.ext { + let v = p.r(e[0], e[1]); + let q = p.s(e[0], e[1], e[2]); + bb[0] = bb[0].min(v[0]); + bb[1] = bb[1].min(v[1]); + bb[2] = bb[2].max(v[0]); + bb[3] = bb[3].max(v[1]); + bb[4] = bb[4].min(e[2]); + bb[5] = bb[5].max(e[2]); + sb[0] = sb[0].min(q[0]); + sb[1] = sb[1].min(q[1]); + sb[2] = sb[2].max(q[0]); + sb[3] = sb[3].max(q[1]); + } + (bb, sb, !s.ext.is_empty()) +} + +/// Render a scene for one rotation. +pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered { + let p = Proj::new(rot as i64); + let uid = opts.uid.to_string(); + let mut cx = Ctx { p, uid: &uid, out: String::with_capacity(64 * 1024) }; + let mut frame = [1e9f64, 1e9, -1e9, -1e9]; + let mut sprites = 0; + let mut cycles = 0; + let mut order = vec![]; + let mut warnings = scene.warnings.clone(); + + if !scene.defs.is_empty() { + cx.out.push_str(""); + for d in &scene.defs { + cx.out.push_str(&d.replace("{uid}", &uid)); + } + cx.out.push_str(""); + } + + for layer in &scene.layers { + let live: Vec<&Sprite> = layer.sprites.iter().filter(|s| !s.is_empty()).collect(); + if live.is_empty() { + continue; + } + let bx: Vec<(Bb, Sb, bool)> = live.iter().map(|s| boxes(s, p)).collect(); + for (_, sb, has) in &bx { + if *has { + frame[0] = frame[0].min(sb[0]); + frame[1] = frame[1].min(sb[1]); + frame[2] = frame[2].max(sb[2]); + frame[3] = frame[3].max(sb[3]); + } + } + let idx: Vec = if layer.opts.sort { + let pairs: Vec<(Bb, Sb)> = bx.iter().map(|(b, s, _)| (*b, *s)).collect(); + let o = sort::sort(&pairs, layer.opts.zfirst); + cycles += o.cycles; + o.order + } else { + (0..live.len()).collect() + }; + let _ = write!(cx.out, "", esc(&layer.opts.cls), esc(&layer.name)); + for i in idx { + let s = live[i]; + sprites += 1; + cx.out.push_str("'); + for pr in &s.prims { + cx.prim(pr); + } + cx.out.push_str(""); + } + cx.out.push_str(""); + } + + // tags + cx.out.push_str(""); + for t in &scene.tags { + if t.text.is_empty() || (t.kind.is_empty() && !opts.labels) { + continue; + } + let q = p.s(t.at[0], t.at[1], t.at[2]); + let w = (t.text.chars().count() as f64 * 5.6 + 12.0 + 0.5).floor(); + let mut d = String::new(); + d.push('M'); + jn(&mut d, -w / 2.0); + d.push_str(" -21h"); + jn(&mut d, w); + d.push_str("v15h"); + jn(&mut d, -(w / 2.0 - 4.0)); + d.push_str("l-4 5l-4 -5h"); + jn(&mut d, -(w / 2.0 - 4.0)); + d.push('z'); + cx.out.push_str("{}", + esc(&t.text) + ); + } + cx.out.push_str(""); + + // frame: the scene bounds (plan rectangle down to the ground depth) or the drawing + if let Some((b, zb)) = scene.bounds { + let top = frame[1]; + frame = [1e9, 1e9, -1e9, -1e9]; + for (x, y) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])] { + for z in [0.0, zb] { + let q = p.s(x, y, z); + frame[0] = frame[0].min(q[0]); + frame[1] = frame[1].min(q[1]); + frame[2] = frame[2].max(q[0]); + frame[3] = frame[3].max(q[1]); + } + } + frame[1] = frame[1].min(top); + } + if frame[0] > frame[2] { + frame = [-100.0, -100.0, 100.0, 100.0]; + warnings.push("the scene is empty".into()); + } + let bbox = [frame[0], frame[1] - 30.0, frame[2] - frame[0], frame[3] - frame[1] + 30.0].map(round1); + let focus = match scene.focus_point() { + Some(f) => p.s(f[0], f[1], f[2]), + None => [bbox[0] + bbox[2] / 2.0, bbox[1] + bbox[3] / 2.0], + }; + + let html = cx.out; + let nodes = html.matches('<').count() - html.matches(" Scene { + let mut sc = Scene::new(); + let mut s = Sprite::new("hall", ""); + s.cuboid(0.0, 0.0, 10.0, 6.0, 0.0, 4.0, Style::mat("iso-wall")); + sc.add_sprite("main", s); + sc.info("hall", "Hall", "10 x 6 m"); + sc.tag([5.0, 3.0, 5.0], "Hall", 5.0, "hall", ""); + sc + } + + #[test] + fn box_shows_two_sides_and_a_top_in_every_rotation() { + let sc = one_box(); + for rot in 0..4 { + let r = render(&sc, rot, &RenderOpts::default()); + assert_eq!(r.html.matches("iso-wall iso-").count(), 3, "rot {rot}: {}", r.html); + assert!(r.html.contains("iso-wall iso-t")); + assert!(r.html.contains("iso-wall iso-l")); + assert!(r.html.contains("iso-wall iso-r")); + assert!(!r.html.contains("NaN")); + assert_eq!(r.sprites, 1); + } + } + + #[test] + fn box_path_matches_the_js_engine() { + // JS (iso-mooban.js prismS, rotation 0) for box(0,0,10,6,0,4): the top face + let sc = one_box(); + let r = render(&sc, 0, &RenderOpts::default()); + assert!(r.html.contains(""), "{}", r.html); + } + + #[test] + fn labels_off_hides_plain_tags_only() { + let mut sc = one_box(); + sc.tag([0.0, 0.0, 0.0], "Gate", 9.0, "gate", "iso-signtag"); + let r = render(&sc, 1, &RenderOpts { labels: false, ..Default::default() }); + assert!(r.html.contains(">Gate<")); + assert!(!r.html.contains(">Hall<")); + } + + #[test] + fn frame_and_counts() { + let r = render(&one_box(), 1, &RenderOpts::default()); + assert!(r.bbox[2] > 0.0 && r.bbox[3] > 30.0); + assert!(r.nodes >= 5, "{}", r.nodes); + assert_eq!(r.info.len(), 1); + } + + #[test] + fn cylinder_and_frustum_render() { + let mut sc = Scene::new(); + let mut s = Sprite::new("tank", ""); + s.cyl(0.0, 0.0, 2.0, 0.0, 5.0, Style::mat("iso-tank")); + s.frust(0.0, 0.0, 2.0, 0.3, 5.0, 6.0, 12, Style::mat("iso-tank")); + sc.add_sprite("main", s); + for rot in 0..4 { + let r = render(&sc, rot, &RenderOpts::default()); + assert!(r.html.contains("