isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs

This commit is contained in:
grabowski committed 2026-10-22 15:29:07 +07:00
1 parent 95c80ec6b1
commit 564aeb75f6
47 files changed
+5978 -366

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.png binary
*.jpg binary
+2 -1
View File
@@ -1,4 +1,5 @@
target/ target/
/dist/ dist/
*.log *.log
__pycache__/ __pycache__/
*.json.tmp
Generated
+202
View File
@@ -16,6 +16,68 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@@ -49,6 +111,58 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" 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]] [[package]]
name = "compact_str" name = "compact_str"
version = "0.10.0" version = "0.10.0"
@@ -148,6 +262,18 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.2" version = "2.14.2"
@@ -158,9 +284,22 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "isokit-cli" name = "isokit-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"isokit-project",
"serde_json",
"tiny_http",
]
[[package]] [[package]]
name = "isokit-core" name = "isokit-core"
@@ -169,6 +308,18 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "isokit-project"
version = "0.1.0"
dependencies = [
"isokit-core",
"isokit-script",
"isokit-theme",
"serde",
"serde_json",
"toml",
]
[[package]] [[package]]
name = "isokit-script" name = "isokit-script"
version = "0.1.0" version = "0.1.0"
@@ -209,6 +360,12 @@ version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@@ -233,6 +390,12 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -389,6 +552,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
@@ -426,6 +595,18 @@ dependencies = [
"crunchy", "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]] [[package]]
name = "toml" name = "toml"
version = "1.1.8+spec-1.1.0" 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@@ -547,6 +734,21 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.4" version = "1.0.4"
+2
View File
@@ -4,6 +4,7 @@ members = [
"crates/isokit-core", "crates/isokit-core",
"crates/isokit-script", "crates/isokit-script",
"crates/isokit-theme", "crates/isokit-theme",
"crates/isokit-project",
"crates/isokit-cli", "crates/isokit-cli",
] ]
@@ -18,6 +19,7 @@ repository = "https://git.b4l.co.th/B4L/isokit"
isokit-core = { path = "crates/isokit-core" } isokit-core = { path = "crates/isokit-core" }
isokit-script = { path = "crates/isokit-script" } isokit-script = { path = "crates/isokit-script" }
isokit-theme = { path = "crates/isokit-theme" } isokit-theme = { path = "crates/isokit-theme" }
isokit-project = { path = "crates/isokit-project" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = { version = "1", features = ["preserve_order"] } toml = { version = "1", features = ["preserve_order"] }
+136
View File
@@ -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 <project>` 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 <dir>` | 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 <shot-dir> 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`.
+9
View File
@@ -1,8 +1,17 @@
[package] [package]
name = "isokit-cli" name = "isokit-cli"
description = "isokit: isometric scene engine - Studio GUI, MCP server, build and export"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true
[[bin]] [[bin]]
name = "isokit" name = "isokit"
path = "src/main.rs" path = "src/main.rs"
[dependencies]
isokit-project = { workspace = true }
serde_json = { workspace = true }
tiny_http = { workspace = true }
clap = { workspace = true }
anyhow = { workspace = true }
+209 -1
View File
@@ -1 +1,209 @@
fn main() {} //! `isokit`: the command line.
//!
//! isokit new <dir> 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<String>,
},
/// 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<PathBuf>,
},
/// Render one view as a standalone SVG
Render {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(short, long)]
out: PathBuf,
#[arg(long)]
level: Option<String>,
#[arg(long)]
rot: Option<u8>,
/// 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<Project> {
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::<serde_json::Value>(&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(())
}
+356
View File
@@ -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#"<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>isokit preview</title>
<script>(function(){var t=null;try{t=localStorage.getItem("isokit-theme")}catch(e){}if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",t);})();</script>
<link rel="stylesheet" href="/assets/viewer/isokit-view.css"><link rel="stylesheet" href="/assets/theme.css">
<style>:root{color-scheme:dark}html[data-theme=light]{color-scheme:light}body{margin:0;background:#0a0f1a;font:14px system-ui,sans-serif;color:#d6deeb}
html[data-theme=light] body{background:#f4f6fa;color:#1b2533}main{max-width:1320px;margin:0 auto;padding:16px}</style></head>
<body><main><div id="view"></div></main><script src="/assets/viewer/isokit-view.js"></script><script>
fetch("/api/meta").then(function(r){return r.json()}).then(function(m){
var meta={title:m.title,note:m.note,levels:m.levels,defaultLevel:m.default_level,defaultRotation:m.default_rotation};
document.title=m.title+" · preview";
var view=IsoKit.mount(document.getElementById("view"),IsoKit.httpSource("",meta),{});
window.__isokit={view:view};
var v=null;(function poll(){fetch("/api/version").then(function(r){return r.json()}).then(function(r){if(v!==null&&r.version!==v)view.refresh();v=r.version;}).catch(function(){}).then(function(){setTimeout(poll,1000)});})();
});
</script></body></html>"#;
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<String, Value> {
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::<u8>().ok())
.unwrap_or(0);
let cfg = q
.get("cfg")
.and_then(|v| v.as_str())
.and_then(|s| serde_json::from_str::<Map<String, Value>>(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<Vec<Op>, String> = list
.iter()
.map(|v| serde_json::from_value::<Op>(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::<Op>(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<Value> = 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");
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ pub fn r1(out: &mut String, v: f64) {
} }
let a = t.unsigned_abs(); let a = t.unsigned_abs();
let _ = write!(out, "{}", a / 10); let _ = write!(out, "{}", a / 10);
if a % 10 != 0 { if !a.is_multiple_of(10) {
let _ = write!(out, ".{}", a % 10); let _ = write!(out, ".{}", a % 10);
} }
} }
@@ -65,7 +65,7 @@ pub fn thousands(n: f64) -> String {
let s = v.unsigned_abs().to_string(); let s = v.unsigned_abs().to_string();
let mut o = String::new(); let mut o = String::new();
for (i, ch) in s.chars().enumerate() { 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(',');
} }
o.push(ch); o.push(ch);
+89 -14
View File
@@ -46,7 +46,10 @@ pub struct Style {
impl Style { impl Style {
pub fn mat(mat: impl Into<String>) -> Self { pub fn mat(mat: impl Into<String>) -> Self {
Style { mat: mat.into(), ..Default::default() } Style {
mat: mat.into(),
..Default::default()
}
} }
pub fn id(mut self, id: impl Into<String>) -> Self { pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into()); self.id = Some(id.into());
@@ -75,44 +78,116 @@ impl Style {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Prim { pub enum Prim {
/// Vertical prism over a convex counter-clockwise footprint. /// Vertical prism over a convex counter-clockwise footprint.
Prism { pts: Vec<P2>, z0: Zs, z1: Zs, st: Style }, Prism {
pts: Vec<P2>,
z0: Zs,
z1: Zs,
st: Style,
},
/// Upright cylinder (two side faces and an elliptic top). /// 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. /// 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 /// Openings (windows, doors) on the vertical face through a -> b; the
/// outward normal is on the right of a -> b. Each entry is /// outward normal is on the right of a -> b. Each entry is
/// [u0, u1, z0, z1]: distance along the face and heights. /// [u0, u1, z0, z1]: distance along the face and heights.
Open { a: P2, b: P2, list: Vec<[f64; 4]>, cls: String, id: Option<String> }, Open {
a: P2,
b: P2,
list: Vec<[f64; 4]>,
cls: String,
id: Option<String>,
},
/// Flat polygon(s) at height z; later polygons cut holes (even-odd). /// Flat polygon(s) at height z; later polygons cut holes (even-odd).
Flat { polys: Vec<Vec<P2>>, z: f64, cls: String, id: Option<String> }, Flat {
polys: Vec<Vec<P2>>,
z: f64,
cls: String,
id: Option<String>,
},
/// Line segments [x0, y0, x1, y1] at height z. /// Line segments [x0, y0, x1, y1] at height z.
Lines { segs: Vec<[f64; 4]>, z: f64, cls: String, id: Option<String> }, Lines {
segs: Vec<[f64; 4]>,
z: f64,
cls: String,
id: Option<String>,
},
/// Flat ellipse (a circle on the ground plane) at height z. /// Flat ellipse (a circle on the ground plane) at height z.
Disc { c: P2, z: f64, r: f64, cls: String, id: Option<String> }, Disc {
c: P2,
z: f64,
r: f64,
cls: String,
id: Option<String>,
},
/// Planar polygon in 3D (roof slopes, gables, ramps), shaded by its /// Planar polygon in 3D (roof slopes, gables, ramps), shaded by its
/// normal: mostly upward = top, else left/centre/right by direction. /// normal: mostly upward = top, else left/centre/right by direction.
/// One-sided faces (counter-clockwise seen from outside) are culled /// One-sided faces (counter-clockwise seen from outside) are culled
/// when turned away; two-sided faces always draw. /// when turned away; two-sided faces always draw.
Face { pts: Vec<P3>, mat: String, id: Option<String>, two_sided: bool }, Face {
pts: Vec<P3>,
mat: String,
id: Option<String>,
two_sided: bool,
},
/// 3D polygons merged into one path with a fixed class (PV modules, /// 3D polygons merged into one path with a fixed class (PV modules,
/// signs, markings on slopes): no shading, no culling. /// signs, markings on slopes): no shading, no culling.
Poly { polys: Vec<Vec<P3>>, cls: String, id: Option<String> }, Poly {
polys: Vec<Vec<P3>>,
cls: String,
id: Option<String>,
},
/// Screen-space circle of radius r (screen units) at a world point, /// Screen-space circle of radius r (screen units) at a world point,
/// shifted by off (screen units): heads, lamps, LEDs, tree crowns. /// shifted by off (screen units): heads, lamps, LEDs, tree crowns.
Circle { at: P3, r: f64, off: P2, cls: String, id: Option<String> }, Circle {
at: P3,
r: f64,
off: P2,
cls: String,
id: Option<String>,
},
/// Screen-space vertical strip of half width w between two heights: /// Screen-space vertical strip of half width w between two heights:
/// trunks, thin poles. /// trunks, thin poles.
Stem { at: P2, z0: f64, z1: f64, w: f64, cls: String, id: Option<String> }, Stem {
at: P2,
z0: f64,
z1: f64,
w: f64,
cls: String,
id: Option<String>,
},
/// Rising steam or smoke puffs (CSS animation). /// 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). /// Spinning fan lying in the plane at height at.z (CSS animation).
Fan { at: P3, r: f64 }, Fan { at: P3, r: f64 },
/// Raw SVG translated to a world point (or untranslated without one). /// Raw SVG translated to a world point (or untranslated without one).
/// `{uid}` in the text is replaced by the view's unique id. /// `{uid}` in the text is replaced by the view's unique id.
Raw { at: Option<P3>, svg: String }, Raw { at: Option<P3>, svg: String },
/// `<g class style>` around nested primitives (animation, ghosting). /// `<g class style>` around nested primitives (animation, ghosting).
Group { cls: String, style: String, items: Vec<Prim> }, Group {
cls: String,
style: String,
items: Vec<Prim>,
},
/// Items drawn back to front by the depth of their plan anchor. /// Items drawn back to front by the depth of their plan anchor.
Seq { items: Vec<(P2, Vec<Prim>)> }, Seq { items: Vec<(P2, Vec<Prim>)> },
} }
+7 -2
View File
@@ -18,7 +18,9 @@ pub struct Proj {
impl Proj { impl Proj {
pub fn new(rot: i64) -> Self { 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. /// World plan point to view coordinates.
@@ -147,7 +149,10 @@ mod tests {
let p = Proj::new(rot); let p = Proj::new(rot);
let s = p.s(12.5, -3.25, 2.0); let s = p.s(12.5, -3.25, 2.0);
let w = p.unproject(s[0], s[1], 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:?}"
);
} }
} }
+159 -25
View File
@@ -24,7 +24,11 @@ pub struct RenderOpts {
impl Default for RenderOpts { impl Default for RenderOpts {
fn default() -> Self { 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 { if !st.no_side {
for i in 0..n { for i in 0..n {
let (a, b) = (pts[i], pts[(i + 1) % 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; let j = (i + 1) % n;
if z1.at(i) - z0.at(i) < 1e-6 && z1.at(j) - z0.at(j) < 1e-6 { if z1.at(i) - z0.at(i) < 1e-6 && z1.at(j) - z0.at(j) < 1e-6 {
continue; continue;
@@ -109,14 +115,23 @@ impl Ctx<'_> {
self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id); self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id);
p.d( p.d(
&mut self.out, &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, false,
); );
self.out.push_str("\"/>"); self.out.push_str("\"/>");
} }
} }
if !st.no_top { 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); self.path_open(&format!("{} iso-t", st.top_cls()), id);
p.d(&mut self.out, &tp, false); p.d(&mut self.out, &tp, false);
self.out.push_str("\"/>"); self.out.push_str("\"/>");
@@ -150,7 +165,10 @@ impl Ctx<'_> {
if !st.no_top { if !st.no_top {
let _ = write!(self.out, "<ellipse class=\"{} iso-t\"", st.top_cls()); let _ = write!(self.out, "<ellipse class=\"{} iso-t\"", st.top_cls());
did(&mut self.out, id); did(&mut self.out, id);
let _ = write!(self.out, " cx=\"{bx}\" cy=\"{ty}\" rx=\"{rxs}\" ry=\"{rys}\"/>"); let _ = write!(
self.out,
" cx=\"{bx}\" cy=\"{ty}\" rx=\"{rxs}\" ry=\"{rys}\"/>"
);
} }
} }
@@ -168,7 +186,9 @@ impl Ctx<'_> {
if st.no_side { if st.no_side {
continue; 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); self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id);
p.d( p.d(
&mut self.out, &mut self.out,
@@ -194,7 +214,9 @@ impl Ctx<'_> {
return; return;
} }
let p = self.p; 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 (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l = (dx * dx + dy * dy).sqrt().max(1e-9); let l = (dx * dx + dy * dy).sqrt().max(1e-9);
let (ux, uy) = (dx / l, dy / l); let (ux, uy) = (dx / l, dy / l);
@@ -262,7 +284,14 @@ impl Ctx<'_> {
let rx = r * C * SQ2; let rx = r * C * SQ2;
let _ = write!(self.out, "<ellipse class=\"{cls}\""); let _ = write!(self.out, "<ellipse class=\"{cls}\"");
did(&mut self.out, id); did(&mut self.out, id);
let _ = write!(self.out, " cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\"/>", 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 /// Planar polygon shaded by its Newell normal. The view direction in
@@ -296,7 +325,11 @@ impl Ctx<'_> {
nz = -nz; nz = -nz;
v = self.p.r(nx, ny); 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); self.path_open(&format!("{mat} {face}"), id);
let p = self.p; let p = self.p;
p.d(&mut self.out, pts, false); p.d(&mut self.out, pts, false);
@@ -307,12 +340,31 @@ impl Ctx<'_> {
match pr { match pr {
Prim::Prism { pts, z0, z1, st } => self.prism(pts, z0, z1, st), 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::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::Frust {
Prim::Open { a, b, list, cls, id } => self.open(*a, *b, list, cls, id.as_deref()), 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::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::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::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 } => { Prim::Poly { polys, cls, id } => {
if polys.is_empty() { if polys.is_empty() {
return; return;
@@ -324,7 +376,13 @@ impl Ctx<'_> {
} }
self.out.push_str("\"/>"); 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 s = self.p.s(at[0], at[1], at[2]);
let _ = write!(self.out, "<circle class=\"{cls}\""); let _ = write!(self.out, "<circle class=\"{cls}\"");
did(&mut self.out, id.as_deref()); did(&mut self.out, id.as_deref());
@@ -336,7 +394,14 @@ impl Ctx<'_> {
r1s(*r) 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 b = self.p.s(at[0], at[1], *z0);
let t = self.p.s(at[0], at[1], *z1); let t = self.p.s(at[0], at[1], *z1);
self.path_open(cls, id.as_deref()); self.path_open(cls, id.as_deref());
@@ -355,7 +420,12 @@ impl Ctx<'_> {
} }
Prim::Puffs { at, kind, n, dur } => { Prim::Puffs { at, kind, n, dur } => {
let s = self.p.s(at[0], at[1], at[2]); let s = self.p.s(at[0], at[1], at[2]);
let _ = write!(self.out, "<g class=\"iso-vent\" transform=\"translate({} {})\">", r1s(s[0]), r1s(s[1])); let _ = write!(
self.out,
"<g class=\"iso-vent\" transform=\"translate({} {})\">",
r1s(s[0]),
r1s(s[1])
);
let r = if kind == "iso-smoke" { let r = if kind == "iso-smoke" {
3.2 3.2
} else if kind == "iso-puff2" { } else if kind == "iso-puff2" {
@@ -396,7 +466,12 @@ impl Ctx<'_> {
match at { match at {
Some(at) => { Some(at) => {
let s = self.p.s(at[0], at[1], at[2]); let s = self.p.s(at[0], at[1], at[2]);
let _ = write!(self.out, "<g transform=\"translate({} {})\">{svg}</g>", r1s(s[0]), r1s(s[1])); let _ = write!(
self.out,
"<g transform=\"translate({} {})\">{svg}</g>",
r1s(s[0]),
r1s(s[1])
);
} }
None => self.out.push_str(&svg), 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 { pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
let p = Proj::new(rot as i64); let p = Proj::new(rot as i64);
let uid = opts.uid.to_string(); 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 frame = [1e9f64, 1e9, -1e9, -1e9];
let mut sprites = 0; let mut sprites = 0;
let mut cycles = 0; let mut cycles = 0;
@@ -494,7 +573,12 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
} else { } else {
(0..live.len()).collect() (0..live.len()).collect()
}; };
let _ = write!(cx.out, "<g class=\"{}\" data-layer=\"{}\">", esc(&layer.opts.cls), esc(&layer.name)); let _ = write!(
cx.out,
"<g class=\"{}\" data-layer=\"{}\">",
esc(&layer.opts.cls),
esc(&layer.name)
);
for i in idx { for i in idx {
let s = live[i]; let s = live[i];
sprites += 1; 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]; frame = [-100.0, -100.0, 100.0, 100.0];
warnings.push("the scene is empty".into()); 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() { let focus = match scene.focus_point() {
Some(f) => p.s(f[0], f[1], f[2]), Some(f) => p.s(f[0], f[1], f[2]),
None => [bbox[0] + bbox[2] / 2.0, bbox[1] + bbox[3] / 2.0], None => [bbox[0] + bbox[2] / 2.0, bbox[1] + bbox[3] / 2.0],
@@ -627,7 +717,12 @@ mod tests {
let sc = one_box(); let sc = one_box();
for rot in 0..4 { for rot in 0..4 {
let r = render(&sc, rot, &RenderOpts::default()); 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-t"));
assert!(r.html.contains("iso-wall iso-l")); assert!(r.html.contains("iso-wall iso-l"));
assert!(r.html.contains("iso-wall iso-r")); 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 // JS (iso-mooban.js prismS, rotation 0) for box(0,0,10,6,0,4): the top face
let sc = one_box(); let sc = one_box();
let r = render(&sc, 0, &RenderOpts::default()); let r = render(&sc, 0, &RenderOpts::default());
assert!(r.html.contains("<path class=\"iso-wall iso-t\" d=\"M0 -16L40 4L64 -8L24 -28Z\"/>"), "{}", r.html); assert!(
r.html
.contains("<path class=\"iso-wall iso-t\" d=\"M0 -16L40 4L64 -8L24 -28Z\"/>"),
"{}",
r.html
);
} }
#[test] #[test]
fn labels_off_hides_plain_tags_only() { fn labels_off_hides_plain_tags_only() {
let mut sc = one_box(); let mut sc = one_box();
sc.tag([0.0, 0.0, 0.0], "Gate", 9.0, "gate", "iso-signtag"); 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(">Gate<"));
assert!(!r.html.contains(">Hall<")); assert!(!r.html.contains(">Hall<"));
} }
@@ -683,9 +790,29 @@ mod tests {
let mut sc = Scene::new(); let mut sc = Scene::new();
let mut s = Sprite::new("roof", ""); let mut s = Sprite::new("roof", "");
// south-facing roof slope rising to the north (ccw seen from above/outside) // 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) // 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); sc.add_sprite("main", s);
let r0 = render(&sc, 0, &RenderOpts::default()); // from the south-east: north wall hidden let r0 = render(&sc, 0, &RenderOpts::default()); // from the south-east: north wall hidden
assert!(r0.html.contains("iso-roof iso-t")); assert!(r0.html.contains("iso-roof iso-t"));
@@ -698,7 +825,14 @@ mod tests {
fn dev_mode_adds_source_and_order() { fn dev_mode_adds_source_and_order() {
let mut sc = one_box(); let mut sc = one_box();
sc.layers[2].sprites[0].src = Some("scene.rhai:3".into()); 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!(r.html.contains("data-src=\"scene.rhai:3\""));
assert_eq!(r.order.len(), 1); assert_eq!(r.order.len(), 1);
} }
+76 -13
View File
@@ -17,7 +17,11 @@ pub struct LayerOpts {
impl Default for LayerOpts { impl Default for LayerOpts {
fn default() -> Self { 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 { pub fn new() -> Self {
Scene { Scene {
layers: vec![ layers: vec![
Layer { name: "ground".into(), opts: LayerOpts { sort: false, zfirst: false, cls: "iso-ground".into() }, sprites: vec![] }, Layer {
Layer { name: "floor".into(), opts: LayerOpts::default(), sprites: vec![] }, name: "ground".into(),
Layer { name: "main".into(), opts: LayerOpts::default(), sprites: vec![] }, 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![], tags: vec![],
info: BTreeMap::new(), info: BTreeMap::new(),
@@ -101,8 +121,17 @@ impl Scene {
self.layers[i].opts = opts; self.layers[i].opts = opts;
return i; return i;
} }
let at = before.and_then(|b| self.layer_index(b)).unwrap_or(self.layers.len()); let at = before
self.layers.insert(at, Layer { name: name.into(), opts, sprites: vec![] }); .and_then(|b| self.layer_index(b))
.unwrap_or(self.layers.len());
self.layers.insert(
at,
Layer {
name: name.into(),
opts,
sprites: vec![],
},
);
at at
} }
@@ -119,13 +148,39 @@ impl Scene {
self.layer_mut(layer).sprites.push(s); self.layer_mut(layer).sprites.push(s);
} }
pub fn tag(&mut self, at: P3, text: impl Into<String>, prio: f64, id: impl Into<String>, kind: impl Into<String>) { pub fn tag(
self.tags.push(Tag { at, text: text.into(), prio, id: id.into(), kind: kind.into() }); &mut self,
at: P3,
text: impl Into<String>,
prio: f64,
id: impl Into<String>,
kind: impl Into<String>,
) {
self.tags.push(Tag {
at,
text: text.into(),
prio,
id: id.into(),
kind: kind.into(),
});
} }
pub fn info(&mut self, id: impl Into<String>, name: impl Into<String>, detail: impl Into<String>) { pub fn info(
&mut self,
id: impl Into<String>,
name: impl Into<String>,
detail: impl Into<String>,
) {
let id = id.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 { pub fn sprite_count(&self) -> usize {
@@ -137,7 +192,9 @@ impl Scene {
use crate::prim::Prim; use crate::prim::Prim;
fn walk(p: &Prim, out: &mut std::collections::BTreeSet<String>) { fn walk(p: &Prim, out: &mut std::collections::BTreeSet<String>) {
let id = match p { 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::Open { id, .. }
| Prim::Flat { id, .. } | Prim::Flat { id, .. }
| Prim::Lines { id, .. } | Prim::Lines { id, .. }
@@ -151,7 +208,10 @@ impl Scene {
None None
} }
Prim::Seq { items } => { 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
} }
_ => None, _ => None,
@@ -174,7 +234,10 @@ impl Scene {
/// The default focus point: the explicit one, else the bounds centre. /// The default focus point: the explicit one, else the bounds centre.
pub fn focus_point(&self) -> Option<P3> { pub fn focus_point(&self) -> Option<P3> {
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])
})
} }
} }
+21 -4
View File
@@ -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 { for _ in 0..n {
let mut best: Option<usize> = None; let mut best: Option<usize> = None;
for i in 0..n { for i in 0..n {
@@ -102,7 +105,9 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
} }
best = match best { best = match best {
None => Some(i), 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, keep => keep,
}; };
} }
@@ -128,14 +133,26 @@ mod tests {
#[test] #[test]
fn separated_on_x_draws_far_first() { 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.order, vec![1, 0]);
assert_eq!(o.cycles, 0); assert_eq!(o.cycles, 0);
} }
#[test] #[test]
fn stacked_boxes_draw_bottom_first() { 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]); assert_eq!(o.order, vec![1, 0]);
} }
+200 -32
View File
@@ -63,7 +63,12 @@ impl Xf {
pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> [f64; 4] { pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> [f64; 4] {
let a = self.p(x0, y0); let a = self.p(x0, y0);
let b = self.p(x1, y1); 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<P2> { pub fn pts(&self, pts: &[P2]) -> Vec<P2> {
pts.iter().map(|q| self.p(q[0], q[1])).collect() pts.iter().map(|q| self.p(q[0], q[1])).collect()
@@ -76,8 +81,15 @@ impl Xf {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Frame { enum Frame {
Group { cls: String, style: String, items: Vec<Prim> }, Group {
Seq { items: Vec<(P2, Vec<Prim>)>, cur: Option<(P2, Vec<Prim>)> }, cls: String,
style: String,
items: Vec<Prim>,
},
Seq {
items: Vec<(P2, Vec<Prim>)>,
cur: Option<(P2, Vec<Prim>)>,
},
} }
#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -101,6 +113,9 @@ pub struct Sprite {
frames: Vec<Frame>, frames: Vec<Frame>,
} }
/// One item of [`Sprite::seq`]: a plan anchor and the drawing.
pub type SeqItem<'a> = (P2, Box<dyn FnOnce(&mut Sprite) + 'a>);
/// Points of an axis-aligned rectangle, counter-clockwise from (x0, y0). /// Points of an axis-aligned rectangle, counter-clockwise from (x0, y0).
pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<P2> { pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<P2> {
vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]] 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<P2> {
impl Sprite { impl Sprite {
pub fn new(id: impl Into<String>, cls: impl Into<String>) -> Self { pub fn new(id: impl Into<String>, cls: impl Into<String>) -> 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 { 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 { 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] { 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 self
} }
@@ -156,13 +178,27 @@ impl Sprite {
/// Axis-aligned box (`box` in scripts; `box` is a Rust keyword). /// Axis-aligned box (`box` in scripts; `box` is a Rust keyword).
#[allow(clippy::too_many_arguments)] #[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 r = self.xf.rect(x0, y0, x1, y1);
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1)); let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
let pts = rect(r[0], r[1], r[2], r[3]); let pts = rect(r[0], r[1], r[2], r[3]);
self.ext_world(&pts, z0); self.ext_world(&pts, z0);
self.ext_world(&pts, z1); 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<P2>, z0: Zs, z1: Zs, st: Style) -> &mut Self { pub fn prism(&mut self, pts: Vec<P2>, z0: Zs, z1: Zs, st: Style) -> &mut Self {
@@ -188,30 +224,73 @@ impl Sprite {
} }
#[allow(clippy::too_many_arguments)] #[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); let r = r0.max(r1);
self.ext_box(cx - r, cy - r, cx + r, cy + r, z0, z1); self.ext_box(cx - r, cy - r, cx + r, cy + r, z0, z1);
let c = self.xf.p(cx, cy); let c = self.xf.p(cx, cy);
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1)); 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 /// Sloped slab (stair flight, ramp) over x0..x1, rising from y0 (za) to
/// y1 (zb), th thick. /// y1 (zb), th thick.
#[allow(clippy::too_many_arguments)] #[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 zt = vec![za, za, zb, zb];
let zb_: Vec<f64> = zt.iter().map(|z| z - th).collect(); let zb_: Vec<f64> = 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 // ---------------------------------------------------------- surfaces
/// Openings on the vertical face a -> b (outward normal on the right). /// 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<String>) -> &mut Self { pub fn open(
&mut self,
a: P2,
b: P2,
list: Vec<[f64; 4]>,
cls: &str,
id: Option<String>,
) -> &mut Self {
let (a, b) = (self.xf.p(a[0], a[1]), self.xf.p(b[0], b[1])); let (a, b) = (self.xf.p(a[0], a[1]), self.xf.p(b[0], b[1]));
let dz = self.xf.z; 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 (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l = (dx * dx + dy * dy).sqrt().max(1e-9); let l = (dx * dx + dy * dy).sqrt().max(1e-9);
let (ux, uy) = (dx / l, dy / l); 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[0], a[1] + uy * q[0], q[2]]);
self.ext.push([a[0] + ux * q[1], a[1] + uy * q[1], q[3]]); 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<Vec<P2>>, z: f64, cls: &str, id: Option<String>) -> &mut Self { pub fn flat(
&mut self,
polys: Vec<Vec<P2>>,
z: f64,
cls: &str,
id: Option<String>,
) -> &mut Self {
let polys: Vec<Vec<P2>> = polys.iter().map(|p| self.xf.pts(p)).collect(); let polys: Vec<Vec<P2>> = polys.iter().map(|p| self.xf.pts(p)).collect();
let z = self.xf.z(z); let z = self.xf.z(z);
for p in &polys { for p in &polys {
self.ext_world(p, z); 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 { 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[0], s[1], z]);
self.ext.push([s[2], s[3], 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<String>) -> &mut Self { pub fn disc(
&mut self,
cx: f64,
cy: f64,
z: f64,
r: f64,
cls: &str,
id: Option<String>,
) -> &mut Self {
self.ext_box(cx - r, cy - r, cx + r, cy + r, z, z); self.ext_box(cx - r, cy - r, cx + r, cy + r, z, z);
let c = self.xf.p(cx, cy); let c = self.xf.p(cx, cy);
let z = self.xf.z(z); 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`]). /// Planar 3D polygon shaded by its normal (see [`Prim::Face`]).
pub fn face(&mut self, pts: Vec<P3>, mat: &str, id: Option<String>, two_sided: bool) -> &mut Self { pub fn face(
&mut self,
pts: Vec<P3>,
mat: &str,
id: Option<String>,
two_sided: bool,
) -> &mut Self {
let pts: Vec<P3> = pts.into_iter().map(|p| self.xf.p3(p)).collect(); let pts: Vec<P3> = pts.into_iter().map(|p| self.xf.p3(p)).collect();
self.ext.extend(pts.iter().copied()); 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. /// 3D polygons as one path with a fixed class.
pub fn poly(&mut self, polys: Vec<Vec<P3>>, cls: &str, id: Option<String>) -> &mut Self { pub fn poly(&mut self, polys: Vec<Vec<P3>>, cls: &str, id: Option<String>) -> &mut Self {
let polys: Vec<Vec<P3>> = polys.into_iter().map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect()).collect(); let polys: Vec<Vec<P3>> = polys
.into_iter()
.map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect())
.collect();
for p in &polys { for p in &polys {
self.ext.extend(p.iter().copied()); 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 // ---------------------------------------------------------- screen-space details
@@ -277,14 +410,27 @@ impl Sprite {
/// sticks out of the sprite). /// sticks out of the sprite).
pub fn circle(&mut self, at: P3, r: f64, off: P2, cls: &str) -> &mut Self { pub fn circle(&mut self, at: P3, r: f64, off: P2, cls: &str) -> &mut Self {
let at = self.xf.p3(at); 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 { 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); self.ext(x, y, z0).ext(x, y, z1);
let at = self.xf.p(x, y); let at = self.xf.p(x, y);
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1)); 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. /// 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 { 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) 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 { 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]); 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 { 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<P3>, svg: impl Into<String>) -> &mut Self { pub fn raw(&mut self, at: Option<P3>, svg: impl Into<String>) -> &mut Self {
let at = at.map(|a| self.xf.p3(a)); 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 // ---------------------------------------------------------- structure
/// Open a `<g class style>`; close it with [`Sprite::end_group`]. /// Open a `<g class style>`; close it with [`Sprite::end_group`].
pub fn begin_group(&mut self, cls: &str, style: &str) -> &mut Self { 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 self
} }
@@ -344,7 +506,10 @@ impl Sprite {
/// Start a back-to-front sequence; each [`Sprite::seq_item`] starts an /// Start a back-to-front sequence; each [`Sprite::seq_item`] starts an
/// item anchored at a plan point; [`Sprite::end_seq`] closes it. /// item anchored at a plan point; [`Sprite::end_seq`] closes it.
pub fn begin_seq(&mut self) -> &mut Self { 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 self
} }
@@ -370,7 +535,7 @@ impl Sprite {
} }
/// Draw items back to front by the depth of their anchor. /// Draw items back to front by the depth of their anchor.
pub fn seq(&mut self, items: Vec<(P2, Box<dyn FnOnce(&mut Sprite) + '_>)>) -> &mut Self { pub fn seq(&mut self, items: Vec<SeqItem<'_>>) -> &mut Self {
self.begin_seq(); self.begin_seq();
for (at, f) in items { for (at, f) in items {
self.seq_item(at); self.seq_item(at);
@@ -442,7 +607,10 @@ mod tests {
let f = Xf::new(3.0, -4.0, 0.0, deg); let f = Xf::new(3.0, -4.0, 0.0, deg);
let w = f.p(1.5, 2.5); let w = f.p(1.5, 2.5);
let l = f.inv(w[0], w[1]); 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}"
);
} }
} }
+14
View File
@@ -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 }
+113
View File
@@ -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<Cow<'static, str>> {
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_<type> / meta_<type>), 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.
"#;
+144
View File
@@ -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<String>,
}
fn f(level: &str, message: String, object: Option<String>) -> 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<Finding> {
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
}
+470
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<f64>,
}
fn bool_kind() -> String {
"bool".into()
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct BuildOpts {
#[serde(default)]
pub out: Option<String>,
/// 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<String>,
/// Scene document; default `scene.json`.
#[serde(default)]
pub doc: Option<String>,
#[serde(default)]
pub prelude: Vec<String>,
#[serde(default)]
pub theme: Vec<String>,
#[serde(default, rename = "level")]
pub levels: Vec<LevelDef>,
#[serde(default)]
pub default_level: Option<String>,
#[serde(default = "rot1")]
pub default_rotation: u8,
#[serde(default, rename = "param")]
pub params: Vec<ParamDef>,
#[serde(default)]
pub build: BuildOpts,
}
impl Manifest {
pub fn levels(&self) -> Vec<LevelDef> {
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<ScriptError> 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<String, Info>,
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<String>,
/// Object id -> world box [x0, y0, z0, x1, y1, z1].
pub boxes: BTreeMap<String, [f64; 6]>,
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<LevelDef>,
pub default_level: String,
pub default_rotation: u8,
pub params: Vec<ParamDef>,
pub catalog: Vec<PrefabMeta>,
pub materials: Vec<String>,
pub c: f64,
pub cz: f64,
}
fn rd(path: &Path) -> Result<String, Error> {
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<BTreeMap<String, String>, 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<Runtime, ScriptError>,
}
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<Path>) -> Result<Project, Error> {
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<String, Value>>) -> Map<String, Value> {
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<String> {
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<String, Value>>,
dev: bool,
) -> Result<View, ScriptError> {
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(())
}
+360
View File
@@ -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<Value> {
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<String>) -> Value {
json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true })
}
fn boxes(ws: &Workspace) -> Map<String, Value> {
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<String, Value>) -> Option<Value> {
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<String, Value>, k: &str) -> Option<String> {
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<String, Value>) -> 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::<Vec<_>>(),
"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<Value> = 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::<Vec<_>>() })
})
.collect();
text(json!(list))
}
"list_objects" => {
let bx = boxes(ws);
let t = str_arg(a, "type");
let list: Vec<Value> = 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::<Op>(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<String> = 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<Vec<Op>, String> = ops
.into_iter()
.enumerate()
.map(|(i, v)| serde_json::from_value::<Op>(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::<Vec<_>>() }))
}
}
}
"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::<Map<_, _>>(),
})),
}
}
"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<String> {
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<Value> {
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 }))
}
+440
View File
@@ -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<String, Value>,
#[serde(default)]
name: String,
#[serde(default)]
info: String,
#[serde(default)]
tag: String,
#[serde(default)]
levels: Vec<String>,
#[serde(default)]
pts: Option<Vec<[f64; 2]>>,
/// Wanted id (made unique); default from the type.
#[serde(default)]
id: Option<String>,
},
/// Change fields of an object; `props` are merged (null removes a key).
Update {
id: String,
patch: Map<String, Value>,
},
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<String, Value>,
},
/// 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<String>,
}
fn finite(v: f64, what: &str) -> Result<f64, String> {
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<String, Value>) -> 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<String, Value>,
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<Outcome, String> {
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<PrefabMeta> {
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));
}
}
+88
View File
@@ -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 `<script type="application/json">`.
fn script_json(v: &Value) -> String {
v.to_string().replace("</", "<\\/")
}
/// The static page: all levels in all four rotations, no server needed.
pub fn static_page(p: &Project) -> Result<String, ScriptError> {
let meta = p.meta();
let mut views = serde_json::Map::new();
for l in &meta.levels {
for rot in 0..4u8 {
let v = p.render(&l.id, rot, None, false)?;
views.insert(
format!("{}/{rot}", l.id),
json!({ "html": v.html, "info": v.info, "bbox": v.bbox, "focus": v.focus, "north": v.north, "boxes": v.boxes }),
);
}
}
let data = json!({
"meta": {
"title": meta.title, "note": meta.note, "levels": meta.levels,
"defaultLevel": meta.default_level, "defaultRotation": meta.default_rotation
},
"views": views,
"cfg": p.cfg(None),
});
let css = format!(
"{}\n{}",
assets::get("viewer/isokit-view.css").unwrap_or_default(),
p.theme.css()
);
let page = assets::get("viewer/page.html").unwrap_or_default();
Ok(page
.replace("{{title}}", &esc(&meta.title))
.replace("{{date}}", &today())
.replace("/*ISOKIT_CSS*/", &css)
.replace("/*ISOKIT_DATA*/", &script_json(&data))
.replace(
"/*ISOKIT_JS*/",
&assets::get("viewer/isokit-view.js").unwrap_or_default(),
))
}
/// One view as a standalone SVG (dark or light scheme, styles inlined).
pub fn svg(p: &Project, level: &str, rot: u8, light: bool) -> Result<String, ScriptError> {
let v = p.render(level, rot, None, false)?;
let css = format!(
"{}\n{}",
assets::get("viewer/isokit-view.css").unwrap_or_default(),
p.theme.css_scoped("svg.iso-svg", Some(light))
);
let b = v.bbox;
let bg = if light { "#e8f4ff" } else { "#0b1428" };
Ok(format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"iso-svg\" viewBox=\"{} {} {} {}\" width=\"{}\" height=\"{}\">\
<style>{}</style><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{bg}\"/>{}</svg>\n",
b[0], b[1], b[2], b[3], (b[2] * 2.0).round(), (b[3] * 2.0).round(), css, b[0], b[1], b[2], b[3], v.html
))
}
/// Today's date (UTC) as YYYY-MM-DD, without a date crate.
pub fn today() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
// civil-from-days (H. Hinnant)
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d)
}
+141
View File
@@ -0,0 +1,141 @@
//! A project kept loaded across edits: reload on file changes, save after
//! every operation, undo and redo. The Studio server and the MCP server
//! each hold one.
use crate::ops::{self, Op, Outcome};
use crate::{version, Error, Project};
use isokit_script::doc::Doc;
use std::path::{Path, PathBuf};
pub struct Workspace {
pub dir: PathBuf,
pub project: Project,
/// Fingerprint of the files the loaded project was read from.
pub version: u64,
/// Error of the last reload attempt (the previous project stays loaded).
pub load_error: Option<String>,
undo: Vec<Doc>,
redo: Vec<Doc>,
}
const UNDO_DEPTH: usize = 200;
impl Workspace {
pub fn open(dir: impl AsRef<Path>) -> Result<Workspace, Error> {
let dir = dir.as_ref().to_path_buf();
let project = Project::load(&dir)?;
let version = version(&dir);
Ok(Workspace {
dir,
project,
version,
load_error: None,
undo: vec![],
redo: vec![],
})
}
/// Reload when a project file changed on disk (another editor, an agent,
/// a git pull). Returns true when something was reloaded.
pub fn refresh(&mut self) -> bool {
let v = version(&self.dir);
if v == self.version {
return false;
}
self.version = v;
match Project::load(&self.dir) {
Ok(p) => {
if p.doc != self.project.doc {
self.push_undo(self.project.doc.clone());
}
self.project = p;
self.load_error = None;
}
Err(e) => self.load_error = Some(e.to_string()),
}
true
}
fn push_undo(&mut self, d: Doc) {
self.undo.push(d);
if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0);
}
self.redo.clear();
}
fn save(&mut self) -> Result<(), String> {
self.project.save_doc().map_err(|e| e.to_string())?;
self.version = version(&self.dir);
Ok(())
}
/// Apply an operation to the document and save it.
pub fn apply(&mut self, op: Op) -> Result<Outcome, String> {
self.refresh();
let before = self.project.doc.clone();
let catalog = self.project.catalog().to_vec();
let out = ops::apply(&mut self.project.doc, &catalog, op)?;
if self.project.doc != before {
self.push_undo(before);
self.save()?;
}
Ok(out)
}
/// Apply several operations as one undo step; stops at the first error
/// (earlier ones stay applied and saved).
pub fn apply_all(&mut self, list: Vec<Op>) -> Result<Vec<Outcome>, String> {
self.refresh();
let before = self.project.doc.clone();
let catalog = self.project.catalog().to_vec();
let mut outs = vec![];
let mut err = None;
for op in list {
match ops::apply(&mut self.project.doc, &catalog, op) {
Ok(o) => outs.push(o),
Err(e) => {
err = Some(e);
break;
}
}
}
if self.project.doc != before {
self.push_undo(before);
self.save()?;
}
match err {
Some(e) => Err(format!("after {} operation(s): {e}", outs.len())),
None => Ok(outs),
}
}
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn undo(&mut self) -> Result<bool, String> {
self.refresh();
let Some(prev) = self.undo.pop() else {
return Ok(false);
};
let cur = std::mem::replace(&mut self.project.doc, prev);
self.redo.push(cur);
self.save()?;
Ok(true)
}
pub fn redo(&mut self) -> Result<bool, String> {
self.refresh();
let Some(next) = self.redo.pop() else {
return Ok(false);
};
let cur = std::mem::replace(&mut self.project.doc, next);
self.undo.push(cur);
self.save()?;
Ok(true)
}
}
+267
View File
@@ -0,0 +1,267 @@
//! Integration tests on the demo project: load, render every view, export,
//! check, document operations through a Workspace, MCP round trips.
use isokit_project::mcp;
use isokit_project::ops::Op;
use isokit_project::workspace::Workspace;
use isokit_project::{check, page, Project};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
fn demo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo")
}
/// A throwaway copy of the demo (tests edit it).
fn copy_demo(name: &str) -> PathBuf {
let dst = std::env::temp_dir().join(format!("isokit-test-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dst);
fn cp(a: &Path, b: &Path) {
std::fs::create_dir_all(b).unwrap();
for e in std::fs::read_dir(a).unwrap().flatten() {
let p = e.path();
let n = e.file_name();
if p.is_dir() {
if n != "dist" {
cp(&p, &b.join(n));
}
} else {
std::fs::copy(&p, b.join(n)).unwrap();
}
}
}
cp(&demo(), &dst);
dst
}
#[test]
fn demo_renders_every_view_cleanly() {
let p = Project::load(demo()).unwrap();
assert!(p.runtime.is_ok(), "{:?}", p.runtime.as_ref().err());
for l in p.manifest.levels() {
for rot in 0..4 {
let v = p.render(&l.id, rot, None, false).unwrap();
assert!(v.warnings.is_empty(), "{}/{rot}: {:?}", l.id, v.warnings);
assert!(!v.html.contains("NaN"));
assert!(
v.nodes < p.manifest.build.budget,
"{}/{rot}: {} nodes",
l.id,
v.nodes
);
for o in &p.doc.objects {
assert!(
v.boxes.contains_key(&o.id),
"{} not drawn in {}/{rot}",
o.id,
l.id
);
assert!(v.info.contains_key(&o.id), "{} has no tooltip", o.id);
}
// the script's sign and the project prefab are there
assert!(v.html.contains("data-id=\"sign\""));
assert!(v.html.contains("data-obj=\"kiosk\""));
}
}
}
#[test]
fn demo_passes_the_check() {
let p = Project::load(demo()).unwrap();
let f = check::check(&p);
assert!(f.is_empty(), "{f:?}");
}
#[test]
fn static_page_and_svg_export() {
let p = Project::load(demo()).unwrap();
let html = page::static_page(&p).unwrap();
assert!(
html.contains("IsoKit.mount") && html.contains("\"site/1\"") && html.contains("\"gf/3\"")
);
assert!(
!html.contains("/*ISOKIT_"),
"a placeholder was left in the page"
);
assert!(
html.contains(".iso-brick { --b: var(--iso-brick); }"),
"project theme merged"
);
let svg = page::svg(&p, "site", 1, true).unwrap();
assert!(
svg.starts_with("<svg") && svg.contains("<style>") && svg.contains("iso-wall")
|| svg.contains("iso-render")
);
}
#[test]
fn workspace_edits_save_and_undo() {
let dir = copy_demo("ws");
let mut ws = Workspace::open(&dir).unwrap();
let n = ws.project.doc.objects.len();
let id = ws
.apply(
serde_json::from_value::<Op>(json!({"op": "add", "type": "tree", "x": 1, "y": 2}))
.unwrap(),
)
.unwrap()
.id
.unwrap();
// saved to disk
let on_disk = Project::load(&dir).unwrap();
assert_eq!(on_disk.doc.objects.len(), n + 1);
assert!(on_disk.doc.get(&id).is_some());
ws.undo().unwrap();
assert_eq!(Project::load(&dir).unwrap().doc.objects.len(), n);
ws.redo().unwrap();
assert_eq!(ws.project.doc.objects.len(), n + 1);
// an outside edit (another process) is picked up and can be undone
let mut d = on_disk.doc.clone();
d.objects.retain(|o| o.kind != "lamp");
std::fs::write(
dir.join("scene.json"),
serde_json::to_string_pretty(&d).unwrap(),
)
.unwrap();
assert!(ws.refresh());
assert!(ws.project.doc.objects.iter().all(|o| o.kind != "lamp"));
ws.undo().unwrap();
assert!(ws.project.doc.objects.iter().any(|o| o.kind == "lamp"));
let _ = std::fs::remove_dir_all(&dir);
}
fn call(ws: &mut Workspace, id: u64, name: &str, args: Value) -> (bool, Value) {
let r = mcp::handle(ws, &json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}})).unwrap();
let res = &r["result"];
let text = res["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string();
(
res["isError"].as_bool().unwrap_or(true),
serde_json::from_str(&text).unwrap_or(Value::String(text)),
)
}
#[test]
fn mcp_round_trip() {
let dir = copy_demo("mcp");
let mut ws = Workspace::open(&dir).unwrap();
let init = mcp::handle(&mut ws, &json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).unwrap();
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert!(mcp::handle(
&mut ws,
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
)
.is_none());
let list = mcp::handle(
&mut ws,
&json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
)
.unwrap();
let names: Vec<&str> = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
for t in [
"project_info",
"list_prefabs",
"add_object",
"update_object",
"render_view",
"check_project",
"build_html",
] {
assert!(names.contains(&t), "{t}");
}
let (err, info) = call(&mut ws, 3, "project_info", json!({}));
assert!(!err && info["objects"].as_u64().unwrap() > 10);
let (err, pf) = call(&mut ws, 4, "list_prefabs", json!({"type": "building"}));
assert!(
!err && pf["props"]
.as_array()
.unwrap()
.iter()
.any(|p| p["key"] == "floors")
);
let (err, added) = call(
&mut ws,
5,
"add_object",
json!({"type": "building", "x": 20, "y": 15, "props": {"w": 8, "d": 6, "floors": 1}, "name": "Store"}),
);
assert!(!err, "{added}");
let id = added["id"].as_str().unwrap().to_string();
let b = added["object"]["box"].as_array().unwrap();
assert_eq!(
(b[0].as_f64().unwrap(), b[3].as_f64().unwrap()),
(16.0, 24.0)
);
let (err, upd) = call(
&mut ws,
6,
"update_object",
json!({"id": id, "rot": 90, "props": {"roof": "gable"}}),
);
assert!(!err, "{upd}");
assert_eq!(upd["object"]["rot"], 90.0);
let (err, bad) = call(
&mut ws,
7,
"update_object",
json!({"id": id, "props": {"floors": 99}}),
);
assert!(err && bad.as_str().unwrap().contains("outside"), "{bad}");
let (err, ops) = call(
&mut ws,
8,
"apply_ops",
json!({"ops": [
{"op": "add", "type": "tree", "x": 0, "y": 18},
{"op": "add", "type": "tree", "x": 4, "y": 18},
{"op": "remove", "id": "tree-1"}
]}),
);
assert!(!err, "{ops}");
let (err, view) = call(
&mut ws,
9,
"render_view",
json!({"level": "gf", "rotation": 2}),
);
assert!(!err && view["boxes"].get(&id).is_some(), "{view}");
let (err, svg) = call(&mut ws, 10, "render_view", json!({"format": "svg"}));
assert!(!err && svg.as_str().unwrap().starts_with("<svg"));
let (err, chk) = call(&mut ws, 11, "check_project", json!({}));
assert!(!err && chk["ok"] == true, "{chk}");
let (err, built) = call(&mut ws, 12, "build_html", json!({}));
assert!(!err && Path::new(built["path"].as_str().unwrap()).exists());
let (err, _) = call(&mut ws, 13, "undo", json!({}));
assert!(!err);
assert!(
ws.project.doc.get("tree-1").is_some(),
"undo brings back the batch"
);
let unknown = mcp::handle(
&mut ws,
&json!({"jsonrpc": "2.0", "id": 14, "method": "nope"}),
)
.unwrap();
assert_eq!(unknown["error"]["code"], -32601);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scaffolded_project_works() {
let dir = std::env::temp_dir().join(format!("isokit-test-new-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
isokit_project::scaffold(&dir, "New site").unwrap();
let p = Project::load(&dir).unwrap();
assert_eq!(p.meta().title, "New site");
let f = check::check(&p);
assert!(f.is_empty(), "{f:?}");
assert!(isokit_project::scaffold(&dir, "again").is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+485 -186
View File
@@ -27,9 +27,17 @@ fn opt_id(d: &str) -> Option<String> {
} }
} }
pub(crate) fn new_sprite(st: &Shared, ctx: Option<&NativeCallContext>, id: Option<&str>, cls: &str) -> SpriteH { pub(crate) fn new_sprite(
st: &Shared,
ctx: Option<&NativeCallContext>,
id: Option<&str>,
cls: &str,
) -> SpriteH {
let mut s = st.borrow_mut(); let mut s = st.borrow_mut();
let id = id.map(str::to_string).or_else(|| s.obj.clone()).unwrap_or_default(); let id = id
.map(str::to_string)
.or_else(|| s.obj.clone())
.unwrap_or_default();
let mut sp = Sprite::new(id, classes(cls)).with_frame(s.xf); let mut sp = Sprite::new(id, classes(cls)).with_frame(s.xf);
sp.obj = s.obj.clone(); sp.obj = s.obj.clone();
if let Some(ctx) = ctx { if let Some(ctx) = ctx {
@@ -50,210 +58,432 @@ pub fn register_sprite(e: &mut Engine) {
e.register_get("id", |s: &mut SpriteH| s.0.borrow().id.clone()); e.register_get("id", |s: &mut SpriteH| s.0.borrow().id.clone());
// solids // solids
e.register_fn("box", |s: &mut SpriteH, x0: D, y0: D, x1: D, y1: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().cuboid(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?, n(&z0)?, n(&z1)?, style(mat, None));
ok(s)
});
e.register_fn( e.register_fn(
"box", "box",
|s: &mut SpriteH, x0: D, y0: D, x1: D, y1: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> { |s: &mut SpriteH, x0: D, y0: D, x1: D, y1: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().cuboid(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?, n(&z0)?, n(&z1)?, style(mat, Some(&o))); s.0.borrow_mut().cuboid(
n(&x0)?,
n(&y0)?,
n(&x1)?,
n(&y1)?,
n(&z0)?,
n(&z1)?,
style(mat, None),
);
ok(s)
},
);
e.register_fn(
"box",
|s: &mut SpriteH,
x0: D,
y0: D,
x1: D,
y1: D,
z0: D,
z1: D,
mat: &str,
o: Map|
-> RR<SpriteH> {
s.0.borrow_mut().cuboid(
n(&x0)?,
n(&y0)?,
n(&x1)?,
n(&y1)?,
n(&z0)?,
n(&z1)?,
style(mat, Some(&o)),
);
ok(s)
},
);
e.register_fn(
"prism",
|s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, None));
ok(s)
},
);
e.register_fn(
"prism",
|s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut()
.prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, Some(&o)));
ok(s)
},
);
e.register_fn(
"cyl",
|s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.cyl(n(&cx)?, n(&cy)?, n(&r)?, n(&z0)?, n(&z1)?, style(mat, None));
ok(s)
},
);
e.register_fn(
"cyl",
|s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().cyl(
n(&cx)?,
n(&cy)?,
n(&r)?,
n(&z0)?,
n(&z1)?,
style(mat, Some(&o)),
);
ok(s) ok(s)
}, },
); );
e.register_fn("prism", |s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, None));
ok(s)
});
e.register_fn("prism", |s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, Some(&o)));
ok(s)
});
e.register_fn("cyl", |s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().cyl(n(&cx)?, n(&cy)?, n(&r)?, n(&z0)?, n(&z1)?, style(mat, None));
ok(s)
});
e.register_fn("cyl", |s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().cyl(n(&cx)?, n(&cy)?, n(&r)?, n(&z0)?, n(&z1)?, style(mat, Some(&o)));
ok(s)
});
e.register_fn( e.register_fn(
"frust", "frust",
|s: &mut SpriteH, cx: D, cy: D, r0: D, r1: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> { |s: &mut SpriteH, cx: D, cy: D, r0: D, r1: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().frust(n(&cx)?, n(&cy)?, n(&r0)?, n(&r1)?, n(&z0)?, n(&z1)?, 10, style(mat, None)); s.0.borrow_mut().frust(
n(&cx)?,
n(&cy)?,
n(&r0)?,
n(&r1)?,
n(&z0)?,
n(&z1)?,
10,
style(mat, None),
);
ok(s) ok(s)
}, },
); );
e.register_fn( e.register_fn(
"frust", "frust",
|s: &mut SpriteH, cx: D, cy: D, r0: D, r1: D, z0: D, z1: D, mat: &str, sides: D| -> RR<SpriteH> { |s: &mut SpriteH,
cx: D,
cy: D,
r0: D,
r1: D,
z0: D,
z1: D,
mat: &str,
sides: D|
-> RR<SpriteH> {
let k = n(&sides)? as u32; let k = n(&sides)? as u32;
s.0.borrow_mut().frust(n(&cx)?, n(&cy)?, n(&r0)?, n(&r1)?, n(&z0)?, n(&z1)?, k, style(mat, None)); s.0.borrow_mut().frust(
n(&cx)?,
n(&cy)?,
n(&r0)?,
n(&r1)?,
n(&z0)?,
n(&z1)?,
k,
style(mat, None),
);
ok(s) ok(s)
}, },
); );
e.register_fn( e.register_fn(
"frust", "frust",
|s: &mut SpriteH, cx: D, cy: D, r0: D, r1: D, z0: D, z1: D, mat: &str, sides: D, o: Map| -> RR<SpriteH> { |s: &mut SpriteH,
cx: D,
cy: D,
r0: D,
r1: D,
z0: D,
z1: D,
mat: &str,
sides: D,
o: Map|
-> RR<SpriteH> {
let k = n(&sides)? as u32; let k = n(&sides)? as u32;
s.0.borrow_mut().frust(n(&cx)?, n(&cy)?, n(&r0)?, n(&r1)?, n(&z0)?, n(&z1)?, k, style(mat, Some(&o))); s.0.borrow_mut().frust(
n(&cx)?,
n(&cy)?,
n(&r0)?,
n(&r1)?,
n(&z0)?,
n(&z1)?,
k,
style(mat, Some(&o)),
);
ok(s) ok(s)
}, },
); );
e.register_fn( e.register_fn(
"slope", "slope",
|s: &mut SpriteH, x0: D, x1: D, y0: D, y1: D, za: D, zb: D, th: D, mat: &str| -> RR<SpriteH> { |s: &mut SpriteH,
s.0.borrow_mut().slope(n(&x0)?, n(&x1)?, n(&y0)?, n(&y1)?, n(&za)?, n(&zb)?, n(&th)?, &classes(mat)); x0: D,
x1: D,
y0: D,
y1: D,
za: D,
zb: D,
th: D,
mat: &str|
-> RR<SpriteH> {
s.0.borrow_mut().slope(
n(&x0)?,
n(&x1)?,
n(&y0)?,
n(&y1)?,
n(&za)?,
n(&zb)?,
n(&th)?,
&classes(mat),
);
ok(s) ok(s)
}, },
); );
// surfaces // surfaces
e.register_fn("open", |s: &mut SpriteH, a: D, b: D, list: D, cls: &str| -> RR<SpriteH> { e.register_fn(
s.0.borrow_mut().open(p2(&a)?, p2(&b)?, quads(&list, "openings")?, &classes(cls), None); "open",
ok(s) |s: &mut SpriteH, a: D, b: D, list: D, cls: &str| -> RR<SpriteH> {
}); s.0.borrow_mut().open(
e.register_fn("open", |s: &mut SpriteH, a: D, b: D, list: D, cls: &str, id: &str| -> RR<SpriteH> { p2(&a)?,
s.0.borrow_mut().open(p2(&a)?, p2(&b)?, quads(&list, "openings")?, &classes(cls), opt_id(id)); p2(&b)?,
ok(s) quads(&list, "openings")?,
}); &classes(cls),
e.register_fn("flat", |s: &mut SpriteH, p: D, z: D, cls: &str| -> RR<SpriteH> { None,
s.0.borrow_mut().flat(polys(&p)?, n(&z)?, &classes(cls), None); );
ok(s) ok(s)
}); },
e.register_fn("flat", |s: &mut SpriteH, p: D, z: D, cls: &str, id: &str| -> RR<SpriteH> { );
s.0.borrow_mut().flat(polys(&p)?, n(&z)?, &classes(cls), opt_id(id)); e.register_fn(
ok(s) "open",
}); |s: &mut SpriteH, a: D, b: D, list: D, cls: &str, id: &str| -> RR<SpriteH> {
e.register_fn("lines", |s: &mut SpriteH, segs: D, z: D, cls: &str| -> RR<SpriteH> { s.0.borrow_mut().open(
s.0.borrow_mut().lines(quads(&segs, "segments")?, n(&z)?, &classes(cls)); p2(&a)?,
ok(s) p2(&b)?,
}); quads(&list, "openings")?,
e.register_fn("disc", |s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str| -> RR<SpriteH> { &classes(cls),
s.0.borrow_mut().disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), None); opt_id(id),
ok(s) );
}); ok(s)
e.register_fn("disc", |s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str, id: &str| -> RR<SpriteH> { },
s.0.borrow_mut().disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), opt_id(id)); );
ok(s) e.register_fn(
}); "flat",
|s: &mut SpriteH, p: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.flat(polys(&p)?, n(&z)?, &classes(cls), None);
ok(s)
},
);
e.register_fn(
"flat",
|s: &mut SpriteH, p: D, z: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.flat(polys(&p)?, n(&z)?, &classes(cls), opt_id(id));
ok(s)
},
);
e.register_fn(
"lines",
|s: &mut SpriteH, segs: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.lines(quads(&segs, "segments")?, n(&z)?, &classes(cls));
ok(s)
},
);
e.register_fn(
"disc",
|s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), None);
ok(s)
},
);
e.register_fn(
"disc",
|s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), opt_id(id));
ok(s)
},
);
e.register_fn("face", |s: &mut SpriteH, p: D, mat: &str| -> RR<SpriteH> { e.register_fn("face", |s: &mut SpriteH, p: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().face(pts3(&p)?, &classes(mat), None, false); s.0.borrow_mut().face(pts3(&p)?, &classes(mat), None, false);
ok(s) ok(s)
}); });
e.register_fn("face", |s: &mut SpriteH, p: D, mat: &str, o: Map| -> RR<SpriteH> { e.register_fn(
let id = o.get("id").map(|v| v.to_string()); "face",
let two = o.get("two_sided").and_then(|v| v.as_bool().ok()).unwrap_or(false); |s: &mut SpriteH, p: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().face(pts3(&p)?, &classes(mat), id, two); let id = o.get("id").map(|v| v.to_string());
ok(s) let two = o
}); .get("two_sided")
.and_then(|v| v.as_bool().ok())
.unwrap_or(false);
s.0.borrow_mut().face(pts3(&p)?, &classes(mat), id, two);
ok(s)
},
);
e.register_fn("poly", |s: &mut SpriteH, p: D, cls: &str| -> RR<SpriteH> { e.register_fn("poly", |s: &mut SpriteH, p: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().poly(polys3(&p)?, &classes(cls), None); s.0.borrow_mut().poly(polys3(&p)?, &classes(cls), None);
ok(s) ok(s)
}); });
e.register_fn("poly", |s: &mut SpriteH, p: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut().poly(polys3(&p)?, &classes(cls), opt_id(id));
ok(s)
});
// screen-space details
e.register_fn("circle", |s: &mut SpriteH, x: D, y: D, z: D, r: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().circle([n(&x)?, n(&y)?, n(&z)?], n(&r)?, [0.0, 0.0], &classes(cls));
ok(s)
});
e.register_fn( e.register_fn(
"circle", "poly",
|s: &mut SpriteH, x: D, y: D, z: D, r: D, cls: &str, dx: D, dy: D| -> RR<SpriteH> { |s: &mut SpriteH, p: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut().circle([n(&x)?, n(&y)?, n(&z)?], n(&r)?, [n(&dx)?, n(&dy)?], &classes(cls)); s.0.borrow_mut()
.poly(polys3(&p)?, &classes(cls), opt_id(id));
ok(s)
},
);
// screen-space details
e.register_fn(
"circle",
|s: &mut SpriteH, x: D, y: D, z: D, r: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.circle([n(&x)?, n(&y)?, n(&z)?], n(&r)?, [0.0, 0.0], &classes(cls));
ok(s)
},
);
e.register_fn(
"circle",
|s: &mut SpriteH, x: D, y: D, z: D, r: D, cls: &str, dx: D, dy: D| -> RR<SpriteH> {
s.0.borrow_mut().circle(
[n(&x)?, n(&y)?, n(&z)?],
n(&r)?,
[n(&dx)?, n(&dy)?],
&classes(cls),
);
ok(s)
},
);
e.register_fn(
"stem",
|s: &mut SpriteH, x: D, y: D, z0: D, z1: D, w: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.stem(n(&x)?, n(&y)?, n(&z0)?, n(&z1)?, n(&w)?, &classes(cls));
ok(s)
},
);
e.register_fn(
"head",
|s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
s.0.borrow_mut().head(n(&x)?, n(&y)?, n(&z)?, n(&r)?);
ok(s)
},
);
e.register_fn(
"person",
|s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().person(n(&x)?, n(&y)?, n(&z)?, "iso-hivis");
ok(s)
},
);
e.register_fn(
"person",
|s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.person(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
},
);
e.register_fn(
"sit",
|s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().sit(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s) ok(s)
}, },
); );
e.register_fn("stem", |s: &mut SpriteH, x: D, y: D, z0: D, z1: D, w: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().stem(n(&x)?, n(&y)?, n(&z0)?, n(&z1)?, n(&w)?, &classes(cls));
ok(s)
});
e.register_fn("head", |s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
s.0.borrow_mut().head(n(&x)?, n(&y)?, n(&z)?, n(&r)?);
ok(s)
});
e.register_fn("person", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().person(n(&x)?, n(&y)?, n(&z)?, "iso-hivis");
ok(s)
});
e.register_fn("person", |s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().person(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("sit", |s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().sit(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("led", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> { e.register_fn("led", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().led(n(&x)?, n(&y)?, n(&z)?, ""); s.0.borrow_mut().led(n(&x)?, n(&y)?, n(&z)?, "");
ok(s) ok(s)
}); });
e.register_fn("led", |s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().led(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("puffs", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().puffs(n(&x)?, n(&y)?, n(&z)?, "iso-puff", 3, 3.2);
ok(s)
});
e.register_fn("puffs", |s: &mut SpriteH, x: D, y: D, z: D, kind: &str| -> RR<SpriteH> {
s.0.borrow_mut().puffs(n(&x)?, n(&y)?, n(&z)?, &classes(kind), 3, 3.2);
ok(s)
});
e.register_fn( e.register_fn(
"puffs", "led",
|s: &mut SpriteH, x: D, y: D, z: D, kind: &str, k: D, dur: D| -> RR<SpriteH> { |s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().puffs(n(&x)?, n(&y)?, n(&z)?, &classes(kind), n(&k)? as u32, n(&dur)?); s.0.borrow_mut().led(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
},
);
e.register_fn(
"puffs",
|s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut()
.puffs(n(&x)?, n(&y)?, n(&z)?, "iso-puff", 3, 3.2);
ok(s)
},
);
e.register_fn(
"puffs",
|s: &mut SpriteH, x: D, y: D, z: D, kind: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.puffs(n(&x)?, n(&y)?, n(&z)?, &classes(kind), 3, 3.2);
ok(s)
},
);
e.register_fn(
"puffs",
|s: &mut SpriteH, x: D, y: D, z: D, kind: &str, k: D, dur: D| -> RR<SpriteH> {
s.0.borrow_mut().puffs(
n(&x)?,
n(&y)?,
n(&z)?,
&classes(kind),
n(&k)? as u32,
n(&dur)?,
);
ok(s)
},
);
e.register_fn(
"fan",
|s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
s.0.borrow_mut().fan(n(&x)?, n(&y)?, n(&z)?, n(&r)?);
ok(s) ok(s)
}, },
); );
e.register_fn("fan", |s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
s.0.borrow_mut().fan(n(&x)?, n(&y)?, n(&z)?, n(&r)?);
ok(s)
});
e.register_fn("raw", |s: &mut SpriteH, svg: &str| -> RR<SpriteH> { e.register_fn("raw", |s: &mut SpriteH, svg: &str| -> RR<SpriteH> {
s.0.borrow_mut().raw(None, svg); s.0.borrow_mut().raw(None, svg);
ok(s) ok(s)
}); });
e.register_fn("raw", |s: &mut SpriteH, x: D, y: D, z: D, svg: &str| -> RR<SpriteH> { e.register_fn(
s.0.borrow_mut().raw(Some([n(&x)?, n(&y)?, n(&z)?]), svg); "raw",
ok(s) |s: &mut SpriteH, x: D, y: D, z: D, svg: &str| -> RR<SpriteH> {
}); s.0.borrow_mut().raw(Some([n(&x)?, n(&y)?, n(&z)?]), svg);
ok(s)
},
);
// extents // extents
e.register_fn("ext", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> { e.register_fn("ext", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().ext(n(&x)?, n(&y)?, n(&z)?); s.0.borrow_mut().ext(n(&x)?, n(&y)?, n(&z)?);
ok(s) ok(s)
}); });
e.register_fn("ext_box", |s: &mut SpriteH, x0: D, y0: D, x1: D, y1: D, z0: D, z1: D| -> RR<SpriteH> { e.register_fn(
s.0.borrow_mut().ext_box(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?, n(&z0)?, n(&z1)?); "ext_box",
ok(s) |s: &mut SpriteH, x0: D, y0: D, x1: D, y1: D, z0: D, z1: D| -> RR<SpriteH> {
}); s.0.borrow_mut()
.ext_box(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?, n(&z0)?, n(&z1)?);
ok(s)
},
);
// structure // structure
e.register_fn("begin", |s: &mut SpriteH, cls: &str| -> SpriteH { e.register_fn("begin", |s: &mut SpriteH, cls: &str| -> SpriteH {
s.0.borrow_mut().begin_group(&classes(cls), ""); s.0.borrow_mut().begin_group(&classes(cls), "");
s.clone() s.clone()
}); });
e.register_fn("begin", |s: &mut SpriteH, cls: &str, css: &str| -> SpriteH { e.register_fn(
s.0.borrow_mut().begin_group(&classes(cls), css); "begin",
s.clone() |s: &mut SpriteH, cls: &str, css: &str| -> SpriteH {
}); s.0.borrow_mut().begin_group(&classes(cls), css);
s.clone()
},
);
e.register_fn("end", |s: &mut SpriteH| -> SpriteH { e.register_fn("end", |s: &mut SpriteH| -> SpriteH {
s.0.borrow_mut().end_group(); s.0.borrow_mut().end_group();
s.clone() s.clone()
}); });
e.register_fn("group", |ctx: NativeCallContext, s: &mut SpriteH, cls: &str, f: FnPtr| -> RR<SpriteH> { e.register_fn(
s.0.borrow_mut().begin_group(&classes(cls), ""); "group",
let r = f.call_within_context::<Dynamic>(&ctx, (s.clone(),)); |ctx: NativeCallContext, s: &mut SpriteH, cls: &str, f: FnPtr| -> RR<SpriteH> {
s.0.borrow_mut().end_group(); s.0.borrow_mut().begin_group(&classes(cls), "");
let _ = r?; let r = f.call_within_context::<Dynamic>(&ctx, (s.clone(),));
ok(s) s.0.borrow_mut().end_group();
}); let _ = r?;
ok(s)
},
);
e.register_fn( e.register_fn(
"group", "group",
|ctx: NativeCallContext, s: &mut SpriteH, cls: &str, css: &str, f: FnPtr| -> RR<SpriteH> { |ctx: NativeCallContext, s: &mut SpriteH, cls: &str, css: &str, f: FnPtr| -> RR<SpriteH> {
@@ -265,36 +495,51 @@ pub fn register_sprite(e: &mut Engine) {
}, },
); );
// items: [[x, y, |s| { ... }], ...] drawn back to front by the anchor's depth // items: [[x, y, |s| { ... }], ...] drawn back to front by the anchor's depth
e.register_fn("seq", |ctx: NativeCallContext, s: &mut SpriteH, items: Array| -> RR<SpriteH> { e.register_fn(
s.0.borrow_mut().begin_seq(); "seq",
for it in items { |ctx: NativeCallContext, s: &mut SpriteH, items: Array| -> RR<SpriteH> {
let a = it.into_array().map_err(|t| format!("seq: expected [x, y, fn], got {t}"))?; s.0.borrow_mut().begin_seq();
if a.len() < 3 { for it in items {
s.0.borrow_mut().end_seq(); let a = it
return err("seq: expected [x, y, fn]"); .into_array()
.map_err(|t| format!("seq: expected [x, y, fn], got {t}"))?;
if a.len() < 3 {
s.0.borrow_mut().end_seq();
return err("seq: expected [x, y, fn]");
}
let f = a[2]
.clone()
.try_cast::<FnPtr>()
.ok_or("seq: the third entry must be a function")?;
s.0.borrow_mut().seq_item([n(&a[0])?, n(&a[1])?]);
if let Err(e) = f.call_within_context::<Dynamic>(&ctx, (s.clone(),)) {
s.0.borrow_mut().end_seq();
return Err(e);
}
} }
let f = a[2].clone().try_cast::<FnPtr>().ok_or("seq: the third entry must be a function")?; s.0.borrow_mut().end_seq();
s.0.borrow_mut().seq_item([n(&a[0])?, n(&a[1])?]); ok(s)
if let Err(e) = f.call_within_context::<Dynamic>(&ctx, (s.clone(),)) { },
s.0.borrow_mut().end_seq(); );
return Err(e);
}
}
s.0.borrow_mut().end_seq();
ok(s)
});
} }
/// Scene-level functions (they read and write the shared build state). /// Scene-level functions (they read and write the shared build state).
pub(crate) fn register_scene(e: &mut Engine, st: &Shared) { pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
let s = st.clone(); let s = st.clone();
e.register_fn("sprite", move |ctx: NativeCallContext| new_sprite(&s, Some(&ctx), None, "")); e.register_fn("sprite", move |ctx: NativeCallContext| {
let s = st.clone(); new_sprite(&s, Some(&ctx), None, "")
e.register_fn("sprite", move |ctx: NativeCallContext, id: &str| new_sprite(&s, Some(&ctx), Some(id), ""));
let s = st.clone();
e.register_fn("sprite", move |ctx: NativeCallContext, id: &str, cls: &str| {
new_sprite(&s, Some(&ctx), Some(id), cls)
}); });
let s = st.clone();
e.register_fn("sprite", move |ctx: NativeCallContext, id: &str| {
new_sprite(&s, Some(&ctx), Some(id), "")
});
let s = st.clone();
e.register_fn(
"sprite",
move |ctx: NativeCallContext, id: &str, cls: &str| {
new_sprite(&s, Some(&ctx), Some(id), cls)
},
);
// the shared ground sprite (unsorted layer "ground", no pick id) // the shared ground sprite (unsorted layer "ground", no pick id)
let s = st.clone(); let s = st.clone();
e.register_fn("ground", move || { e.register_fn("ground", move || {
@@ -325,8 +570,14 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
let mut b = s.borrow_mut(); let mut b = s.borrow_mut();
let opts = LayerOpts { let opts = LayerOpts {
sort: o.get("sort").and_then(|v| v.as_bool().ok()).unwrap_or(true), sort: o.get("sort").and_then(|v| v.as_bool().ok()).unwrap_or(true),
zfirst: o.get("zfirst").and_then(|v| v.as_bool().ok()).unwrap_or(false), zfirst: o
cls: o.get("cls").map(|v| classes(&v.to_string())).unwrap_or_else(|| "iso-sprites".into()), .get("zfirst")
.and_then(|v| v.as_bool().ok())
.unwrap_or(false),
cls: o
.get("cls")
.map(|v| classes(&v.to_string()))
.unwrap_or_else(|| "iso-sprites".into()),
}; };
let before = o.get("before").map(|v| v.to_string()); let before = o.get("before").map(|v| v.to_string());
b.scene.add_layer(name, opts, before.as_deref()); b.scene.add_layer(name, opts, before.as_deref());
@@ -336,33 +587,62 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
e.register_fn("current_layer", move || s.borrow().layer.clone()); e.register_fn("current_layer", move || s.borrow().layer.clone());
// tags and info // tags and info
fn tag(s: &Shared, x: D, y: D, z: D, text: &str, prio: f64, id: Option<&str>, kind: &str) -> RR<()> { #[allow(clippy::too_many_arguments)]
fn tag(
s: &Shared,
x: D,
y: D,
z: D,
text: &str,
prio: f64,
id: Option<&str>,
kind: &str,
) -> RR<()> {
let mut b = s.borrow_mut(); let mut b = s.borrow_mut();
let p = b.xf.p3([n(&x)?, n(&y)?, n(&z)?]); let p = b.xf.p3([n(&x)?, n(&y)?, n(&z)?]);
let id = id.map(str::to_string).or_else(|| b.obj.clone()).unwrap_or_default(); let id = id
.map(str::to_string)
.or_else(|| b.obj.clone())
.unwrap_or_default();
b.scene.tag(p, text, prio, id, classes(kind)); b.scene.tag(p, text, prio, id, classes(kind));
Ok(()) Ok(())
} }
let s = st.clone(); let s = st.clone();
e.register_fn("tag", move |x: D, y: D, z: D, t: &str| tag(&s, x, y, z, t, 1.0, None, "")); e.register_fn("tag", move |x: D, y: D, z: D, t: &str| {
let s = st.clone(); tag(&s, x, y, z, t, 1.0, None, "")
e.register_fn("tag", move |x: D, y: D, z: D, t: &str, p: D| tag(&s, x, y, z, t, n(&p)?, None, ""));
let s = st.clone();
e.register_fn("tag", move |x: D, y: D, z: D, t: &str, p: D, id: &str| tag(&s, x, y, z, t, n(&p)?, Some(id), ""));
let s = st.clone();
e.register_fn("tag", move |x: D, y: D, z: D, t: &str, p: D, id: &str, k: &str| {
tag(&s, x, y, z, t, n(&p)?, Some(id), k)
}); });
let s = st.clone(); let s = st.clone();
e.register_fn("info", move |id: &str, name: &str| s.borrow_mut().scene.info(id, name, "")); e.register_fn("tag", move |x: D, y: D, z: D, t: &str, p: D| {
tag(&s, x, y, z, t, n(&p)?, None, "")
});
let s = st.clone(); let s = st.clone();
e.register_fn("info", move |id: &str, name: &str, detail: &str| s.borrow_mut().scene.info(id, name, detail)); e.register_fn("tag", move |x: D, y: D, z: D, t: &str, p: D, id: &str| {
tag(&s, x, y, z, t, n(&p)?, Some(id), "")
});
let s = st.clone();
e.register_fn(
"tag",
move |x: D, y: D, z: D, t: &str, p: D, id: &str, k: &str| {
tag(&s, x, y, z, t, n(&p)?, Some(id), k)
},
);
let s = st.clone();
e.register_fn("info", move |id: &str, name: &str| {
s.borrow_mut().scene.info(id, name, "")
});
let s = st.clone();
e.register_fn("info", move |id: &str, name: &str, detail: &str| {
s.borrow_mut().scene.info(id, name, detail)
});
let s = st.clone(); let s = st.clone();
e.register_fn("info", move |id: &str, m: Map| { e.register_fn("info", move |id: &str, m: Map| {
let g = |k: &str| m.get(k).map(|v| v.to_string()).unwrap_or_default(); let g = |k: &str| m.get(k).map(|v| v.to_string()).unwrap_or_default();
let mut b = s.borrow_mut(); let mut b = s.borrow_mut();
b.scene.info(id, g("name"), g("detail")); b.scene.info(id, g("name"), g("detail"));
if let Some(rows) = m.get("rows").and_then(|r| r.read_lock::<Array>().map(|a| a.clone())) { if let Some(rows) = m
.get("rows")
.and_then(|r| r.read_lock::<Array>().map(|a| a.clone()))
{
let rows: Vec<(String, String)> = rows let rows: Vec<(String, String)> = rows
.iter() .iter()
.filter_map(|r| r.read_lock::<Array>().map(|a| a.clone())) .filter_map(|r| r.read_lock::<Array>().map(|a| a.clone()))
@@ -391,16 +671,23 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
Ok(()) Ok(())
}); });
let s = st.clone(); let s = st.clone();
e.register_fn("bounds", move |x0: D, y0: D, x1: D, y1: D, zb: D| -> RR<()> { e.register_fn(
let mut b = s.borrow_mut(); "bounds",
let r = b.xf.rect(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?); move |x0: D, y0: D, x1: D, y1: D, zb: D| -> RR<()> {
b.scene.bounds = Some((r, -n(&zb)?.abs())); let mut b = s.borrow_mut();
Ok(()) let r = b.xf.rect(n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?);
b.scene.bounds = Some((r, -n(&zb)?.abs()));
Ok(())
},
);
let s = st.clone();
e.register_fn("defs", move |svg: &str| {
s.borrow_mut().scene.defs.push(svg.to_string())
}); });
let s = st.clone(); let s = st.clone();
e.register_fn("defs", move |svg: &str| s.borrow_mut().scene.defs.push(svg.to_string())); e.register_fn("warn", move |msg: &str| {
let s = st.clone(); s.borrow_mut().scene.warnings.push(msg.to_string())
e.register_fn("warn", move |msg: &str| s.borrow_mut().scene.warnings.push(msg.to_string())); });
// view and inputs // view and inputs
let s = st.clone(); let s = st.clone();
@@ -410,7 +697,9 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
let s = st.clone(); let s = st.clone();
e.register_fn("cfg", move || s.borrow().cfg.clone()); e.register_fn("cfg", move || s.borrow().cfg.clone());
let s = st.clone(); let s = st.clone();
e.register_fn("cfg", move |k: &str| s.borrow().cfg.get(k).cloned().unwrap_or(Dynamic::UNIT)); e.register_fn("cfg", move |k: &str| {
s.borrow().cfg.get(k).cloned().unwrap_or(Dynamic::UNIT)
});
let s = st.clone(); let s = st.clone();
e.register_fn("objects", move || s.borrow().all_objects.clone()); e.register_fn("objects", move || s.borrow().all_objects.clone());
// is a face with outward normal (nx, ny) (object coordinates) turned towards the viewer? // is a face with outward normal (nx, ny) (object coordinates) turned towards the viewer?
@@ -425,7 +714,10 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
let b = s.borrow(); let b = s.borrow();
let w = b.xf.v(n(&dx)?, n(&dy)?); let w = b.xf.v(n(&dx)?, n(&dy)?);
let v = b.proj.dv(w[0], w[1], n(&dz)?); let v = b.proj.dv(w[0], w[1], n(&dz)?);
Ok(vec![Dynamic::from_float(round1(v[0])), Dynamic::from_float(round1(v[1]))]) Ok(vec![
Dynamic::from_float(round1(v[0])),
Dynamic::from_float(round1(v[1])),
])
}); });
// should a mover run along its x axis? (x is then the axis the depth sort compares second) // should a mover run along its x axis? (x is then the axis the depth sort compares second)
let s = st.clone(); let s = st.clone();
@@ -449,10 +741,17 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
e.register_fn("num", |x: D| -> RR<f64> { n(&x) }); e.register_fn("num", |x: D| -> RR<f64> { n(&x) });
e.register_fn("fmin", |a: D, b: D| -> RR<f64> { Ok(n(&a)?.min(n(&b)?)) }); e.register_fn("fmin", |a: D, b: D| -> RR<f64> { Ok(n(&a)?.min(n(&b)?)) });
e.register_fn("fmax", |a: D, b: D| -> RR<f64> { Ok(n(&a)?.max(n(&b)?)) }); e.register_fn("fmax", |a: D, b: D| -> RR<f64> { Ok(n(&a)?.max(n(&b)?)) });
e.register_fn("clamp", |x: D, lo: D, hi: D| -> RR<f64> { Ok(n(&x)?.max(n(&lo)?).min(n(&hi)?)) }); e.register_fn("clamp", |x: D, lo: D, hi: D| -> RR<f64> {
Ok(n(&x)?.max(n(&lo)?).min(n(&hi)?))
});
e.register_fn("rect", |x0: D, y0: D, x1: D, y1: D| -> RR<Array> { e.register_fn("rect", |x0: D, y0: D, x1: D, y1: D| -> RR<Array> {
let (x0, y0, x1, y1) = (n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?); let (x0, y0, x1, y1) = (n(&x0)?, n(&y0)?, n(&x1)?, n(&y1)?);
Ok(vec![arr_f(&[x0, y0]), arr_f(&[x1, y0]), arr_f(&[x1, y1]), arr_f(&[x0, y1])]) Ok(vec![
arr_f(&[x0, y0]),
arr_f(&[x1, y0]),
arr_f(&[x1, y1]),
arr_f(&[x0, y1]),
])
}); });
e.register_fn("ellipse", |cx: D, cy: D, a: D, b: D, k: D| -> RR<Array> { e.register_fn("ellipse", |cx: D, cy: D, a: D, b: D, k: D| -> RR<Array> {
let (cx, cy, a, b, k) = (n(&cx)?, n(&cy)?, n(&a)?, n(&b)?, n(&k)?.max(3.0) as usize); let (cx, cy, a, b, k) = (n(&cx)?, n(&cy)?, n(&a)?, n(&b)?, n(&k)?.max(3.0) as usize);
+15 -4
View File
@@ -59,7 +59,9 @@ pub fn pts3(d: &Dynamic) -> RR<Vec<P3>> {
} }
fn is_point(d: &Dynamic) -> bool { fn is_point(d: &Dynamic) -> bool {
d.read_lock::<Array>().map(|a| !a.is_empty() && (a[0].is_int() || a[0].is_float())).unwrap_or(false) d.read_lock::<Array>()
.map(|a| !a.is_empty() && (a[0].is_int() || a[0].is_float()))
.unwrap_or(false)
} }
/// One polygon `[[x, y], ...]` or several `[[[x, y], ...], ...]`. /// One polygon `[[x, y], ...]` or several `[[[x, y], ...], ...]`.
@@ -114,18 +116,27 @@ pub fn rect4(d: &Dynamic) -> RR<[f64; 4]> {
/// A height: one number or one per vertex. /// A height: one number or one per vertex.
pub fn zs(d: &Dynamic) -> RR<Zs> { pub fn zs(d: &Dynamic) -> RR<Zs> {
if d.is_array() { if d.is_array() {
Ok(Zs::Each(arr(d, "heights")?.iter().map(n).collect::<RR<Vec<f64>>>()?)) Ok(Zs::Each(
arr(d, "heights")?.iter().map(n).collect::<RR<Vec<f64>>>()?,
))
} else { } else {
Ok(Zs::One(n(d)?)) Ok(Zs::One(n(d)?))
} }
} }
fn get_str(m: &Map, k: &str) -> Option<String> { fn get_str(m: &Map, k: &str) -> Option<String> {
m.get(k).and_then(|v| if v.is_unit() { None } else { Some(v.to_string()) }) m.get(k).and_then(|v| {
if v.is_unit() {
None
} else {
Some(v.to_string())
}
})
} }
fn get_bool(m: &Map, keys: &[&str]) -> bool { fn get_bool(m: &Map, keys: &[&str]) -> bool {
keys.iter().any(|k| m.get(*k).and_then(|v| v.as_bool().ok()).unwrap_or(false)) keys.iter()
.any(|k| m.get(*k).and_then(|v| v.as_bool().ok()).unwrap_or(false))
} }
/// Style from a material and an optional options map /// Style from a material and an optional options map
+69 -15
View File
@@ -129,13 +129,27 @@ impl PrefabMeta {
pub fn resolve(&self, props: &Map<String, Value>) -> Map<String, Value> { pub fn resolve(&self, props: &Map<String, Value>) -> Map<String, Value> {
let mut out = props.clone(); let mut out = props.clone();
for d in &self.props { for d in &self.props {
let v = out.get(&d.key).cloned().unwrap_or_else(|| d.default.clone()); let v = out
.get(&d.key)
.cloned()
.unwrap_or_else(|| d.default.clone());
let v = match (d.kind.as_str(), &v) { let v = match (d.kind.as_str(), &v) {
("float", Value::Number(n)) => n.as_f64().map(Value::from).unwrap_or(v), ("float", Value::Number(n)) => n.as_f64().map(Value::from).unwrap_or(v),
("float", Value::String(s)) => s.parse::<f64>().map(Value::from).unwrap_or(d.default.clone()), ("float", Value::String(s)) => s
("int", Value::Number(n)) => n.as_f64().map(|f| Value::from(f.round() as i64)).unwrap_or(v), .parse::<f64>()
("int", Value::String(s)) => s.parse::<f64>().map(|f| Value::from(f.round() as i64)).unwrap_or(d.default.clone()), .map(Value::from)
("bool", Value::String(s)) => Value::Bool(matches!(s.as_str(), "true" | "1" | "yes" | "on")), .unwrap_or(d.default.clone()),
("int", Value::Number(n)) => n
.as_f64()
.map(|f| Value::from(f.round() as i64))
.unwrap_or(v),
("int", Value::String(s)) => s
.parse::<f64>()
.map(|f| Value::from(f.round() as i64))
.unwrap_or(d.default.clone()),
("bool", Value::String(s)) => {
Value::Bool(matches!(s.as_str(), "true" | "1" | "yes" | "on"))
}
("bool", Value::Number(n)) => Value::Bool(n.as_f64().unwrap_or(0.0) != 0.0), ("bool", Value::Number(n)) => Value::Bool(n.as_f64().unwrap_or(0.0) != 0.0),
_ => v, _ => v,
}; };
@@ -156,13 +170,26 @@ impl Doc {
pub fn free_id(&self, base: &str) -> String { pub fn free_id(&self, base: &str) -> String {
let base: String = base let base: String = base
.chars() .chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' }) .map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect(); .collect();
let base = if base.is_empty() { "obj".to_string() } else { base }; let base = if base.is_empty() {
"obj".to_string()
} else {
base
};
if self.get(&base).is_none() { if self.get(&base).is_none() {
return base; return base;
} }
(2..).map(|i| format!("{base}-{i}")).find(|id| self.get(id).is_none()).unwrap() (2..)
.map(|i| format!("{base}-{i}"))
.find(|id| self.get(id).is_none())
.unwrap()
} }
} }
@@ -173,19 +200,38 @@ mod tests {
#[test] #[test]
fn object_round_trips_compactly() { fn object_round_trips_compactly() {
let o: Object = serde_json::from_value(json!({"id": "t1", "type": "tree", "x": 3, "y": 4.5})).unwrap(); let o: Object =
serde_json::from_value(json!({"id": "t1", "type": "tree", "x": 3, "y": 4.5})).unwrap();
assert_eq!(o.kind, "tree"); assert_eq!(o.kind, "tree");
let back = serde_json::to_value(&o).unwrap(); let back = serde_json::to_value(&o).unwrap();
assert_eq!(back, json!({"id": "t1", "type": "tree", "x": 3.0, "y": 4.5})); assert_eq!(
back,
json!({"id": "t1", "type": "tree", "x": 3.0, "y": 4.5})
);
} }
#[test] #[test]
fn defaults_and_coercion() { fn defaults_and_coercion() {
let m = PrefabMeta { let m = PrefabMeta {
props: vec![ props: vec![
PropDef { key: "r".into(), kind: "float".into(), default: json!(1.5), ..Default::default() }, PropDef {
PropDef { key: "n".into(), kind: "int".into(), default: json!(2), ..Default::default() }, key: "r".into(),
PropDef { key: "on".into(), kind: "bool".into(), default: json!(false), ..Default::default() }, kind: "float".into(),
default: json!(1.5),
..Default::default()
},
PropDef {
key: "n".into(),
kind: "int".into(),
default: json!(2),
..Default::default()
},
PropDef {
key: "on".into(),
kind: "bool".into(),
default: json!(false),
..Default::default()
},
], ],
..Default::default() ..Default::default()
}; };
@@ -199,8 +245,16 @@ mod tests {
fn free_ids() { fn free_ids() {
let mut d = Doc::default(); let mut d = Doc::default();
assert_eq!(d.free_id("Tree"), "tree"); assert_eq!(d.free_id("Tree"), "tree");
d.objects.push(Object { id: "tree".into(), kind: "tree".into(), ..Default::default() }); d.objects.push(Object {
d.objects.push(Object { id: "tree-2".into(), kind: "tree".into(), ..Default::default() }); id: "tree".into(),
kind: "tree".into(),
..Default::default()
});
d.objects.push(Object {
id: "tree-2".into(),
kind: "tree".into(),
..Default::default()
});
assert_eq!(d.free_id("tree"), "tree-3"); assert_eq!(d.free_id("tree"), "tree-3");
} }
} }
+120 -33
View File
@@ -77,14 +77,20 @@ fn to_error(e: &EvalAltResult, file: &str) -> ScriptError {
loop { loop {
match cur { match cur {
EvalAltResult::ErrorInFunctionCall(name, src, inner, p) => { EvalAltResult::ErrorInFunctionCall(name, src, inner, p) => {
trace.push(format!("in {name}() called at {file}:{}", p.line().unwrap_or(0))); trace.push(format!(
"in {name}() called at {file}:{}",
p.line().unwrap_or(0)
));
if !src.is_empty() { if !src.is_empty() {
file = src.clone(); file = src.clone();
} }
cur = inner; cur = inner;
} }
EvalAltResult::ErrorInModule(path, inner, p) => { EvalAltResult::ErrorInModule(path, inner, p) => {
trace.push(format!("in module {path} imported at {file}:{}", p.line().unwrap_or(0))); trace.push(format!(
"in module {path} imported at {file}:{}",
p.line().unwrap_or(0)
));
file = path.clone(); file = path.clone();
cur = inner; cur = inner;
} }
@@ -96,7 +102,13 @@ fn to_error(e: &EvalAltResult, file: &str) -> ScriptError {
if let Some(i) = message.rfind(" (line ") { if let Some(i) = message.rfind(" (line ") {
message.truncate(i); message.truncate(i);
} }
ScriptError { message, file, line: pos.line(), col: pos.position(), trace } ScriptError {
message,
file,
line: pos.line(),
col: pos.position(),
trace,
}
} }
/// Shared state of one build, read and written by the registered functions. /// Shared state of one build, read and written by the registered functions.
@@ -241,16 +253,38 @@ fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
b.obj_start = b.sprites.len(); b.obj_start = b.sprites.len();
let kind = g("type").to_string(); let kind = g("type").to_string();
let name = g("name").to_string(); let name = g("name").to_string();
let label = b.catalog.get(&kind).map(|m| m.label.clone()).unwrap_or_else(|| kind.clone()); let label = b
let name = if name.is_empty() || g("name").is_unit() { label } else { name }; .catalog
let detail = if g("info").is_unit() { String::new() } else { g("info").to_string() }; .get(&kind)
.map(|m| m.label.clone())
.unwrap_or_else(|| kind.clone());
let name = if name.is_empty() || g("name").is_unit() {
label
} else {
name
};
let detail = if g("info").is_unit() {
String::new()
} else {
g("info").to_string()
};
b.scene.info(id, name, detail); b.scene.info(id, name, detail);
if !b.catalog.contains_key(&kind) { if !b.catalog.contains_key(&kind) {
b.scene.warnings.push(format!("object {}: unknown type \"{kind}\"", g("id"))); b.scene
.warnings
.push(format!("object {}: unknown type \"{kind}\"", g("id")));
drop(b); drop(b);
// a placeholder so the object can still be found and fixed // a placeholder so the object can still be found and fixed
let sp = api::new_sprite(&s, None, None, ""); let sp = api::new_sprite(&s, None, None, "");
sp.0.borrow_mut().cuboid(-0.5, -0.5, 0.5, 0.5, 0.0, 1.0, isokit_core::Style::mat("iso-warn")); sp.0.borrow_mut().cuboid(
-0.5,
-0.5,
0.5,
0.5,
0.0,
1.0,
isokit_core::Style::mat("iso-warn"),
);
s.borrow_mut().obj = None; s.borrow_mut().obj = None;
s.borrow_mut().xf = Xf::default(); s.borrow_mut().xf = Xf::default();
return false; return false;
@@ -273,7 +307,13 @@ fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
if bx[0] <= bx[3] { if bx[0] <= bx[3] {
let tag = o.get("tag").map(|v| v.to_string()).unwrap_or_default(); let tag = o.get("tag").map(|v| v.to_string()).unwrap_or_default();
if !tag.is_empty() && o.get("tag").is_some_and(|v| !v.is_unit()) { if !tag.is_empty() && o.get("tag").is_some_and(|v| !v.is_unit()) {
b.scene.tag([(bx[0] + bx[3]) / 2.0, (bx[1] + bx[4]) / 2.0, bx[5] + 0.6], tag, 6.0, id.clone(), ""); b.scene.tag(
[(bx[0] + bx[3]) / 2.0, (bx[1] + bx[4]) / 2.0, bx[5] + 0.6],
tag,
6.0,
id.clone(),
"",
);
} }
b.boxes.insert(id, bx); b.boxes.insert(id, bx);
} }
@@ -291,7 +331,10 @@ fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
None => e.to_string(), None => e.to_string(),
}; };
let id = o.get("id").map(|v| v.to_string()).unwrap_or_default(); let id = o.get("id").map(|v| v.to_string()).unwrap_or_default();
s.borrow_mut().scene.warnings.push(format!("object {id}: {msg}")); s.borrow_mut()
.scene
.warnings
.push(format!("object {id}: {msg}"));
}); });
} }
@@ -305,14 +348,19 @@ impl Runtime {
engine.set_max_operations(80_000_000); engine.set_max_operations(80_000_000);
engine.set_max_call_levels(64); engine.set_max_call_levels(64);
engine.set_max_expr_depths(512, 512); engine.set_max_expr_depths(512, 512);
engine.set_module_resolver(SourceResolver { sources: Rc::new(bundle.sources.clone()) }); engine.set_module_resolver(SourceResolver {
sources: Rc::new(bundle.sources.clone()),
});
api::register_sprite(&mut engine); api::register_sprite(&mut engine);
api::register_scene(&mut engine, &state); api::register_scene(&mut engine, &state);
register_driver(&mut engine, &state); register_driver(&mut engine, &state);
// global modules: built-in library, then the project's prelude // global modules: built-in library, then the project's prelude
let mut asts: Vec<(String, AST)> = vec![]; let mut asts: Vec<(String, AST)> = vec![];
let mut mods: Vec<(String, String)> = stdlib::MODULES.iter().map(|(n, s)| (n.to_string(), s.to_string())).collect(); let mut mods: Vec<(String, String)> = stdlib::MODULES
.iter()
.map(|(n, s)| (n.to_string(), s.to_string()))
.collect();
for p in &bundle.prelude { for p in &bundle.prelude {
let src = bundle.sources.get(p).ok_or_else(|| ScriptError { let src = bundle.sources.get(p).ok_or_else(|| ScriptError {
message: format!("prelude module {p} not found"), message: format!("prelude module {p} not found"),
@@ -324,7 +372,8 @@ impl Runtime {
for (name, src) in &mods { for (name, src) in &mods {
let mut ast = engine.compile(src).map_err(|e| to_error(&e.into(), name))?; let mut ast = engine.compile(src).map_err(|e| to_error(&e.into(), name))?;
ast.set_source(name.as_str()); ast.set_source(name.as_str());
let m = Module::eval_ast_as_new(Scope::new(), &ast, &engine).map_err(|e| to_error(&e, name))?; let m = Module::eval_ast_as_new(Scope::new(), &ast, &engine)
.map_err(|e| to_error(&e, name))?;
engine.register_global_module(m.into()); engine.register_global_module(m.into());
asts.push((name.clone(), ast)); asts.push((name.clone(), ast));
} }
@@ -344,24 +393,32 @@ impl Runtime {
None => AST::empty(), None => AST::empty(),
}; };
asts.push((main.clone(), ast.clone())); asts.push((main.clone(), ast.clone()));
let driver = engine.compile(DRIVER).map_err(|e| to_error(&e.into(), "driver"))?; let driver = engine
.compile(DRIVER)
.map_err(|e| to_error(&e.into(), "driver"))?;
ast = ast.merge(&driver); ast = ast.merge(&driver);
ast.set_source(main.as_str()); ast.set_source(main.as_str());
// prefab catalog: prefab_<type>(o) with an optional meta_<type>() // prefab catalog: prefab_<type>(o) with an optional meta_<type>()
let mut catalog = vec![]; let mut catalog: Vec<(usize, PrefabMeta)> = vec![];
for (name, a) in &asts { for (mi, (name, a)) in asts.iter().enumerate() {
for f in a.iter_functions() { for f in a.iter_functions() {
let Some(kind) = f.name.strip_prefix("prefab_") else { continue }; let Some(kind) = f.name.strip_prefix("prefab_") else {
continue;
};
if f.params.len() != 1 { if f.params.len() != 1 {
continue; continue;
} }
let meta_fn = format!("meta_{kind}"); let meta_fn = format!("meta_{kind}");
let mut meta = if a.iter_functions().any(|g| g.name == meta_fn && g.params.is_empty()) { let mut meta = if a
.iter_functions()
.any(|g| g.name == meta_fn && g.params.is_empty())
{
let v: Dynamic = engine let v: Dynamic = engine
.call_fn(&mut Scope::new(), a, &meta_fn, ()) .call_fn(&mut Scope::new(), a, &meta_fn, ())
.map_err(|e| to_error(&e, name))?; .map_err(|e| to_error(&e, name))?;
let j: serde_json::Value = rhai::serde::from_dynamic(&v).map_err(|e| to_error(&e, name))?; let j: serde_json::Value =
rhai::serde::from_dynamic(&v).map_err(|e| to_error(&e, name))?;
serde_json::from_value::<PrefabMeta>(j).map_err(|e| ScriptError { serde_json::from_value::<PrefabMeta>(j).map_err(|e| ScriptError {
message: format!("{meta_fn}(): {e}"), message: format!("{meta_fn}(): {e}"),
file: name.clone(), file: name.clone(),
@@ -378,12 +435,26 @@ impl Runtime {
meta.category = "Other".into(); meta.category = "Other".into();
} }
meta.source = name.clone(); meta.source = name.clone();
catalog.retain(|m: &PrefabMeta| m.kind != meta.kind); // later modules override catalog.retain(|(_, m)| m.kind != meta.kind); // later modules override
catalog.push(meta); catalog.push((mi, meta));
} }
} }
state.borrow_mut().catalog = catalog.iter().map(|m| (m.kind.clone(), m.clone())).collect(); // stable palette order: library modules first, then by category and label
Ok(Runtime { engine, ast, state, catalog, main }) catalog.sort_by(|(ia, a), (ib, b)| {
(ia, &a.category, &a.label).cmp(&(ib, &b.category, &b.label))
});
let catalog: Vec<PrefabMeta> = catalog.into_iter().map(|(_, m)| m).collect();
state.borrow_mut().catalog = catalog
.iter()
.map(|m| (m.kind.clone(), m.clone()))
.collect();
Ok(Runtime {
engine,
ast,
state,
catalog,
main,
})
} }
/// Object types available to the document. /// Object types available to the document.
@@ -397,7 +468,13 @@ impl Runtime {
/// Build the scene for a level and rotation. `cfg` holds the parameter /// Build the scene for a level and rotation. `cfg` holds the parameter
/// values (already merged with the defaults). /// values (already merged with the defaults).
pub fn build(&self, level: &str, rot: u8, cfg: &serde_json::Map<String, serde_json::Value>, doc: &Doc) -> Result<Built, ScriptError> { pub fn build(
&self,
level: &str,
rot: u8,
cfg: &serde_json::Map<String, serde_json::Value>,
doc: &Doc,
) -> Result<Built, ScriptError> {
{ {
let mut b = self.state.borrow_mut(); let mut b = self.state.borrow_mut();
let catalog = std::mem::take(&mut b.catalog); let catalog = std::mem::take(&mut b.catalog);
@@ -407,23 +484,28 @@ impl Runtime {
b.main = main; b.main = main;
b.level = level.to_string(); b.level = level.to_string();
b.proj = Proj::new(rot as i64); b.proj = Proj::new(rot as i64);
b.cfg = match dyn_json(&serde_json::Value::Object(cfg.clone())).try_cast::<Map>() { b.cfg = dyn_json(&serde_json::Value::Object(cfg.clone()))
Some(m) => m, .try_cast::<Map>()
None => Map::new(), .unwrap_or_default();
};
for o in &doc.objects { for o in &doc.objects {
let mut j = serde_json::to_value(o).unwrap_or_default(); let mut j = serde_json::to_value(o).unwrap_or_default();
let meta = b.catalog.get(&o.kind); let meta = b.catalog.get(&o.kind);
let props = meta.map(|m| m.resolve(&o.props)).unwrap_or_else(|| o.props.clone()); let props = meta
.map(|m| m.resolve(&o.props))
.unwrap_or_else(|| o.props.clone());
if let Some(m) = j.as_object_mut() { if let Some(m) = j.as_object_mut() {
for k in ["name", "info", "tag"] { for k in ["name", "info", "tag"] {
m.entry(k).or_insert(serde_json::Value::String(String::new())); m.entry(k)
.or_insert(serde_json::Value::String(String::new()));
} }
for k in ["z", "rot"] { for k in ["z", "rot"] {
m.entry(k).or_insert(serde_json::Value::from(0.0)); m.entry(k).or_insert(serde_json::Value::from(0.0));
} }
m.insert("p".into(), serde_json::Value::Object(props)); m.insert("p".into(), serde_json::Value::Object(props));
m.insert("__fn".into(), serde_json::Value::String(format!("prefab_{}", o.kind))); m.insert(
"__fn".into(),
serde_json::Value::String(format!("prefab_{}", o.kind)),
);
m.insert("level".into(), serde_json::Value::String(level.into())); m.insert("level".into(), serde_json::Value::String(level.into()));
} }
let d = dyn_json(&j); let d = dyn_json(&j);
@@ -445,11 +527,16 @@ impl Runtime {
let mut sp = std::mem::take(&mut *h.0.borrow_mut()); let mut sp = std::mem::take(&mut *h.0.borrow_mut());
let open = sp.finish(); let open = sp.finish();
if open > 0 { if open > 0 {
scene.warnings.push(format!("sprite {}: {open} group(s) left open", sp.id)); scene
.warnings
.push(format!("sprite {}: {open} group(s) left open", sp.id));
} }
scene.add_sprite(&layer, sp); scene.add_sprite(&layer, sp);
} }
Ok(Built { scene, boxes: std::mem::take(&mut b.boxes) }) Ok(Built {
scene,
boxes: std::mem::take(&mut b.boxes),
})
} }
} }
+107 -25
View File
@@ -6,7 +6,10 @@ use isokit_core::{render, RenderOpts};
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
fn rt(main: &str) -> Runtime { fn rt(main: &str) -> Runtime {
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() }; let mut b = Bundle {
main: Some("scene.rhai".into()),
..Default::default()
};
b.sources.insert("scene.rhai".into(), main.into()); b.sources.insert("scene.rhai".into(), main.into());
Runtime::new(&b).unwrap_or_else(|e| panic!("{e}")) Runtime::new(&b).unwrap_or_else(|e| panic!("{e}"))
} }
@@ -16,7 +19,14 @@ fn cfg() -> Map<String, Value> {
} }
fn obj(id: &str, kind: &str, x: f64, y: f64, props: Value) -> Object { fn obj(id: &str, kind: &str, x: f64, y: f64, props: Value) -> Object {
Object { id: id.into(), kind: kind.into(), x, y, props: props.as_object().cloned().unwrap_or_default(), ..Default::default() } Object {
id: id.into(),
kind: kind.into(),
x,
y,
props: props.as_object().cloned().unwrap_or_default(),
..Default::default()
}
} }
#[test] #[test]
@@ -59,17 +69,29 @@ fn seq_group_and_closures() {
let b = r.build("site", 0, &cfg(), &Doc::default()).unwrap(); let b = r.build("site", 0, &cfg(), &Doc::default()).unwrap();
let out = render(&b.scene, 0, &RenderOpts::default()); let out = render(&b.scene, 0, &RenderOpts::default());
// from the south-east the box at x = 5 is nearer, so it is drawn after the one at x = 0 // from the south-east the box at x = 5 is nearer, so it is drawn after the one at x = 0
let (a, c) = (out.html.find("iso-wall").unwrap(), out.html.find("iso-roof").unwrap()); let (a, c) = (
out.html.find("iso-wall").unwrap(),
out.html.find("iso-roof").unwrap(),
);
assert!(a < c); assert!(a < c);
assert!(out.html.contains("<g class=\"iso-a iso-drive\">")); assert!(out.html.contains("<g class=\"iso-a iso-drive\">"));
} }
#[test] #[test]
fn errors_point_at_the_line() { fn errors_point_at_the_line() {
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() }; let mut b = Bundle {
b.sources.insert("scene.rhai".into(), "let s = sprite(\"a\");\n\ns.box(0, 0, 1, 1, 0, \"high\", \"wall\");\n".into()); main: Some("scene.rhai".into()),
..Default::default()
};
b.sources.insert(
"scene.rhai".into(),
"let s = sprite(\"a\");\n\ns.box(0, 0, 1, 1, 0, \"high\", \"wall\");\n".into(),
);
let r = Runtime::new(&b).unwrap(); let r = Runtime::new(&b).unwrap();
let e = r.build("site", 0, &cfg(), &Doc::default()).err().expect("error"); let e = r
.build("site", 0, &cfg(), &Doc::default())
.err()
.expect("error");
assert_eq!(e.file, "scene.rhai"); assert_eq!(e.file, "scene.rhai");
assert_eq!(e.line, Some(3), "{e}"); assert_eq!(e.line, Some(3), "{e}");
assert!(e.message.contains("number"), "{e}"); assert!(e.message.contains("number"), "{e}");
@@ -81,9 +103,18 @@ fn errors_point_at_the_line() {
#[test] #[test]
fn imports_resolve_from_the_bundle() { fn imports_resolve_from_the_bundle() {
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() }; let mut b = Bundle {
b.sources.insert("lib/kiosk.rhai".into(), "fn kiosk(x, y) { sprite(\"kiosk\").box(x, y, x + 2, y + 2, 0, 2.5, \"wall\"); }".into()); main: Some("scene.rhai".into()),
b.sources.insert("scene.rhai".into(), "import \"lib/kiosk\" as k;\nk::kiosk(1, 2);".into()); ..Default::default()
};
b.sources.insert(
"lib/kiosk.rhai".into(),
"fn kiosk(x, y) { sprite(\"kiosk\").box(x, y, x + 2, y + 2, 0, 2.5, \"wall\"); }".into(),
);
b.sources.insert(
"scene.rhai".into(),
"import \"lib/kiosk\" as k;\nk::kiosk(1, 2);".into(),
);
let r = Runtime::new(&b).unwrap(); let r = Runtime::new(&b).unwrap();
let s = r.build("site", 0, &cfg(), &Doc::default()).unwrap().scene; let s = r.build("site", 0, &cfg(), &Doc::default()).unwrap().scene;
assert_eq!(s.sprite_count(), 1); assert_eq!(s.sprite_count(), 1);
@@ -91,7 +122,11 @@ fn imports_resolve_from_the_bundle() {
#[test] #[test]
fn catalog_lists_library_and_project_prefabs() { fn catalog_lists_library_and_project_prefabs() {
let mut b = Bundle { main: None, prelude: vec!["lib/kiosk.rhai".into()], ..Default::default() }; let mut b = Bundle {
main: None,
prelude: vec!["lib/kiosk.rhai".into()],
..Default::default()
};
b.sources.insert( b.sources.insert(
"lib/kiosk.rhai".into(), "lib/kiosk.rhai".into(),
r#" r#"
@@ -102,7 +137,9 @@ fn catalog_lists_library_and_project_prefabs() {
); );
let r = Runtime::new(&b).unwrap(); let r = Runtime::new(&b).unwrap();
let kinds: Vec<&str> = r.catalog().iter().map(|m| m.kind.as_str()).collect(); let kinds: Vec<&str> = r.catalog().iter().map(|m| m.kind.as_str()).collect();
for k in ["site", "area", "road", "building", "tree", "car", "person", "tank", "kiosk"] { for k in [
"site", "area", "road", "building", "tree", "car", "person", "tank", "kiosk",
] {
assert!(kinds.contains(&k), "{k} missing from {kinds:?}"); assert!(kinds.contains(&k), "{k} missing from {kinds:?}");
} }
let k = r.prefab("kiosk").unwrap(); let k = r.prefab("kiosk").unwrap();
@@ -115,7 +152,13 @@ fn catalog_lists_library_and_project_prefabs() {
fn document_objects_are_placed_in_their_frame() { fn document_objects_are_placed_in_their_frame() {
let r = Runtime::new(&Bundle::default()).unwrap(); let r = Runtime::new(&Bundle::default()).unwrap();
let mut doc = Doc::default(); let mut doc = Doc::default();
let mut b = obj("hall", "building", 30.0, 20.0, json!({"w": 10, "d": 6, "floors": 1})); let mut b = obj(
"hall",
"building",
30.0,
20.0,
json!({"w": 10, "d": 6, "floors": 1}),
);
b.rot = 90.0; b.rot = 90.0;
b.name = "Main hall".into(); b.name = "Main hall".into();
b.tag = "Hall".into(); b.tag = "Hall".into();
@@ -123,23 +166,37 @@ fn document_objects_are_placed_in_their_frame() {
doc.objects.push(obj("t1", "tree", 5.0, 5.0, json!({}))); doc.objects.push(obj("t1", "tree", 5.0, 5.0, json!({})));
let built = r.build("roof", 1, &cfg(), &doc).unwrap(); let built = r.build("roof", 1, &cfg(), &doc).unwrap();
// turned a quarter: 6 m along x, 10 m along y, centred on (30, 20) // turned a quarter: 6 m along x, 10 m along y, centred on (30, 20)
let bx = *built.boxes.get("hall").unwrap_or_else(|| panic!("{:?}", built.scene.warnings)); let bx = *built
assert!((bx[0] - 27.0).abs() < 1e-9 && (bx[3] - 33.0).abs() < 1e-9, "{bx:?}"); .boxes
assert!((bx[1] - 15.0).abs() < 1e-9 && (bx[4] - 25.0).abs() < 1e-9, "{bx:?}"); .get("hall")
.unwrap_or_else(|| panic!("{:?}", built.scene.warnings));
assert!(
(bx[0] - 27.0).abs() < 1e-9 && (bx[3] - 33.0).abs() < 1e-9,
"{bx:?}"
);
assert!(
(bx[1] - 15.0).abs() < 1e-9 && (bx[4] - 25.0).abs() < 1e-9,
"{bx:?}"
);
let out = render(&built.scene, 1, &RenderOpts::default()); let out = render(&built.scene, 1, &RenderOpts::default());
assert!(out.html.contains("data-obj=\"hall\"")); assert!(out.html.contains("data-obj=\"hall\""));
assert!(out.html.contains("data-obj=\"t1\"")); assert!(out.html.contains("data-obj=\"t1\""));
assert_eq!(out.info["hall"].name, "Main hall"); assert_eq!(out.info["hall"].name, "Main hall");
assert_eq!(out.info["t1"].name, "Tree"); assert_eq!(out.info["t1"].name, "Tree");
assert!(out.html.contains(">Hall<")); assert!(out.html.contains(">Hall<"));
assert!(built.scene.warnings.is_empty(), "{:?}", built.scene.warnings); assert!(
built.scene.warnings.is_empty(),
"{:?}",
built.scene.warnings
);
} }
#[test] #[test]
fn levels_cut_buildings_open_and_filter_objects() { fn levels_cut_buildings_open_and_filter_objects() {
let r = Runtime::new(&Bundle::default()).unwrap(); let r = Runtime::new(&Bundle::default()).unwrap();
let mut doc = Doc::default(); let mut doc = Doc::default();
doc.objects.push(obj("b", "building", 0.0, 0.0, json!({"floors": 2}))); doc.objects
.push(obj("b", "building", 0.0, 0.0, json!({"floors": 2})));
let mut desk = obj("desk", "tree", 0.0, 0.0, json!({})); let mut desk = obj("desk", "tree", 0.0, 0.0, json!({}));
desk.levels = vec!["gf".into()]; desk.levels = vec!["gf".into()];
doc.objects.push(desk); doc.objects.push(desk);
@@ -153,7 +210,10 @@ fn levels_cut_buildings_open_and_filter_objects() {
let html = render(&gf.scene, 0, &RenderOpts::default()).html; let html = render(&gf.scene, 0, &RenderOpts::default()).html;
assert!(html.contains("iso-z-floor iso-t")); assert!(html.contains("iso-z-floor iso-t"));
let html = render(&up.scene, 0, &RenderOpts::default()).html; let html = render(&up.scene, 0, &RenderOpts::default()).html;
assert!(html.contains("class=\"iso-dim\""), "ghosted above the top storey"); assert!(
html.contains("class=\"iso-dim\""),
"ghosted above the top storey"
);
} }
#[test] #[test]
@@ -172,13 +232,26 @@ fn every_library_prefab_renders_cleanly_in_every_rotation() {
} }
for level in ["site", "gf", "1f"] { for level in ["site", "gf", "1f"] {
for rot in 0..4 { for rot in 0..4 {
let b = r.build(level, rot, &cfg(), &doc).unwrap_or_else(|e| panic!("{level}/{rot}: {e}")); let b = r
assert!(b.scene.warnings.is_empty(), "{level}/{rot}: {:?}", b.scene.warnings); .build(level, rot, &cfg(), &doc)
.unwrap_or_else(|e| panic!("{level}/{rot}: {e}"));
assert!(
b.scene.warnings.is_empty(),
"{level}/{rot}: {:?}",
b.scene.warnings
);
let out = render(&b.scene, rot, &RenderOpts::default()); let out = render(&b.scene, rot, &RenderOpts::default());
assert!(!out.html.contains("NaN"), "{level}/{rot}"); assert!(!out.html.contains("NaN"), "{level}/{rot}");
for m in r.catalog() { for m in r.catalog() {
let id = format!("o{}", r.catalog().iter().position(|q| q.kind == m.kind).unwrap()); let id = format!(
assert!(b.boxes.contains_key(&id), "{} drew nothing in {level}/{rot}", m.kind); "o{}",
r.catalog().iter().position(|q| q.kind == m.kind).unwrap()
);
assert!(
b.boxes.contains_key(&id),
"{} drew nothing in {level}/{rot}",
m.kind
);
} }
} }
} }
@@ -186,8 +259,15 @@ fn every_library_prefab_renders_cleanly_in_every_rotation() {
#[test] #[test]
fn unknown_types_and_failing_prefabs_warn_but_render() { fn unknown_types_and_failing_prefabs_warn_but_render() {
let mut b = Bundle { main: None, prelude: vec!["lib/bad.rhai".into()], ..Default::default() }; let mut b = Bundle {
b.sources.insert("lib/bad.rhai".into(), "fn prefab_bad(o) { sprite().box(0, 0, 1, 1, 0, o.p.nope, \"wall\"); }".into()); main: None,
prelude: vec!["lib/bad.rhai".into()],
..Default::default()
};
b.sources.insert(
"lib/bad.rhai".into(),
"fn prefab_bad(o) { sprite().box(0, 0, 1, 1, 0, o.p.nope, \"wall\"); }".into(),
);
let r = Runtime::new(&b).unwrap(); let r = Runtime::new(&b).unwrap();
let mut doc = Doc::default(); let mut doc = Doc::default();
doc.objects.push(obj("u", "ufo", 0.0, 0.0, json!({}))); doc.objects.push(obj("u", "ufo", 0.0, 0.0, json!({})));
@@ -202,7 +282,9 @@ fn unknown_types_and_failing_prefabs_warn_but_render() {
#[test] #[test]
fn cfg_reaches_the_script() { fn cfg_reaches_the_script() {
let r = rt(r#"if cfg("big") { sprite("a").box(0, 0, 9, 9, 0, 9, "wall"); } else { sprite("a").box(0, 0, 1, 1, 0, 1, "wall"); }"#); let r = rt(
r#"if cfg("big") { sprite("a").box(0, 0, 9, 9, 0, 9, "wall"); } else { sprite("a").box(0, 0, 1, 1, 0, 1, "wall"); }"#,
);
let mut c = Map::new(); let mut c = Map::new();
c.insert("big".into(), json!(true)); c.insert("big".into(), json!(true));
let b = r.build("site", 0, &c, &Doc::default()).unwrap(); let b = r.build("site", 0, &c, &Doc::default()).unwrap();
+2 -2
View File
@@ -27,8 +27,8 @@ fn meta_building() {
#{ key: "lit", type: "bool", def: true, label: "Lit windows" }, #{ key: "lit", type: "bool", def: true, label: "Lit windows" },
#{ key: "door", type: "bool", def: true, label: "Front door" }, #{ key: "door", type: "bool", def: true, label: "Front door" },
#{ key: "pv", type: "bool", def: false, label: "Solar panels" }, #{ key: "pv", type: "bool", def: false, label: "Solar panels" },
#{ key: "ac", type: "bool", def: false, label: "Roof AC units (flat roof)" }, #{ key: "ac", type: "bool", def: false, label: "Roof AC units" },
#{ key: "floor_mat", type: "material", def: "z-floor", choices: floor_materials(), label: "Floor (cut-open levels)" }, #{ key: "floor_mat", type: "material", def: "z-floor", choices: floor_materials(), label: "Cut-open floor" },
] } ] }
} }
+2 -2
View File
@@ -19,7 +19,7 @@ fn prefab_tank(o) {
} }
fn meta_container() { fn meta_container() {
#{ label: "Shipping container", category: "Equipment", icon: "▭", #{ label: "Container", category: "Equipment", icon: "▭",
props: [ props: [
#{ key: "size", type: "choice", def: "20ft", choices: ["10ft", "20ft", "40ft"], label: "Size" }, #{ key: "size", type: "choice", def: "20ft", choices: ["10ft", "20ft", "40ft"], label: "Size" },
#{ key: "colour", type: "choice", def: "rack", choices: ["rack", "truckbox", "classic", "steel", "firewall"], label: "Colour" }, #{ key: "colour", type: "choice", def: "rack", choices: ["rack", "truckbox", "classic", "steel", "firewall"], label: "Colour" },
@@ -75,7 +75,7 @@ fn prefab_pv_field(o) {
} }
fn meta_chimney() { fn meta_chimney() {
#{ label: "Chimney / stack", category: "Equipment", icon: "▮", #{ label: "Chimney", category: "Equipment", icon: "▮",
props: [ props: [
#{ key: "h", type: "float", def: 12.0, min: 2.0, max: 120.0, step: 0.5, label: "Height (m)" }, #{ key: "h", type: "float", def: 12.0, min: 2.0, max: 120.0, step: 0.5, label: "Height (m)" },
#{ key: "r", type: "float", def: 0.5, min: 0.1, max: 5.0, step: 0.05, label: "Radius (m)" }, #{ key: "r", type: "float", def: 0.5, min: 0.1, max: 5.0, step: 0.05, label: "Radius (m)" },
+1 -1
View File
@@ -15,7 +15,7 @@ fn prefab_person(o) {
} }
fn meta_crowd() { fn meta_crowd() {
#{ label: "Group of people", category: "People", icon: "👥", #{ label: "Crowd", category: "People", icon: "👥",
props: [ props: [
#{ key: "n", type: "int", def: 5, min: 2, max: 40, step: 1, label: "People" }, #{ key: "n", type: "int", def: 5, min: 2, max: 40, step: 1, label: "People" },
#{ key: "r", type: "float", def: 2.0, min: 0.6, max: 15.0, step: 0.1, label: "Spread (m)" }, #{ key: "r", type: "float", def: 2.0, min: 0.6, max: 15.0, step: 0.1, label: "Spread (m)" },
+15 -4
View File
@@ -43,7 +43,9 @@ fn upsert(list: &mut Vec<(String, String)>, k: &str, v: String) {
fn strings(t: Option<&toml::Value>, what: &str) -> Result<Vec<(String, String)>, String> { fn strings(t: Option<&toml::Value>, what: &str) -> Result<Vec<(String, String)>, String> {
let Some(v) = t else { return Ok(vec![]) }; let Some(v) = t else { return Ok(vec![]) };
let tab = v.as_table().ok_or_else(|| format!("[{what}] must be a table"))?; let tab = v
.as_table()
.ok_or_else(|| format!("[{what}] must be a table"))?;
tab.iter() tab.iter()
.map(|(k, v)| match v { .map(|(k, v)| match v {
toml::Value::String(s) => Ok((k.clone(), s.clone())), toml::Value::String(s) => Ok((k.clone(), s.clone())),
@@ -58,7 +60,8 @@ impl Theme {
/// The built-in default theme. /// The built-in default theme.
pub fn default_theme() -> Self { pub fn default_theme() -> Self {
let mut t = Theme::default(); let mut t = Theme::default();
t.merge_str(DEFAULT, "default").expect("built-in theme parses"); t.merge_str(DEFAULT, "default")
.expect("built-in theme parses");
t t
} }
@@ -79,7 +82,11 @@ impl Theme {
for (k, v) in strings(tab.get("rules"), "rules").map_err(err)? { for (k, v) in strings(tab.get("rules"), "rules").map_err(err)? {
upsert(&mut self.rules, &k, v); upsert(&mut self.rules, &k, v);
} }
if let Some(css) = tab.get("extra").and_then(|e| e.get("css")).and_then(|c| c.as_str()) { if let Some(css) = tab
.get("extra")
.and_then(|e| e.get("css"))
.and_then(|c| c.as_str())
{
self.extra.push_str(css); self.extra.push_str(css);
self.extra.push('\n'); self.extra.push('\n');
} }
@@ -118,7 +125,11 @@ impl Theme {
match light { match light {
None => { None => {
block(&mut o, scope, &self.dark); block(&mut o, scope, &self.dark);
block(&mut o, &format!("html[data-theme=light] {scope}"), &self.light); block(
&mut o,
&format!("html[data-theme=light] {scope}"),
&self.light,
);
} }
Some(false) => block(&mut o, scope, &self.dark), Some(false) => block(&mut o, scope, &self.dark),
Some(true) => { Some(true) => {
+53
View File
@@ -0,0 +1,53 @@
# Roadmap
Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
## Phase 1: engine, Studio, MCP (done)
- [x] Core: projection with 4 rotations, primitives (box, prism, cylinder, frustum, face, poly, flat,
openings, lines, disc, circle, stem, figures, puffs, fan, raw, groups, sequences), sprites with
90° object frames, depth sort ported from the JS views, SVG writer, tags, tooltips
- [x] Theme: built-in palette, materials and rules converted from the Mooban/CNX CSS; TOML overrides
- [x] Rhai runtime: drawing API, module imports from memory, errors with file:line, safety limits
- [x] Prefab library: site, area, road, parking, wall, fence, lamp, marker, tree, tree row, hedge,
building (storeys, windows, door, flat/gable roof, solar, AC, cut-open levels), carport, shed,
car (driving), truck, motorbike, person, crowd, tank, container, generator, solar field, chimney
- [x] Scene document + prefab catalog with property schemas
- [x] Project folder, ops (add/update/remove/duplicate/reorder/set_cfg/replace), Workspace (save, undo,
reload on outside changes), check, static HTML page, SVG export
- [x] Viewer JS: port of the views' mount() (pan, pinch, wheel, declutter, tooltips, full screen) for
prerendered, server and future WASM sources
- [x] Studio GUI: palette, object list, placing, drag-to-move with snap, keyboard, forms from schemas,
levels, undo/redo, live follow of agent edits, MCP connection panel
- [x] MCP: 15 tools over stdio and HTTP (`/mcp`), validation messages for agents
- [x] Demo project, 52 Rust tests, Playwright Studio + MCP test
## Phase 2: richer editing
- [ ] Interior prefabs (desk, table with chairs, shelf, machine, bed, sofa, partition walls) and a
building `rooms` prop, so the GF/1F levels show something
- [ ] Path objects in the Studio: click points for areas, roads (polyline with width), fences, walls,
pipe runs; vertex handles
- [ ] Multi-select (box select, Shift-click), group move, align/distribute
- [ ] Copy/paste between projects; prefab thumbnails in the palette (rendered icons)
- [ ] Measure tool and grid overlay; numeric resize handles on the selection
- [ ] Studio editor for `theme.toml` (colour pickers) and for project parameters
## Phase 3: embedding in the other projects
- [ ] WASM build of isokit-script + core: the same scene recomputes in the browser when a host
page's calculator changes the parameters (cnx_vinyl fleet calculator)
- [ ] `isokit build --fragment`: `<style>` + `<script>` blocks for the Python build scripts of the
host pages (replace `inline.py`)
- [ ] Host API parity with CNXIso (`mount`, `update(cfg)`, `setLevel`, `focus`, `info`, `onPick`)
- [ ] Port the existing scenes: mooban estate, CNX Scuba centre (section cut), CNX Vinyl plant;
check each against its old view with the QA checklist from the skill
## Phase 4: output and QA
- [ ] Section cut layer (underground scenes) from iso-scuba: cut plane, clip-path per cavity
- [ ] PNG export (static style resolution + resvg) so agents get an image from `render_view`
- [ ] `isokit check` rules from the QA checklist: props on paved areas, runs ending in the air,
covers drawn over their contents, tag overlaps
- [ ] Performance budget test (render ms per view, 60 fps pan) on a 3,500-node scene
- [ ] Packaged desktop app (Tauri) for users without a terminal; hosted Studio behind login
+117
View File
@@ -0,0 +1,117 @@
# Scripting and prefabs
Scenes and prefabs are written in [Rhai](https://rhai.rs/book/), a small
scripting language embedded in isokit. Nothing is compiled: save the file and
the Studio redraws.
## A prefab
A prefab is an object type that can be placed in the Studio. It is two
functions in a module that is listed in `isokit.toml` under `prelude`:
```rust
fn meta_kiosk() { // label, palette group and property form
#{ label: "Gate kiosk", category: "Site", icon: "⌂", help: "…",
props: [
#{ key: "h", type: "float", def: 2.8, min: 2.2, max: 4.0, step: 0.1, label: "Height (m)" },
#{ key: "boom", type: "bool", def: true, label: "Boom barrier" },
] }
}
fn prefab_kiosk(o) { // draw around (0, 0) in the object's own frame
let s = sprite(); // sprite ids default to the object id
s.box(-1.4, -1.1, 1.4, 1.1, 0.0, o.p.h, "brick", #{ top: "roof3" });
}
```
- Property types: `float`, `int`, `bool`, `choice` (with `choices`),
`material` (a theme material, optionally with `choices`), `text`.
Write `def:` for the default (`default` is a reserved word in Rhai).
- `o` holds `id, type, name, x, y, z, rot, p` (the props with defaults
filled in), `pts`, `tag`, `info`, `level`.
- The engine sets the object's frame before calling the prefab. All
coordinates are local: the origin is the object's position, and local x and y
turn with `rot`. `vis()`, `dv()`, `tag()` and `focus()` use the frame too.
- A prefab with the same name in the project overrides the built-in one.
- If a prefab fails, the engine logs a warning for that object and still draws the rest.
## Drawing API
Numbers can be ints or floats. Classes are CSS classes without the `iso-`
prefix (`"wall"` becomes `iso-wall`). A material sets the face colour, which is
shaded automatically by face direction.
### Scene
| Function | |
|---|---|
| `sprite()` / `sprite(id)` / `sprite(id, cls)` | new sprite (the unit of the depth sort) in the current layer |
| `ground()` | the shared unsorted ground sprite |
| `layer("ground" \| "floor" \| "main")`, `layer(name, #{sort, zfirst, cls, before})` | where new sprites go |
| `tag(x, y, z, text[, prio[, id[, kind]]])` | label; `kind` "signtag" / "warntag" always shows |
| `info(id, name[, detail])`, `info(id, #{name, detail, rows: [[k, v], …]})` | tooltip |
| `focus(x, y, z)`, `bounds(x0, y0, x1, y1[, depth])`, `defs(svg)`, `warn(msg)` | framing and extras |
| `level()`, `rot()`, `cfg()`, `cfg(name)`, `objects()` | inputs |
| `vis(nx, ny)` | is a face with that outward normal turned to the viewer? |
| `dv(dx, dy, dz)` | screen vector of a world vector (CSS motion) |
| `along_x()` | should a mover run along x in this rotation? |
| `hash(x)`, `fmt(n)`, `round1(x)`, `fmin`, `fmax`, `clamp`, `px()` | helpers |
| `rect(x0, y0, x1, y1)`, `ellipse(cx, cy, a, b, n)`, `rsegs(r)`, `sides(x0, y0, x1, y1)` | geometry |
### Sprite methods (chainable)
| Method | |
|---|---|
| `box(x0, y0, x1, y1, z0, z1, mat[, #{id, top, side, no_top, no_side}])` | axis-aligned box |
| `prism(pts, z0, z1, mat[, opts])` | convex counter-clockwise footprint; z0/z1 one value or one per vertex |
| `cyl(cx, cy, r, z0, z1, mat[, opts])`, `frust(cx, cy, r0, r1, z0, z1, mat[, sides[, opts]])` | round things |
| `slope(x0, x1, y0, y1, za, zb, th, mat)` | ramp / stair flight |
| `face(pts3, mat[, #{id, two_sided}])` | any planar polygon, shaded and culled by its normal (roof slopes, gables) |
| `poly(polys3, cls[, id])` | 3D polygons as one path (solar modules) |
| `flat(poly or polys, z, cls[, id])` | flat polygon(s); later ones cut holes |
| `open(a, b, [[u0, u1, z0, z1], …], cls[, id])` | windows / doors on the face a→b (outward normal on the right) |
| `lines(segs, z, cls)`, `disc(cx, cy, z, r, cls[, id])` | markings |
| `circle(x, y, z, r_px, cls[, dx, dy])`, `stem(x, y, z0, z1, w_px, cls)` | screen-space details (crowns, poles) |
| `person(x, y, z[, cls])`, `sit(x, y, z, cls)`, `head(x, y, z, r)`, `led(x, y, z[, cls])` | figures |
| `puffs(x, y, z[, kind[, n, dur]])`, `fan(x, y, z, r)` | animated steam / smoke / fans |
| `seq([[x, y, \|q\| …], …])` | items drawn back to front by their anchor |
| `group(cls[, css], \|q\| …)`, `begin(cls[, css])` … `end()` | `<g>` around parts (animation) |
| `ext(x, y, z)`, `ext_box(…)` | widen the sort box |
| `raw([x, y, z,] svg)` | raw SVG (`{uid}` becomes the view's id) |
Library helpers (`stdlib/*.rhai`): `s.tree_at(x, y, r)`, `s.car(x0, y0, x1, y1, cls)`,
`s.drive(dx, dy, dur, delay)` … `s.end()`, `cut_walls(...)`, `win_list(...)`,
`floor_of(level)`, `building_windows`, `gable_roof`, `flat_roof_kit`.
## Rules that keep the depth sort right
These are lessons from the CNX/Mooban views, and the engine relies on them:
- **One sprite per thing that can stand in front of another.** Split long walls,
fences and solar rows into pieces (≤ 30 m). One big sprite overlapping many
others creates sort cycles (`isokit check` reports them).
- **Things under a cover belong in the cover's sprite**: draw cars under a carport
before its posts and roof (see `prefab_carport`).
- **Floors of cut-open storeys go in the `floor` layer** (ground floor) or in
the same sprite as the storey below. Start walls and furniture standing on
them a little above the floor (+0.02 m).
- **Movers** (`drive`) run along the axis the sort compares second
(`along_x()`). Along the other axis they stay still.
- Street furniture stays off drive areas, and pipe runs end on walls or supports.
## Themes
`theme.toml` stacks on the built-in palette:
```toml
[palette.dark]
brick = "#8a4f3e"
[palette.light]
brick = "#b8664c"
[materials]
brick = "brick" # class iso-brick, colour var(--iso-brick)
[rules]
".iso-svg .iso-sign2" = "fill: #ffcc00; stroke: none"
```
`isokit check` warns about any class in the drawing that has no style.
+38
View File
@@ -0,0 +1,38 @@
# isokit demo: a small works site. Open it with `isokit studio examples/demo`.
title = "isokit demo works"
note = "Demo scene for isokit. Drag to pan, Ctrl + wheel or pinch to zoom, tap anything for details; GF and 1F cut the building open."
default_level = "site"
default_rotation = 1
prelude = ["lib/kiosk.rhai"]
theme = ["theme.toml"]
[[level]]
id = "site"
label = "Site"
title = "Closed buildings and roofs"
[[level]]
id = "gf"
label = "GF"
title = "Ground floor, cut open"
[[level]]
id = "1f"
label = "1F"
title = "First floor, 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
+27
View File
@@ -0,0 +1,27 @@
// A project prefab: shows up as "Gate kiosk" in the Studio palette.
fn meta_kiosk() {
#{ label: "Gate kiosk", category: "Site", icon: "⌂",
help: "A small kiosk with a boom barrier (project prefab, lib/kiosk.rhai).",
props: [
#{ key: "h", type: "float", def: 2.8, min: 2.2, max: 4.0, step: 0.1, label: "Height (m)" },
#{ key: "boom", type: "bool", def: true, label: "Boom barrier" },
#{ key: "boom_len", type: "float", def: 5.0, min: 2.0, max: 8.0, step: 0.5, label: "Boom length (m)" },
] }
}
fn prefab_kiosk(o) {
let p = o.p;
let s = sprite();
s.box(-1.5, -1.2, 1.5, 1.2, 0.0, 0.2, "plinth");
s.box(-1.4, -1.1, 1.4, 1.1, 0.2, p.h, "brick", #{ top: "roof3" });
let sd = sides(-1.4, -1.1, 1.4, 1.1);
s.open(sd.S[0], sd.S[1], [[0.3, 1.5, 1.1, 2.0]], "glass");
s.open(sd.E[0], sd.E[1], [[0.6, 1.4, 0.2, 2.3]], "door2");
if p.boom {
// the boom is its own sprite so cars and people sort around it
let b = sprite();
b.box(1.7, -1.5, 1.95, -1.25, 0.0, 1.0, "boom");
b.box(1.75, -1.5 - p.boom_len, 1.9, -1.45, 0.9, 1.0, "boom");
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"version": 1,
"objects": [
{ "id": "site", "type": "site", "name": "Site", "x": 0, "y": 0, "props": { "w": 70, "d": 46 }, "info": "70 x 46 m plot", "locked": true },
{ "id": "road", "type": "road", "name": "Access road", "x": 0, "y": -19.5, "props": { "w": 70, "d": 6 } },
{ "id": "yard", "type": "area", "name": "Service yard", "x": 14, "y": 4, "props": { "w": 24, "d": 18, "mat": "g-conc" } },
{ "id": "parking", "type": "parking", "name": "Staff parking", "x": -20, "y": -12, "props": { "n": 8 } },
{ "id": "hall", "type": "building", "name": "Workshop and offices", "x": -12, "y": 6, "props": { "w": 24, "d": 12, "floors": 2, "pv": true }, "tag": "Workshop", "info": "Two storeys, 24 x 12 m, solar on the roof" },
{ "id": "carport", "type": "carport", "name": "Carport", "x": 14, "y": -10, "props": { "w": 12, "cars": 4, "pv": true }, "info": "Four bays with solar on the canopy" },
{ "id": "tank", "type": "tank", "name": "Water tank", "x": 22, "y": 9, "props": { "r": 2.2, "h": 6, "mat": "water" }, "tag": "Water", "info": "90 m3 process water" },
{ "id": "genset", "type": "generator", "name": "Standby generator", "x": 8, "y": 10, "props": { "running": true } },
{ "id": "stack", "type": "chimney", "name": "Boiler stack", "x": 30, "y": 12, "props": { "h": 14, "smoke": "puff" } },
{ "id": "truck", "type": "truck", "name": "Delivery truck", "x": 15, "y": 1 },
{ "id": "kiosk", "type": "kiosk", "name": "Gate kiosk", "x": -30, "y": -4, "tag": "Gate" },
{ "id": "crowd", "type": "crowd", "name": "Visitors", "x": -12, "y": -3, "props": { "n": 5 } },
{ "id": "car-1", "type": "car", "x": -28.75, "y": -12, "rot": 90, "props": { "colour": "car1" } },
{ "id": "car-2", "type": "car", "x": -23.75, "y": -12, "rot": 90, "props": { "colour": "car3" } },
{ "id": "car-3", "type": "car", "x": -16.25, "y": -12, "rot": 90, "props": { "colour": "car4" } },
{ "id": "bus", "type": "car", "name": "Passing car", "x": -25, "y": -18, "props": { "colour": "car2", "moving": true, "travel": 45, "dur": 12 } },
{ "id": "trees-north", "type": "tree_row", "name": "Tree belt", "x": 0, "y": 19.5, "props": { "w": 62, "spacing": 7 } },
{ "id": "tree-1", "type": "tree", "x": 30, "y": -2, "props": { "r": 2.0 } },
{ "id": "tree-2", "type": "tree", "x": -31, "y": 8 },
{ "id": "fence-east", "type": "fence", "name": "East fence", "x": 34.6, "y": 0, "rot": 90, "props": { "w": 40 } },
{ "id": "lamp-1", "type": "lamp", "x": -25, "y": -15.6 },
{ "id": "lamp-2", "type": "lamp", "x": -5, "y": -15.6 },
{ "id": "lamp-3", "type": "lamp", "x": 15, "y": -15.6 },
{ "id": "assembly", "type": "marker", "name": "Assembly point", "x": 30, "y": -14.5, "tag": "Assembly point", "info": "Fire assembly point" }
]
}
+11
View File
@@ -0,0 +1,11 @@
// Optional script, drawn before the objects of scene.json.
// Everything placed in the Studio lives in scene.json; use this file for
// things that are easier to code (repeats, data-driven layouts).
let s = sprite("sign");
s.box(-34.0, -15.7, -33.85, -15.55, 0.0, 2.4, "steel", #{ no_top: true });
s.box(-34.6, -15.75, -33.25, -15.5, 1.5, 2.5, "brick");
info("sign", "Site sign", "Drawn by scene.rhai, not by the Studio");
// open the view on the workshop on small screens
focus(-12.0, 6.0, 0.0);
+9
View File
@@ -0,0 +1,9 @@
# Demo theme: adds a brick material on top of the built-in theme.
[palette.dark]
brick = "#8a4f3e"
[palette.light]
brick = "#b8664c"
[materials]
brick = "brick"
+76
View File
@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>isokit Studio</title>
<script>
(function(){var t=null;try{t=localStorage.getItem("isokit-theme")}catch(e){}
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
document.documentElement.setAttribute("data-theme",t);})();
</script>
<link rel="stylesheet" href="/assets/viewer/isokit-view.css">
<link rel="stylesheet" href="/assets/theme.css">
<link rel="stylesheet" href="/assets/studio/studio.css">
</head>
<body>
<header class="st-head">
<b class="st-logo">isokit <span>Studio</span></b>
<span class="st-title" id="title"></span>
<span class="st-grow"></span>
<button type="button" id="undo" title="Undo (Ctrl+Z)" disabled>&#x21B6; Undo</button>
<button type="button" id="redo" title="Redo (Ctrl+Y)" disabled>&#x21B7; Redo</button>
<span class="st-save" id="save" title="Every change is saved to scene.json at once">Saved</span>
<span class="st-sep"></span>
<a class="st-btn" href="/view" target="_blank" rel="noopener" title="The scene as visitors see it (live)">Preview</a>
<button type="button" id="build" title="Write the self-contained HTML page">Build page</button>
<button type="button" id="theme" title="Light / dark">&#x25D0;</button>
<button type="button" id="help" title="Keys and tips">?</button>
</header>
<main class="st-main">
<aside class="st-left">
<section class="st-panel">
<h2>Add <small>click, then click on the map</small></h2>
<input type="search" id="psearch" placeholder="Search objects…" autocomplete="off">
<div id="palette" class="st-palette"></div>
</section>
<section class="st-panel st-grow-panel">
<h2>Objects <small id="ocount"></small></h2>
<input type="search" id="osearch" placeholder="Filter…" autocomplete="off">
<ul id="objects" class="st-objects"></ul>
</section>
</aside>
<section class="st-centre">
<div id="view" class="iso-fill"></div>
<div class="st-hint" id="hint" hidden></div>
<footer class="st-status">
<span id="cursor">x – y –</span>
<label title="Snap positions to a grid">Snap
<select id="snap"><option value="0">off</option><option value="0.1">0.1 m</option><option value="0.5" selected>0.5 m</option><option value="1">1 m</option><option value="5">5 m</option></select>
</label>
<span class="st-grow"></span>
<button type="button" id="warnbtn" class="st-link" hidden></button>
<span id="stats"></span>
</footer>
</section>
<aside class="st-right" id="right"></aside>
</main>
<div class="st-toast" id="toast" hidden></div>
<dialog id="helpdlg" class="st-dialog">
<h3>isokit Studio</h3>
<table>
<tr><th>Add</th><td>pick an object on the left, click on the map (Shift-click to place several)</td></tr>
<tr><th>Select</th><td>click it on the map or in the list</td></tr>
<tr><th>Move</th><td>drag it, or arrow keys (by the snap step)</td></tr>
<tr><th>Turn</th><td>R / Shift+R, or the ⟲ ⟳ buttons</td></tr>
<tr><th>Copy / delete</th><td>Ctrl+D / Delete</td></tr>
<tr><th>Undo / redo</th><td>Ctrl+Z / Ctrl+Y</td></tr>
<tr><th>View</th><td>drag the ground to pan, Ctrl + wheel or pinch to zoom, ↺ ↻ to rotate, levels to look inside</td></tr>
<tr><th>Agents</th><td>connect an AI agent over MCP; its edits appear here live (see the panel when nothing is selected)</td></tr>
</table>
<form method="dialog"><button>Close</button></form>
</dialog>
<script src="/assets/viewer/isokit-view.js"></script>
<script src="/assets/studio/studio.js"></script>
</body>
</html>
+87
View File
@@ -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); }
}
+360
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
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 '<div><div class="st-cat">' + esc(c) + '</div><div class="st-tiles">' + cats[c].map(function (m) {
return '<button type="button" class="st-tile" data-type="' + esc(m.type) + '" title="' + esc(m.help || m.label) + '" aria-pressed="' + (S.placing === m.type) + '"><i>' + esc(m.icon || ICON_DEFAULT) + "</i><span>" + esc(m.label) + "</span></button>";
}).join("") + "</div></div>";
}).join("") || '<div class="st-help">No object matches.</div>';
}
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 '<li data-id="' + esc(o.id) + '" aria-selected="' + (o.id === S.sel) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
}).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 '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : html); }
function propInput(d, v) {
var id = "p-" + d.key, val = v === undefined ? d.default : v;
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + ">";
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 '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + ch.map(function (c) { return "<option" + (c === val ? " selected" : "") + ">" + esc(c) + "</option>"; }).join("") + "</select>";
}
if (d.type === "material") return '<input id="' + id + '" data-prop="' + esc(d.key) + '" list="materials" value="' + esc(val) + '">';
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 ? '<input type="range" data-prop="' + esc(d.key) + '" data-range="1"' + mm + ' step="' + step + '" value="' + esc(val) + '">' : "<span></span>";
return '<div class="st-num">' + range + '<input type="number" id="' + id + '" data-prop="' + esc(d.key) + '"' + mm + ' step="' + step + '" value="' + esc(val) + '"></div>';
}
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '">';
}
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 = "<h3><i>" + esc(m.icon || ICON_DEFAULT) + "</i>" + esc(label(o)) + "</h3>" +
'<div class="st-sub">' + esc(m.label) + " · id " + esc(o.id) + "</div>" +
(m.help ? '<p class="st-help">' + esc(m.help) + "</p>" : "") +
'<div class="st-actions">' +
'<button type="button" data-act="dup" title="Ctrl+D">Duplicate</button>' +
'<button type="button" data-act="focus" title="Zoom to it">Zoom to</button>' +
'<button type="button" data-act="front" title="Draw after the others (ground items)">To front</button>' +
'<button type="button" data-act="back" title="Draw before the others (ground items)">To back</button>' +
'<button type="button" data-act="lock">' + (o.locked ? "Unlock" : "Lock") + "</button>" +
'<button type="button" data-act="hide">' + (o.hidden ? "Show" : "Hide") + "</button>" +
'<button type="button" data-act="del" class="st-danger" title="Delete">Delete</button></div>' +
S.warnings.filter(function (w) { return w.indexOf("object " + o.id + ":") === 0; }).map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") +
'<h2>Placement</h2><div class="st-form">' +
field("xy", "Position x y z", '<div class="st-xy"><input type="number" step="any" data-f="x" value="' + o.x + '" title="x (east), m"><input type="number" step="any" data-f="y" value="' + o.y + '" title="y (north), m"><input type="number" step="any" data-f="z" value="' + (o.z || 0) + '" title="z (height), m"></div>') +
field("rot", "Rotation", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>' + (o.rot || 0) + '°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') +
"</div>" +
(m.props.length ? "<h2>Properties</h2><div class=\"st-form\">" + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "</div>" : "") +
'<h2>Label and tooltip</h2><div class="st-form">' +
field("name", "Name", '<input data-f="name" value="' + esc(o.name || "") + '" placeholder="' + esc(m.label) + '">') +
field("tag", "Tag", '<input data-f="tag" value="' + esc(o.tag || "") + '" placeholder="label on the map">') +
field("info", "Tooltip", '<textarea data-f="info" rows="2" placeholder="shown on hover / tap">' + esc(o.info || "") + "</textarea>") +
"</div>" +
(levels.length > 1 ? '<h2>Shown on levels</h2><div class="st-levels">' + levels.map(function (l) {
return '<label><input type="checkbox" data-level="' + esc(l.id) + '"' + ((o.levels || []).indexOf(l.id) >= 0 ? " checked" : "") + "> " + esc(l.label || l.id) + "</label>";
}).join("") + '</div><p class="st-help">None ticked: shown on every level.</p>' : "") +
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
R.innerHTML = h;
}
function renderProject(R) {
var m = S.meta, cfg = S.doc.cfg || {};
var base = location.origin;
R.innerHTML = "<h3>" + esc(m.title) + "</h3>" + '<div class="st-sub">' + (S.doc.objects || []).length + " objects · " + (m.catalog || []).length + " object types</div>" +
(m.params && m.params.length ? "<h2>Scene options</h2><div class=\"st-form\">" + 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("") + "</div>" : "") +
(S.warnings.length ? "<h2>Warnings</h2>" + S.warnings.map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") : "") +
'<div class="st-box"><h2>Connect an AI agent (MCP)</h2>' +
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live.</p>' +
"HTTP (while the Studio runs):<code>" + esc(base) + "/mcp</code>" +
"stdio (any time):<code>isokit mcp " + esc(m.dir || ".") + "</code></div>" +
'<p class="st-help">Select an object to edit it, or pick one on the left to add it.</p>';
}
$("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();
});
})();
+143
View File
@@ -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)
+164
View File
@@ -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; }
+472
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 ? '<span class="iso-seg" role="group" aria-label="Level">' + levels.map(function (l) {
return '<button type="button" data-a="level" data-level="' + esc(l.id) + '" title="' + esc(l.title || l.label || l.id) + '">' + esc(l.label || l.id) + "</button>";
}).join("") + "</span>" : "";
wrap.innerHTML = '<div class="iso-stage"><svg class="iso-svg" xmlns="http://www.w3.org/2000/svg" role="img" tabindex="0" aria-label="' + esc(meta.title || "Isometric view") + '"></svg>' +
'<div class="iso-bar" role="toolbar" aria-label="View">' + segs +
'<button type="button" data-a="rotl" title="Rotate left" aria-label="Rotate the view left">&#x21BA;</button>' +
'<button type="button" data-a="rotr" title="Rotate right" aria-label="Rotate the view right">&#x21BB;</button>' +
'<span class="iso-compass" title="North" aria-hidden="true"><svg viewBox="-10 -10 20 20"><g class="iso-needle"><path d="M0 -8L3 3L0 1.4L-3 3Z"/></g></svg><b>N</b></span>' +
'<button type="button" data-a="zout" title="Zoom out" aria-label="Zoom out">&#x2212;</button>' +
'<button type="button" data-a="zin" title="Zoom in" aria-label="Zoom in">+</button>' +
'<button type="button" data-a="fit" title="Show everything" aria-label="Fit the view">Fit</button>' +
(opts.fullscreen === false ? "" : '<button type="button" data-a="full" class="iso-fullbtn" title="Full screen (F)" aria-label="Full screen" aria-pressed="false">' +
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path class="iso-fs-in" d="M1.5 6V1.5H6M10 1.5h4.5V6M14.5 10v4.5H10M6 14.5H1.5V10"/>' +
'<path class="iso-fs-out" d="M6 1.5V6H1.5M10 1.5V6h4.5M14.5 10H10v4.5M1.5 10H6v4.5"/></svg></button>') + "</div>" +
'<div class="iso-tip" role="status" hidden></div><div class="iso-err" hidden></div><div class="iso-busy" hidden></div></div>' +
(opts.note === false || !meta.note ? "" : '<p class="iso-note">' + esc(meta.note) + "</p>");
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 = "<b>Scene error</b><span>" + esc((e.file ? e.file + (e.line ? ":" + e.line : "") + ": " : "") + e.message) + "</span>" +
(e.trace && e.trace.length ? "<small>" + e.trace.map(esc).join("<br>") + "</small>" : "");
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 ? "<table>" + info.rows.map(function (r) { return "<tr><th>" + esc(r[0]) + "</th><td>" + esc(r[1]) + "</td></tr>"; }).join("") + "</table>" : "";
tip.innerHTML = "<b>" + esc(info.name) + "</b>" + (info.detail ? "<span>" + esc(info.detail) + "</span>" : "") + 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);
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title}}</title>
<!-- Built by isokit (https://git.b4l.co.th/B4L/isokit) from the project's isokit.toml, scene.json and scripts. Edit those, not this file. -->
<script>
(function(){var t=null;try{t=localStorage.getItem("isokit-theme")}catch(e){}
if(t!=="light"&&t!=="dark")t=window.matchMedia&&matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";
document.documentElement.setAttribute("data-theme",t);})();
</script>
<style>
:root { color-scheme: dark; --bg: #0a0f1a; --panel: #0f1626; --stroke: #2a3a56; --fg: #d6deeb; --mute: #8a97b0; --accent: #3ddc84; }
html[data-theme=light] { color-scheme: light; --bg: #f4f6fa; --panel: #ffffff; --stroke: #cfd8e6; --fg: #1b2533; --mute: #5b6880; --accent: #0e8f4f; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
header, .wrap { max-width: 1320px; margin: 0 auto; padding: 0 20px; }
header { padding-top: 24px; display: flex; align-items: center; gap: 12px; }
h1 { margin: 0; font-size: 24px; flex: 1; }
.themebtn { background: var(--panel); color: var(--fg); border: 1px solid var(--stroke); border-radius: 5px; padding: 6px 12px; font: inherit; font-size: 14px; cursor: pointer; }
.themebtn:hover { border-color: var(--accent); }
.card { background: var(--panel); border: 1px solid var(--stroke); border-radius: 8px; padding: 14px; margin: 16px 0; }
.picked { min-height: 44px; color: var(--mute); font-size: 14px; }
.picked b { color: var(--fg); }
.picked table { border-collapse: collapse; margin-top: 4px; }
.picked th { text-align: left; font-weight: 400; padding: 1px 14px 1px 0; }
.picked td { color: var(--fg); }
footer { color: var(--mute); font-size: 12px; padding: 0 0 24px; }
/*ISOKIT_CSS*/
</style>
</head>
<body>
<header><h1>{{title}}</h1><button class="themebtn" id="themebtn" type="button">Light / dark</button></header>
<div class="wrap">
<div class="card"><div id="view"></div></div>
<div class="card picked" id="picked">Tap or click anything for its details.</div>
<footer>Made with isokit · built {{date}}</footer>
</div>
<script id="isokit-data" type="application/json">/*ISOKIT_DATA*/</script>
<script>
/*ISOKIT_JS*/
</script>
<script>
(function () {
var data = JSON.parse(document.getElementById("isokit-data").textContent);
var picked = document.getElementById("picked");
function esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
var view = IsoKit.mount(document.getElementById("view"), IsoKit.staticSource(data), {
fullscreen: document.querySelector(".card"),
onPick: function (i) {
if (!i) { picked.textContent = "Tap or click anything for its details."; return; }
picked.innerHTML = "<b>" + esc(i.name) + "</b>" + (i.detail ? "<br>" + esc(i.detail) : "") +
(i.rows && i.rows.length ? "<table>" + i.rows.map(function (r) { return "<tr><th>" + esc(r[0]) + "</th><td>" + esc(r[1]) + "</td></tr>"; }).join("") + "</table>" : "");
}
});
window.__isokit = { view: view, data: data };
document.getElementById("themebtn").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 */ }
});
})();
</script>
</body>
</html>