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

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

No files matched your search

+9
View File
@@ -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 }
+209 -1
View File
@@ -1 +1,209 @@
fn main() {}
//! `isokit`: the command line.
//!
//! isokit new <dir> start a project
//! isokit studio [dir] the GUI editor in the browser (also serves MCP at /mcp)
//! isokit mcp [dir] MCP server over stdio for AI agents
//! isokit build [dir] self-contained HTML page with every view
//! isokit render [dir] -o x.svg one view as SVG
//! isokit check [dir] lint the project
//! isokit prefabs [dir] list the object types
mod serve;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use isokit_project::{check, page, Project};
use std::io::{BufRead, Write};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "isokit",
version,
about = "Isometric scene engine: Studio GUI, MCP server, HTML/SVG export"
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Create a project folder with a starter scene
New {
dir: PathBuf,
#[arg(long)]
title: Option<String>,
},
/// Open the Studio (GUI editor) in the browser
Studio {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(long, default_value_t = 7878)]
port: u16,
/// Do not open a browser
#[arg(long)]
no_open: bool,
},
/// Serve the MCP protocol on stdio (for AI agents)
Mcp {
#[arg(default_value = ".")]
dir: PathBuf,
},
/// Build the self-contained HTML page
Build {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
},
/// Render one view as a standalone SVG
Render {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(short, long)]
out: PathBuf,
#[arg(long)]
level: Option<String>,
#[arg(long)]
rot: Option<u8>,
/// Light (day) colours instead of dark
#[arg(long)]
light: bool,
},
/// Lint the project (exit code 1 on errors)
Check {
#[arg(default_value = ".")]
dir: PathBuf,
},
/// List the object types (prefabs) and their properties
Prefabs {
#[arg(default_value = ".")]
dir: PathBuf,
},
}
fn load(dir: &PathBuf) -> Result<Project> {
let p = Project::load(dir).map_err(|e| anyhow::anyhow!("{e}"))?;
if let Err(e) = &p.runtime {
bail!("{e}");
}
Ok(p)
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Cmd::New { dir, title } => {
let title = title.unwrap_or_else(|| {
dir.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or("Scene".into())
});
isokit_project::scaffold(&dir, &title).map_err(|e| anyhow::anyhow!("{e}"))?;
println!(
"Created {} - open it with: isokit studio {}",
dir.display(),
dir.display()
);
}
Cmd::Studio { dir, port, no_open } => serve::studio(&dir, port, !no_open)?,
Cmd::Mcp { dir } => mcp_stdio(&dir)?,
Cmd::Build { dir, out } => {
let p = load(&dir)?;
let out = out.unwrap_or_else(|| {
dir.join(
p.manifest
.build
.out
.clone()
.unwrap_or_else(|| "dist/index.html".into()),
)
});
let html = page::static_page(&p).map_err(|e| anyhow::anyhow!("{e}"))?;
if let Some(d) = out.parent() {
std::fs::create_dir_all(d).ok();
}
std::fs::write(&out, &html).with_context(|| out.display().to_string())?;
let views = p.manifest.levels().len() * 4;
println!(
"{} ({} views, {} kB)",
out.display(),
views,
html.len() / 1024
);
}
Cmd::Render {
dir,
out,
level,
rot,
light,
} => {
let p = load(&dir)?;
let level = level.unwrap_or_else(|| p.manifest.default_level());
let rot = rot.unwrap_or(p.manifest.default_rotation) % 4;
let svg = page::svg(&p, &level, rot, light).map_err(|e| anyhow::anyhow!("{e}"))?;
std::fs::write(&out, svg).with_context(|| out.display().to_string())?;
println!("{} ({level}, rotation {rot})", out.display());
}
Cmd::Check { dir } => {
let p = Project::load(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
let f = check::check(&p);
for x in &f {
println!(
"{:7} {}{}",
x.level,
x.object
.as_ref()
.map(|o| format!("[{o}] "))
.unwrap_or_default(),
x.message
);
}
let errors = f.iter().filter(|x| x.level == "error").count();
println!("{} finding(s), {errors} error(s)", f.len());
if errors > 0 {
std::process::exit(1);
}
}
Cmd::Prefabs { dir } => {
let p = load(&dir)?;
for m in p.catalog() {
println!("{:12} {:24} {} [{}]", m.kind, m.label, m.category, m.source);
for d in &m.props {
println!(
" {:10} {:8} default {} {}",
d.key, d.kind, d.default, d.label
);
}
}
}
}
Ok(())
}
/// MCP over stdio: one JSON-RPC message per line in, one per line out.
fn mcp_stdio(dir: &PathBuf) -> Result<()> {
let mut ws =
isokit_project::workspace::Workspace::open(dir).map_err(|e| anyhow::anyhow!("{e}"))?;
let stdin = std::io::stdin();
let mut out = std::io::stdout().lock();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let resp = match serde_json::from_str::<serde_json::Value>(&line) {
Ok(msg) => isokit_project::mcp::handle(&mut ws, &msg),
Err(e) => Some(
serde_json::json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
};
if let Some(r) = resp {
writeln!(out, "{r}")?;
out.flush()?;
}
}
Ok(())
}
+356
View File
@@ -0,0 +1,356 @@
//! `isokit studio`: the GUI editor and its JSON API on localhost, plus the
//! MCP endpoint (`POST /mcp`, JSON responses) so agents edit the same scene
//! live. Single-threaded: one request at a time against one Workspace.
use anyhow::Result;
use isokit_project::ops::Op;
use isokit_project::workspace::Workspace;
use isokit_project::{assets, check, mcp, page};
use serde_json::{json, Map, Value};
use std::io::Read;
use std::path::Path;
use tiny_http::{Header, Method, Request, Response, Server};
const VIEW_PAGE: &str = r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>isokit preview</title>
<script>(function(){var t=null;try{t=localStorage.getItem("isokit-theme")}catch(e){}if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",t);})();</script>
<link rel="stylesheet" href="/assets/viewer/isokit-view.css"><link rel="stylesheet" href="/assets/theme.css">
<style>:root{color-scheme:dark}html[data-theme=light]{color-scheme:light}body{margin:0;background:#0a0f1a;font:14px system-ui,sans-serif;color:#d6deeb}
html[data-theme=light] body{background:#f4f6fa;color:#1b2533}main{max-width:1320px;margin:0 auto;padding:16px}</style></head>
<body><main><div id="view"></div></main><script src="/assets/viewer/isokit-view.js"></script><script>
fetch("/api/meta").then(function(r){return r.json()}).then(function(m){
var meta={title:m.title,note:m.note,levels:m.levels,defaultLevel:m.default_level,defaultRotation:m.default_rotation};
document.title=m.title+" · preview";
var view=IsoKit.mount(document.getElementById("view"),IsoKit.httpSource("",meta),{});
window.__isokit={view:view};
var v=null;(function poll(){fetch("/api/version").then(function(r){return r.json()}).then(function(r){if(v!==null&&r.version!==v)view.refresh();v=r.version;}).catch(function(){}).then(function(){setTimeout(poll,1000)});})();
});
</script></body></html>"#;
fn header(k: &str, v: &str) -> Header {
Header::from_bytes(k.as_bytes(), v.as_bytes()).expect("valid header")
}
fn send(req: Request, status: u16, mime: &str, body: String) {
let r = Response::from_string(body)
.with_status_code(status)
.with_header(header("Content-Type", mime))
.with_header(header("Cache-Control", "no-store"));
let _ = req.respond(r);
}
fn send_json(req: Request, v: Value) {
let status = if v.get("error").is_some() && v.get("jsonrpc").is_none() {
400
} else {
200
};
send(
req,
status,
"application/json; charset=utf-8",
v.to_string(),
);
}
fn query(url: &str) -> Map<String, Value> {
let mut m = Map::new();
if let Some((_, q)) = url.split_once('?') {
for kv in q.split('&') {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
m.insert(decode(k), Value::String(decode(v)));
}
}
m
}
fn decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < b.len() => {
if let Ok(v) =
u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16)
{
out.push(v);
i += 2;
} else {
out.push(b'%');
}
}
c => out.push(c),
}
i += 1;
}
String::from_utf8_lossy(&out).to_string()
}
/// Requests from a web page on another origin (DNS rebinding, a malicious
/// site) must not edit the scene.
fn origin_ok(req: &Request) -> bool {
match req.headers().iter().find(|h| h.field.equiv("Origin")) {
None => true,
Some(h) => {
let o = h.value.as_str();
o == "null" || o.starts_with("http://127.0.0.1") || o.starts_with("http://localhost")
}
}
}
fn state(ws: &Workspace) -> Value {
json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() })
}
fn meta(ws: &Workspace) -> Value {
let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default();
v["dir"] = json!(ws.dir.display().to_string());
v["load_error"] = json!(ws.load_error);
v["script_error"] = json!(ws.project.runtime.as_ref().err());
v
}
pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
// Windows canonical paths carry a \?\ prefix that other tools do not accept
let dir = std::path::PathBuf::from(
dir.to_string_lossy()
.trim_start_matches(r"\\?\")
.to_string(),
);
let mut ws = Workspace::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
let (server, port) = (port..port.saturating_add(20))
.find_map(|p| Server::http(("127.0.0.1", p)).ok().map(|s| (s, p)))
.ok_or_else(|| anyhow::anyhow!("no free port from {port}"))?;
let url = format!("http://127.0.0.1:{port}/");
println!("isokit Studio: {url}");
println!(" project {}", dir.display());
println!(" preview {url}view");
println!(" MCP {url}mcp (or: isokit mcp {})", dir.display());
if let Err(e) = &ws.project.runtime {
println!(" script error: {e}");
}
if open {
open_browser(&url);
}
for mut req in server.incoming_requests() {
let url = req.url().to_string();
let path = url.split('?').next().unwrap_or("/").to_string();
let method = req.method().clone();
let mut body = String::new();
if method == Method::Post {
if !origin_ok(&req) {
send(
req,
403,
"text/plain",
"cross-origin request refused".into(),
);
continue;
}
let _ = req.as_reader().take(16 << 20).read_to_string(&mut body);
}
let bodyv: Value = if body.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&body).unwrap_or(Value::Null)
};
match (&method, path.as_str()) {
(Method::Get, "/") => send(
req,
200,
"text/html; charset=utf-8",
assets::get("studio/index.html")
.unwrap_or_default()
.into_owned(),
),
(Method::Get, "/view") => send(req, 200, "text/html; charset=utf-8", VIEW_PAGE.into()),
(Method::Get, "/assets/theme.css") => {
ws.refresh();
send(req, 200, "text/css; charset=utf-8", ws.project.theme.css())
}
(Method::Get, p) if p.starts_with("/assets/") => {
let name = &p["/assets/".len()..];
let mime = if name.ends_with(".js") {
"text/javascript; charset=utf-8"
} else if name.ends_with(".css") {
"text/css; charset=utf-8"
} else {
"text/html; charset=utf-8"
};
match assets::get(name) {
Some(s) => send(req, 200, mime, s.into_owned()),
None => send(req, 404, "text/plain", "not found".into()),
}
}
(Method::Get, "/api/version") => {
ws.refresh();
send_json(req, json!({ "version": ws.version.to_string() }))
}
(Method::Get, "/api/meta") => {
ws.refresh();
send_json(req, meta(&ws))
}
(Method::Get, "/api/doc") => {
ws.refresh();
send_json(req, state(&ws))
}
(Method::Get, "/api/render") => {
ws.refresh();
let q = query(&url);
let p = &ws.project;
let level = q
.get("level")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| p.manifest.default_level());
let rot = q
.get("rot")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(0);
let cfg = q
.get("cfg")
.and_then(|v| v.as_str())
.and_then(|s| serde_json::from_str::<Map<String, Value>>(s).ok());
let dev = q.get("dev").is_some();
match p.render(&level, rot, cfg.as_ref(), dev) {
Ok(v) => send_json(req, serde_json::to_value(v).unwrap_or_default()),
Err(e) => send(
req,
200,
"application/json",
json!({ "error": e }).to_string(),
),
}
}
(Method::Get, "/api/check") => {
ws.refresh();
send_json(req, json!({ "findings": check::check(&ws.project) }))
}
(Method::Post, "/api/op") => {
let r = if let Some(list) = bodyv.get("ops").and_then(|v| v.as_array()) {
let ops: Result<Vec<Op>, String> = list
.iter()
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
.collect();
ops.and_then(|l| ws.apply_all(l))
.map(|o| o.last().and_then(|x| x.id.clone()))
} else {
serde_json::from_value::<Op>(bodyv.clone())
.map_err(|e| format!("bad operation: {e}"))
.and_then(|op| ws.apply(op))
.map(|o| o.id)
};
match r {
Ok(id) => {
let mut s = state(&ws);
s["ok"] = json!(true);
s["id"] = json!(id);
send_json(req, s)
}
Err(e) => send(
req,
200,
"application/json",
json!({ "error": e }).to_string(),
),
}
}
(Method::Post, "/api/undo") | (Method::Post, "/api/redo") => {
let r = if path.ends_with("undo") {
ws.undo()
} else {
ws.redo()
};
match r {
Ok(_) => send_json(req, state(&ws)),
Err(e) => send(
req,
200,
"application/json",
json!({ "error": e }).to_string(),
),
}
}
(Method::Post, "/api/build") => {
ws.refresh();
let p = &ws.project;
let out = p.dir.join(
p.manifest
.build
.out
.clone()
.unwrap_or_else(|| "dist/index.html".into()),
);
let r = page::static_page(p)
.map_err(|e| e.to_string())
.and_then(|html| {
if let Some(d) = out.parent() {
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
}
std::fs::write(&out, html).map_err(|e| e.to_string())
});
match r {
Ok(_) => send_json(req, json!({ "path": out.display().to_string() })),
Err(e) => send(
req,
200,
"application/json",
json!({ "error": e }).to_string(),
),
}
}
(Method::Post, "/mcp") => {
// a batch (array) or a single message; notifications get 202
let resp = match &bodyv {
Value::Array(list) => {
let out: Vec<Value> = list
.iter()
.filter_map(|m| mcp::handle(&mut ws, m))
.collect();
if out.is_empty() {
None
} else {
Some(Value::Array(out))
}
}
Value::Null => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": "parse error" } }),
),
m => mcp::handle(&mut ws, m),
};
match resp {
Some(v) => send(req, 200, "application/json", v.to_string()),
None => {
let _ = req.respond(Response::empty(202));
}
}
}
(Method::Get, "/mcp") => send(
req,
405,
"text/plain",
"MCP: POST JSON-RPC messages here".into(),
),
_ => send(req, 404, "text/plain", "not found".into()),
}
}
Ok(())
}
fn open_browser(url: &str) {
#[cfg(windows)]
let r = std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn();
#[cfg(target_os = "macos")]
let r = std::process::Command::new("open").arg(url).spawn();
#[cfg(all(unix, not(target_os = "macos")))]
let r = std::process::Command::new("xdg-open").arg(url).spawn();
if r.is_err() {
println!(" open {url} in a browser");
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ pub fn r1(out: &mut String, v: f64) {
}
let a = t.unsigned_abs();
let _ = 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);
+89 -14
View File
@@ -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>)> },
}
+7 -2
View File
@@ -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:?}"
);
}
}
+159 -25
View File
@@ -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);
}
+76 -13
View File
@@ -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])
})
}
}
+21 -4
View File
@@ -86,7 +86,10 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
}
}
}
let mut out = Order { order: Vec::with_capacity(n), cycles: 0 };
let mut out = Order {
order: Vec::with_capacity(n),
cycles: 0,
};
for _ in 0..n {
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]);
}
+200 -32
View File
@@ -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}"
);
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "isokit-project"
description = "isokit projects: manifest, scene document ops, rendering, export, checks, MCP tools"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
isokit-core = { workspace = true }
isokit-script = { workspace = true }
isokit-theme = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
+113
View File
@@ -0,0 +1,113 @@
//! Files embedded in the binary: the viewer, the Studio and the starter
//! project. Set `ISOKIT_ASSETS` to the repository folder to serve the
//! viewer and Studio files from disk instead (edit them without rebuilding).
use std::borrow::Cow;
pub const VIEW_JS: &str = include_str!("../../../viewer/isokit-view.js");
pub const VIEW_CSS: &str = include_str!("../../../viewer/isokit-view.css");
pub const PAGE: &str = include_str!("../../../viewer/page.html");
pub const STUDIO_HTML: &str = include_str!("../../../studio/index.html");
pub const STUDIO_JS: &str = include_str!("../../../studio/studio.js");
pub const STUDIO_CSS: &str = include_str!("../../../studio/studio.css");
/// Asset by its path in the repository ("viewer/isokit-view.js",
/// "studio/studio.js"), from disk when `ISOKIT_ASSETS` is set.
pub fn get(path: &str) -> Option<Cow<'static, str>> {
if let Ok(root) = std::env::var("ISOKIT_ASSETS") {
if let Ok(s) = std::fs::read_to_string(std::path::Path::new(&root).join(path)) {
return Some(Cow::Owned(s));
}
}
let s = match path {
"viewer/isokit-view.js" => VIEW_JS,
"viewer/isokit-view.css" => VIEW_CSS,
"viewer/page.html" => PAGE,
"studio/index.html" => STUDIO_HTML,
"studio/studio.js" => STUDIO_JS,
"studio/studio.css" => STUDIO_CSS,
_ => return None,
};
Some(Cow::Borrowed(s))
}
pub const NEW_MANIFEST: &str = r#"# isokit project. Open it with `isokit studio` (GUI) or build it with `isokit build`.
title = "{title}"
note = "Schematic isometric view. Drag to pan, Ctrl + wheel or pinch to zoom, tap anything for details."
# levels: "site" shows buildings closed; "gf", "1f", ... cut buildings open at that storey
default_level = "site"
default_rotation = 1
# project prefab libraries (functions prefab_<type> / meta_<type>), always loaded
prelude = []
# colour overrides on top of the built-in theme
theme = ["theme.toml"]
[[level]]
id = "site"
label = "Site"
title = "Closed buildings and roofs"
[[level]]
id = "gf"
label = "GF"
title = "Ground floors, cut open"
[[level]]
id = "1f"
label = "1F"
title = "First floors, cut open"
[[param]]
name = "labels"
type = "bool"
default = true
label = "Labels"
[[param]]
name = "running"
type = "bool"
default = true
label = "Animation"
[build]
out = "dist/index.html"
budget = 4000
"#;
pub const NEW_DOC: &str = r#"{
"version": 1,
"objects": [
{ "id": "site", "type": "site", "name": "Site", "x": 0, "y": 0, "props": { "w": 60, "d": 40 }, "locked": true },
{ "id": "road", "type": "road", "name": "Access road", "x": 0, "y": -17, "props": { "w": 60, "d": 6 } },
{ "id": "building", "type": "building", "name": "Main building", "x": -8, "y": 3, "props": { "w": 22, "d": 12, "floors": 2, "pv": true }, "tag": "Main building", "info": "Two storeys, solar on the roof" },
{ "id": "tree", "type": "tree", "x": 16, "y": 8 },
{ "id": "car", "type": "car", "x": 12, "y": -10, "props": { "colour": "car1" } }
]
}
"#;
pub const NEW_THEME: &str = r##"# Colour overrides on top of the built-in theme. Examples:
#
# [palette.dark]
# render = "#8f8b84"
# [palette.light]
# render = "#f4efe6"
# [materials]
# brick = "#9a5a44" # use as mat "brick" in prefabs and scripts
"##;
pub const NEW_LIB_README: &str = r#"Project prefabs live here. A prefab is a pair of Rhai functions:
fn meta_kiosk() {
#{ label: "Kiosk", category: "Shops", icon: "K",
props: [ #{ key: "h", type: "float", def: 2.6, min: 2.0, max: 5.0, label: "Height (m)" } ] }
}
fn prefab_kiosk(o) { // o.p holds the props; draw around (0, 0)
sprite().box(-1.5, -1, 1.5, 1, 0, o.p.h, "render2", #{ top: "roof3" });
}
List the file in isokit.toml (prelude = ["lib/kiosk.rhai"]) and the Studio
offers "Kiosk" in its palette.
"#;
+144
View File
@@ -0,0 +1,144 @@
//! `isokit check`: problems a person would spot by eye, found by code.
use crate::ops::check_props;
use crate::{assets, Project};
use serde::Serialize;
use std::collections::BTreeSet;
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct Finding {
/// error | warning | info
pub level: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub object: Option<String>,
}
fn f(level: &str, message: String, object: Option<String>) -> Finding {
Finding {
level: level.into(),
message,
object,
}
}
/// Classes the viewer styles itself (faces, structure, animation).
const STRUCTURAL: &[&str] = &[
"iso-t",
"iso-l",
"iso-c",
"iso-r",
"iso-ground",
"iso-sprites",
"iso-tags",
"iso-tag",
"iso-a",
"iso-vent",
"iso-dim",
"iso-headc",
"iso-led",
"iso-lit",
"iso-on",
"iso-empty",
];
pub fn check(p: &Project) -> Vec<Finding> {
let mut out = vec![];
let rt = match &p.runtime {
Ok(r) => r,
Err(e) => return vec![f("error", e.to_string(), None)],
};
// document: types and props
let mut ids = BTreeSet::new();
for o in &p.doc.objects {
if !ids.insert(o.id.clone()) {
out.push(f(
"error",
format!("duplicate object id \"{}\"", o.id),
Some(o.id.clone()),
));
}
match rt.prefab(&o.kind) {
None => out.push(f(
"error",
format!("unknown type \"{}\"", o.kind),
Some(o.id.clone()),
)),
Some(m) => {
if let Err(e) = check_props(m, &o.props) {
out.push(f("warning", e, Some(o.id.clone())));
}
}
}
}
// every view: warnings, NaN, budget, sort cycles, undefined classes
let css = format!(
"{}\n{}",
assets::get("viewer/isokit-view.css").unwrap_or_default(),
p.theme.css()
);
let mut seen_warn = BTreeSet::new();
let mut missing = BTreeSet::new();
for l in p.manifest.levels() {
for rot in 0..4u8 {
let v = match p.render(&l.id, rot, None, false) {
Ok(v) => v,
Err(e) => {
out.push(f("error", format!("{}/{rot}: {e}", l.id), None));
continue;
}
};
for w in &v.warnings {
if seen_warn.insert(w.clone()) {
let obj = w
.strip_prefix("object ")
.and_then(|r| r.split(':').next())
.map(str::to_string);
out.push(f("warning", w.clone(), obj));
}
}
if v.html.contains("NaN") {
out.push(f(
"error",
format!(
"{}/{rot}: NaN in the drawing (a coordinate is not a number)",
l.id
),
None,
));
}
if v.nodes > p.manifest.build.budget {
out.push(f(
"warning",
format!(
"{}/{rot}: {} SVG nodes, budget {}",
l.id, v.nodes, p.manifest.build.budget
),
None,
));
}
if v.cycles > 0 {
out.push(f("info", format!("{}/{rot}: {} depth-sort cycle(s) broken; split large sprites if something draws in front of what it stands behind", l.id, v.cycles), None));
}
for cls in v
.html
.split("class=\"")
.skip(1)
.filter_map(|s| s.split('"').next())
{
for c in cls.split_whitespace() {
if c.starts_with("iso-")
&& !STRUCTURAL.contains(&c)
&& !css.contains(&format!(".{c}"))
{
missing.insert(c.to_string());
}
}
}
}
}
for c in missing {
out.push(f("warning", format!("class {c} has no style in the theme (drawn grey): add it under [materials] in theme.toml"), None));
}
out
}
+470
View File
@@ -0,0 +1,470 @@
//! isokit projects.
//!
//! A project is a folder:
//!
//! ```text
//! isokit.toml manifest: title, levels, parameters, theme files, prelude modules
//! scene.json the scene document (objects), edited by the Studio and agents
//! scene.rhai optional script drawn before the objects
//! lib/*.rhai optional project prefabs (list them in `prelude`)
//! theme.toml optional colours and materials
//! ```
//!
//! [`Project`] loads and renders it; [`ops`] edits the document; [`page`]
//! exports HTML/SVG; [`check`] lints it; [`workspace::Workspace`] keeps a
//! project loaded across edits (undo, reload on file changes) for the Studio
//! and the MCP server ([`mcp`]).
pub mod assets;
pub mod check;
pub mod mcp;
pub mod ops;
pub mod page;
pub mod workspace;
use isokit_core::{render, Info, RenderOpts};
use isokit_script::doc::{Doc, PrefabMeta};
use isokit_script::{Bundle, Runtime, ScriptError};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
pub use isokit_script;
pub const MANIFEST: &str = "isokit.toml";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct LevelDef {
pub id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub title: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ParamDef {
pub name: String,
/// bool | int | float | choice | text
#[serde(rename = "type", default = "bool_kind")]
pub kind: String,
#[serde(default)]
pub default: Value,
#[serde(default)]
pub label: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<f64>,
}
fn bool_kind() -> String {
"bool".into()
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct BuildOpts {
#[serde(default)]
pub out: Option<String>,
/// Node budget per view for `isokit check`.
#[serde(default = "budget")]
pub budget: usize,
}
fn budget() -> usize {
4000
}
impl Default for BuildOpts {
fn default() -> Self {
BuildOpts {
out: None,
budget: budget(),
}
}
}
fn rot1() -> u8 {
1
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Manifest {
#[serde(default)]
pub title: String,
#[serde(default)]
pub note: String,
/// Main script; default `scene.rhai` when that file exists.
#[serde(default)]
pub script: Option<String>,
/// Scene document; default `scene.json`.
#[serde(default)]
pub doc: Option<String>,
#[serde(default)]
pub prelude: Vec<String>,
#[serde(default)]
pub theme: Vec<String>,
#[serde(default, rename = "level")]
pub levels: Vec<LevelDef>,
#[serde(default)]
pub default_level: Option<String>,
#[serde(default = "rot1")]
pub default_rotation: u8,
#[serde(default, rename = "param")]
pub params: Vec<ParamDef>,
#[serde(default)]
pub build: BuildOpts,
}
impl Manifest {
pub fn levels(&self) -> Vec<LevelDef> {
if self.levels.is_empty() {
vec![LevelDef {
id: "site".into(),
label: "Site".into(),
title: "The whole site".into(),
}]
} else {
self.levels.clone()
}
}
pub fn default_level(&self) -> String {
let lv = self.levels();
self.default_level
.clone()
.filter(|d| lv.iter().any(|l| &l.id == d))
.unwrap_or_else(|| lv[0].id.clone())
}
pub fn doc_path(&self) -> String {
self.doc.clone().unwrap_or_else(|| "scene.json".into())
}
}
#[derive(Debug)]
pub enum Error {
Io(String),
Manifest(String),
Theme(String),
Doc(String),
Script(ScriptError),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Io(e) => write!(f, "{e}"),
Error::Manifest(e) => write!(f, "isokit.toml: {e}"),
Error::Theme(e) => write!(f, "theme: {e}"),
Error::Doc(e) => write!(f, "scene document: {e}"),
Error::Script(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for Error {}
impl From<ScriptError> for Error {
fn from(e: ScriptError) -> Self {
Error::Script(e)
}
}
/// One rendered view as the viewer consumes it.
#[derive(Clone, Debug, Serialize)]
pub struct View {
pub html: String,
pub info: BTreeMap<String, Info>,
pub bbox: [f64; 4],
pub focus: [f64; 2],
pub rot: u8,
pub north: f64,
pub level: String,
pub sprites: usize,
pub nodes: usize,
pub cycles: usize,
pub warnings: Vec<String>,
/// Object id -> world box [x0, y0, z0, x1, y1, z1].
pub boxes: BTreeMap<String, [f64; 6]>,
pub ms: f64,
}
/// Project facts the viewer, the Studio and agents need.
#[derive(Clone, Debug, Serialize)]
pub struct Meta {
pub title: String,
pub note: String,
pub levels: Vec<LevelDef>,
pub default_level: String,
pub default_rotation: u8,
pub params: Vec<ParamDef>,
pub catalog: Vec<PrefabMeta>,
pub materials: Vec<String>,
pub c: f64,
pub cz: f64,
}
fn rd(path: &Path) -> Result<String, Error> {
std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))
}
/// Every `*.rhai` below `dir` (skipping dist, target and dot folders),
/// keyed by forward-slash relative path.
fn collect_sources(dir: &Path) -> Result<BTreeMap<String, String>, Error> {
let mut out = BTreeMap::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let rd_ = std::fs::read_dir(&d).map_err(|e| Error::Io(format!("{}: {e}", d.display())))?;
for ent in rd_.flatten() {
let p = ent.path();
let name = ent.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !name.starts_with('.')
&& name != "dist"
&& name != "target"
&& name != "node_modules"
{
stack.push(p);
}
} else if name.ends_with(".rhai") {
let rel = p
.strip_prefix(dir)
.unwrap_or(&p)
.to_string_lossy()
.replace('\\', "/");
out.insert(rel, rd(&p)?);
}
}
}
Ok(out)
}
pub struct Project {
pub dir: PathBuf,
pub manifest: Manifest,
pub theme: isokit_theme::Theme,
pub doc: Doc,
pub bundle: Bundle,
/// The compiled scripts, or the error that stopped them.
pub runtime: Result<Runtime, ScriptError>,
}
impl Project {
/// Load a project folder. A script error does not fail the load: the
/// document stays editable and [`Project::runtime`] holds the error.
pub fn load(dir: impl AsRef<Path>) -> Result<Project, Error> {
let dir = dir.as_ref().to_path_buf();
let mpath = dir.join(MANIFEST);
let manifest: Manifest = if mpath.exists() {
toml::from_str(&rd(&mpath)?).map_err(|e| Error::Manifest(e.to_string()))?
} else {
return Err(Error::Io(format!(
"{} not found (create a project with `isokit new`)",
mpath.display()
)));
};
let mut layers = vec![];
for t in &manifest.theme {
layers.push((t.clone(), rd(&dir.join(t))?));
}
let refs: Vec<(&str, &str)> = layers
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
let theme = isokit_theme::load(&refs).map_err(Error::Theme)?;
let dpath = dir.join(manifest.doc_path());
let doc: Doc = if dpath.exists() {
serde_json::from_str(&rd(&dpath)?)
.map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?
} else {
Doc {
version: 1,
..Default::default()
}
};
let sources = collect_sources(&dir)?;
let main = match &manifest.script {
Some(s) => Some(s.clone()),
None if sources.contains_key("scene.rhai") => Some("scene.rhai".into()),
None => None,
};
let bundle = Bundle {
main,
prelude: manifest.prelude.clone(),
sources,
};
let runtime = Runtime::new(&bundle);
Ok(Project {
dir,
manifest,
theme,
doc,
bundle,
runtime,
})
}
pub fn catalog(&self) -> &[PrefabMeta] {
match &self.runtime {
Ok(r) => r.catalog(),
Err(_) => &[],
}
}
/// Parameter values: manifest defaults, then the document, then `over`.
pub fn cfg(&self, over: Option<&Map<String, Value>>) -> Map<String, Value> {
let mut c = Map::new();
for p in &self.manifest.params {
c.insert(p.name.clone(), p.default.clone());
}
for (k, v) in &self.doc.cfg {
c.insert(k.clone(), v.clone());
}
if let Some(o) = over {
for (k, v) in o {
c.insert(k.clone(), v.clone());
}
}
c
}
pub fn materials(&self) -> Vec<String> {
self.theme
.materials
.iter()
.map(|(k, _)| k.clone())
.collect()
}
pub fn meta(&self) -> Meta {
Meta {
title: if self.manifest.title.is_empty() {
self.dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
} else {
self.manifest.title.clone()
},
note: self.manifest.note.clone(),
levels: self.manifest.levels(),
default_level: self.manifest.default_level(),
default_rotation: self.manifest.default_rotation % 4,
params: self.manifest.params.clone(),
catalog: self.catalog().to_vec(),
materials: self.materials(),
c: isokit_core::C,
cz: isokit_core::CZ,
}
}
/// Render one view of the current document.
pub fn render(
&self,
level: &str,
rot: u8,
over: Option<&Map<String, Value>>,
dev: bool,
) -> Result<View, ScriptError> {
let t0 = Instant::now();
let rt = self.runtime.as_ref().map_err(|e| e.clone())?;
let cfg = self.cfg(over);
let built = rt.build(level, rot % 4, &cfg, &self.doc)?;
let labels = cfg.get("labels").and_then(|v| v.as_bool()).unwrap_or(true);
let r = render(
&built.scene,
rot % 4,
&RenderOpts {
uid: 0,
labels,
dev,
},
);
Ok(View {
html: r.html,
info: r.info,
bbox: r.bbox,
focus: r.focus,
rot: r.rot,
north: r.north,
level: level.to_string(),
sprites: r.sprites,
nodes: r.nodes,
cycles: r.cycles,
warnings: r.warnings,
boxes: built.boxes,
ms: (t0.elapsed().as_secs_f64() * 1e4).round() / 10.0,
})
}
/// Write the document (pretty JSON, atomically).
pub fn save_doc(&self) -> Result<(), Error> {
let path = self.dir.join(self.manifest.doc_path());
let mut s =
serde_json::to_string_pretty(&self.doc).map_err(|e| Error::Doc(e.to_string()))?;
s.push('\n');
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, s).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?;
std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
Ok(())
}
}
/// A fingerprint of the project files (manifest, documents, scripts,
/// themes): changes when any of them is edited, added or removed.
pub fn version(dir: &Path) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut stack = vec![dir.to_path_buf()];
let mut files = vec![];
while let Some(d) = stack.pop() {
let Ok(rd_) = std::fs::read_dir(&d) else {
continue;
};
for ent in rd_.flatten() {
let p = ent.path();
let name = ent.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !name.starts_with('.')
&& name != "dist"
&& name != "target"
&& name != "node_modules"
{
stack.push(p);
}
} else if name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json")
{
if let Ok(m) = ent.metadata() {
files.push((p.to_string_lossy().to_string(), m.len(), m.modified().ok()));
}
}
}
}
files.sort();
files.hash(&mut h);
h.finish()
}
/// Write a new project folder with a starter scene.
pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
if dir.join(MANIFEST).exists() {
return Err(Error::Io(format!(
"{} already has an isokit.toml",
dir.display()
)));
}
std::fs::create_dir_all(dir.join("lib")).map_err(|e| Error::Io(e.to_string()))?;
let w = |name: &str, body: &str| {
std::fs::write(dir.join(name), body).map_err(|e| Error::Io(format!("{name}: {e}")))
};
w(MANIFEST, &assets::NEW_MANIFEST.replace("{title}", title))?;
w("scene.json", assets::NEW_DOC)?;
w("theme.toml", assets::NEW_THEME)?;
w("lib/README.md", assets::NEW_LIB_README)?;
Ok(())
}
+360
View File
@@ -0,0 +1,360 @@
//! MCP (Model Context Protocol) server: agents edit the scene with the same
//! operations as the Studio. Transport-agnostic: [`handle`] takes one
//! JSON-RPC message and returns the response (None for notifications); the
//! CLI serves it over stdio (`isokit mcp`) and the Studio over HTTP (`/mcp`).
use crate::check::check;
use crate::ops::Op;
use crate::page;
use crate::workspace::Workspace;
use serde_json::{json, Map, Value};
pub const PROTOCOL: &str = "2025-06-18";
const INSTRUCTIONS: &str = "isokit edits an isometric scene (a site plan drawn as a SimCity-style 3D view). \
World coordinates are metres: x east, y north, z up. The scene is a list of objects; each has a type (a prefab), \
a position (x, y = centre of its footprint), a rotation in 90 degree steps and props from the prefab's schema. \
Start with project_info and list_prefabs, then add_object / update_object. Use render_view (summary) to see where \
everything ended up and check_project to find problems. Edits are saved at once and show live in an open Studio.";
fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value {
json!({ "name": name, "description": description,
"inputSchema": { "type": "object", "properties": props, "required": required } })
}
pub fn tools() -> Vec<Value> {
let obj_fields = json!({
"x": { "type": "number", "description": "east, metres (centre of the footprint)" },
"y": { "type": "number", "description": "north, metres" },
"z": { "type": "number", "description": "base height, metres (default 0)" },
"rot": { "type": "number", "description": "rotation in degrees, multiples of 90" },
"props": { "type": "object", "description": "prefab properties (see list_prefabs); in updates they merge, null removes" },
"name": { "type": "string", "description": "name shown in the tooltip" },
"info": { "type": "string", "description": "tooltip text" },
"tag": { "type": "string", "description": "label pinned above the object" },
"levels": { "type": "array", "items": { "type": "string" }, "description": "levels it shows on (empty: all)" },
});
let mut add = obj_fields.as_object().unwrap().clone();
add.insert("type".into(), json!({ "type": "string", "description": "prefab type, e.g. building, tree, car (list_prefabs)" }));
add.insert("id".into(), json!({ "type": "string", "description": "wanted id (made unique); default from the type" }));
let mut upd = obj_fields.as_object().unwrap().clone();
upd.insert("id".into(), json!({ "type": "string" }));
upd.insert("hidden".into(), json!({ "type": "boolean" }));
upd.insert(
"type".into(),
json!({ "type": "string", "description": "change the prefab type" }),
);
vec![
tool("project_info", "Title, folder, levels, parameters, object count and load errors of the project.", json!({}), &[]),
tool("list_prefabs", "Object types that can be placed, with their property schemas (type, default, min, max, choices).",
json!({ "category": { "type": "string" }, "type": { "type": "string", "description": "one type in full detail" } }), &[]),
tool("list_objects", "Objects in the scene with position, props and the world box [x0, y0, z0, x1, y1, z1] each one occupies.",
json!({ "type": { "type": "string", "description": "only this type" } }), &[]),
tool("get_object", "One object with all fields and its world box.", json!({ "id": { "type": "string" } }), &["id"]),
tool("add_object", "Place a new object. Returns its id and world box.", Value::Object(add), &["type", "x", "y"]),
tool("update_object", "Change an object: move (x, y, z), turn (rot), edit props, name, info, tag, levels.", Value::Object(upd), &["id"]),
tool("remove_object", "Delete an object.", json!({ "id": { "type": "string" } }), &["id"]),
tool("duplicate_object", "Copy an object, offset by dx, dy metres (default 2, 2).",
json!({ "id": { "type": "string" }, "dx": { "type": "number" }, "dy": { "type": "number" } }), &["id"]),
tool("apply_ops", "Several edits as one undo step. Each op is {op: add|update|remove|duplicate|reorder|set_cfg, ...} \
(add: same fields as add_object; update: {id, patch: {...}}; reorder: {id, to: front|back|index}).",
json!({ "ops": { "type": "array", "items": { "type": "object" } } }), &["ops"]),
tool("set_params", "Set project parameter values (see project_info); null resets to the default.",
json!({ "values": { "type": "object" } }), &["values"]),
tool("render_view", "Render one view. format summary (default): sprite and node counts, warnings, object boxes; \
svg: the standalone SVG markup.",
json!({ "level": { "type": "string" }, "rotation": { "type": "integer", "minimum": 0, "maximum": 3 },
"format": { "type": "string", "enum": ["summary", "svg"] } }), &[]),
tool("check_project", "Lint the project: unknown types, bad props, script warnings, NaN, node budget, unstyled classes.", json!({}), &[]),
tool("build_html", "Write the self-contained HTML page (all views prerendered). Returns the file path.",
json!({ "out": { "type": "string", "description": "path relative to the project (default from isokit.toml)" } }), &[]),
tool("undo", "Undo the last edit.", json!({}), &[]),
tool("redo", "Redo the last undone edit.", json!({}), &[]),
]
}
fn text(v: Value) -> Value {
let t = match v {
Value::String(s) => s,
o => serde_json::to_string_pretty(&o).unwrap_or_default(),
};
json!({ "content": [{ "type": "text", "text": t }], "isError": false })
}
fn tool_error(msg: impl Into<String>) -> Value {
json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true })
}
fn boxes(ws: &Workspace) -> Map<String, Value> {
let p = &ws.project;
match p.render(&p.manifest.default_level(), 0, None, false) {
Ok(v) => v
.boxes
.into_iter()
.map(|(k, b)| (k, json!(b.map(isokit_core::fmt::round1))))
.collect(),
Err(_) => Map::new(),
}
}
fn object_json(ws: &Workspace, id: &str, bx: &Map<String, Value>) -> Option<Value> {
let o = ws.project.doc.get(id)?;
let mut v = serde_json::to_value(o).ok()?;
if let Some(b) = bx.get(id) {
v["box"] = b.clone();
}
Some(v)
}
fn str_arg(a: &Map<String, Value>, k: &str) -> Option<String> {
a.get(k).and_then(|v| v.as_str()).map(str::to_string)
}
/// Run one tool.
pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Value {
ws.refresh();
match name {
"project_info" => {
let p = &ws.project;
let m = p.meta();
text(json!({
"title": m.title, "dir": p.dir.display().to_string(), "levels": m.levels, "default_level": m.default_level,
"params": m.params, "values": p.cfg(None), "objects": p.doc.objects.len(),
"types": m.catalog.iter().map(|c| format!("{} ({})", c.kind, c.label)).collect::<Vec<_>>(),
"script_error": p.runtime.as_ref().err().map(|e| e.to_string()),
"load_error": ws.load_error,
"coordinates": "metres; x east, y north, z up; object x, y = centre of its footprint",
}))
}
"list_prefabs" => {
let cat = ws.project.catalog();
if let Some(t) = str_arg(a, "type") {
return match cat.iter().find(|m| m.kind == t) {
Some(m) => text(serde_json::to_value(m).unwrap_or_default()),
None => tool_error(format!("no prefab \"{t}\"")),
};
}
let c = str_arg(a, "category");
let list: Vec<Value> = cat
.iter()
.filter(|m| c.as_ref().is_none_or(|c| m.category.eq_ignore_ascii_case(c)))
.map(|m| {
json!({ "type": m.kind, "label": m.label, "category": m.category, "help": m.help,
"props": m.props.iter().map(|p| {
let mut d = json!({ "key": p.key, "type": p.kind, "default": p.default });
if let Some(x) = p.min { d["min"] = json!(x); }
if let Some(x) = p.max { d["max"] = json!(x); }
if !p.choices.is_empty() { d["choices"] = json!(p.choices); }
if !p.label.is_empty() { d["label"] = json!(p.label); }
d
}).collect::<Vec<_>>() })
})
.collect();
text(json!(list))
}
"list_objects" => {
let bx = boxes(ws);
let t = str_arg(a, "type");
let list: Vec<Value> = ws
.project
.doc
.objects
.iter()
.filter(|o| t.as_ref().is_none_or(|t| &o.kind == t))
.filter_map(|o| object_json(ws, &o.id, &bx))
.collect();
text(json!(list))
}
"get_object" => {
let id = str_arg(a, "id").unwrap_or_default();
let bx = boxes(ws);
match object_json(ws, &id, &bx) {
Some(v) => text(v),
None => tool_error(format!("no object \"{id}\"")),
}
}
"add_object" => {
let mut op = a.clone();
op.insert("op".into(), json!("add"));
match serde_json::from_value::<Op>(Value::Object(op)) {
Err(e) => tool_error(format!("bad arguments: {e}")),
Ok(op) => match ws.apply(op) {
Err(e) => tool_error(e),
Ok(o) => {
let id = o.id.unwrap_or_default();
let bx = boxes(ws);
let warn: Vec<String> = render_warnings(ws)
.into_iter()
.filter(|w| w.contains(&format!("object {id}:")))
.collect();
text(
json!({ "id": id, "object": object_json(ws, &id, &bx), "warnings": warn }),
)
}
},
}
}
"update_object" => {
let id = str_arg(a, "id").unwrap_or_default();
let mut patch = a.clone();
patch.remove("id");
match ws.apply(Op::Update {
id: id.clone(),
patch,
}) {
Err(e) => tool_error(e),
Ok(_) => {
let bx = boxes(ws);
text(json!({ "object": object_json(ws, &id, &bx) }))
}
}
}
"remove_object" => match ws.apply(Op::Remove {
id: str_arg(a, "id").unwrap_or_default(),
}) {
Err(e) => tool_error(e),
Ok(_) => text(json!({ "removed": true, "objects": ws.project.doc.objects.len() })),
},
"duplicate_object" => {
let num = |k: &str| a.get(k).and_then(|v| v.as_f64()).unwrap_or(2.0);
match ws.apply(Op::Duplicate {
id: str_arg(a, "id").unwrap_or_default(),
dx: num("dx"),
dy: num("dy"),
}) {
Err(e) => tool_error(e),
Ok(o) => text(json!({ "id": o.id })),
}
}
"apply_ops" => {
let ops = a
.get("ops")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let parsed: Result<Vec<Op>, String> = ops
.into_iter()
.enumerate()
.map(|(i, v)| serde_json::from_value::<Op>(v).map_err(|e| format!("op {i}: {e}")))
.collect();
match parsed.and_then(|l| ws.apply_all(l)) {
Err(e) => tool_error(e),
Ok(outs) => {
text(json!({ "ids": outs.into_iter().map(|o| o.id).collect::<Vec<_>>() }))
}
}
}
"set_params" => {
let values = a
.get("values")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
match ws.apply(Op::SetCfg { values }) {
Err(e) => tool_error(e),
Ok(_) => text(json!({ "values": ws.project.cfg(None) })),
}
}
"render_view" => {
let p = &ws.project;
let level = str_arg(a, "level").unwrap_or_else(|| p.manifest.default_level());
let rot = a
.get("rotation")
.and_then(|v| v.as_u64())
.unwrap_or(p.manifest.default_rotation as u64) as u8
% 4;
if str_arg(a, "format").as_deref() == Some("svg") {
return match page::svg(p, &level, rot, false) {
Ok(s) => text(Value::String(s)),
Err(e) => tool_error(e.to_string()),
};
}
match p.render(&level, rot, None, false) {
Err(e) => tool_error(e.to_string()),
Ok(v) => text(json!({
"level": level, "rotation": rot, "sprites": v.sprites, "nodes": v.nodes, "sort_cycles": v.cycles,
"render_ms": v.ms, "warnings": v.warnings,
"boxes": v.boxes.iter().map(|(k, b)| (k.clone(), json!(b.map(isokit_core::fmt::round1)))).collect::<Map<_, _>>(),
})),
}
}
"check_project" => {
let f = check(&ws.project);
text(json!({ "findings": f, "ok": !f.iter().any(|x| x.level == "error") }))
}
"build_html" => {
let p = &ws.project;
let out = str_arg(a, "out")
.or_else(|| p.manifest.build.out.clone())
.unwrap_or_else(|| "dist/index.html".into());
let path = p.dir.join(&out);
match page::static_page(p) {
Err(e) => tool_error(e.to_string()),
Ok(html) => {
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
match std::fs::write(&path, html) {
Ok(_) => text(json!({ "path": path.display().to_string() })),
Err(e) => tool_error(format!("{}: {e}", path.display())),
}
}
}
}
"undo" => match ws.undo() {
Ok(done) => text(json!({ "undone": done })),
Err(e) => tool_error(e),
},
"redo" => match ws.redo() {
Ok(done) => text(json!({ "redone": done })),
Err(e) => tool_error(e),
},
other => tool_error(format!("unknown tool \"{other}\"")),
}
}
fn render_warnings(ws: &Workspace) -> Vec<String> {
let p = &ws.project;
p.render(&p.manifest.default_level(), 0, None, false)
.map(|v| v.warnings)
.unwrap_or_default()
}
/// Handle one JSON-RPC message.
pub fn handle(ws: &mut Workspace, msg: &Value) -> Option<Value> {
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(json!({}));
let id = id?; // notifications get no response
let result = match method {
"initialize" => {
let pv = params
.get("protocolVersion")
.and_then(|v| v.as_str())
.unwrap_or(PROTOCOL)
.to_string();
json!({ "protocolVersion": pv, "capabilities": { "tools": { "listChanged": false } },
"serverInfo": { "name": "isokit", "version": env!("CARGO_PKG_VERSION") },
"instructions": INSTRUCTIONS })
}
"ping" => json!({}),
"tools/list" => json!({ "tools": tools() }),
"tools/call" => {
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = params
.get("arguments")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
call_tool(ws, name, &args)
}
"resources/list" => json!({ "resources": [] }),
"prompts/list" => json!({ "prompts": [] }),
_ => {
return Some(
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("method not found: {method}") } }),
);
}
};
Some(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
}
+440
View File
@@ -0,0 +1,440 @@
//! Edits to the scene document. The Studio and the MCP tools send the same
//! JSON operations: `{"op": "add", "type": "tree", "x": 3, "y": 4}`.
use isokit_script::doc::{Doc, Object, PrefabMeta};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Op {
/// Place a new object of a prefab type.
Add {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
x: f64,
#[serde(default)]
y: f64,
#[serde(default)]
z: f64,
#[serde(default)]
rot: f64,
#[serde(default)]
props: Map<String, Value>,
#[serde(default)]
name: String,
#[serde(default)]
info: String,
#[serde(default)]
tag: String,
#[serde(default)]
levels: Vec<String>,
#[serde(default)]
pts: Option<Vec<[f64; 2]>>,
/// Wanted id (made unique); default from the type.
#[serde(default)]
id: Option<String>,
},
/// Change fields of an object; `props` are merged (null removes a key).
Update {
id: String,
patch: Map<String, Value>,
},
Remove {
id: String,
},
Duplicate {
id: String,
#[serde(default = "two")]
dx: f64,
#[serde(default = "two")]
dy: f64,
},
/// Move an object in the drawing order: "front", "back" or an index.
Reorder {
id: String,
to: Value,
},
/// Set parameter values (null removes the document's value).
SetCfg {
values: Map<String, Value>,
},
/// Replace the whole document.
Replace {
doc: Doc,
},
}
fn two() -> f64 {
2.0
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Outcome {
/// The object created or changed, if any.
pub id: Option<String>,
}
fn finite(v: f64, what: &str) -> Result<f64, String> {
if v.is_finite() {
Ok(v)
} else {
Err(format!("{what} must be a finite number"))
}
}
fn norm_rot(r: f64) -> f64 {
(((r / 90.0).round() as i64).rem_euclid(4) * 90) as f64
}
/// Check property values against the prefab schema (unknown keys pass:
/// project scripts may read them).
pub fn check_props(meta: &PrefabMeta, props: &Map<String, Value>) -> Result<(), String> {
for (k, v) in props {
let Some(d) = meta.prop(k) else { continue };
match d.kind.as_str() {
"float" | "int" => {
let Some(x) = v
.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
else {
return Err(format!("{}.{k}: expected a number, got {v}", meta.kind));
};
if d.min.is_some_and(|m| x < m) || d.max.is_some_and(|m| x > m) {
return Err(format!(
"{}.{k} = {x} is outside {}..{}",
meta.kind,
d.min.map(|m| m.to_string()).unwrap_or_default(),
d.max.map(|m| m.to_string()).unwrap_or_default()
));
}
}
"bool" => {
if !v.is_boolean() {
return Err(format!(
"{}.{k}: expected true or false, got {v}",
meta.kind
));
}
}
"choice" => {
let s = v.as_str().unwrap_or_default();
if !d.choices.is_empty() && !d.choices.iter().any(|c| c == s) {
return Err(format!(
"{}.{k}: \"{s}\" is not one of {}",
meta.kind,
d.choices.join(", ")
));
}
}
_ => {}
}
}
Ok(())
}
fn str_of(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
o => o.to_string(),
}
}
/// Apply a field patch to an object.
pub fn patch_object(
o: &mut Object,
patch: &Map<String, Value>,
meta: Option<&PrefabMeta>,
) -> Result<(), String> {
for (k, v) in patch {
let num = || v.as_f64().ok_or_else(|| format!("{k} must be a number"));
match k.as_str() {
"x" => o.x = finite(num()?, "x")?,
"y" => o.y = finite(num()?, "y")?,
"z" => o.z = finite(num()?, "z")?,
"rot" => o.rot = norm_rot(finite(num()?, "rot")?),
"name" => o.name = str_of(v),
"info" => o.info = str_of(v),
"tag" => o.tag = str_of(v),
"hidden" => o.hidden = v.as_bool().unwrap_or(false),
"locked" => o.locked = v.as_bool().unwrap_or(false),
"levels" => {
o.levels = v.as_array().map(|a| a.iter().map(str_of).filter(|s| !s.is_empty()).collect()).unwrap_or_default()
}
"pts" => {
o.pts = if v.is_null() { None } else { Some(serde_json::from_value(v.clone()).map_err(|e| format!("pts: {e}"))?) }
}
"props" => {
let m = v.as_object().ok_or("props must be an object")?;
let mut next = o.props.clone();
for (pk, pv) in m {
if pv.is_null() {
next.remove(pk);
} else {
next.insert(pk.clone(), pv.clone());
}
}
if let Some(meta) = meta {
check_props(meta, &next)?;
}
o.props = next;
}
"type" => {
let t = str_of(v);
if t.is_empty() {
return Err("type must not be empty".into());
}
o.kind = t;
}
"id" => {}
other => return Err(format!("unknown field \"{other}\" (x, y, z, rot, name, info, tag, levels, props, pts, hidden, locked, type)")),
}
}
Ok(())
}
/// Apply one operation. `catalog` validates types and props.
pub fn apply(doc: &mut Doc, catalog: &[PrefabMeta], op: Op) -> Result<Outcome, String> {
let meta_of = |k: &str| catalog.iter().find(|m| m.kind == k);
match op {
Op::Add {
kind,
x,
y,
z,
rot,
props,
name,
info,
tag,
levels,
pts,
id,
} => {
let Some(meta) = meta_of(&kind) else {
let mut known: Vec<&str> = catalog.iter().map(|m| m.kind.as_str()).collect();
known.sort();
return Err(format!(
"unknown type \"{kind}\"; available: {}",
known.join(", ")
));
};
check_props(meta, &props)?;
let id = doc.free_id(id.as_deref().filter(|s| !s.is_empty()).unwrap_or(&kind));
doc.objects.push(Object {
id: id.clone(),
kind,
name,
x: finite(x, "x")?,
y: finite(y, "y")?,
z: finite(z, "z")?,
rot: norm_rot(rot),
props,
info,
tag,
levels,
pts,
hidden: false,
locked: false,
});
Ok(Outcome { id: Some(id) })
}
Op::Update { id, patch } => {
let kind = doc
.get(&id)
.ok_or_else(|| format!("no object \"{id}\""))?
.kind
.clone();
let kind = patch
.get("type")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or(kind);
let meta = meta_of(&kind);
let o = doc.get_mut(&id).expect("checked");
let mut next = o.clone();
patch_object(&mut next, &patch, meta)?;
*o = next;
Ok(Outcome { id: Some(id) })
}
Op::Remove { id } => {
let n = doc.objects.len();
doc.objects.retain(|o| o.id != id);
if doc.objects.len() == n {
return Err(format!("no object \"{id}\""));
}
Ok(Outcome { id: None })
}
Op::Duplicate { id, dx, dy } => {
let src = doc
.get(&id)
.ok_or_else(|| format!("no object \"{id}\""))?
.clone();
let base = src
.id
.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-')
.to_string();
let nid = doc.free_id(if base.is_empty() { &src.kind } else { &base });
let mut o = src;
o.id = nid.clone();
o.x += dx;
o.y += dy;
o.locked = false;
let at = doc
.objects
.iter()
.position(|q| q.id == id)
.map(|i| i + 1)
.unwrap_or(doc.objects.len());
doc.objects.insert(at, o);
Ok(Outcome { id: Some(nid) })
}
Op::Reorder { id, to } => {
let i = doc
.objects
.iter()
.position(|o| o.id == id)
.ok_or_else(|| format!("no object \"{id}\""))?;
let o = doc.objects.remove(i);
let n = doc.objects.len();
let at = match &to {
Value::String(s) if s == "front" => n,
Value::String(s) if s == "back" => 0,
Value::Number(k) => (k.as_f64().unwrap_or(0.0).max(0.0) as usize).min(n),
_ => return Err("to must be \"front\", \"back\" or an index".into()),
};
doc.objects.insert(at, o);
Ok(Outcome { id: Some(id) })
}
Op::SetCfg { values } => {
for (k, v) in values {
if v.is_null() {
doc.cfg.remove(&k);
} else {
doc.cfg.insert(k, v);
}
}
Ok(Outcome { id: None })
}
Op::Replace { doc: d } => {
*doc = d;
Ok(Outcome { id: None })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use isokit_script::doc::PropDef;
use serde_json::json;
fn catalog() -> Vec<PrefabMeta> {
vec![PrefabMeta {
kind: "tree".into(),
label: "Tree".into(),
props: vec![PropDef {
key: "r".into(),
kind: "float".into(),
default: json!(1.6),
min: Some(0.5),
max: Some(6.0),
..Default::default()
}],
..Default::default()
}]
}
fn op(v: Value) -> Op {
serde_json::from_value(v).unwrap()
}
#[test]
fn add_update_duplicate_remove() {
let mut d = Doc::default();
let c = catalog();
let id = apply(
&mut d,
&c,
op(json!({"op": "add", "type": "tree", "x": 3, "y": 4, "rot": 100})),
)
.unwrap()
.id
.unwrap();
assert_eq!(id, "tree");
assert_eq!(d.objects[0].rot, 90.0);
apply(&mut d, &c, op(json!({"op": "update", "id": "tree", "patch": {"x": 7.5, "props": {"r": 2.0}, "tag": "Oak"}}))).unwrap();
assert_eq!(d.objects[0].x, 7.5);
assert_eq!(d.objects[0].props["r"], json!(2.0));
let dup = apply(&mut d, &c, op(json!({"op": "duplicate", "id": "tree"})))
.unwrap()
.id
.unwrap();
assert_eq!(dup, "tree-2");
assert_eq!(d.objects[1].x, 9.5);
apply(
&mut d,
&c,
op(json!({"op": "update", "id": "tree", "patch": {"props": {"r": null}}})),
)
.unwrap();
assert!(d.objects[0].props.is_empty());
apply(&mut d, &c, op(json!({"op": "remove", "id": "tree"}))).unwrap();
assert_eq!(d.objects.len(), 1);
}
#[test]
fn validation_messages_help_the_caller() {
let mut d = Doc::default();
let c = catalog();
let e = apply(&mut d, &c, op(json!({"op": "add", "type": "ufo"}))).unwrap_err();
assert!(e.contains("available: tree"), "{e}");
let e = apply(
&mut d,
&c,
op(json!({"op": "add", "type": "tree", "props": {"r": 9}})),
)
.unwrap_err();
assert!(e.contains("outside"), "{e}");
apply(&mut d, &c, op(json!({"op": "add", "type": "tree"}))).unwrap();
let e = apply(
&mut d,
&c,
op(json!({"op": "update", "id": "tree", "patch": {"colour": 1}})),
)
.unwrap_err();
assert!(e.contains("unknown field"), "{e}");
let e = apply(
&mut d,
&c,
op(json!({"op": "update", "id": "nope", "patch": {}})),
)
.unwrap_err();
assert!(e.contains("no object"), "{e}");
}
#[test]
fn reorder_and_cfg() {
let mut d = Doc::default();
let c = catalog();
for _ in 0..3 {
apply(&mut d, &c, op(json!({"op": "add", "type": "tree"}))).unwrap();
}
apply(
&mut d,
&c,
op(json!({"op": "reorder", "id": "tree", "to": "front"})),
)
.unwrap();
assert_eq!(d.objects[2].id, "tree");
apply(
&mut d,
&c,
op(json!({"op": "set_cfg", "values": {"labels": false}})),
)
.unwrap();
assert_eq!(d.cfg["labels"], json!(false));
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Export: a self-contained HTML page with every view prerendered, or one
//! view as a standalone SVG file.
use crate::assets;
use crate::Project;
use isokit_core::fmt::esc;
use isokit_script::ScriptError;
use serde_json::{json, Value};
/// JSON safe to put inside `<script type="application/json">`.
fn script_json(v: &Value) -> String {
v.to_string().replace("</", "<\\/")
}
/// The static page: all levels in all four rotations, no server needed.
pub fn static_page(p: &Project) -> Result<String, ScriptError> {
let meta = p.meta();
let mut views = serde_json::Map::new();
for l in &meta.levels {
for rot in 0..4u8 {
let v = p.render(&l.id, rot, None, false)?;
views.insert(
format!("{}/{rot}", l.id),
json!({ "html": v.html, "info": v.info, "bbox": v.bbox, "focus": v.focus, "north": v.north, "boxes": v.boxes }),
);
}
}
let data = json!({
"meta": {
"title": meta.title, "note": meta.note, "levels": meta.levels,
"defaultLevel": meta.default_level, "defaultRotation": meta.default_rotation
},
"views": views,
"cfg": p.cfg(None),
});
let css = format!(
"{}\n{}",
assets::get("viewer/isokit-view.css").unwrap_or_default(),
p.theme.css()
);
let page = assets::get("viewer/page.html").unwrap_or_default();
Ok(page
.replace("{{title}}", &esc(&meta.title))
.replace("{{date}}", &today())
.replace("/*ISOKIT_CSS*/", &css)
.replace("/*ISOKIT_DATA*/", &script_json(&data))
.replace(
"/*ISOKIT_JS*/",
&assets::get("viewer/isokit-view.js").unwrap_or_default(),
))
}
/// One view as a standalone SVG (dark or light scheme, styles inlined).
pub fn svg(p: &Project, level: &str, rot: u8, light: bool) -> Result<String, ScriptError> {
let v = p.render(level, rot, None, false)?;
let css = format!(
"{}\n{}",
assets::get("viewer/isokit-view.css").unwrap_or_default(),
p.theme.css_scoped("svg.iso-svg", Some(light))
);
let b = v.bbox;
let bg = if light { "#e8f4ff" } else { "#0b1428" };
Ok(format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"iso-svg\" viewBox=\"{} {} {} {}\" width=\"{}\" height=\"{}\">\
<style>{}</style><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{bg}\"/>{}</svg>\n",
b[0], b[1], b[2], b[3], (b[2] * 2.0).round(), (b[3] * 2.0).round(), css, b[0], b[1], b[2], b[3], v.html
))
}
/// Today's date (UTC) as YYYY-MM-DD, without a date crate.
pub fn today() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
// civil-from-days (H. Hinnant)
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d)
}
+141
View File
@@ -0,0 +1,141 @@
//! A project kept loaded across edits: reload on file changes, save after
//! every operation, undo and redo. The Studio server and the MCP server
//! each hold one.
use crate::ops::{self, Op, Outcome};
use crate::{version, Error, Project};
use isokit_script::doc::Doc;
use std::path::{Path, PathBuf};
pub struct Workspace {
pub dir: PathBuf,
pub project: Project,
/// Fingerprint of the files the loaded project was read from.
pub version: u64,
/// Error of the last reload attempt (the previous project stays loaded).
pub load_error: Option<String>,
undo: Vec<Doc>,
redo: Vec<Doc>,
}
const UNDO_DEPTH: usize = 200;
impl Workspace {
pub fn open(dir: impl AsRef<Path>) -> Result<Workspace, Error> {
let dir = dir.as_ref().to_path_buf();
let project = Project::load(&dir)?;
let version = version(&dir);
Ok(Workspace {
dir,
project,
version,
load_error: None,
undo: vec![],
redo: vec![],
})
}
/// Reload when a project file changed on disk (another editor, an agent,
/// a git pull). Returns true when something was reloaded.
pub fn refresh(&mut self) -> bool {
let v = version(&self.dir);
if v == self.version {
return false;
}
self.version = v;
match Project::load(&self.dir) {
Ok(p) => {
if p.doc != self.project.doc {
self.push_undo(self.project.doc.clone());
}
self.project = p;
self.load_error = None;
}
Err(e) => self.load_error = Some(e.to_string()),
}
true
}
fn push_undo(&mut self, d: Doc) {
self.undo.push(d);
if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0);
}
self.redo.clear();
}
fn save(&mut self) -> Result<(), String> {
self.project.save_doc().map_err(|e| e.to_string())?;
self.version = version(&self.dir);
Ok(())
}
/// Apply an operation to the document and save it.
pub fn apply(&mut self, op: Op) -> Result<Outcome, String> {
self.refresh();
let before = self.project.doc.clone();
let catalog = self.project.catalog().to_vec();
let out = ops::apply(&mut self.project.doc, &catalog, op)?;
if self.project.doc != before {
self.push_undo(before);
self.save()?;
}
Ok(out)
}
/// Apply several operations as one undo step; stops at the first error
/// (earlier ones stay applied and saved).
pub fn apply_all(&mut self, list: Vec<Op>) -> Result<Vec<Outcome>, String> {
self.refresh();
let before = self.project.doc.clone();
let catalog = self.project.catalog().to_vec();
let mut outs = vec![];
let mut err = None;
for op in list {
match ops::apply(&mut self.project.doc, &catalog, op) {
Ok(o) => outs.push(o),
Err(e) => {
err = Some(e);
break;
}
}
}
if self.project.doc != before {
self.push_undo(before);
self.save()?;
}
match err {
Some(e) => Err(format!("after {} operation(s): {e}", outs.len())),
None => Ok(outs),
}
}
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn undo(&mut self) -> Result<bool, String> {
self.refresh();
let Some(prev) = self.undo.pop() else {
return Ok(false);
};
let cur = std::mem::replace(&mut self.project.doc, prev);
self.redo.push(cur);
self.save()?;
Ok(true)
}
pub fn redo(&mut self) -> Result<bool, String> {
self.refresh();
let Some(next) = self.redo.pop() else {
return Ok(false);
};
let cur = std::mem::replace(&mut self.project.doc, next);
self.undo.push(cur);
self.save()?;
Ok(true)
}
}
+267
View File
@@ -0,0 +1,267 @@
//! Integration tests on the demo project: load, render every view, export,
//! check, document operations through a Workspace, MCP round trips.
use isokit_project::mcp;
use isokit_project::ops::Op;
use isokit_project::workspace::Workspace;
use isokit_project::{check, page, Project};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
fn demo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo")
}
/// A throwaway copy of the demo (tests edit it).
fn copy_demo(name: &str) -> PathBuf {
let dst = std::env::temp_dir().join(format!("isokit-test-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dst);
fn cp(a: &Path, b: &Path) {
std::fs::create_dir_all(b).unwrap();
for e in std::fs::read_dir(a).unwrap().flatten() {
let p = e.path();
let n = e.file_name();
if p.is_dir() {
if n != "dist" {
cp(&p, &b.join(n));
}
} else {
std::fs::copy(&p, b.join(n)).unwrap();
}
}
}
cp(&demo(), &dst);
dst
}
#[test]
fn demo_renders_every_view_cleanly() {
let p = Project::load(demo()).unwrap();
assert!(p.runtime.is_ok(), "{:?}", p.runtime.as_ref().err());
for l in p.manifest.levels() {
for rot in 0..4 {
let v = p.render(&l.id, rot, None, false).unwrap();
assert!(v.warnings.is_empty(), "{}/{rot}: {:?}", l.id, v.warnings);
assert!(!v.html.contains("NaN"));
assert!(
v.nodes < p.manifest.build.budget,
"{}/{rot}: {} nodes",
l.id,
v.nodes
);
for o in &p.doc.objects {
assert!(
v.boxes.contains_key(&o.id),
"{} not drawn in {}/{rot}",
o.id,
l.id
);
assert!(v.info.contains_key(&o.id), "{} has no tooltip", o.id);
}
// the script's sign and the project prefab are there
assert!(v.html.contains("data-id=\"sign\""));
assert!(v.html.contains("data-obj=\"kiosk\""));
}
}
}
#[test]
fn demo_passes_the_check() {
let p = Project::load(demo()).unwrap();
let f = check::check(&p);
assert!(f.is_empty(), "{f:?}");
}
#[test]
fn static_page_and_svg_export() {
let p = Project::load(demo()).unwrap();
let html = page::static_page(&p).unwrap();
assert!(
html.contains("IsoKit.mount") && html.contains("\"site/1\"") && html.contains("\"gf/3\"")
);
assert!(
!html.contains("/*ISOKIT_"),
"a placeholder was left in the page"
);
assert!(
html.contains(".iso-brick { --b: var(--iso-brick); }"),
"project theme merged"
);
let svg = page::svg(&p, "site", 1, true).unwrap();
assert!(
svg.starts_with("<svg") && svg.contains("<style>") && svg.contains("iso-wall")
|| svg.contains("iso-render")
);
}
#[test]
fn workspace_edits_save_and_undo() {
let dir = copy_demo("ws");
let mut ws = Workspace::open(&dir).unwrap();
let n = ws.project.doc.objects.len();
let id = ws
.apply(
serde_json::from_value::<Op>(json!({"op": "add", "type": "tree", "x": 1, "y": 2}))
.unwrap(),
)
.unwrap()
.id
.unwrap();
// saved to disk
let on_disk = Project::load(&dir).unwrap();
assert_eq!(on_disk.doc.objects.len(), n + 1);
assert!(on_disk.doc.get(&id).is_some());
ws.undo().unwrap();
assert_eq!(Project::load(&dir).unwrap().doc.objects.len(), n);
ws.redo().unwrap();
assert_eq!(ws.project.doc.objects.len(), n + 1);
// an outside edit (another process) is picked up and can be undone
let mut d = on_disk.doc.clone();
d.objects.retain(|o| o.kind != "lamp");
std::fs::write(
dir.join("scene.json"),
serde_json::to_string_pretty(&d).unwrap(),
)
.unwrap();
assert!(ws.refresh());
assert!(ws.project.doc.objects.iter().all(|o| o.kind != "lamp"));
ws.undo().unwrap();
assert!(ws.project.doc.objects.iter().any(|o| o.kind == "lamp"));
let _ = std::fs::remove_dir_all(&dir);
}
fn call(ws: &mut Workspace, id: u64, name: &str, args: Value) -> (bool, Value) {
let r = mcp::handle(ws, &json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}})).unwrap();
let res = &r["result"];
let text = res["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string();
(
res["isError"].as_bool().unwrap_or(true),
serde_json::from_str(&text).unwrap_or(Value::String(text)),
)
}
#[test]
fn mcp_round_trip() {
let dir = copy_demo("mcp");
let mut ws = Workspace::open(&dir).unwrap();
let init = mcp::handle(&mut ws, &json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).unwrap();
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert!(mcp::handle(
&mut ws,
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
)
.is_none());
let list = mcp::handle(
&mut ws,
&json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
)
.unwrap();
let names: Vec<&str> = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
for t in [
"project_info",
"list_prefabs",
"add_object",
"update_object",
"render_view",
"check_project",
"build_html",
] {
assert!(names.contains(&t), "{t}");
}
let (err, info) = call(&mut ws, 3, "project_info", json!({}));
assert!(!err && info["objects"].as_u64().unwrap() > 10);
let (err, pf) = call(&mut ws, 4, "list_prefabs", json!({"type": "building"}));
assert!(
!err && pf["props"]
.as_array()
.unwrap()
.iter()
.any(|p| p["key"] == "floors")
);
let (err, added) = call(
&mut ws,
5,
"add_object",
json!({"type": "building", "x": 20, "y": 15, "props": {"w": 8, "d": 6, "floors": 1}, "name": "Store"}),
);
assert!(!err, "{added}");
let id = added["id"].as_str().unwrap().to_string();
let b = added["object"]["box"].as_array().unwrap();
assert_eq!(
(b[0].as_f64().unwrap(), b[3].as_f64().unwrap()),
(16.0, 24.0)
);
let (err, upd) = call(
&mut ws,
6,
"update_object",
json!({"id": id, "rot": 90, "props": {"roof": "gable"}}),
);
assert!(!err, "{upd}");
assert_eq!(upd["object"]["rot"], 90.0);
let (err, bad) = call(
&mut ws,
7,
"update_object",
json!({"id": id, "props": {"floors": 99}}),
);
assert!(err && bad.as_str().unwrap().contains("outside"), "{bad}");
let (err, ops) = call(
&mut ws,
8,
"apply_ops",
json!({"ops": [
{"op": "add", "type": "tree", "x": 0, "y": 18},
{"op": "add", "type": "tree", "x": 4, "y": 18},
{"op": "remove", "id": "tree-1"}
]}),
);
assert!(!err, "{ops}");
let (err, view) = call(
&mut ws,
9,
"render_view",
json!({"level": "gf", "rotation": 2}),
);
assert!(!err && view["boxes"].get(&id).is_some(), "{view}");
let (err, svg) = call(&mut ws, 10, "render_view", json!({"format": "svg"}));
assert!(!err && svg.as_str().unwrap().starts_with("<svg"));
let (err, chk) = call(&mut ws, 11, "check_project", json!({}));
assert!(!err && chk["ok"] == true, "{chk}");
let (err, built) = call(&mut ws, 12, "build_html", json!({}));
assert!(!err && Path::new(built["path"].as_str().unwrap()).exists());
let (err, _) = call(&mut ws, 13, "undo", json!({}));
assert!(!err);
assert!(
ws.project.doc.get("tree-1").is_some(),
"undo brings back the batch"
);
let unknown = mcp::handle(
&mut ws,
&json!({"jsonrpc": "2.0", "id": 14, "method": "nope"}),
)
.unwrap();
assert_eq!(unknown["error"]["code"], -32601);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scaffolded_project_works() {
let dir = std::env::temp_dir().join(format!("isokit-test-new-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
isokit_project::scaffold(&dir, "New site").unwrap();
let p = Project::load(&dir).unwrap();
assert_eq!(p.meta().title, "New site");
let f = check::check(&p);
assert!(f.is_empty(), "{f:?}");
assert!(isokit_project::scaffold(&dir, "again").is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+485 -186
View File
@@ -27,9 +27,17 @@ fn opt_id(d: &str) -> Option<String> {
}
}
pub(crate) fn new_sprite(st: &Shared, ctx: Option<&NativeCallContext>, id: Option<&str>, cls: &str) -> SpriteH {
pub(crate) fn new_sprite(
st: &Shared,
ctx: Option<&NativeCallContext>,
id: Option<&str>,
cls: &str,
) -> SpriteH {
let mut s = st.borrow_mut();
let 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(
"box",
|s: &mut SpriteH,
x0: D,
y0: D,
x1: D,
y1: D,
z0: D,
z1: D,
mat: &str,
o: Map|
-> RR<SpriteH> {
s.0.borrow_mut().cuboid(
n(&x0)?,
n(&y0)?,
n(&x1)?,
n(&y1)?,
n(&z0)?,
n(&z1)?,
style(mat, Some(&o)),
);
ok(s)
},
);
e.register_fn(
"prism",
|s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, None));
ok(s)
},
);
e.register_fn(
"prism",
|s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut()
.prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, Some(&o)));
ok(s)
},
);
e.register_fn(
"cyl",
|s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.cyl(n(&cx)?, n(&cy)?, n(&r)?, n(&z0)?, n(&z1)?, style(mat, None));
ok(s)
},
);
e.register_fn(
"cyl",
|s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().cyl(
n(&cx)?,
n(&cy)?,
n(&r)?,
n(&z0)?,
n(&z1)?,
style(mat, Some(&o)),
);
ok(s)
},
);
e.register_fn("prism", |s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, None));
ok(s)
});
e.register_fn("prism", |s: &mut SpriteH, p: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().prism(pts(&p)?, zs(&z0)?, zs(&z1)?, style(mat, Some(&o)));
ok(s)
});
e.register_fn("cyl", |s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str| -> RR<SpriteH> {
s.0.borrow_mut().cyl(n(&cx)?, n(&cy)?, n(&r)?, n(&z0)?, n(&z1)?, style(mat, None));
ok(s)
});
e.register_fn("cyl", |s: &mut SpriteH, cx: D, cy: D, r: D, z0: D, z1: D, mat: &str, o: Map| -> RR<SpriteH> {
s.0.borrow_mut().cyl(n(&cx)?, n(&cy)?, n(&r)?, n(&z0)?, n(&z1)?, style(mat, Some(&o)));
ok(s)
});
e.register_fn(
"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);
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));
ok(s)
});
e.register_fn("flat", |s: &mut SpriteH, p: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().flat(polys(&p)?, n(&z)?, &classes(cls), None);
ok(s)
});
e.register_fn("flat", |s: &mut SpriteH, p: D, z: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut().flat(polys(&p)?, n(&z)?, &classes(cls), opt_id(id));
ok(s)
});
e.register_fn("lines", |s: &mut SpriteH, segs: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().lines(quads(&segs, "segments")?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("disc", |s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), None);
ok(s)
});
e.register_fn("disc", |s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut().disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), opt_id(id));
ok(s)
});
e.register_fn(
"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),
);
ok(s)
},
);
e.register_fn(
"flat",
|s: &mut SpriteH, p: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.flat(polys(&p)?, n(&z)?, &classes(cls), None);
ok(s)
},
);
e.register_fn(
"flat",
|s: &mut SpriteH, p: D, z: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.flat(polys(&p)?, n(&z)?, &classes(cls), opt_id(id));
ok(s)
},
);
e.register_fn(
"lines",
|s: &mut SpriteH, segs: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.lines(quads(&segs, "segments")?, n(&z)?, &classes(cls));
ok(s)
},
);
e.register_fn(
"disc",
|s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), None);
ok(s)
},
);
e.register_fn(
"disc",
|s: &mut SpriteH, cx: D, cy: D, z: D, r: D, cls: &str, id: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.disc(n(&cx)?, n(&cy)?, n(&z)?, n(&r)?, &classes(cls), opt_id(id));
ok(s)
},
);
e.register_fn("face", |s: &mut SpriteH, p: D, mat: &str| -> RR<SpriteH> {
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> {
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);
s.0.borrow_mut().face(pts3(&p)?, &classes(mat), id, two);
ok(s)
});
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);
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)
},
);
// screen-space details
e.register_fn(
"circle",
|s: &mut SpriteH, x: D, y: D, z: D, r: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.circle([n(&x)?, n(&y)?, n(&z)?], n(&r)?, [0.0, 0.0], &classes(cls));
ok(s)
},
);
e.register_fn(
"circle",
|s: &mut SpriteH, x: D, y: D, z: D, r: D, cls: &str, dx: D, dy: D| -> RR<SpriteH> {
s.0.borrow_mut().circle(
[n(&x)?, n(&y)?, n(&z)?],
n(&r)?,
[n(&dx)?, n(&dy)?],
&classes(cls),
);
ok(s)
},
);
e.register_fn(
"stem",
|s: &mut SpriteH, x: D, y: D, z0: D, z1: D, w: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.stem(n(&x)?, n(&y)?, n(&z0)?, n(&z1)?, n(&w)?, &classes(cls));
ok(s)
},
);
e.register_fn(
"head",
|s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
s.0.borrow_mut().head(n(&x)?, n(&y)?, n(&z)?, n(&r)?);
ok(s)
},
);
e.register_fn(
"person",
|s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().person(n(&x)?, n(&y)?, n(&z)?, "iso-hivis");
ok(s)
},
);
e.register_fn(
"person",
|s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut()
.person(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
},
);
e.register_fn(
"sit",
|s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().sit(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
},
);
e.register_fn("stem", |s: &mut SpriteH, x: D, y: D, z0: D, z1: D, w: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().stem(n(&x)?, n(&y)?, n(&z0)?, n(&z1)?, n(&w)?, &classes(cls));
ok(s)
});
e.register_fn("head", |s: &mut SpriteH, x: D, y: D, z: D, r: D| -> RR<SpriteH> {
s.0.borrow_mut().head(n(&x)?, n(&y)?, n(&z)?, n(&r)?);
ok(s)
});
e.register_fn("person", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
s.0.borrow_mut().person(n(&x)?, n(&y)?, n(&z)?, "iso-hivis");
ok(s)
});
e.register_fn("person", |s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().person(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("sit", |s: &mut SpriteH, x: D, y: D, z: D, cls: &str| -> RR<SpriteH> {
s.0.borrow_mut().sit(n(&x)?, n(&y)?, n(&z)?, &classes(cls));
ok(s)
});
e.register_fn("led", |s: &mut SpriteH, x: D, y: D, z: D| -> RR<SpriteH> {
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(
"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("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> {
s.0.borrow_mut().raw(Some([n(&x)?, n(&y)?, n(&z)?]), svg);
ok(s)
});
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)?);
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)?);
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 {
s.0.borrow_mut().begin_group(&classes(cls), css);
s.clone()
});
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> {
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, 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,36 +495,51 @@ 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> {
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}"))?;
if a.len() < 3 {
s.0.borrow_mut().end_seq();
return err("seq: expected [x, y, fn]");
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}"))?;
if a.len() < 3 {
s.0.borrow_mut().end_seq();
return err("seq: expected [x, y, fn]");
}
let f = a[2]
.clone()
.try_cast::<FnPtr>()
.ok_or("seq: the third entry must be a function")?;
s.0.borrow_mut().seq_item([n(&a[0])?, n(&a[1])?]);
if let Err(e) = f.call_within_context::<Dynamic>(&ctx, (s.clone(),)) {
s.0.borrow_mut().end_seq();
return Err(e);
}
}
let f = a[2].clone().try_cast::<FnPtr>().ok_or("seq: the third entry must be a function")?;
s.0.borrow_mut().seq_item([n(&a[0])?, n(&a[1])?]);
if let Err(e) = f.call_within_context::<Dynamic>(&ctx, (s.clone(),)) {
s.0.borrow_mut().end_seq();
return Err(e);
}
}
s.0.borrow_mut().end_seq();
ok(s)
});
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<()> {
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(())
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);
+15 -4
View File
@@ -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
+69 -15
View File
@@ -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
View File
@@ -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),
})
}
}
+107 -25
View File
@@ -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();
+2 -2
View File
@@ -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" },
] }
}
+2 -2
View File
@@ -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)" },
+1 -1
View File
@@ -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)" },
+15 -4
View File
@@ -43,7 +43,9 @@ fn upsert(list: &mut Vec<(String, String)>, k: &str, v: String) {
fn strings(t: Option<&toml::Value>, what: &str) -> Result<Vec<(String, String)>, String> {
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) => {