diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..46526f6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.png binary +*.jpg binary diff --git a/.gitignore b/.gitignore index be16902..5fe7b1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target/ -/dist/ +dist/ *.log __pycache__/ +*.json.tmp diff --git a/Cargo.lock b/Cargo.lock index 3c960cb..8cc52c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,68 @@ dependencies = [ "zerocopy", ] +[[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", +] + +[[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", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "autocfg" version = "1.5.1" @@ -49,6 +111,58 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compact_str" version = "0.10.0" @@ -148,6 +262,18 @@ 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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "indexmap" version = "2.14.2" @@ -158,9 +284,22 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "isokit-cli" version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "isokit-project", + "serde_json", + "tiny_http", +] [[package]] name = "isokit-core" @@ -169,6 +308,18 @@ dependencies = [ "serde", ] +[[package]] +name = "isokit-project" +version = "0.1.0" +dependencies = [ + "isokit-core", + "isokit-script", + "isokit-theme", + "serde", + "serde_json", + "toml", +] + [[package]] name = "isokit-script" version = "0.1.0" @@ -209,6 +360,12 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" @@ -233,6 +390,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -389,6 +552,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.119" @@ -426,6 +595,18 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "toml" version = "1.1.8+spec-1.1.0" @@ -471,6 +652,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -547,6 +734,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "winnow" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 41f21ac..bebb7a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/isokit-core", "crates/isokit-script", "crates/isokit-theme", + "crates/isokit-project", "crates/isokit-cli", ] @@ -18,6 +19,7 @@ repository = "https://git.b4l.co.th/B4L/isokit" isokit-core = { path = "crates/isokit-core" } isokit-script = { path = "crates/isokit-script" } isokit-theme = { path = "crates/isokit-theme" } +isokit-project = { path = "crates/isokit-project" } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = { version = "1", features = ["preserve_order"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..e189277 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# isokit + +Isometric scene engine for B4L projects: site plans, plants and buildings drawn +as SimCity-style views that you can pan, zoom, rotate, cut open by storey and +click for details. One Rust binary does everything: + +- **Studio**: a GUI editor in the browser for people who don't code. You add + objects from a palette, drag them into place, and edit their properties in forms. +- **MCP server**: AI agents (Hermes and others) edit the same scene with the + same operations; their changes show up live in an open Studio. +- **Build**: a self-contained HTML page with every view prerendered, or + standalone SVGs for documents. +- **Engine and scripting**: everything you can place is a *prefab* written in + [Rhai](https://rhai.rs). Projects add their own prefabs, scripts and colours + without touching the engine. + +The engine is a Rust port of the hand-written JS views in `cnx_vinyl` +(fleet calculator), `cnx_scuba` (centre view) and `mooban` (estate view), +and it keeps their look: same projection, depth sort, face shading, palette and +viewer behaviour. + +## Quick start + +```bash +cargo build --release # target/release/isokit.exe (one file, ~7 MB) + +isokit new C:/dev/myproject/site-view # starter project +isokit studio C:/dev/myproject/site-view # opens the editor in the browser +isokit build C:/dev/myproject/site-view # dist/index.html, no server needed +``` + +Try the demo: `isokit studio examples/demo`. + +### Studio (for everyone) + +| Do | How | +|---|---| +| Add | pick an object on the left, click on the map (Shift-click places several) | +| Select | click it on the map or in the object list | +| Move | drag it, or arrow keys by the snap step (0.1 / 0.5 / 1 / 5 m) | +| Turn | R / Shift+R or ⟲ ⟳ | +| Edit | the right panel: size, storeys, colours, solar, tooltip text, label, levels | +| Copy / delete | Ctrl+D / Delete; Ctrl+Z / Ctrl+Y undo and redo | +| Look | drag the ground to pan, Ctrl+wheel or pinch to zoom, ↺ ↻ rotate, Site / GF / 1F levels | + +Every change is saved to `scene.json` at once. When the file changes on disk +(an agent, git pull, another editor), the Studio reloads it, and that change can +be undone like any other. + +### Agents (MCP) + +While the Studio runs, it also serves MCP over HTTP at `http://127.0.0.1:7878/mcp`. +Without the Studio, run `isokit mcp ` over stdio. In Hermes +(`config.yaml`): + +```yaml +mcp_servers: + isokit: + command: C:\dev\isokit\target\release\isokit.exe + args: [mcp, C:\dev\myproject\site-view] +``` + +Tools: `project_info`, `list_prefabs`, `list_objects`, `get_object`, +`add_object`, `update_object`, `remove_object`, `duplicate_object`, +`apply_ops` (batch, one undo step), `set_params`, `render_view`, +`check_project`, `build_html`, `undo`, `redo`. Bad input comes back with a +message that says what is allowed, e.g. the list of types or a prop's range. + +## A project + +```text +isokit.toml title, levels, parameters, theme files, prefab libraries +scene.json the objects (edited by the Studio and agents) +scene.rhai optional script, drawn before the objects +lib/*.rhai optional project prefabs (listed under prelude) +theme.toml optional colours and materials +``` + +An object in `scene.json`: + +```json +{ "id": "hall", "type": "building", "name": "Workshop", "x": -12, "y": 6, "rot": 90, + "props": { "w": 24, "d": 12, "floors": 2, "pv": true }, + "tag": "Workshop", "info": "Two storeys, solar on the roof", "levels": [] } +``` + +Coordinates are metres: x points east, y north, z up. `x, y` is the centre of +the footprint, and `rot` turns the object in 90° steps. Levels: `site` (or any +other id) shows buildings closed, while `gf`, `1f`, `2f` … cut them open at that +storey. + +## Commands + +| Command | | +|---|---| +| `isokit new ` | starter project | +| `isokit studio [dir] [--port N] [--no-open]` | GUI editor + `/mcp` + `/view` live preview | +| `isokit mcp [dir]` | MCP over stdio | +| `isokit build [dir] [-o file]` | self-contained HTML (all levels × 4 rotations) | +| `isokit render [dir] -o x.svg [--level gf] [--rot 2] [--light]` | one view as SVG | +| `isokit check [dir]` | lint: unknown types, props out of range, script warnings, NaN, node budget, unstyled classes | +| `isokit prefabs [dir]` | object types and their properties | + +## Architecture + +```text +crates/isokit-core projection (4 rotations), primitives, sprites, depth sort, SVG (no deps but serde) +crates/isokit-theme TOML palettes / materials / rules -> CSS; built-in default theme +crates/isokit-script Rhai runtime: drawing API, prefab library (stdlib/*.rhai), scene document driver +crates/isokit-project project folder, document ops, export, check, Workspace (undo, reload), MCP tools +crates/isokit-cli the `isokit` binary: Studio HTTP server, MCP stdio, build/render/check +viewer/ isokit-view.js/.css: pan, zoom, levels, rotation, tooltips (any page can embed it) +studio/ the Studio GUI (served by `isokit studio`) +examples/demo demo project +``` + +The layers are independent. A Rust program can draw with `isokit-core` alone. +`isokit-script` reads sources from memory, with no disk access, so the same +runtime can later run in the browser as WASM. The Studio and MCP both apply the +same `ops::Op` JSON operations through a `Workspace`. + +Writing prefabs and scripts: [docs/SCRIPTING.md](docs/SCRIPTING.md). +Plans: [docs/ROADMAP.md](docs/ROADMAP.md). + +## Development + +```bash +cargo test # engine, runtime, ops, demo, MCP +cargo clippy --all-targets -- -D warnings +isokit studio examples/demo --port 7890 --no-open & +uv run --with playwright python tools/check_studio.py http://127.0.0.1:7890 # GUI + MCP end to end (Edge) +``` + +Set `ISOKIT_ASSETS=C:/dev/isokit` to serve `viewer/` and `studio/` from disk, so +you can edit the GUI without rebuilding. `tools/css2theme.py` converted the +original views' CSS into `crates/isokit-theme/themes/default.toml`. diff --git a/crates/isokit-cli/Cargo.toml b/crates/isokit-cli/Cargo.toml index e8c0db5..dd8b895 100644 --- a/crates/isokit-cli/Cargo.toml +++ b/crates/isokit-cli/Cargo.toml @@ -1,8 +1,17 @@ [package] name = "isokit-cli" +description = "isokit: isometric scene engine - Studio GUI, MCP server, build and export" version.workspace = true edition.workspace = true +license.workspace = true [[bin]] name = "isokit" path = "src/main.rs" + +[dependencies] +isokit-project = { workspace = true } +serde_json = { workspace = true } +tiny_http = { workspace = true } +clap = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/isokit-cli/src/main.rs b/crates/isokit-cli/src/main.rs index f328e4d..51d6fce 100644 --- a/crates/isokit-cli/src/main.rs +++ b/crates/isokit-cli/src/main.rs @@ -1 +1,209 @@ -fn main() {} +//! `isokit`: the command line. +//! +//! isokit new start a project +//! isokit studio [dir] the GUI editor in the browser (also serves MCP at /mcp) +//! isokit mcp [dir] MCP server over stdio for AI agents +//! isokit build [dir] self-contained HTML page with every view +//! isokit render [dir] -o x.svg one view as SVG +//! isokit check [dir] lint the project +//! isokit prefabs [dir] list the object types + +mod serve; + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use isokit_project::{check, page, Project}; +use std::io::{BufRead, Write}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command( + name = "isokit", + version, + about = "Isometric scene engine: Studio GUI, MCP server, HTML/SVG export" +)] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Create a project folder with a starter scene + New { + dir: PathBuf, + #[arg(long)] + title: Option, + }, + /// Open the Studio (GUI editor) in the browser + Studio { + #[arg(default_value = ".")] + dir: PathBuf, + #[arg(long, default_value_t = 7878)] + port: u16, + /// Do not open a browser + #[arg(long)] + no_open: bool, + }, + /// Serve the MCP protocol on stdio (for AI agents) + Mcp { + #[arg(default_value = ".")] + dir: PathBuf, + }, + /// Build the self-contained HTML page + Build { + #[arg(default_value = ".")] + dir: PathBuf, + #[arg(short, long)] + out: Option, + }, + /// Render one view as a standalone SVG + Render { + #[arg(default_value = ".")] + dir: PathBuf, + #[arg(short, long)] + out: PathBuf, + #[arg(long)] + level: Option, + #[arg(long)] + rot: Option, + /// Light (day) colours instead of dark + #[arg(long)] + light: bool, + }, + /// Lint the project (exit code 1 on errors) + Check { + #[arg(default_value = ".")] + dir: PathBuf, + }, + /// List the object types (prefabs) and their properties + Prefabs { + #[arg(default_value = ".")] + dir: PathBuf, + }, +} + +fn load(dir: &PathBuf) -> Result { + let p = Project::load(dir).map_err(|e| anyhow::anyhow!("{e}"))?; + if let Err(e) = &p.runtime { + bail!("{e}"); + } + Ok(p) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.cmd { + Cmd::New { dir, title } => { + let title = title.unwrap_or_else(|| { + dir.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or("Scene".into()) + }); + isokit_project::scaffold(&dir, &title).map_err(|e| anyhow::anyhow!("{e}"))?; + println!( + "Created {} - open it with: isokit studio {}", + dir.display(), + dir.display() + ); + } + Cmd::Studio { dir, port, no_open } => serve::studio(&dir, port, !no_open)?, + Cmd::Mcp { dir } => mcp_stdio(&dir)?, + Cmd::Build { dir, out } => { + let p = load(&dir)?; + let out = out.unwrap_or_else(|| { + dir.join( + p.manifest + .build + .out + .clone() + .unwrap_or_else(|| "dist/index.html".into()), + ) + }); + let html = page::static_page(&p).map_err(|e| anyhow::anyhow!("{e}"))?; + if let Some(d) = out.parent() { + std::fs::create_dir_all(d).ok(); + } + std::fs::write(&out, &html).with_context(|| out.display().to_string())?; + let views = p.manifest.levels().len() * 4; + println!( + "{} ({} views, {} kB)", + out.display(), + views, + html.len() / 1024 + ); + } + Cmd::Render { + dir, + out, + level, + rot, + light, + } => { + let p = load(&dir)?; + let level = level.unwrap_or_else(|| p.manifest.default_level()); + let rot = rot.unwrap_or(p.manifest.default_rotation) % 4; + let svg = page::svg(&p, &level, rot, light).map_err(|e| anyhow::anyhow!("{e}"))?; + std::fs::write(&out, svg).with_context(|| out.display().to_string())?; + println!("{} ({level}, rotation {rot})", out.display()); + } + Cmd::Check { dir } => { + let p = Project::load(&dir).map_err(|e| anyhow::anyhow!("{e}"))?; + let f = check::check(&p); + for x in &f { + println!( + "{:7} {}{}", + x.level, + x.object + .as_ref() + .map(|o| format!("[{o}] ")) + .unwrap_or_default(), + x.message + ); + } + let errors = f.iter().filter(|x| x.level == "error").count(); + println!("{} finding(s), {errors} error(s)", f.len()); + if errors > 0 { + std::process::exit(1); + } + } + Cmd::Prefabs { dir } => { + let p = load(&dir)?; + for m in p.catalog() { + println!("{:12} {:24} {} [{}]", m.kind, m.label, m.category, m.source); + for d in &m.props { + println!( + " {:10} {:8} default {} {}", + d.key, d.kind, d.default, d.label + ); + } + } + } + } + Ok(()) +} + +/// MCP over stdio: one JSON-RPC message per line in, one per line out. +fn mcp_stdio(dir: &PathBuf) -> Result<()> { + let mut ws = + isokit_project::workspace::Workspace::open(dir).map_err(|e| anyhow::anyhow!("{e}"))?; + let stdin = std::io::stdin(); + let mut out = std::io::stdout().lock(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let resp = match serde_json::from_str::(&line) { + Ok(msg) => isokit_project::mcp::handle(&mut ws, &msg), + Err(e) => Some( + serde_json::json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }), + ), + }; + if let Some(r) = resp { + writeln!(out, "{r}")?; + out.flush()?; + } + } + Ok(()) +} diff --git a/crates/isokit-cli/src/serve.rs b/crates/isokit-cli/src/serve.rs new file mode 100644 index 0000000..b9ee41b --- /dev/null +++ b/crates/isokit-cli/src/serve.rs @@ -0,0 +1,356 @@ +//! `isokit studio`: the GUI editor and its JSON API on localhost, plus the +//! MCP endpoint (`POST /mcp`, JSON responses) so agents edit the same scene +//! live. Single-threaded: one request at a time against one Workspace. + +use anyhow::Result; +use isokit_project::ops::Op; +use isokit_project::workspace::Workspace; +use isokit_project::{assets, check, mcp, page}; +use serde_json::{json, Map, Value}; +use std::io::Read; +use std::path::Path; +use tiny_http::{Header, Method, Request, Response, Server}; + +const VIEW_PAGE: &str = r#" +isokit preview + + + +
"#; + +fn header(k: &str, v: &str) -> Header { + Header::from_bytes(k.as_bytes(), v.as_bytes()).expect("valid header") +} + +fn send(req: Request, status: u16, mime: &str, body: String) { + let r = Response::from_string(body) + .with_status_code(status) + .with_header(header("Content-Type", mime)) + .with_header(header("Cache-Control", "no-store")); + let _ = req.respond(r); +} + +fn send_json(req: Request, v: Value) { + let status = if v.get("error").is_some() && v.get("jsonrpc").is_none() { + 400 + } else { + 200 + }; + send( + req, + status, + "application/json; charset=utf-8", + v.to_string(), + ); +} + +fn query(url: &str) -> Map { + let mut m = Map::new(); + if let Some((_, q)) = url.split_once('?') { + for kv in q.split('&') { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + m.insert(decode(k), Value::String(decode(v))); + } + } + m +} + +fn decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'+' => out.push(b' '), + b'%' if i + 2 < b.len() => { + if let Ok(v) = + u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) + { + out.push(v); + i += 2; + } else { + out.push(b'%'); + } + } + c => out.push(c), + } + i += 1; + } + String::from_utf8_lossy(&out).to_string() +} + +/// Requests from a web page on another origin (DNS rebinding, a malicious +/// site) must not edit the scene. +fn origin_ok(req: &Request) -> bool { + match req.headers().iter().find(|h| h.field.equiv("Origin")) { + None => true, + Some(h) => { + let o = h.value.as_str(); + o == "null" || o.starts_with("http://127.0.0.1") || o.starts_with("http://localhost") + } + } +} + +fn state(ws: &Workspace) -> Value { + json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() }) +} + +fn meta(ws: &Workspace) -> Value { + let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default(); + v["dir"] = json!(ws.dir.display().to_string()); + v["load_error"] = json!(ws.load_error); + v["script_error"] = json!(ws.project.runtime.as_ref().err()); + v +} + +pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> { + let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + // Windows canonical paths carry a \?\ prefix that other tools do not accept + let dir = std::path::PathBuf::from( + dir.to_string_lossy() + .trim_start_matches(r"\\?\") + .to_string(), + ); + let mut ws = Workspace::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?; + let (server, port) = (port..port.saturating_add(20)) + .find_map(|p| Server::http(("127.0.0.1", p)).ok().map(|s| (s, p))) + .ok_or_else(|| anyhow::anyhow!("no free port from {port}"))?; + let url = format!("http://127.0.0.1:{port}/"); + println!("isokit Studio: {url}"); + println!(" project {}", dir.display()); + println!(" preview {url}view"); + println!(" MCP {url}mcp (or: isokit mcp {})", dir.display()); + if let Err(e) = &ws.project.runtime { + println!(" script error: {e}"); + } + if open { + open_browser(&url); + } + for mut req in server.incoming_requests() { + let url = req.url().to_string(); + let path = url.split('?').next().unwrap_or("/").to_string(); + let method = req.method().clone(); + let mut body = String::new(); + if method == Method::Post { + if !origin_ok(&req) { + send( + req, + 403, + "text/plain", + "cross-origin request refused".into(), + ); + continue; + } + let _ = req.as_reader().take(16 << 20).read_to_string(&mut body); + } + let bodyv: Value = if body.trim().is_empty() { + json!({}) + } else { + serde_json::from_str(&body).unwrap_or(Value::Null) + }; + match (&method, path.as_str()) { + (Method::Get, "/") => send( + req, + 200, + "text/html; charset=utf-8", + assets::get("studio/index.html") + .unwrap_or_default() + .into_owned(), + ), + (Method::Get, "/view") => send(req, 200, "text/html; charset=utf-8", VIEW_PAGE.into()), + (Method::Get, "/assets/theme.css") => { + ws.refresh(); + send(req, 200, "text/css; charset=utf-8", ws.project.theme.css()) + } + (Method::Get, p) if p.starts_with("/assets/") => { + let name = &p["/assets/".len()..]; + let mime = if name.ends_with(".js") { + "text/javascript; charset=utf-8" + } else if name.ends_with(".css") { + "text/css; charset=utf-8" + } else { + "text/html; charset=utf-8" + }; + match assets::get(name) { + Some(s) => send(req, 200, mime, s.into_owned()), + None => send(req, 404, "text/plain", "not found".into()), + } + } + (Method::Get, "/api/version") => { + ws.refresh(); + send_json(req, json!({ "version": ws.version.to_string() })) + } + (Method::Get, "/api/meta") => { + ws.refresh(); + send_json(req, meta(&ws)) + } + (Method::Get, "/api/doc") => { + ws.refresh(); + send_json(req, state(&ws)) + } + (Method::Get, "/api/render") => { + ws.refresh(); + let q = query(&url); + let p = &ws.project; + let level = q + .get("level") + .and_then(|v| v.as_str()) + .map(str::to_string) + .unwrap_or_else(|| p.manifest.default_level()); + let rot = q + .get("rot") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let cfg = q + .get("cfg") + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str::>(s).ok()); + let dev = q.get("dev").is_some(); + match p.render(&level, rot, cfg.as_ref(), dev) { + Ok(v) => send_json(req, serde_json::to_value(v).unwrap_or_default()), + Err(e) => send( + req, + 200, + "application/json", + json!({ "error": e }).to_string(), + ), + } + } + (Method::Get, "/api/check") => { + ws.refresh(); + send_json(req, json!({ "findings": check::check(&ws.project) })) + } + (Method::Post, "/api/op") => { + let r = if let Some(list) = bodyv.get("ops").and_then(|v| v.as_array()) { + let ops: Result, String> = list + .iter() + .map(|v| serde_json::from_value::(v.clone()).map_err(|e| e.to_string())) + .collect(); + ops.and_then(|l| ws.apply_all(l)) + .map(|o| o.last().and_then(|x| x.id.clone())) + } else { + serde_json::from_value::(bodyv.clone()) + .map_err(|e| format!("bad operation: {e}")) + .and_then(|op| ws.apply(op)) + .map(|o| o.id) + }; + match r { + Ok(id) => { + let mut s = state(&ws); + s["ok"] = json!(true); + s["id"] = json!(id); + send_json(req, s) + } + Err(e) => send( + req, + 200, + "application/json", + json!({ "error": e }).to_string(), + ), + } + } + (Method::Post, "/api/undo") | (Method::Post, "/api/redo") => { + let r = if path.ends_with("undo") { + ws.undo() + } else { + ws.redo() + }; + match r { + Ok(_) => send_json(req, state(&ws)), + Err(e) => send( + req, + 200, + "application/json", + json!({ "error": e }).to_string(), + ), + } + } + (Method::Post, "/api/build") => { + ws.refresh(); + let p = &ws.project; + let out = p.dir.join( + p.manifest + .build + .out + .clone() + .unwrap_or_else(|| "dist/index.html".into()), + ); + let r = page::static_page(p) + .map_err(|e| e.to_string()) + .and_then(|html| { + if let Some(d) = out.parent() { + std::fs::create_dir_all(d).map_err(|e| e.to_string())?; + } + std::fs::write(&out, html).map_err(|e| e.to_string()) + }); + match r { + Ok(_) => send_json(req, json!({ "path": out.display().to_string() })), + Err(e) => send( + req, + 200, + "application/json", + json!({ "error": e }).to_string(), + ), + } + } + (Method::Post, "/mcp") => { + // a batch (array) or a single message; notifications get 202 + let resp = match &bodyv { + Value::Array(list) => { + let out: Vec = list + .iter() + .filter_map(|m| mcp::handle(&mut ws, m)) + .collect(); + if out.is_empty() { + None + } else { + Some(Value::Array(out)) + } + } + Value::Null => Some( + json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": "parse error" } }), + ), + m => mcp::handle(&mut ws, m), + }; + match resp { + Some(v) => send(req, 200, "application/json", v.to_string()), + None => { + let _ = req.respond(Response::empty(202)); + } + } + } + (Method::Get, "/mcp") => send( + req, + 405, + "text/plain", + "MCP: POST JSON-RPC messages here".into(), + ), + _ => send(req, 404, "text/plain", "not found".into()), + } + } + Ok(()) +} + +fn open_browser(url: &str) { + #[cfg(windows)] + let r = std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .spawn(); + #[cfg(target_os = "macos")] + let r = std::process::Command::new("open").arg(url).spawn(); + #[cfg(all(unix, not(target_os = "macos")))] + let r = std::process::Command::new("xdg-open").arg(url).spawn(); + if r.is_err() { + println!(" open {url} in a browser"); + } +} diff --git a/crates/isokit-core/src/fmt.rs b/crates/isokit-core/src/fmt.rs index 9a821a2..0101b27 100644 --- a/crates/isokit-core/src/fmt.rs +++ b/crates/isokit-core/src/fmt.rs @@ -16,7 +16,7 @@ pub fn r1(out: &mut String, v: f64) { } let a = t.unsigned_abs(); let _ = write!(out, "{}", a / 10); - if a % 10 != 0 { + if !a.is_multiple_of(10) { let _ = write!(out, ".{}", a % 10); } } @@ -65,7 +65,7 @@ pub fn thousands(n: f64) -> String { 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 { + if i > 0 && (s.len() - i).is_multiple_of(3) { o.push(','); } o.push(ch); diff --git a/crates/isokit-core/src/prim.rs b/crates/isokit-core/src/prim.rs index 8099004..cbf7be1 100644 --- a/crates/isokit-core/src/prim.rs +++ b/crates/isokit-core/src/prim.rs @@ -46,7 +46,10 @@ pub struct Style { impl Style { pub fn mat(mat: impl Into) -> Self { - Style { mat: mat.into(), ..Default::default() } + Style { + mat: mat.into(), + ..Default::default() + } } pub fn id(mut self, id: impl Into) -> Self { self.id = Some(id.into()); @@ -75,44 +78,116 @@ impl Style { #[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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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 }, + 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 index e560662..c547c23 100644 --- a/crates/isokit-core/src/proj.rs +++ b/crates/isokit-core/src/proj.rs @@ -18,7 +18,9 @@ pub struct Proj { impl Proj { pub fn new(rot: i64) -> Self { - Proj { rot: rot.rem_euclid(4) as u8 } + Proj { + rot: rot.rem_euclid(4) as u8, + } } /// World plan point to view coordinates. @@ -147,7 +149,10 @@ mod tests { 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:?}"); + assert!( + (w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9, + "rot {rot}: {w:?}" + ); } } diff --git a/crates/isokit-core/src/render.rs b/crates/isokit-core/src/render.rs index 69e40d4..c94ac02 100644 --- a/crates/isokit-core/src/render.rs +++ b/crates/isokit-core/src/render.rs @@ -24,7 +24,11 @@ pub struct RenderOpts { impl Default for RenderOpts { fn default() -> Self { - RenderOpts { uid: 0, labels: true, dev: false } + RenderOpts { + uid: 0, + labels: true, + dev: false, + } } } @@ -101,7 +105,9 @@ impl Ctx<'_> { if !st.no_side { for i in 0..n { let (a, b) = (pts[i], pts[(i + 1) % n]); - let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { continue }; + let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { + continue; + }; let j = (i + 1) % n; if z1.at(i) - z0.at(i) < 1e-6 && z1.at(j) - z0.at(j) < 1e-6 { continue; @@ -109,14 +115,23 @@ impl Ctx<'_> { self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id); p.d( &mut self.out, - &[[a[0], a[1], z0.at(i)], [b[0], b[1], z0.at(j)], [b[0], b[1], z1.at(j)], [a[0], a[1], z1.at(i)]], + &[ + [a[0], a[1], z0.at(i)], + [b[0], b[1], z0.at(j)], + [b[0], b[1], z1.at(j)], + [a[0], a[1], z1.at(i)], + ], false, ); 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(); + 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("\"/>"); @@ -150,7 +165,10 @@ impl Ctx<'_> { if !st.no_top { let _ = write!(self.out, ""); + let _ = write!( + self.out, + " cx=\"{bx}\" cy=\"{ty}\" rx=\"{rxs}\" ry=\"{rys}\"/>" + ); } } @@ -168,7 +186,9 @@ impl Ctx<'_> { if st.no_side { continue; } - let Some(v) = p.vis(am.cos(), am.sin()) else { 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, @@ -194,7 +214,9 @@ impl Ctx<'_> { return; } let p = self.p; - let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { return }; + 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); @@ -262,7 +284,14 @@ impl Ctx<'_> { let rx = r * C * SQ2; let _ = write!(self.out, "", r1s(s[0]), r1s(s[1]), r1s(rx), r1s(rx / 2.0)); + let _ = write!( + self.out, + " cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\"/>", + r1s(s[0]), + r1s(s[1]), + r1s(rx), + r1s(rx / 2.0) + ); } /// Planar polygon shaded by its Newell normal. The view direction in @@ -296,7 +325,11 @@ impl Ctx<'_> { 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) }; + 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); @@ -307,12 +340,31 @@ impl Ctx<'_> { 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::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::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; @@ -324,7 +376,13 @@ impl Ctx<'_> { } self.out.push_str("\"/>"); } - Prim::Circle { at, r, off, cls, id } => { + Prim::Circle { + at, + r, + off, + cls, + id, + } => { let s = self.p.s(at[0], at[1], at[2]); let _ = write!(self.out, " { r1s(*r) ); } - Prim::Stem { at, z0, z1, w, cls, id } => { + 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()); @@ -355,7 +420,12 @@ impl Ctx<'_> { } 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 _ = write!( + self.out, + "", + r1s(s[0]), + r1s(s[1]) + ); let r = if kind == "iso-smoke" { 3.2 } else if kind == "iso-puff2" { @@ -396,7 +466,12 @@ impl Ctx<'_> { 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])); + let _ = write!( + self.out, + "{svg}", + r1s(s[0]), + r1s(s[1]) + ); } None => self.out.push_str(&svg), } @@ -457,7 +532,11 @@ pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) { 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 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; @@ -494,7 +573,12 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered { } else { (0..live.len()).collect() }; - let _ = write!(cx.out, "", esc(&layer.opts.cls), esc(&layer.name)); + let _ = write!( + cx.out, + "", + esc(&layer.opts.cls), + esc(&layer.name) + ); for i in idx { let s = live[i]; sprites += 1; @@ -583,7 +667,13 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered { 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 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], @@ -627,7 +717,12 @@ mod tests { 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_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")); @@ -641,14 +736,26 @@ mod tests { // 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); + 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() }); + let r = render( + &sc, + 1, + &RenderOpts { + labels: false, + ..Default::default() + }, + ); assert!(r.html.contains(">Gate<")); assert!(!r.html.contains(">Hall<")); } @@ -683,9 +790,29 @@ mod tests { let mut sc = Scene::new(); let mut s = Sprite::new("roof", ""); // south-facing roof slope rising to the north (ccw seen from above/outside) - s.face(vec![[0.0, 0.0, 3.0], [10.0, 0.0, 3.0], [10.0, 4.0, 5.0], [0.0, 4.0, 5.0]], "iso-roof", None, false); + s.face( + vec![ + [0.0, 0.0, 3.0], + [10.0, 0.0, 3.0], + [10.0, 4.0, 5.0], + [0.0, 4.0, 5.0], + ], + "iso-roof", + None, + false, + ); // a vertical wall facing north (ccw seen from the north) - s.face(vec![[10.0, 6.0, 0.0], [0.0, 6.0, 0.0], [0.0, 6.0, 3.0], [10.0, 6.0, 3.0]], "iso-wall", None, false); + s.face( + vec![ + [10.0, 6.0, 0.0], + [0.0, 6.0, 0.0], + [0.0, 6.0, 3.0], + [10.0, 6.0, 3.0], + ], + "iso-wall", + None, + false, + ); sc.add_sprite("main", s); let r0 = render(&sc, 0, &RenderOpts::default()); // from the south-east: north wall hidden assert!(r0.html.contains("iso-roof iso-t")); @@ -698,7 +825,14 @@ mod tests { fn dev_mode_adds_source_and_order() { let mut sc = one_box(); sc.layers[2].sprites[0].src = Some("scene.rhai:3".into()); - let r = render(&sc, 0, &RenderOpts { dev: true, ..Default::default() }); + let r = render( + &sc, + 0, + &RenderOpts { + dev: true, + ..Default::default() + }, + ); assert!(r.html.contains("data-src=\"scene.rhai:3\"")); assert_eq!(r.order.len(), 1); } diff --git a/crates/isokit-core/src/scene.rs b/crates/isokit-core/src/scene.rs index 8335cdb..ea5d081 100644 --- a/crates/isokit-core/src/scene.rs +++ b/crates/isokit-core/src/scene.rs @@ -17,7 +17,11 @@ pub struct LayerOpts { impl Default for LayerOpts { fn default() -> Self { - LayerOpts { sort: true, zfirst: false, cls: "iso-sprites".into() } + LayerOpts { + sort: true, + zfirst: false, + cls: "iso-sprites".into(), + } } } @@ -78,9 +82,25 @@ impl Scene { pub fn new() -> Self { Scene { layers: vec![ - Layer { name: "ground".into(), opts: LayerOpts { sort: false, zfirst: false, cls: "iso-ground".into() }, sprites: vec![] }, - Layer { name: "floor".into(), opts: LayerOpts::default(), sprites: vec![] }, - Layer { name: "main".into(), opts: LayerOpts::default(), sprites: vec![] }, + Layer { + name: "ground".into(), + opts: LayerOpts { + sort: false, + zfirst: false, + cls: "iso-ground".into(), + }, + sprites: vec![], + }, + Layer { + name: "floor".into(), + opts: LayerOpts::default(), + sprites: vec![], + }, + Layer { + name: "main".into(), + opts: LayerOpts::default(), + sprites: vec![], + }, ], tags: vec![], info: BTreeMap::new(), @@ -101,8 +121,17 @@ impl Scene { self.layers[i].opts = opts; return i; } - let at = before.and_then(|b| self.layer_index(b)).unwrap_or(self.layers.len()); - self.layers.insert(at, Layer { name: name.into(), opts, sprites: vec![] }); + let at = before + .and_then(|b| self.layer_index(b)) + .unwrap_or(self.layers.len()); + self.layers.insert( + at, + Layer { + name: name.into(), + opts, + sprites: vec![], + }, + ); at } @@ -119,13 +148,39 @@ impl Scene { self.layer_mut(layer).sprites.push(s); } - pub fn tag(&mut self, at: P3, text: impl Into, prio: f64, id: impl Into, kind: impl Into) { - self.tags.push(Tag { at, text: text.into(), prio, id: id.into(), kind: kind.into() }); + pub fn tag( + &mut self, + at: P3, + text: impl Into, + prio: f64, + id: impl Into, + kind: impl Into, + ) { + self.tags.push(Tag { + at, + text: text.into(), + prio, + id: id.into(), + kind: kind.into(), + }); } - pub fn info(&mut self, id: impl Into, name: impl Into, detail: impl Into) { + pub fn info( + &mut self, + id: impl Into, + name: impl Into, + detail: impl Into, + ) { let id = id.into(); - self.info.insert(id.clone(), Info { id, name: name.into(), detail: detail.into(), rows: vec![] }); + self.info.insert( + id.clone(), + Info { + id, + name: name.into(), + detail: detail.into(), + rows: vec![], + }, + ); } pub fn sprite_count(&self) -> usize { @@ -137,7 +192,9 @@ impl Scene { use crate::prim::Prim; fn walk(p: &Prim, out: &mut std::collections::BTreeSet) { let id = match p { - Prim::Prism { st, .. } | Prim::Cyl { st, .. } | Prim::Frust { st, .. } => st.id.clone(), + Prim::Prism { st, .. } | Prim::Cyl { st, .. } | Prim::Frust { st, .. } => { + st.id.clone() + } Prim::Open { id, .. } | Prim::Flat { id, .. } | Prim::Lines { id, .. } @@ -151,7 +208,10 @@ impl Scene { None } Prim::Seq { items } => { - items.iter().flat_map(|(_, v)| v.iter()).for_each(|q| walk(q, out)); + items + .iter() + .flat_map(|(_, v)| v.iter()) + .for_each(|q| walk(q, out)); None } _ => None, @@ -174,7 +234,10 @@ impl Scene { /// The default focus point: the explicit one, else the bounds centre. pub fn focus_point(&self) -> Option { - self.focus.or_else(|| self.bounds.map(|(b, _)| [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0, 0.0])) + self.focus.or_else(|| { + self.bounds + .map(|(b, _)| [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0, 0.0]) + }) } } diff --git a/crates/isokit-core/src/sort.rs b/crates/isokit-core/src/sort.rs index c6d0385..5008e0a 100644 --- a/crates/isokit-core/src/sort.rs +++ b/crates/isokit-core/src/sort.rs @@ -86,7 +86,10 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order { } } } - let mut out = Order { order: Vec::with_capacity(n), cycles: 0 }; + let mut out = Order { + order: Vec::with_capacity(n), + cycles: 0, + }; for _ in 0..n { let mut best: Option = None; for i in 0..n { @@ -102,7 +105,9 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order { } best = match best { None => Some(i), - Some(b) if indeg[i] < indeg[b] || (indeg[i] == indeg[b] && key[i] < key[b]) => Some(i), + Some(b) if indeg[i] < indeg[b] || (indeg[i] == indeg[b] && key[i] < key[b]) => { + Some(i) + } keep => keep, }; } @@ -128,14 +133,26 @@ mod tests { #[test] fn separated_on_x_draws_far_first() { - let o = sort(&[bx(5.0, 0.0, 6.0, 1.0, 0.0, 1.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false); + let o = sort( + &[ + bx(5.0, 0.0, 6.0, 1.0, 0.0, 1.0), + bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0), + ], + false, + ); assert_eq!(o.order, vec![1, 0]); assert_eq!(o.cycles, 0); } #[test] fn stacked_boxes_draw_bottom_first() { - let o = sort(&[bx(0.0, 0.0, 1.0, 1.0, 1.0, 2.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false); + let o = sort( + &[ + bx(0.0, 0.0, 1.0, 1.0, 1.0, 2.0), + bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0), + ], + false, + ); assert_eq!(o.order, vec![1, 0]); } diff --git a/crates/isokit-core/src/sprite.rs b/crates/isokit-core/src/sprite.rs index 2c2bca2..e955c0a 100644 --- a/crates/isokit-core/src/sprite.rs +++ b/crates/isokit-core/src/sprite.rs @@ -63,7 +63,12 @@ impl Xf { pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> [f64; 4] { let a = self.p(x0, y0); let b = self.p(x1, y1); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + [ + a[0].min(b[0]), + a[1].min(b[1]), + a[0].max(b[0]), + a[1].max(b[1]), + ] } pub fn pts(&self, pts: &[P2]) -> Vec { pts.iter().map(|q| self.p(q[0], q[1])).collect() @@ -76,8 +81,15 @@ impl Xf { #[derive(Clone, Debug)] enum Frame { - Group { cls: String, style: String, items: Vec }, - Seq { items: Vec<(P2, Vec)>, cur: Option<(P2, Vec)> }, + Group { + cls: String, + style: String, + items: Vec, + }, + Seq { + items: Vec<(P2, Vec)>, + cur: Option<(P2, Vec)>, + }, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -101,6 +113,9 @@ pub struct Sprite { frames: Vec, } +/// One item of [`Sprite::seq`]: a plan anchor and the drawing. +pub type SeqItem<'a> = (P2, Box); + /// Points of an axis-aligned rectangle, counter-clockwise from (x0, y0). pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec { vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]] @@ -108,7 +123,11 @@ pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec { impl Sprite { pub fn new(id: impl Into, cls: impl Into) -> Self { - Sprite { id: id.into(), cls: cls.into(), ..Default::default() } + Sprite { + id: id.into(), + cls: cls.into(), + ..Default::default() + } } pub fn with_frame(mut self, xf: Xf) -> Self { @@ -141,7 +160,10 @@ impl Sprite { pub fn ext_box(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> &mut Self { for z in [z0, z1] { - self.ext(x0, y0, z).ext(x1, y0, z).ext(x1, y1, z).ext(x0, y1, z); + self.ext(x0, y0, z) + .ext(x1, y0, z) + .ext(x1, y1, z) + .ext(x0, y1, z); } self } @@ -156,13 +178,27 @@ impl Sprite { /// Axis-aligned box (`box` in scripts; `box` is a Rust keyword). #[allow(clippy::too_many_arguments)] - pub fn cuboid(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64, st: Style) -> &mut Self { + pub fn cuboid( + &mut self, + x0: f64, + y0: f64, + x1: f64, + y1: f64, + z0: f64, + z1: f64, + st: Style, + ) -> &mut Self { let r = self.xf.rect(x0, y0, x1, y1); let (z0, z1) = (self.xf.z(z0), self.xf.z(z1)); let pts = rect(r[0], r[1], r[2], r[3]); self.ext_world(&pts, z0); self.ext_world(&pts, z1); - self.push(Prim::Prism { pts, z0: Zs::One(z0), z1: Zs::One(z1), st }) + self.push(Prim::Prism { + pts, + z0: Zs::One(z0), + z1: Zs::One(z1), + st, + }) } pub fn prism(&mut self, pts: Vec, z0: Zs, z1: Zs, st: Style) -> &mut Self { @@ -188,30 +224,73 @@ impl Sprite { } #[allow(clippy::too_many_arguments)] - pub fn frust(&mut self, cx: f64, cy: f64, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style) -> &mut Self { + pub fn frust( + &mut self, + cx: f64, + cy: f64, + r0: f64, + r1: f64, + z0: f64, + z1: f64, + n: u32, + st: Style, + ) -> &mut Self { let r = r0.max(r1); self.ext_box(cx - r, cy - r, cx + r, cy + r, z0, z1); let c = self.xf.p(cx, cy); let (z0, z1) = (self.xf.z(z0), self.xf.z(z1)); - self.push(Prim::Frust { c, r0, r1, z0, z1, n: n.max(3), st }) + self.push(Prim::Frust { + c, + r0, + r1, + z0, + z1, + n: n.max(3), + st, + }) } /// Sloped slab (stair flight, ramp) over x0..x1, rising from y0 (za) to /// y1 (zb), th thick. #[allow(clippy::too_many_arguments)] - pub fn slope(&mut self, x0: f64, x1: f64, y0: f64, y1: f64, za: f64, zb: f64, th: f64, mat: &str) -> &mut Self { + pub fn slope( + &mut self, + x0: f64, + x1: f64, + y0: f64, + y1: f64, + za: f64, + zb: f64, + th: f64, + mat: &str, + ) -> &mut Self { let zt = vec![za, za, zb, zb]; let zb_: Vec = zt.iter().map(|z| z - th).collect(); - self.prism(rect(x0, y0, x1, y1), Zs::Each(zb_), Zs::Each(zt), Style::mat(mat)) + self.prism( + rect(x0, y0, x1, y1), + Zs::Each(zb_), + Zs::Each(zt), + Style::mat(mat), + ) } // ---------------------------------------------------------- surfaces /// Openings on the vertical face a -> b (outward normal on the right). - pub fn open(&mut self, a: P2, b: P2, list: Vec<[f64; 4]>, cls: &str, id: Option) -> &mut Self { + pub fn open( + &mut self, + a: P2, + b: P2, + list: Vec<[f64; 4]>, + cls: &str, + id: Option, + ) -> &mut Self { let (a, b) = (self.xf.p(a[0], a[1]), self.xf.p(b[0], b[1])); let dz = self.xf.z; - let list: Vec<[f64; 4]> = list.into_iter().map(|q| [q[0], q[1], q[2] + dz, q[3] + dz]).collect(); + let list: Vec<[f64; 4]> = list + .into_iter() + .map(|q| [q[0], q[1], q[2] + dz, q[3] + dz]) + .collect(); 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); @@ -219,16 +298,33 @@ impl Sprite { self.ext.push([a[0] + ux * q[0], a[1] + uy * q[0], q[2]]); self.ext.push([a[0] + ux * q[1], a[1] + uy * q[1], q[3]]); } - self.push(Prim::Open { a, b, list, cls: cls.into(), id }) + self.push(Prim::Open { + a, + b, + list, + cls: cls.into(), + id, + }) } - pub fn flat(&mut self, polys: Vec>, z: f64, cls: &str, id: Option) -> &mut Self { + pub fn flat( + &mut self, + polys: Vec>, + z: f64, + cls: &str, + id: Option, + ) -> &mut Self { let polys: Vec> = polys.iter().map(|p| self.xf.pts(p)).collect(); let z = self.xf.z(z); for p in &polys { self.ext_world(p, z); } - self.push(Prim::Flat { polys, z, cls: cls.into(), id }) + self.push(Prim::Flat { + polys, + z, + cls: cls.into(), + id, + }) } pub fn lines(&mut self, segs: Vec<[f64; 4]>, z: f64, cls: &str) -> &mut Self { @@ -245,30 +341,67 @@ impl Sprite { self.ext.push([s[0], s[1], z]); self.ext.push([s[2], s[3], z]); } - self.push(Prim::Lines { segs, z, cls: cls.into(), id: None }) + self.push(Prim::Lines { + segs, + z, + cls: cls.into(), + id: None, + }) } - pub fn disc(&mut self, cx: f64, cy: f64, z: f64, r: f64, cls: &str, id: Option) -> &mut Self { + pub fn disc( + &mut self, + cx: f64, + cy: f64, + z: f64, + r: f64, + cls: &str, + id: Option, + ) -> &mut Self { self.ext_box(cx - r, cy - r, cx + r, cy + r, z, z); let c = self.xf.p(cx, cy); let z = self.xf.z(z); - self.push(Prim::Disc { c, z, r, cls: cls.into(), id }) + self.push(Prim::Disc { + c, + z, + r, + cls: cls.into(), + id, + }) } /// Planar 3D polygon shaded by its normal (see [`Prim::Face`]). - pub fn face(&mut self, pts: Vec, mat: &str, id: Option, two_sided: bool) -> &mut Self { + pub fn face( + &mut self, + pts: Vec, + mat: &str, + id: Option, + two_sided: bool, + ) -> &mut Self { let pts: Vec = pts.into_iter().map(|p| self.xf.p3(p)).collect(); self.ext.extend(pts.iter().copied()); - self.push(Prim::Face { pts, mat: mat.into(), id, two_sided }) + self.push(Prim::Face { + pts, + mat: mat.into(), + id, + two_sided, + }) } /// 3D polygons as one path with a fixed class. pub fn poly(&mut self, polys: Vec>, cls: &str, id: Option) -> &mut Self { - let polys: Vec> = polys.into_iter().map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect()).collect(); + let polys: Vec> = polys + .into_iter() + .map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect()) + .collect(); for p in &polys { self.ext.extend(p.iter().copied()); } - self.push(Prim::Poly { polys, cls: cls.into(), id }) + self.push(Prim::Poly { + polys, + cls: cls.into(), + id, + }) } // ---------------------------------------------------------- screen-space details @@ -277,14 +410,27 @@ impl Sprite { /// sticks out of the sprite). pub fn circle(&mut self, at: P3, r: f64, off: P2, cls: &str) -> &mut Self { let at = self.xf.p3(at); - self.push(Prim::Circle { at, r, off, cls: cls.into(), id: None }) + self.push(Prim::Circle { + at, + r, + off, + cls: cls.into(), + id: None, + }) } pub fn stem(&mut self, x: f64, y: f64, z0: f64, z1: f64, w: f64, cls: &str) -> &mut Self { self.ext(x, y, z0).ext(x, y, z1); let at = self.xf.p(x, y); let (z0, z1) = (self.xf.z(z0), self.xf.z(z1)); - self.push(Prim::Stem { at, z0, z1, w, cls: cls.into(), id: None }) + self.push(Prim::Stem { + at, + z0, + z1, + w, + cls: cls.into(), + id: None, + }) } /// A head (ball of radius r metres) centred at z. @@ -307,13 +453,22 @@ impl Sprite { } pub fn led(&mut self, x: f64, y: f64, z: f64, cls: &str) -> &mut Self { - let c = if cls.is_empty() { "iso-led".to_string() } else { format!("iso-led {cls}") }; + let c = if cls.is_empty() { + "iso-led".to_string() + } else { + format!("iso-led {cls}") + }; self.circle([x, y, z], 0.9, [0.0, 0.0], &c) } pub fn puffs(&mut self, x: f64, y: f64, z: f64, kind: &str, n: u32, dur: f64) -> &mut Self { let at = self.xf.p3([x, y, z]); - self.push(Prim::Puffs { at, kind: kind.into(), n: n.max(1), dur }) + self.push(Prim::Puffs { + at, + kind: kind.into(), + n: n.max(1), + dur, + }) } pub fn fan(&mut self, x: f64, y: f64, z: f64, r: f64) -> &mut Self { @@ -323,14 +478,21 @@ impl Sprite { pub fn raw(&mut self, at: Option, svg: impl Into) -> &mut Self { let at = at.map(|a| self.xf.p3(a)); - self.push(Prim::Raw { at, svg: svg.into() }) + self.push(Prim::Raw { + at, + svg: svg.into(), + }) } // ---------------------------------------------------------- structure /// Open a ``; close it with [`Sprite::end_group`]. pub fn begin_group(&mut self, cls: &str, style: &str) -> &mut Self { - self.frames.push(Frame::Group { cls: cls.into(), style: style.into(), items: vec![] }); + self.frames.push(Frame::Group { + cls: cls.into(), + style: style.into(), + items: vec![], + }); self } @@ -344,7 +506,10 @@ impl Sprite { /// Start a back-to-front sequence; each [`Sprite::seq_item`] starts an /// item anchored at a plan point; [`Sprite::end_seq`] closes it. pub fn begin_seq(&mut self) -> &mut Self { - self.frames.push(Frame::Seq { items: vec![], cur: None }); + self.frames.push(Frame::Seq { + items: vec![], + cur: None, + }); self } @@ -370,7 +535,7 @@ impl Sprite { } /// Draw items back to front by the depth of their anchor. - pub fn seq(&mut self, items: Vec<(P2, Box)>) -> &mut Self { + pub fn seq(&mut self, items: Vec>) -> &mut Self { self.begin_seq(); for (at, f) in items { self.seq_item(at); @@ -442,7 +607,10 @@ mod tests { let f = Xf::new(3.0, -4.0, 0.0, deg); let w = f.p(1.5, 2.5); let l = f.inv(w[0], w[1]); - assert!((l[0] - 1.5).abs() < 1e-12 && (l[1] - 2.5).abs() < 1e-12, "{deg}"); + assert!( + (l[0] - 1.5).abs() < 1e-12 && (l[1] - 2.5).abs() < 1e-12, + "{deg}" + ); } } diff --git a/crates/isokit-project/Cargo.toml b/crates/isokit-project/Cargo.toml new file mode 100644 index 0000000..093bd01 --- /dev/null +++ b/crates/isokit-project/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "isokit-project" +description = "isokit projects: manifest, scene document ops, rendering, export, checks, MCP tools" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +isokit-core = { workspace = true } +isokit-script = { workspace = true } +isokit-theme = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } diff --git a/crates/isokit-project/src/assets.rs b/crates/isokit-project/src/assets.rs new file mode 100644 index 0000000..c0d58a6 --- /dev/null +++ b/crates/isokit-project/src/assets.rs @@ -0,0 +1,113 @@ +//! Files embedded in the binary: the viewer, the Studio and the starter +//! project. Set `ISOKIT_ASSETS` to the repository folder to serve the +//! viewer and Studio files from disk instead (edit them without rebuilding). + +use std::borrow::Cow; + +pub const VIEW_JS: &str = include_str!("../../../viewer/isokit-view.js"); +pub const VIEW_CSS: &str = include_str!("../../../viewer/isokit-view.css"); +pub const PAGE: &str = include_str!("../../../viewer/page.html"); +pub const STUDIO_HTML: &str = include_str!("../../../studio/index.html"); +pub const STUDIO_JS: &str = include_str!("../../../studio/studio.js"); +pub const STUDIO_CSS: &str = include_str!("../../../studio/studio.css"); + +/// Asset by its path in the repository ("viewer/isokit-view.js", +/// "studio/studio.js"), from disk when `ISOKIT_ASSETS` is set. +pub fn get(path: &str) -> Option> { + if let Ok(root) = std::env::var("ISOKIT_ASSETS") { + if let Ok(s) = std::fs::read_to_string(std::path::Path::new(&root).join(path)) { + return Some(Cow::Owned(s)); + } + } + let s = match path { + "viewer/isokit-view.js" => VIEW_JS, + "viewer/isokit-view.css" => VIEW_CSS, + "viewer/page.html" => PAGE, + "studio/index.html" => STUDIO_HTML, + "studio/studio.js" => STUDIO_JS, + "studio/studio.css" => STUDIO_CSS, + _ => return None, + }; + Some(Cow::Borrowed(s)) +} + +pub const NEW_MANIFEST: &str = r#"# isokit project. Open it with `isokit studio` (GUI) or build it with `isokit build`. +title = "{title}" +note = "Schematic isometric view. Drag to pan, Ctrl + wheel or pinch to zoom, tap anything for details." + +# levels: "site" shows buildings closed; "gf", "1f", ... cut buildings open at that storey +default_level = "site" +default_rotation = 1 + +# project prefab libraries (functions prefab_ / meta_), always loaded +prelude = [] +# colour overrides on top of the built-in theme +theme = ["theme.toml"] + +[[level]] +id = "site" +label = "Site" +title = "Closed buildings and roofs" + +[[level]] +id = "gf" +label = "GF" +title = "Ground floors, cut open" + +[[level]] +id = "1f" +label = "1F" +title = "First floors, cut open" + +[[param]] +name = "labels" +type = "bool" +default = true +label = "Labels" + +[[param]] +name = "running" +type = "bool" +default = true +label = "Animation" + +[build] +out = "dist/index.html" +budget = 4000 +"#; + +pub const NEW_DOC: &str = r#"{ + "version": 1, + "objects": [ + { "id": "site", "type": "site", "name": "Site", "x": 0, "y": 0, "props": { "w": 60, "d": 40 }, "locked": true }, + { "id": "road", "type": "road", "name": "Access road", "x": 0, "y": -17, "props": { "w": 60, "d": 6 } }, + { "id": "building", "type": "building", "name": "Main building", "x": -8, "y": 3, "props": { "w": 22, "d": 12, "floors": 2, "pv": true }, "tag": "Main building", "info": "Two storeys, solar on the roof" }, + { "id": "tree", "type": "tree", "x": 16, "y": 8 }, + { "id": "car", "type": "car", "x": 12, "y": -10, "props": { "colour": "car1" } } + ] +} +"#; + +pub const NEW_THEME: &str = r##"# Colour overrides on top of the built-in theme. Examples: +# +# [palette.dark] +# render = "#8f8b84" +# [palette.light] +# render = "#f4efe6" +# [materials] +# brick = "#9a5a44" # use as mat "brick" in prefabs and scripts +"##; + +pub const NEW_LIB_README: &str = r#"Project prefabs live here. A prefab is a pair of Rhai functions: + + fn meta_kiosk() { + #{ label: "Kiosk", category: "Shops", icon: "K", + props: [ #{ key: "h", type: "float", def: 2.6, min: 2.0, max: 5.0, label: "Height (m)" } ] } + } + fn prefab_kiosk(o) { // o.p holds the props; draw around (0, 0) + sprite().box(-1.5, -1, 1.5, 1, 0, o.p.h, "render2", #{ top: "roof3" }); + } + +List the file in isokit.toml (prelude = ["lib/kiosk.rhai"]) and the Studio +offers "Kiosk" in its palette. +"#; diff --git a/crates/isokit-project/src/check.rs b/crates/isokit-project/src/check.rs new file mode 100644 index 0000000..1f9940b --- /dev/null +++ b/crates/isokit-project/src/check.rs @@ -0,0 +1,144 @@ +//! `isokit check`: problems a person would spot by eye, found by code. + +use crate::ops::check_props; +use crate::{assets, Project}; +use serde::Serialize; +use std::collections::BTreeSet; + +#[derive(Clone, Debug, Serialize, PartialEq)] +pub struct Finding { + /// error | warning | info + pub level: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, +} + +fn f(level: &str, message: String, object: Option) -> Finding { + Finding { + level: level.into(), + message, + object, + } +} + +/// Classes the viewer styles itself (faces, structure, animation). +const STRUCTURAL: &[&str] = &[ + "iso-t", + "iso-l", + "iso-c", + "iso-r", + "iso-ground", + "iso-sprites", + "iso-tags", + "iso-tag", + "iso-a", + "iso-vent", + "iso-dim", + "iso-headc", + "iso-led", + "iso-lit", + "iso-on", + "iso-empty", +]; + +pub fn check(p: &Project) -> Vec { + let mut out = vec![]; + let rt = match &p.runtime { + Ok(r) => r, + Err(e) => return vec![f("error", e.to_string(), None)], + }; + // document: types and props + let mut ids = BTreeSet::new(); + for o in &p.doc.objects { + if !ids.insert(o.id.clone()) { + out.push(f( + "error", + format!("duplicate object id \"{}\"", o.id), + Some(o.id.clone()), + )); + } + match rt.prefab(&o.kind) { + None => out.push(f( + "error", + format!("unknown type \"{}\"", o.kind), + Some(o.id.clone()), + )), + Some(m) => { + if let Err(e) = check_props(m, &o.props) { + out.push(f("warning", e, Some(o.id.clone()))); + } + } + } + } + // every view: warnings, NaN, budget, sort cycles, undefined classes + let css = format!( + "{}\n{}", + assets::get("viewer/isokit-view.css").unwrap_or_default(), + p.theme.css() + ); + let mut seen_warn = BTreeSet::new(); + let mut missing = BTreeSet::new(); + for l in p.manifest.levels() { + for rot in 0..4u8 { + let v = match p.render(&l.id, rot, None, false) { + Ok(v) => v, + Err(e) => { + out.push(f("error", format!("{}/{rot}: {e}", l.id), None)); + continue; + } + }; + for w in &v.warnings { + if seen_warn.insert(w.clone()) { + let obj = w + .strip_prefix("object ") + .and_then(|r| r.split(':').next()) + .map(str::to_string); + out.push(f("warning", w.clone(), obj)); + } + } + if v.html.contains("NaN") { + out.push(f( + "error", + format!( + "{}/{rot}: NaN in the drawing (a coordinate is not a number)", + l.id + ), + None, + )); + } + if v.nodes > p.manifest.build.budget { + out.push(f( + "warning", + format!( + "{}/{rot}: {} SVG nodes, budget {}", + l.id, v.nodes, p.manifest.build.budget + ), + None, + )); + } + if v.cycles > 0 { + out.push(f("info", format!("{}/{rot}: {} depth-sort cycle(s) broken; split large sprites if something draws in front of what it stands behind", l.id, v.cycles), None)); + } + for cls in v + .html + .split("class=\"") + .skip(1) + .filter_map(|s| s.split('"').next()) + { + for c in cls.split_whitespace() { + if c.starts_with("iso-") + && !STRUCTURAL.contains(&c) + && !css.contains(&format!(".{c}")) + { + missing.insert(c.to_string()); + } + } + } + } + } + for c in missing { + out.push(f("warning", format!("class {c} has no style in the theme (drawn grey): add it under [materials] in theme.toml"), None)); + } + out +} diff --git a/crates/isokit-project/src/lib.rs b/crates/isokit-project/src/lib.rs new file mode 100644 index 0000000..b44c155 --- /dev/null +++ b/crates/isokit-project/src/lib.rs @@ -0,0 +1,470 @@ +//! isokit projects. +//! +//! A project is a folder: +//! +//! ```text +//! isokit.toml manifest: title, levels, parameters, theme files, prelude modules +//! scene.json the scene document (objects), edited by the Studio and agents +//! scene.rhai optional script drawn before the objects +//! lib/*.rhai optional project prefabs (list them in `prelude`) +//! theme.toml optional colours and materials +//! ``` +//! +//! [`Project`] loads and renders it; [`ops`] edits the document; [`page`] +//! exports HTML/SVG; [`check`] lints it; [`workspace::Workspace`] keeps a +//! project loaded across edits (undo, reload on file changes) for the Studio +//! and the MCP server ([`mcp`]). + +pub mod assets; +pub mod check; +pub mod mcp; +pub mod ops; +pub mod page; +pub mod workspace; + +use isokit_core::{render, Info, RenderOpts}; +use isokit_script::doc::{Doc, PrefabMeta}; +use isokit_script::{Bundle, Runtime, ScriptError}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +pub use isokit_script; + +pub const MANIFEST: &str = "isokit.toml"; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct LevelDef { + pub id: String, + #[serde(default)] + pub label: String, + #[serde(default)] + pub title: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ParamDef { + pub name: String, + /// bool | int | float | choice | text + #[serde(rename = "type", default = "bool_kind")] + pub kind: String, + #[serde(default)] + pub default: Value, + #[serde(default)] + pub label: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub choices: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step: Option, +} + +fn bool_kind() -> String { + "bool".into() +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct BuildOpts { + #[serde(default)] + pub out: Option, + /// Node budget per view for `isokit check`. + #[serde(default = "budget")] + pub budget: usize, +} + +fn budget() -> usize { + 4000 +} + +impl Default for BuildOpts { + fn default() -> Self { + BuildOpts { + out: None, + budget: budget(), + } + } +} + +fn rot1() -> u8 { + 1 +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Manifest { + #[serde(default)] + pub title: String, + #[serde(default)] + pub note: String, + /// Main script; default `scene.rhai` when that file exists. + #[serde(default)] + pub script: Option, + /// Scene document; default `scene.json`. + #[serde(default)] + pub doc: Option, + #[serde(default)] + pub prelude: Vec, + #[serde(default)] + pub theme: Vec, + #[serde(default, rename = "level")] + pub levels: Vec, + #[serde(default)] + pub default_level: Option, + #[serde(default = "rot1")] + pub default_rotation: u8, + #[serde(default, rename = "param")] + pub params: Vec, + #[serde(default)] + pub build: BuildOpts, +} + +impl Manifest { + pub fn levels(&self) -> Vec { + if self.levels.is_empty() { + vec![LevelDef { + id: "site".into(), + label: "Site".into(), + title: "The whole site".into(), + }] + } else { + self.levels.clone() + } + } + pub fn default_level(&self) -> String { + let lv = self.levels(); + self.default_level + .clone() + .filter(|d| lv.iter().any(|l| &l.id == d)) + .unwrap_or_else(|| lv[0].id.clone()) + } + pub fn doc_path(&self) -> String { + self.doc.clone().unwrap_or_else(|| "scene.json".into()) + } +} + +#[derive(Debug)] +pub enum Error { + Io(String), + Manifest(String), + Theme(String), + Doc(String), + Script(ScriptError), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Io(e) => write!(f, "{e}"), + Error::Manifest(e) => write!(f, "isokit.toml: {e}"), + Error::Theme(e) => write!(f, "theme: {e}"), + Error::Doc(e) => write!(f, "scene document: {e}"), + Error::Script(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(e: ScriptError) -> Self { + Error::Script(e) + } +} + +/// One rendered view as the viewer consumes it. +#[derive(Clone, Debug, Serialize)] +pub struct View { + pub html: String, + pub info: BTreeMap, + pub bbox: [f64; 4], + pub focus: [f64; 2], + pub rot: u8, + pub north: f64, + pub level: String, + pub sprites: usize, + pub nodes: usize, + pub cycles: usize, + pub warnings: Vec, + /// Object id -> world box [x0, y0, z0, x1, y1, z1]. + pub boxes: BTreeMap, + pub ms: f64, +} + +/// Project facts the viewer, the Studio and agents need. +#[derive(Clone, Debug, Serialize)] +pub struct Meta { + pub title: String, + pub note: String, + pub levels: Vec, + pub default_level: String, + pub default_rotation: u8, + pub params: Vec, + pub catalog: Vec, + pub materials: Vec, + pub c: f64, + pub cz: f64, +} + +fn rd(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display()))) +} + +/// Every `*.rhai` below `dir` (skipping dist, target and dot folders), +/// keyed by forward-slash relative path. +fn collect_sources(dir: &Path) -> Result, Error> { + let mut out = BTreeMap::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let rd_ = std::fs::read_dir(&d).map_err(|e| Error::Io(format!("{}: {e}", d.display())))?; + for ent in rd_.flatten() { + let p = ent.path(); + let name = ent.file_name().to_string_lossy().to_string(); + if p.is_dir() { + if !name.starts_with('.') + && name != "dist" + && name != "target" + && name != "node_modules" + { + stack.push(p); + } + } else if name.ends_with(".rhai") { + let rel = p + .strip_prefix(dir) + .unwrap_or(&p) + .to_string_lossy() + .replace('\\', "/"); + out.insert(rel, rd(&p)?); + } + } + } + Ok(out) +} + +pub struct Project { + pub dir: PathBuf, + pub manifest: Manifest, + pub theme: isokit_theme::Theme, + pub doc: Doc, + pub bundle: Bundle, + /// The compiled scripts, or the error that stopped them. + pub runtime: Result, +} + +impl Project { + /// Load a project folder. A script error does not fail the load: the + /// document stays editable and [`Project::runtime`] holds the error. + pub fn load(dir: impl AsRef) -> Result { + let dir = dir.as_ref().to_path_buf(); + let mpath = dir.join(MANIFEST); + let manifest: Manifest = if mpath.exists() { + toml::from_str(&rd(&mpath)?).map_err(|e| Error::Manifest(e.to_string()))? + } else { + return Err(Error::Io(format!( + "{} not found (create a project with `isokit new`)", + mpath.display() + ))); + }; + let mut layers = vec![]; + for t in &manifest.theme { + layers.push((t.clone(), rd(&dir.join(t))?)); + } + let refs: Vec<(&str, &str)> = layers + .iter() + .map(|(a, b)| (a.as_str(), b.as_str())) + .collect(); + let theme = isokit_theme::load(&refs).map_err(Error::Theme)?; + let dpath = dir.join(manifest.doc_path()); + let doc: Doc = if dpath.exists() { + serde_json::from_str(&rd(&dpath)?) + .map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))? + } else { + Doc { + version: 1, + ..Default::default() + } + }; + let sources = collect_sources(&dir)?; + let main = match &manifest.script { + Some(s) => Some(s.clone()), + None if sources.contains_key("scene.rhai") => Some("scene.rhai".into()), + None => None, + }; + let bundle = Bundle { + main, + prelude: manifest.prelude.clone(), + sources, + }; + let runtime = Runtime::new(&bundle); + Ok(Project { + dir, + manifest, + theme, + doc, + bundle, + runtime, + }) + } + + pub fn catalog(&self) -> &[PrefabMeta] { + match &self.runtime { + Ok(r) => r.catalog(), + Err(_) => &[], + } + } + + /// Parameter values: manifest defaults, then the document, then `over`. + pub fn cfg(&self, over: Option<&Map>) -> Map { + let mut c = Map::new(); + for p in &self.manifest.params { + c.insert(p.name.clone(), p.default.clone()); + } + for (k, v) in &self.doc.cfg { + c.insert(k.clone(), v.clone()); + } + if let Some(o) = over { + for (k, v) in o { + c.insert(k.clone(), v.clone()); + } + } + c + } + + pub fn materials(&self) -> Vec { + self.theme + .materials + .iter() + .map(|(k, _)| k.clone()) + .collect() + } + + pub fn meta(&self) -> Meta { + Meta { + title: if self.manifest.title.is_empty() { + self.dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() + } else { + self.manifest.title.clone() + }, + note: self.manifest.note.clone(), + levels: self.manifest.levels(), + default_level: self.manifest.default_level(), + default_rotation: self.manifest.default_rotation % 4, + params: self.manifest.params.clone(), + catalog: self.catalog().to_vec(), + materials: self.materials(), + c: isokit_core::C, + cz: isokit_core::CZ, + } + } + + /// Render one view of the current document. + pub fn render( + &self, + level: &str, + rot: u8, + over: Option<&Map>, + dev: bool, + ) -> Result { + let t0 = Instant::now(); + let rt = self.runtime.as_ref().map_err(|e| e.clone())?; + let cfg = self.cfg(over); + let built = rt.build(level, rot % 4, &cfg, &self.doc)?; + let labels = cfg.get("labels").and_then(|v| v.as_bool()).unwrap_or(true); + let r = render( + &built.scene, + rot % 4, + &RenderOpts { + uid: 0, + labels, + dev, + }, + ); + Ok(View { + html: r.html, + info: r.info, + bbox: r.bbox, + focus: r.focus, + rot: r.rot, + north: r.north, + level: level.to_string(), + sprites: r.sprites, + nodes: r.nodes, + cycles: r.cycles, + warnings: r.warnings, + boxes: built.boxes, + ms: (t0.elapsed().as_secs_f64() * 1e4).round() / 10.0, + }) + } + + /// Write the document (pretty JSON, atomically). + pub fn save_doc(&self) -> Result<(), Error> { + let path = self.dir.join(self.manifest.doc_path()); + let mut s = + serde_json::to_string_pretty(&self.doc).map_err(|e| Error::Doc(e.to_string()))?; + s.push('\n'); + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, s).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?; + std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + Ok(()) + } +} + +/// A fingerprint of the project files (manifest, documents, scripts, +/// themes): changes when any of them is edited, added or removed. +pub fn version(dir: &Path) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + let mut stack = vec![dir.to_path_buf()]; + let mut files = vec![]; + while let Some(d) = stack.pop() { + let Ok(rd_) = std::fs::read_dir(&d) else { + continue; + }; + for ent in rd_.flatten() { + let p = ent.path(); + let name = ent.file_name().to_string_lossy().to_string(); + if p.is_dir() { + if !name.starts_with('.') + && name != "dist" + && name != "target" + && name != "node_modules" + { + stack.push(p); + } + } else if name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json") + { + if let Ok(m) = ent.metadata() { + files.push((p.to_string_lossy().to_string(), m.len(), m.modified().ok())); + } + } + } + } + files.sort(); + files.hash(&mut h); + h.finish() +} + +/// Write a new project folder with a starter scene. +pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> { + if dir.join(MANIFEST).exists() { + return Err(Error::Io(format!( + "{} already has an isokit.toml", + dir.display() + ))); + } + std::fs::create_dir_all(dir.join("lib")).map_err(|e| Error::Io(e.to_string()))?; + let w = |name: &str, body: &str| { + std::fs::write(dir.join(name), body).map_err(|e| Error::Io(format!("{name}: {e}"))) + }; + w(MANIFEST, &assets::NEW_MANIFEST.replace("{title}", title))?; + w("scene.json", assets::NEW_DOC)?; + w("theme.toml", assets::NEW_THEME)?; + w("lib/README.md", assets::NEW_LIB_README)?; + Ok(()) +} diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs new file mode 100644 index 0000000..5b98b83 --- /dev/null +++ b/crates/isokit-project/src/mcp.rs @@ -0,0 +1,360 @@ +//! MCP (Model Context Protocol) server: agents edit the scene with the same +//! operations as the Studio. Transport-agnostic: [`handle`] takes one +//! JSON-RPC message and returns the response (None for notifications); the +//! CLI serves it over stdio (`isokit mcp`) and the Studio over HTTP (`/mcp`). + +use crate::check::check; +use crate::ops::Op; +use crate::page; +use crate::workspace::Workspace; +use serde_json::{json, Map, Value}; + +pub const PROTOCOL: &str = "2025-06-18"; + +const INSTRUCTIONS: &str = "isokit edits an isometric scene (a site plan drawn as a SimCity-style 3D view). \ +World coordinates are metres: x east, y north, z up. The scene is a list of objects; each has a type (a prefab), \ +a position (x, y = centre of its footprint), a rotation in 90 degree steps and props from the prefab's schema. \ +Start with project_info and list_prefabs, then add_object / update_object. Use render_view (summary) to see where \ +everything ended up and check_project to find problems. Edits are saved at once and show live in an open Studio."; + +fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value { + json!({ "name": name, "description": description, + "inputSchema": { "type": "object", "properties": props, "required": required } }) +} + +pub fn tools() -> Vec { + let obj_fields = json!({ + "x": { "type": "number", "description": "east, metres (centre of the footprint)" }, + "y": { "type": "number", "description": "north, metres" }, + "z": { "type": "number", "description": "base height, metres (default 0)" }, + "rot": { "type": "number", "description": "rotation in degrees, multiples of 90" }, + "props": { "type": "object", "description": "prefab properties (see list_prefabs); in updates they merge, null removes" }, + "name": { "type": "string", "description": "name shown in the tooltip" }, + "info": { "type": "string", "description": "tooltip text" }, + "tag": { "type": "string", "description": "label pinned above the object" }, + "levels": { "type": "array", "items": { "type": "string" }, "description": "levels it shows on (empty: all)" }, + }); + let mut add = obj_fields.as_object().unwrap().clone(); + add.insert("type".into(), json!({ "type": "string", "description": "prefab type, e.g. building, tree, car (list_prefabs)" })); + add.insert("id".into(), json!({ "type": "string", "description": "wanted id (made unique); default from the type" })); + let mut upd = obj_fields.as_object().unwrap().clone(); + upd.insert("id".into(), json!({ "type": "string" })); + upd.insert("hidden".into(), json!({ "type": "boolean" })); + upd.insert( + "type".into(), + json!({ "type": "string", "description": "change the prefab type" }), + ); + vec![ + tool("project_info", "Title, folder, levels, parameters, object count and load errors of the project.", json!({}), &[]), + tool("list_prefabs", "Object types that can be placed, with their property schemas (type, default, min, max, choices).", + json!({ "category": { "type": "string" }, "type": { "type": "string", "description": "one type in full detail" } }), &[]), + tool("list_objects", "Objects in the scene with position, props and the world box [x0, y0, z0, x1, y1, z1] each one occupies.", + json!({ "type": { "type": "string", "description": "only this type" } }), &[]), + tool("get_object", "One object with all fields and its world box.", json!({ "id": { "type": "string" } }), &["id"]), + tool("add_object", "Place a new object. Returns its id and world box.", Value::Object(add), &["type", "x", "y"]), + tool("update_object", "Change an object: move (x, y, z), turn (rot), edit props, name, info, tag, levels.", Value::Object(upd), &["id"]), + tool("remove_object", "Delete an object.", json!({ "id": { "type": "string" } }), &["id"]), + tool("duplicate_object", "Copy an object, offset by dx, dy metres (default 2, 2).", + json!({ "id": { "type": "string" }, "dx": { "type": "number" }, "dy": { "type": "number" } }), &["id"]), + tool("apply_ops", "Several edits as one undo step. Each op is {op: add|update|remove|duplicate|reorder|set_cfg, ...} \ + (add: same fields as add_object; update: {id, patch: {...}}; reorder: {id, to: front|back|index}).", + json!({ "ops": { "type": "array", "items": { "type": "object" } } }), &["ops"]), + tool("set_params", "Set project parameter values (see project_info); null resets to the default.", + json!({ "values": { "type": "object" } }), &["values"]), + tool("render_view", "Render one view. format summary (default): sprite and node counts, warnings, object boxes; \ + svg: the standalone SVG markup.", + json!({ "level": { "type": "string" }, "rotation": { "type": "integer", "minimum": 0, "maximum": 3 }, + "format": { "type": "string", "enum": ["summary", "svg"] } }), &[]), + tool("check_project", "Lint the project: unknown types, bad props, script warnings, NaN, node budget, unstyled classes.", json!({}), &[]), + tool("build_html", "Write the self-contained HTML page (all views prerendered). Returns the file path.", + json!({ "out": { "type": "string", "description": "path relative to the project (default from isokit.toml)" } }), &[]), + tool("undo", "Undo the last edit.", json!({}), &[]), + tool("redo", "Redo the last undone edit.", json!({}), &[]), + ] +} + +fn text(v: Value) -> Value { + let t = match v { + Value::String(s) => s, + o => serde_json::to_string_pretty(&o).unwrap_or_default(), + }; + json!({ "content": [{ "type": "text", "text": t }], "isError": false }) +} + +fn tool_error(msg: impl Into) -> Value { + json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true }) +} + +fn boxes(ws: &Workspace) -> Map { + let p = &ws.project; + match p.render(&p.manifest.default_level(), 0, None, false) { + Ok(v) => v + .boxes + .into_iter() + .map(|(k, b)| (k, json!(b.map(isokit_core::fmt::round1)))) + .collect(), + Err(_) => Map::new(), + } +} + +fn object_json(ws: &Workspace, id: &str, bx: &Map) -> Option { + let o = ws.project.doc.get(id)?; + let mut v = serde_json::to_value(o).ok()?; + if let Some(b) = bx.get(id) { + v["box"] = b.clone(); + } + Some(v) +} + +fn str_arg(a: &Map, k: &str) -> Option { + a.get(k).and_then(|v| v.as_str()).map(str::to_string) +} + +/// Run one tool. +pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map) -> Value { + ws.refresh(); + match name { + "project_info" => { + let p = &ws.project; + let m = p.meta(); + text(json!({ + "title": m.title, "dir": p.dir.display().to_string(), "levels": m.levels, "default_level": m.default_level, + "params": m.params, "values": p.cfg(None), "objects": p.doc.objects.len(), + "types": m.catalog.iter().map(|c| format!("{} ({})", c.kind, c.label)).collect::>(), + "script_error": p.runtime.as_ref().err().map(|e| e.to_string()), + "load_error": ws.load_error, + "coordinates": "metres; x east, y north, z up; object x, y = centre of its footprint", + })) + } + "list_prefabs" => { + let cat = ws.project.catalog(); + if let Some(t) = str_arg(a, "type") { + return match cat.iter().find(|m| m.kind == t) { + Some(m) => text(serde_json::to_value(m).unwrap_or_default()), + None => tool_error(format!("no prefab \"{t}\"")), + }; + } + let c = str_arg(a, "category"); + let list: Vec = cat + .iter() + .filter(|m| c.as_ref().is_none_or(|c| m.category.eq_ignore_ascii_case(c))) + .map(|m| { + json!({ "type": m.kind, "label": m.label, "category": m.category, "help": m.help, + "props": m.props.iter().map(|p| { + let mut d = json!({ "key": p.key, "type": p.kind, "default": p.default }); + if let Some(x) = p.min { d["min"] = json!(x); } + if let Some(x) = p.max { d["max"] = json!(x); } + if !p.choices.is_empty() { d["choices"] = json!(p.choices); } + if !p.label.is_empty() { d["label"] = json!(p.label); } + d + }).collect::>() }) + }) + .collect(); + text(json!(list)) + } + "list_objects" => { + let bx = boxes(ws); + let t = str_arg(a, "type"); + let list: Vec = ws + .project + .doc + .objects + .iter() + .filter(|o| t.as_ref().is_none_or(|t| &o.kind == t)) + .filter_map(|o| object_json(ws, &o.id, &bx)) + .collect(); + text(json!(list)) + } + "get_object" => { + let id = str_arg(a, "id").unwrap_or_default(); + let bx = boxes(ws); + match object_json(ws, &id, &bx) { + Some(v) => text(v), + None => tool_error(format!("no object \"{id}\"")), + } + } + "add_object" => { + let mut op = a.clone(); + op.insert("op".into(), json!("add")); + match serde_json::from_value::(Value::Object(op)) { + Err(e) => tool_error(format!("bad arguments: {e}")), + Ok(op) => match ws.apply(op) { + Err(e) => tool_error(e), + Ok(o) => { + let id = o.id.unwrap_or_default(); + let bx = boxes(ws); + let warn: Vec = render_warnings(ws) + .into_iter() + .filter(|w| w.contains(&format!("object {id}:"))) + .collect(); + text( + json!({ "id": id, "object": object_json(ws, &id, &bx), "warnings": warn }), + ) + } + }, + } + } + "update_object" => { + let id = str_arg(a, "id").unwrap_or_default(); + let mut patch = a.clone(); + patch.remove("id"); + match ws.apply(Op::Update { + id: id.clone(), + patch, + }) { + Err(e) => tool_error(e), + Ok(_) => { + let bx = boxes(ws); + text(json!({ "object": object_json(ws, &id, &bx) })) + } + } + } + "remove_object" => match ws.apply(Op::Remove { + id: str_arg(a, "id").unwrap_or_default(), + }) { + Err(e) => tool_error(e), + Ok(_) => text(json!({ "removed": true, "objects": ws.project.doc.objects.len() })), + }, + "duplicate_object" => { + let num = |k: &str| a.get(k).and_then(|v| v.as_f64()).unwrap_or(2.0); + match ws.apply(Op::Duplicate { + id: str_arg(a, "id").unwrap_or_default(), + dx: num("dx"), + dy: num("dy"), + }) { + Err(e) => tool_error(e), + Ok(o) => text(json!({ "id": o.id })), + } + } + "apply_ops" => { + let ops = a + .get("ops") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let parsed: Result, String> = ops + .into_iter() + .enumerate() + .map(|(i, v)| serde_json::from_value::(v).map_err(|e| format!("op {i}: {e}"))) + .collect(); + match parsed.and_then(|l| ws.apply_all(l)) { + Err(e) => tool_error(e), + Ok(outs) => { + text(json!({ "ids": outs.into_iter().map(|o| o.id).collect::>() })) + } + } + } + "set_params" => { + let values = a + .get("values") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + match ws.apply(Op::SetCfg { values }) { + Err(e) => tool_error(e), + Ok(_) => text(json!({ "values": ws.project.cfg(None) })), + } + } + "render_view" => { + let p = &ws.project; + let level = str_arg(a, "level").unwrap_or_else(|| p.manifest.default_level()); + let rot = a + .get("rotation") + .and_then(|v| v.as_u64()) + .unwrap_or(p.manifest.default_rotation as u64) as u8 + % 4; + if str_arg(a, "format").as_deref() == Some("svg") { + return match page::svg(p, &level, rot, false) { + Ok(s) => text(Value::String(s)), + Err(e) => tool_error(e.to_string()), + }; + } + match p.render(&level, rot, None, false) { + Err(e) => tool_error(e.to_string()), + Ok(v) => text(json!({ + "level": level, "rotation": rot, "sprites": v.sprites, "nodes": v.nodes, "sort_cycles": v.cycles, + "render_ms": v.ms, "warnings": v.warnings, + "boxes": v.boxes.iter().map(|(k, b)| (k.clone(), json!(b.map(isokit_core::fmt::round1)))).collect::>(), + })), + } + } + "check_project" => { + let f = check(&ws.project); + text(json!({ "findings": f, "ok": !f.iter().any(|x| x.level == "error") })) + } + "build_html" => { + let p = &ws.project; + let out = str_arg(a, "out") + .or_else(|| p.manifest.build.out.clone()) + .unwrap_or_else(|| "dist/index.html".into()); + let path = p.dir.join(&out); + match page::static_page(p) { + Err(e) => tool_error(e.to_string()), + Ok(html) => { + if let Some(d) = path.parent() { + let _ = std::fs::create_dir_all(d); + } + match std::fs::write(&path, html) { + Ok(_) => text(json!({ "path": path.display().to_string() })), + Err(e) => tool_error(format!("{}: {e}", path.display())), + } + } + } + } + "undo" => match ws.undo() { + Ok(done) => text(json!({ "undone": done })), + Err(e) => tool_error(e), + }, + "redo" => match ws.redo() { + Ok(done) => text(json!({ "redone": done })), + Err(e) => tool_error(e), + }, + other => tool_error(format!("unknown tool \"{other}\"")), + } +} + +fn render_warnings(ws: &Workspace) -> Vec { + let p = &ws.project; + p.render(&p.manifest.default_level(), 0, None, false) + .map(|v| v.warnings) + .unwrap_or_default() +} + +/// Handle one JSON-RPC message. +pub fn handle(ws: &mut Workspace, msg: &Value) -> Option { + let id = msg.get("id").cloned(); + let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let params = msg.get("params").cloned().unwrap_or(json!({})); + let id = id?; // notifications get no response + let result = match method { + "initialize" => { + let pv = params + .get("protocolVersion") + .and_then(|v| v.as_str()) + .unwrap_or(PROTOCOL) + .to_string(); + json!({ "protocolVersion": pv, "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { "name": "isokit", "version": env!("CARGO_PKG_VERSION") }, + "instructions": INSTRUCTIONS }) + } + "ping" => json!({}), + "tools/list" => json!({ "tools": tools() }), + "tools/call" => { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let args = params + .get("arguments") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + call_tool(ws, name, &args) + } + "resources/list" => json!({ "resources": [] }), + "prompts/list" => json!({ "prompts": [] }), + _ => { + return Some( + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("method not found: {method}") } }), + ); + } + }; + Some(json!({ "jsonrpc": "2.0", "id": id, "result": result })) +} diff --git a/crates/isokit-project/src/ops.rs b/crates/isokit-project/src/ops.rs new file mode 100644 index 0000000..db4b6f4 --- /dev/null +++ b/crates/isokit-project/src/ops.rs @@ -0,0 +1,440 @@ +//! Edits to the scene document. The Studio and the MCP tools send the same +//! JSON operations: `{"op": "add", "type": "tree", "x": 3, "y": 4}`. + +use isokit_script::doc::{Doc, Object, PrefabMeta}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum Op { + /// Place a new object of a prefab type. + Add { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + x: f64, + #[serde(default)] + y: f64, + #[serde(default)] + z: f64, + #[serde(default)] + rot: f64, + #[serde(default)] + props: Map, + #[serde(default)] + name: String, + #[serde(default)] + info: String, + #[serde(default)] + tag: String, + #[serde(default)] + levels: Vec, + #[serde(default)] + pts: Option>, + /// Wanted id (made unique); default from the type. + #[serde(default)] + id: Option, + }, + /// Change fields of an object; `props` are merged (null removes a key). + Update { + id: String, + patch: Map, + }, + Remove { + id: String, + }, + Duplicate { + id: String, + #[serde(default = "two")] + dx: f64, + #[serde(default = "two")] + dy: f64, + }, + /// Move an object in the drawing order: "front", "back" or an index. + Reorder { + id: String, + to: Value, + }, + /// Set parameter values (null removes the document's value). + SetCfg { + values: Map, + }, + /// Replace the whole document. + Replace { + doc: Doc, + }, +} + +fn two() -> f64 { + 2.0 +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Outcome { + /// The object created or changed, if any. + pub id: Option, +} + +fn finite(v: f64, what: &str) -> Result { + if v.is_finite() { + Ok(v) + } else { + Err(format!("{what} must be a finite number")) + } +} + +fn norm_rot(r: f64) -> f64 { + (((r / 90.0).round() as i64).rem_euclid(4) * 90) as f64 +} + +/// Check property values against the prefab schema (unknown keys pass: +/// project scripts may read them). +pub fn check_props(meta: &PrefabMeta, props: &Map) -> Result<(), String> { + for (k, v) in props { + let Some(d) = meta.prop(k) else { continue }; + match d.kind.as_str() { + "float" | "int" => { + let Some(x) = v + .as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + else { + return Err(format!("{}.{k}: expected a number, got {v}", meta.kind)); + }; + if d.min.is_some_and(|m| x < m) || d.max.is_some_and(|m| x > m) { + return Err(format!( + "{}.{k} = {x} is outside {}..{}", + meta.kind, + d.min.map(|m| m.to_string()).unwrap_or_default(), + d.max.map(|m| m.to_string()).unwrap_or_default() + )); + } + } + "bool" => { + if !v.is_boolean() { + return Err(format!( + "{}.{k}: expected true or false, got {v}", + meta.kind + )); + } + } + "choice" => { + let s = v.as_str().unwrap_or_default(); + if !d.choices.is_empty() && !d.choices.iter().any(|c| c == s) { + return Err(format!( + "{}.{k}: \"{s}\" is not one of {}", + meta.kind, + d.choices.join(", ") + )); + } + } + _ => {} + } + } + Ok(()) +} + +fn str_of(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => String::new(), + o => o.to_string(), + } +} + +/// Apply a field patch to an object. +pub fn patch_object( + o: &mut Object, + patch: &Map, + meta: Option<&PrefabMeta>, +) -> Result<(), String> { + for (k, v) in patch { + let num = || v.as_f64().ok_or_else(|| format!("{k} must be a number")); + match k.as_str() { + "x" => o.x = finite(num()?, "x")?, + "y" => o.y = finite(num()?, "y")?, + "z" => o.z = finite(num()?, "z")?, + "rot" => o.rot = norm_rot(finite(num()?, "rot")?), + "name" => o.name = str_of(v), + "info" => o.info = str_of(v), + "tag" => o.tag = str_of(v), + "hidden" => o.hidden = v.as_bool().unwrap_or(false), + "locked" => o.locked = v.as_bool().unwrap_or(false), + "levels" => { + o.levels = v.as_array().map(|a| a.iter().map(str_of).filter(|s| !s.is_empty()).collect()).unwrap_or_default() + } + "pts" => { + o.pts = if v.is_null() { None } else { Some(serde_json::from_value(v.clone()).map_err(|e| format!("pts: {e}"))?) } + } + "props" => { + let m = v.as_object().ok_or("props must be an object")?; + let mut next = o.props.clone(); + for (pk, pv) in m { + if pv.is_null() { + next.remove(pk); + } else { + next.insert(pk.clone(), pv.clone()); + } + } + if let Some(meta) = meta { + check_props(meta, &next)?; + } + o.props = next; + } + "type" => { + let t = str_of(v); + if t.is_empty() { + return Err("type must not be empty".into()); + } + o.kind = t; + } + "id" => {} + other => return Err(format!("unknown field \"{other}\" (x, y, z, rot, name, info, tag, levels, props, pts, hidden, locked, type)")), + } + } + Ok(()) +} + +/// Apply one operation. `catalog` validates types and props. +pub fn apply(doc: &mut Doc, catalog: &[PrefabMeta], op: Op) -> Result { + let meta_of = |k: &str| catalog.iter().find(|m| m.kind == k); + match op { + Op::Add { + kind, + x, + y, + z, + rot, + props, + name, + info, + tag, + levels, + pts, + id, + } => { + let Some(meta) = meta_of(&kind) else { + let mut known: Vec<&str> = catalog.iter().map(|m| m.kind.as_str()).collect(); + known.sort(); + return Err(format!( + "unknown type \"{kind}\"; available: {}", + known.join(", ") + )); + }; + check_props(meta, &props)?; + let id = doc.free_id(id.as_deref().filter(|s| !s.is_empty()).unwrap_or(&kind)); + doc.objects.push(Object { + id: id.clone(), + kind, + name, + x: finite(x, "x")?, + y: finite(y, "y")?, + z: finite(z, "z")?, + rot: norm_rot(rot), + props, + info, + tag, + levels, + pts, + hidden: false, + locked: false, + }); + Ok(Outcome { id: Some(id) }) + } + Op::Update { id, patch } => { + let kind = doc + .get(&id) + .ok_or_else(|| format!("no object \"{id}\""))? + .kind + .clone(); + let kind = patch + .get("type") + .and_then(|v| v.as_str()) + .map(str::to_string) + .unwrap_or(kind); + let meta = meta_of(&kind); + let o = doc.get_mut(&id).expect("checked"); + let mut next = o.clone(); + patch_object(&mut next, &patch, meta)?; + *o = next; + Ok(Outcome { id: Some(id) }) + } + Op::Remove { id } => { + let n = doc.objects.len(); + doc.objects.retain(|o| o.id != id); + if doc.objects.len() == n { + return Err(format!("no object \"{id}\"")); + } + Ok(Outcome { id: None }) + } + Op::Duplicate { id, dx, dy } => { + let src = doc + .get(&id) + .ok_or_else(|| format!("no object \"{id}\""))? + .clone(); + let base = src + .id + .trim_end_matches(|c: char| c.is_ascii_digit() || c == '-') + .to_string(); + let nid = doc.free_id(if base.is_empty() { &src.kind } else { &base }); + let mut o = src; + o.id = nid.clone(); + o.x += dx; + o.y += dy; + o.locked = false; + let at = doc + .objects + .iter() + .position(|q| q.id == id) + .map(|i| i + 1) + .unwrap_or(doc.objects.len()); + doc.objects.insert(at, o); + Ok(Outcome { id: Some(nid) }) + } + Op::Reorder { id, to } => { + let i = doc + .objects + .iter() + .position(|o| o.id == id) + .ok_or_else(|| format!("no object \"{id}\""))?; + let o = doc.objects.remove(i); + let n = doc.objects.len(); + let at = match &to { + Value::String(s) if s == "front" => n, + Value::String(s) if s == "back" => 0, + Value::Number(k) => (k.as_f64().unwrap_or(0.0).max(0.0) as usize).min(n), + _ => return Err("to must be \"front\", \"back\" or an index".into()), + }; + doc.objects.insert(at, o); + Ok(Outcome { id: Some(id) }) + } + Op::SetCfg { values } => { + for (k, v) in values { + if v.is_null() { + doc.cfg.remove(&k); + } else { + doc.cfg.insert(k, v); + } + } + Ok(Outcome { id: None }) + } + Op::Replace { doc: d } => { + *doc = d; + Ok(Outcome { id: None }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use isokit_script::doc::PropDef; + use serde_json::json; + + fn catalog() -> Vec { + vec![PrefabMeta { + kind: "tree".into(), + label: "Tree".into(), + props: vec![PropDef { + key: "r".into(), + kind: "float".into(), + default: json!(1.6), + min: Some(0.5), + max: Some(6.0), + ..Default::default() + }], + ..Default::default() + }] + } + + fn op(v: Value) -> Op { + serde_json::from_value(v).unwrap() + } + + #[test] + fn add_update_duplicate_remove() { + let mut d = Doc::default(); + let c = catalog(); + let id = apply( + &mut d, + &c, + op(json!({"op": "add", "type": "tree", "x": 3, "y": 4, "rot": 100})), + ) + .unwrap() + .id + .unwrap(); + assert_eq!(id, "tree"); + assert_eq!(d.objects[0].rot, 90.0); + apply(&mut d, &c, op(json!({"op": "update", "id": "tree", "patch": {"x": 7.5, "props": {"r": 2.0}, "tag": "Oak"}}))).unwrap(); + assert_eq!(d.objects[0].x, 7.5); + assert_eq!(d.objects[0].props["r"], json!(2.0)); + let dup = apply(&mut d, &c, op(json!({"op": "duplicate", "id": "tree"}))) + .unwrap() + .id + .unwrap(); + assert_eq!(dup, "tree-2"); + assert_eq!(d.objects[1].x, 9.5); + apply( + &mut d, + &c, + op(json!({"op": "update", "id": "tree", "patch": {"props": {"r": null}}})), + ) + .unwrap(); + assert!(d.objects[0].props.is_empty()); + apply(&mut d, &c, op(json!({"op": "remove", "id": "tree"}))).unwrap(); + assert_eq!(d.objects.len(), 1); + } + + #[test] + fn validation_messages_help_the_caller() { + let mut d = Doc::default(); + let c = catalog(); + let e = apply(&mut d, &c, op(json!({"op": "add", "type": "ufo"}))).unwrap_err(); + assert!(e.contains("available: tree"), "{e}"); + let e = apply( + &mut d, + &c, + op(json!({"op": "add", "type": "tree", "props": {"r": 9}})), + ) + .unwrap_err(); + assert!(e.contains("outside"), "{e}"); + apply(&mut d, &c, op(json!({"op": "add", "type": "tree"}))).unwrap(); + let e = apply( + &mut d, + &c, + op(json!({"op": "update", "id": "tree", "patch": {"colour": 1}})), + ) + .unwrap_err(); + assert!(e.contains("unknown field"), "{e}"); + let e = apply( + &mut d, + &c, + op(json!({"op": "update", "id": "nope", "patch": {}})), + ) + .unwrap_err(); + assert!(e.contains("no object"), "{e}"); + } + + #[test] + fn reorder_and_cfg() { + let mut d = Doc::default(); + let c = catalog(); + for _ in 0..3 { + apply(&mut d, &c, op(json!({"op": "add", "type": "tree"}))).unwrap(); + } + apply( + &mut d, + &c, + op(json!({"op": "reorder", "id": "tree", "to": "front"})), + ) + .unwrap(); + assert_eq!(d.objects[2].id, "tree"); + apply( + &mut d, + &c, + op(json!({"op": "set_cfg", "values": {"labels": false}})), + ) + .unwrap(); + assert_eq!(d.cfg["labels"], json!(false)); + } +} diff --git a/crates/isokit-project/src/page.rs b/crates/isokit-project/src/page.rs new file mode 100644 index 0000000..fd6c42b --- /dev/null +++ b/crates/isokit-project/src/page.rs @@ -0,0 +1,88 @@ +//! Export: a self-contained HTML page with every view prerendered, or one +//! view as a standalone SVG file. + +use crate::assets; +use crate::Project; +use isokit_core::fmt::esc; +use isokit_script::ScriptError; +use serde_json::{json, Value}; + +/// JSON safe to put inside ` + + + + + +
+ + + + + + Saved + + Preview + + + +
+
+ +
+
+ +
+ x – y – + + + + +
+
+ +
+ + +

isokit Studio

+ + + + + + + + + +
Addpick an object on the left, click on the map (Shift-click to place several)
Selectclick it on the map or in the list
Movedrag it, or arrow keys (by the snap step)
TurnR / Shift+R, or the ⟲ ⟳ buttons
Copy / deleteCtrl+D / Delete
Undo / redoCtrl+Z / Ctrl+Y
Viewdrag the ground to pan, Ctrl + wheel or pinch to zoom, ↺ ↻ to rotate, levels to look inside
Agentsconnect an AI agent over MCP; its edits appear here live (see the panel when nothing is selected)
+
+
+ + + + diff --git a/studio/studio.css b/studio/studio.css new file mode 100644 index 0000000..4de5eae --- /dev/null +++ b/studio/studio.css @@ -0,0 +1,87 @@ +/* isokit Studio: layout and controls. Scene colours come from the theme. */ +:root { color-scheme: dark; --bg: #0a0f1a; --panel: #0f1626; --panel2: #131c30; --stroke: #2a3a56; --fg: #d6deeb; --mute: #8a97b0; --accent: #3ddc84; --danger: #ff6b6b; } +html[data-theme=light] { color-scheme: light; --bg: #eef2f7; --panel: #ffffff; --panel2: #f5f7fb; --stroke: #cfd8e6; --fg: #1b2533; --mute: #5b6880; --accent: #0e8f4f; --danger: #c62828; } +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { background: var(--bg); color: var(--fg); font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; flex-direction: column; overflow: hidden; } +button, select, input, textarea { font: inherit; color: inherit; } +button, .st-btn { background: var(--panel); border: 1px solid var(--stroke); border-radius: 5px; padding: 4px 10px; cursor: pointer; text-decoration: none; color: var(--fg); white-space: nowrap; } +button:hover:not(:disabled), .st-btn:hover { border-color: var(--accent); } +button:disabled { opacity: .45; cursor: default; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +input, select, textarea { background: var(--panel2); border: 1px solid var(--stroke); border-radius: 4px; padding: 3px 6px; min-width: 0; } +input[type=checkbox] { accent-color: var(--accent); } + +.st-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--panel); border-bottom: 1px solid var(--stroke); flex: 0 0 auto; } +.st-logo { font-size: 15px; letter-spacing: .02em; } +.st-logo span { color: var(--accent); font-weight: 600; } +.st-title { color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.st-grow { flex: 1 1 auto; } +.st-sep { width: 1px; height: 22px; background: var(--stroke); } +.st-save { color: var(--mute); font-size: 12px; min-width: 64px; text-align: center; } +.st-save.st-err { color: var(--danger); } + +.st-main { flex: 1 1 auto; display: grid; grid-template-columns: 250px minmax(0, 1fr) 300px; min-height: 0; } +.st-left, .st-right { background: var(--panel); border-right: 1px solid var(--stroke); overflow: auto; min-height: 0; } +.st-left { display: flex; flex-direction: column; } +.st-right { border-right: 0; border-left: 1px solid var(--stroke); padding: 10px; } +.st-panel { padding: 8px 10px; border-bottom: 1px solid var(--stroke); } +.st-grow-panel { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 140px; } +.st-panel h2, .st-right h2 { margin: 2px 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); font-weight: 600; } +.st-panel h2 small { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 4px; } +.st-panel input[type=search] { width: 100%; margin-bottom: 6px; } +.st-palette { display: flex; flex-direction: column; gap: 6px; } +.st-cat { font-size: 11px; color: var(--mute); margin: 4px 0 2px; } +.st-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } +.st-tile { display: flex; align-items: center; gap: 6px; text-align: left; padding: 4px 6px; overflow: hidden; } +.st-tile i { font-style: normal; width: 18px; text-align: center; flex: 0 0 auto; } +.st-tile span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.st-tile[aria-pressed=true] { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 18%, var(--panel)); } +.st-objects { list-style: none; margin: 0; padding: 0; overflow: auto; flex: 1 1 auto; } +.st-objects li { display: flex; align-items: center; gap: 6px; padding: 3px 6px; border-radius: 4px; cursor: pointer; } +.st-objects li:hover { background: var(--panel2); } +.st-objects li[aria-selected=true] { background: color-mix(in srgb, var(--accent) 20%, var(--panel)); } +.st-objects li i { font-style: normal; width: 18px; text-align: center; } +.st-objects li span { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.st-objects li small { color: var(--mute); } +.st-objects li.st-hidden span { opacity: .5; text-decoration: line-through; } + +.st-centre { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; } +.st-centre > #view { flex: 1 1 auto; min-height: 0; } +.st-centre.st-placing .iso-svg { cursor: crosshair; } +.st-hint { position: absolute; top: 52px; left: 50%; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--accent); border-radius: 16px; padding: 4px 14px; z-index: 5; pointer-events: none; } +.st-status { display: flex; align-items: center; gap: 14px; padding: 4px 10px; background: var(--panel); border-top: 1px solid var(--stroke); color: var(--mute); font-variant-numeric: tabular-nums; flex: 0 0 auto; } +.st-status select { padding: 1px 4px; } +.st-link { background: none; border: 0; color: var(--danger); padding: 0; text-decoration: underline; } + +.st-right h3 { margin: 0; font-size: 15px; display: flex; align-items: center; gap: 6px; } +.st-right h3 i { font-style: normal; } +.st-sub { color: var(--mute); font-size: 12px; margin: 2px 0 8px; } +.st-actions { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; } +.st-actions button { padding: 3px 8px; } +.st-actions .st-danger { color: var(--danger); } +.st-form { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 5px 8px; align-items: center; margin-bottom: 12px; } +.st-form > label { color: var(--mute); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.st-form textarea { resize: vertical; min-height: 44px; } +.st-form .st-full { grid-column: 1 / -1; } +.st-xy { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px; } +.st-xy input { width: 100%; } +.st-num { display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px; align-items: center; } +.st-num input[type=number] { width: 100%; } +.st-rot { display: flex; gap: 4px; align-items: center; } +.st-levels { display: flex; flex-wrap: wrap; gap: 4px 10px; } +.st-levels label { color: var(--fg); font-size: 12px; } +.st-help { color: var(--mute); font-size: 12px; margin: -4px 0 10px; } +.st-box { border: 1px dashed var(--stroke); border-radius: 6px; padding: 8px; margin: 10px 0; } +.st-box code { display: block; background: var(--panel2); border-radius: 4px; padding: 4px 6px; margin: 4px 0; font-size: 11.5px; word-break: break-all; user-select: all; } +.st-warn { color: var(--danger); font-size: 12px; margin: 4px 0; } +.st-toast { position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); background: var(--panel); border: 1px solid var(--accent); border-radius: 6px; padding: 6px 14px; z-index: 50; box-shadow: 0 4px 14px rgba(0, 0, 0, .3); } +.st-toast.st-bad { border-color: var(--danger); } +.st-dialog { background: var(--panel); color: var(--fg); border: 1px solid var(--stroke); border-radius: 8px; max-width: 560px; } +.st-dialog th { text-align: left; padding: 3px 12px 3px 0; color: var(--mute); font-weight: 500; vertical-align: top; } +.st-dialog form { text-align: right; margin-top: 10px; } + +@media (max-width: 900px) { + .st-main { grid-template-columns: 200px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 40vh; } + .st-right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--stroke); } +} diff --git a/studio/studio.js b/studio/studio.js new file mode 100644 index 0000000..758a89e --- /dev/null +++ b/studio/studio.js @@ -0,0 +1,360 @@ +/* isokit Studio: the scene editor (talks to `isokit studio` over /api). */ +(function () { + "use strict"; + + var $ = function (id) { return document.getElementById(id); }; + var S = { meta: null, doc: null, version: null, sel: null, placing: null, view: null, boxes: {}, warnings: [], busy: false, canUndo: false, canRedo: false, port: location.port }; + var ICON_DEFAULT = "▪"; + + function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } + function api(path, body) { + var o = body === undefined ? { cache: "no-store" } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; + return fetch(path, o).then(function (r) { return r.json(); }); + } + var toastT = 0; + function toast(msg, bad) { + var t = $("toast"); + t.textContent = msg; t.hidden = false; t.classList.toggle("st-bad", !!bad); + clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, bad ? 6000 : 2600); + } + function snapStep() { return parseFloat($("snap").value) || 0; } + function snap(v) { var s = snapStep(); return s ? Math.round(v / s) * s : Math.round(v * 100) / 100; } + function prefab(kind) { return (S.meta.catalog || []).filter(function (m) { return m.type === kind; })[0]; } + function obj(id) { return (S.doc.objects || []).filter(function (o) { return o.id === id; })[0]; } + function label(o) { var m = prefab(o.type); return o.name || (m ? m.label : o.type); } + function icon(kind) { var m = prefab(kind); return (m && m.icon) || ICON_DEFAULT; } + + // ------------------------------------------------------------ server state + function takeState(r) { + if (r.doc) S.doc = r.doc; + if (r.version !== undefined) S.version = r.version; + if (r.can_undo !== undefined) { S.canUndo = r.can_undo; S.canRedo = r.can_redo; } + $("undo").disabled = !S.canUndo; $("redo").disabled = !S.canRedo; + } + function op(o, quiet) { + $("save").textContent = "Saving…"; $("save").classList.remove("st-err"); + return api("/api/op", o).then(function (r) { + if (r.error) { + $("save").textContent = "Not saved"; $("save").classList.add("st-err"); + toast(r.error, true); + return null; + } + takeState(r); + $("save").textContent = "Saved"; + if (r.id && o.op !== "update") S.sel = r.id; + if (S.sel && !obj(S.sel)) S.sel = null; + S.view.refresh(); + renderLists(); if (!quiet) renderRight(); + return r; + }, function (e) { $("save").textContent = "Offline"; $("save").classList.add("st-err"); toast("Studio server not reachable: " + e, true); }); + } + function update(id, patch, quiet) { return op({ op: "update", id: id, patch: patch }, quiet); } + function undo() { api("/api/undo", {}).then(function (r) { takeState(r); afterExternal(); }); } + function redo() { api("/api/redo", {}).then(function (r) { takeState(r); afterExternal(); }); } + function afterExternal() { + if (S.sel && !obj(S.sel)) S.sel = null; + S.view.refresh(); renderLists(); renderRight(); + } + function reloadAll(why) { + return Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) { + var levelsChanged = JSON.stringify(rs[0].levels) !== JSON.stringify(S.meta.levels); + S.meta = rs[0]; takeState(rs[1]); + if (levelsChanged) { remount(); } else { S.view.source.meta = S.meta; S.view.refresh(); } + renderPalette(); afterExternal(); + if (why) toast(why); + }); + } + // another editor or an agent changed the files: follow along + function poll() { + api("/api/version").then(function (r) { + if (r.version !== S.version && !S.busy) { S.version = r.version; reloadAll("Scene updated outside the Studio (agent or file edit)"); } + }).catch(function () { /* server restarting */ }).then(function () { setTimeout(poll, 800); }); + } + + // ------------------------------------------------------------ view + var drag = null; + function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); } + function mountView() { + var src = IsoKit.httpSource("", toViewerMeta(S.meta)); + S.view = IsoKit.mount($("view"), src, { + note: false, noSelect: true, wheelZoom: true, + onRender: function (d) { + if (!d || d.error) { $("stats").textContent = "script error"; return; } + S.boxes = d.boxes || {}; S.warnings = d.warnings || []; + $("stats").textContent = d.sprites + " sprites · " + d.nodes + " nodes · " + d.ms + " ms"; + var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s"); + if (S.sel) S.view.selectObj(S.sel); + }, + onHover: function (w) { if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1); }, + onPick: function (i) { select(i && i.obj ? i.obj : null); }, + onKey: keyHandler, + interact: { + down: function (e, hit) { + if (S.placing) { return true; } + if (!hit.obj) return false; + var o = obj(hit.obj); + if (!o || o.locked) return false; + drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null }; + return true; + }, + move: function (e, m) { + if (S.placing || !drag || !m.world || !drag.w0) return; + if (!drag.moved) { drag.moved = true; drag.els = moveEls(drag.id); select(drag.id, true); } + var dx = snap(drag.ox + m.world[0] - drag.w0[0]) - drag.ox, dy = snap(drag.oy + m.world[1] - drag.w0[1]) - drag.oy; + var a = S.view.screenOf(dx, dy, 0), b = S.view.screenOf(0, 0, 0); + drag.dx = dx; drag.dy = dy; + Array.prototype.forEach.call(drag.els, function (g) { g.setAttribute("transform", "translate(" + (a[0] - b[0]).toFixed(1) + " " + (a[1] - b[1]).toFixed(1) + ")"); g.classList.add("iso-moving"); }); + $("cursor").textContent = "x " + (drag.ox + dx).toFixed(2) + " y " + (drag.oy + dy).toFixed(2); + }, + up: function (e, m) { + if (S.placing) { + if (!m.cancel && m.world) place(S.placing, m.world, e.shiftKey); + return; + } + var d = drag; drag = null; + if (!d) return; + if (!d.moved || m.cancel) { select(d.id); return; } + if (d.dx || d.dy) update(d.id, { x: +(d.ox + d.dx).toFixed(3), y: +(d.oy + d.dy).toFixed(3) }); + else S.view.refresh(); + }, + cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; } + } + }); + window.__studio = { S: S, view: S.view, op: op, select: select }; + } + function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; } + function remount() { + var lv = S.view ? S.view.getLevel() : null, rot = S.view ? S.view.getRotation() : null; + if (S.view) S.view.destroy(); + mountView(); + if (lv) S.view.setLevel(lv); + if (rot !== null) S.view.setRotation(rot); + } + + // ------------------------------------------------------------ actions + function place(kind, w, keep) { + var m = prefab(kind); + op({ op: "add", type: kind, x: snap(w[0]), y: snap(w[1]) }).then(function (r) { + if (r) toast((m ? m.label : kind) + " added" + (keep ? "" : " · Shift-click to place several")); + }); + if (!keep) setPlacing(null); + } + function setPlacing(kind) { + S.placing = kind; + document.querySelector(".st-centre").classList.toggle("st-placing", !!kind); + Array.prototype.forEach.call(document.querySelectorAll(".st-tile"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-type") === kind)); }); + var h = $("hint"); + if (kind) { var m = prefab(kind); h.textContent = "Click on the map to place: " + (m ? m.label : kind) + " · Esc to stop"; h.hidden = false; } + else h.hidden = true; + } + function select(id, quiet) { + if (id && !obj(id)) id = null; + S.sel = id; + S.view.selectObj(id); + Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(li.getAttribute("data-id") === id)); }); + if (!quiet) renderRight(); + } + function remove(id) { if (id) op({ op: "remove", id: id }).then(function (r) { if (r) toast("Deleted · Ctrl+Z brings it back"); }); } + function duplicate(id) { if (id) op({ op: "duplicate", id: id, dx: Math.max(snapStep(), 1) * 2, dy: Math.max(snapStep(), 1) * 2 }); } + function turn(id, d) { var o = obj(id); if (o) update(id, { rot: ((o.rot || 0) + d + 360) % 360 }); } + function nudge(id, dx, dy) { + var o = obj(id); if (!o || o.locked) return; + var s = snapStep() || 0.5; + // arrow keys follow the screen: map screen directions onto world axes for the current rotation + var rot = S.view.getRotation(); + var right = IsoKit.unproject(rot, 1, 0, 0), up = IsoKit.unproject(rot, 0, -1, 0), o0 = IsoKit.unproject(rot, 0, 0, 0); + var r = [right[0] - o0[0], right[1] - o0[1]], u = [up[0] - o0[0], up[1] - o0[1]]; + var wx = dx * r[0] + dy * u[0], wy = dx * r[1] + dy * u[1]; + // pick the dominant world axis so a key press moves along x or y only + if (Math.abs(wx) >= Math.abs(wy)) { wy = 0; wx = Math.sign(wx); } else { wx = 0; wy = Math.sign(wy); } + update(id, { x: +(o.x + wx * s).toFixed(3), y: +(o.y + wy * s).toFixed(3) }, true).then(function () { renderRight(); }); + } + function keyHandler(e) { + var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT"); + if (typing) return false; + var k = e.key, ctrl = e.ctrlKey || e.metaKey; + if (ctrl && (k === "z" || k === "Z")) { if (e.shiftKey) redo(); else undo(); return true; } + if (ctrl && (k === "y" || k === "Y")) { redo(); return true; } + if (k === "Escape") { if (S.placing) setPlacing(null); else select(null); return true; } + if (!S.sel) return false; + if (k === "Delete" || k === "Backspace") { remove(S.sel); return true; } + if (ctrl && (k === "d" || k === "D")) { duplicate(S.sel); return true; } + if (!ctrl && (k === "r" || k === "R")) { turn(S.sel, e.shiftKey ? -90 : 90); return true; } + if (k === "ArrowLeft") { nudge(S.sel, -1, 0); return true; } + if (k === "ArrowRight") { nudge(S.sel, 1, 0); return true; } + if (k === "ArrowUp") { nudge(S.sel, 0, 1); return true; } + if (k === "ArrowDown") { nudge(S.sel, 0, -1); return true; } + return false; + } + document.addEventListener("keydown", function (e) { + if (S.view && e.target === S.view.svg) return; // the viewer calls keyHandler itself + if (keyHandler(e)) e.preventDefault(); + }); + + // ------------------------------------------------------------ left panel + function renderPalette() { + var q = $("psearch").value.trim().toLowerCase(), cats = {}, order = []; + (S.meta.catalog || []).forEach(function (m) { + if (q && (m.label + " " + m.type + " " + m.category + " " + (m.help || "")).toLowerCase().indexOf(q) < 0) return; + if (!cats[m.category]) { cats[m.category] = []; order.push(m.category); } + cats[m.category].push(m); + }); + $("palette").innerHTML = order.map(function (c) { + return '
' + esc(c) + '
' + cats[c].map(function (m) { + return '"; + }).join("") + "
"; + }).join("") || '
No object matches.
'; + } + function renderLists() { + var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || []; + $("ocount").textContent = list.length; + $("objects").innerHTML = list.filter(function (o) { + return !q || (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0; + }).slice().reverse().map(function (o) { + return '
  • ' + esc(icon(o.type)) + "" + esc(label(o)) + "" + (o.locked ? "🔒" : "") + "
  • "; + }).join(""); + } + $("palette").addEventListener("click", function (e) { + var b = e.target.closest(".st-tile"); if (!b) return; + var k = b.getAttribute("data-type"); + setPlacing(S.placing === k ? null : k); + S.view.svg.focus(); + }); + $("psearch").addEventListener("input", renderPalette); + $("osearch").addEventListener("input", renderLists); + $("objects").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li) select(li.getAttribute("data-id")); }); + $("objects").addEventListener("dblclick", function (e) { var li = e.target.closest("li"); if (li) S.view.focus(li.getAttribute("data-id"), 3); }); + + // ------------------------------------------------------------ right panel + function field(key, lab, html, full) { return '" + (full ? '
    ' + html + "
    " : html); } + function propInput(d, v) { + var id = "p-" + d.key, val = v === undefined ? d.default : v; + if (d.type === "bool") return '"; + if (d.type === "choice" || (d.type === "material" && d.choices && d.choices.length)) { + var ch = (d.choices || []).slice(); if (val !== undefined && ch.indexOf(val) < 0) ch.unshift(val); + return '"; + } + if (d.type === "material") return ''; + if (d.type === "float" || d.type === "int") { + var step = d.step || (d.type === "int" ? 1 : 0.1), mm = (d.min !== undefined ? ' min="' + d.min + '"' : "") + (d.max !== undefined ? ' max="' + d.max + '"' : ""); + var range = d.min !== undefined && d.max !== undefined ? '' : ""; + return '
    ' + range + '
    '; + } + return ''; + } + function renderRight() { + var R = $("right"), o = S.sel ? obj(S.sel) : null; + if (!o) { renderProject(R); return; } + var m = prefab(o.type) || { label: o.type, props: [] }, p = o.props || {}; + var levels = S.meta.levels || []; + var h = "

    " + esc(m.icon || ICON_DEFAULT) + "" + esc(label(o)) + "

    " + + '
    ' + esc(m.label) + " · id " + esc(o.id) + "
    " + + (m.help ? '

    ' + esc(m.help) + "

    " : "") + + '
    ' + + '' + + '' + + '' + + '' + + '" + + '" + + '
    ' + + S.warnings.filter(function (w) { return w.indexOf("object " + o.id + ":") === 0; }).map(function (w) { return '
    ' + esc(w) + "
    "; }).join("") + + '

    Placement

    ' + + field("xy", "Position x y z", '
    ') + + field("rot", "Rotation", '
    ' + (o.rot || 0) + '°
    ') + + "
    " + + (m.props.length ? "

    Properties

    " + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "
    " : "") + + '

    Label and tooltip

    ' + + field("name", "Name", '') + + field("tag", "Tag", '') + + field("info", "Tooltip", '") + + "
    " + + (levels.length > 1 ? '

    Shown on levels

    ' + levels.map(function (l) { + return '"; + }).join("") + '

    None ticked: shown on every level.

    ' : "") + + '' + (S.meta.materials || []).map(function (x) { return ""; }).join("") + ""; + R.innerHTML = h; + } + function renderProject(R) { + var m = S.meta, cfg = S.doc.cfg || {}; + var base = location.origin; + R.innerHTML = "

    " + esc(m.title) + "

    " + '
    ' + (S.doc.objects || []).length + " objects · " + (m.catalog || []).length + " object types
    " + + (m.params && m.params.length ? "

    Scene options

    " + m.params.map(function (p) { + var v = cfg[p.name] !== undefined ? cfg[p.name] : p.default; + return field(p.name, p.label || p.name, propInput({ key: p.name, type: p.type, default: p.default, choices: p.choices, min: p.min, max: p.max, step: p.step }, v).replace(/data-prop=/g, "data-cfg=")); + }).join("") + "
    " : "") + + (S.warnings.length ? "

    Warnings

    " + S.warnings.map(function (w) { return '
    ' + esc(w) + "
    "; }).join("") : "") + + '

    Connect an AI agent (MCP)

    ' + + '

    Agents edit this scene with the same operations as the Studio; their changes appear here live.

    ' + + "HTTP (while the Studio runs):" + esc(base) + "/mcp" + + "stdio (any time):isokit mcp " + esc(m.dir || ".") + "
    " + + '

    Select an object to edit it, or pick one on the left to add it.

    '; + } + $("right").addEventListener("click", function (e) { + var b = e.target.closest("button[data-act]"); if (!b || !S.sel) return; + var a = b.getAttribute("data-act"), o = obj(S.sel); + if (a === "del") remove(S.sel); + else if (a === "dup") duplicate(S.sel); + else if (a === "focus") S.view.focus(S.sel, 3); + else if (a === "front" || a === "back") op({ op: "reorder", id: S.sel, to: a }); + else if (a === "lock") update(S.sel, { locked: !o.locked }); + else if (a === "hide") update(S.sel, { hidden: !o.hidden }); + else if (a === "rotl") turn(S.sel, -90); + else if (a === "rotr") turn(S.sel, 90); + }); + var rangeT = 0; + function formChange(e, live) { + var t = e.target, o = S.sel ? obj(S.sel) : null; + if (t.hasAttribute("data-cfg")) { + var k = t.getAttribute("data-cfg"), v = t.type === "checkbox" ? t.checked : t.type === "number" || t.type === "range" ? parseFloat(t.value) : t.value, vals = {}; + vals[k] = v; op({ op: "set_cfg", values: vals }, true); + return; + } + if (!o) return; + if (t.hasAttribute("data-f")) { + var f = t.getAttribute("data-f"), val = t.type === "number" ? parseFloat(t.value) : t.value, patch = {}; + if (t.type === "number" && !isFinite(val)) return; + patch[f] = val; update(o.id, patch, live); + } else if (t.hasAttribute("data-prop")) { + var key = t.getAttribute("data-prop"), d = (prefab(o.type) || { props: [] }).props.filter(function (x) { return x.key === key; })[0] || {}; + var pv = t.type === "checkbox" ? t.checked : (d.type === "float" || d.type === "int") ? parseFloat(t.value) : t.value; + if ((d.type === "float" || d.type === "int") && !isFinite(pv)) return; + if (d.type === "int") pv = Math.round(pv); + // keep the slider and the number box in step + Array.prototype.forEach.call(document.querySelectorAll('#right [data-prop="' + key + '"]'), function (x) { if (x !== t && x.type !== "checkbox") x.value = pv; }); + var pp = {}; pp[key] = pv; + if (live) { clearTimeout(rangeT); rangeT = setTimeout(function () { update(o.id, { props: pp }, true); }, 160); } + else update(o.id, { props: pp }, true); + } else if (t.hasAttribute("data-level")) { + var lv = Array.prototype.filter.call(document.querySelectorAll("#right [data-level]"), function (x) { return x.checked; }).map(function (x) { return x.getAttribute("data-level"); }); + update(o.id, { levels: lv }); + } + } + $("right").addEventListener("change", function (e) { formChange(e, false); }); + $("right").addEventListener("input", function (e) { if (e.target.type === "range") formChange(e, true); }); + $("right").addEventListener("keydown", function (e) { if (e.key === "Enter" && e.target.tagName === "INPUT") e.target.blur(); }); + + // ------------------------------------------------------------ header + $("undo").addEventListener("click", undo); + $("redo").addEventListener("click", redo); + $("help").addEventListener("click", function () { $("helpdlg").showModal(); }); + $("theme").addEventListener("click", function () { + var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light"; + document.documentElement.setAttribute("data-theme", t); + try { localStorage.setItem("isokit-theme", t); } catch (e) { /* private mode */ } + }); + $("build").addEventListener("click", function () { + api("/api/build", {}).then(function (r) { if (r.error) toast(r.error, true); else toast("Page written: " + r.path); }); + }); + $("warnbtn").addEventListener("click", function () { select(null); }); + + // ------------------------------------------------------------ start + Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) { + S.meta = rs[0]; takeState(rs[1]); + document.title = S.meta.title + " · isokit Studio"; + $("title").textContent = S.meta.title; + if (S.meta.load_error) toast(S.meta.load_error, true); + renderPalette(); renderLists(); mountView(); renderRight(); + poll(); + }); +})(); diff --git a/tools/check_studio.py b/tools/check_studio.py new file mode 100644 index 0000000..aced5aa --- /dev/null +++ b/tools/check_studio.py @@ -0,0 +1,143 @@ +"""Functional test of the isokit Studio (GUI) and its MCP endpoint, headless Edge.""" +import json +import sys +import urllib.request +from pathlib import Path +from playwright.sync_api import sync_playwright + +BASE = sys.argv[2] if len(sys.argv) > 2 else "http://127.0.0.1:7878" +OUT = Path(sys.argv[1]) +OUT.mkdir(parents=True, exist_ok=True) +fails = [] + + +def check(cond, msg): + print(("ok " if cond else "FAIL ") + msg) + if not cond: + fails.append(msg) + + +def get(path): + return json.loads(urllib.request.urlopen(BASE + path).read()) + + +def mcp(msg): + req = urllib.request.Request(BASE + "/mcp", data=json.dumps(msg).encode(), headers={"Content-Type": "application/json"}) + r = urllib.request.urlopen(req) + body = r.read() + return json.loads(body) if body else None + + +def doc(): + return get("/api/doc")["doc"] + + +def obj(id_): + return next((o for o in doc()["objects"] if o["id"] == id_), None) + + +with sync_playwright() as p: + b = p.chromium.launch(channel="msedge", headless=True) + pg = b.new_page(viewport={"width": 1500, "height": 950}) + errs = [] + pg.on("pageerror", lambda e: errs.append(str(e))) + pg.on("console", lambda m: errs.append(m.text) if m.type == "error" else None) + pg.goto(BASE + "/") + pg.wait_for_selector(".iso-svg [data-obj]", timeout=10000) + pg.wait_for_timeout(500) + n0 = len(doc()["objects"]) + check(pg.locator("#objects li").count() == n0, f"object list shows {n0} objects") + check(pg.locator(".st-tile").count() >= 20, "palette lists the prefabs") + pg.screenshot(path=str(OUT / "studio-start.jpg"), type="jpeg", quality=80) + + # place a tree: pick it in the palette, click an empty spot of the map + pg.locator('.st-tile[data-type="tree"]').click() + check(pg.locator("#hint").is_visible(), "placing hint shows") + stage = pg.locator(".iso-stage").bounding_box() + pg.mouse.click(stage["x"] + stage["width"] * 0.30, stage["y"] + stage["height"] * 0.62) + pg.wait_for_timeout(700) + d = doc() + check(len(d["objects"]) == n0 + 1, "click on the map adds an object") + new = d["objects"][-1] + check(new["type"] == "tree", f"the new object is a tree at ({new['x']}, {new['y']})") + check(pg.locator("#right h3").inner_text().strip().endswith("Tree"), "the new object is selected in the panel") + + # drag the generator + g = obj("genset") + # aim at the generator body (its group's box also spans the exhaust puffs above it) + box = pg.locator('.iso-svg g[data-obj="genset"] path.iso-t').nth(1).bounding_box() + cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 + hit = pg.evaluate("([x, y]) => { const e = document.elementFromPoint(x, y); const g = e && e.closest('[data-obj]'); return g && g.getAttribute('data-obj'); }", [cx, cy]) + check(hit == "genset", f"pointer is on the generator ({hit})") + pg.mouse.move(cx, cy) + pg.mouse.down() + for i in range(1, 11): + pg.mouse.move(cx + 9 * i, cy + 3 * i) + pg.wait_for_timeout(20) + pg.mouse.up() + pg.wait_for_timeout(700) + g2 = obj("genset") + check((g2["x"], g2["y"]) != (g["x"], g["y"]), f"drag moves the generator ({g['x']},{g['y']}) -> ({g2['x']},{g2['y']})") + check(abs(g2["x"] * 2 - round(g2["x"] * 2)) < 1e-9, "the move snaps to 0.5 m") + + # edit a property of the building in the form + pg.locator('#objects li[data-id="hall"]').click() + pg.wait_for_timeout(300) + inp = pg.locator('#right input[type=number][data-prop="floors"]') + inp.fill("3") + inp.press("Enter") + pg.wait_for_timeout(700) + check(obj("hall")["props"].get("floors") == 3, "the form sets floors = 3") + pg.screenshot(path=str(OUT / "studio-selected.jpg"), type="jpeg", quality=80) + + # undo the floors change + pg.locator("#undo").click() + pg.wait_for_timeout(700) + check(obj("hall")["props"].get("floors") == 2, "undo restores floors = 2") + + # keyboard: rotate and delete the new tree + pg.locator(f'#objects li[data-id="{new["id"]}"]').click() + pg.locator(".iso-svg").focus() + pg.keyboard.press("Delete") + pg.wait_for_timeout(700) + check(obj(new["id"]) is None, "Delete removes the selected object") + + # an agent edits over MCP: the open Studio follows + init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}) + check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize") + check(mcp({"jsonrpc": "2.0", "method": "notifications/initialized"}) is None, "MCP notification gets no body") + tools = mcp({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})["result"]["tools"] + check(len(tools) >= 14, f"MCP lists {len(tools)} tools") + r = mcp({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "add_object", "arguments": {"type": "container", "x": 20, "y": -1, "rot": 90, "name": "Spare parts", "props": {"stack": 2}}}}) + res = json.loads(r["result"]["content"][0]["text"]) + check(not r["result"]["isError"] and res["id"] == "container", f"MCP add_object -> {res.get('id')}, box {res.get('object', {}).get('box')}") + bad = mcp({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "add_object", "arguments": {"type": "ufo", "x": 0, "y": 0}}}) + check(bad["result"]["isError"] and "available" in bad["result"]["content"][0]["text"], "MCP explains a bad type") + pg.wait_for_timeout(2000) + check(pg.locator('#objects li[data-id="container"]').count() == 1, "the Studio shows the agent's container") + check(pg.locator('.iso-svg g[data-obj="container"]').count() >= 1, "the map draws the agent's container") + summ = json.loads(mcp({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "render_view", "arguments": {"level": "gf"}}})["result"]["content"][0]["text"]) + check(summ["sprites"] > 20 and "container" in summ["boxes"], f"MCP render_view summary: {summ['sprites']} sprites, {summ['nodes']} nodes") + chk = json.loads(mcp({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "check_project", "arguments": {}}})["result"]["content"][0]["text"]) + check(chk["ok"], f"MCP check_project ok ({len(chk['findings'])} findings)") + + # cross-origin edits are refused + req = urllib.request.Request(BASE + "/api/op", data=b'{"op":"remove","id":"hall"}', headers={"Content-Type": "application/json", "Origin": "http://evil.example"}) + try: + urllib.request.urlopen(req) + check(False, "cross-origin POST refused") + except urllib.error.HTTPError as e: + check(e.code == 403, "cross-origin POST refused") + + # view controls still work in the Studio + pg.locator('.iso-seg button[data-level="gf"]').click() + pg.wait_for_timeout(600) + check(pg.locator('.iso-seg button[data-level="gf"]').get_attribute("aria-pressed") == "true", "level switch") + pg.locator('[data-a="rotr"]').click() + pg.wait_for_timeout(600) + pg.screenshot(path=str(OUT / "studio-gf.jpg"), type="jpeg", quality=80) + check(not errs, f"no script errors {errs}") + b.close() + +print("FAILED:" if fails else "ALL PASSED", fails) +sys.exit(1 if fails else 0) diff --git a/viewer/isokit-view.css b/viewer/isokit-view.css new file mode 100644 index 0000000..e4fa93d --- /dev/null +++ b/viewer/isokit-view.css @@ -0,0 +1,164 @@ +/* isokit-view.css: structure of the isokit viewer (stage, toolbar, tooltip, + faces, tags, selection, animation, full screen). Colours come from the + theme (palette variables --iso-*); this file holds no scene colours. + Ported from the CNX Vinyl / CNX Scuba / Mooban views. */ + +.iso-wrap { position: relative; color: var(--iso-tipfg); } + +/* stage, toolbar, tooltip, note */ +.iso-stage { + position: relative; width: 100%; aspect-ratio: 16 / 10; min-height: 340px; max-height: 86vh; + overflow: hidden; border-radius: 8px; border: 1px solid var(--iso-btnline); + background: + radial-gradient(1px 1px at 12% 18%, var(--iso-star) 50%, transparent 51%), + radial-gradient(1px 1px at 33% 9%, var(--iso-star) 50%, transparent 51%), + radial-gradient(1px 1px at 58% 14%, var(--iso-star) 50%, transparent 51%), + radial-gradient(1.2px 1.2px at 81% 7%, var(--iso-star) 50%, transparent 51%), + radial-gradient(1px 1px at 91% 22%, var(--iso-star) 50%, transparent 51%), + linear-gradient(180deg, var(--iso-sky1), var(--iso-sky2)); +} +.iso-svg { display: block; width: 100%; height: 100%; touch-action: pan-y; cursor: grab; user-select: none; -webkit-user-select: none; outline: none; } +.iso-dragging .iso-svg { cursor: grabbing; } +.iso-svg:focus-visible { outline: 2px solid var(--iso-accent); outline-offset: -2px; } +.iso-bar { + position: absolute; top: 8px; left: 8px; right: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; + pointer-events: none; font: 13px/1.2 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +.iso-bar > *, .iso-bar button { pointer-events: auto; } +.iso-bar button, .iso-compass { + background: var(--iso-btn); color: var(--iso-btnfg); border: 1px solid var(--iso-btnline); border-radius: 5px; + padding: 5px 10px; font: inherit; cursor: pointer; min-width: 32px; min-height: 30px; +} +.iso-bar button:hover { border-color: var(--iso-accent); } +.iso-bar button:focus-visible { outline: 2px solid var(--iso-accent); outline-offset: 1px; } +.iso-seg { display: inline-flex; } +.iso-seg button { border-radius: 0; } +.iso-seg button:first-child { border-radius: 5px 0 0 5px; } +.iso-seg button:last-child { border-radius: 0 5px 5px 0; } +.iso-seg button + button { border-left: 0; } +.iso-seg button[aria-pressed=true] { background: var(--iso-btnon); border-color: var(--iso-accent); color: var(--iso-btnfg); font-weight: 600; } +.iso-compass { display: inline-flex; align-items: center; gap: 2px; padding: 3px 7px; cursor: default; } +.iso-compass svg { width: 20px; height: 20px; } +.iso-compass path { fill: var(--iso-accent); } +.iso-compass b { font-size: 11px; } +.iso-tip { + position: absolute; z-index: 3; max-width: min(330px, calc(100% - 12px)); padding: 8px 10px; pointer-events: none; + background: var(--iso-tipbg); color: var(--iso-tipfg); border: 1px solid var(--iso-tipline); border-radius: 6px; + font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; box-shadow: 0 4px 14px rgba(0, 0, 0, .28); +} +.iso-tip b { display: block; font-weight: 600; margin-bottom: 2px; } +.iso-tip span { display: block; color: var(--iso-tipmute); font-size: 12px; } +.iso-tip table { margin-top: 4px; border-collapse: collapse; font-size: 12px; } +.iso-tip th { text-align: left; font-weight: 400; color: var(--iso-tipmute); padding: 1px 10px 1px 0; } +.iso-tip td { text-align: right; font-variant-numeric: tabular-nums; } +.iso-tip[hidden], .iso-wrap [hidden] { display: none !important; } +.iso-note { margin: 6px 2px 0; font: 12px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--iso-mute); } +.iso-err { + position: absolute; left: 8px; right: 8px; bottom: 8px; z-index: 4; padding: 8px 10px; border-radius: 6px; + background: var(--iso-warnbg); color: var(--iso-warnfg); border: 1px solid var(--iso-warnline); + font: 13px/1.4 ui-monospace, "Cascadia Mono", Consolas, monospace; +} +.iso-err b { display: block; font: 600 13px/1.4 system-ui, sans-serif; } +.iso-err small { display: block; opacity: .8; margin-top: 3px; } +.iso-loading .iso-svg { opacity: .92; } + +/* faces: material colour --b mixed towards the shade per face direction */ +.iso-svg .iso-t { fill: var(--b, #888); } +.iso-svg .iso-l { fill: color-mix(in srgb, var(--b, #888) var(--iso-kl), var(--iso-shade)); } +.iso-svg .iso-c { fill: color-mix(in srgb, var(--b, #888) var(--iso-kc), var(--iso-shade)); } +.iso-svg .iso-r { fill: color-mix(in srgb, var(--b, #888) var(--iso-kr), var(--iso-shade)); } +.iso-sprites path, .iso-sprites ellipse, .iso-sprites circle { stroke: var(--iso-edge); stroke-width: var(--iso-edgew); stroke-linejoin: round; } +.iso-ground path, .iso-ground ellipse { stroke: none; } +@supports not (color: color-mix(in srgb, red 50%, blue)) { + .iso-svg .iso-l, .iso-svg .iso-c, .iso-svg .iso-r { fill: var(--b, #888); } + .iso-svg .iso-r { fill-opacity: .82; } +} + +/* ghosting (levels above a building) */ +.iso-svg .iso-dim { opacity: .4; } +html[data-theme=light] .iso-svg .iso-dim { opacity: .5; } +.iso-svg .iso-dim .iso-a { animation: none !important; } + +/* tags (counter-scaled by --iso-k so they keep their screen size) */ +.iso-tag path { fill: var(--iso-tagbg); stroke: var(--iso-tagline); stroke-width: 1; } +.iso-tag text { fill: var(--iso-tagfg); font: 600 10.5px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; text-anchor: middle; } +.iso-signtag path { fill: var(--iso-signbg); stroke: var(--iso-signline); } +.iso-signtag text { fill: var(--iso-signfg); } +.iso-warntag path { fill: var(--iso-warnbg); stroke: var(--iso-warnline); } +.iso-warntag text { fill: var(--iso-warnfg); } +.iso-tag { cursor: default; } +.iso-svg .iso-hide { display: none; } +.iso-svg .iso-ghost { opacity: .38; } + +/* hover and selection; .iso-osel marks the object selected in the Studio */ +.iso-svg .iso-hov > path, .iso-svg .iso-hov > ellipse, .iso-svg path.iso-hov { stroke: var(--iso-hov); stroke-width: .8; } +.iso-svg .iso-sel > path, .iso-svg .iso-sel > ellipse, .iso-svg .iso-sel > g > path, .iso-svg path.iso-sel { stroke: var(--iso-sel); stroke-width: 1.1; } +.iso-svg .iso-osel path, .iso-svg .iso-osel ellipse, .iso-svg .iso-osel circle { stroke: var(--iso-sel); stroke-width: 1.2; } +.iso-svg .iso-moving { opacity: .75; } + +/* animation: CSS only; paused off-screen (iso-off), when stopped (iso-still) and for reduced motion */ +.iso-svg .iso-fan { transform-box: fill-box; transform-origin: center; animation: iso-spin 1.1s linear infinite; } +.iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke { transform-box: fill-box; transform-origin: center; animation: iso-puff 3.2s ease-out infinite; opacity: 0; pointer-events: none; stroke: none; } +.iso-svg .iso-puff, .iso-svg .iso-puff2 { fill: var(--iso-steam); } +.iso-svg .iso-smoke { fill: var(--iso-smoke); } +.iso-svg .iso-glint { animation: iso-glint 7s ease-in-out infinite; display: none; } +html[data-theme=light] .iso-svg .iso-glint { display: inline; } +.iso-svg .iso-disc { animation: iso-move 16s linear infinite; } +.iso-svg .iso-walk { animation: iso-shuttle 70s ease-in-out infinite; } +.iso-svg .iso-shuttle { animation: iso-shuttle 48s ease-in-out infinite; } +.iso-svg .iso-drive { animation: iso-drive 16s linear infinite; } +.iso-svg .iso-ledr, .iso-svg .iso-ledo { animation: iso-blink 2.4s ease-in-out infinite; } +.iso-svg .iso-park { display: none; } +.iso-off .iso-a, .iso-still .iso-a, .iso-off .iso-drive, .iso-still .iso-drive { animation-play-state: paused !important; } +.iso-still .iso-park { display: inline; } +@keyframes iso-spin { to { transform: rotate(360deg); } } +@keyframes iso-puff { + 0% { transform: translate(0, 0) scale(.35); opacity: 0; } + 12% { opacity: .75; } + 100% { transform: translate(5px, -28px) scale(2); opacity: 0; } +} +@keyframes iso-glint { 0%, 84%, 100% { opacity: 0; } 91% { opacity: .9; } } +@keyframes iso-blink { 0%, 100% { opacity: 1; } 50% { opacity: .45; } } +@keyframes iso-move { from { transform: translate(0, 0); } to { transform: translate(var(--dx), var(--dy)); } } +@keyframes iso-shuttle { + 0%, 8% { transform: translate(0, 0); } + 46%, 56% { transform: translate(var(--dx), var(--dy)); } + 94%, 100% { transform: translate(0, 0); } +} +@keyframes iso-drive { + 0% { transform: translate(0, 0); opacity: 0; } + 5% { opacity: 1; } + 92% { transform: translate(var(--dx), var(--dy)); opacity: 1; } + 96%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; } +} +@media (prefers-reduced-motion: reduce) { + .iso-svg .iso-a { animation: none !important; } + .iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke, .iso-svg .iso-disc, .iso-svg .iso-glint { display: none; } + .iso-svg .iso-park { display: inline; } +} +@media (max-width: 640px) { + .iso-stage { aspect-ratio: 1 / 1; min-height: 320px; } + .iso-bar { gap: 3px; top: 6px; left: 6px; right: 6px; } + .iso-bar button { padding: 4px 6px; min-width: 26px; min-height: 30px; } + .iso-compass { padding: 3px; min-height: 30px; } + .iso-compass b { display: none; } +} +@media (max-width: 370px) { .iso-compass { display: none; } } + +/* full screen: native (:fullscreen) or pinned over the page where the Fullscreen API is missing (iPhone) */ +.iso-fs { display: flex !important; flex-direction: column; box-sizing: border-box; overflow: hidden; } +.iso-fs-fake { position: fixed !important; inset: 0; z-index: 2147483000; margin: 0 !important; border-radius: 0 !important; + width: auto !important; height: auto !important; max-width: none !important; } +.iso-wrap.iso-fs { background: var(--iso-fsbg, #0e1117); padding: 0; } +html[data-theme=light] .iso-wrap.iso-fs { --iso-fsbg: #eef3f9; } +html.iso-fs-lock, html.iso-fs-lock body { overflow: hidden !important; } +.iso-fs .iso-fs-grow, .iso-fs .iso-wrap, .iso-wrap.iso-fs { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; margin: 0; } +.iso-fs .iso-stage { flex: 1 1 auto; aspect-ratio: auto; max-height: none; min-height: 0; height: auto; } +.iso-fs .iso-note { display: none; } +.iso-fullbtn svg { display: block; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.iso-fullbtn .iso-fs-out, .iso-fs .iso-fullbtn .iso-fs-in { display: none; } +.iso-fs .iso-fullbtn .iso-fs-out { display: inline; } + +/* fill a sized container instead of the 16:10 card (Studio) */ +.iso-fill, .iso-fill > .iso-wrap { height: 100%; display: flex; flex-direction: column; } +.iso-fill .iso-stage { flex: 1 1 auto; aspect-ratio: auto; max-height: none; min-height: 0; border-radius: 0; border: 0; } diff --git a/viewer/isokit-view.js b/viewer/isokit-view.js new file mode 100644 index 0000000..0fa2c93 --- /dev/null +++ b/viewer/isokit-view.js @@ -0,0 +1,472 @@ +/* isokit-view.js: browser shell for isokit scenes (global IsoKit) */ +/* + * Plain ES2019, no dependencies. The Rust engine renders SVG; this file shows + * it: levels, four rotations, drag pan, pinch / wheel zoom, tag declutter, + * tooltips, selection, full screen. Port of the CNXIso / Mooban mount(). + * + * var view = IsoKit.mount(el, source, { onPick: function (info) {} }); + * source = IsoKit.staticSource(data) // prerendered views (isokit build) + * | IsoKit.httpSource("", meta) // the Studio / dev server + * source.meta = { title, note, levels: [{id, label, title}], defaultLevel, defaultRotation } + * source.render(level, rot, cfg) -> view data or a Promise of it: + * { html, info, bbox: [x, y, w, h], focus: [x, y], north, boxes, warnings } + * + * view.update(cfg) view.refresh() view.setLevel(id) view.setRotation(r) view.rotate(d) + * view.select(id) view.selectObj(id) view.focus(id | [x, y, z], zoom) view.fit() + * view.toWorld(clientX, clientY, z) -> [x, y] view.ready() -> Promise + * view.info(id) view.stats() view.fullscreen(on) view.destroy() + */ +(function (root) { + "use strict"; + + var VERSION = "0.1.0"; + var C = 4, CZ = 4; + + function esc(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function r1(v) { return Math.round(v * 10) / 10; } + function rotOf(r) { return ((r | 0) % 4 + 4) % 4; } + // view coordinates -> world plan (inverse of the engine's Proj.r) + function rInv(rot, vx, vy) { + switch (rot) { case 0: return [vx, -vy]; case 1: return [-vy, -vx]; case 2: return [-vx, vy]; default: return [vy, vx]; } + } + function rFwd(rot, x, y) { + switch (rot) { case 0: return [x, -y]; case 1: return [-y, -x]; case 2: return [-x, y]; default: return [y, x]; } + } + function project(rot, x, y, z) { var v = rFwd(rot, x, y); return [(v[0] - v[1]) * C, (v[0] + v[1]) * C / 2 - (z || 0) * CZ]; } + function unproject(rot, sx, sy, z) { + var a = sx / C, b = (sy + (z || 0) * CZ) * 2 / C; + return rInv(rot, (a + b) / 2, (b - a) / 2); + } + function isThen(x) { return x && typeof x.then === "function"; } + + // ------------------------------------------------------------ sources + function staticSource(data) { + return { + meta: data.meta, + render: function (level, rot) { + var v = data.views[level + "/" + rot]; + return v || { error: { message: "view " + level + "/" + rot + " was not built" } }; + } + }; + } + function httpSource(base, meta) { + base = base || ""; + return { + meta: meta, + dev: true, + render: function (level, rot, cfg) { + var q = "level=" + encodeURIComponent(level) + "&rot=" + rot + "&dev=1" + (cfg ? "&cfg=" + encodeURIComponent(JSON.stringify(cfg)) : ""); + return fetch(base + "/api/render?" + q, { cache: "no-store" }).then(function (r) { return r.json(); }); + } + }; + } + + // ------------------------------------------------------------ mount + var UID = 0; + function mount(el, source, opts) { + if (!el) throw new Error("IsoKit.mount: no container"); + opts = opts || {}; + var meta = source.meta || {}; + var levels = meta.levels && meta.levels.length ? meta.levels : [{ id: "site", label: "Site" }]; + var st = { + cfg: Object.assign({}, opts.cfg || {}), + level: opts.level || meta.defaultLevel || levels[0].id, + rot: rotOf(opts.rotation === undefined ? (meta.defaultRotation === undefined ? 1 : meta.defaultRotation) : opts.rotation), + sel: null, selObj: null, uid: ++UID + }; + var wrap = document.createElement("div"); + wrap.className = "iso-wrap"; + var segs = levels.length > 1 ? '' + levels.map(function (l) { + return '"; + }).join("") + "" : ""; + wrap.innerHTML = '
    ' + + '" + + '
    ' + + (opts.note === false || !meta.note ? "" : '

    ' + esc(meta.note) + "

    "); + el.appendChild(wrap); + var stage = wrap.querySelector(".iso-stage"), svg = wrap.querySelector(".iso-svg"), tip = wrap.querySelector(".iso-tip"), + bar = wrap.querySelector(".iso-bar"), errBox = wrap.querySelector(".iso-err"); + var scn = null, vb = null, fitW = 1, atFit = true, metas = [], declT = 0, alive = true, lastSize = [0, 0], seq = 0, waiting = []; + var touched = false, first = true; + + // ---------------------------------------------------------- full screen + var fsEl = opts.fullscreen && opts.fullscreen.nodeType === 1 ? opts.fullscreen : wrap, fsBtn = bar.querySelector('[data-a="full"]'); + function fsNative() { return (document.fullscreenElement || document.webkitFullscreenElement) === fsEl; } + function isFull() { return fsNative() || fsEl.classList.contains("iso-fs-fake"); } + function fsSync() { + var on = isFull(); + fsEl.classList.toggle("iso-fs", on); + if (fsBtn) { fsBtn.setAttribute("aria-pressed", String(on)); fsBtn.title = on ? "Leave full screen (Esc)" : "Full screen (F)"; } + call(opts.onFullscreen, on); + } + function fsFake(on) { fsEl.classList.toggle("iso-fs-fake", on); document.documentElement.classList.toggle("iso-fs-lock", on); fsSync(); } + function setFull(on) { + if (on === isFull()) return; + if (!on) { + if (fsEl.classList.contains("iso-fs-fake")) fsFake(false); + else { var ex = document.exitFullscreen || document.webkitExitFullscreen; if (ex) ex.call(document); } + return; + } + var req = fsEl.requestFullscreen || fsEl.webkitRequestFullscreen; + if (!req) { fsFake(true); return; } + try { var p = req.call(fsEl); if (p && p.catch) p.catch(function () { fsFake(true); }); } catch (e) { fsFake(true); } + } + function onFsKey(e) { if (e.key === "Escape" && fsEl.classList.contains("iso-fs-fake")) { fsFake(false); svg.focus(); } } + document.addEventListener("fullscreenchange", fsSync); + document.addEventListener("webkitfullscreenchange", fsSync); + document.addEventListener("keydown", onFsKey); + + function call(fn) { + if (typeof fn !== "function") return undefined; + try { return fn.apply(null, Array.prototype.slice.call(arguments, 1)); } catch (e) { if (window.console) console.error(e); } + return undefined; + } + + // ---------------------------------------------------------- build + function size() { var r = stage.getBoundingClientRect(); return [Math.max(1, r.width), Math.max(1, r.height)]; } + function build() { + var my = ++seq; + wrap.classList.add("iso-loading"); + var r; + try { r = source.render(st.level, st.rot, st.cfg); } catch (e) { r = { error: { message: String(e) } }; } + if (isThen(r)) { + r.then(function (d) { if (my === seq) applyScene(d); }, function (e) { if (my === seq) applyScene({ error: { message: String(e) } }); }); + } else applyScene(r); + } + function applyScene(d) { + if (!alive) return; + wrap.classList.remove("iso-loading"); + if (!d || d.error) { + var e = (d && d.error) || { message: "no data" }; + errBox.innerHTML = "Scene error" + esc((e.file ? e.file + (e.line ? ":" + e.line : "") + ": " : "") + e.message) + "" + + (e.trace && e.trace.length ? "" + e.trace.map(esc).join("
    ") + "
    " : ""); + errBox.hidden = false; + call(opts.onRender, d); + flush(); + return; + } + errBox.hidden = true; + var hadScene = !!scn; + scn = d; + svg.innerHTML = d.html; + wrap.classList.toggle("iso-still", st.cfg.running === false); + metas = Array.prototype.map.call(svg.querySelectorAll(".iso-tag"), function (g, i) { + return { el: g, p: +g.getAttribute("data-p"), w: +g.getAttribute("data-w"), x: +g.getAttribute("data-x"), y: +g.getAttribute("data-y"), i: i, hid: false }; + }).sort(function (a, b) { return b.p - a.p || a.i - b.i; }); + Array.prototype.forEach.call(bar.querySelectorAll(".iso-seg button"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-level") === st.level)); }); + wrap.querySelector(".iso-needle").setAttribute("transform", "rotate(" + (d.north || 0).toFixed(1) + ")"); + if (st.sel) select(st.sel, true); + if (st.selObj) selectObj(st.selObj); + if (first || !hadScene) { first = false; lastSize = size(); focusDefault(); } + else if (pendingFrame) { var f = pendingFrame; pendingFrame = null; f(); } + else { var wasFit = atFit; computeFit(); if (wasFit) fit(); else { clampVB(); apply(); } } + call(opts.onRender, d); + flush(); + } + var pendingFrame = null; + function flush() { var w = waiting; waiting = []; w.forEach(function (f) { f(); }); } + function computeFit() { + var b = scn.bbox, sz = size(), ar = sz[0] / sz[1], w = b[2] * 1.02, h = b[3] * 1.04; + if (w / h > ar) h = w / ar; else w = h * ar; + fitW = w; + return { x: b[0] + b[2] / 2 - w / 2, y: b[1] + b[3] / 2 - h / 2, w: w, h: h }; + } + function apply() { + if (!vb) return; + svg.setAttribute("viewBox", r1(vb.x) + " " + r1(vb.y) + " " + r1(vb.w) + " " + r1(vb.h)); + var sz = size(); + svg.style.setProperty("--iso-k", (vb.w / sz[0]).toFixed(4)); + atFit = vb.w >= fitW * 0.995; + svg.style.touchAction = atFit ? "pan-y" : "none"; + wrap.classList.toggle("iso-zoomed", !atFit); + schedule(); + } + function fit() { if (!scn) return; vb = computeFit(); apply(); touched = false; } + function clampVB() { + var b = scn.bbox, cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2; + cx = Math.max(b[0], Math.min(b[0] + b[2], cx)); cy = Math.max(b[1], Math.min(b[1] + b[3], cy)); + vb.x = cx - vb.w / 2; vb.y = cy - vb.h / 2; + } + function zoomAt(f, px, py) { + if (!vb) return; + touched = true; + var sz = size(), nw = Math.max(fitW / 20, Math.min(fitW, vb.w / f)), nh = nw * sz[1] / sz[0]; + if (px === undefined) { px = 0.5; py = 0.5; } + var ux = vb.x + px * vb.w, uy = vb.y + py * vb.h; + vb = { x: ux - px * nw, y: uy - py * nh, w: nw, h: nh }; + if (nw >= fitW * 0.999) vb = computeFit(); else clampVB(); + apply(); + } + function zoomClient(f, cx, cy) { var r = stage.getBoundingClientRect(); zoomAt(f, (cx - r.left) / r.width, (cy - r.top) / r.height); } + function panPx(dx, dy) { if (!vb) return; touched = true; var sz = size(), k = vb.w / sz[0]; vb.x -= dx * k; vb.y -= dy * k; clampVB(); apply(); } + function focusDefault() { + fit(); + var sz = size(); + if (sz[0] < 640 && scn && !isFull()) { + var nw = fitW / 2.4, nh = nw * sz[1] / sz[0]; + vb = { x: scn.focus[0] - nw / 2, y: scn.focus[1] - nh / 2, w: nw, h: nh }; + clampVB(); apply(); + } + touched = false; + } + function schedule() { + if (declT) return; + declT = (window.requestAnimationFrame || setTimeout)(function () { declT = 0; declutter(); }); + } + function declutter() { + if (!alive || !vb) return; + var sz = size(), k = vb.w / sz[0], kept = [], i, j, t, x, y, r, vis; + var bt = bar.offsetTop + bar.offsetHeight + 2, bw = bar.offsetLeft + bar.offsetWidth; + for (i = 0; i < metas.length; i++) { + t = metas[i]; x = (t.x - vb.x) / k; y = (t.y - vb.y) / k; + r = [x - t.w / 2 - 2, y - 23, x + t.w / 2 + 2, y - 4]; + vis = r[0] >= 2 && r[2] <= sz[0] - 2 && r[1] >= 2 && r[3] <= sz[1] - 2 && !(r[1] < bt && r[0] < bw); + for (j = 0; vis && j < kept.length; j++) if (!(r[2] < kept[j][0] || r[0] > kept[j][2] || r[3] < kept[j][1] || r[1] > kept[j][3])) vis = false; + if (vis) kept.push(r); + if (t.hid === vis) { t.hid = !vis; t.el.classList.toggle("iso-hide", !vis); } + } + } + + // ---------------------------------------------------------- picking + function infoOf(id) { return (scn && scn.info && scn.info[id]) || { id: id, name: id, detail: "" }; } + function showTip(info, cx, cy) { + var rows = info.rows && info.rows.length ? "" + info.rows.map(function (r) { return ""; }).join("") + "
    " + esc(r[0]) + "" + esc(r[1]) + "
    " : ""; + tip.innerHTML = "" + esc(info.name) + "" + (info.detail ? "" + esc(info.detail) + "" : "") + rows; + tip.hidden = false; + var r = stage.getBoundingClientRect(), tw = tip.offsetWidth, th = tip.offsetHeight, x = cx - r.left + 14, y = cy - r.top + 14; + if (x + tw > r.width - 6) x = Math.max(6, cx - r.left - tw - 14); + if (y + th > r.height - 6) y = Math.max(6, cy - r.top - th - 14); + tip.style.left = x + "px"; tip.style.top = y + "px"; + } + function hideTip() { tip.hidden = true; } + function q(attr, id) { return svg.querySelectorAll("[" + attr + '="' + String(id).replace(/["\\]/g, "") + '"]'); } + function select(id, quiet) { + Array.prototype.forEach.call(svg.querySelectorAll(".iso-sel"), function (e) { e.classList.remove("iso-sel"); }); + st.sel = id || null; + if (st.selObj) selectObj(st.selObj); + if (!id) return; + Array.prototype.forEach.call(q("data-id", id), function (e) { if (!e.classList.contains("iso-tag")) e.classList.add("iso-sel"); }); + } + function selectObj(id) { + Array.prototype.forEach.call(svg.querySelectorAll(".iso-osel"), function (e) { e.classList.remove("iso-osel"); }); + st.selObj = id || null; + if (id) Array.prototype.forEach.call(q("data-obj", id), function (e) { e.classList.add("iso-osel"); }); + } + function targetId(t) { var e = t && t.closest ? t.closest("[data-id]") : null; return e && svg.contains(e) ? e.getAttribute("data-id") : null; } + function targetObj(t) { var e = t && t.closest ? t.closest("[data-obj]") : null; return e && svg.contains(e) ? e.getAttribute("data-obj") : null; } + function targetSrc(t) { var e = t && t.closest ? t.closest("[data-src]") : null; return e && svg.contains(e) ? e.getAttribute("data-src") : null; } + function pick(t, cx, cy) { + var id = targetId(t), obj = targetObj(t); + if (!id && !obj) { select(null); hideTip(); call(opts.onPick, null); return; } + var info = infoOf(id || obj); + if (!opts.noSelect) select(id); + if (!opts.noTip) showTip(info, cx, cy); + call(opts.onPick, { id: info.id || id, name: info.name, detail: info.detail, rows: info.rows || [], obj: obj, src: targetSrc(t) }); + } + // client point -> world plan at height z + function toWorld(cx, cy, z) { + if (!vb) return null; + var r = stage.getBoundingClientRect(), sx = vb.x + (cx - r.left) / r.width * vb.w, sy = vb.y + (cy - r.top) / r.height * vb.h; + return unproject(st.rot, sx, sy, z || 0); + } + function screenPx() { var sz = size(); return vb ? vb.w / sz[0] : 1; } + + // ---------------------------------------------------------- pointer + var ptrs = {}, down = null, moved = false, pinch = null, hoverId = null, claimed = false; + function npt() { return Object.keys(ptrs).length; } + svg.addEventListener("pointerdown", function (e) { + ptrs[e.pointerId] = { x: e.clientX, y: e.clientY }; + if (npt() === 1) { + down = { x: e.clientX, y: e.clientY, id: e.pointerId, t: e.target }; moved = false; + claimed = !!(opts.interact && call(opts.interact.down, e, { obj: targetObj(e.target), id: targetId(e.target), world: toWorld(e.clientX, e.clientY, 0) })); + if (claimed) { try { svg.setPointerCapture(e.pointerId); } catch (er) { /* ignore */ } } + } + if (npt() === 2) { + if (claimed) { call(opts.interact.cancel); claimed = false; } + var k = Object.keys(ptrs), a = ptrs[k[0]], b = ptrs[k[1]]; + pinch = { d: Math.hypot(a.x - b.x, a.y - b.y) || 1 }; moved = true; + } + }); + svg.addEventListener("pointermove", function (e) { + if (opts.onHover) call(opts.onHover, toWorld(e.clientX, e.clientY, 0), e); + var p = ptrs[e.pointerId]; + if (p) { + var dx = e.clientX - p.x, dy = e.clientY - p.y; + p.x = e.clientX; p.y = e.clientY; + if (pinch && npt() >= 2) { + var k = Object.keys(ptrs), a = ptrs[k[0]], b = ptrs[k[1]], d = Math.hypot(a.x - b.x, a.y - b.y) || 1; + zoomClient(d / pinch.d, (a.x + b.x) / 2, (a.y + b.y) / 2); pinch.d = d; return; + } + if (down && !moved && Math.hypot(e.clientX - down.x, e.clientY - down.y) > 4) { + moved = true; hideTip(); wrap.classList.add("iso-dragging"); + if (!claimed) { try { svg.setPointerCapture(e.pointerId); } catch (er) { /* ignore */ } } + } + if (moved) { + if (claimed) call(opts.interact.move, e, { world: toWorld(e.clientX, e.clientY, 0), dx: e.clientX - down.x, dy: e.clientY - down.y, k: screenPx() }); + else panPx(dx, dy); + e.preventDefault(); + } + return; + } + if (e.pointerType === "mouse" && !opts.noTip) { + var id = targetId(e.target); + if (id) { + if (id !== hoverId) { if (hoverId) setHover(hoverId, false); setHover(id, true); hoverId = id; } + showTip(infoOf(id), e.clientX, e.clientY); + } else if (hoverId) { setHover(hoverId, false); hoverId = null; if (!st.sel) hideTip(); } + } + }); + function setHover(id, on) { + Array.prototype.forEach.call(q("data-id", id), function (e) { if (!e.classList.contains("iso-tag")) e.classList.toggle("iso-hov", on); }); + } + function up(e) { + var wasDown = down && down.id === e.pointerId; + delete ptrs[e.pointerId]; + if (npt() < 2) pinch = null; + if (wasDown) { + if (claimed) call(opts.interact.up, e, { moved: moved, world: toWorld(e.clientX, e.clientY, 0), cancel: e.type !== "pointerup" }); + else if (!moved && e.type === "pointerup") pick(e.target, e.clientX, e.clientY); + } + if (npt() === 0) { down = null; claimed = false; wrap.classList.remove("iso-dragging"); } + } + svg.addEventListener("pointerup", up); + svg.addEventListener("pointercancel", up); + svg.addEventListener("pointerleave", function (e) { if (e.pointerType === "mouse" && hoverId) { setHover(hoverId, false); hoverId = null; if (!st.sel) hideTip(); } }); + svg.addEventListener("wheel", function (e) { + if (!(e.ctrlKey || e.metaKey || opts.wheelZoom)) return; + e.preventDefault(); + zoomClient(Math.exp(-e.deltaY * 0.004), e.clientX, e.clientY); + }, { passive: false }); + svg.addEventListener("dblclick", function (e) { e.preventDefault(); zoomClient(1.8, e.clientX, e.clientY); }); + svg.addEventListener("keydown", function (e) { + if (opts.onKey && call(opts.onKey, e)) { e.preventDefault(); return; } + var k = e.key; + if (k === "+" || k === "=") zoomAt(1.4); else if (k === "-") zoomAt(1 / 1.4); else if (k === "0") fit(); + else if (k === "ArrowLeft") panPx(60, 0); else if (k === "ArrowRight") panPx(-60, 0); else if (k === "ArrowUp") panPx(0, 60); else if (k === "ArrowDown") panPx(0, -60); + else if (k === "Escape") { select(null); hideTip(); } + else if ((k === "f" || k === "F") && fsBtn && !e.ctrlKey && !e.metaKey && !e.altKey) setFull(!isFull()); else return; + e.preventDefault(); + }); + bar.addEventListener("click", function (e) { + var b = e.target.closest ? e.target.closest("button") : null; + if (!b) return; + var a = b.getAttribute("data-a"); + if (a === "level") setLevel(b.getAttribute("data-level")); + else if (a === "rotl") rotate(-1); else if (a === "rotr") rotate(1); + else if (a === "zin") zoomAt(1.5); else if (a === "zout") zoomAt(1 / 1.5); else if (a === "fit") fit(); + else if (a === "full") setFull(!isFull()); + }); + + function setLevel(l) { + if (!l || l === st.level || !levels.some(function (x) { return x.id === l; })) return; + st.level = l; hideTip(); build(); + call(opts.onLevel, l); + } + function rotate(d) { + var keepZoom = vb ? vb.w / fitW : 1; + st.rot = rotOf(st.rot + (d || 1)); hideTip(); + pendingFrame = function () { + if (keepZoom >= 0.995) { fit(); return; } + computeFit(); + var sz = size(), nw = fitW * keepZoom, nh = nw * sz[1] / sz[0], c = scn.focus; + if (st.selObj && scn.boxes && scn.boxes[st.selObj]) { var b = scn.boxes[st.selObj]; c = project(st.rot, (b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2); } + vb = { x: c[0] - nw / 2, y: c[1] - nh / 2, w: nw, h: nh }; clampVB(); apply(); + }; + build(); + call(opts.onRotate, st.rot); + } + var ro = null, io = null; + function onResize() { + if (!scn) return; + var sz = size(); + if (Math.abs(sz[0] - lastSize[0]) < 0.5 && Math.abs(sz[1] - lastSize[1]) < 0.5) return; + var old = lastSize; lastSize = sz; + if (!vb || !old[0] || !touched) { focusDefault(); return; } + var wasFit = atFit, k = vb.w / old[0], cx = vb.x + vb.w / 2, cy = vb.y + vb.h / 2; + computeFit(); + if (wasFit) { fit(); return; } + var nw = Math.min(fitW, k * sz[0]), nh = nw * sz[1] / sz[0]; + vb = { x: cx - nw / 2, y: cy - nh / 2, w: nw, h: nh }; clampVB(); apply(); + } + if (window.ResizeObserver) { ro = new ResizeObserver(onResize); ro.observe(stage); } + else window.addEventListener("resize", onResize); + if (window.IntersectionObserver) { + io = new IntersectionObserver(function (es) { es.forEach(function (en) { wrap.classList.toggle("iso-off", !en.isIntersecting); }); }); + io.observe(stage); + } + build(); + + var view = { + el: wrap, + svg: svg, + stage: stage, + source: source, + update: function (c) { st.cfg = Object.assign({}, st.cfg, c || {}); build(); return view; }, + refresh: function () { build(); return view; }, + setSource: function (s) { source = s; build(); return view; }, + setLevel: function (l) { setLevel(l); return view; }, + getLevel: function () { return st.level; }, + rotate: function (d) { rotate(d); return view; }, + setRotation: function (r) { var d = rotOf(r - st.rot); if (d) rotate(d); return view; }, + getRotation: function () { return st.rot; }, + select: function (id) { select(id); return view; }, + selectObj: function (id) { selectObj(id); return view; }, + // centre on an object id / data-id or a world point [x, y, z] at a zoom relative to fit + focus: function (target, f) { + if (!scn) return view; + var c = null; + if (Array.isArray(target)) c = project(st.rot, target[0], target[1], target[2] || 0); + else if (target && scn.boxes && scn.boxes[target]) { var b = scn.boxes[target]; c = project(st.rot, (b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2); } + else if (target) { + var bx = [1e9, 1e9, -1e9, -1e9]; + Array.prototype.forEach.call(q("data-id", target), function (e) { + if (e.classList.contains("iso-tag")) return; + try { var b = e.getBBox(); if (b.width || b.height) { bx[0] = Math.min(bx[0], b.x); bx[1] = Math.min(bx[1], b.y); bx[2] = Math.max(bx[2], b.x + b.width); bx[3] = Math.max(bx[3], b.y + b.height); } } catch (er) { /* not rendered */ } + }); + if (bx[0] < 1e9) c = [(bx[0] + bx[2]) / 2, (bx[1] + bx[3]) / 2]; + } + if (!c) return view; + computeFit(); + var sz = size(), nw = fitW / Math.max(1, f || 3), nh = nw * sz[1] / sz[0]; + vb = { x: c[0] - nw / 2, y: c[1] - nh / 2, w: nw, h: nh }; clampVB(); apply(); touched = true; + return view; + }, + zoom: function (f) { zoomAt(f || 1.5); return view; }, + fit: function () { fit(); return view; }, + fullscreen: function (on) { setFull(on === undefined ? !isFull() : !!on); return view; }, + isFullscreen: function () { return isFull(); }, + toWorld: toWorld, + screenOf: function (x, y, z) { return project(st.rot, x, y, z); }, + screenPx: screenPx, + scene: function () { return scn; }, + ready: function () { return new Promise(function (res) { if (!wrap.classList.contains("iso-loading") && scn) res(view); else waiting.push(function () { res(view); }); }); }, + info: function (id) { return id ? infoOf(id) : Object.keys((scn && scn.info) || {}).map(infoOf); }, + config: function () { return Object.assign({}, st.cfg); }, + stats: function () { return { nodes: svg.querySelectorAll("*").length, sprites: scn ? scn.sprites : 0, level: st.level, rotation: st.rot, ms: scn ? scn.ms : 0 }; }, + destroy: function () { + alive = false; + if (ro) ro.disconnect(); else window.removeEventListener("resize", onResize); + if (isFull()) setFull(false); + document.removeEventListener("fullscreenchange", fsSync); + document.removeEventListener("webkitfullscreenchange", fsSync); + document.removeEventListener("keydown", onFsKey); + if (io) io.disconnect(); + if (wrap.parentNode) wrap.parentNode.removeChild(wrap); + } + }; + return view; + } + + root.IsoKit = { mount: mount, staticSource: staticSource, httpSource: httpSource, project: project, unproject: unproject, version: VERSION }; +})(typeof window !== "undefined" ? window : this); diff --git a/viewer/page.html b/viewer/page.html new file mode 100644 index 0000000..3d2f4af --- /dev/null +++ b/viewer/page.html @@ -0,0 +1,66 @@ + + + + + +{{title}} + + + + + +

    {{title}}

    +
    +
    +
    Tap or click anything for its details.
    +
    Made with isokit · built {{date}}
    +
    + + + + +