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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user