isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs
This commit is contained in:
1 parent
95c80ec6b1
commit
564aeb75f6
47 files changed
+5978
-366
No files matched your search
@@ -1,8 +1,17 @@
|
||||
[package]
|
||||
name = "isokit-cli"
|
||||
description = "isokit: isometric scene engine - Studio GUI, MCP server, build and export"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "isokit"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
isokit-project = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
@@ -1 +1,209 @@
|
||||
fn main() {}
|
||||
//! `isokit`: the command line.
|
||||
//!
|
||||
//! isokit new <dir> start a project
|
||||
//! isokit studio [dir] the GUI editor in the browser (also serves MCP at /mcp)
|
||||
//! isokit mcp [dir] MCP server over stdio for AI agents
|
||||
//! isokit build [dir] self-contained HTML page with every view
|
||||
//! isokit render [dir] -o x.svg one view as SVG
|
||||
//! isokit check [dir] lint the project
|
||||
//! isokit prefabs [dir] list the object types
|
||||
|
||||
mod serve;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use isokit_project::{check, page, Project};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "isokit",
|
||||
version,
|
||||
about = "Isometric scene engine: Studio GUI, MCP server, HTML/SVG export"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Create a project folder with a starter scene
|
||||
New {
|
||||
dir: PathBuf,
|
||||
#[arg(long)]
|
||||
title: Option<String>,
|
||||
},
|
||||
/// Open the Studio (GUI editor) in the browser
|
||||
Studio {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
#[arg(long, default_value_t = 7878)]
|
||||
port: u16,
|
||||
/// Do not open a browser
|
||||
#[arg(long)]
|
||||
no_open: bool,
|
||||
},
|
||||
/// Serve the MCP protocol on stdio (for AI agents)
|
||||
Mcp {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Build the self-contained HTML page
|
||||
Build {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
#[arg(short, long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Render one view as a standalone SVG
|
||||
Render {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
#[arg(short, long)]
|
||||
out: PathBuf,
|
||||
#[arg(long)]
|
||||
level: Option<String>,
|
||||
#[arg(long)]
|
||||
rot: Option<u8>,
|
||||
/// Light (day) colours instead of dark
|
||||
#[arg(long)]
|
||||
light: bool,
|
||||
},
|
||||
/// Lint the project (exit code 1 on errors)
|
||||
Check {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// List the object types (prefabs) and their properties
|
||||
Prefabs {
|
||||
#[arg(default_value = ".")]
|
||||
dir: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
fn load(dir: &PathBuf) -> Result<Project> {
|
||||
let p = Project::load(dir).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
if let Err(e) = &p.runtime {
|
||||
bail!("{e}");
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
Cmd::New { dir, title } => {
|
||||
let title = title.unwrap_or_else(|| {
|
||||
dir.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or("Scene".into())
|
||||
});
|
||||
isokit_project::scaffold(&dir, &title).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
println!(
|
||||
"Created {} - open it with: isokit studio {}",
|
||||
dir.display(),
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
Cmd::Studio { dir, port, no_open } => serve::studio(&dir, port, !no_open)?,
|
||||
Cmd::Mcp { dir } => mcp_stdio(&dir)?,
|
||||
Cmd::Build { dir, out } => {
|
||||
let p = load(&dir)?;
|
||||
let out = out.unwrap_or_else(|| {
|
||||
dir.join(
|
||||
p.manifest
|
||||
.build
|
||||
.out
|
||||
.clone()
|
||||
.unwrap_or_else(|| "dist/index.html".into()),
|
||||
)
|
||||
});
|
||||
let html = page::static_page(&p).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
if let Some(d) = out.parent() {
|
||||
std::fs::create_dir_all(d).ok();
|
||||
}
|
||||
std::fs::write(&out, &html).with_context(|| out.display().to_string())?;
|
||||
let views = p.manifest.levels().len() * 4;
|
||||
println!(
|
||||
"{} ({} views, {} kB)",
|
||||
out.display(),
|
||||
views,
|
||||
html.len() / 1024
|
||||
);
|
||||
}
|
||||
Cmd::Render {
|
||||
dir,
|
||||
out,
|
||||
level,
|
||||
rot,
|
||||
light,
|
||||
} => {
|
||||
let p = load(&dir)?;
|
||||
let level = level.unwrap_or_else(|| p.manifest.default_level());
|
||||
let rot = rot.unwrap_or(p.manifest.default_rotation) % 4;
|
||||
let svg = page::svg(&p, &level, rot, light).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
std::fs::write(&out, svg).with_context(|| out.display().to_string())?;
|
||||
println!("{} ({level}, rotation {rot})", out.display());
|
||||
}
|
||||
Cmd::Check { dir } => {
|
||||
let p = Project::load(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let f = check::check(&p);
|
||||
for x in &f {
|
||||
println!(
|
||||
"{:7} {}{}",
|
||||
x.level,
|
||||
x.object
|
||||
.as_ref()
|
||||
.map(|o| format!("[{o}] "))
|
||||
.unwrap_or_default(),
|
||||
x.message
|
||||
);
|
||||
}
|
||||
let errors = f.iter().filter(|x| x.level == "error").count();
|
||||
println!("{} finding(s), {errors} error(s)", f.len());
|
||||
if errors > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Cmd::Prefabs { dir } => {
|
||||
let p = load(&dir)?;
|
||||
for m in p.catalog() {
|
||||
println!("{:12} {:24} {} [{}]", m.kind, m.label, m.category, m.source);
|
||||
for d in &m.props {
|
||||
println!(
|
||||
" {:10} {:8} default {} {}",
|
||||
d.key, d.kind, d.default, d.label
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// MCP over stdio: one JSON-RPC message per line in, one per line out.
|
||||
fn mcp_stdio(dir: &PathBuf) -> Result<()> {
|
||||
let mut ws =
|
||||
isokit_project::workspace::Workspace::open(dir).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let stdin = std::io::stdin();
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in stdin.lock().lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let resp = match serde_json::from_str::<serde_json::Value>(&line) {
|
||||
Ok(msg) => isokit_project::mcp::handle(&mut ws, &msg),
|
||||
Err(e) => Some(
|
||||
serde_json::json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
|
||||
),
|
||||
};
|
||||
if let Some(r) = resp {
|
||||
writeln!(out, "{r}")?;
|
||||
out.flush()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
//! `isokit studio`: the GUI editor and its JSON API on localhost, plus the
|
||||
//! MCP endpoint (`POST /mcp`, JSON responses) so agents edit the same scene
|
||||
//! live. Single-threaded: one request at a time against one Workspace.
|
||||
|
||||
use anyhow::Result;
|
||||
use isokit_project::ops::Op;
|
||||
use isokit_project::workspace::Workspace;
|
||||
use isokit_project::{assets, check, mcp, page};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use tiny_http::{Header, Method, Request, Response, Server};
|
||||
|
||||
const VIEW_PAGE: &str = r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"><title>isokit preview</title>
|
||||
<script>(function(){var t=null;try{t=localStorage.getItem("isokit-theme")}catch(e){}if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",t);})();</script>
|
||||
<link rel="stylesheet" href="/assets/viewer/isokit-view.css"><link rel="stylesheet" href="/assets/theme.css">
|
||||
<style>:root{color-scheme:dark}html[data-theme=light]{color-scheme:light}body{margin:0;background:#0a0f1a;font:14px system-ui,sans-serif;color:#d6deeb}
|
||||
html[data-theme=light] body{background:#f4f6fa;color:#1b2533}main{max-width:1320px;margin:0 auto;padding:16px}</style></head>
|
||||
<body><main><div id="view"></div></main><script src="/assets/viewer/isokit-view.js"></script><script>
|
||||
fetch("/api/meta").then(function(r){return r.json()}).then(function(m){
|
||||
var meta={title:m.title,note:m.note,levels:m.levels,defaultLevel:m.default_level,defaultRotation:m.default_rotation};
|
||||
document.title=m.title+" · preview";
|
||||
var view=IsoKit.mount(document.getElementById("view"),IsoKit.httpSource("",meta),{});
|
||||
window.__isokit={view:view};
|
||||
var v=null;(function poll(){fetch("/api/version").then(function(r){return r.json()}).then(function(r){if(v!==null&&r.version!==v)view.refresh();v=r.version;}).catch(function(){}).then(function(){setTimeout(poll,1000)});})();
|
||||
});
|
||||
</script></body></html>"#;
|
||||
|
||||
fn header(k: &str, v: &str) -> Header {
|
||||
Header::from_bytes(k.as_bytes(), v.as_bytes()).expect("valid header")
|
||||
}
|
||||
|
||||
fn send(req: Request, status: u16, mime: &str, body: String) {
|
||||
let r = Response::from_string(body)
|
||||
.with_status_code(status)
|
||||
.with_header(header("Content-Type", mime))
|
||||
.with_header(header("Cache-Control", "no-store"));
|
||||
let _ = req.respond(r);
|
||||
}
|
||||
|
||||
fn send_json(req: Request, v: Value) {
|
||||
let status = if v.get("error").is_some() && v.get("jsonrpc").is_none() {
|
||||
400
|
||||
} else {
|
||||
200
|
||||
};
|
||||
send(
|
||||
req,
|
||||
status,
|
||||
"application/json; charset=utf-8",
|
||||
v.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
fn query(url: &str) -> Map<String, Value> {
|
||||
let mut m = Map::new();
|
||||
if let Some((_, q)) = url.split_once('?') {
|
||||
for kv in q.split('&') {
|
||||
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
|
||||
m.insert(decode(k), Value::String(decode(v)));
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'+' => out.push(b' '),
|
||||
b'%' if i + 2 < b.len() => {
|
||||
if let Ok(v) =
|
||||
u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16)
|
||||
{
|
||||
out.push(v);
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(b'%');
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
}
|
||||
|
||||
/// Requests from a web page on another origin (DNS rebinding, a malicious
|
||||
/// site) must not edit the scene.
|
||||
fn origin_ok(req: &Request) -> bool {
|
||||
match req.headers().iter().find(|h| h.field.equiv("Origin")) {
|
||||
None => true,
|
||||
Some(h) => {
|
||||
let o = h.value.as_str();
|
||||
o == "null" || o.starts_with("http://127.0.0.1") || o.starts_with("http://localhost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state(ws: &Workspace) -> Value {
|
||||
json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() })
|
||||
}
|
||||
|
||||
fn meta(ws: &Workspace) -> Value {
|
||||
let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default();
|
||||
v["dir"] = json!(ws.dir.display().to_string());
|
||||
v["load_error"] = json!(ws.load_error);
|
||||
v["script_error"] = json!(ws.project.runtime.as_ref().err());
|
||||
v
|
||||
}
|
||||
|
||||
pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
|
||||
let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
||||
// Windows canonical paths carry a \?\ prefix that other tools do not accept
|
||||
let dir = std::path::PathBuf::from(
|
||||
dir.to_string_lossy()
|
||||
.trim_start_matches(r"\\?\")
|
||||
.to_string(),
|
||||
);
|
||||
let mut ws = Workspace::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let (server, port) = (port..port.saturating_add(20))
|
||||
.find_map(|p| Server::http(("127.0.0.1", p)).ok().map(|s| (s, p)))
|
||||
.ok_or_else(|| anyhow::anyhow!("no free port from {port}"))?;
|
||||
let url = format!("http://127.0.0.1:{port}/");
|
||||
println!("isokit Studio: {url}");
|
||||
println!(" project {}", dir.display());
|
||||
println!(" preview {url}view");
|
||||
println!(" MCP {url}mcp (or: isokit mcp {})", dir.display());
|
||||
if let Err(e) = &ws.project.runtime {
|
||||
println!(" script error: {e}");
|
||||
}
|
||||
if open {
|
||||
open_browser(&url);
|
||||
}
|
||||
for mut req in server.incoming_requests() {
|
||||
let url = req.url().to_string();
|
||||
let path = url.split('?').next().unwrap_or("/").to_string();
|
||||
let method = req.method().clone();
|
||||
let mut body = String::new();
|
||||
if method == Method::Post {
|
||||
if !origin_ok(&req) {
|
||||
send(
|
||||
req,
|
||||
403,
|
||||
"text/plain",
|
||||
"cross-origin request refused".into(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let _ = req.as_reader().take(16 << 20).read_to_string(&mut body);
|
||||
}
|
||||
let bodyv: Value = if body.trim().is_empty() {
|
||||
json!({})
|
||||
} else {
|
||||
serde_json::from_str(&body).unwrap_or(Value::Null)
|
||||
};
|
||||
match (&method, path.as_str()) {
|
||||
(Method::Get, "/") => send(
|
||||
req,
|
||||
200,
|
||||
"text/html; charset=utf-8",
|
||||
assets::get("studio/index.html")
|
||||
.unwrap_or_default()
|
||||
.into_owned(),
|
||||
),
|
||||
(Method::Get, "/view") => send(req, 200, "text/html; charset=utf-8", VIEW_PAGE.into()),
|
||||
(Method::Get, "/assets/theme.css") => {
|
||||
ws.refresh();
|
||||
send(req, 200, "text/css; charset=utf-8", ws.project.theme.css())
|
||||
}
|
||||
(Method::Get, p) if p.starts_with("/assets/") => {
|
||||
let name = &p["/assets/".len()..];
|
||||
let mime = if name.ends_with(".js") {
|
||||
"text/javascript; charset=utf-8"
|
||||
} else if name.ends_with(".css") {
|
||||
"text/css; charset=utf-8"
|
||||
} else {
|
||||
"text/html; charset=utf-8"
|
||||
};
|
||||
match assets::get(name) {
|
||||
Some(s) => send(req, 200, mime, s.into_owned()),
|
||||
None => send(req, 404, "text/plain", "not found".into()),
|
||||
}
|
||||
}
|
||||
(Method::Get, "/api/version") => {
|
||||
ws.refresh();
|
||||
send_json(req, json!({ "version": ws.version.to_string() }))
|
||||
}
|
||||
(Method::Get, "/api/meta") => {
|
||||
ws.refresh();
|
||||
send_json(req, meta(&ws))
|
||||
}
|
||||
(Method::Get, "/api/doc") => {
|
||||
ws.refresh();
|
||||
send_json(req, state(&ws))
|
||||
}
|
||||
(Method::Get, "/api/render") => {
|
||||
ws.refresh();
|
||||
let q = query(&url);
|
||||
let p = &ws.project;
|
||||
let level = q
|
||||
.get("level")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| p.manifest.default_level());
|
||||
let rot = q
|
||||
.get("rot")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
let cfg = q
|
||||
.get("cfg")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| serde_json::from_str::<Map<String, Value>>(s).ok());
|
||||
let dev = q.get("dev").is_some();
|
||||
match p.render(&level, rot, cfg.as_ref(), dev) {
|
||||
Ok(v) => send_json(req, serde_json::to_value(v).unwrap_or_default()),
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
(Method::Get, "/api/check") => {
|
||||
ws.refresh();
|
||||
send_json(req, json!({ "findings": check::check(&ws.project) }))
|
||||
}
|
||||
(Method::Post, "/api/op") => {
|
||||
let r = if let Some(list) = bodyv.get("ops").and_then(|v| v.as_array()) {
|
||||
let ops: Result<Vec<Op>, String> = list
|
||||
.iter()
|
||||
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
|
||||
.collect();
|
||||
ops.and_then(|l| ws.apply_all(l))
|
||||
.map(|o| o.last().and_then(|x| x.id.clone()))
|
||||
} else {
|
||||
serde_json::from_value::<Op>(bodyv.clone())
|
||||
.map_err(|e| format!("bad operation: {e}"))
|
||||
.and_then(|op| ws.apply(op))
|
||||
.map(|o| o.id)
|
||||
};
|
||||
match r {
|
||||
Ok(id) => {
|
||||
let mut s = state(&ws);
|
||||
s["ok"] = json!(true);
|
||||
s["id"] = json!(id);
|
||||
send_json(req, s)
|
||||
}
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
(Method::Post, "/api/undo") | (Method::Post, "/api/redo") => {
|
||||
let r = if path.ends_with("undo") {
|
||||
ws.undo()
|
||||
} else {
|
||||
ws.redo()
|
||||
};
|
||||
match r {
|
||||
Ok(_) => send_json(req, state(&ws)),
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
(Method::Post, "/api/build") => {
|
||||
ws.refresh();
|
||||
let p = &ws.project;
|
||||
let out = p.dir.join(
|
||||
p.manifest
|
||||
.build
|
||||
.out
|
||||
.clone()
|
||||
.unwrap_or_else(|| "dist/index.html".into()),
|
||||
);
|
||||
let r = page::static_page(p)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|html| {
|
||||
if let Some(d) = out.parent() {
|
||||
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&out, html).map_err(|e| e.to_string())
|
||||
});
|
||||
match r {
|
||||
Ok(_) => send_json(req, json!({ "path": out.display().to_string() })),
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
(Method::Post, "/mcp") => {
|
||||
// a batch (array) or a single message; notifications get 202
|
||||
let resp = match &bodyv {
|
||||
Value::Array(list) => {
|
||||
let out: Vec<Value> = list
|
||||
.iter()
|
||||
.filter_map(|m| mcp::handle(&mut ws, m))
|
||||
.collect();
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Array(out))
|
||||
}
|
||||
}
|
||||
Value::Null => Some(
|
||||
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": "parse error" } }),
|
||||
),
|
||||
m => mcp::handle(&mut ws, m),
|
||||
};
|
||||
match resp {
|
||||
Some(v) => send(req, 200, "application/json", v.to_string()),
|
||||
None => {
|
||||
let _ = req.respond(Response::empty(202));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Method::Get, "/mcp") => send(
|
||||
req,
|
||||
405,
|
||||
"text/plain",
|
||||
"MCP: POST JSON-RPC messages here".into(),
|
||||
),
|
||||
_ => send(req, 404, "text/plain", "not found".into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_browser(url: &str) {
|
||||
#[cfg(windows)]
|
||||
let r = std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn();
|
||||
#[cfg(target_os = "macos")]
|
||||
let r = std::process::Command::new("open").arg(url).spawn();
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let r = std::process::Command::new("xdg-open").arg(url).spawn();
|
||||
if r.is_err() {
|
||||
println!(" open {url} in a browser");
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ pub fn r1(out: &mut String, v: f64) {
|
||||
}
|
||||
let a = t.unsigned_abs();
|
||||
let _ = write!(out, "{}", a / 10);
|
||||
if a % 10 != 0 {
|
||||
if !a.is_multiple_of(10) {
|
||||
let _ = write!(out, ".{}", a % 10);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ pub fn thousands(n: f64) -> String {
|
||||
let s = v.unsigned_abs().to_string();
|
||||
let mut o = String::new();
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i) % 3 == 0 {
|
||||
if i > 0 && (s.len() - i).is_multiple_of(3) {
|
||||
o.push(',');
|
||||
}
|
||||
o.push(ch);
|
||||
|
||||
@@ -46,7 +46,10 @@ pub struct Style {
|
||||
|
||||
impl Style {
|
||||
pub fn mat(mat: impl Into<String>) -> Self {
|
||||
Style { mat: mat.into(), ..Default::default() }
|
||||
Style {
|
||||
mat: mat.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub fn id(mut self, id: impl Into<String>) -> Self {
|
||||
self.id = Some(id.into());
|
||||
@@ -75,44 +78,116 @@ impl Style {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Prim {
|
||||
/// Vertical prism over a convex counter-clockwise footprint.
|
||||
Prism { pts: Vec<P2>, z0: Zs, z1: Zs, st: Style },
|
||||
Prism {
|
||||
pts: Vec<P2>,
|
||||
z0: Zs,
|
||||
z1: Zs,
|
||||
st: Style,
|
||||
},
|
||||
/// Upright cylinder (two side faces and an elliptic top).
|
||||
Cyl { c: P2, r: f64, z0: f64, z1: f64, st: Style },
|
||||
Cyl {
|
||||
c: P2,
|
||||
r: f64,
|
||||
z0: f64,
|
||||
z1: f64,
|
||||
st: Style,
|
||||
},
|
||||
/// Frustum (cone) with n sides: radius r0 at z0, r1 at z1.
|
||||
Frust { c: P2, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style },
|
||||
Frust {
|
||||
c: P2,
|
||||
r0: f64,
|
||||
r1: f64,
|
||||
z0: f64,
|
||||
z1: f64,
|
||||
n: u32,
|
||||
st: Style,
|
||||
},
|
||||
/// Openings (windows, doors) on the vertical face through a -> b; the
|
||||
/// outward normal is on the right of a -> b. Each entry is
|
||||
/// [u0, u1, z0, z1]: distance along the face and heights.
|
||||
Open { a: P2, b: P2, list: Vec<[f64; 4]>, cls: String, id: Option<String> },
|
||||
Open {
|
||||
a: P2,
|
||||
b: P2,
|
||||
list: Vec<[f64; 4]>,
|
||||
cls: String,
|
||||
id: Option<String>,
|
||||
},
|
||||
/// Flat polygon(s) at height z; later polygons cut holes (even-odd).
|
||||
Flat { polys: Vec<Vec<P2>>, z: f64, cls: String, id: Option<String> },
|
||||
Flat {
|
||||
polys: Vec<Vec<P2>>,
|
||||
z: f64,
|
||||
cls: String,
|
||||
id: Option<String>,
|
||||
},
|
||||
/// Line segments [x0, y0, x1, y1] at height z.
|
||||
Lines { segs: Vec<[f64; 4]>, z: f64, cls: String, id: Option<String> },
|
||||
Lines {
|
||||
segs: Vec<[f64; 4]>,
|
||||
z: f64,
|
||||
cls: String,
|
||||
id: Option<String>,
|
||||
},
|
||||
/// Flat ellipse (a circle on the ground plane) at height z.
|
||||
Disc { c: P2, z: f64, r: f64, cls: String, id: Option<String> },
|
||||
Disc {
|
||||
c: P2,
|
||||
z: f64,
|
||||
r: f64,
|
||||
cls: String,
|
||||
id: Option<String>,
|
||||
},
|
||||
/// Planar polygon in 3D (roof slopes, gables, ramps), shaded by its
|
||||
/// normal: mostly upward = top, else left/centre/right by direction.
|
||||
/// One-sided faces (counter-clockwise seen from outside) are culled
|
||||
/// when turned away; two-sided faces always draw.
|
||||
Face { pts: Vec<P3>, mat: String, id: Option<String>, two_sided: bool },
|
||||
Face {
|
||||
pts: Vec<P3>,
|
||||
mat: String,
|
||||
id: Option<String>,
|
||||
two_sided: bool,
|
||||
},
|
||||
/// 3D polygons merged into one path with a fixed class (PV modules,
|
||||
/// signs, markings on slopes): no shading, no culling.
|
||||
Poly { polys: Vec<Vec<P3>>, cls: String, id: Option<String> },
|
||||
Poly {
|
||||
polys: Vec<Vec<P3>>,
|
||||
cls: String,
|
||||
id: Option<String>,
|
||||
},
|
||||
/// Screen-space circle of radius r (screen units) at a world point,
|
||||
/// shifted by off (screen units): heads, lamps, LEDs, tree crowns.
|
||||
Circle { at: P3, r: f64, off: P2, cls: String, id: Option<String> },
|
||||
Circle {
|
||||
at: P3,
|
||||
r: f64,
|
||||
off: P2,
|
||||
cls: String,
|
||||
id: Option<String>,
|
||||
},
|
||||
/// Screen-space vertical strip of half width w between two heights:
|
||||
/// trunks, thin poles.
|
||||
Stem { at: P2, z0: f64, z1: f64, w: f64, cls: String, id: Option<String> },
|
||||
Stem {
|
||||
at: P2,
|
||||
z0: f64,
|
||||
z1: f64,
|
||||
w: f64,
|
||||
cls: String,
|
||||
id: Option<String>,
|
||||
},
|
||||
/// Rising steam or smoke puffs (CSS animation).
|
||||
Puffs { at: P3, kind: String, n: u32, dur: f64 },
|
||||
Puffs {
|
||||
at: P3,
|
||||
kind: String,
|
||||
n: u32,
|
||||
dur: f64,
|
||||
},
|
||||
/// Spinning fan lying in the plane at height at.z (CSS animation).
|
||||
Fan { at: P3, r: f64 },
|
||||
/// Raw SVG translated to a world point (or untranslated without one).
|
||||
/// `{uid}` in the text is replaced by the view's unique id.
|
||||
Raw { at: Option<P3>, svg: String },
|
||||
/// `<g class style>` around nested primitives (animation, ghosting).
|
||||
Group { cls: String, style: String, items: Vec<Prim> },
|
||||
Group {
|
||||
cls: String,
|
||||
style: String,
|
||||
items: Vec<Prim>,
|
||||
},
|
||||
/// Items drawn back to front by the depth of their plan anchor.
|
||||
Seq { items: Vec<(P2, Vec<Prim>)> },
|
||||
}
|
||||
@@ -18,7 +18,9 @@ pub struct Proj {
|
||||
|
||||
impl Proj {
|
||||
pub fn new(rot: i64) -> Self {
|
||||
Proj { rot: rot.rem_euclid(4) as u8 }
|
||||
Proj {
|
||||
rot: rot.rem_euclid(4) as u8,
|
||||
}
|
||||
}
|
||||
|
||||
/// World plan point to view coordinates.
|
||||
@@ -147,7 +149,10 @@ mod tests {
|
||||
let p = Proj::new(rot);
|
||||
let s = p.s(12.5, -3.25, 2.0);
|
||||
let w = p.unproject(s[0], s[1], 2.0);
|
||||
assert!((w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9, "rot {rot}: {w:?}");
|
||||
assert!(
|
||||
(w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9,
|
||||
"rot {rot}: {w:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ pub struct RenderOpts {
|
||||
|
||||
impl Default for RenderOpts {
|
||||
fn default() -> Self {
|
||||
RenderOpts { uid: 0, labels: true, dev: false }
|
||||
RenderOpts {
|
||||
uid: 0,
|
||||
labels: true,
|
||||
dev: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +105,9 @@ impl Ctx<'_> {
|
||||
if !st.no_side {
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { continue };
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else {
|
||||
continue;
|
||||
};
|
||||
let j = (i + 1) % n;
|
||||
if z1.at(i) - z0.at(i) < 1e-6 && z1.at(j) - z0.at(j) < 1e-6 {
|
||||
continue;
|
||||
@@ -109,14 +115,23 @@ impl Ctx<'_> {
|
||||
self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id);
|
||||
p.d(
|
||||
&mut self.out,
|
||||
&[[a[0], a[1], z0.at(i)], [b[0], b[1], z0.at(j)], [b[0], b[1], z1.at(j)], [a[0], a[1], z1.at(i)]],
|
||||
&[
|
||||
[a[0], a[1], z0.at(i)],
|
||||
[b[0], b[1], z0.at(j)],
|
||||
[b[0], b[1], z1.at(j)],
|
||||
[a[0], a[1], z1.at(i)],
|
||||
],
|
||||
false,
|
||||
);
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
}
|
||||
if !st.no_top {
|
||||
let tp: Vec<[f64; 3]> = pts.iter().enumerate().map(|(i, q)| [q[0], q[1], z1.at(i)]).collect();
|
||||
let tp: Vec<[f64; 3]> = pts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, q)| [q[0], q[1], z1.at(i)])
|
||||
.collect();
|
||||
self.path_open(&format!("{} iso-t", st.top_cls()), id);
|
||||
p.d(&mut self.out, &tp, false);
|
||||
self.out.push_str("\"/>");
|
||||
@@ -150,7 +165,10 @@ impl Ctx<'_> {
|
||||
if !st.no_top {
|
||||
let _ = write!(self.out, "<ellipse class=\"{} iso-t\"", st.top_cls());
|
||||
did(&mut self.out, id);
|
||||
let _ = write!(self.out, " cx=\"{bx}\" cy=\"{ty}\" rx=\"{rxs}\" ry=\"{rys}\"/>");
|
||||
let _ = write!(
|
||||
self.out,
|
||||
" cx=\"{bx}\" cy=\"{ty}\" rx=\"{rxs}\" ry=\"{rys}\"/>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +186,9 @@ impl Ctx<'_> {
|
||||
if st.no_side {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = p.vis(am.cos(), am.sin()) else { continue };
|
||||
let Some(v) = p.vis(am.cos(), am.sin()) else {
|
||||
continue;
|
||||
};
|
||||
self.path_open(&format!("{} {}", st.side_cls(), shade(v)), id);
|
||||
p.d(
|
||||
&mut self.out,
|
||||
@@ -194,7 +214,9 @@ impl Ctx<'_> {
|
||||
return;
|
||||
}
|
||||
let p = self.p;
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else { return };
|
||||
let Some(v) = p.vis(b[1] - a[1], a[0] - b[0]) else {
|
||||
return;
|
||||
};
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l = (dx * dx + dy * dy).sqrt().max(1e-9);
|
||||
let (ux, uy) = (dx / l, dy / l);
|
||||
@@ -262,7 +284,14 @@ impl Ctx<'_> {
|
||||
let rx = r * C * SQ2;
|
||||
let _ = write!(self.out, "<ellipse class=\"{cls}\"");
|
||||
did(&mut self.out, id);
|
||||
let _ = write!(self.out, " cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\"/>", r1s(s[0]), r1s(s[1]), r1s(rx), r1s(rx / 2.0));
|
||||
let _ = write!(
|
||||
self.out,
|
||||
" cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\"/>",
|
||||
r1s(s[0]),
|
||||
r1s(s[1]),
|
||||
r1s(rx),
|
||||
r1s(rx / 2.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Planar polygon shaded by its Newell normal. The view direction in
|
||||
@@ -296,7 +325,11 @@ impl Ctx<'_> {
|
||||
nz = -nz;
|
||||
v = self.p.r(nx, ny);
|
||||
}
|
||||
let face = if nz >= 0.5 || v[0] + v[1] <= 1e-6 { "iso-t" } else { shade(v) };
|
||||
let face = if nz >= 0.5 || v[0] + v[1] <= 1e-6 {
|
||||
"iso-t"
|
||||
} else {
|
||||
shade(v)
|
||||
};
|
||||
self.path_open(&format!("{mat} {face}"), id);
|
||||
let p = self.p;
|
||||
p.d(&mut self.out, pts, false);
|
||||
@@ -307,12 +340,31 @@ impl Ctx<'_> {
|
||||
match pr {
|
||||
Prim::Prism { pts, z0, z1, st } => self.prism(pts, z0, z1, st),
|
||||
Prim::Cyl { c, r, z0, z1, st } => self.cyl(*c, *r, *z0, *z1, st),
|
||||
Prim::Frust { c, r0, r1, z0, z1, n, st } => self.frust(*c, *r0, *r1, *z0, *z1, *n, st),
|
||||
Prim::Open { a, b, list, cls, id } => self.open(*a, *b, list, cls, id.as_deref()),
|
||||
Prim::Frust {
|
||||
c,
|
||||
r0,
|
||||
r1,
|
||||
z0,
|
||||
z1,
|
||||
n,
|
||||
st,
|
||||
} => self.frust(*c, *r0, *r1, *z0, *z1, *n, st),
|
||||
Prim::Open {
|
||||
a,
|
||||
b,
|
||||
list,
|
||||
cls,
|
||||
id,
|
||||
} => self.open(*a, *b, list, cls, id.as_deref()),
|
||||
Prim::Flat { polys, z, cls, id } => self.flat(polys, *z, cls, id.as_deref()),
|
||||
Prim::Lines { segs, z, cls, id } => self.lines(segs, *z, cls, id.as_deref()),
|
||||
Prim::Disc { c, z, r, cls, id } => self.disc(*c, *z, *r, cls, id.as_deref()),
|
||||
Prim::Face { pts, mat, id, two_sided } => self.face(pts, mat, id.as_deref(), *two_sided),
|
||||
Prim::Face {
|
||||
pts,
|
||||
mat,
|
||||
id,
|
||||
two_sided,
|
||||
} => self.face(pts, mat, id.as_deref(), *two_sided),
|
||||
Prim::Poly { polys, cls, id } => {
|
||||
if polys.is_empty() {
|
||||
return;
|
||||
@@ -324,7 +376,13 @@ impl Ctx<'_> {
|
||||
}
|
||||
self.out.push_str("\"/>");
|
||||
}
|
||||
Prim::Circle { at, r, off, cls, id } => {
|
||||
Prim::Circle {
|
||||
at,
|
||||
r,
|
||||
off,
|
||||
cls,
|
||||
id,
|
||||
} => {
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let _ = write!(self.out, "<circle class=\"{cls}\"");
|
||||
did(&mut self.out, id.as_deref());
|
||||
@@ -336,7 +394,14 @@ impl Ctx<'_> {
|
||||
r1s(*r)
|
||||
);
|
||||
}
|
||||
Prim::Stem { at, z0, z1, w, cls, id } => {
|
||||
Prim::Stem {
|
||||
at,
|
||||
z0,
|
||||
z1,
|
||||
w,
|
||||
cls,
|
||||
id,
|
||||
} => {
|
||||
let b = self.p.s(at[0], at[1], *z0);
|
||||
let t = self.p.s(at[0], at[1], *z1);
|
||||
self.path_open(cls, id.as_deref());
|
||||
@@ -355,7 +420,12 @@ impl Ctx<'_> {
|
||||
}
|
||||
Prim::Puffs { at, kind, n, dur } => {
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let _ = write!(self.out, "<g class=\"iso-vent\" transform=\"translate({} {})\">", r1s(s[0]), r1s(s[1]));
|
||||
let _ = write!(
|
||||
self.out,
|
||||
"<g class=\"iso-vent\" transform=\"translate({} {})\">",
|
||||
r1s(s[0]),
|
||||
r1s(s[1])
|
||||
);
|
||||
let r = if kind == "iso-smoke" {
|
||||
3.2
|
||||
} else if kind == "iso-puff2" {
|
||||
@@ -396,7 +466,12 @@ impl Ctx<'_> {
|
||||
match at {
|
||||
Some(at) => {
|
||||
let s = self.p.s(at[0], at[1], at[2]);
|
||||
let _ = write!(self.out, "<g transform=\"translate({} {})\">{svg}</g>", r1s(s[0]), r1s(s[1]));
|
||||
let _ = write!(
|
||||
self.out,
|
||||
"<g transform=\"translate({} {})\">{svg}</g>",
|
||||
r1s(s[0]),
|
||||
r1s(s[1])
|
||||
);
|
||||
}
|
||||
None => self.out.push_str(&svg),
|
||||
}
|
||||
@@ -457,7 +532,11 @@ pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) {
|
||||
pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
|
||||
let p = Proj::new(rot as i64);
|
||||
let uid = opts.uid.to_string();
|
||||
let mut cx = Ctx { p, uid: &uid, out: String::with_capacity(64 * 1024) };
|
||||
let mut cx = Ctx {
|
||||
p,
|
||||
uid: &uid,
|
||||
out: String::with_capacity(64 * 1024),
|
||||
};
|
||||
let mut frame = [1e9f64, 1e9, -1e9, -1e9];
|
||||
let mut sprites = 0;
|
||||
let mut cycles = 0;
|
||||
@@ -494,7 +573,12 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
|
||||
} else {
|
||||
(0..live.len()).collect()
|
||||
};
|
||||
let _ = write!(cx.out, "<g class=\"{}\" data-layer=\"{}\">", esc(&layer.opts.cls), esc(&layer.name));
|
||||
let _ = write!(
|
||||
cx.out,
|
||||
"<g class=\"{}\" data-layer=\"{}\">",
|
||||
esc(&layer.opts.cls),
|
||||
esc(&layer.name)
|
||||
);
|
||||
for i in idx {
|
||||
let s = live[i];
|
||||
sprites += 1;
|
||||
@@ -583,7 +667,13 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
|
||||
frame = [-100.0, -100.0, 100.0, 100.0];
|
||||
warnings.push("the scene is empty".into());
|
||||
}
|
||||
let bbox = [frame[0], frame[1] - 30.0, frame[2] - frame[0], frame[3] - frame[1] + 30.0].map(round1);
|
||||
let bbox = [
|
||||
frame[0],
|
||||
frame[1] - 30.0,
|
||||
frame[2] - frame[0],
|
||||
frame[3] - frame[1] + 30.0,
|
||||
]
|
||||
.map(round1);
|
||||
let focus = match scene.focus_point() {
|
||||
Some(f) => p.s(f[0], f[1], f[2]),
|
||||
None => [bbox[0] + bbox[2] / 2.0, bbox[1] + bbox[3] / 2.0],
|
||||
@@ -627,7 +717,12 @@ mod tests {
|
||||
let sc = one_box();
|
||||
for rot in 0..4 {
|
||||
let r = render(&sc, rot, &RenderOpts::default());
|
||||
assert_eq!(r.html.matches("iso-wall iso-").count(), 3, "rot {rot}: {}", r.html);
|
||||
assert_eq!(
|
||||
r.html.matches("iso-wall iso-").count(),
|
||||
3,
|
||||
"rot {rot}: {}",
|
||||
r.html
|
||||
);
|
||||
assert!(r.html.contains("iso-wall iso-t"));
|
||||
assert!(r.html.contains("iso-wall iso-l"));
|
||||
assert!(r.html.contains("iso-wall iso-r"));
|
||||
@@ -641,14 +736,26 @@ mod tests {
|
||||
// JS (iso-mooban.js prismS, rotation 0) for box(0,0,10,6,0,4): the top face
|
||||
let sc = one_box();
|
||||
let r = render(&sc, 0, &RenderOpts::default());
|
||||
assert!(r.html.contains("<path class=\"iso-wall iso-t\" d=\"M0 -16L40 4L64 -8L24 -28Z\"/>"), "{}", r.html);
|
||||
assert!(
|
||||
r.html
|
||||
.contains("<path class=\"iso-wall iso-t\" d=\"M0 -16L40 4L64 -8L24 -28Z\"/>"),
|
||||
"{}",
|
||||
r.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_off_hides_plain_tags_only() {
|
||||
let mut sc = one_box();
|
||||
sc.tag([0.0, 0.0, 0.0], "Gate", 9.0, "gate", "iso-signtag");
|
||||
let r = render(&sc, 1, &RenderOpts { labels: false, ..Default::default() });
|
||||
let r = render(
|
||||
&sc,
|
||||
1,
|
||||
&RenderOpts {
|
||||
labels: false,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(r.html.contains(">Gate<"));
|
||||
assert!(!r.html.contains(">Hall<"));
|
||||
}
|
||||
@@ -683,9 +790,29 @@ mod tests {
|
||||
let mut sc = Scene::new();
|
||||
let mut s = Sprite::new("roof", "");
|
||||
// south-facing roof slope rising to the north (ccw seen from above/outside)
|
||||
s.face(vec![[0.0, 0.0, 3.0], [10.0, 0.0, 3.0], [10.0, 4.0, 5.0], [0.0, 4.0, 5.0]], "iso-roof", None, false);
|
||||
s.face(
|
||||
vec![
|
||||
[0.0, 0.0, 3.0],
|
||||
[10.0, 0.0, 3.0],
|
||||
[10.0, 4.0, 5.0],
|
||||
[0.0, 4.0, 5.0],
|
||||
],
|
||||
"iso-roof",
|
||||
None,
|
||||
false,
|
||||
);
|
||||
// a vertical wall facing north (ccw seen from the north)
|
||||
s.face(vec![[10.0, 6.0, 0.0], [0.0, 6.0, 0.0], [0.0, 6.0, 3.0], [10.0, 6.0, 3.0]], "iso-wall", None, false);
|
||||
s.face(
|
||||
vec![
|
||||
[10.0, 6.0, 0.0],
|
||||
[0.0, 6.0, 0.0],
|
||||
[0.0, 6.0, 3.0],
|
||||
[10.0, 6.0, 3.0],
|
||||
],
|
||||
"iso-wall",
|
||||
None,
|
||||
false,
|
||||
);
|
||||
sc.add_sprite("main", s);
|
||||
let r0 = render(&sc, 0, &RenderOpts::default()); // from the south-east: north wall hidden
|
||||
assert!(r0.html.contains("iso-roof iso-t"));
|
||||
@@ -698,7 +825,14 @@ mod tests {
|
||||
fn dev_mode_adds_source_and_order() {
|
||||
let mut sc = one_box();
|
||||
sc.layers[2].sprites[0].src = Some("scene.rhai:3".into());
|
||||
let r = render(&sc, 0, &RenderOpts { dev: true, ..Default::default() });
|
||||
let r = render(
|
||||
&sc,
|
||||
0,
|
||||
&RenderOpts {
|
||||
dev: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(r.html.contains("data-src=\"scene.rhai:3\""));
|
||||
assert_eq!(r.order.len(), 1);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ pub struct LayerOpts {
|
||||
|
||||
impl Default for LayerOpts {
|
||||
fn default() -> Self {
|
||||
LayerOpts { sort: true, zfirst: false, cls: "iso-sprites".into() }
|
||||
LayerOpts {
|
||||
sort: true,
|
||||
zfirst: false,
|
||||
cls: "iso-sprites".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,9 +82,25 @@ impl Scene {
|
||||
pub fn new() -> Self {
|
||||
Scene {
|
||||
layers: vec![
|
||||
Layer { name: "ground".into(), opts: LayerOpts { sort: false, zfirst: false, cls: "iso-ground".into() }, sprites: vec![] },
|
||||
Layer { name: "floor".into(), opts: LayerOpts::default(), sprites: vec![] },
|
||||
Layer { name: "main".into(), opts: LayerOpts::default(), sprites: vec![] },
|
||||
Layer {
|
||||
name: "ground".into(),
|
||||
opts: LayerOpts {
|
||||
sort: false,
|
||||
zfirst: false,
|
||||
cls: "iso-ground".into(),
|
||||
},
|
||||
sprites: vec![],
|
||||
},
|
||||
Layer {
|
||||
name: "floor".into(),
|
||||
opts: LayerOpts::default(),
|
||||
sprites: vec![],
|
||||
},
|
||||
Layer {
|
||||
name: "main".into(),
|
||||
opts: LayerOpts::default(),
|
||||
sprites: vec![],
|
||||
},
|
||||
],
|
||||
tags: vec![],
|
||||
info: BTreeMap::new(),
|
||||
@@ -101,8 +121,17 @@ impl Scene {
|
||||
self.layers[i].opts = opts;
|
||||
return i;
|
||||
}
|
||||
let at = before.and_then(|b| self.layer_index(b)).unwrap_or(self.layers.len());
|
||||
self.layers.insert(at, Layer { name: name.into(), opts, sprites: vec![] });
|
||||
let at = before
|
||||
.and_then(|b| self.layer_index(b))
|
||||
.unwrap_or(self.layers.len());
|
||||
self.layers.insert(
|
||||
at,
|
||||
Layer {
|
||||
name: name.into(),
|
||||
opts,
|
||||
sprites: vec![],
|
||||
},
|
||||
);
|
||||
at
|
||||
}
|
||||
|
||||
@@ -119,13 +148,39 @@ impl Scene {
|
||||
self.layer_mut(layer).sprites.push(s);
|
||||
}
|
||||
|
||||
pub fn tag(&mut self, at: P3, text: impl Into<String>, prio: f64, id: impl Into<String>, kind: impl Into<String>) {
|
||||
self.tags.push(Tag { at, text: text.into(), prio, id: id.into(), kind: kind.into() });
|
||||
pub fn tag(
|
||||
&mut self,
|
||||
at: P3,
|
||||
text: impl Into<String>,
|
||||
prio: f64,
|
||||
id: impl Into<String>,
|
||||
kind: impl Into<String>,
|
||||
) {
|
||||
self.tags.push(Tag {
|
||||
at,
|
||||
text: text.into(),
|
||||
prio,
|
||||
id: id.into(),
|
||||
kind: kind.into(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn info(&mut self, id: impl Into<String>, name: impl Into<String>, detail: impl Into<String>) {
|
||||
pub fn info(
|
||||
&mut self,
|
||||
id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
detail: impl Into<String>,
|
||||
) {
|
||||
let id = id.into();
|
||||
self.info.insert(id.clone(), Info { id, name: name.into(), detail: detail.into(), rows: vec![] });
|
||||
self.info.insert(
|
||||
id.clone(),
|
||||
Info {
|
||||
id,
|
||||
name: name.into(),
|
||||
detail: detail.into(),
|
||||
rows: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn sprite_count(&self) -> usize {
|
||||
@@ -137,7 +192,9 @@ impl Scene {
|
||||
use crate::prim::Prim;
|
||||
fn walk(p: &Prim, out: &mut std::collections::BTreeSet<String>) {
|
||||
let id = match p {
|
||||
Prim::Prism { st, .. } | Prim::Cyl { st, .. } | Prim::Frust { st, .. } => st.id.clone(),
|
||||
Prim::Prism { st, .. } | Prim::Cyl { st, .. } | Prim::Frust { st, .. } => {
|
||||
st.id.clone()
|
||||
}
|
||||
Prim::Open { id, .. }
|
||||
| Prim::Flat { id, .. }
|
||||
| Prim::Lines { id, .. }
|
||||
@@ -151,7 +208,10 @@ impl Scene {
|
||||
None
|
||||
}
|
||||
Prim::Seq { items } => {
|
||||
items.iter().flat_map(|(_, v)| v.iter()).for_each(|q| walk(q, out));
|
||||
items
|
||||
.iter()
|
||||
.flat_map(|(_, v)| v.iter())
|
||||
.for_each(|q| walk(q, out));
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
@@ -174,7 +234,10 @@ impl Scene {
|
||||
|
||||
/// The default focus point: the explicit one, else the bounds centre.
|
||||
pub fn focus_point(&self) -> Option<P3> {
|
||||
self.focus.or_else(|| self.bounds.map(|(b, _)| [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0, 0.0]))
|
||||
self.focus.or_else(|| {
|
||||
self.bounds
|
||||
.map(|(b, _)| [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0, 0.0])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,10 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Order { order: Vec::with_capacity(n), cycles: 0 };
|
||||
let mut out = Order {
|
||||
order: Vec::with_capacity(n),
|
||||
cycles: 0,
|
||||
};
|
||||
for _ in 0..n {
|
||||
let mut best: Option<usize> = None;
|
||||
for i in 0..n {
|
||||
@@ -102,7 +105,9 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
|
||||
}
|
||||
best = match best {
|
||||
None => Some(i),
|
||||
Some(b) if indeg[i] < indeg[b] || (indeg[i] == indeg[b] && key[i] < key[b]) => Some(i),
|
||||
Some(b) if indeg[i] < indeg[b] || (indeg[i] == indeg[b] && key[i] < key[b]) => {
|
||||
Some(i)
|
||||
}
|
||||
keep => keep,
|
||||
};
|
||||
}
|
||||
@@ -128,14 +133,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn separated_on_x_draws_far_first() {
|
||||
let o = sort(&[bx(5.0, 0.0, 6.0, 1.0, 0.0, 1.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false);
|
||||
let o = sort(
|
||||
&[
|
||||
bx(5.0, 0.0, 6.0, 1.0, 0.0, 1.0),
|
||||
bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0),
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert_eq!(o.order, vec![1, 0]);
|
||||
assert_eq!(o.cycles, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_boxes_draw_bottom_first() {
|
||||
let o = sort(&[bx(0.0, 0.0, 1.0, 1.0, 1.0, 2.0), bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0)], false);
|
||||
let o = sort(
|
||||
&[
|
||||
bx(0.0, 0.0, 1.0, 1.0, 1.0, 2.0),
|
||||
bx(0.0, 0.0, 1.0, 1.0, 0.0, 1.0),
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert_eq!(o.order, vec![1, 0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,12 @@ impl Xf {
|
||||
pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> [f64; 4] {
|
||||
let a = self.p(x0, y0);
|
||||
let b = self.p(x1, y1);
|
||||
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
|
||||
[
|
||||
a[0].min(b[0]),
|
||||
a[1].min(b[1]),
|
||||
a[0].max(b[0]),
|
||||
a[1].max(b[1]),
|
||||
]
|
||||
}
|
||||
pub fn pts(&self, pts: &[P2]) -> Vec<P2> {
|
||||
pts.iter().map(|q| self.p(q[0], q[1])).collect()
|
||||
@@ -76,8 +81,15 @@ impl Xf {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Frame {
|
||||
Group { cls: String, style: String, items: Vec<Prim> },
|
||||
Seq { items: Vec<(P2, Vec<Prim>)>, cur: Option<(P2, Vec<Prim>)> },
|
||||
Group {
|
||||
cls: String,
|
||||
style: String,
|
||||
items: Vec<Prim>,
|
||||
},
|
||||
Seq {
|
||||
items: Vec<(P2, Vec<Prim>)>,
|
||||
cur: Option<(P2, Vec<Prim>)>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
@@ -101,6 +113,9 @@ pub struct Sprite {
|
||||
frames: Vec<Frame>,
|
||||
}
|
||||
|
||||
/// One item of [`Sprite::seq`]: a plan anchor and the drawing.
|
||||
pub type SeqItem<'a> = (P2, Box<dyn FnOnce(&mut Sprite) + 'a>);
|
||||
|
||||
/// Points of an axis-aligned rectangle, counter-clockwise from (x0, y0).
|
||||
pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<P2> {
|
||||
vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
|
||||
@@ -108,7 +123,11 @@ pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<P2> {
|
||||
|
||||
impl Sprite {
|
||||
pub fn new(id: impl Into<String>, cls: impl Into<String>) -> Self {
|
||||
Sprite { id: id.into(), cls: cls.into(), ..Default::default() }
|
||||
Sprite {
|
||||
id: id.into(),
|
||||
cls: cls.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_frame(mut self, xf: Xf) -> Self {
|
||||
@@ -141,7 +160,10 @@ impl Sprite {
|
||||
|
||||
pub fn ext_box(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> &mut Self {
|
||||
for z in [z0, z1] {
|
||||
self.ext(x0, y0, z).ext(x1, y0, z).ext(x1, y1, z).ext(x0, y1, z);
|
||||
self.ext(x0, y0, z)
|
||||
.ext(x1, y0, z)
|
||||
.ext(x1, y1, z)
|
||||
.ext(x0, y1, z);
|
||||
}
|
||||
self
|
||||
}
|
||||
@@ -156,13 +178,27 @@ impl Sprite {
|
||||
|
||||
/// Axis-aligned box (`box` in scripts; `box` is a Rust keyword).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn cuboid(&mut self, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64, st: Style) -> &mut Self {
|
||||
pub fn cuboid(
|
||||
&mut self,
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
x1: f64,
|
||||
y1: f64,
|
||||
z0: f64,
|
||||
z1: f64,
|
||||
st: Style,
|
||||
) -> &mut Self {
|
||||
let r = self.xf.rect(x0, y0, x1, y1);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
let pts = rect(r[0], r[1], r[2], r[3]);
|
||||
self.ext_world(&pts, z0);
|
||||
self.ext_world(&pts, z1);
|
||||
self.push(Prim::Prism { pts, z0: Zs::One(z0), z1: Zs::One(z1), st })
|
||||
self.push(Prim::Prism {
|
||||
pts,
|
||||
z0: Zs::One(z0),
|
||||
z1: Zs::One(z1),
|
||||
st,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prism(&mut self, pts: Vec<P2>, z0: Zs, z1: Zs, st: Style) -> &mut Self {
|
||||
@@ -188,30 +224,73 @@ impl Sprite {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn frust(&mut self, cx: f64, cy: f64, r0: f64, r1: f64, z0: f64, z1: f64, n: u32, st: Style) -> &mut Self {
|
||||
pub fn frust(
|
||||
&mut self,
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
r0: f64,
|
||||
r1: f64,
|
||||
z0: f64,
|
||||
z1: f64,
|
||||
n: u32,
|
||||
st: Style,
|
||||
) -> &mut Self {
|
||||
let r = r0.max(r1);
|
||||
self.ext_box(cx - r, cy - r, cx + r, cy + r, z0, z1);
|
||||
let c = self.xf.p(cx, cy);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
self.push(Prim::Frust { c, r0, r1, z0, z1, n: n.max(3), st })
|
||||
self.push(Prim::Frust {
|
||||
c,
|
||||
r0,
|
||||
r1,
|
||||
z0,
|
||||
z1,
|
||||
n: n.max(3),
|
||||
st,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sloped slab (stair flight, ramp) over x0..x1, rising from y0 (za) to
|
||||
/// y1 (zb), th thick.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn slope(&mut self, x0: f64, x1: f64, y0: f64, y1: f64, za: f64, zb: f64, th: f64, mat: &str) -> &mut Self {
|
||||
pub fn slope(
|
||||
&mut self,
|
||||
x0: f64,
|
||||
x1: f64,
|
||||
y0: f64,
|
||||
y1: f64,
|
||||
za: f64,
|
||||
zb: f64,
|
||||
th: f64,
|
||||
mat: &str,
|
||||
) -> &mut Self {
|
||||
let zt = vec![za, za, zb, zb];
|
||||
let zb_: Vec<f64> = zt.iter().map(|z| z - th).collect();
|
||||
self.prism(rect(x0, y0, x1, y1), Zs::Each(zb_), Zs::Each(zt), Style::mat(mat))
|
||||
self.prism(
|
||||
rect(x0, y0, x1, y1),
|
||||
Zs::Each(zb_),
|
||||
Zs::Each(zt),
|
||||
Style::mat(mat),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- surfaces
|
||||
|
||||
/// Openings on the vertical face a -> b (outward normal on the right).
|
||||
pub fn open(&mut self, a: P2, b: P2, list: Vec<[f64; 4]>, cls: &str, id: Option<String>) -> &mut Self {
|
||||
pub fn open(
|
||||
&mut self,
|
||||
a: P2,
|
||||
b: P2,
|
||||
list: Vec<[f64; 4]>,
|
||||
cls: &str,
|
||||
id: Option<String>,
|
||||
) -> &mut Self {
|
||||
let (a, b) = (self.xf.p(a[0], a[1]), self.xf.p(b[0], b[1]));
|
||||
let dz = self.xf.z;
|
||||
let list: Vec<[f64; 4]> = list.into_iter().map(|q| [q[0], q[1], q[2] + dz, q[3] + dz]).collect();
|
||||
let list: Vec<[f64; 4]> = list
|
||||
.into_iter()
|
||||
.map(|q| [q[0], q[1], q[2] + dz, q[3] + dz])
|
||||
.collect();
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l = (dx * dx + dy * dy).sqrt().max(1e-9);
|
||||
let (ux, uy) = (dx / l, dy / l);
|
||||
@@ -219,16 +298,33 @@ impl Sprite {
|
||||
self.ext.push([a[0] + ux * q[0], a[1] + uy * q[0], q[2]]);
|
||||
self.ext.push([a[0] + ux * q[1], a[1] + uy * q[1], q[3]]);
|
||||
}
|
||||
self.push(Prim::Open { a, b, list, cls: cls.into(), id })
|
||||
self.push(Prim::Open {
|
||||
a,
|
||||
b,
|
||||
list,
|
||||
cls: cls.into(),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn flat(&mut self, polys: Vec<Vec<P2>>, z: f64, cls: &str, id: Option<String>) -> &mut Self {
|
||||
pub fn flat(
|
||||
&mut self,
|
||||
polys: Vec<Vec<P2>>,
|
||||
z: f64,
|
||||
cls: &str,
|
||||
id: Option<String>,
|
||||
) -> &mut Self {
|
||||
let polys: Vec<Vec<P2>> = polys.iter().map(|p| self.xf.pts(p)).collect();
|
||||
let z = self.xf.z(z);
|
||||
for p in &polys {
|
||||
self.ext_world(p, z);
|
||||
}
|
||||
self.push(Prim::Flat { polys, z, cls: cls.into(), id })
|
||||
self.push(Prim::Flat {
|
||||
polys,
|
||||
z,
|
||||
cls: cls.into(),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn lines(&mut self, segs: Vec<[f64; 4]>, z: f64, cls: &str) -> &mut Self {
|
||||
@@ -245,30 +341,67 @@ impl Sprite {
|
||||
self.ext.push([s[0], s[1], z]);
|
||||
self.ext.push([s[2], s[3], z]);
|
||||
}
|
||||
self.push(Prim::Lines { segs, z, cls: cls.into(), id: None })
|
||||
self.push(Prim::Lines {
|
||||
segs,
|
||||
z,
|
||||
cls: cls.into(),
|
||||
id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn disc(&mut self, cx: f64, cy: f64, z: f64, r: f64, cls: &str, id: Option<String>) -> &mut Self {
|
||||
pub fn disc(
|
||||
&mut self,
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
z: f64,
|
||||
r: f64,
|
||||
cls: &str,
|
||||
id: Option<String>,
|
||||
) -> &mut Self {
|
||||
self.ext_box(cx - r, cy - r, cx + r, cy + r, z, z);
|
||||
let c = self.xf.p(cx, cy);
|
||||
let z = self.xf.z(z);
|
||||
self.push(Prim::Disc { c, z, r, cls: cls.into(), id })
|
||||
self.push(Prim::Disc {
|
||||
c,
|
||||
z,
|
||||
r,
|
||||
cls: cls.into(),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Planar 3D polygon shaded by its normal (see [`Prim::Face`]).
|
||||
pub fn face(&mut self, pts: Vec<P3>, mat: &str, id: Option<String>, two_sided: bool) -> &mut Self {
|
||||
pub fn face(
|
||||
&mut self,
|
||||
pts: Vec<P3>,
|
||||
mat: &str,
|
||||
id: Option<String>,
|
||||
two_sided: bool,
|
||||
) -> &mut Self {
|
||||
let pts: Vec<P3> = pts.into_iter().map(|p| self.xf.p3(p)).collect();
|
||||
self.ext.extend(pts.iter().copied());
|
||||
self.push(Prim::Face { pts, mat: mat.into(), id, two_sided })
|
||||
self.push(Prim::Face {
|
||||
pts,
|
||||
mat: mat.into(),
|
||||
id,
|
||||
two_sided,
|
||||
})
|
||||
}
|
||||
|
||||
/// 3D polygons as one path with a fixed class.
|
||||
pub fn poly(&mut self, polys: Vec<Vec<P3>>, cls: &str, id: Option<String>) -> &mut Self {
|
||||
let polys: Vec<Vec<P3>> = polys.into_iter().map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect()).collect();
|
||||
let polys: Vec<Vec<P3>> = polys
|
||||
.into_iter()
|
||||
.map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect())
|
||||
.collect();
|
||||
for p in &polys {
|
||||
self.ext.extend(p.iter().copied());
|
||||
}
|
||||
self.push(Prim::Poly { polys, cls: cls.into(), id })
|
||||
self.push(Prim::Poly {
|
||||
polys,
|
||||
cls: cls.into(),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- screen-space details
|
||||
@@ -277,14 +410,27 @@ impl Sprite {
|
||||
/// sticks out of the sprite).
|
||||
pub fn circle(&mut self, at: P3, r: f64, off: P2, cls: &str) -> &mut Self {
|
||||
let at = self.xf.p3(at);
|
||||
self.push(Prim::Circle { at, r, off, cls: cls.into(), id: None })
|
||||
self.push(Prim::Circle {
|
||||
at,
|
||||
r,
|
||||
off,
|
||||
cls: cls.into(),
|
||||
id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stem(&mut self, x: f64, y: f64, z0: f64, z1: f64, w: f64, cls: &str) -> &mut Self {
|
||||
self.ext(x, y, z0).ext(x, y, z1);
|
||||
let at = self.xf.p(x, y);
|
||||
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
|
||||
self.push(Prim::Stem { at, z0, z1, w, cls: cls.into(), id: None })
|
||||
self.push(Prim::Stem {
|
||||
at,
|
||||
z0,
|
||||
z1,
|
||||
w,
|
||||
cls: cls.into(),
|
||||
id: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A head (ball of radius r metres) centred at z.
|
||||
@@ -307,13 +453,22 @@ impl Sprite {
|
||||
}
|
||||
|
||||
pub fn led(&mut self, x: f64, y: f64, z: f64, cls: &str) -> &mut Self {
|
||||
let c = if cls.is_empty() { "iso-led".to_string() } else { format!("iso-led {cls}") };
|
||||
let c = if cls.is_empty() {
|
||||
"iso-led".to_string()
|
||||
} else {
|
||||
format!("iso-led {cls}")
|
||||
};
|
||||
self.circle([x, y, z], 0.9, [0.0, 0.0], &c)
|
||||
}
|
||||
|
||||
pub fn puffs(&mut self, x: f64, y: f64, z: f64, kind: &str, n: u32, dur: f64) -> &mut Self {
|
||||
let at = self.xf.p3([x, y, z]);
|
||||
self.push(Prim::Puffs { at, kind: kind.into(), n: n.max(1), dur })
|
||||
self.push(Prim::Puffs {
|
||||
at,
|
||||
kind: kind.into(),
|
||||
n: n.max(1),
|
||||
dur,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fan(&mut self, x: f64, y: f64, z: f64, r: f64) -> &mut Self {
|
||||
@@ -323,14 +478,21 @@ impl Sprite {
|
||||
|
||||
pub fn raw(&mut self, at: Option<P3>, svg: impl Into<String>) -> &mut Self {
|
||||
let at = at.map(|a| self.xf.p3(a));
|
||||
self.push(Prim::Raw { at, svg: svg.into() })
|
||||
self.push(Prim::Raw {
|
||||
at,
|
||||
svg: svg.into(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- structure
|
||||
|
||||
/// Open a `<g class style>`; close it with [`Sprite::end_group`].
|
||||
pub fn begin_group(&mut self, cls: &str, style: &str) -> &mut Self {
|
||||
self.frames.push(Frame::Group { cls: cls.into(), style: style.into(), items: vec![] });
|
||||
self.frames.push(Frame::Group {
|
||||
cls: cls.into(),
|
||||
style: style.into(),
|
||||
items: vec![],
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
@@ -344,7 +506,10 @@ impl Sprite {
|
||||
/// Start a back-to-front sequence; each [`Sprite::seq_item`] starts an
|
||||
/// item anchored at a plan point; [`Sprite::end_seq`] closes it.
|
||||
pub fn begin_seq(&mut self) -> &mut Self {
|
||||
self.frames.push(Frame::Seq { items: vec![], cur: None });
|
||||
self.frames.push(Frame::Seq {
|
||||
items: vec![],
|
||||
cur: None,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
@@ -370,7 +535,7 @@ impl Sprite {
|
||||
}
|
||||
|
||||
/// Draw items back to front by the depth of their anchor.
|
||||
pub fn seq(&mut self, items: Vec<(P2, Box<dyn FnOnce(&mut Sprite) + '_>)>) -> &mut Self {
|
||||
pub fn seq(&mut self, items: Vec<SeqItem<'_>>) -> &mut Self {
|
||||
self.begin_seq();
|
||||
for (at, f) in items {
|
||||
self.seq_item(at);
|
||||
@@ -442,7 +607,10 @@ mod tests {
|
||||
let f = Xf::new(3.0, -4.0, 0.0, deg);
|
||||
let w = f.p(1.5, 2.5);
|
||||
let l = f.inv(w[0], w[1]);
|
||||
assert!((l[0] - 1.5).abs() < 1e-12 && (l[1] - 2.5).abs() < 1e-12, "{deg}");
|
||||
assert!(
|
||||
(l[0] - 1.5).abs() < 1e-12 && (l[1] - 2.5).abs() < 1e-12,
|
||||
"{deg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "isokit-project"
|
||||
description = "isokit projects: manifest, scene document ops, rendering, export, checks, MCP tools"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
isokit-core = { workspace = true }
|
||||
isokit-script = { workspace = true }
|
||||
isokit-theme = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Files embedded in the binary: the viewer, the Studio and the starter
|
||||
//! project. Set `ISOKIT_ASSETS` to the repository folder to serve the
|
||||
//! viewer and Studio files from disk instead (edit them without rebuilding).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub const VIEW_JS: &str = include_str!("../../../viewer/isokit-view.js");
|
||||
pub const VIEW_CSS: &str = include_str!("../../../viewer/isokit-view.css");
|
||||
pub const PAGE: &str = include_str!("../../../viewer/page.html");
|
||||
pub const STUDIO_HTML: &str = include_str!("../../../studio/index.html");
|
||||
pub const STUDIO_JS: &str = include_str!("../../../studio/studio.js");
|
||||
pub const STUDIO_CSS: &str = include_str!("../../../studio/studio.css");
|
||||
|
||||
/// Asset by its path in the repository ("viewer/isokit-view.js",
|
||||
/// "studio/studio.js"), from disk when `ISOKIT_ASSETS` is set.
|
||||
pub fn get(path: &str) -> Option<Cow<'static, str>> {
|
||||
if let Ok(root) = std::env::var("ISOKIT_ASSETS") {
|
||||
if let Ok(s) = std::fs::read_to_string(std::path::Path::new(&root).join(path)) {
|
||||
return Some(Cow::Owned(s));
|
||||
}
|
||||
}
|
||||
let s = match path {
|
||||
"viewer/isokit-view.js" => VIEW_JS,
|
||||
"viewer/isokit-view.css" => VIEW_CSS,
|
||||
"viewer/page.html" => PAGE,
|
||||
"studio/index.html" => STUDIO_HTML,
|
||||
"studio/studio.js" => STUDIO_JS,
|
||||
"studio/studio.css" => STUDIO_CSS,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Cow::Borrowed(s))
|
||||
}
|
||||
|
||||
pub const NEW_MANIFEST: &str = r#"# isokit project. Open it with `isokit studio` (GUI) or build it with `isokit build`.
|
||||
title = "{title}"
|
||||
note = "Schematic isometric view. Drag to pan, Ctrl + wheel or pinch to zoom, tap anything for details."
|
||||
|
||||
# levels: "site" shows buildings closed; "gf", "1f", ... cut buildings open at that storey
|
||||
default_level = "site"
|
||||
default_rotation = 1
|
||||
|
||||
# project prefab libraries (functions prefab_<type> / meta_<type>), always loaded
|
||||
prelude = []
|
||||
# colour overrides on top of the built-in theme
|
||||
theme = ["theme.toml"]
|
||||
|
||||
[[level]]
|
||||
id = "site"
|
||||
label = "Site"
|
||||
title = "Closed buildings and roofs"
|
||||
|
||||
[[level]]
|
||||
id = "gf"
|
||||
label = "GF"
|
||||
title = "Ground floors, cut open"
|
||||
|
||||
[[level]]
|
||||
id = "1f"
|
||||
label = "1F"
|
||||
title = "First floors, cut open"
|
||||
|
||||
[[param]]
|
||||
name = "labels"
|
||||
type = "bool"
|
||||
default = true
|
||||
label = "Labels"
|
||||
|
||||
[[param]]
|
||||
name = "running"
|
||||
type = "bool"
|
||||
default = true
|
||||
label = "Animation"
|
||||
|
||||
[build]
|
||||
out = "dist/index.html"
|
||||
budget = 4000
|
||||
"#;
|
||||
|
||||
pub const NEW_DOC: &str = r#"{
|
||||
"version": 1,
|
||||
"objects": [
|
||||
{ "id": "site", "type": "site", "name": "Site", "x": 0, "y": 0, "props": { "w": 60, "d": 40 }, "locked": true },
|
||||
{ "id": "road", "type": "road", "name": "Access road", "x": 0, "y": -17, "props": { "w": 60, "d": 6 } },
|
||||
{ "id": "building", "type": "building", "name": "Main building", "x": -8, "y": 3, "props": { "w": 22, "d": 12, "floors": 2, "pv": true }, "tag": "Main building", "info": "Two storeys, solar on the roof" },
|
||||
{ "id": "tree", "type": "tree", "x": 16, "y": 8 },
|
||||
{ "id": "car", "type": "car", "x": 12, "y": -10, "props": { "colour": "car1" } }
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
pub const NEW_THEME: &str = r##"# Colour overrides on top of the built-in theme. Examples:
|
||||
#
|
||||
# [palette.dark]
|
||||
# render = "#8f8b84"
|
||||
# [palette.light]
|
||||
# render = "#f4efe6"
|
||||
# [materials]
|
||||
# brick = "#9a5a44" # use as mat "brick" in prefabs and scripts
|
||||
"##;
|
||||
|
||||
pub const NEW_LIB_README: &str = r#"Project prefabs live here. A prefab is a pair of Rhai functions:
|
||||
|
||||
fn meta_kiosk() {
|
||||
#{ label: "Kiosk", category: "Shops", icon: "K",
|
||||
props: [ #{ key: "h", type: "float", def: 2.6, min: 2.0, max: 5.0, label: "Height (m)" } ] }
|
||||
}
|
||||
fn prefab_kiosk(o) { // o.p holds the props; draw around (0, 0)
|
||||
sprite().box(-1.5, -1, 1.5, 1, 0, o.p.h, "render2", #{ top: "roof3" });
|
||||
}
|
||||
|
||||
List the file in isokit.toml (prelude = ["lib/kiosk.rhai"]) and the Studio
|
||||
offers "Kiosk" in its palette.
|
||||
"#;
|
||||
@@ -0,0 +1,144 @@
|
||||
//! `isokit check`: problems a person would spot by eye, found by code.
|
||||
|
||||
use crate::ops::check_props;
|
||||
use crate::{assets, Project};
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||
pub struct Finding {
|
||||
/// error | warning | info
|
||||
pub level: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object: Option<String>,
|
||||
}
|
||||
|
||||
fn f(level: &str, message: String, object: Option<String>) -> Finding {
|
||||
Finding {
|
||||
level: level.into(),
|
||||
message,
|
||||
object,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classes the viewer styles itself (faces, structure, animation).
|
||||
const STRUCTURAL: &[&str] = &[
|
||||
"iso-t",
|
||||
"iso-l",
|
||||
"iso-c",
|
||||
"iso-r",
|
||||
"iso-ground",
|
||||
"iso-sprites",
|
||||
"iso-tags",
|
||||
"iso-tag",
|
||||
"iso-a",
|
||||
"iso-vent",
|
||||
"iso-dim",
|
||||
"iso-headc",
|
||||
"iso-led",
|
||||
"iso-lit",
|
||||
"iso-on",
|
||||
"iso-empty",
|
||||
];
|
||||
|
||||
pub fn check(p: &Project) -> Vec<Finding> {
|
||||
let mut out = vec![];
|
||||
let rt = match &p.runtime {
|
||||
Ok(r) => r,
|
||||
Err(e) => return vec![f("error", e.to_string(), None)],
|
||||
};
|
||||
// document: types and props
|
||||
let mut ids = BTreeSet::new();
|
||||
for o in &p.doc.objects {
|
||||
if !ids.insert(o.id.clone()) {
|
||||
out.push(f(
|
||||
"error",
|
||||
format!("duplicate object id \"{}\"", o.id),
|
||||
Some(o.id.clone()),
|
||||
));
|
||||
}
|
||||
match rt.prefab(&o.kind) {
|
||||
None => out.push(f(
|
||||
"error",
|
||||
format!("unknown type \"{}\"", o.kind),
|
||||
Some(o.id.clone()),
|
||||
)),
|
||||
Some(m) => {
|
||||
if let Err(e) = check_props(m, &o.props) {
|
||||
out.push(f("warning", e, Some(o.id.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// every view: warnings, NaN, budget, sort cycles, undefined classes
|
||||
let css = format!(
|
||||
"{}\n{}",
|
||||
assets::get("viewer/isokit-view.css").unwrap_or_default(),
|
||||
p.theme.css()
|
||||
);
|
||||
let mut seen_warn = BTreeSet::new();
|
||||
let mut missing = BTreeSet::new();
|
||||
for l in p.manifest.levels() {
|
||||
for rot in 0..4u8 {
|
||||
let v = match p.render(&l.id, rot, None, false) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
out.push(f("error", format!("{}/{rot}: {e}", l.id), None));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for w in &v.warnings {
|
||||
if seen_warn.insert(w.clone()) {
|
||||
let obj = w
|
||||
.strip_prefix("object ")
|
||||
.and_then(|r| r.split(':').next())
|
||||
.map(str::to_string);
|
||||
out.push(f("warning", w.clone(), obj));
|
||||
}
|
||||
}
|
||||
if v.html.contains("NaN") {
|
||||
out.push(f(
|
||||
"error",
|
||||
format!(
|
||||
"{}/{rot}: NaN in the drawing (a coordinate is not a number)",
|
||||
l.id
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if v.nodes > p.manifest.build.budget {
|
||||
out.push(f(
|
||||
"warning",
|
||||
format!(
|
||||
"{}/{rot}: {} SVG nodes, budget {}",
|
||||
l.id, v.nodes, p.manifest.build.budget
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if v.cycles > 0 {
|
||||
out.push(f("info", format!("{}/{rot}: {} depth-sort cycle(s) broken; split large sprites if something draws in front of what it stands behind", l.id, v.cycles), None));
|
||||
}
|
||||
for cls in v
|
||||
.html
|
||||
.split("class=\"")
|
||||
.skip(1)
|
||||
.filter_map(|s| s.split('"').next())
|
||||
{
|
||||
for c in cls.split_whitespace() {
|
||||
if c.starts_with("iso-")
|
||||
&& !STRUCTURAL.contains(&c)
|
||||
&& !css.contains(&format!(".{c}"))
|
||||
{
|
||||
missing.insert(c.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in missing {
|
||||
out.push(f("warning", format!("class {c} has no style in the theme (drawn grey): add it under [materials] in theme.toml"), None));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
//! isokit projects.
|
||||
//!
|
||||
//! A project is a folder:
|
||||
//!
|
||||
//! ```text
|
||||
//! isokit.toml manifest: title, levels, parameters, theme files, prelude modules
|
||||
//! scene.json the scene document (objects), edited by the Studio and agents
|
||||
//! scene.rhai optional script drawn before the objects
|
||||
//! lib/*.rhai optional project prefabs (list them in `prelude`)
|
||||
//! theme.toml optional colours and materials
|
||||
//! ```
|
||||
//!
|
||||
//! [`Project`] loads and renders it; [`ops`] edits the document; [`page`]
|
||||
//! exports HTML/SVG; [`check`] lints it; [`workspace::Workspace`] keeps a
|
||||
//! project loaded across edits (undo, reload on file changes) for the Studio
|
||||
//! and the MCP server ([`mcp`]).
|
||||
|
||||
pub mod assets;
|
||||
pub mod check;
|
||||
pub mod mcp;
|
||||
pub mod ops;
|
||||
pub mod page;
|
||||
pub mod workspace;
|
||||
|
||||
use isokit_core::{render, Info, RenderOpts};
|
||||
use isokit_script::doc::{Doc, PrefabMeta};
|
||||
use isokit_script::{Bundle, Runtime, ScriptError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
pub use isokit_script;
|
||||
|
||||
pub const MANIFEST: &str = "isokit.toml";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LevelDef {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ParamDef {
|
||||
pub name: String,
|
||||
/// bool | int | float | choice | text
|
||||
#[serde(rename = "type", default = "bool_kind")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub default: Value,
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub choices: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub min: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub step: Option<f64>,
|
||||
}
|
||||
|
||||
fn bool_kind() -> String {
|
||||
"bool".into()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BuildOpts {
|
||||
#[serde(default)]
|
||||
pub out: Option<String>,
|
||||
/// Node budget per view for `isokit check`.
|
||||
#[serde(default = "budget")]
|
||||
pub budget: usize,
|
||||
}
|
||||
|
||||
fn budget() -> usize {
|
||||
4000
|
||||
}
|
||||
|
||||
impl Default for BuildOpts {
|
||||
fn default() -> Self {
|
||||
BuildOpts {
|
||||
out: None,
|
||||
budget: budget(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rot1() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Manifest {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
/// Main script; default `scene.rhai` when that file exists.
|
||||
#[serde(default)]
|
||||
pub script: Option<String>,
|
||||
/// Scene document; default `scene.json`.
|
||||
#[serde(default)]
|
||||
pub doc: Option<String>,
|
||||
#[serde(default)]
|
||||
pub prelude: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub theme: Vec<String>,
|
||||
#[serde(default, rename = "level")]
|
||||
pub levels: Vec<LevelDef>,
|
||||
#[serde(default)]
|
||||
pub default_level: Option<String>,
|
||||
#[serde(default = "rot1")]
|
||||
pub default_rotation: u8,
|
||||
#[serde(default, rename = "param")]
|
||||
pub params: Vec<ParamDef>,
|
||||
#[serde(default)]
|
||||
pub build: BuildOpts,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn levels(&self) -> Vec<LevelDef> {
|
||||
if self.levels.is_empty() {
|
||||
vec![LevelDef {
|
||||
id: "site".into(),
|
||||
label: "Site".into(),
|
||||
title: "The whole site".into(),
|
||||
}]
|
||||
} else {
|
||||
self.levels.clone()
|
||||
}
|
||||
}
|
||||
pub fn default_level(&self) -> String {
|
||||
let lv = self.levels();
|
||||
self.default_level
|
||||
.clone()
|
||||
.filter(|d| lv.iter().any(|l| &l.id == d))
|
||||
.unwrap_or_else(|| lv[0].id.clone())
|
||||
}
|
||||
pub fn doc_path(&self) -> String {
|
||||
self.doc.clone().unwrap_or_else(|| "scene.json".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Io(String),
|
||||
Manifest(String),
|
||||
Theme(String),
|
||||
Doc(String),
|
||||
Script(ScriptError),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Io(e) => write!(f, "{e}"),
|
||||
Error::Manifest(e) => write!(f, "isokit.toml: {e}"),
|
||||
Error::Theme(e) => write!(f, "theme: {e}"),
|
||||
Error::Doc(e) => write!(f, "scene document: {e}"),
|
||||
Error::Script(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl From<ScriptError> for Error {
|
||||
fn from(e: ScriptError) -> Self {
|
||||
Error::Script(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// One rendered view as the viewer consumes it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct View {
|
||||
pub html: String,
|
||||
pub info: BTreeMap<String, Info>,
|
||||
pub bbox: [f64; 4],
|
||||
pub focus: [f64; 2],
|
||||
pub rot: u8,
|
||||
pub north: f64,
|
||||
pub level: String,
|
||||
pub sprites: usize,
|
||||
pub nodes: usize,
|
||||
pub cycles: usize,
|
||||
pub warnings: Vec<String>,
|
||||
/// Object id -> world box [x0, y0, z0, x1, y1, z1].
|
||||
pub boxes: BTreeMap<String, [f64; 6]>,
|
||||
pub ms: f64,
|
||||
}
|
||||
|
||||
/// Project facts the viewer, the Studio and agents need.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Meta {
|
||||
pub title: String,
|
||||
pub note: String,
|
||||
pub levels: Vec<LevelDef>,
|
||||
pub default_level: String,
|
||||
pub default_rotation: u8,
|
||||
pub params: Vec<ParamDef>,
|
||||
pub catalog: Vec<PrefabMeta>,
|
||||
pub materials: Vec<String>,
|
||||
pub c: f64,
|
||||
pub cz: f64,
|
||||
}
|
||||
|
||||
fn rd(path: &Path) -> Result<String, Error> {
|
||||
std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Every `*.rhai` below `dir` (skipping dist, target and dot folders),
|
||||
/// keyed by forward-slash relative path.
|
||||
fn collect_sources(dir: &Path) -> Result<BTreeMap<String, String>, Error> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
let rd_ = std::fs::read_dir(&d).map_err(|e| Error::Io(format!("{}: {e}", d.display())))?;
|
||||
for ent in rd_.flatten() {
|
||||
let p = ent.path();
|
||||
let name = ent.file_name().to_string_lossy().to_string();
|
||||
if p.is_dir() {
|
||||
if !name.starts_with('.')
|
||||
&& name != "dist"
|
||||
&& name != "target"
|
||||
&& name != "node_modules"
|
||||
{
|
||||
stack.push(p);
|
||||
}
|
||||
} else if name.ends_with(".rhai") {
|
||||
let rel = p
|
||||
.strip_prefix(dir)
|
||||
.unwrap_or(&p)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
out.insert(rel, rd(&p)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub struct Project {
|
||||
pub dir: PathBuf,
|
||||
pub manifest: Manifest,
|
||||
pub theme: isokit_theme::Theme,
|
||||
pub doc: Doc,
|
||||
pub bundle: Bundle,
|
||||
/// The compiled scripts, or the error that stopped them.
|
||||
pub runtime: Result<Runtime, ScriptError>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Load a project folder. A script error does not fail the load: the
|
||||
/// document stays editable and [`Project::runtime`] holds the error.
|
||||
pub fn load(dir: impl AsRef<Path>) -> Result<Project, Error> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
let mpath = dir.join(MANIFEST);
|
||||
let manifest: Manifest = if mpath.exists() {
|
||||
toml::from_str(&rd(&mpath)?).map_err(|e| Error::Manifest(e.to_string()))?
|
||||
} else {
|
||||
return Err(Error::Io(format!(
|
||||
"{} not found (create a project with `isokit new`)",
|
||||
mpath.display()
|
||||
)));
|
||||
};
|
||||
let mut layers = vec![];
|
||||
for t in &manifest.theme {
|
||||
layers.push((t.clone(), rd(&dir.join(t))?));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = layers
|
||||
.iter()
|
||||
.map(|(a, b)| (a.as_str(), b.as_str()))
|
||||
.collect();
|
||||
let theme = isokit_theme::load(&refs).map_err(Error::Theme)?;
|
||||
let dpath = dir.join(manifest.doc_path());
|
||||
let doc: Doc = if dpath.exists() {
|
||||
serde_json::from_str(&rd(&dpath)?)
|
||||
.map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?
|
||||
} else {
|
||||
Doc {
|
||||
version: 1,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
let sources = collect_sources(&dir)?;
|
||||
let main = match &manifest.script {
|
||||
Some(s) => Some(s.clone()),
|
||||
None if sources.contains_key("scene.rhai") => Some("scene.rhai".into()),
|
||||
None => None,
|
||||
};
|
||||
let bundle = Bundle {
|
||||
main,
|
||||
prelude: manifest.prelude.clone(),
|
||||
sources,
|
||||
};
|
||||
let runtime = Runtime::new(&bundle);
|
||||
Ok(Project {
|
||||
dir,
|
||||
manifest,
|
||||
theme,
|
||||
doc,
|
||||
bundle,
|
||||
runtime,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn catalog(&self) -> &[PrefabMeta] {
|
||||
match &self.runtime {
|
||||
Ok(r) => r.catalog(),
|
||||
Err(_) => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameter values: manifest defaults, then the document, then `over`.
|
||||
pub fn cfg(&self, over: Option<&Map<String, Value>>) -> Map<String, Value> {
|
||||
let mut c = Map::new();
|
||||
for p in &self.manifest.params {
|
||||
c.insert(p.name.clone(), p.default.clone());
|
||||
}
|
||||
for (k, v) in &self.doc.cfg {
|
||||
c.insert(k.clone(), v.clone());
|
||||
}
|
||||
if let Some(o) = over {
|
||||
for (k, v) in o {
|
||||
c.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
pub fn materials(&self) -> Vec<String> {
|
||||
self.theme
|
||||
.materials
|
||||
.iter()
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn meta(&self) -> Meta {
|
||||
Meta {
|
||||
title: if self.manifest.title.is_empty() {
|
||||
self.dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
self.manifest.title.clone()
|
||||
},
|
||||
note: self.manifest.note.clone(),
|
||||
levels: self.manifest.levels(),
|
||||
default_level: self.manifest.default_level(),
|
||||
default_rotation: self.manifest.default_rotation % 4,
|
||||
params: self.manifest.params.clone(),
|
||||
catalog: self.catalog().to_vec(),
|
||||
materials: self.materials(),
|
||||
c: isokit_core::C,
|
||||
cz: isokit_core::CZ,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one view of the current document.
|
||||
pub fn render(
|
||||
&self,
|
||||
level: &str,
|
||||
rot: u8,
|
||||
over: Option<&Map<String, Value>>,
|
||||
dev: bool,
|
||||
) -> Result<View, ScriptError> {
|
||||
let t0 = Instant::now();
|
||||
let rt = self.runtime.as_ref().map_err(|e| e.clone())?;
|
||||
let cfg = self.cfg(over);
|
||||
let built = rt.build(level, rot % 4, &cfg, &self.doc)?;
|
||||
let labels = cfg.get("labels").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let r = render(
|
||||
&built.scene,
|
||||
rot % 4,
|
||||
&RenderOpts {
|
||||
uid: 0,
|
||||
labels,
|
||||
dev,
|
||||
},
|
||||
);
|
||||
Ok(View {
|
||||
html: r.html,
|
||||
info: r.info,
|
||||
bbox: r.bbox,
|
||||
focus: r.focus,
|
||||
rot: r.rot,
|
||||
north: r.north,
|
||||
level: level.to_string(),
|
||||
sprites: r.sprites,
|
||||
nodes: r.nodes,
|
||||
cycles: r.cycles,
|
||||
warnings: r.warnings,
|
||||
boxes: built.boxes,
|
||||
ms: (t0.elapsed().as_secs_f64() * 1e4).round() / 10.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the document (pretty JSON, atomically).
|
||||
pub fn save_doc(&self) -> Result<(), Error> {
|
||||
let path = self.dir.join(self.manifest.doc_path());
|
||||
let mut s =
|
||||
serde_json::to_string_pretty(&self.doc).map_err(|e| Error::Doc(e.to_string()))?;
|
||||
s.push('\n');
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, s).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A fingerprint of the project files (manifest, documents, scripts,
|
||||
/// themes): changes when any of them is edited, added or removed.
|
||||
pub fn version(dir: &Path) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
let mut files = vec![];
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd_) = std::fs::read_dir(&d) else {
|
||||
continue;
|
||||
};
|
||||
for ent in rd_.flatten() {
|
||||
let p = ent.path();
|
||||
let name = ent.file_name().to_string_lossy().to_string();
|
||||
if p.is_dir() {
|
||||
if !name.starts_with('.')
|
||||
&& name != "dist"
|
||||
&& name != "target"
|
||||
&& name != "node_modules"
|
||||
{
|
||||
stack.push(p);
|
||||
}
|
||||
} else if name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json")
|
||||
{
|
||||
if let Ok(m) = ent.metadata() {
|
||||
files.push((p.to_string_lossy().to_string(), m.len(), m.modified().ok()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Write a new project folder with a starter scene.
|
||||
pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
|
||||
if dir.join(MANIFEST).exists() {
|
||||
return Err(Error::Io(format!(
|
||||
"{} already has an isokit.toml",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
std::fs::create_dir_all(dir.join("lib")).map_err(|e| Error::Io(e.to_string()))?;
|
||||
let w = |name: &str, body: &str| {
|
||||
std::fs::write(dir.join(name), body).map_err(|e| Error::Io(format!("{name}: {e}")))
|
||||
};
|
||||
w(MANIFEST, &assets::NEW_MANIFEST.replace("{title}", title))?;
|
||||
w("scene.json", assets::NEW_DOC)?;
|
||||
w("theme.toml", assets::NEW_THEME)?;
|
||||
w("lib/README.md", assets::NEW_LIB_README)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
//! MCP (Model Context Protocol) server: agents edit the scene with the same
|
||||
//! operations as the Studio. Transport-agnostic: [`handle`] takes one
|
||||
//! JSON-RPC message and returns the response (None for notifications); the
|
||||
//! CLI serves it over stdio (`isokit mcp`) and the Studio over HTTP (`/mcp`).
|
||||
|
||||
use crate::check::check;
|
||||
use crate::ops::Op;
|
||||
use crate::page;
|
||||
use crate::workspace::Workspace;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
pub const PROTOCOL: &str = "2025-06-18";
|
||||
|
||||
const INSTRUCTIONS: &str = "isokit edits an isometric scene (a site plan drawn as a SimCity-style 3D view). \
|
||||
World coordinates are metres: x east, y north, z up. The scene is a list of objects; each has a type (a prefab), \
|
||||
a position (x, y = centre of its footprint), a rotation in 90 degree steps and props from the prefab's schema. \
|
||||
Start with project_info and list_prefabs, then add_object / update_object. Use render_view (summary) to see where \
|
||||
everything ended up and check_project to find problems. Edits are saved at once and show live in an open Studio.";
|
||||
|
||||
fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value {
|
||||
json!({ "name": name, "description": description,
|
||||
"inputSchema": { "type": "object", "properties": props, "required": required } })
|
||||
}
|
||||
|
||||
pub fn tools() -> Vec<Value> {
|
||||
let obj_fields = json!({
|
||||
"x": { "type": "number", "description": "east, metres (centre of the footprint)" },
|
||||
"y": { "type": "number", "description": "north, metres" },
|
||||
"z": { "type": "number", "description": "base height, metres (default 0)" },
|
||||
"rot": { "type": "number", "description": "rotation in degrees, multiples of 90" },
|
||||
"props": { "type": "object", "description": "prefab properties (see list_prefabs); in updates they merge, null removes" },
|
||||
"name": { "type": "string", "description": "name shown in the tooltip" },
|
||||
"info": { "type": "string", "description": "tooltip text" },
|
||||
"tag": { "type": "string", "description": "label pinned above the object" },
|
||||
"levels": { "type": "array", "items": { "type": "string" }, "description": "levels it shows on (empty: all)" },
|
||||
});
|
||||
let mut add = obj_fields.as_object().unwrap().clone();
|
||||
add.insert("type".into(), json!({ "type": "string", "description": "prefab type, e.g. building, tree, car (list_prefabs)" }));
|
||||
add.insert("id".into(), json!({ "type": "string", "description": "wanted id (made unique); default from the type" }));
|
||||
let mut upd = obj_fields.as_object().unwrap().clone();
|
||||
upd.insert("id".into(), json!({ "type": "string" }));
|
||||
upd.insert("hidden".into(), json!({ "type": "boolean" }));
|
||||
upd.insert(
|
||||
"type".into(),
|
||||
json!({ "type": "string", "description": "change the prefab type" }),
|
||||
);
|
||||
vec![
|
||||
tool("project_info", "Title, folder, levels, parameters, object count and load errors of the project.", json!({}), &[]),
|
||||
tool("list_prefabs", "Object types that can be placed, with their property schemas (type, default, min, max, choices).",
|
||||
json!({ "category": { "type": "string" }, "type": { "type": "string", "description": "one type in full detail" } }), &[]),
|
||||
tool("list_objects", "Objects in the scene with position, props and the world box [x0, y0, z0, x1, y1, z1] each one occupies.",
|
||||
json!({ "type": { "type": "string", "description": "only this type" } }), &[]),
|
||||
tool("get_object", "One object with all fields and its world box.", json!({ "id": { "type": "string" } }), &["id"]),
|
||||
tool("add_object", "Place a new object. Returns its id and world box.", Value::Object(add), &["type", "x", "y"]),
|
||||
tool("update_object", "Change an object: move (x, y, z), turn (rot), edit props, name, info, tag, levels.", Value::Object(upd), &["id"]),
|
||||
tool("remove_object", "Delete an object.", json!({ "id": { "type": "string" } }), &["id"]),
|
||||
tool("duplicate_object", "Copy an object, offset by dx, dy metres (default 2, 2).",
|
||||
json!({ "id": { "type": "string" }, "dx": { "type": "number" }, "dy": { "type": "number" } }), &["id"]),
|
||||
tool("apply_ops", "Several edits as one undo step. Each op is {op: add|update|remove|duplicate|reorder|set_cfg, ...} \
|
||||
(add: same fields as add_object; update: {id, patch: {...}}; reorder: {id, to: front|back|index}).",
|
||||
json!({ "ops": { "type": "array", "items": { "type": "object" } } }), &["ops"]),
|
||||
tool("set_params", "Set project parameter values (see project_info); null resets to the default.",
|
||||
json!({ "values": { "type": "object" } }), &["values"]),
|
||||
tool("render_view", "Render one view. format summary (default): sprite and node counts, warnings, object boxes; \
|
||||
svg: the standalone SVG markup.",
|
||||
json!({ "level": { "type": "string" }, "rotation": { "type": "integer", "minimum": 0, "maximum": 3 },
|
||||
"format": { "type": "string", "enum": ["summary", "svg"] } }), &[]),
|
||||
tool("check_project", "Lint the project: unknown types, bad props, script warnings, NaN, node budget, unstyled classes.", json!({}), &[]),
|
||||
tool("build_html", "Write the self-contained HTML page (all views prerendered). Returns the file path.",
|
||||
json!({ "out": { "type": "string", "description": "path relative to the project (default from isokit.toml)" } }), &[]),
|
||||
tool("undo", "Undo the last edit.", json!({}), &[]),
|
||||
tool("redo", "Redo the last undone edit.", json!({}), &[]),
|
||||
]
|
||||
}
|
||||
|
||||
fn text(v: Value) -> Value {
|
||||
let t = match v {
|
||||
Value::String(s) => s,
|
||||
o => serde_json::to_string_pretty(&o).unwrap_or_default(),
|
||||
};
|
||||
json!({ "content": [{ "type": "text", "text": t }], "isError": false })
|
||||
}
|
||||
|
||||
fn tool_error(msg: impl Into<String>) -> Value {
|
||||
json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true })
|
||||
}
|
||||
|
||||
fn boxes(ws: &Workspace) -> Map<String, Value> {
|
||||
let p = &ws.project;
|
||||
match p.render(&p.manifest.default_level(), 0, None, false) {
|
||||
Ok(v) => v
|
||||
.boxes
|
||||
.into_iter()
|
||||
.map(|(k, b)| (k, json!(b.map(isokit_core::fmt::round1))))
|
||||
.collect(),
|
||||
Err(_) => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn object_json(ws: &Workspace, id: &str, bx: &Map<String, Value>) -> Option<Value> {
|
||||
let o = ws.project.doc.get(id)?;
|
||||
let mut v = serde_json::to_value(o).ok()?;
|
||||
if let Some(b) = bx.get(id) {
|
||||
v["box"] = b.clone();
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn str_arg(a: &Map<String, Value>, k: &str) -> Option<String> {
|
||||
a.get(k).and_then(|v| v.as_str()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Run one tool.
|
||||
pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Value {
|
||||
ws.refresh();
|
||||
match name {
|
||||
"project_info" => {
|
||||
let p = &ws.project;
|
||||
let m = p.meta();
|
||||
text(json!({
|
||||
"title": m.title, "dir": p.dir.display().to_string(), "levels": m.levels, "default_level": m.default_level,
|
||||
"params": m.params, "values": p.cfg(None), "objects": p.doc.objects.len(),
|
||||
"types": m.catalog.iter().map(|c| format!("{} ({})", c.kind, c.label)).collect::<Vec<_>>(),
|
||||
"script_error": p.runtime.as_ref().err().map(|e| e.to_string()),
|
||||
"load_error": ws.load_error,
|
||||
"coordinates": "metres; x east, y north, z up; object x, y = centre of its footprint",
|
||||
}))
|
||||
}
|
||||
"list_prefabs" => {
|
||||
let cat = ws.project.catalog();
|
||||
if let Some(t) = str_arg(a, "type") {
|
||||
return match cat.iter().find(|m| m.kind == t) {
|
||||
Some(m) => text(serde_json::to_value(m).unwrap_or_default()),
|
||||
None => tool_error(format!("no prefab \"{t}\"")),
|
||||
};
|
||||
}
|
||||
let c = str_arg(a, "category");
|
||||
let list: Vec<Value> = cat
|
||||
.iter()
|
||||
.filter(|m| c.as_ref().is_none_or(|c| m.category.eq_ignore_ascii_case(c)))
|
||||
.map(|m| {
|
||||
json!({ "type": m.kind, "label": m.label, "category": m.category, "help": m.help,
|
||||
"props": m.props.iter().map(|p| {
|
||||
let mut d = json!({ "key": p.key, "type": p.kind, "default": p.default });
|
||||
if let Some(x) = p.min { d["min"] = json!(x); }
|
||||
if let Some(x) = p.max { d["max"] = json!(x); }
|
||||
if !p.choices.is_empty() { d["choices"] = json!(p.choices); }
|
||||
if !p.label.is_empty() { d["label"] = json!(p.label); }
|
||||
d
|
||||
}).collect::<Vec<_>>() })
|
||||
})
|
||||
.collect();
|
||||
text(json!(list))
|
||||
}
|
||||
"list_objects" => {
|
||||
let bx = boxes(ws);
|
||||
let t = str_arg(a, "type");
|
||||
let list: Vec<Value> = ws
|
||||
.project
|
||||
.doc
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|o| t.as_ref().is_none_or(|t| &o.kind == t))
|
||||
.filter_map(|o| object_json(ws, &o.id, &bx))
|
||||
.collect();
|
||||
text(json!(list))
|
||||
}
|
||||
"get_object" => {
|
||||
let id = str_arg(a, "id").unwrap_or_default();
|
||||
let bx = boxes(ws);
|
||||
match object_json(ws, &id, &bx) {
|
||||
Some(v) => text(v),
|
||||
None => tool_error(format!("no object \"{id}\"")),
|
||||
}
|
||||
}
|
||||
"add_object" => {
|
||||
let mut op = a.clone();
|
||||
op.insert("op".into(), json!("add"));
|
||||
match serde_json::from_value::<Op>(Value::Object(op)) {
|
||||
Err(e) => tool_error(format!("bad arguments: {e}")),
|
||||
Ok(op) => match ws.apply(op) {
|
||||
Err(e) => tool_error(e),
|
||||
Ok(o) => {
|
||||
let id = o.id.unwrap_or_default();
|
||||
let bx = boxes(ws);
|
||||
let warn: Vec<String> = render_warnings(ws)
|
||||
.into_iter()
|
||||
.filter(|w| w.contains(&format!("object {id}:")))
|
||||
.collect();
|
||||
text(
|
||||
json!({ "id": id, "object": object_json(ws, &id, &bx), "warnings": warn }),
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
"update_object" => {
|
||||
let id = str_arg(a, "id").unwrap_or_default();
|
||||
let mut patch = a.clone();
|
||||
patch.remove("id");
|
||||
match ws.apply(Op::Update {
|
||||
id: id.clone(),
|
||||
patch,
|
||||
}) {
|
||||
Err(e) => tool_error(e),
|
||||
Ok(_) => {
|
||||
let bx = boxes(ws);
|
||||
text(json!({ "object": object_json(ws, &id, &bx) }))
|
||||
}
|
||||
}
|
||||
}
|
||||
"remove_object" => match ws.apply(Op::Remove {
|
||||
id: str_arg(a, "id").unwrap_or_default(),
|
||||
}) {
|
||||
Err(e) => tool_error(e),
|
||||
Ok(_) => text(json!({ "removed": true, "objects": ws.project.doc.objects.len() })),
|
||||
},
|
||||
"duplicate_object" => {
|
||||
let num = |k: &str| a.get(k).and_then(|v| v.as_f64()).unwrap_or(2.0);
|
||||
match ws.apply(Op::Duplicate {
|
||||
id: str_arg(a, "id").unwrap_or_default(),
|
||||
dx: num("dx"),
|
||||
dy: num("dy"),
|
||||
}) {
|
||||
Err(e) => tool_error(e),
|
||||
Ok(o) => text(json!({ "id": o.id })),
|
||||
}
|
||||
}
|
||||
"apply_ops" => {
|
||||
let ops = a
|
||||
.get("ops")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let parsed: Result<Vec<Op>, String> = ops
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| serde_json::from_value::<Op>(v).map_err(|e| format!("op {i}: {e}")))
|
||||
.collect();
|
||||
match parsed.and_then(|l| ws.apply_all(l)) {
|
||||
Err(e) => tool_error(e),
|
||||
Ok(outs) => {
|
||||
text(json!({ "ids": outs.into_iter().map(|o| o.id).collect::<Vec<_>>() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
"set_params" => {
|
||||
let values = a
|
||||
.get("values")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
match ws.apply(Op::SetCfg { values }) {
|
||||
Err(e) => tool_error(e),
|
||||
Ok(_) => text(json!({ "values": ws.project.cfg(None) })),
|
||||
}
|
||||
}
|
||||
"render_view" => {
|
||||
let p = &ws.project;
|
||||
let level = str_arg(a, "level").unwrap_or_else(|| p.manifest.default_level());
|
||||
let rot = a
|
||||
.get("rotation")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(p.manifest.default_rotation as u64) as u8
|
||||
% 4;
|
||||
if str_arg(a, "format").as_deref() == Some("svg") {
|
||||
return match page::svg(p, &level, rot, false) {
|
||||
Ok(s) => text(Value::String(s)),
|
||||
Err(e) => tool_error(e.to_string()),
|
||||
};
|
||||
}
|
||||
match p.render(&level, rot, None, false) {
|
||||
Err(e) => tool_error(e.to_string()),
|
||||
Ok(v) => text(json!({
|
||||
"level": level, "rotation": rot, "sprites": v.sprites, "nodes": v.nodes, "sort_cycles": v.cycles,
|
||||
"render_ms": v.ms, "warnings": v.warnings,
|
||||
"boxes": v.boxes.iter().map(|(k, b)| (k.clone(), json!(b.map(isokit_core::fmt::round1)))).collect::<Map<_, _>>(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
"check_project" => {
|
||||
let f = check(&ws.project);
|
||||
text(json!({ "findings": f, "ok": !f.iter().any(|x| x.level == "error") }))
|
||||
}
|
||||
"build_html" => {
|
||||
let p = &ws.project;
|
||||
let out = str_arg(a, "out")
|
||||
.or_else(|| p.manifest.build.out.clone())
|
||||
.unwrap_or_else(|| "dist/index.html".into());
|
||||
let path = p.dir.join(&out);
|
||||
match page::static_page(p) {
|
||||
Err(e) => tool_error(e.to_string()),
|
||||
Ok(html) => {
|
||||
if let Some(d) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
match std::fs::write(&path, html) {
|
||||
Ok(_) => text(json!({ "path": path.display().to_string() })),
|
||||
Err(e) => tool_error(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"undo" => match ws.undo() {
|
||||
Ok(done) => text(json!({ "undone": done })),
|
||||
Err(e) => tool_error(e),
|
||||
},
|
||||
"redo" => match ws.redo() {
|
||||
Ok(done) => text(json!({ "redone": done })),
|
||||
Err(e) => tool_error(e),
|
||||
},
|
||||
other => tool_error(format!("unknown tool \"{other}\"")),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_warnings(ws: &Workspace) -> Vec<String> {
|
||||
let p = &ws.project;
|
||||
p.render(&p.manifest.default_level(), 0, None, false)
|
||||
.map(|v| v.warnings)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Handle one JSON-RPC message.
|
||||
pub fn handle(ws: &mut Workspace, msg: &Value) -> Option<Value> {
|
||||
let id = msg.get("id").cloned();
|
||||
let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let id = id?; // notifications get no response
|
||||
let result = match method {
|
||||
"initialize" => {
|
||||
let pv = params
|
||||
.get("protocolVersion")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(PROTOCOL)
|
||||
.to_string();
|
||||
json!({ "protocolVersion": pv, "capabilities": { "tools": { "listChanged": false } },
|
||||
"serverInfo": { "name": "isokit", "version": env!("CARGO_PKG_VERSION") },
|
||||
"instructions": INSTRUCTIONS })
|
||||
}
|
||||
"ping" => json!({}),
|
||||
"tools/list" => json!({ "tools": tools() }),
|
||||
"tools/call" => {
|
||||
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let args = params
|
||||
.get("arguments")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
call_tool(ws, name, &args)
|
||||
}
|
||||
"resources/list" => json!({ "resources": [] }),
|
||||
"prompts/list" => json!({ "prompts": [] }),
|
||||
_ => {
|
||||
return Some(
|
||||
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("method not found: {method}") } }),
|
||||
);
|
||||
}
|
||||
};
|
||||
Some(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Edits to the scene document. The Studio and the MCP tools send the same
|
||||
//! JSON operations: `{"op": "add", "type": "tree", "x": 3, "y": 4}`.
|
||||
|
||||
use isokit_script::doc::{Doc, Object, PrefabMeta};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum Op {
|
||||
/// Place a new object of a prefab type.
|
||||
Add {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
x: f64,
|
||||
#[serde(default)]
|
||||
y: f64,
|
||||
#[serde(default)]
|
||||
z: f64,
|
||||
#[serde(default)]
|
||||
rot: f64,
|
||||
#[serde(default)]
|
||||
props: Map<String, Value>,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
info: String,
|
||||
#[serde(default)]
|
||||
tag: String,
|
||||
#[serde(default)]
|
||||
levels: Vec<String>,
|
||||
#[serde(default)]
|
||||
pts: Option<Vec<[f64; 2]>>,
|
||||
/// Wanted id (made unique); default from the type.
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
},
|
||||
/// Change fields of an object; `props` are merged (null removes a key).
|
||||
Update {
|
||||
id: String,
|
||||
patch: Map<String, Value>,
|
||||
},
|
||||
Remove {
|
||||
id: String,
|
||||
},
|
||||
Duplicate {
|
||||
id: String,
|
||||
#[serde(default = "two")]
|
||||
dx: f64,
|
||||
#[serde(default = "two")]
|
||||
dy: f64,
|
||||
},
|
||||
/// Move an object in the drawing order: "front", "back" or an index.
|
||||
Reorder {
|
||||
id: String,
|
||||
to: Value,
|
||||
},
|
||||
/// Set parameter values (null removes the document's value).
|
||||
SetCfg {
|
||||
values: Map<String, Value>,
|
||||
},
|
||||
/// Replace the whole document.
|
||||
Replace {
|
||||
doc: Doc,
|
||||
},
|
||||
}
|
||||
|
||||
fn two() -> f64 {
|
||||
2.0
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct Outcome {
|
||||
/// The object created or changed, if any.
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
fn finite(v: f64, what: &str) -> Result<f64, String> {
|
||||
if v.is_finite() {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(format!("{what} must be a finite number"))
|
||||
}
|
||||
}
|
||||
|
||||
fn norm_rot(r: f64) -> f64 {
|
||||
(((r / 90.0).round() as i64).rem_euclid(4) * 90) as f64
|
||||
}
|
||||
|
||||
/// Check property values against the prefab schema (unknown keys pass:
|
||||
/// project scripts may read them).
|
||||
pub fn check_props(meta: &PrefabMeta, props: &Map<String, Value>) -> Result<(), String> {
|
||||
for (k, v) in props {
|
||||
let Some(d) = meta.prop(k) else { continue };
|
||||
match d.kind.as_str() {
|
||||
"float" | "int" => {
|
||||
let Some(x) = v
|
||||
.as_f64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
else {
|
||||
return Err(format!("{}.{k}: expected a number, got {v}", meta.kind));
|
||||
};
|
||||
if d.min.is_some_and(|m| x < m) || d.max.is_some_and(|m| x > m) {
|
||||
return Err(format!(
|
||||
"{}.{k} = {x} is outside {}..{}",
|
||||
meta.kind,
|
||||
d.min.map(|m| m.to_string()).unwrap_or_default(),
|
||||
d.max.map(|m| m.to_string()).unwrap_or_default()
|
||||
));
|
||||
}
|
||||
}
|
||||
"bool" => {
|
||||
if !v.is_boolean() {
|
||||
return Err(format!(
|
||||
"{}.{k}: expected true or false, got {v}",
|
||||
meta.kind
|
||||
));
|
||||
}
|
||||
}
|
||||
"choice" => {
|
||||
let s = v.as_str().unwrap_or_default();
|
||||
if !d.choices.is_empty() && !d.choices.iter().any(|c| c == s) {
|
||||
return Err(format!(
|
||||
"{}.{k}: \"{s}\" is not one of {}",
|
||||
meta.kind,
|
||||
d.choices.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn str_of(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => String::new(),
|
||||
o => o.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a field patch to an object.
|
||||
pub fn patch_object(
|
||||
o: &mut Object,
|
||||
patch: &Map<String, Value>,
|
||||
meta: Option<&PrefabMeta>,
|
||||
) -> Result<(), String> {
|
||||
for (k, v) in patch {
|
||||
let num = || v.as_f64().ok_or_else(|| format!("{k} must be a number"));
|
||||
match k.as_str() {
|
||||
"x" => o.x = finite(num()?, "x")?,
|
||||
"y" => o.y = finite(num()?, "y")?,
|
||||
"z" => o.z = finite(num()?, "z")?,
|
||||
"rot" => o.rot = norm_rot(finite(num()?, "rot")?),
|
||||
"name" => o.name = str_of(v),
|
||||
"info" => o.info = str_of(v),
|
||||
"tag" => o.tag = str_of(v),
|
||||
"hidden" => o.hidden = v.as_bool().unwrap_or(false),
|
||||
"locked" => o.locked = v.as_bool().unwrap_or(false),
|
||||
"levels" => {
|
||||
o.levels = v.as_array().map(|a| a.iter().map(str_of).filter(|s| !s.is_empty()).collect()).unwrap_or_default()
|
||||
}
|
||||
"pts" => {
|
||||
o.pts = if v.is_null() { None } else { Some(serde_json::from_value(v.clone()).map_err(|e| format!("pts: {e}"))?) }
|
||||
}
|
||||
"props" => {
|
||||
let m = v.as_object().ok_or("props must be an object")?;
|
||||
let mut next = o.props.clone();
|
||||
for (pk, pv) in m {
|
||||
if pv.is_null() {
|
||||
next.remove(pk);
|
||||
} else {
|
||||
next.insert(pk.clone(), pv.clone());
|
||||
}
|
||||
}
|
||||
if let Some(meta) = meta {
|
||||
check_props(meta, &next)?;
|
||||
}
|
||||
o.props = next;
|
||||
}
|
||||
"type" => {
|
||||
let t = str_of(v);
|
||||
if t.is_empty() {
|
||||
return Err("type must not be empty".into());
|
||||
}
|
||||
o.kind = t;
|
||||
}
|
||||
"id" => {}
|
||||
other => return Err(format!("unknown field \"{other}\" (x, y, z, rot, name, info, tag, levels, props, pts, hidden, locked, type)")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply one operation. `catalog` validates types and props.
|
||||
pub fn apply(doc: &mut Doc, catalog: &[PrefabMeta], op: Op) -> Result<Outcome, String> {
|
||||
let meta_of = |k: &str| catalog.iter().find(|m| m.kind == k);
|
||||
match op {
|
||||
Op::Add {
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
rot,
|
||||
props,
|
||||
name,
|
||||
info,
|
||||
tag,
|
||||
levels,
|
||||
pts,
|
||||
id,
|
||||
} => {
|
||||
let Some(meta) = meta_of(&kind) else {
|
||||
let mut known: Vec<&str> = catalog.iter().map(|m| m.kind.as_str()).collect();
|
||||
known.sort();
|
||||
return Err(format!(
|
||||
"unknown type \"{kind}\"; available: {}",
|
||||
known.join(", ")
|
||||
));
|
||||
};
|
||||
check_props(meta, &props)?;
|
||||
let id = doc.free_id(id.as_deref().filter(|s| !s.is_empty()).unwrap_or(&kind));
|
||||
doc.objects.push(Object {
|
||||
id: id.clone(),
|
||||
kind,
|
||||
name,
|
||||
x: finite(x, "x")?,
|
||||
y: finite(y, "y")?,
|
||||
z: finite(z, "z")?,
|
||||
rot: norm_rot(rot),
|
||||
props,
|
||||
info,
|
||||
tag,
|
||||
levels,
|
||||
pts,
|
||||
hidden: false,
|
||||
locked: false,
|
||||
});
|
||||
Ok(Outcome { id: Some(id) })
|
||||
}
|
||||
Op::Update { id, patch } => {
|
||||
let kind = doc
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("no object \"{id}\""))?
|
||||
.kind
|
||||
.clone();
|
||||
let kind = patch
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or(kind);
|
||||
let meta = meta_of(&kind);
|
||||
let o = doc.get_mut(&id).expect("checked");
|
||||
let mut next = o.clone();
|
||||
patch_object(&mut next, &patch, meta)?;
|
||||
*o = next;
|
||||
Ok(Outcome { id: Some(id) })
|
||||
}
|
||||
Op::Remove { id } => {
|
||||
let n = doc.objects.len();
|
||||
doc.objects.retain(|o| o.id != id);
|
||||
if doc.objects.len() == n {
|
||||
return Err(format!("no object \"{id}\""));
|
||||
}
|
||||
Ok(Outcome { id: None })
|
||||
}
|
||||
Op::Duplicate { id, dx, dy } => {
|
||||
let src = doc
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("no object \"{id}\""))?
|
||||
.clone();
|
||||
let base = src
|
||||
.id
|
||||
.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-')
|
||||
.to_string();
|
||||
let nid = doc.free_id(if base.is_empty() { &src.kind } else { &base });
|
||||
let mut o = src;
|
||||
o.id = nid.clone();
|
||||
o.x += dx;
|
||||
o.y += dy;
|
||||
o.locked = false;
|
||||
let at = doc
|
||||
.objects
|
||||
.iter()
|
||||
.position(|q| q.id == id)
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(doc.objects.len());
|
||||
doc.objects.insert(at, o);
|
||||
Ok(Outcome { id: Some(nid) })
|
||||
}
|
||||
Op::Reorder { id, to } => {
|
||||
let i = doc
|
||||
.objects
|
||||
.iter()
|
||||
.position(|o| o.id == id)
|
||||
.ok_or_else(|| format!("no object \"{id}\""))?;
|
||||
let o = doc.objects.remove(i);
|
||||
let n = doc.objects.len();
|
||||
let at = match &to {
|
||||
Value::String(s) if s == "front" => n,
|
||||
Value::String(s) if s == "back" => 0,
|
||||
Value::Number(k) => (k.as_f64().unwrap_or(0.0).max(0.0) as usize).min(n),
|
||||
_ => return Err("to must be \"front\", \"back\" or an index".into()),
|
||||
};
|
||||
doc.objects.insert(at, o);
|
||||
Ok(Outcome { id: Some(id) })
|
||||
}
|
||||
Op::SetCfg { values } => {
|
||||
for (k, v) in values {
|
||||
if v.is_null() {
|
||||
doc.cfg.remove(&k);
|
||||
} else {
|
||||
doc.cfg.insert(k, v);
|
||||
}
|
||||
}
|
||||
Ok(Outcome { id: None })
|
||||
}
|
||||
Op::Replace { doc: d } => {
|
||||
*doc = d;
|
||||
Ok(Outcome { id: None })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use isokit_script::doc::PropDef;
|
||||
use serde_json::json;
|
||||
|
||||
fn catalog() -> Vec<PrefabMeta> {
|
||||
vec![PrefabMeta {
|
||||
kind: "tree".into(),
|
||||
label: "Tree".into(),
|
||||
props: vec![PropDef {
|
||||
key: "r".into(),
|
||||
kind: "float".into(),
|
||||
default: json!(1.6),
|
||||
min: Some(0.5),
|
||||
max: Some(6.0),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
fn op(v: Value) -> Op {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_update_duplicate_remove() {
|
||||
let mut d = Doc::default();
|
||||
let c = catalog();
|
||||
let id = apply(
|
||||
&mut d,
|
||||
&c,
|
||||
op(json!({"op": "add", "type": "tree", "x": 3, "y": 4, "rot": 100})),
|
||||
)
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
assert_eq!(id, "tree");
|
||||
assert_eq!(d.objects[0].rot, 90.0);
|
||||
apply(&mut d, &c, op(json!({"op": "update", "id": "tree", "patch": {"x": 7.5, "props": {"r": 2.0}, "tag": "Oak"}}))).unwrap();
|
||||
assert_eq!(d.objects[0].x, 7.5);
|
||||
assert_eq!(d.objects[0].props["r"], json!(2.0));
|
||||
let dup = apply(&mut d, &c, op(json!({"op": "duplicate", "id": "tree"})))
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
assert_eq!(dup, "tree-2");
|
||||
assert_eq!(d.objects[1].x, 9.5);
|
||||
apply(
|
||||
&mut d,
|
||||
&c,
|
||||
op(json!({"op": "update", "id": "tree", "patch": {"props": {"r": null}}})),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(d.objects[0].props.is_empty());
|
||||
apply(&mut d, &c, op(json!({"op": "remove", "id": "tree"}))).unwrap();
|
||||
assert_eq!(d.objects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_messages_help_the_caller() {
|
||||
let mut d = Doc::default();
|
||||
let c = catalog();
|
||||
let e = apply(&mut d, &c, op(json!({"op": "add", "type": "ufo"}))).unwrap_err();
|
||||
assert!(e.contains("available: tree"), "{e}");
|
||||
let e = apply(
|
||||
&mut d,
|
||||
&c,
|
||||
op(json!({"op": "add", "type": "tree", "props": {"r": 9}})),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(e.contains("outside"), "{e}");
|
||||
apply(&mut d, &c, op(json!({"op": "add", "type": "tree"}))).unwrap();
|
||||
let e = apply(
|
||||
&mut d,
|
||||
&c,
|
||||
op(json!({"op": "update", "id": "tree", "patch": {"colour": 1}})),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(e.contains("unknown field"), "{e}");
|
||||
let e = apply(
|
||||
&mut d,
|
||||
&c,
|
||||
op(json!({"op": "update", "id": "nope", "patch": {}})),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(e.contains("no object"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_and_cfg() {
|
||||
let mut d = Doc::default();
|
||||
let c = catalog();
|
||||
for _ in 0..3 {
|
||||
apply(&mut d, &c, op(json!({"op": "add", "type": "tree"}))).unwrap();
|
||||
}
|
||||
apply(
|
||||
&mut d,
|
||||
&c,
|
||||
op(json!({"op": "reorder", "id": "tree", "to": "front"})),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(d.objects[2].id, "tree");
|
||||
apply(
|
||||
&mut d,
|
||||
&c,
|
||||
op(json!({"op": "set_cfg", "values": {"labels": false}})),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(d.cfg["labels"], json!(false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Export: a self-contained HTML page with every view prerendered, or one
|
||||
//! view as a standalone SVG file.
|
||||
|
||||
use crate::assets;
|
||||
use crate::Project;
|
||||
use isokit_core::fmt::esc;
|
||||
use isokit_script::ScriptError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// JSON safe to put inside `<script type="application/json">`.
|
||||
fn script_json(v: &Value) -> String {
|
||||
v.to_string().replace("</", "<\\/")
|
||||
}
|
||||
|
||||
/// The static page: all levels in all four rotations, no server needed.
|
||||
pub fn static_page(p: &Project) -> Result<String, ScriptError> {
|
||||
let meta = p.meta();
|
||||
let mut views = serde_json::Map::new();
|
||||
for l in &meta.levels {
|
||||
for rot in 0..4u8 {
|
||||
let v = p.render(&l.id, rot, None, false)?;
|
||||
views.insert(
|
||||
format!("{}/{rot}", l.id),
|
||||
json!({ "html": v.html, "info": v.info, "bbox": v.bbox, "focus": v.focus, "north": v.north, "boxes": v.boxes }),
|
||||
);
|
||||
}
|
||||
}
|
||||
let data = json!({
|
||||
"meta": {
|
||||
"title": meta.title, "note": meta.note, "levels": meta.levels,
|
||||
"defaultLevel": meta.default_level, "defaultRotation": meta.default_rotation
|
||||
},
|
||||
"views": views,
|
||||
"cfg": p.cfg(None),
|
||||
});
|
||||
let css = format!(
|
||||
"{}\n{}",
|
||||
assets::get("viewer/isokit-view.css").unwrap_or_default(),
|
||||
p.theme.css()
|
||||
);
|
||||
let page = assets::get("viewer/page.html").unwrap_or_default();
|
||||
Ok(page
|
||||
.replace("{{title}}", &esc(&meta.title))
|
||||
.replace("{{date}}", &today())
|
||||
.replace("/*ISOKIT_CSS*/", &css)
|
||||
.replace("/*ISOKIT_DATA*/", &script_json(&data))
|
||||
.replace(
|
||||
"/*ISOKIT_JS*/",
|
||||
&assets::get("viewer/isokit-view.js").unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
/// One view as a standalone SVG (dark or light scheme, styles inlined).
|
||||
pub fn svg(p: &Project, level: &str, rot: u8, light: bool) -> Result<String, ScriptError> {
|
||||
let v = p.render(level, rot, None, false)?;
|
||||
let css = format!(
|
||||
"{}\n{}",
|
||||
assets::get("viewer/isokit-view.css").unwrap_or_default(),
|
||||
p.theme.css_scoped("svg.iso-svg", Some(light))
|
||||
);
|
||||
let b = v.bbox;
|
||||
let bg = if light { "#e8f4ff" } else { "#0b1428" };
|
||||
Ok(format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"iso-svg\" viewBox=\"{} {} {} {}\" width=\"{}\" height=\"{}\">\
|
||||
<style>{}</style><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{bg}\"/>{}</svg>\n",
|
||||
b[0], b[1], b[2], b[3], (b[2] * 2.0).round(), (b[3] * 2.0).round(), css, b[0], b[1], b[2], b[3], v.html
|
||||
))
|
||||
}
|
||||
|
||||
/// Today's date (UTC) as YYYY-MM-DD, without a date crate.
|
||||
pub fn today() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = (secs / 86_400) as i64;
|
||||
// civil-from-days (H. Hinnant)
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! A project kept loaded across edits: reload on file changes, save after
|
||||
//! every operation, undo and redo. The Studio server and the MCP server
|
||||
//! each hold one.
|
||||
|
||||
use crate::ops::{self, Op, Outcome};
|
||||
use crate::{version, Error, Project};
|
||||
use isokit_script::doc::Doc;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct Workspace {
|
||||
pub dir: PathBuf,
|
||||
pub project: Project,
|
||||
/// Fingerprint of the files the loaded project was read from.
|
||||
pub version: u64,
|
||||
/// Error of the last reload attempt (the previous project stays loaded).
|
||||
pub load_error: Option<String>,
|
||||
undo: Vec<Doc>,
|
||||
redo: Vec<Doc>,
|
||||
}
|
||||
|
||||
const UNDO_DEPTH: usize = 200;
|
||||
|
||||
impl Workspace {
|
||||
pub fn open(dir: impl AsRef<Path>) -> Result<Workspace, Error> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
let project = Project::load(&dir)?;
|
||||
let version = version(&dir);
|
||||
Ok(Workspace {
|
||||
dir,
|
||||
project,
|
||||
version,
|
||||
load_error: None,
|
||||
undo: vec![],
|
||||
redo: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Reload when a project file changed on disk (another editor, an agent,
|
||||
/// a git pull). Returns true when something was reloaded.
|
||||
pub fn refresh(&mut self) -> bool {
|
||||
let v = version(&self.dir);
|
||||
if v == self.version {
|
||||
return false;
|
||||
}
|
||||
self.version = v;
|
||||
match Project::load(&self.dir) {
|
||||
Ok(p) => {
|
||||
if p.doc != self.project.doc {
|
||||
self.push_undo(self.project.doc.clone());
|
||||
}
|
||||
self.project = p;
|
||||
self.load_error = None;
|
||||
}
|
||||
Err(e) => self.load_error = Some(e.to_string()),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn push_undo(&mut self, d: Doc) {
|
||||
self.undo.push(d);
|
||||
if self.undo.len() > UNDO_DEPTH {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
fn save(&mut self) -> Result<(), String> {
|
||||
self.project.save_doc().map_err(|e| e.to_string())?;
|
||||
self.version = version(&self.dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply an operation to the document and save it.
|
||||
pub fn apply(&mut self, op: Op) -> Result<Outcome, String> {
|
||||
self.refresh();
|
||||
let before = self.project.doc.clone();
|
||||
let catalog = self.project.catalog().to_vec();
|
||||
let out = ops::apply(&mut self.project.doc, &catalog, op)?;
|
||||
if self.project.doc != before {
|
||||
self.push_undo(before);
|
||||
self.save()?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Apply several operations as one undo step; stops at the first error
|
||||
/// (earlier ones stay applied and saved).
|
||||
pub fn apply_all(&mut self, list: Vec<Op>) -> Result<Vec<Outcome>, String> {
|
||||
self.refresh();
|
||||
let before = self.project.doc.clone();
|
||||
let catalog = self.project.catalog().to_vec();
|
||||
let mut outs = vec![];
|
||||
let mut err = None;
|
||||
for op in list {
|
||||
match ops::apply(&mut self.project.doc, &catalog, op) {
|
||||
Ok(o) => outs.push(o),
|
||||
Err(e) => {
|
||||
err = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.project.doc != before {
|
||||
self.push_undo(before);
|
||||
self.save()?;
|
||||
}
|
||||
match err {
|
||||
Some(e) => Err(format!("after {} operation(s): {e}", outs.len())),
|
||||
None => Ok(outs),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.undo.is_empty()
|
||||
}
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.redo.is_empty()
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> Result<bool, String> {
|
||||
self.refresh();
|
||||
let Some(prev) = self.undo.pop() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let cur = std::mem::replace(&mut self.project.doc, prev);
|
||||
self.redo.push(cur);
|
||||
self.save()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> Result<bool, String> {
|
||||
self.refresh();
|
||||
let Some(next) = self.redo.pop() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let cur = std::mem::replace(&mut self.project.doc, next);
|
||||
self.undo.push(cur);
|
||||
self.save()?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Integration tests on the demo project: load, render every view, export,
|
||||
//! check, document operations through a Workspace, MCP round trips.
|
||||
|
||||
use isokit_project::mcp;
|
||||
use isokit_project::ops::Op;
|
||||
use isokit_project::workspace::Workspace;
|
||||
use isokit_project::{check, page, Project};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn demo() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo")
|
||||
}
|
||||
|
||||
/// A throwaway copy of the demo (tests edit it).
|
||||
fn copy_demo(name: &str) -> PathBuf {
|
||||
let dst = std::env::temp_dir().join(format!("isokit-test-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dst);
|
||||
fn cp(a: &Path, b: &Path) {
|
||||
std::fs::create_dir_all(b).unwrap();
|
||||
for e in std::fs::read_dir(a).unwrap().flatten() {
|
||||
let p = e.path();
|
||||
let n = e.file_name();
|
||||
if p.is_dir() {
|
||||
if n != "dist" {
|
||||
cp(&p, &b.join(n));
|
||||
}
|
||||
} else {
|
||||
std::fs::copy(&p, b.join(n)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
cp(&demo(), &dst);
|
||||
dst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_renders_every_view_cleanly() {
|
||||
let p = Project::load(demo()).unwrap();
|
||||
assert!(p.runtime.is_ok(), "{:?}", p.runtime.as_ref().err());
|
||||
for l in p.manifest.levels() {
|
||||
for rot in 0..4 {
|
||||
let v = p.render(&l.id, rot, None, false).unwrap();
|
||||
assert!(v.warnings.is_empty(), "{}/{rot}: {:?}", l.id, v.warnings);
|
||||
assert!(!v.html.contains("NaN"));
|
||||
assert!(
|
||||
v.nodes < p.manifest.build.budget,
|
||||
"{}/{rot}: {} nodes",
|
||||
l.id,
|
||||
v.nodes
|
||||
);
|
||||
for o in &p.doc.objects {
|
||||
assert!(
|
||||
v.boxes.contains_key(&o.id),
|
||||
"{} not drawn in {}/{rot}",
|
||||
o.id,
|
||||
l.id
|
||||
);
|
||||
assert!(v.info.contains_key(&o.id), "{} has no tooltip", o.id);
|
||||
}
|
||||
// the script's sign and the project prefab are there
|
||||
assert!(v.html.contains("data-id=\"sign\""));
|
||||
assert!(v.html.contains("data-obj=\"kiosk\""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_passes_the_check() {
|
||||
let p = Project::load(demo()).unwrap();
|
||||
let f = check::check(&p);
|
||||
assert!(f.is_empty(), "{f:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_page_and_svg_export() {
|
||||
let p = Project::load(demo()).unwrap();
|
||||
let html = page::static_page(&p).unwrap();
|
||||
assert!(
|
||||
html.contains("IsoKit.mount") && html.contains("\"site/1\"") && html.contains("\"gf/3\"")
|
||||
);
|
||||
assert!(
|
||||
!html.contains("/*ISOKIT_"),
|
||||
"a placeholder was left in the page"
|
||||
);
|
||||
assert!(
|
||||
html.contains(".iso-brick { --b: var(--iso-brick); }"),
|
||||
"project theme merged"
|
||||
);
|
||||
let svg = page::svg(&p, "site", 1, true).unwrap();
|
||||
assert!(
|
||||
svg.starts_with("<svg") && svg.contains("<style>") && svg.contains("iso-wall")
|
||||
|| svg.contains("iso-render")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_edits_save_and_undo() {
|
||||
let dir = copy_demo("ws");
|
||||
let mut ws = Workspace::open(&dir).unwrap();
|
||||
let n = ws.project.doc.objects.len();
|
||||
let id = ws
|
||||
.apply(
|
||||
serde_json::from_value::<Op>(json!({"op": "add", "type": "tree", "x": 1, "y": 2}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.id
|
||||
.unwrap();
|
||||
// saved to disk
|
||||
let on_disk = Project::load(&dir).unwrap();
|
||||
assert_eq!(on_disk.doc.objects.len(), n + 1);
|
||||
assert!(on_disk.doc.get(&id).is_some());
|
||||
ws.undo().unwrap();
|
||||
assert_eq!(Project::load(&dir).unwrap().doc.objects.len(), n);
|
||||
ws.redo().unwrap();
|
||||
assert_eq!(ws.project.doc.objects.len(), n + 1);
|
||||
// an outside edit (another process) is picked up and can be undone
|
||||
let mut d = on_disk.doc.clone();
|
||||
d.objects.retain(|o| o.kind != "lamp");
|
||||
std::fs::write(
|
||||
dir.join("scene.json"),
|
||||
serde_json::to_string_pretty(&d).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ws.refresh());
|
||||
assert!(ws.project.doc.objects.iter().all(|o| o.kind != "lamp"));
|
||||
ws.undo().unwrap();
|
||||
assert!(ws.project.doc.objects.iter().any(|o| o.kind == "lamp"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn call(ws: &mut Workspace, id: u64, name: &str, args: Value) -> (bool, Value) {
|
||||
let r = mcp::handle(ws, &json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}})).unwrap();
|
||||
let res = &r["result"];
|
||||
let text = res["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
(
|
||||
res["isError"].as_bool().unwrap_or(true),
|
||||
serde_json::from_str(&text).unwrap_or(Value::String(text)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_round_trip() {
|
||||
let dir = copy_demo("mcp");
|
||||
let mut ws = Workspace::open(&dir).unwrap();
|
||||
let init = mcp::handle(&mut ws, &json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).unwrap();
|
||||
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
|
||||
assert!(mcp::handle(
|
||||
&mut ws,
|
||||
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
)
|
||||
.is_none());
|
||||
let list = mcp::handle(
|
||||
&mut ws,
|
||||
&json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
|
||||
)
|
||||
.unwrap();
|
||||
let names: Vec<&str> = list["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t["name"].as_str().unwrap())
|
||||
.collect();
|
||||
for t in [
|
||||
"project_info",
|
||||
"list_prefabs",
|
||||
"add_object",
|
||||
"update_object",
|
||||
"render_view",
|
||||
"check_project",
|
||||
"build_html",
|
||||
] {
|
||||
assert!(names.contains(&t), "{t}");
|
||||
}
|
||||
let (err, info) = call(&mut ws, 3, "project_info", json!({}));
|
||||
assert!(!err && info["objects"].as_u64().unwrap() > 10);
|
||||
let (err, pf) = call(&mut ws, 4, "list_prefabs", json!({"type": "building"}));
|
||||
assert!(
|
||||
!err && pf["props"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p["key"] == "floors")
|
||||
);
|
||||
let (err, added) = call(
|
||||
&mut ws,
|
||||
5,
|
||||
"add_object",
|
||||
json!({"type": "building", "x": 20, "y": 15, "props": {"w": 8, "d": 6, "floors": 1}, "name": "Store"}),
|
||||
);
|
||||
assert!(!err, "{added}");
|
||||
let id = added["id"].as_str().unwrap().to_string();
|
||||
let b = added["object"]["box"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
(b[0].as_f64().unwrap(), b[3].as_f64().unwrap()),
|
||||
(16.0, 24.0)
|
||||
);
|
||||
let (err, upd) = call(
|
||||
&mut ws,
|
||||
6,
|
||||
"update_object",
|
||||
json!({"id": id, "rot": 90, "props": {"roof": "gable"}}),
|
||||
);
|
||||
assert!(!err, "{upd}");
|
||||
assert_eq!(upd["object"]["rot"], 90.0);
|
||||
let (err, bad) = call(
|
||||
&mut ws,
|
||||
7,
|
||||
"update_object",
|
||||
json!({"id": id, "props": {"floors": 99}}),
|
||||
);
|
||||
assert!(err && bad.as_str().unwrap().contains("outside"), "{bad}");
|
||||
let (err, ops) = call(
|
||||
&mut ws,
|
||||
8,
|
||||
"apply_ops",
|
||||
json!({"ops": [
|
||||
{"op": "add", "type": "tree", "x": 0, "y": 18},
|
||||
{"op": "add", "type": "tree", "x": 4, "y": 18},
|
||||
{"op": "remove", "id": "tree-1"}
|
||||
]}),
|
||||
);
|
||||
assert!(!err, "{ops}");
|
||||
let (err, view) = call(
|
||||
&mut ws,
|
||||
9,
|
||||
"render_view",
|
||||
json!({"level": "gf", "rotation": 2}),
|
||||
);
|
||||
assert!(!err && view["boxes"].get(&id).is_some(), "{view}");
|
||||
let (err, svg) = call(&mut ws, 10, "render_view", json!({"format": "svg"}));
|
||||
assert!(!err && svg.as_str().unwrap().starts_with("<svg"));
|
||||
let (err, chk) = call(&mut ws, 11, "check_project", json!({}));
|
||||
assert!(!err && chk["ok"] == true, "{chk}");
|
||||
let (err, built) = call(&mut ws, 12, "build_html", json!({}));
|
||||
assert!(!err && Path::new(built["path"].as_str().unwrap()).exists());
|
||||
let (err, _) = call(&mut ws, 13, "undo", json!({}));
|
||||
assert!(!err);
|
||||
assert!(
|
||||
ws.project.doc.get("tree-1").is_some(),
|
||||
"undo brings back the batch"
|
||||
);
|
||||
let unknown = mcp::handle(
|
||||
&mut ws,
|
||||
&json!({"jsonrpc": "2.0", "id": 14, "method": "nope"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(unknown["error"]["code"], -32601);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffolded_project_works() {
|
||||
let dir = std::env::temp_dir().join(format!("isokit-test-new-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
isokit_project::scaffold(&dir, "New site").unwrap();
|
||||
let p = Project::load(&dir).unwrap();
|
||||
assert_eq!(p.meta().title, "New site");
|
||||
let f = check::check(&p);
|
||||
assert!(f.is_empty(), "{f:?}");
|
||||
assert!(isokit_project::scaffold(&dir, "again").is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
+485
-186
@@ -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);
|
||||
|
||||
@@ -59,7 +59,9 @@ pub fn pts3(d: &Dynamic) -> RR<Vec<P3>> {
|
||||
}
|
||||
|
||||
fn is_point(d: &Dynamic) -> bool {
|
||||
d.read_lock::<Array>().map(|a| !a.is_empty() && (a[0].is_int() || a[0].is_float())).unwrap_or(false)
|
||||
d.read_lock::<Array>()
|
||||
.map(|a| !a.is_empty() && (a[0].is_int() || a[0].is_float()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// One polygon `[[x, y], ...]` or several `[[[x, y], ...], ...]`.
|
||||
@@ -114,18 +116,27 @@ pub fn rect4(d: &Dynamic) -> RR<[f64; 4]> {
|
||||
/// A height: one number or one per vertex.
|
||||
pub fn zs(d: &Dynamic) -> RR<Zs> {
|
||||
if d.is_array() {
|
||||
Ok(Zs::Each(arr(d, "heights")?.iter().map(n).collect::<RR<Vec<f64>>>()?))
|
||||
Ok(Zs::Each(
|
||||
arr(d, "heights")?.iter().map(n).collect::<RR<Vec<f64>>>()?,
|
||||
))
|
||||
} else {
|
||||
Ok(Zs::One(n(d)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_str(m: &Map, k: &str) -> Option<String> {
|
||||
m.get(k).and_then(|v| if v.is_unit() { None } else { Some(v.to_string()) })
|
||||
m.get(k).and_then(|v| {
|
||||
if v.is_unit() {
|
||||
None
|
||||
} else {
|
||||
Some(v.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_bool(m: &Map, keys: &[&str]) -> bool {
|
||||
keys.iter().any(|k| m.get(*k).and_then(|v| v.as_bool().ok()).unwrap_or(false))
|
||||
keys.iter()
|
||||
.any(|k| m.get(*k).and_then(|v| v.as_bool().ok()).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Style from a material and an optional options map
|
||||
|
||||
@@ -129,13 +129,27 @@ impl PrefabMeta {
|
||||
pub fn resolve(&self, props: &Map<String, Value>) -> Map<String, Value> {
|
||||
let mut out = props.clone();
|
||||
for d in &self.props {
|
||||
let v = out.get(&d.key).cloned().unwrap_or_else(|| d.default.clone());
|
||||
let v = out
|
||||
.get(&d.key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| d.default.clone());
|
||||
let v = match (d.kind.as_str(), &v) {
|
||||
("float", Value::Number(n)) => n.as_f64().map(Value::from).unwrap_or(v),
|
||||
("float", Value::String(s)) => s.parse::<f64>().map(Value::from).unwrap_or(d.default.clone()),
|
||||
("int", Value::Number(n)) => n.as_f64().map(|f| Value::from(f.round() as i64)).unwrap_or(v),
|
||||
("int", Value::String(s)) => s.parse::<f64>().map(|f| Value::from(f.round() as i64)).unwrap_or(d.default.clone()),
|
||||
("bool", Value::String(s)) => Value::Bool(matches!(s.as_str(), "true" | "1" | "yes" | "on")),
|
||||
("float", Value::String(s)) => s
|
||||
.parse::<f64>()
|
||||
.map(Value::from)
|
||||
.unwrap_or(d.default.clone()),
|
||||
("int", Value::Number(n)) => n
|
||||
.as_f64()
|
||||
.map(|f| Value::from(f.round() as i64))
|
||||
.unwrap_or(v),
|
||||
("int", Value::String(s)) => s
|
||||
.parse::<f64>()
|
||||
.map(|f| Value::from(f.round() as i64))
|
||||
.unwrap_or(d.default.clone()),
|
||||
("bool", Value::String(s)) => {
|
||||
Value::Bool(matches!(s.as_str(), "true" | "1" | "yes" | "on"))
|
||||
}
|
||||
("bool", Value::Number(n)) => Value::Bool(n.as_f64().unwrap_or(0.0) != 0.0),
|
||||
_ => v,
|
||||
};
|
||||
@@ -156,13 +170,26 @@ impl Doc {
|
||||
pub fn free_id(&self, base: &str) -> String {
|
||||
let base: String = base
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c.to_ascii_lowercase() } else { '-' })
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let base = if base.is_empty() { "obj".to_string() } else { base };
|
||||
let base = if base.is_empty() {
|
||||
"obj".to_string()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
if self.get(&base).is_none() {
|
||||
return base;
|
||||
}
|
||||
(2..).map(|i| format!("{base}-{i}")).find(|id| self.get(id).is_none()).unwrap()
|
||||
(2..)
|
||||
.map(|i| format!("{base}-{i}"))
|
||||
.find(|id| self.get(id).is_none())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,19 +200,38 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn object_round_trips_compactly() {
|
||||
let o: Object = serde_json::from_value(json!({"id": "t1", "type": "tree", "x": 3, "y": 4.5})).unwrap();
|
||||
let o: Object =
|
||||
serde_json::from_value(json!({"id": "t1", "type": "tree", "x": 3, "y": 4.5})).unwrap();
|
||||
assert_eq!(o.kind, "tree");
|
||||
let back = serde_json::to_value(&o).unwrap();
|
||||
assert_eq!(back, json!({"id": "t1", "type": "tree", "x": 3.0, "y": 4.5}));
|
||||
assert_eq!(
|
||||
back,
|
||||
json!({"id": "t1", "type": "tree", "x": 3.0, "y": 4.5})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_and_coercion() {
|
||||
let m = PrefabMeta {
|
||||
props: vec![
|
||||
PropDef { key: "r".into(), kind: "float".into(), default: json!(1.5), ..Default::default() },
|
||||
PropDef { key: "n".into(), kind: "int".into(), default: json!(2), ..Default::default() },
|
||||
PropDef { key: "on".into(), kind: "bool".into(), default: json!(false), ..Default::default() },
|
||||
PropDef {
|
||||
key: "r".into(),
|
||||
kind: "float".into(),
|
||||
default: json!(1.5),
|
||||
..Default::default()
|
||||
},
|
||||
PropDef {
|
||||
key: "n".into(),
|
||||
kind: "int".into(),
|
||||
default: json!(2),
|
||||
..Default::default()
|
||||
},
|
||||
PropDef {
|
||||
key: "on".into(),
|
||||
kind: "bool".into(),
|
||||
default: json!(false),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
@@ -199,8 +245,16 @@ mod tests {
|
||||
fn free_ids() {
|
||||
let mut d = Doc::default();
|
||||
assert_eq!(d.free_id("Tree"), "tree");
|
||||
d.objects.push(Object { id: "tree".into(), kind: "tree".into(), ..Default::default() });
|
||||
d.objects.push(Object { id: "tree-2".into(), kind: "tree".into(), ..Default::default() });
|
||||
d.objects.push(Object {
|
||||
id: "tree".into(),
|
||||
kind: "tree".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.objects.push(Object {
|
||||
id: "tree-2".into(),
|
||||
kind: "tree".into(),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(d.free_id("tree"), "tree-3");
|
||||
}
|
||||
}
|
||||
+120
-33
@@ -77,14 +77,20 @@ fn to_error(e: &EvalAltResult, file: &str) -> ScriptError {
|
||||
loop {
|
||||
match cur {
|
||||
EvalAltResult::ErrorInFunctionCall(name, src, inner, p) => {
|
||||
trace.push(format!("in {name}() called at {file}:{}", p.line().unwrap_or(0)));
|
||||
trace.push(format!(
|
||||
"in {name}() called at {file}:{}",
|
||||
p.line().unwrap_or(0)
|
||||
));
|
||||
if !src.is_empty() {
|
||||
file = src.clone();
|
||||
}
|
||||
cur = inner;
|
||||
}
|
||||
EvalAltResult::ErrorInModule(path, inner, p) => {
|
||||
trace.push(format!("in module {path} imported at {file}:{}", p.line().unwrap_or(0)));
|
||||
trace.push(format!(
|
||||
"in module {path} imported at {file}:{}",
|
||||
p.line().unwrap_or(0)
|
||||
));
|
||||
file = path.clone();
|
||||
cur = inner;
|
||||
}
|
||||
@@ -96,7 +102,13 @@ fn to_error(e: &EvalAltResult, file: &str) -> ScriptError {
|
||||
if let Some(i) = message.rfind(" (line ") {
|
||||
message.truncate(i);
|
||||
}
|
||||
ScriptError { message, file, line: pos.line(), col: pos.position(), trace }
|
||||
ScriptError {
|
||||
message,
|
||||
file,
|
||||
line: pos.line(),
|
||||
col: pos.position(),
|
||||
trace,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared state of one build, read and written by the registered functions.
|
||||
@@ -241,16 +253,38 @@ fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
|
||||
b.obj_start = b.sprites.len();
|
||||
let kind = g("type").to_string();
|
||||
let name = g("name").to_string();
|
||||
let label = b.catalog.get(&kind).map(|m| m.label.clone()).unwrap_or_else(|| kind.clone());
|
||||
let name = if name.is_empty() || g("name").is_unit() { label } else { name };
|
||||
let detail = if g("info").is_unit() { String::new() } else { g("info").to_string() };
|
||||
let label = b
|
||||
.catalog
|
||||
.get(&kind)
|
||||
.map(|m| m.label.clone())
|
||||
.unwrap_or_else(|| kind.clone());
|
||||
let name = if name.is_empty() || g("name").is_unit() {
|
||||
label
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let detail = if g("info").is_unit() {
|
||||
String::new()
|
||||
} else {
|
||||
g("info").to_string()
|
||||
};
|
||||
b.scene.info(id, name, detail);
|
||||
if !b.catalog.contains_key(&kind) {
|
||||
b.scene.warnings.push(format!("object {}: unknown type \"{kind}\"", g("id")));
|
||||
b.scene
|
||||
.warnings
|
||||
.push(format!("object {}: unknown type \"{kind}\"", g("id")));
|
||||
drop(b);
|
||||
// a placeholder so the object can still be found and fixed
|
||||
let sp = api::new_sprite(&s, None, None, "");
|
||||
sp.0.borrow_mut().cuboid(-0.5, -0.5, 0.5, 0.5, 0.0, 1.0, isokit_core::Style::mat("iso-warn"));
|
||||
sp.0.borrow_mut().cuboid(
|
||||
-0.5,
|
||||
-0.5,
|
||||
0.5,
|
||||
0.5,
|
||||
0.0,
|
||||
1.0,
|
||||
isokit_core::Style::mat("iso-warn"),
|
||||
);
|
||||
s.borrow_mut().obj = None;
|
||||
s.borrow_mut().xf = Xf::default();
|
||||
return false;
|
||||
@@ -273,7 +307,13 @@ fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
|
||||
if bx[0] <= bx[3] {
|
||||
let tag = o.get("tag").map(|v| v.to_string()).unwrap_or_default();
|
||||
if !tag.is_empty() && o.get("tag").is_some_and(|v| !v.is_unit()) {
|
||||
b.scene.tag([(bx[0] + bx[3]) / 2.0, (bx[1] + bx[4]) / 2.0, bx[5] + 0.6], tag, 6.0, id.clone(), "");
|
||||
b.scene.tag(
|
||||
[(bx[0] + bx[3]) / 2.0, (bx[1] + bx[4]) / 2.0, bx[5] + 0.6],
|
||||
tag,
|
||||
6.0,
|
||||
id.clone(),
|
||||
"",
|
||||
);
|
||||
}
|
||||
b.boxes.insert(id, bx);
|
||||
}
|
||||
@@ -291,7 +331,10 @@ fn register_driver(e: &mut Engine, st: &Rc<RefCell<State>>) {
|
||||
None => e.to_string(),
|
||||
};
|
||||
let id = o.get("id").map(|v| v.to_string()).unwrap_or_default();
|
||||
s.borrow_mut().scene.warnings.push(format!("object {id}: {msg}"));
|
||||
s.borrow_mut()
|
||||
.scene
|
||||
.warnings
|
||||
.push(format!("object {id}: {msg}"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,14 +348,19 @@ impl Runtime {
|
||||
engine.set_max_operations(80_000_000);
|
||||
engine.set_max_call_levels(64);
|
||||
engine.set_max_expr_depths(512, 512);
|
||||
engine.set_module_resolver(SourceResolver { sources: Rc::new(bundle.sources.clone()) });
|
||||
engine.set_module_resolver(SourceResolver {
|
||||
sources: Rc::new(bundle.sources.clone()),
|
||||
});
|
||||
api::register_sprite(&mut engine);
|
||||
api::register_scene(&mut engine, &state);
|
||||
register_driver(&mut engine, &state);
|
||||
|
||||
// global modules: built-in library, then the project's prelude
|
||||
let mut asts: Vec<(String, AST)> = vec![];
|
||||
let mut mods: Vec<(String, String)> = stdlib::MODULES.iter().map(|(n, s)| (n.to_string(), s.to_string())).collect();
|
||||
let mut mods: Vec<(String, String)> = stdlib::MODULES
|
||||
.iter()
|
||||
.map(|(n, s)| (n.to_string(), s.to_string()))
|
||||
.collect();
|
||||
for p in &bundle.prelude {
|
||||
let src = bundle.sources.get(p).ok_or_else(|| ScriptError {
|
||||
message: format!("prelude module {p} not found"),
|
||||
@@ -324,7 +372,8 @@ impl Runtime {
|
||||
for (name, src) in &mods {
|
||||
let mut ast = engine.compile(src).map_err(|e| to_error(&e.into(), name))?;
|
||||
ast.set_source(name.as_str());
|
||||
let m = Module::eval_ast_as_new(Scope::new(), &ast, &engine).map_err(|e| to_error(&e, name))?;
|
||||
let m = Module::eval_ast_as_new(Scope::new(), &ast, &engine)
|
||||
.map_err(|e| to_error(&e, name))?;
|
||||
engine.register_global_module(m.into());
|
||||
asts.push((name.clone(), ast));
|
||||
}
|
||||
@@ -344,24 +393,32 @@ impl Runtime {
|
||||
None => AST::empty(),
|
||||
};
|
||||
asts.push((main.clone(), ast.clone()));
|
||||
let driver = engine.compile(DRIVER).map_err(|e| to_error(&e.into(), "driver"))?;
|
||||
let driver = engine
|
||||
.compile(DRIVER)
|
||||
.map_err(|e| to_error(&e.into(), "driver"))?;
|
||||
ast = ast.merge(&driver);
|
||||
ast.set_source(main.as_str());
|
||||
|
||||
// prefab catalog: prefab_<type>(o) with an optional meta_<type>()
|
||||
let mut catalog = vec![];
|
||||
for (name, a) in &asts {
|
||||
let mut catalog: Vec<(usize, PrefabMeta)> = vec![];
|
||||
for (mi, (name, a)) in asts.iter().enumerate() {
|
||||
for f in a.iter_functions() {
|
||||
let Some(kind) = f.name.strip_prefix("prefab_") else { continue };
|
||||
let Some(kind) = f.name.strip_prefix("prefab_") else {
|
||||
continue;
|
||||
};
|
||||
if f.params.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
let meta_fn = format!("meta_{kind}");
|
||||
let mut meta = if a.iter_functions().any(|g| g.name == meta_fn && g.params.is_empty()) {
|
||||
let mut meta = if a
|
||||
.iter_functions()
|
||||
.any(|g| g.name == meta_fn && g.params.is_empty())
|
||||
{
|
||||
let v: Dynamic = engine
|
||||
.call_fn(&mut Scope::new(), a, &meta_fn, ())
|
||||
.map_err(|e| to_error(&e, name))?;
|
||||
let j: serde_json::Value = rhai::serde::from_dynamic(&v).map_err(|e| to_error(&e, name))?;
|
||||
let j: serde_json::Value =
|
||||
rhai::serde::from_dynamic(&v).map_err(|e| to_error(&e, name))?;
|
||||
serde_json::from_value::<PrefabMeta>(j).map_err(|e| ScriptError {
|
||||
message: format!("{meta_fn}(): {e}"),
|
||||
file: name.clone(),
|
||||
@@ -378,12 +435,26 @@ impl Runtime {
|
||||
meta.category = "Other".into();
|
||||
}
|
||||
meta.source = name.clone();
|
||||
catalog.retain(|m: &PrefabMeta| m.kind != meta.kind); // later modules override
|
||||
catalog.push(meta);
|
||||
catalog.retain(|(_, m)| m.kind != meta.kind); // later modules override
|
||||
catalog.push((mi, meta));
|
||||
}
|
||||
}
|
||||
state.borrow_mut().catalog = catalog.iter().map(|m| (m.kind.clone(), m.clone())).collect();
|
||||
Ok(Runtime { engine, ast, state, catalog, main })
|
||||
// stable palette order: library modules first, then by category and label
|
||||
catalog.sort_by(|(ia, a), (ib, b)| {
|
||||
(ia, &a.category, &a.label).cmp(&(ib, &b.category, &b.label))
|
||||
});
|
||||
let catalog: Vec<PrefabMeta> = catalog.into_iter().map(|(_, m)| m).collect();
|
||||
state.borrow_mut().catalog = catalog
|
||||
.iter()
|
||||
.map(|m| (m.kind.clone(), m.clone()))
|
||||
.collect();
|
||||
Ok(Runtime {
|
||||
engine,
|
||||
ast,
|
||||
state,
|
||||
catalog,
|
||||
main,
|
||||
})
|
||||
}
|
||||
|
||||
/// Object types available to the document.
|
||||
@@ -397,7 +468,13 @@ impl Runtime {
|
||||
|
||||
/// Build the scene for a level and rotation. `cfg` holds the parameter
|
||||
/// values (already merged with the defaults).
|
||||
pub fn build(&self, level: &str, rot: u8, cfg: &serde_json::Map<String, serde_json::Value>, doc: &Doc) -> Result<Built, ScriptError> {
|
||||
pub fn build(
|
||||
&self,
|
||||
level: &str,
|
||||
rot: u8,
|
||||
cfg: &serde_json::Map<String, serde_json::Value>,
|
||||
doc: &Doc,
|
||||
) -> Result<Built, ScriptError> {
|
||||
{
|
||||
let mut b = self.state.borrow_mut();
|
||||
let catalog = std::mem::take(&mut b.catalog);
|
||||
@@ -407,23 +484,28 @@ impl Runtime {
|
||||
b.main = main;
|
||||
b.level = level.to_string();
|
||||
b.proj = Proj::new(rot as i64);
|
||||
b.cfg = match dyn_json(&serde_json::Value::Object(cfg.clone())).try_cast::<Map>() {
|
||||
Some(m) => m,
|
||||
None => Map::new(),
|
||||
};
|
||||
b.cfg = dyn_json(&serde_json::Value::Object(cfg.clone()))
|
||||
.try_cast::<Map>()
|
||||
.unwrap_or_default();
|
||||
for o in &doc.objects {
|
||||
let mut j = serde_json::to_value(o).unwrap_or_default();
|
||||
let meta = b.catalog.get(&o.kind);
|
||||
let props = meta.map(|m| m.resolve(&o.props)).unwrap_or_else(|| o.props.clone());
|
||||
let props = meta
|
||||
.map(|m| m.resolve(&o.props))
|
||||
.unwrap_or_else(|| o.props.clone());
|
||||
if let Some(m) = j.as_object_mut() {
|
||||
for k in ["name", "info", "tag"] {
|
||||
m.entry(k).or_insert(serde_json::Value::String(String::new()));
|
||||
m.entry(k)
|
||||
.or_insert(serde_json::Value::String(String::new()));
|
||||
}
|
||||
for k in ["z", "rot"] {
|
||||
m.entry(k).or_insert(serde_json::Value::from(0.0));
|
||||
}
|
||||
m.insert("p".into(), serde_json::Value::Object(props));
|
||||
m.insert("__fn".into(), serde_json::Value::String(format!("prefab_{}", o.kind)));
|
||||
m.insert(
|
||||
"__fn".into(),
|
||||
serde_json::Value::String(format!("prefab_{}", o.kind)),
|
||||
);
|
||||
m.insert("level".into(), serde_json::Value::String(level.into()));
|
||||
}
|
||||
let d = dyn_json(&j);
|
||||
@@ -445,11 +527,16 @@ impl Runtime {
|
||||
let mut sp = std::mem::take(&mut *h.0.borrow_mut());
|
||||
let open = sp.finish();
|
||||
if open > 0 {
|
||||
scene.warnings.push(format!("sprite {}: {open} group(s) left open", sp.id));
|
||||
scene
|
||||
.warnings
|
||||
.push(format!("sprite {}: {open} group(s) left open", sp.id));
|
||||
}
|
||||
scene.add_sprite(&layer, sp);
|
||||
}
|
||||
Ok(Built { scene, boxes: std::mem::take(&mut b.boxes) })
|
||||
Ok(Built {
|
||||
scene,
|
||||
boxes: std::mem::take(&mut b.boxes),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ use isokit_core::{render, RenderOpts};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
fn rt(main: &str) -> Runtime {
|
||||
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() };
|
||||
let mut b = Bundle {
|
||||
main: Some("scene.rhai".into()),
|
||||
..Default::default()
|
||||
};
|
||||
b.sources.insert("scene.rhai".into(), main.into());
|
||||
Runtime::new(&b).unwrap_or_else(|e| panic!("{e}"))
|
||||
}
|
||||
@@ -16,7 +19,14 @@ fn cfg() -> Map<String, Value> {
|
||||
}
|
||||
|
||||
fn obj(id: &str, kind: &str, x: f64, y: f64, props: Value) -> Object {
|
||||
Object { id: id.into(), kind: kind.into(), x, y, props: props.as_object().cloned().unwrap_or_default(), ..Default::default() }
|
||||
Object {
|
||||
id: id.into(),
|
||||
kind: kind.into(),
|
||||
x,
|
||||
y,
|
||||
props: props.as_object().cloned().unwrap_or_default(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -59,17 +69,29 @@ fn seq_group_and_closures() {
|
||||
let b = r.build("site", 0, &cfg(), &Doc::default()).unwrap();
|
||||
let out = render(&b.scene, 0, &RenderOpts::default());
|
||||
// from the south-east the box at x = 5 is nearer, so it is drawn after the one at x = 0
|
||||
let (a, c) = (out.html.find("iso-wall").unwrap(), out.html.find("iso-roof").unwrap());
|
||||
let (a, c) = (
|
||||
out.html.find("iso-wall").unwrap(),
|
||||
out.html.find("iso-roof").unwrap(),
|
||||
);
|
||||
assert!(a < c);
|
||||
assert!(out.html.contains("<g class=\"iso-a iso-drive\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_point_at_the_line() {
|
||||
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() };
|
||||
b.sources.insert("scene.rhai".into(), "let s = sprite(\"a\");\n\ns.box(0, 0, 1, 1, 0, \"high\", \"wall\");\n".into());
|
||||
let mut b = Bundle {
|
||||
main: Some("scene.rhai".into()),
|
||||
..Default::default()
|
||||
};
|
||||
b.sources.insert(
|
||||
"scene.rhai".into(),
|
||||
"let s = sprite(\"a\");\n\ns.box(0, 0, 1, 1, 0, \"high\", \"wall\");\n".into(),
|
||||
);
|
||||
let r = Runtime::new(&b).unwrap();
|
||||
let e = r.build("site", 0, &cfg(), &Doc::default()).err().expect("error");
|
||||
let e = r
|
||||
.build("site", 0, &cfg(), &Doc::default())
|
||||
.err()
|
||||
.expect("error");
|
||||
assert_eq!(e.file, "scene.rhai");
|
||||
assert_eq!(e.line, Some(3), "{e}");
|
||||
assert!(e.message.contains("number"), "{e}");
|
||||
@@ -81,9 +103,18 @@ fn errors_point_at_the_line() {
|
||||
|
||||
#[test]
|
||||
fn imports_resolve_from_the_bundle() {
|
||||
let mut b = Bundle { main: Some("scene.rhai".into()), ..Default::default() };
|
||||
b.sources.insert("lib/kiosk.rhai".into(), "fn kiosk(x, y) { sprite(\"kiosk\").box(x, y, x + 2, y + 2, 0, 2.5, \"wall\"); }".into());
|
||||
b.sources.insert("scene.rhai".into(), "import \"lib/kiosk\" as k;\nk::kiosk(1, 2);".into());
|
||||
let mut b = Bundle {
|
||||
main: Some("scene.rhai".into()),
|
||||
..Default::default()
|
||||
};
|
||||
b.sources.insert(
|
||||
"lib/kiosk.rhai".into(),
|
||||
"fn kiosk(x, y) { sprite(\"kiosk\").box(x, y, x + 2, y + 2, 0, 2.5, \"wall\"); }".into(),
|
||||
);
|
||||
b.sources.insert(
|
||||
"scene.rhai".into(),
|
||||
"import \"lib/kiosk\" as k;\nk::kiosk(1, 2);".into(),
|
||||
);
|
||||
let r = Runtime::new(&b).unwrap();
|
||||
let s = r.build("site", 0, &cfg(), &Doc::default()).unwrap().scene;
|
||||
assert_eq!(s.sprite_count(), 1);
|
||||
@@ -91,7 +122,11 @@ fn imports_resolve_from_the_bundle() {
|
||||
|
||||
#[test]
|
||||
fn catalog_lists_library_and_project_prefabs() {
|
||||
let mut b = Bundle { main: None, prelude: vec!["lib/kiosk.rhai".into()], ..Default::default() };
|
||||
let mut b = Bundle {
|
||||
main: None,
|
||||
prelude: vec!["lib/kiosk.rhai".into()],
|
||||
..Default::default()
|
||||
};
|
||||
b.sources.insert(
|
||||
"lib/kiosk.rhai".into(),
|
||||
r#"
|
||||
@@ -102,7 +137,9 @@ fn catalog_lists_library_and_project_prefabs() {
|
||||
);
|
||||
let r = Runtime::new(&b).unwrap();
|
||||
let kinds: Vec<&str> = r.catalog().iter().map(|m| m.kind.as_str()).collect();
|
||||
for k in ["site", "area", "road", "building", "tree", "car", "person", "tank", "kiosk"] {
|
||||
for k in [
|
||||
"site", "area", "road", "building", "tree", "car", "person", "tank", "kiosk",
|
||||
] {
|
||||
assert!(kinds.contains(&k), "{k} missing from {kinds:?}");
|
||||
}
|
||||
let k = r.prefab("kiosk").unwrap();
|
||||
@@ -115,7 +152,13 @@ fn catalog_lists_library_and_project_prefabs() {
|
||||
fn document_objects_are_placed_in_their_frame() {
|
||||
let r = Runtime::new(&Bundle::default()).unwrap();
|
||||
let mut doc = Doc::default();
|
||||
let mut b = obj("hall", "building", 30.0, 20.0, json!({"w": 10, "d": 6, "floors": 1}));
|
||||
let mut b = obj(
|
||||
"hall",
|
||||
"building",
|
||||
30.0,
|
||||
20.0,
|
||||
json!({"w": 10, "d": 6, "floors": 1}),
|
||||
);
|
||||
b.rot = 90.0;
|
||||
b.name = "Main hall".into();
|
||||
b.tag = "Hall".into();
|
||||
@@ -123,23 +166,37 @@ fn document_objects_are_placed_in_their_frame() {
|
||||
doc.objects.push(obj("t1", "tree", 5.0, 5.0, json!({})));
|
||||
let built = r.build("roof", 1, &cfg(), &doc).unwrap();
|
||||
// turned a quarter: 6 m along x, 10 m along y, centred on (30, 20)
|
||||
let bx = *built.boxes.get("hall").unwrap_or_else(|| panic!("{:?}", built.scene.warnings));
|
||||
assert!((bx[0] - 27.0).abs() < 1e-9 && (bx[3] - 33.0).abs() < 1e-9, "{bx:?}");
|
||||
assert!((bx[1] - 15.0).abs() < 1e-9 && (bx[4] - 25.0).abs() < 1e-9, "{bx:?}");
|
||||
let bx = *built
|
||||
.boxes
|
||||
.get("hall")
|
||||
.unwrap_or_else(|| panic!("{:?}", built.scene.warnings));
|
||||
assert!(
|
||||
(bx[0] - 27.0).abs() < 1e-9 && (bx[3] - 33.0).abs() < 1e-9,
|
||||
"{bx:?}"
|
||||
);
|
||||
assert!(
|
||||
(bx[1] - 15.0).abs() < 1e-9 && (bx[4] - 25.0).abs() < 1e-9,
|
||||
"{bx:?}"
|
||||
);
|
||||
let out = render(&built.scene, 1, &RenderOpts::default());
|
||||
assert!(out.html.contains("data-obj=\"hall\""));
|
||||
assert!(out.html.contains("data-obj=\"t1\""));
|
||||
assert_eq!(out.info["hall"].name, "Main hall");
|
||||
assert_eq!(out.info["t1"].name, "Tree");
|
||||
assert!(out.html.contains(">Hall<"));
|
||||
assert!(built.scene.warnings.is_empty(), "{:?}", built.scene.warnings);
|
||||
assert!(
|
||||
built.scene.warnings.is_empty(),
|
||||
"{:?}",
|
||||
built.scene.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_cut_buildings_open_and_filter_objects() {
|
||||
let r = Runtime::new(&Bundle::default()).unwrap();
|
||||
let mut doc = Doc::default();
|
||||
doc.objects.push(obj("b", "building", 0.0, 0.0, json!({"floors": 2})));
|
||||
doc.objects
|
||||
.push(obj("b", "building", 0.0, 0.0, json!({"floors": 2})));
|
||||
let mut desk = obj("desk", "tree", 0.0, 0.0, json!({}));
|
||||
desk.levels = vec!["gf".into()];
|
||||
doc.objects.push(desk);
|
||||
@@ -153,7 +210,10 @@ fn levels_cut_buildings_open_and_filter_objects() {
|
||||
let html = render(&gf.scene, 0, &RenderOpts::default()).html;
|
||||
assert!(html.contains("iso-z-floor iso-t"));
|
||||
let html = render(&up.scene, 0, &RenderOpts::default()).html;
|
||||
assert!(html.contains("class=\"iso-dim\""), "ghosted above the top storey");
|
||||
assert!(
|
||||
html.contains("class=\"iso-dim\""),
|
||||
"ghosted above the top storey"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -172,13 +232,26 @@ fn every_library_prefab_renders_cleanly_in_every_rotation() {
|
||||
}
|
||||
for level in ["site", "gf", "1f"] {
|
||||
for rot in 0..4 {
|
||||
let b = r.build(level, rot, &cfg(), &doc).unwrap_or_else(|e| panic!("{level}/{rot}: {e}"));
|
||||
assert!(b.scene.warnings.is_empty(), "{level}/{rot}: {:?}", b.scene.warnings);
|
||||
let b = r
|
||||
.build(level, rot, &cfg(), &doc)
|
||||
.unwrap_or_else(|e| panic!("{level}/{rot}: {e}"));
|
||||
assert!(
|
||||
b.scene.warnings.is_empty(),
|
||||
"{level}/{rot}: {:?}",
|
||||
b.scene.warnings
|
||||
);
|
||||
let out = render(&b.scene, rot, &RenderOpts::default());
|
||||
assert!(!out.html.contains("NaN"), "{level}/{rot}");
|
||||
for m in r.catalog() {
|
||||
let id = format!("o{}", r.catalog().iter().position(|q| q.kind == m.kind).unwrap());
|
||||
assert!(b.boxes.contains_key(&id), "{} drew nothing in {level}/{rot}", m.kind);
|
||||
let id = format!(
|
||||
"o{}",
|
||||
r.catalog().iter().position(|q| q.kind == m.kind).unwrap()
|
||||
);
|
||||
assert!(
|
||||
b.boxes.contains_key(&id),
|
||||
"{} drew nothing in {level}/{rot}",
|
||||
m.kind
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,8 +259,15 @@ fn every_library_prefab_renders_cleanly_in_every_rotation() {
|
||||
|
||||
#[test]
|
||||
fn unknown_types_and_failing_prefabs_warn_but_render() {
|
||||
let mut b = Bundle { main: None, prelude: vec!["lib/bad.rhai".into()], ..Default::default() };
|
||||
b.sources.insert("lib/bad.rhai".into(), "fn prefab_bad(o) { sprite().box(0, 0, 1, 1, 0, o.p.nope, \"wall\"); }".into());
|
||||
let mut b = Bundle {
|
||||
main: None,
|
||||
prelude: vec!["lib/bad.rhai".into()],
|
||||
..Default::default()
|
||||
};
|
||||
b.sources.insert(
|
||||
"lib/bad.rhai".into(),
|
||||
"fn prefab_bad(o) { sprite().box(0, 0, 1, 1, 0, o.p.nope, \"wall\"); }".into(),
|
||||
);
|
||||
let r = Runtime::new(&b).unwrap();
|
||||
let mut doc = Doc::default();
|
||||
doc.objects.push(obj("u", "ufo", 0.0, 0.0, json!({})));
|
||||
@@ -202,7 +282,9 @@ fn unknown_types_and_failing_prefabs_warn_but_render() {
|
||||
|
||||
#[test]
|
||||
fn cfg_reaches_the_script() {
|
||||
let r = rt(r#"if cfg("big") { sprite("a").box(0, 0, 9, 9, 0, 9, "wall"); } else { sprite("a").box(0, 0, 1, 1, 0, 1, "wall"); }"#);
|
||||
let r = rt(
|
||||
r#"if cfg("big") { sprite("a").box(0, 0, 9, 9, 0, 9, "wall"); } else { sprite("a").box(0, 0, 1, 1, 0, 1, "wall"); }"#,
|
||||
);
|
||||
let mut c = Map::new();
|
||||
c.insert("big".into(), json!(true));
|
||||
let b = r.build("site", 0, &c, &Doc::default()).unwrap();
|
||||
|
||||
@@ -27,8 +27,8 @@ fn meta_building() {
|
||||
#{ key: "lit", type: "bool", def: true, label: "Lit windows" },
|
||||
#{ key: "door", type: "bool", def: true, label: "Front door" },
|
||||
#{ key: "pv", type: "bool", def: false, label: "Solar panels" },
|
||||
#{ key: "ac", type: "bool", def: false, label: "Roof AC units (flat roof)" },
|
||||
#{ key: "floor_mat", type: "material", def: "z-floor", choices: floor_materials(), label: "Floor (cut-open levels)" },
|
||||
#{ key: "ac", type: "bool", def: false, label: "Roof AC units" },
|
||||
#{ key: "floor_mat", type: "material", def: "z-floor", choices: floor_materials(), label: "Cut-open floor" },
|
||||
] }
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ fn prefab_tank(o) {
|
||||
}
|
||||
|
||||
fn meta_container() {
|
||||
#{ label: "Shipping container", category: "Equipment", icon: "▭",
|
||||
#{ label: "Container", category: "Equipment", icon: "▭",
|
||||
props: [
|
||||
#{ key: "size", type: "choice", def: "20ft", choices: ["10ft", "20ft", "40ft"], label: "Size" },
|
||||
#{ key: "colour", type: "choice", def: "rack", choices: ["rack", "truckbox", "classic", "steel", "firewall"], label: "Colour" },
|
||||
@@ -75,7 +75,7 @@ fn prefab_pv_field(o) {
|
||||
}
|
||||
|
||||
fn meta_chimney() {
|
||||
#{ label: "Chimney / stack", category: "Equipment", icon: "▮",
|
||||
#{ label: "Chimney", category: "Equipment", icon: "▮",
|
||||
props: [
|
||||
#{ key: "h", type: "float", def: 12.0, min: 2.0, max: 120.0, step: 0.5, label: "Height (m)" },
|
||||
#{ key: "r", type: "float", def: 0.5, min: 0.1, max: 5.0, step: 0.05, label: "Radius (m)" },
|
||||
|
||||
@@ -15,7 +15,7 @@ fn prefab_person(o) {
|
||||
}
|
||||
|
||||
fn meta_crowd() {
|
||||
#{ label: "Group of people", category: "People", icon: "👥",
|
||||
#{ label: "Crowd", category: "People", icon: "👥",
|
||||
props: [
|
||||
#{ key: "n", type: "int", def: 5, min: 2, max: 40, step: 1, label: "People" },
|
||||
#{ key: "r", type: "float", def: 2.0, min: 0.6, max: 15.0, step: 0.1, label: "Spread (m)" },
|
||||
|
||||
@@ -43,7 +43,9 @@ fn upsert(list: &mut Vec<(String, String)>, k: &str, v: String) {
|
||||
|
||||
fn strings(t: Option<&toml::Value>, what: &str) -> Result<Vec<(String, String)>, String> {
|
||||
let Some(v) = t else { return Ok(vec![]) };
|
||||
let tab = v.as_table().ok_or_else(|| format!("[{what}] must be a table"))?;
|
||||
let tab = v
|
||||
.as_table()
|
||||
.ok_or_else(|| format!("[{what}] must be a table"))?;
|
||||
tab.iter()
|
||||
.map(|(k, v)| match v {
|
||||
toml::Value::String(s) => Ok((k.clone(), s.clone())),
|
||||
@@ -58,7 +60,8 @@ impl Theme {
|
||||
/// The built-in default theme.
|
||||
pub fn default_theme() -> Self {
|
||||
let mut t = Theme::default();
|
||||
t.merge_str(DEFAULT, "default").expect("built-in theme parses");
|
||||
t.merge_str(DEFAULT, "default")
|
||||
.expect("built-in theme parses");
|
||||
t
|
||||
}
|
||||
|
||||
@@ -79,7 +82,11 @@ impl Theme {
|
||||
for (k, v) in strings(tab.get("rules"), "rules").map_err(err)? {
|
||||
upsert(&mut self.rules, &k, v);
|
||||
}
|
||||
if let Some(css) = tab.get("extra").and_then(|e| e.get("css")).and_then(|c| c.as_str()) {
|
||||
if let Some(css) = tab
|
||||
.get("extra")
|
||||
.and_then(|e| e.get("css"))
|
||||
.and_then(|c| c.as_str())
|
||||
{
|
||||
self.extra.push_str(css);
|
||||
self.extra.push('\n');
|
||||
}
|
||||
@@ -118,7 +125,11 @@ impl Theme {
|
||||
match light {
|
||||
None => {
|
||||
block(&mut o, scope, &self.dark);
|
||||
block(&mut o, &format!("html[data-theme=light] {scope}"), &self.light);
|
||||
block(
|
||||
&mut o,
|
||||
&format!("html[data-theme=light] {scope}"),
|
||||
&self.light,
|
||||
);
|
||||
}
|
||||
Some(false) => block(&mut o, scope, &self.dark),
|
||||
Some(true) => {
|
||||
|
||||
Reference in new issue
Block a user