isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs
This commit is contained in:
1 parent
95c80ec6b1
commit
564aeb75f6
47 files changed
+5927
-315
No files matched your search
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
target/
|
||||
/dist/
|
||||
dist/
|
||||
*.log
|
||||
__pycache__/
|
||||
*.json.tmp
|
||||
Generated
+202
@@ -16,6 +16,68 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -49,6 +111,58 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chunked_transfer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.10.0"
|
||||
@@ -148,6 +262,18 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -158,9 +284,22 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "isokit-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"isokit-project",
|
||||
"serde_json",
|
||||
"tiny_http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isokit-core"
|
||||
@@ -169,6 +308,18 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isokit-project"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"isokit-core",
|
||||
"isokit-script",
|
||||
"isokit-theme",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isokit-script"
|
||||
version = "0.1.0"
|
||||
@@ -209,6 +360,12 @@ version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -233,6 +390,12 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -389,6 +552,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -426,6 +595,18 @@ dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||
dependencies = [
|
||||
"ascii",
|
||||
"chunked_transfer",
|
||||
"httpdate",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
@@ -471,6 +652,12 @@ version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -547,6 +734,21 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
|
||||
@@ -4,6 +4,7 @@ members = [
|
||||
"crates/isokit-core",
|
||||
"crates/isokit-script",
|
||||
"crates/isokit-theme",
|
||||
"crates/isokit-project",
|
||||
"crates/isokit-cli",
|
||||
]
|
||||
|
||||
@@ -18,6 +19,7 @@ repository = "https://git.b4l.co.th/B4L/isokit"
|
||||
isokit-core = { path = "crates/isokit-core" }
|
||||
isokit-script = { path = "crates/isokit-script" }
|
||||
isokit-theme = { path = "crates/isokit-theme" }
|
||||
isokit-project = { path = "crates/isokit-project" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = { version = "1", features = ["preserve_order"] }
|
||||
|
||||
@@ -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`.
|
||||
@@ -1,8 +1,17 @@
|
||||
[package]
|
||||
name = "isokit-cli"
|
||||
description = "isokit: isometric scene engine - Studio GUI, MCP server, build and export"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "isokit"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
isokit-project = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ pub fn r1(out: &mut String, v: f64) {
|
||||
}
|
||||
let a = t.unsigned_abs();
|
||||
let _ = write!(out, "{}", a / 10);
|
||||
if a % 10 != 0 {
|
||||
if !a.is_multiple_of(10) {
|
||||
let _ = write!(out, ".{}", a % 10);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ pub fn thousands(n: f64) -> String {
|
||||
let s = v.unsigned_abs().to_string();
|
||||
let mut o = String::new();
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i) % 3 == 0 {
|
||||
if i > 0 && (s.len() - i).is_multiple_of(3) {
|
||||
o.push(',');
|
||||
}
|
||||
o.push(ch);
|
||||
|
||||
@@ -46,7 +46,10 @@ pub struct Style {
|
||||
|
||||
impl Style {
|
||||
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 {
|
||||
self.id = Some(id.into());
|
||||
@@ -75,44 +78,116 @@ impl Style {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Prim {
|
||||
/// Vertical prism over a convex counter-clockwise footprint.
|
||||
Prism { pts: Vec<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).
|
||||
Cyl { c: P2, r: f64, z0: f64, z1: f64, st: Style },
|
||||
Cyl {
|
||||
c: P2,
|
||||
r: f64,
|
||||
z0: f64,
|
||||
z1: f64,
|
||||
st: Style,
|
||||
},
|
||||
/// Frustum (cone) with n sides: radius r0 at z0, r1 at z1.
|
||||
Frust { c: P2, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style },
|
||||
Frust {
|
||||
c: P2,
|
||||
r0: f64,
|
||||
r1: f64,
|
||||
z0: f64,
|
||||
z1: f64,
|
||||
n: u32,
|
||||
st: Style,
|
||||
},
|
||||
/// Openings (windows, doors) on the vertical face through a -> b; the
|
||||
/// outward normal is on the right of a -> b. Each entry is
|
||||
/// [u0, u1, z0, z1]: distance along the face and heights.
|
||||
Open { a: P2, b: P2, list: Vec<[f64; 4]>, cls: String, id: Option<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 { 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.
|
||||
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.
|
||||
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
|
||||
/// normal: mostly upward = top, else left/centre/right by direction.
|
||||
/// One-sided faces (counter-clockwise seen from outside) are culled
|
||||
/// when turned away; two-sided faces always draw.
|
||||
Face { pts: Vec<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,
|
||||
/// 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,
|
||||
/// 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:
|
||||
/// 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).
|
||||
Puffs { at: P3, kind: String, n: u32, dur: f64 },
|
||||
Puffs {
|
||||
at: P3,
|
||||
kind: String,
|
||||
n: u32,
|
||||
dur: f64,
|
||||
},
|
||||
/// Spinning fan lying in the plane at height at.z (CSS animation).
|
||||
Fan { at: P3, r: f64 },
|
||||
/// Raw SVG translated to a world point (or untranslated without one).
|
||||
/// `{uid}` in the text is replaced by the view's unique id.
|
||||
Raw { at: Option<P3>, svg: String },
|
||||
/// `<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.
|
||||
Seq { items: Vec<(P2, Vec<Prim>)> },
|
||||
}
|
||||
@@ -18,7 +18,9 @@ pub struct Proj {
|
||||
|
||||
impl Proj {
|
||||
pub fn new(rot: i64) -> Self {
|
||||
Proj { rot: rot.rem_euclid(4) as u8 }
|
||||
Proj {
|
||||
rot: rot.rem_euclid(4) as u8,
|
||||
}
|
||||
}
|
||||
|
||||
/// World plan point to view coordinates.
|
||||
@@ -147,7 +149,10 @@ mod tests {
|
||||
let p = Proj::new(rot);
|
||||
let s = p.s(12.5, -3.25, 2.0);
|
||||
let w = p.unproject(s[0], s[1], 2.0);
|
||||
assert!((w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9, "rot {rot}: {w:?}");
|
||||
assert!(
|
||||
(w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9,
|
||||
"rot {rot}: {w:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ pub struct RenderOpts {
|
||||
|
||||
impl Default for RenderOpts {
|
||||
fn default() -> Self {
|
||||
RenderOpts { uid: 0, labels: true, dev: false }
|
||||
RenderOpts {
|
||||
uid: 0,
|
||||
labels: true,
|
||||
dev: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +105,9 @@ impl Ctx<'_> {
|
||||
if !st.no_side {
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { continue };
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else {
|
||||
continue;
|
||||
};
|
||||
let j = (i + 1) % n;
|
||||
if z1.at(i) - z0.at(i) < 1e-6 && z1.at(j) - z0.at(j) < 1e-6 {
|
||||
continue;
|
||||
@@ -109,14 +115,23 @@ impl Ctx<'_> {
|
||||
self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id);
|
||||
p.d(
|
||||
&mut self.out,
|
||||
&[[a[0], a[1], z0.at(i)], [b[0], b[1], z0.at(j)], [b[0], b[1], z1.at(j)], [a[0], a[1], z1.at(i)]],
|
||||
&[
|
||||
[a[0], a[1], z0.at(i)],
|
||||
[b[0], b[1], z0.at(j)],
|
||||
[b[0], b[1], z1.at(j)],
|
||||
[a[0], a[1], z1.at(i)],
|
||||
],
|
||||
false,
|
||||
);
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
}
|
||||
if !st.no_top {
|
||||
let tp: Vec<[f64; 3]> = pts.iter().enumerate().map(|(i, q)| [q[0], q[1], z1.at(i)]).collect();
|
||||
let tp: Vec<[f64; 3]> = pts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, q)| [q[0], q[1], z1.at(i)])
|
||||
.collect();
|
||||
self.path_open(&format!("{} iso-t", st.top_cls()), id);
|
||||
p.d(&mut self.out, &tp, false);
|
||||
self.out.push_str("\"/>");
|
||||
@@ -150,7 +165,10 @@ impl Ctx<'_> {
|
||||
if !st.no_top {
|
||||
let _ = write!(self.out, "<ellipse class=\"{} iso-t\"", st.top_cls());
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = p.vis(am.cos(), am.sin()) else { continue };
|
||||
let Some(v) = p.vis(am.cos(), am.sin()) else {
|
||||
continue;
|
||||
};
|
||||
self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id);
|
||||
p.d(
|
||||
&mut self.out,
|
||||
@@ -194,7 +214,9 @@ impl Ctx<'_> {
|
||||
return;
|
||||
}
|
||||
let p = self.p;
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { return };
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else {
|
||||
return;
|
||||
};
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l = (dx * dx + dy * dy).sqrt().max(1e-9);
|
||||
let (ux, uy) = (dx / l, dy / l);
|
||||
@@ -262,7 +284,14 @@ impl Ctx<'_> {
|
||||
let rx = r * C * SQ2;
|
||||
let _ = write!(self.out, "<ellipse class=\"{cls}\"");
|
||||
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
|
||||
@@ -296,7 +325,11 @@ impl Ctx<'_> {
|
||||
nz = -nz;
|
||||
v = self.p.r(nx, ny);
|
||||
}
|
||||
let face = if nz >= 0.5 || v[0] + v[1] <= 1e-6 { "iso-t" } else { shade(v) };
|
||||
let face = if nz >= 0.5 || v[0] + v[1] <= 1e-6 {
|
||||
"iso-t"
|
||||
} else {
|
||||
shade(v)
|
||||
};
|
||||
self.path_open(&format!("{mat} {face}"), id);
|
||||
let p = self.p;
|
||||
p.d(&mut self.out, pts, false);
|
||||
@@ -307,12 +340,31 @@ impl Ctx<'_> {
|
||||
match pr {
|
||||
Prim::Prism { pts, z0, z1, st } => self.prism(pts, z0, z1, st),
|
||||
Prim::Cyl { c, r, z0, z1, st } => self.cyl(*c, *r, *z0, *z1, st),
|
||||
Prim::Frust { c, r0, r1, z0, z1, n, st } => self.frust(*c, *r0, *r1, *z0, *z1, *n, st),
|
||||
Prim::Open { a, b, list, cls, id } => self.open(*a, *b, list, cls, id.as_deref()),
|
||||
Prim::Frust {
|
||||
c,
|
||||
r0,
|
||||
r1,
|
||||
z0,
|
||||
z1,
|
||||
n,
|
||||
st,
|
||||
} => self.frust(*c, *r0, *r1, *z0, *z1, *n, st),
|
||||
Prim::Open {
|
||||
a,
|
||||
b,
|
||||
list,
|
||||
cls,
|
||||
id,
|
||||
} => self.open(*a, *b, list, cls, id.as_deref()),
|
||||
Prim::Flat { polys, z, cls, id } => self.flat(polys, *z, cls, id.as_deref()),
|
||||
Prim::Lines { segs, z, cls, id } => self.lines(segs, *z, cls, id.as_deref()),
|
||||
Prim::Disc { c, z, r, cls, id } => self.disc(*c, *z, *r, cls, id.as_deref()),
|
||||
Prim::Face { pts, mat, id, two_sided } => self.face(pts, mat, id.as_deref(), *two_sided),
|
||||
Prim::Face {
|
||||
pts,
|
||||
mat,
|
||||
id,
|
||||
two_sided,
|
||||
} => self.face(pts, mat, id.as_deref(), *two_sided),
|
||||
Prim::Poly { polys, cls, id } => {
|
||||
if polys.is_empty() {
|
||||
return;
|
||||
@@ -324,7 +376,13 @@ impl Ctx<'_> {
|
||||
}
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
Prim::Circle { at, r, off, cls, id } => {
|
||||
Prim::Circle {
|
||||
at,
|
||||
r,
|
||||
off,
|
||||
cls,
|
||||
id,
|
||||
} => {
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let _ = write!(self.out, "<circle class=\"{cls}\"");
|
||||
did(&mut self.out, id.as_deref());
|
||||
@@ -336,7 +394,14 @@ impl Ctx<'_> {
|
||||
r1s(*r)
|
||||
);
|
||||
}
|
||||
Prim::Stem { at, z0, z1, w, cls, id } => {
|
||||
Prim::Stem {
|
||||
at,
|
||||
z0,
|
||||
z1,
|
||||
w,
|
||||
cls,
|
||||
id,
|
||||
} => {
|
||||
let b = self.p.s(at[0], at[1], *z0);
|
||||
let t = self.p.s(at[0], at[1], *z1);
|
||||
self.path_open(cls, id.as_deref());
|
||||
@@ -355,7 +420,12 @@ impl Ctx<'_> {
|
||||
}
|
||||
Prim::Puffs { at, kind, n, dur } => {
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let _ = write!(self.out, "<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" {
|
||||
3.2
|
||||
} else if kind == "iso-puff2" {
|
||||
@@ -396,7 +466,12 @@ impl Ctx<'_> {
|
||||
match at {
|
||||
Some(at) => {
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let _ = write!(self.out, "<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),
|
||||
}
|
||||
@@ -457,7 +532,11 @@ pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) {
|
||||
pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
|
||||
let p = Proj::new(rot as i64);
|
||||
let uid = opts.uid.to_string();
|
||||
let mut cx = Ctx { p, uid: &uid, out: String::with_capacity(64 * 1024) };
|
||||
let mut cx = Ctx {
|
||||
p,
|
||||
uid: &uid,
|
||||
out: String::with_capacity(64 * 1024),
|
||||
};
|
||||
let mut frame = [1e9f64, 1e9, -1e9, -1e9];
|
||||
let mut sprites = 0;
|
||||
let mut cycles = 0;
|
||||
@@ -494,7 +573,12 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
|
||||
} else {
|
||||
(0..live.len()).collect()
|
||||
};
|
||||
let _ = write!(cx.out, "<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 {
|
||||
let s = live[i];
|
||||
sprites += 1;
|
||||
@@ -583,7 +667,13 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
|
||||
frame = [-100.0, -100.0, 100.0, 100.0];
|
||||
warnings.push("the scene is empty".into());
|
||||
}
|
||||
let bbox = [frame[0], frame[1] - 30.0, frame[2] - frame[0], frame[3] - frame[1] + 30.0].map(round1);
|
||||
let bbox = [
|
||||
frame[0],
|
||||
frame[1] - 30.0,
|
||||
frame[2] - frame[0],
|
||||
frame[3] - frame[1] + 30.0,
|
||||
]
|
||||
.map(round1);
|
||||
let focus = match scene.focus_point() {
|
||||
Some(f) => p.s(f[0], f[1], f[2]),
|
||||
None => [bbox[0] + bbox[2] / 2.0, bbox[1] + bbox[3] / 2.0],
|
||||
@@ -627,7 +717,12 @@ mod tests {
|
||||
let sc = one_box();
|
||||
for rot in 0..4 {
|
||||
let r = render(&sc, rot, &RenderOpts::default());
|
||||
assert_eq!(r.html.matches("iso-wall iso-").count(), 3, "rot {rot}: {}", r.html);
|
||||
assert_eq!(
|
||||
r.html.matches("iso-wall iso-").count(),
|
||||
3,
|
||||
"rot {rot}: {}",
|
||||
r.html
|
||||
);
|
||||
assert!(r.html.contains("iso-wall iso-t"));
|
||||
assert!(r.html.contains("iso-wall iso-l"));
|
||||
assert!(r.html.contains("iso-wall iso-r"));
|
||||
@@ -641,14 +736,26 @@ mod tests {
|
||||
// JS (iso-mooban.js prismS, rotation 0) for box(0,0,10,6,0,4): the top face
|
||||
let sc = one_box();
|
||||
let r = render(&sc, 0, &RenderOpts::default());
|
||||
assert!(r.html.contains("<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]
|
||||
fn labels_off_hides_plain_tags_only() {
|
||||
let mut sc = one_box();
|
||||
sc.tag([0.0, 0.0, 0.0], "Gate", 9.0, "gate", "iso-signtag");
|
||||
let r = render(&sc, 1, &RenderOpts { labels: false, ..Default::default() });
|
||||
let r = render(
|
||||
&sc,
|
||||
1,
|
||||
&RenderOpts {
|
||||
labels: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(r.html.contains(">Gate<"));
|
||||
assert!(!r.html.contains(">Hall<"));
|
||||
}
|
||||
@@ -683,9 +790,29 @@ mod tests {
|
||||
let mut sc = Scene::new();
|
||||
let mut s = Sprite::new("roof", "");
|
||||
// south-facing roof slope rising to the north (ccw seen from above/outside)
|
||||
s.face(vec![[0.0, 0.0, 3.0], [10.0, 0.0, 3.0], [10.0, 4.0, 5.0], [0.0, 4.0, 5.0]], "iso-roof", None, false);
|
||||
s.face(
|
||||
vec![
|
||||
[0.0, 0.0, 3.0],
|
||||
[10.0, 0.0, 3.0],
|
||||
[10.0, 4.0, 5.0],
|
||||
[0.0, 4.0, 5.0],
|
||||
],
|
||||
"iso-roof",
|
||||
None,
|
||||
false,
|
||||
);
|
||||
// a vertical wall facing north (ccw seen from the north)
|
||||
s.face(vec![[10.0, 6.0, 0.0], [0.0, 6.0, 0.0], [0.0, 6.0, 3.0], [10.0, 6.0, 3.0]], "iso-wall", None, false);
|
||||
s.face(
|
||||
vec![
|
||||
[10.0, 6.0, 0.0],
|
||||
[0.0, 6.0, 0.0],
|
||||
[0.0, 6.0, 3.0],
|
||||
[10.0, 6.0, 3.0],
|
||||
],
|
||||
"iso-wall",
|
||||
None,
|
||||
false,
|
||||
);
|
||||
sc.add_sprite("main", s);
|
||||
let r0 = render(&sc, 0, &RenderOpts::default()); // from the south-east: north wall hidden
|
||||
assert!(r0.html.contains("iso-roof iso-t"));
|
||||
@@ -698,7 +825,14 @@ mod tests {
|
||||
fn dev_mode_adds_source_and_order() {
|
||||
let mut sc = one_box();
|
||||
sc.layers[2].sprites[0].src = Some("scene.rhai:3".into());
|
||||
let r = render(&sc, 0, &RenderOpts { dev: true, ..Default::default() });
|
||||
let r = render(
|
||||
&sc,
|
||||
0,
|
||||
&RenderOpts {
|
||||
dev: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(r.html.contains("data-src=\"scene.rhai:3\""));
|
||||
assert_eq!(r.order.len(), 1);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ pub struct LayerOpts {
|
||||
|
||||
impl Default for LayerOpts {
|
||||
fn default() -> Self {
|
||||
LayerOpts { sort: true, zfirst: false, cls: "iso-sprites".into() }
|
||||
LayerOpts {
|
||||
sort: true,
|
||||
zfirst: false,
|
||||
cls: "iso-sprites".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,9 +82,25 @@ impl Scene {
|
||||
pub fn new() -> Self {
|
||||
Scene {
|
||||
layers: vec![
|
||||
Layer { name: "ground".into(), opts: LayerOpts { sort: false, zfirst: false, cls: "iso-ground".into() }, sprites: vec![] },
|
||||
Layer { name: "floor".into(), opts: LayerOpts::default(), sprites: vec![] },
|
||||
Layer { name: "main".into(), opts: LayerOpts::default(), sprites: vec![] },
|
||||
Layer {
|
||||
name: "ground".into(),
|
||||
opts: LayerOpts {
|
||||
sort: false,
|
||||
zfirst: false,
|
||||
cls: "iso-ground".into(),
|
||||
},
|
||||
sprites: vec![],
|
||||
},
|
||||
Layer {
|
||||
name: "floor".into(),
|
||||
opts: LayerOpts::default(),
|
||||
sprites: vec![],
|
||||
},
|
||||
Layer {
|
||||
name: "main".into(),
|
||||
opts: LayerOpts::default(),
|
||||
sprites: vec![],
|
||||
},
|
||||
],
|
||||
tags: vec![],
|
||||
info: BTreeMap::new(),
|
||||
@@ -101,8 +121,17 @@ impl Scene {
|
||||
self.layers[i].opts = opts;
|
||||
return i;
|
||||
}
|
||||
let at = before.and_then(|b| self.layer_index(b)).unwrap_or(self.layers.len());
|
||||
self.layers.insert(at, Layer { name: name.into(), opts, sprites: vec![] });
|
||||
let at = before
|
||||
.and_then(|b| self.layer_index(b))
|
||||
.unwrap_or(self.layers.len());
|
||||
self.layers.insert(
|
||||
at,
|
||||
Layer {
|
||||
name: name.into(),
|
||||
opts,
|
||||
sprites: vec![],
|
||||
},
|
||||
);
|
||||
at
|
||||
}
|
||||
|
||||
@@ -119,13 +148,39 @@ impl Scene {
|
||||
self.layer_mut(layer).sprites.push(s);
|
||||
}
|
||||
|
||||
pub fn tag(&mut self, at: P3, text: impl Into<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 tag(
|
||||
&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();
|
||||
self.info.insert(id.clone(), Info { id, name: name.into(), detail: detail.into(), rows: vec![] });
|
||||
self.info.insert(
|
||||
id.clone(),
|
||||
Info {
|
||||
id,
|
||||
name: name.into(),
|
||||
detail: detail.into(),
|
||||
rows: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn sprite_count(&self) -> usize {
|
||||
@@ -137,7 +192,9 @@ impl Scene {
|
||||
use crate::prim::Prim;
|
||||
fn walk(p: &Prim, out: &mut std::collections::BTreeSet<String>) {
|
||||
let id = match p {
|
||||
Prim::Prism { st, .. } | Prim::Cyl { st, .. } | Prim::Frust { st, .. } => st.id.clone(),
|
||||
Prim::Prism { st, .. } | Prim::Cyl { st, .. } | Prim::Frust { st, .. } => {
|
||||
st.id.clone()
|
||||
}
|
||||
Prim::Open { id, .. }
|
||||
| Prim::Flat { id, .. }
|
||||
| Prim::Lines { id, .. }
|
||||
@@ -151,7 +208,10 @@ impl Scene {
|
||||
None
|
||||
}
|
||||
Prim::Seq { items } => {
|
||||
items.iter().flat_map(|(_, v)| v.iter()).for_each(|q| walk(q, out));
|
||||
items
|
||||
.iter()
|
||||
.flat_map(|(_, v)| v.iter())
|
||||
.for_each(|q| walk(q, out));
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
@@ -174,7 +234,10 @@ impl Scene {
|
||||
|
||||
/// The default focus point: the explicit one, else the bounds centre.
|
||||
pub fn focus_point(&self) -> Option<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])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,10 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Order { order: Vec::with_capacity(n), cycles: 0 };
|
||||
let mut out = Order {
|
||||
order: Vec::with_capacity(n),
|
||||
cycles: 0,
|
||||
};
|
||||
for _ in 0..n {
|
||||
let mut best: Option<usize> = None;
|
||||
for i in 0..n {
|
||||
@@ -102,7 +105,9 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
|
||||
}
|
||||
best = match best {
|
||||
None => Some(i),
|
||||
Some(b) if indeg[i] < indeg[b] || (indeg[i] == indeg[b] && key[i] < key[b]) => Some(i),
|
||||
Some(b) if indeg[i] < indeg[b] || (indeg[i] == indeg[b] && key[i] < key[b]) => {
|
||||
Some(i)
|
||||
}
|
||||
keep => keep,
|
||||
};
|
||||
}
|
||||
@@ -128,14 +133,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn separated_on_x_draws_far_first() {
|
||||
let o = sort(&[bx(5.0, 0.0, 6.0, 1.0, 0.0, 1.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false);
|
||||
let o = sort(
|
||||
&[
|
||||
bx(5.0, 0.0, 6.0, 1.0, 0.0, 1.0),
|
||||
bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0),
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert_eq!(o.order, vec![1, 0]);
|
||||
assert_eq!(o.cycles, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_boxes_draw_bottom_first() {
|
||||
let o = sort(&[bx(0.0, 0.0, 1.0, 1.0, 1.0, 2.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false);
|
||||
let o = sort(
|
||||
&[
|
||||
bx(0.0, 0.0, 1.0, 1.0, 1.0, 2.0),
|
||||
bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0),
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert_eq!(o.order, vec![1, 0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,12 @@ impl Xf {
|
||||
pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> [f64; 4] {
|
||||
let a = self.p(x0, y0);
|
||||
let b = self.p(x1, y1);
|
||||
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
|
||||
[
|
||||
a[0].min(b[0]),
|
||||
a[1].min(b[1]),
|
||||
a[0].max(b[0]),
|
||||
a[1].max(b[1]),
|
||||
]
|
||||
}
|
||||
pub fn pts(&self, pts: &[P2]) -> Vec<P2> {
|
||||
pts.iter().map(|q| self.p(q[0], q[1])).collect()
|
||||
@@ -76,8 +81,15 @@ impl Xf {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Frame {
|
||||
Group { cls: String, style: String, items: Vec<Prim> },
|
||||
Seq { items: Vec<(P2, Vec<Prim>)>, cur: Option<(P2, Vec<Prim>)> },
|
||||
Group {
|
||||
cls: String,
|
||||
style: String,
|
||||
items: Vec<Prim>,
|
||||
},
|
||||
Seq {
|
||||
items: Vec<(P2, Vec<Prim>)>,
|
||||
cur: Option<(P2, Vec<Prim>)>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
@@ -101,6 +113,9 @@ pub struct Sprite {
|
||||
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).
|
||||
pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<P2> {
|
||||
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 {
|
||||
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 {
|
||||
@@ -141,7 +160,10 @@ impl Sprite {
|
||||
|
||||
pub fn ext_box(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> &mut Self {
|
||||
for z in [z0, z1] {
|
||||
self.ext(x0, y0, z).ext(x1, y0, z).ext(x1, y1, z).ext(x0, y1, z);
|
||||
self.ext(x0, y0, z)
|
||||
.ext(x1, y0, z)
|
||||
.ext(x1, y1, z)
|
||||
.ext(x0, y1, z);
|
||||
}
|
||||
self
|
||||
}
|
||||
@@ -156,13 +178,27 @@ impl Sprite {
|
||||
|
||||
/// Axis-aligned box (`box` in scripts; `box` is a Rust keyword).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn cuboid(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64, st: Style) -> &mut Self {
|
||||
pub fn cuboid(
|
||||
&mut self,
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
x1: f64,
|
||||
y1: f64,
|
||||
z0: f64,
|
||||
z1: f64,
|
||||
st: Style,
|
||||
) -> &mut Self {
|
||||
let r = self.xf.rect(x0, y0, x1, y1);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
let pts = rect(r[0], r[1], r[2], r[3]);
|
||||
self.ext_world(&pts, z0);
|
||||
self.ext_world(&pts, z1);
|
||||
self.push(Prim::Prism { pts, z0: Zs::One(z0), z1: Zs::One(z1), st })
|
||||
self.push(Prim::Prism {
|
||||
pts,
|
||||
z0: Zs::One(z0),
|
||||
z1: Zs::One(z1),
|
||||
st,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prism(&mut self, pts: Vec<P2>, z0: Zs, z1: Zs, st: Style) -> &mut Self {
|
||||
@@ -188,30 +224,73 @@ impl Sprite {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn frust(&mut self, cx: f64, cy: f64, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style) -> &mut Self {
|
||||
pub fn frust(
|
||||
&mut self,
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
r0: f64,
|
||||
r1: f64,
|
||||
z0: f64,
|
||||
z1: f64,
|
||||
n: u32,
|
||||
st: Style,
|
||||
) -> &mut Self {
|
||||
let r = r0.max(r1);
|
||||
self.ext_box(cx - r, cy - r, cx + r, cy + r, z0, z1);
|
||||
let c = self.xf.p(cx, cy);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
self.push(Prim::Frust { c, r0, r1, z0, z1, n: n.max(3), st })
|
||||
self.push(Prim::Frust {
|
||||
c,
|
||||
r0,
|
||||
r1,
|
||||
z0,
|
||||
z1,
|
||||
n: n.max(3),
|
||||
st,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sloped slab (stair flight, ramp) over x0..x1, rising from y0 (za) to
|
||||
/// y1 (zb), th thick.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn slope(&mut self, x0: f64, x1: f64, y0: f64, y1: f64, za: f64, zb: f64, th: f64, mat: &str) -> &mut Self {
|
||||
pub fn slope(
|
||||
&mut self,
|
||||
x0: f64,
|
||||
x1: f64,
|
||||
y0: f64,
|
||||
y1: f64,
|
||||
za: f64,
|
||||
zb: f64,
|
||||
th: f64,
|
||||
mat: &str,
|
||||
) -> &mut Self {
|
||||
let zt = vec![za, za, zb, zb];
|
||||
let zb_: Vec<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
|
||||
|
||||
/// 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 dz = self.xf.z;
|
||||
let list: Vec<[f64; 4]> = list.into_iter().map(|q| [q[0], q[1], q[2] + dz, q[3] + dz]).collect();
|
||||
let list: Vec<[f64; 4]> = list
|
||||
.into_iter()
|
||||
.map(|q| [q[0], q[1], q[2] + dz, q[3] + dz])
|
||||
.collect();
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l = (dx * dx + dy * dy).sqrt().max(1e-9);
|
||||
let (ux, uy) = (dx / l, dy / l);
|
||||
@@ -219,16 +298,33 @@ impl Sprite {
|
||||
self.ext.push([a[0] + ux * q[0], a[1] + uy * q[0], q[2]]);
|
||||
self.ext.push([a[0] + ux * q[1], a[1] + uy * q[1], q[3]]);
|
||||
}
|
||||
self.push(Prim::Open { a, b, list, cls: cls.into(), id })
|
||||
self.push(Prim::Open {
|
||||
a,
|
||||
b,
|
||||
list,
|
||||
cls: cls.into(),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn flat(&mut self, polys: Vec<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 z = self.xf.z(z);
|
||||
for p in &polys {
|
||||
self.ext_world(p, z);
|
||||
}
|
||||
self.push(Prim::Flat { polys, z, cls: cls.into(), id })
|
||||
self.push(Prim::Flat {
|
||||
polys,
|
||||
z,
|
||||
cls: cls.into(),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn lines(&mut self, segs: Vec<[f64; 4]>, z: f64, cls: &str) -> &mut Self {
|
||||
@@ -245,30 +341,67 @@ impl Sprite {
|
||||
self.ext.push([s[0], s[1], z]);
|
||||
self.ext.push([s[2], s[3], z]);
|
||||
}
|
||||
self.push(Prim::Lines { segs, z, cls: cls.into(), id: None })
|
||||
self.push(Prim::Lines {
|
||||
segs,
|
||||
z,
|
||||
cls: cls.into(),
|
||||
id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn disc(&mut self, cx: f64, cy: f64, z: f64, r: f64, cls: &str, id: Option<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);
|
||||
let c = self.xf.p(cx, cy);
|
||||
let z = self.xf.z(z);
|
||||
self.push(Prim::Disc { c, z, r, cls: cls.into(), id })
|
||||
self.push(Prim::Disc {
|
||||
c,
|
||||
z,
|
||||
r,
|
||||
cls: cls.into(),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Planar 3D polygon shaded by its normal (see [`Prim::Face`]).
|
||||
pub fn face(&mut self, pts: Vec<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();
|
||||
self.ext.extend(pts.iter().copied());
|
||||
self.push(Prim::Face { pts, mat: mat.into(), id, two_sided })
|
||||
self.push(Prim::Face {
|
||||
pts,
|
||||
mat: mat.into(),
|
||||
id,
|
||||
two_sided,
|
||||
})
|
||||
}
|
||||
|
||||
/// 3D polygons as one path with a fixed class.
|
||||
pub fn poly(&mut self, polys: Vec<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 {
|
||||
self.ext.extend(p.iter().copied());
|
||||
}
|
||||
self.push(Prim::Poly { polys, cls: cls.into(), id })
|
||||
self.push(Prim::Poly {
|
||||
polys,
|
||||
cls: cls.into(),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- screen-space details
|
||||
@@ -277,14 +410,27 @@ impl Sprite {
|
||||
/// sticks out of the sprite).
|
||||
pub fn circle(&mut self, at: P3, r: f64, off: P2, cls: &str) -> &mut Self {
|
||||
let at = self.xf.p3(at);
|
||||
self.push(Prim::Circle { at, r, off, cls: cls.into(), id: None })
|
||||
self.push(Prim::Circle {
|
||||
at,
|
||||
r,
|
||||
off,
|
||||
cls: cls.into(),
|
||||
id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stem(&mut self, x: f64, y: f64, z0: f64, z1: f64, w: f64, cls: &str) -> &mut Self {
|
||||
self.ext(x, y, z0).ext(x, y, z1);
|
||||
let at = self.xf.p(x, y);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
self.push(Prim::Stem { at, z0, z1, w, cls: cls.into(), id: None })
|
||||
self.push(Prim::Stem {
|
||||
at,
|
||||
z0,
|
||||
z1,
|
||||
w,
|
||||
cls: cls.into(),
|
||||
id: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A head (ball of radius r metres) centred at z.
|
||||
@@ -307,13 +453,22 @@ impl Sprite {
|
||||
}
|
||||
|
||||
pub fn led(&mut self, x: f64, y: f64, z: f64, cls: &str) -> &mut Self {
|
||||
let c = if cls.is_empty() { "iso-led".to_string() } else { format!("iso-led {cls}") };
|
||||
let c = if cls.is_empty() {
|
||||
"iso-led".to_string()
|
||||
} else {
|
||||
format!("iso-led {cls}")
|
||||
};
|
||||
self.circle([x, y, z], 0.9, [0.0, 0.0], &c)
|
||||
}
|
||||
|
||||
pub fn puffs(&mut self, x: f64, y: f64, z: f64, kind: &str, n: u32, dur: f64) -> &mut Self {
|
||||
let at = self.xf.p3([x, y, z]);
|
||||
self.push(Prim::Puffs { at, kind: kind.into(), n: n.max(1), dur })
|
||||
self.push(Prim::Puffs {
|
||||
at,
|
||||
kind: kind.into(),
|
||||
n: n.max(1),
|
||||
dur,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fan(&mut self, x: f64, y: f64, z: f64, r: f64) -> &mut Self {
|
||||
@@ -323,14 +478,21 @@ impl Sprite {
|
||||
|
||||
pub fn raw(&mut self, at: Option<P3>, svg: impl Into<String>) -> &mut Self {
|
||||
let at = at.map(|a| self.xf.p3(a));
|
||||
self.push(Prim::Raw { at, svg: svg.into() })
|
||||
self.push(Prim::Raw {
|
||||
at,
|
||||
svg: svg.into(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- structure
|
||||
|
||||
/// Open a `<g class style>`; close it with [`Sprite::end_group`].
|
||||
pub fn begin_group(&mut self, cls: &str, style: &str) -> &mut Self {
|
||||
self.frames.push(Frame::Group { cls: cls.into(), style: style.into(), items: vec![] });
|
||||
self.frames.push(Frame::Group {
|
||||
cls: cls.into(),
|
||||
style: style.into(),
|
||||
items: vec![],
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
@@ -344,7 +506,10 @@ impl Sprite {
|
||||
/// Start a back-to-front sequence; each [`Sprite::seq_item`] starts an
|
||||
/// item anchored at a plan point; [`Sprite::end_seq`] closes it.
|
||||
pub fn begin_seq(&mut self) -> &mut Self {
|
||||
self.frames.push(Frame::Seq { items: vec![], cur: None });
|
||||
self.frames.push(Frame::Seq {
|
||||
items: vec![],
|
||||
cur: None,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
@@ -370,7 +535,7 @@ impl Sprite {
|
||||
}
|
||||
|
||||
/// Draw items back to front by the depth of their anchor.
|
||||
pub fn seq(&mut self, items: Vec<(P2, Box<dyn FnOnce(&mut Sprite) + '_>)>) -> &mut Self {
|
||||
pub fn seq(&mut self, items: Vec<SeqItem<'_>>) -> &mut Self {
|
||||
self.begin_seq();
|
||||
for (at, f) in items {
|
||||
self.seq_item(at);
|
||||
@@ -442,7 +607,10 @@ mod tests {
|
||||
let f = Xf::new(3.0, -4.0, 0.0, deg);
|
||||
let w = f.p(1.5, 2.5);
|
||||
let l = f.inv(w[0], w[1]);
|
||||
assert!((l[0] - 1.5).abs() < 1e-12 && (l[1] - 2.5).abs() < 1e-12, "{deg}");
|
||||
assert!(
|
||||
(l[0] - 1.5).abs() < 1e-12 && (l[1] - 2.5).abs() < 1e-12,
|
||||
"{deg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
"#;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+434
-135
@@ -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 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);
|
||||
sp.obj = s.obj.clone();
|
||||
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());
|
||||
|
||||
// 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(
|
||||
"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)));
|
||||
|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("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));
|
||||
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, o: Map| -> RR<SpriteH> {
|
||||
s.0.borrow_mut().prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, Some(&o)));
|
||||
},
|
||||
);
|
||||
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("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));
|
||||
},
|
||||
);
|
||||
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, o: Map| -> RR<SpriteH> {
|
||||
s.0.borrow_mut().cyl(n(&cx)?, n(&cy)?, n(&r)?, n(&z0)?, n(&z1)?, style(mat, Some(&o)));
|
||||
},
|
||||
);
|
||||
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(
|
||||
"frust",
|
||||
|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)
|
||||
},
|
||||
);
|
||||
e.register_fn(
|
||||
"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;
|
||||
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)
|
||||
},
|
||||
);
|
||||
e.register_fn(
|
||||
"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;
|
||||
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)
|
||||
},
|
||||
);
|
||||
e.register_fn(
|
||||
"slope",
|
||||
|s: &mut SpriteH, 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));
|
||||
|s: &mut SpriteH,
|
||||
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)
|
||||
},
|
||||
);
|
||||
|
||||
// surfaces
|
||||
e.register_fn("open", |s: &mut SpriteH, a: D, b: D, list: D, cls: &str| -> RR<SpriteH> {
|
||||
s.0.borrow_mut().open(p2(&a)?, p2(&b)?, quads(&list, "openings")?, &classes(cls), None);
|
||||
e.register_fn(
|
||||
"open",
|
||||
|s: &mut SpriteH, a: D, b: D, list: D, cls: &str| -> RR<SpriteH> {
|
||||
s.0.borrow_mut().open(
|
||||
p2(&a)?,
|
||||
p2(&b)?,
|
||||
quads(&list, "openings")?,
|
||||
&classes(cls),
|
||||
None,
|
||||
);
|
||||
ok(s)
|
||||
});
|
||||
e.register_fn("open", |s: &mut SpriteH, a: D, b: D, list: D, cls: &str, id: &str| -> RR<SpriteH> {
|
||||
s.0.borrow_mut().open(p2(&a)?, p2(&b)?, quads(&list, "openings")?, &classes(cls), opt_id(id));
|
||||
},
|
||||
);
|
||||
e.register_fn(
|
||||
"open",
|
||||
|s: &mut SpriteH, a: D, b: D, list: D, cls: &str, id: &str| -> RR<SpriteH> {
|
||||
s.0.borrow_mut().open(
|
||||
p2(&a)?,
|
||||
p2(&b)?,
|
||||
quads(&list, "openings")?,
|
||||
&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);
|
||||
},
|
||||
);
|
||||
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));
|
||||
},
|
||||
);
|
||||
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));
|
||||
},
|
||||
);
|
||||
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);
|
||||
},
|
||||
);
|
||||
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));
|
||||
},
|
||||
);
|
||||
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> {
|
||||
s.0.borrow_mut().face(pts3(&p)?, &classes(mat), None, false);
|
||||
ok(s)
|
||||
});
|
||||
e.register_fn("face", |s: &mut SpriteH, p: D, mat: &str, o: Map| -> RR<SpriteH> {
|
||||
e.register_fn(
|
||||
"face",
|
||||
|s: &mut SpriteH, p: D, mat: &str, o: Map| -> RR<SpriteH> {
|
||||
let id = o.get("id").map(|v| v.to_string());
|
||||
let two = o.get("two_sided").and_then(|v| v.as_bool().ok()).unwrap_or(false);
|
||||
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> {
|
||||
s.0.borrow_mut().poly(polys3(&p)?, &classes(cls), None);
|
||||
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(
|
||||
"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));
|
||||
"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)
|
||||
},
|
||||
);
|
||||
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));
|
||||
|
||||
// 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("head", |s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
|
||||
},
|
||||
);
|
||||
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> {
|
||||
},
|
||||
);
|
||||
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));
|
||||
},
|
||||
);
|
||||
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> {
|
||||
},
|
||||
);
|
||||
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> {
|
||||
s.0.borrow_mut().led(n(&x)?, n(&y)?, n(&z)?, "");
|
||||
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(
|
||||
"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)?);
|
||||
"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("fan", |s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
|
||||
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)
|
||||
});
|
||||
},
|
||||
);
|
||||
e.register_fn("raw", |s: &mut SpriteH, svg: &str| -> RR<SpriteH> {
|
||||
s.0.borrow_mut().raw(None, svg);
|
||||
ok(s)
|
||||
});
|
||||
e.register_fn("raw", |s: &mut SpriteH, x: D, y: D, z: D, svg: &str| -> RR<SpriteH> {
|
||||
e.register_fn(
|
||||
"raw",
|
||||
|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
|
||||
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)?);
|
||||
ok(s)
|
||||
});
|
||||
e.register_fn("ext_box", |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)?);
|
||||
e.register_fn(
|
||||
"ext_box",
|
||||
|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
|
||||
e.register_fn("begin", |s: &mut SpriteH, cls: &str| -> SpriteH {
|
||||
s.0.borrow_mut().begin_group(&classes(cls), "");
|
||||
s.clone()
|
||||
});
|
||||
e.register_fn("begin", |s: &mut SpriteH, cls: &str, css: &str| -> SpriteH {
|
||||
e.register_fn(
|
||||
"begin",
|
||||
|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 {
|
||||
s.0.borrow_mut().end_group();
|
||||
s.clone()
|
||||
});
|
||||
e.register_fn("group", |ctx: NativeCallContext, s: &mut SpriteH, cls: &str, f: FnPtr| -> RR<SpriteH> {
|
||||
e.register_fn(
|
||||
"group",
|
||||
|ctx: NativeCallContext, s: &mut SpriteH, cls: &str, f: FnPtr| -> RR<SpriteH> {
|
||||
s.0.borrow_mut().begin_group(&classes(cls), "");
|
||||
let r = f.call_within_context::<Dynamic>(&ctx, (s.clone(),));
|
||||
s.0.borrow_mut().end_group();
|
||||
let _ = r?;
|
||||
ok(s)
|
||||
});
|
||||
},
|
||||
);
|
||||
e.register_fn(
|
||||
"group",
|
||||
|ctx: NativeCallContext, s: &mut SpriteH, cls: &str, css: &str, f: FnPtr| -> RR<SpriteH> {
|
||||
@@ -265,15 +495,22 @@ pub fn register_sprite(e: &mut Engine) {
|
||||
},
|
||||
);
|
||||
// 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(
|
||||
"seq",
|
||||
|ctx: NativeCallContext, s: &mut SpriteH, items: Array| -> RR<SpriteH> {
|
||||
s.0.borrow_mut().begin_seq();
|
||||
for it in items {
|
||||
let a = it.into_array().map_err(|t| format!("seq: expected [x, y, fn], got {t}"))?;
|
||||
let a = it
|
||||
.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")?;
|
||||
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();
|
||||
@@ -282,19 +519,27 @@ pub fn register_sprite(e: &mut Engine) {
|
||||
}
|
||||
s.0.borrow_mut().end_seq();
|
||||
ok(s)
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Scene-level functions (they read and write the shared build state).
|
||||
pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
|
||||
let s = st.clone();
|
||||
e.register_fn("sprite", move |ctx: NativeCallContext| new_sprite(&s, Some(&ctx), None, ""));
|
||||
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)
|
||||
e.register_fn("sprite", move |ctx: NativeCallContext| {
|
||||
new_sprite(&s, Some(&ctx), None, "")
|
||||
});
|
||||
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)
|
||||
let s = st.clone();
|
||||
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 opts = LayerOpts {
|
||||
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),
|
||||
cls: o.get("cls").map(|v| classes(&v.to_string())).unwrap_or_else(|| "iso-sprites".into()),
|
||||
zfirst: o
|
||||
.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());
|
||||
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());
|
||||
|
||||
// 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 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));
|
||||
Ok(())
|
||||
}
|
||||
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, ""));
|
||||
let s = st.clone();
|
||||
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)
|
||||
e.register_fn("tag", move |x: D, y: D, z: D, t: &str| {
|
||||
tag(&s, x, y, z, t, 1.0, None, "")
|
||||
});
|
||||
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();
|
||||
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();
|
||||
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 mut b = s.borrow_mut();
|
||||
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
|
||||
.iter()
|
||||
.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(())
|
||||
});
|
||||
let s = st.clone();
|
||||
e.register_fn("bounds", move |x0: D, y0: D, x1: D, y1: D, zb: D| -> RR<()> {
|
||||
e.register_fn(
|
||||
"bounds",
|
||||
move |x0: D, y0: D, x1: D, y1: D, zb: D| -> RR<()> {
|
||||
let mut b = s.borrow_mut();
|
||||
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();
|
||||
e.register_fn("defs", move |svg: &str| s.borrow_mut().scene.defs.push(svg.to_string()));
|
||||
let s = st.clone();
|
||||
e.register_fn("warn", move |msg: &str| 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
|
||||
let s = st.clone();
|
||||
@@ -410,7 +697,9 @@ pub(crate) fn register_scene(e: &mut Engine, st: &Shared) {
|
||||
let s = st.clone();
|
||||
e.register_fn("cfg", move || s.borrow().cfg.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();
|
||||
e.register_fn("objects", move || s.borrow().all_objects.clone());
|
||||
// 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 w = b.xf.v(n(&dx)?, n(&dy)?);
|
||||
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)
|
||||
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("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("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> {
|
||||
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> {
|
||||
let (cx, cy, a, b, k) = (n(&cx)?, n(&cy)?, n(&a)?, n(&b)?, n(&k)?.max(3.0) as usize);
|
||||
|
||||
@@ -59,7 +59,9 @@ pub fn pts3(d: &Dynamic) -> RR<Vec<P3>> {
|
||||
}
|
||||
|
||||
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], ...], ...]`.
|
||||
@@ -114,18 +116,27 @@ pub fn rect4(d: &Dynamic) -> RR<[f64; 4]> {
|
||||
/// A height: one number or one per vertex.
|
||||
pub fn zs(d: &Dynamic) -> RR<Zs> {
|
||||
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 {
|
||||
Ok(Zs::One(n(d)?))
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -129,13 +129,27 @@ impl PrefabMeta {
|
||||
pub fn resolve(&self, props: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = props.clone();
|
||||
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) {
|
||||
("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()),
|
||||
("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")),
|
||||
("float", Value::String(s)) => s
|
||||
.parse::<f64>()
|
||||
.map(Value::from)
|
||||
.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),
|
||||
_ => v,
|
||||
};
|
||||
@@ -156,13 +170,26 @@ impl Doc {
|
||||
pub fn free_id(&self, base: &str) -> String {
|
||||
let base: String = base
|
||||
.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();
|
||||
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() {
|
||||
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]
|
||||
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");
|
||||
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]
|
||||
fn defaults_and_coercion() {
|
||||
let m = PrefabMeta {
|
||||
props: vec![
|
||||
PropDef { key: "r".into(), 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() },
|
||||
PropDef {
|
||||
key: "r".into(),
|
||||
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()
|
||||
};
|
||||
@@ -199,8 +245,16 @@ mod tests {
|
||||
fn free_ids() {
|
||||
let mut d = Doc::default();
|
||||
assert_eq!(d.free_id("Tree"), "tree");
|
||||
d.objects.push(Object { id: "tree".into(), kind: "tree".into(), ..Default::default() });
|
||||
d.objects.push(Object { id: "tree-2".into(), kind: "tree".into(), ..Default::default() });
|
||||
d.objects.push(Object {
|
||||
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");
|
||||
}
|
||||
}
|
||||
+120
-33
@@ -77,14 +77,20 @@ fn to_error(e: &EvalAltResult, file: &str) -> ScriptError {
|
||||
loop {
|
||||
match cur {
|
||||
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() {
|
||||
file = src.clone();
|
||||
}
|
||||
cur = inner;
|
||||
}
|
||||
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();
|
||||
cur = inner;
|
||||
}
|
||||
@@ -96,7 +102,13 @@ fn to_error(e: &EvalAltResult, file: &str) -> ScriptError {
|
||||
if let Some(i) = message.rfind(" (line ") {
|
||||
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.
|
||||
@@ -241,16 +253,38 @@ fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
|
||||
b.obj_start = b.sprites.len();
|
||||
let kind = g("type").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 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() };
|
||||
let label = b
|
||||
.catalog
|
||||
.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);
|
||||
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);
|
||||
// a placeholder so the object can still be found and fixed
|
||||
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().xf = Xf::default();
|
||||
return false;
|
||||
@@ -273,7 +307,13 @@ fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
|
||||
if bx[0] <= bx[3] {
|
||||
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()) {
|
||||
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);
|
||||
}
|
||||
@@ -291,7 +331,10 @@ fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
|
||||
None => e.to_string(),
|
||||
};
|
||||
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_call_levels(64);
|
||||
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_scene(&mut engine, &state);
|
||||
register_driver(&mut engine, &state);
|
||||
|
||||
// global modules: built-in library, then the project's prelude
|
||||
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 {
|
||||
let src = bundle.sources.get(p).ok_or_else(|| ScriptError {
|
||||
message: format!("prelude module {p} not found"),
|
||||
@@ -324,7 +372,8 @@ impl Runtime {
|
||||
for (name, src) in &mods {
|
||||
let mut ast = engine.compile(src).map_err(|e| to_error(&e.into(), name))?;
|
||||
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());
|
||||
asts.push((name.clone(), ast));
|
||||
}
|
||||
@@ -344,24 +393,32 @@ impl Runtime {
|
||||
None => AST::empty(),
|
||||
};
|
||||
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.set_source(main.as_str());
|
||||
|
||||
// prefab catalog: prefab_<type>(o) with an optional meta_<type>()
|
||||
let mut catalog = vec![];
|
||||
for (name, a) in &asts {
|
||||
let mut catalog: Vec<(usize, PrefabMeta)> = vec![];
|
||||
for (mi, (name, a)) in asts.iter().enumerate() {
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
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
|
||||
.call_fn(&mut Scope::new(), a, &meta_fn, ())
|
||||
.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 {
|
||||
message: format!("{meta_fn}(): {e}"),
|
||||
file: name.clone(),
|
||||
@@ -378,12 +435,26 @@ impl Runtime {
|
||||
meta.category = "Other".into();
|
||||
}
|
||||
meta.source = name.clone();
|
||||
catalog.retain(|m: &PrefabMeta| m.kind != meta.kind); // later modules override
|
||||
catalog.push(meta);
|
||||
catalog.retain(|(_, m)| m.kind != meta.kind); // later modules override
|
||||
catalog.push((mi, meta));
|
||||
}
|
||||
}
|
||||
state.borrow_mut().catalog = catalog.iter().map(|m| (m.kind.clone(), m.clone())).collect();
|
||||
Ok(Runtime { engine, ast, state, catalog, main })
|
||||
// stable palette order: library modules first, then by category and label
|
||||
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.
|
||||
@@ -397,7 +468,13 @@ impl Runtime {
|
||||
|
||||
/// Build the scene for a level and rotation. `cfg` holds the parameter
|
||||
/// 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 catalog = std::mem::take(&mut b.catalog);
|
||||
@@ -407,23 +484,28 @@ impl Runtime {
|
||||
b.main = main;
|
||||
b.level = level.to_string();
|
||||
b.proj = Proj::new(rot as i64);
|
||||
b.cfg = match dyn_json(&serde_json::Value::Object(cfg.clone())).try_cast::<Map>() {
|
||||
Some(m) => m,
|
||||
None => Map::new(),
|
||||
};
|
||||
b.cfg = dyn_json(&serde_json::Value::Object(cfg.clone()))
|
||||
.try_cast::<Map>()
|
||||
.unwrap_or_default();
|
||||
for o in &doc.objects {
|
||||
let mut j = serde_json::to_value(o).unwrap_or_default();
|
||||
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() {
|
||||
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"] {
|
||||
m.entry(k).or_insert(serde_json::Value::from(0.0));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
let d = dyn_json(&j);
|
||||
@@ -445,11 +527,16 @@ impl Runtime {
|
||||
let mut sp = std::mem::take(&mut *h.0.borrow_mut());
|
||||
let open = sp.finish();
|
||||
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);
|
||||
}
|
||||
Ok(Built { scene, boxes: std::mem::take(&mut b.boxes) })
|
||||
Ok(Built {
|
||||
scene,
|
||||
boxes: std::mem::take(&mut b.boxes),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ use isokit_core::{render, RenderOpts};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
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());
|
||||
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 {
|
||||
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]
|
||||
@@ -59,17 +69,29 @@ fn seq_group_and_closures() {
|
||||
let b = r.build("site", 0, &cfg(), &Doc::default()).unwrap();
|
||||
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
|
||||
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!(out.html.contains("<g class=\"iso-a iso-drive\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_point_at_the_line() {
|
||||
let mut b = Bundle { 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 mut b = Bundle {
|
||||
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 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.line, Some(3), "{e}");
|
||||
assert!(e.message.contains("number"), "{e}");
|
||||
@@ -81,9 +103,18 @@ fn errors_point_at_the_line() {
|
||||
|
||||
#[test]
|
||||
fn imports_resolve_from_the_bundle() {
|
||||
let mut b = Bundle { main: Some("scene.rhai".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 mut b = Bundle {
|
||||
main: Some("scene.rhai".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 s = r.build("site", 0, &cfg(), &Doc::default()).unwrap().scene;
|
||||
assert_eq!(s.sprite_count(), 1);
|
||||
@@ -91,7 +122,11 @@ fn imports_resolve_from_the_bundle() {
|
||||
|
||||
#[test]
|
||||
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(
|
||||
"lib/kiosk.rhai".into(),
|
||||
r#"
|
||||
@@ -102,7 +137,9 @@ fn catalog_lists_library_and_project_prefabs() {
|
||||
);
|
||||
let r = Runtime::new(&b).unwrap();
|
||||
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:?}");
|
||||
}
|
||||
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() {
|
||||
let r = Runtime::new(&Bundle::default()).unwrap();
|
||||
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.name = "Main 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!({})));
|
||||
let built = r.build("roof", 1, &cfg(), &doc).unwrap();
|
||||
// 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));
|
||||
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 bx = *built
|
||||
.boxes
|
||||
.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());
|
||||
assert!(out.html.contains("data-obj=\"hall\""));
|
||||
assert!(out.html.contains("data-obj=\"t1\""));
|
||||
assert_eq!(out.info["hall"].name, "Main hall");
|
||||
assert_eq!(out.info["t1"].name, "Tree");
|
||||
assert!(out.html.contains(">Hall<"));
|
||||
assert!(built.scene.warnings.is_empty(), "{:?}", built.scene.warnings);
|
||||
assert!(
|
||||
built.scene.warnings.is_empty(),
|
||||
"{:?}",
|
||||
built.scene.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_cut_buildings_open_and_filter_objects() {
|
||||
let r = Runtime::new(&Bundle::default()).unwrap();
|
||||
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!({}));
|
||||
desk.levels = vec!["gf".into()];
|
||||
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;
|
||||
assert!(html.contains("iso-z-floor iso-t"));
|
||||
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]
|
||||
@@ -172,13 +232,26 @@ fn every_library_prefab_renders_cleanly_in_every_rotation() {
|
||||
}
|
||||
for level in ["site", "gf", "1f"] {
|
||||
for rot in 0..4 {
|
||||
let b = r.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 b = r
|
||||
.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());
|
||||
assert!(!out.html.contains("NaN"), "{level}/{rot}");
|
||||
for m in r.catalog() {
|
||||
let id = format!("o{}", r.catalog().iter().position(|q| q.kind == m.kind).unwrap());
|
||||
assert!(b.boxes.contains_key(&id), "{} drew nothing in {level}/{rot}", m.kind);
|
||||
let id = format!(
|
||||
"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]
|
||||
fn unknown_types_and_failing_prefabs_warn_but_render() {
|
||||
let mut b = Bundle { 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 mut b = Bundle {
|
||||
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 mut doc = Doc::default();
|
||||
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]
|
||||
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();
|
||||
c.insert("big".into(), json!(true));
|
||||
let b = r.build("site", 0, &c, &Doc::default()).unwrap();
|
||||
|
||||
@@ -27,8 +27,8 @@ fn meta_building() {
|
||||
#{ key: "lit", type: "bool", def: true, label: "Lit windows" },
|
||||
#{ key: "door", type: "bool", def: true, label: "Front door" },
|
||||
#{ key: "pv", type: "bool", def: false, label: "Solar panels" },
|
||||
#{ key: "ac", type: "bool", def: false, label: "Roof AC units (flat roof)" },
|
||||
#{ key: "floor_mat", type: "material", def: "z-floor", choices: floor_materials(), label: "Floor (cut-open levels)" },
|
||||
#{ key: "ac", type: "bool", def: false, label: "Roof AC units" },
|
||||
#{ key: "floor_mat", type: "material", def: "z-floor", choices: floor_materials(), label: "Cut-open floor" },
|
||||
] }
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ fn prefab_tank(o) {
|
||||
}
|
||||
|
||||
fn meta_container() {
|
||||
#{ label: "Shipping container", category: "Equipment", icon: "▭",
|
||||
#{ label: "Container", category: "Equipment", icon: "▭",
|
||||
props: [
|
||||
#{ 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" },
|
||||
@@ -75,7 +75,7 @@ fn prefab_pv_field(o) {
|
||||
}
|
||||
|
||||
fn meta_chimney() {
|
||||
#{ label: "Chimney / stack", category: "Equipment", icon: "▮",
|
||||
#{ label: "Chimney", category: "Equipment", icon: "▮",
|
||||
props: [
|
||||
#{ 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)" },
|
||||
|
||||
@@ -15,7 +15,7 @@ fn prefab_person(o) {
|
||||
}
|
||||
|
||||
fn meta_crowd() {
|
||||
#{ label: "Group of people", category: "People", icon: "👥",
|
||||
#{ label: "Crowd", category: "People", icon: "👥",
|
||||
props: [
|
||||
#{ 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)" },
|
||||
|
||||
@@ -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> {
|
||||
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()
|
||||
.map(|(k, v)| match v {
|
||||
toml::Value::String(s) => Ok((k.clone(), s.clone())),
|
||||
@@ -58,7 +60,8 @@ impl Theme {
|
||||
/// The built-in default theme.
|
||||
pub fn default_theme() -> Self {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -79,7 +82,11 @@ impl Theme {
|
||||
for (k, v) in strings(tab.get("rules"), "rules").map_err(err)? {
|
||||
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('\n');
|
||||
}
|
||||
@@ -118,7 +125,11 @@ impl Theme {
|
||||
match light {
|
||||
None => {
|
||||
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(true) => {
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
@@ -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>↶ Undo</button>
|
||||
<button type="button" id="redo" title="Redo (Ctrl+Y)" disabled>↷ 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">◐</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>
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/* isokit Studio: the scene editor (talks to `isokit studio` over /api). */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var S = { meta: null, doc: null, version: null, sel: null, placing: null, view: null, boxes: {}, warnings: [], busy: false, canUndo: false, canRedo: false, port: location.port };
|
||||
var ICON_DEFAULT = "▪";
|
||||
|
||||
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
||||
function api(path, body) {
|
||||
var o = body === undefined ? { cache: "no-store" } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
||||
return fetch(path, o).then(function (r) { return r.json(); });
|
||||
}
|
||||
var toastT = 0;
|
||||
function toast(msg, bad) {
|
||||
var t = $("toast");
|
||||
t.textContent = msg; t.hidden = false; t.classList.toggle("st-bad", !!bad);
|
||||
clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, bad ? 6000 : 2600);
|
||||
}
|
||||
function snapStep() { return parseFloat($("snap").value) || 0; }
|
||||
function snap(v) { var s = snapStep(); return s ? Math.round(v / s) * s : Math.round(v * 100) / 100; }
|
||||
function prefab(kind) { return (S.meta.catalog || []).filter(function (m) { return m.type === kind; })[0]; }
|
||||
function obj(id) { return (S.doc.objects || []).filter(function (o) { return o.id === id; })[0]; }
|
||||
function label(o) { var m = prefab(o.type); return o.name || (m ? m.label : o.type); }
|
||||
function icon(kind) { var m = prefab(kind); return (m && m.icon) || ICON_DEFAULT; }
|
||||
|
||||
// ------------------------------------------------------------ server state
|
||||
function takeState(r) {
|
||||
if (r.doc) S.doc = r.doc;
|
||||
if (r.version !== undefined) S.version = r.version;
|
||||
if (r.can_undo !== undefined) { S.canUndo = r.can_undo; S.canRedo = r.can_redo; }
|
||||
$("undo").disabled = !S.canUndo; $("redo").disabled = !S.canRedo;
|
||||
}
|
||||
function op(o, quiet) {
|
||||
$("save").textContent = "Saving…"; $("save").classList.remove("st-err");
|
||||
return api("/api/op", o).then(function (r) {
|
||||
if (r.error) {
|
||||
$("save").textContent = "Not saved"; $("save").classList.add("st-err");
|
||||
toast(r.error, true);
|
||||
return null;
|
||||
}
|
||||
takeState(r);
|
||||
$("save").textContent = "Saved";
|
||||
if (r.id && o.op !== "update") S.sel = r.id;
|
||||
if (S.sel && !obj(S.sel)) S.sel = null;
|
||||
S.view.refresh();
|
||||
renderLists(); if (!quiet) renderRight();
|
||||
return r;
|
||||
}, function (e) { $("save").textContent = "Offline"; $("save").classList.add("st-err"); toast("Studio server not reachable: " + e, true); });
|
||||
}
|
||||
function update(id, patch, quiet) { return op({ op: "update", id: id, patch: patch }, quiet); }
|
||||
function undo() { api("/api/undo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
||||
function redo() { api("/api/redo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
||||
function afterExternal() {
|
||||
if (S.sel && !obj(S.sel)) S.sel = null;
|
||||
S.view.refresh(); renderLists(); renderRight();
|
||||
}
|
||||
function reloadAll(why) {
|
||||
return Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) {
|
||||
var levelsChanged = JSON.stringify(rs[0].levels) !== JSON.stringify(S.meta.levels);
|
||||
S.meta = rs[0]; takeState(rs[1]);
|
||||
if (levelsChanged) { remount(); } else { S.view.source.meta = S.meta; S.view.refresh(); }
|
||||
renderPalette(); afterExternal();
|
||||
if (why) toast(why);
|
||||
});
|
||||
}
|
||||
// another editor or an agent changed the files: follow along
|
||||
function poll() {
|
||||
api("/api/version").then(function (r) {
|
||||
if (r.version !== S.version && !S.busy) { S.version = r.version; reloadAll("Scene updated outside the Studio (agent or file edit)"); }
|
||||
}).catch(function () { /* server restarting */ }).then(function () { setTimeout(poll, 800); });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ view
|
||||
var drag = null;
|
||||
function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); }
|
||||
function mountView() {
|
||||
var src = IsoKit.httpSource("", toViewerMeta(S.meta));
|
||||
S.view = IsoKit.mount($("view"), src, {
|
||||
note: false, noSelect: true, wheelZoom: true,
|
||||
onRender: function (d) {
|
||||
if (!d || d.error) { $("stats").textContent = "script error"; return; }
|
||||
S.boxes = d.boxes || {}; S.warnings = d.warnings || [];
|
||||
$("stats").textContent = d.sprites + " sprites · " + d.nodes + " nodes · " + d.ms + " ms";
|
||||
var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s");
|
||||
if (S.sel) S.view.selectObj(S.sel);
|
||||
},
|
||||
onHover: function (w) { if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1); },
|
||||
onPick: function (i) { select(i && i.obj ? i.obj : null); },
|
||||
onKey: keyHandler,
|
||||
interact: {
|
||||
down: function (e, hit) {
|
||||
if (S.placing) { return true; }
|
||||
if (!hit.obj) return false;
|
||||
var o = obj(hit.obj);
|
||||
if (!o || o.locked) return false;
|
||||
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null };
|
||||
return true;
|
||||
},
|
||||
move: function (e, m) {
|
||||
if (S.placing || !drag || !m.world || !drag.w0) return;
|
||||
if (!drag.moved) { drag.moved = true; drag.els = moveEls(drag.id); select(drag.id, true); }
|
||||
var dx = snap(drag.ox + m.world[0] - drag.w0[0]) - drag.ox, dy = snap(drag.oy + m.world[1] - drag.w0[1]) - drag.oy;
|
||||
var a = S.view.screenOf(dx, dy, 0), b = S.view.screenOf(0, 0, 0);
|
||||
drag.dx = dx; drag.dy = dy;
|
||||
Array.prototype.forEach.call(drag.els, function (g) { g.setAttribute("transform", "translate(" + (a[0] - b[0]).toFixed(1) + " " + (a[1] - b[1]).toFixed(1) + ")"); g.classList.add("iso-moving"); });
|
||||
$("cursor").textContent = "x " + (drag.ox + dx).toFixed(2) + " y " + (drag.oy + dy).toFixed(2);
|
||||
},
|
||||
up: function (e, m) {
|
||||
if (S.placing) {
|
||||
if (!m.cancel && m.world) place(S.placing, m.world, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
var d = drag; drag = null;
|
||||
if (!d) return;
|
||||
if (!d.moved || m.cancel) { select(d.id); return; }
|
||||
if (d.dx || d.dy) update(d.id, { x: +(d.ox + d.dx).toFixed(3), y: +(d.oy + d.dy).toFixed(3) });
|
||||
else S.view.refresh();
|
||||
},
|
||||
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; }
|
||||
}
|
||||
});
|
||||
window.__studio = { S: S, view: S.view, op: op, select: select };
|
||||
}
|
||||
function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; }
|
||||
function remount() {
|
||||
var lv = S.view ? S.view.getLevel() : null, rot = S.view ? S.view.getRotation() : null;
|
||||
if (S.view) S.view.destroy();
|
||||
mountView();
|
||||
if (lv) S.view.setLevel(lv);
|
||||
if (rot !== null) S.view.setRotation(rot);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ actions
|
||||
function place(kind, w, keep) {
|
||||
var m = prefab(kind);
|
||||
op({ op: "add", type: kind, x: snap(w[0]), y: snap(w[1]) }).then(function (r) {
|
||||
if (r) toast((m ? m.label : kind) + " added" + (keep ? "" : " · Shift-click to place several"));
|
||||
});
|
||||
if (!keep) setPlacing(null);
|
||||
}
|
||||
function setPlacing(kind) {
|
||||
S.placing = kind;
|
||||
document.querySelector(".st-centre").classList.toggle("st-placing", !!kind);
|
||||
Array.prototype.forEach.call(document.querySelectorAll(".st-tile"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-type") === kind)); });
|
||||
var h = $("hint");
|
||||
if (kind) { var m = prefab(kind); h.textContent = "Click on the map to place: " + (m ? m.label : kind) + " · Esc to stop"; h.hidden = false; }
|
||||
else h.hidden = true;
|
||||
}
|
||||
function select(id, quiet) {
|
||||
if (id && !obj(id)) id = null;
|
||||
S.sel = id;
|
||||
S.view.selectObj(id);
|
||||
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(li.getAttribute("data-id") === id)); });
|
||||
if (!quiet) renderRight();
|
||||
}
|
||||
function remove(id) { if (id) op({ op: "remove", id: id }).then(function (r) { if (r) toast("Deleted · Ctrl+Z brings it back"); }); }
|
||||
function duplicate(id) { if (id) op({ op: "duplicate", id: id, dx: Math.max(snapStep(), 1) * 2, dy: Math.max(snapStep(), 1) * 2 }); }
|
||||
function turn(id, d) { var o = obj(id); if (o) update(id, { rot: ((o.rot || 0) + d + 360) % 360 }); }
|
||||
function nudge(id, dx, dy) {
|
||||
var o = obj(id); if (!o || o.locked) return;
|
||||
var s = snapStep() || 0.5;
|
||||
// arrow keys follow the screen: map screen directions onto world axes for the current rotation
|
||||
var rot = S.view.getRotation();
|
||||
var right = IsoKit.unproject(rot, 1, 0, 0), up = IsoKit.unproject(rot, 0, -1, 0), o0 = IsoKit.unproject(rot, 0, 0, 0);
|
||||
var r = [right[0] - o0[0], right[1] - o0[1]], u = [up[0] - o0[0], up[1] - o0[1]];
|
||||
var wx = dx * r[0] + dy * u[0], wy = dx * r[1] + dy * u[1];
|
||||
// pick the dominant world axis so a key press moves along x or y only
|
||||
if (Math.abs(wx) >= Math.abs(wy)) { wy = 0; wx = Math.sign(wx); } else { wx = 0; wy = Math.sign(wy); }
|
||||
update(id, { x: +(o.x + wx * s).toFixed(3), y: +(o.y + wy * s).toFixed(3) }, true).then(function () { renderRight(); });
|
||||
}
|
||||
function keyHandler(e) {
|
||||
var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
|
||||
if (typing) return false;
|
||||
var k = e.key, ctrl = e.ctrlKey || e.metaKey;
|
||||
if (ctrl && (k === "z" || k === "Z")) { if (e.shiftKey) redo(); else undo(); return true; }
|
||||
if (ctrl && (k === "y" || k === "Y")) { redo(); return true; }
|
||||
if (k === "Escape") { if (S.placing) setPlacing(null); else select(null); return true; }
|
||||
if (!S.sel) return false;
|
||||
if (k === "Delete" || k === "Backspace") { remove(S.sel); return true; }
|
||||
if (ctrl && (k === "d" || k === "D")) { duplicate(S.sel); return true; }
|
||||
if (!ctrl && (k === "r" || k === "R")) { turn(S.sel, e.shiftKey ? -90 : 90); return true; }
|
||||
if (k === "ArrowLeft") { nudge(S.sel, -1, 0); return true; }
|
||||
if (k === "ArrowRight") { nudge(S.sel, 1, 0); return true; }
|
||||
if (k === "ArrowUp") { nudge(S.sel, 0, 1); return true; }
|
||||
if (k === "ArrowDown") { nudge(S.sel, 0, -1); return true; }
|
||||
return false;
|
||||
}
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (S.view && e.target === S.view.svg) return; // the viewer calls keyHandler itself
|
||||
if (keyHandler(e)) e.preventDefault();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ left panel
|
||||
function renderPalette() {
|
||||
var q = $("psearch").value.trim().toLowerCase(), cats = {}, order = [];
|
||||
(S.meta.catalog || []).forEach(function (m) {
|
||||
if (q && (m.label + " " + m.type + " " + m.category + " " + (m.help || "")).toLowerCase().indexOf(q) < 0) return;
|
||||
if (!cats[m.category]) { cats[m.category] = []; order.push(m.category); }
|
||||
cats[m.category].push(m);
|
||||
});
|
||||
$("palette").innerHTML = order.map(function (c) {
|
||||
return '<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();
|
||||
});
|
||||
})();
|
||||
@@ -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)
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,472 @@
|
||||
/* isokit-view.js: browser shell for isokit scenes (global IsoKit) */
|
||||
/*
|
||||
* Plain ES2019, no dependencies. The Rust engine renders SVG; this file shows
|
||||
* it: levels, four rotations, drag pan, pinch / wheel zoom, tag declutter,
|
||||
* tooltips, selection, full screen. Port of the CNXIso / Mooban mount().
|
||||
*
|
||||
* var view = IsoKit.mount(el, source, { onPick: function (info) {} });
|
||||
* source = IsoKit.staticSource(data) // prerendered views (isokit build)
|
||||
* | IsoKit.httpSource("", meta) // the Studio / dev server
|
||||
* source.meta = { title, note, levels: [{id, label, title}], defaultLevel, defaultRotation }
|
||||
* source.render(level, rot, cfg) -> view data or a Promise of it:
|
||||
* { html, info, bbox: [x, y, w, h], focus: [x, y], north, boxes, warnings }
|
||||
*
|
||||
* view.update(cfg) view.refresh() view.setLevel(id) view.setRotation(r) view.rotate(d)
|
||||
* view.select(id) view.selectObj(id) view.focus(id | [x, y, z], zoom) view.fit()
|
||||
* view.toWorld(clientX, clientY, z) -> [x, y] view.ready() -> Promise
|
||||
* view.info(id) view.stats() view.fullscreen(on) view.destroy()
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
var VERSION = "0.1.0";
|
||||
var C = 4, CZ = 4;
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
function r1(v) { return Math.round(v * 10) / 10; }
|
||||
function rotOf(r) { return ((r | 0) % 4 + 4) % 4; }
|
||||
// view coordinates -> world plan (inverse of the engine's Proj.r)
|
||||
function rInv(rot, vx, vy) {
|
||||
switch (rot) { case 0: return [vx, -vy]; case 1: return [-vy, -vx]; case 2: return [-vx, vy]; default: return [vy, vx]; }
|
||||
}
|
||||
function rFwd(rot, x, y) {
|
||||
switch (rot) { case 0: return [x, -y]; case 1: return [-y, -x]; case 2: return [-x, y]; default: return [y, x]; }
|
||||
}
|
||||
function project(rot, x, y, z) { var v = rFwd(rot, x, y); return [(v[0] - v[1]) * C, (v[0] + v[1]) * C / 2 - (z || 0) * CZ]; }
|
||||
function unproject(rot, sx, sy, z) {
|
||||
var a = sx / C, b = (sy + (z || 0) * CZ) * 2 / C;
|
||||
return rInv(rot, (a + b) / 2, (b - a) / 2);
|
||||
}
|
||||
function isThen(x) { return x && typeof x.then === "function"; }
|
||||
|
||||
// ------------------------------------------------------------ sources
|
||||
function staticSource(data) {
|
||||
return {
|
||||
meta: data.meta,
|
||||
render: function (level, rot) {
|
||||
var v = data.views[level + "/" + rot];
|
||||
return v || { error: { message: "view " + level + "/" + rot + " was not built" } };
|
||||
}
|
||||
};
|
||||
}
|
||||
function httpSource(base, meta) {
|
||||
base = base || "";
|
||||
return {
|
||||
meta: meta,
|
||||
dev: true,
|
||||
render: function (level, rot, cfg) {
|
||||
var q = "level=" + encodeURIComponent(level) + "&rot=" + rot + "&dev=1" + (cfg ? "&cfg=" + encodeURIComponent(JSON.stringify(cfg)) : "");
|
||||
return fetch(base + "/api/render?" + q, { cache: "no-store" }).then(function (r) { return r.json(); });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ mount
|
||||
var UID = 0;
|
||||
function mount(el, source, opts) {
|
||||
if (!el) throw new Error("IsoKit.mount: no container");
|
||||
opts = opts || {};
|
||||
var meta = source.meta || {};
|
||||
var levels = meta.levels && meta.levels.length ? meta.levels : [{ id: "site", label: "Site" }];
|
||||
var st = {
|
||||
cfg: Object.assign({}, opts.cfg || {}),
|
||||
level: opts.level || meta.defaultLevel || levels[0].id,
|
||||
rot: rotOf(opts.rotation === undefined ? (meta.defaultRotation === undefined ? 1 : meta.defaultRotation) : opts.rotation),
|
||||
sel: null, selObj: null, uid: ++UID
|
||||
};
|
||||
var wrap = document.createElement("div");
|
||||
wrap.className = "iso-wrap";
|
||||
var segs = levels.length > 1 ? '<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">↺</button>' +
|
||||
'<button type="button" data-a="rotr" title="Rotate right" aria-label="Rotate the view right">↻</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">−</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);
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">"); }
|
||||
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>
|
||||
Reference in new issue
Block a user