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

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

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
//! Files embedded in the binary: the viewer, the Studio and the starter
//! project. Set `ISOKIT_ASSETS` to the repository folder to serve the
//! viewer and Studio files from disk instead (edit them without rebuilding).
use std::borrow::Cow;
pub const VIEW_JS: &str = include_str!("../../../viewer/isokit-view.js");
pub const VIEW_CSS: &str = include_str!("../../../viewer/isokit-view.css");
pub const PAGE: &str = include_str!("../../../viewer/page.html");
pub const STUDIO_HTML: &str = include_str!("../../../studio/index.html");
pub const STUDIO_JS: &str = include_str!("../../../studio/studio.js");
pub const STUDIO_CSS: &str = include_str!("../../../studio/studio.css");
/// Asset by its path in the repository ("viewer/isokit-view.js",
/// "studio/studio.js"), from disk when `ISOKIT_ASSETS` is set.
pub fn get(path: &str) -> Option<Cow<'static, str>> {
if let Ok(root) = std::env::var("ISOKIT_ASSETS") {
if let Ok(s) = std::fs::read_to_string(std::path::Path::new(&root).join(path)) {
return Some(Cow::Owned(s));
}
}
let s = match path {
"viewer/isokit-view.js" => VIEW_JS,
"viewer/isokit-view.css" => VIEW_CSS,
"viewer/page.html" => PAGE,
"studio/index.html" => STUDIO_HTML,
"studio/studio.js" => STUDIO_JS,
"studio/studio.css" => STUDIO_CSS,
_ => return None,
};
Some(Cow::Borrowed(s))
}
pub const NEW_MANIFEST: &str = r#"# isokit project. Open it with `isokit studio` (GUI) or build it with `isokit build`.
title = "{title}"
note = "Schematic isometric view. Drag to pan, Ctrl + wheel or pinch to zoom, tap anything for details."
# levels: "site" shows buildings closed; "gf", "1f", ... cut buildings open at that storey
default_level = "site"
default_rotation = 1
# project prefab libraries (functions prefab_<type> / meta_<type>), always loaded
prelude = []
# colour overrides on top of the built-in theme
theme = ["theme.toml"]
[[level]]
id = "site"
label = "Site"
title = "Closed buildings and roofs"
[[level]]
id = "gf"
label = "GF"
title = "Ground floors, cut open"
[[level]]
id = "1f"
label = "1F"
title = "First floors, cut open"
[[param]]
name = "labels"
type = "bool"
default = true
label = "Labels"
[[param]]
name = "running"
type = "bool"
default = true
label = "Animation"
[build]
out = "dist/index.html"
budget = 4000
"#;
pub const NEW_DOC: &str = r#"{
"version": 1,
"objects": [
{ "id": "site", "type": "site", "name": "Site", "x": 0, "y": 0, "props": { "w": 60, "d": 40 }, "locked": true },
{ "id": "road", "type": "road", "name": "Access road", "x": 0, "y": -17, "props": { "w": 60, "d": 6 } },
{ "id": "building", "type": "building", "name": "Main building", "x": -8, "y": 3, "props": { "w": 22, "d": 12, "floors": 2, "pv": true }, "tag": "Main building", "info": "Two storeys, solar on the roof" },
{ "id": "tree", "type": "tree", "x": 16, "y": 8 },
{ "id": "car", "type": "car", "x": 12, "y": -10, "props": { "colour": "car1" } }
]
}
"#;
pub const NEW_THEME: &str = r##"# Colour overrides on top of the built-in theme. Examples:
#
# [palette.dark]
# render = "#8f8b84"
# [palette.light]
# render = "#f4efe6"
# [materials]
# brick = "#9a5a44" # use as mat "brick" in prefabs and scripts
"##;
pub const NEW_LIB_README: &str = r#"Project prefabs live here. A prefab is a pair of Rhai functions:
fn meta_kiosk() {
#{ label: "Kiosk", category: "Shops", icon: "K",
props: [ #{ key: "h", type: "float", def: 2.6, min: 2.0, max: 5.0, label: "Height (m)" } ] }
}
fn prefab_kiosk(o) { // o.p holds the props; draw around (0, 0)
sprite().box(-1.5, -1, 1.5, 1, 0, o.p.h, "render2", #{ top: "roof3" });
}
List the file in isokit.toml (prelude = ["lib/kiosk.rhai"]) and the Studio
offers "Kiosk" in its palette.
"#;
+144
View File
@@ -0,0 +1,144 @@
//! `isokit check`: problems a person would spot by eye, found by code.
use crate::ops::check_props;
use crate::{assets, Project};
use serde::Serialize;
use std::collections::BTreeSet;
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct Finding {
/// error | warning | info
pub level: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub object: Option<String>,
}
fn f(level: &str, message: String, object: Option<String>) -> Finding {
Finding {
level: level.into(),
message,
object,
}
}
/// Classes the viewer styles itself (faces, structure, animation).
const STRUCTURAL: &[&str] = &[
"iso-t",
"iso-l",
"iso-c",
"iso-r",
"iso-ground",
"iso-sprites",
"iso-tags",
"iso-tag",
"iso-a",
"iso-vent",
"iso-dim",
"iso-headc",
"iso-led",
"iso-lit",
"iso-on",
"iso-empty",
];
pub fn check(p: &Project) -> Vec<Finding> {
let mut out = vec![];
let rt = match &p.runtime {
Ok(r) => r,
Err(e) => return vec![f("error", e.to_string(), None)],
};
// document: types and props
let mut ids = BTreeSet::new();
for o in &p.doc.objects {
if !ids.insert(o.id.clone()) {
out.push(f(
"error",
format!("duplicate object id \"{}\"", o.id),
Some(o.id.clone()),
));
}
match rt.prefab(&o.kind) {
None => out.push(f(
"error",
format!("unknown type \"{}\"", o.kind),
Some(o.id.clone()),
)),
Some(m) => {
if let Err(e) = check_props(m, &o.props) {
out.push(f("warning", e, Some(o.id.clone())));
}
}
}
}
// every view: warnings, NaN, budget, sort cycles, undefined classes
let css = format!(
"{}\n{}",
assets::get("viewer/isokit-view.css").unwrap_or_default(),
p.theme.css()
);
let mut seen_warn = BTreeSet::new();
let mut missing = BTreeSet::new();
for l in p.manifest.levels() {
for rot in 0..4u8 {
let v = match p.render(&l.id, rot, None, false) {
Ok(v) => v,
Err(e) => {
out.push(f("error", format!("{}/{rot}: {e}", l.id), None));
continue;
}
};
for w in &v.warnings {
if seen_warn.insert(w.clone()) {
let obj = w
.strip_prefix("object ")
.and_then(|r| r.split(':').next())
.map(str::to_string);
out.push(f("warning", w.clone(), obj));
}
}
if v.html.contains("NaN") {
out.push(f(
"error",
format!(
"{}/{rot}: NaN in the drawing (a coordinate is not a number)",
l.id
),
None,
));
}
if v.nodes > p.manifest.build.budget {
out.push(f(
"warning",
format!(
"{}/{rot}: {} SVG nodes, budget {}",
l.id, v.nodes, p.manifest.build.budget
),
None,
));
}
if v.cycles > 0 {
out.push(f("info", format!("{}/{rot}: {} depth-sort cycle(s) broken; split large sprites if something draws in front of what it stands behind", l.id, v.cycles), None));
}
for cls in v
.html
.split("class=\"")
.skip(1)
.filter_map(|s| s.split('"').next())
{
for c in cls.split_whitespace() {
if c.starts_with("iso-")
&& !STRUCTURAL.contains(&c)
&& !css.contains(&format!(".{c}"))
{
missing.insert(c.to_string());
}
}
}
}
}
for c in missing {
out.push(f("warning", format!("class {c} has no style in the theme (drawn grey): add it under [materials] in theme.toml"), None));
}
out
}
+470
View File
@@ -0,0 +1,470 @@
//! isokit projects.
//!
//! A project is a folder:
//!
//! ```text
//! isokit.toml manifest: title, levels, parameters, theme files, prelude modules
//! scene.json the scene document (objects), edited by the Studio and agents
//! scene.rhai optional script drawn before the objects
//! lib/*.rhai optional project prefabs (list them in `prelude`)
//! theme.toml optional colours and materials
//! ```
//!
//! [`Project`] loads and renders it; [`ops`] edits the document; [`page`]
//! exports HTML/SVG; [`check`] lints it; [`workspace::Workspace`] keeps a
//! project loaded across edits (undo, reload on file changes) for the Studio
//! and the MCP server ([`mcp`]).
pub mod assets;
pub mod check;
pub mod mcp;
pub mod ops;
pub mod page;
pub mod workspace;
use isokit_core::{render, Info, RenderOpts};
use isokit_script::doc::{Doc, PrefabMeta};
use isokit_script::{Bundle, Runtime, ScriptError};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
pub use isokit_script;
pub const MANIFEST: &str = "isokit.toml";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct LevelDef {
pub id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub title: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ParamDef {
pub name: String,
/// bool | int | float | choice | text
#[serde(rename = "type", default = "bool_kind")]
pub kind: String,
#[serde(default)]
pub default: Value,
#[serde(default)]
pub label: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<f64>,
}
fn bool_kind() -> String {
"bool".into()
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct BuildOpts {
#[serde(default)]
pub out: Option<String>,
/// Node budget per view for `isokit check`.
#[serde(default = "budget")]
pub budget: usize,
}
fn budget() -> usize {
4000
}
impl Default for BuildOpts {
fn default() -> Self {
BuildOpts {
out: None,
budget: budget(),
}
}
}
fn rot1() -> u8 {
1
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Manifest {
#[serde(default)]
pub title: String,
#[serde(default)]
pub note: String,
/// Main script; default `scene.rhai` when that file exists.
#[serde(default)]
pub script: Option<String>,
/// Scene document; default `scene.json`.
#[serde(default)]
pub doc: Option<String>,
#[serde(default)]
pub prelude: Vec<String>,
#[serde(default)]
pub theme: Vec<String>,
#[serde(default, rename = "level")]
pub levels: Vec<LevelDef>,
#[serde(default)]
pub default_level: Option<String>,
#[serde(default = "rot1")]
pub default_rotation: u8,
#[serde(default, rename = "param")]
pub params: Vec<ParamDef>,
#[serde(default)]
pub build: BuildOpts,
}
impl Manifest {
pub fn levels(&self) -> Vec<LevelDef> {
if self.levels.is_empty() {
vec![LevelDef {
id: "site".into(),
label: "Site".into(),
title: "The whole site".into(),
}]
} else {
self.levels.clone()
}
}
pub fn default_level(&self) -> String {
let lv = self.levels();
self.default_level
.clone()
.filter(|d| lv.iter().any(|l| &l.id == d))
.unwrap_or_else(|| lv[0].id.clone())
}
pub fn doc_path(&self) -> String {
self.doc.clone().unwrap_or_else(|| "scene.json".into())
}
}
#[derive(Debug)]
pub enum Error {
Io(String),
Manifest(String),
Theme(String),
Doc(String),
Script(ScriptError),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Io(e) => write!(f, "{e}"),
Error::Manifest(e) => write!(f, "isokit.toml: {e}"),
Error::Theme(e) => write!(f, "theme: {e}"),
Error::Doc(e) => write!(f, "scene document: {e}"),
Error::Script(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for Error {}
impl From<ScriptError> for Error {
fn from(e: ScriptError) -> Self {
Error::Script(e)
}
}
/// One rendered view as the viewer consumes it.
#[derive(Clone, Debug, Serialize)]
pub struct View {
pub html: String,
pub info: BTreeMap<String, Info>,
pub bbox: [f64; 4],
pub focus: [f64; 2],
pub rot: u8,
pub north: f64,
pub level: String,
pub sprites: usize,
pub nodes: usize,
pub cycles: usize,
pub warnings: Vec<String>,
/// Object id -> world box [x0, y0, z0, x1, y1, z1].
pub boxes: BTreeMap<String, [f64; 6]>,
pub ms: f64,
}
/// Project facts the viewer, the Studio and agents need.
#[derive(Clone, Debug, Serialize)]
pub struct Meta {
pub title: String,
pub note: String,
pub levels: Vec<LevelDef>,
pub default_level: String,
pub default_rotation: u8,
pub params: Vec<ParamDef>,
pub catalog: Vec<PrefabMeta>,
pub materials: Vec<String>,
pub c: f64,
pub cz: f64,
}
fn rd(path: &Path) -> Result<String, Error> {
std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))
}
/// Every `*.rhai` below `dir` (skipping dist, target and dot folders),
/// keyed by forward-slash relative path.
fn collect_sources(dir: &Path) -> Result<BTreeMap<String, String>, Error> {
let mut out = BTreeMap::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let rd_ = std::fs::read_dir(&d).map_err(|e| Error::Io(format!("{}: {e}", d.display())))?;
for ent in rd_.flatten() {
let p = ent.path();
let name = ent.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !name.starts_with('.')
&& name != "dist"
&& name != "target"
&& name != "node_modules"
{
stack.push(p);
}
} else if name.ends_with(".rhai") {
let rel = p
.strip_prefix(dir)
.unwrap_or(&p)
.to_string_lossy()
.replace('\\', "/");
out.insert(rel, rd(&p)?);
}
}
}
Ok(out)
}
pub struct Project {
pub dir: PathBuf,
pub manifest: Manifest,
pub theme: isokit_theme::Theme,
pub doc: Doc,
pub bundle: Bundle,
/// The compiled scripts, or the error that stopped them.
pub runtime: Result<Runtime, ScriptError>,
}
impl Project {
/// Load a project folder. A script error does not fail the load: the
/// document stays editable and [`Project::runtime`] holds the error.
pub fn load(dir: impl AsRef<Path>) -> Result<Project, Error> {
let dir = dir.as_ref().to_path_buf();
let mpath = dir.join(MANIFEST);
let manifest: Manifest = if mpath.exists() {
toml::from_str(&rd(&mpath)?).map_err(|e| Error::Manifest(e.to_string()))?
} else {
return Err(Error::Io(format!(
"{} not found (create a project with `isokit new`)",
mpath.display()
)));
};
let mut layers = vec![];
for t in &manifest.theme {
layers.push((t.clone(), rd(&dir.join(t))?));
}
let refs: Vec<(&str, &str)> = layers
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
let theme = isokit_theme::load(&refs).map_err(Error::Theme)?;
let dpath = dir.join(manifest.doc_path());
let doc: Doc = if dpath.exists() {
serde_json::from_str(&rd(&dpath)?)
.map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?
} else {
Doc {
version: 1,
..Default::default()
}
};
let sources = collect_sources(&dir)?;
let main = match &manifest.script {
Some(s) => Some(s.clone()),
None if sources.contains_key("scene.rhai") => Some("scene.rhai".into()),
None => None,
};
let bundle = Bundle {
main,
prelude: manifest.prelude.clone(),
sources,
};
let runtime = Runtime::new(&bundle);
Ok(Project {
dir,
manifest,
theme,
doc,
bundle,
runtime,
})
}
pub fn catalog(&self) -> &[PrefabMeta] {
match &self.runtime {
Ok(r) => r.catalog(),
Err(_) => &[],
}
}
/// Parameter values: manifest defaults, then the document, then `over`.
pub fn cfg(&self, over: Option<&Map<String, Value>>) -> Map<String, Value> {
let mut c = Map::new();
for p in &self.manifest.params {
c.insert(p.name.clone(), p.default.clone());
}
for (k, v) in &self.doc.cfg {
c.insert(k.clone(), v.clone());
}
if let Some(o) = over {
for (k, v) in o {
c.insert(k.clone(), v.clone());
}
}
c
}
pub fn materials(&self) -> Vec<String> {
self.theme
.materials
.iter()
.map(|(k, _)| k.clone())
.collect()
}
pub fn meta(&self) -> Meta {
Meta {
title: if self.manifest.title.is_empty() {
self.dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
} else {
self.manifest.title.clone()
},
note: self.manifest.note.clone(),
levels: self.manifest.levels(),
default_level: self.manifest.default_level(),
default_rotation: self.manifest.default_rotation % 4,
params: self.manifest.params.clone(),
catalog: self.catalog().to_vec(),
materials: self.materials(),
c: isokit_core::C,
cz: isokit_core::CZ,
}
}
/// Render one view of the current document.
pub fn render(
&self,
level: &str,
rot: u8,
over: Option<&Map<String, Value>>,
dev: bool,
) -> Result<View, ScriptError> {
let t0 = Instant::now();
let rt = self.runtime.as_ref().map_err(|e| e.clone())?;
let cfg = self.cfg(over);
let built = rt.build(level, rot % 4, &cfg, &self.doc)?;
let labels = cfg.get("labels").and_then(|v| v.as_bool()).unwrap_or(true);
let r = render(
&built.scene,
rot % 4,
&RenderOpts {
uid: 0,
labels,
dev,
},
);
Ok(View {
html: r.html,
info: r.info,
bbox: r.bbox,
focus: r.focus,
rot: r.rot,
north: r.north,
level: level.to_string(),
sprites: r.sprites,
nodes: r.nodes,
cycles: r.cycles,
warnings: r.warnings,
boxes: built.boxes,
ms: (t0.elapsed().as_secs_f64() * 1e4).round() / 10.0,
})
}
/// Write the document (pretty JSON, atomically).
pub fn save_doc(&self) -> Result<(), Error> {
let path = self.dir.join(self.manifest.doc_path());
let mut s =
serde_json::to_string_pretty(&self.doc).map_err(|e| Error::Doc(e.to_string()))?;
s.push('\n');
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, s).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?;
std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
Ok(())
}
}
/// A fingerprint of the project files (manifest, documents, scripts,
/// themes): changes when any of them is edited, added or removed.
pub fn version(dir: &Path) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut stack = vec![dir.to_path_buf()];
let mut files = vec![];
while let Some(d) = stack.pop() {
let Ok(rd_) = std::fs::read_dir(&d) else {
continue;
};
for ent in rd_.flatten() {
let p = ent.path();
let name = ent.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !name.starts_with('.')
&& name != "dist"
&& name != "target"
&& name != "node_modules"
{
stack.push(p);
}
} else if name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json")
{
if let Ok(m) = ent.metadata() {
files.push((p.to_string_lossy().to_string(), m.len(), m.modified().ok()));
}
}
}
}
files.sort();
files.hash(&mut h);
h.finish()
}
/// Write a new project folder with a starter scene.
pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
if dir.join(MANIFEST).exists() {
return Err(Error::Io(format!(
"{} already has an isokit.toml",
dir.display()
)));
}
std::fs::create_dir_all(dir.join("lib")).map_err(|e| Error::Io(e.to_string()))?;
let w = |name: &str, body: &str| {
std::fs::write(dir.join(name), body).map_err(|e| Error::Io(format!("{name}: {e}")))
};
w(MANIFEST, &assets::NEW_MANIFEST.replace("{title}", title))?;
w("scene.json", assets::NEW_DOC)?;
w("theme.toml", assets::NEW_THEME)?;
w("lib/README.md", assets::NEW_LIB_README)?;
Ok(())
}
+360
View File
@@ -0,0 +1,360 @@
//! MCP (Model Context Protocol) server: agents edit the scene with the same
//! operations as the Studio. Transport-agnostic: [`handle`] takes one
//! JSON-RPC message and returns the response (None for notifications); the
//! CLI serves it over stdio (`isokit mcp`) and the Studio over HTTP (`/mcp`).
use crate::check::check;
use crate::ops::Op;
use crate::page;
use crate::workspace::Workspace;
use serde_json::{json, Map, Value};
pub const PROTOCOL: &str = "2025-06-18";
const INSTRUCTIONS: &str = "isokit edits an isometric scene (a site plan drawn as a SimCity-style 3D view). \
World coordinates are metres: x east, y north, z up. The scene is a list of objects; each has a type (a prefab), \
a position (x, y = centre of its footprint), a rotation in 90 degree steps and props from the prefab's schema. \
Start with project_info and list_prefabs, then add_object / update_object. Use render_view (summary) to see where \
everything ended up and check_project to find problems. Edits are saved at once and show live in an open Studio.";
fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value {
json!({ "name": name, "description": description,
"inputSchema": { "type": "object", "properties": props, "required": required } })
}
pub fn tools() -> Vec<Value> {
let obj_fields = json!({
"x": { "type": "number", "description": "east, metres (centre of the footprint)" },
"y": { "type": "number", "description": "north, metres" },
"z": { "type": "number", "description": "base height, metres (default 0)" },
"rot": { "type": "number", "description": "rotation in degrees, multiples of 90" },
"props": { "type": "object", "description": "prefab properties (see list_prefabs); in updates they merge, null removes" },
"name": { "type": "string", "description": "name shown in the tooltip" },
"info": { "type": "string", "description": "tooltip text" },
"tag": { "type": "string", "description": "label pinned above the object" },
"levels": { "type": "array", "items": { "type": "string" }, "description": "levels it shows on (empty: all)" },
});
let mut add = obj_fields.as_object().unwrap().clone();
add.insert("type".into(), json!({ "type": "string", "description": "prefab type, e.g. building, tree, car (list_prefabs)" }));
add.insert("id".into(), json!({ "type": "string", "description": "wanted id (made unique); default from the type" }));
let mut upd = obj_fields.as_object().unwrap().clone();
upd.insert("id".into(), json!({ "type": "string" }));
upd.insert("hidden".into(), json!({ "type": "boolean" }));
upd.insert(
"type".into(),
json!({ "type": "string", "description": "change the prefab type" }),
);
vec![
tool("project_info", "Title, folder, levels, parameters, object count and load errors of the project.", json!({}), &[]),
tool("list_prefabs", "Object types that can be placed, with their property schemas (type, default, min, max, choices).",
json!({ "category": { "type": "string" }, "type": { "type": "string", "description": "one type in full detail" } }), &[]),
tool("list_objects", "Objects in the scene with position, props and the world box [x0, y0, z0, x1, y1, z1] each one occupies.",
json!({ "type": { "type": "string", "description": "only this type" } }), &[]),
tool("get_object", "One object with all fields and its world box.", json!({ "id": { "type": "string" } }), &["id"]),
tool("add_object", "Place a new object. Returns its id and world box.", Value::Object(add), &["type", "x", "y"]),
tool("update_object", "Change an object: move (x, y, z), turn (rot), edit props, name, info, tag, levels.", Value::Object(upd), &["id"]),
tool("remove_object", "Delete an object.", json!({ "id": { "type": "string" } }), &["id"]),
tool("duplicate_object", "Copy an object, offset by dx, dy metres (default 2, 2).",
json!({ "id": { "type": "string" }, "dx": { "type": "number" }, "dy": { "type": "number" } }), &["id"]),
tool("apply_ops", "Several edits as one undo step. Each op is {op: add|update|remove|duplicate|reorder|set_cfg, ...} \
(add: same fields as add_object; update: {id, patch: {...}}; reorder: {id, to: front|back|index}).",
json!({ "ops": { "type": "array", "items": { "type": "object" } } }), &["ops"]),
tool("set_params", "Set project parameter values (see project_info); null resets to the default.",
json!({ "values": { "type": "object" } }), &["values"]),
tool("render_view", "Render one view. format summary (default): sprite and node counts, warnings, object boxes; \
svg: the standalone SVG markup.",
json!({ "level": { "type": "string" }, "rotation": { "type": "integer", "minimum": 0, "maximum": 3 },
"format": { "type": "string", "enum": ["summary", "svg"] } }), &[]),
tool("check_project", "Lint the project: unknown types, bad props, script warnings, NaN, node budget, unstyled classes.", json!({}), &[]),
tool("build_html", "Write the self-contained HTML page (all views prerendered). Returns the file path.",
json!({ "out": { "type": "string", "description": "path relative to the project (default from isokit.toml)" } }), &[]),
tool("undo", "Undo the last edit.", json!({}), &[]),
tool("redo", "Redo the last undone edit.", json!({}), &[]),
]
}
fn text(v: Value) -> Value {
let t = match v {
Value::String(s) => s,
o => serde_json::to_string_pretty(&o).unwrap_or_default(),
};
json!({ "content": [{ "type": "text", "text": t }], "isError": false })
}
fn tool_error(msg: impl Into<String>) -> Value {
json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true })
}
fn boxes(ws: &Workspace) -> Map<String, Value> {
let p = &ws.project;
match p.render(&p.manifest.default_level(), 0, None, false) {
Ok(v) => v
.boxes
.into_iter()
.map(|(k, b)| (k, json!(b.map(isokit_core::fmt::round1))))
.collect(),
Err(_) => Map::new(),
}
}
fn object_json(ws: &Workspace, id: &str, bx: &Map<String, Value>) -> Option<Value> {
let o = ws.project.doc.get(id)?;
let mut v = serde_json::to_value(o).ok()?;
if let Some(b) = bx.get(id) {
v["box"] = b.clone();
}
Some(v)
}
fn str_arg(a: &Map<String, Value>, k: &str) -> Option<String> {
a.get(k).and_then(|v| v.as_str()).map(str::to_string)
}
/// Run one tool.
pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Value {
ws.refresh();
match name {
"project_info" => {
let p = &ws.project;
let m = p.meta();
text(json!({
"title": m.title, "dir": p.dir.display().to_string(), "levels": m.levels, "default_level": m.default_level,
"params": m.params, "values": p.cfg(None), "objects": p.doc.objects.len(),
"types": m.catalog.iter().map(|c| format!("{} ({})", c.kind, c.label)).collect::<Vec<_>>(),
"script_error": p.runtime.as_ref().err().map(|e| e.to_string()),
"load_error": ws.load_error,
"coordinates": "metres; x east, y north, z up; object x, y = centre of its footprint",
}))
}
"list_prefabs" => {
let cat = ws.project.catalog();
if let Some(t) = str_arg(a, "type") {
return match cat.iter().find(|m| m.kind == t) {
Some(m) => text(serde_json::to_value(m).unwrap_or_default()),
None => tool_error(format!("no prefab \"{t}\"")),
};
}
let c = str_arg(a, "category");
let list: Vec<Value> = cat
.iter()
.filter(|m| c.as_ref().is_none_or(|c| m.category.eq_ignore_ascii_case(c)))
.map(|m| {
json!({ "type": m.kind, "label": m.label, "category": m.category, "help": m.help,
"props": m.props.iter().map(|p| {
let mut d = json!({ "key": p.key, "type": p.kind, "default": p.default });
if let Some(x) = p.min { d["min"] = json!(x); }
if let Some(x) = p.max { d["max"] = json!(x); }
if !p.choices.is_empty() { d["choices"] = json!(p.choices); }
if !p.label.is_empty() { d["label"] = json!(p.label); }
d
}).collect::<Vec<_>>() })
})
.collect();
text(json!(list))
}
"list_objects" => {
let bx = boxes(ws);
let t = str_arg(a, "type");
let list: Vec<Value> = ws
.project
.doc
.objects
.iter()
.filter(|o| t.as_ref().is_none_or(|t| &o.kind == t))
.filter_map(|o| object_json(ws, &o.id, &bx))
.collect();
text(json!(list))
}
"get_object" => {
let id = str_arg(a, "id").unwrap_or_default();
let bx = boxes(ws);
match object_json(ws, &id, &bx) {
Some(v) => text(v),
None => tool_error(format!("no object \"{id}\"")),
}
}
"add_object" => {
let mut op = a.clone();
op.insert("op".into(), json!("add"));
match serde_json::from_value::<Op>(Value::Object(op)) {
Err(e) => tool_error(format!("bad arguments: {e}")),
Ok(op) => match ws.apply(op) {
Err(e) => tool_error(e),
Ok(o) => {
let id = o.id.unwrap_or_default();
let bx = boxes(ws);
let warn: Vec<String> = render_warnings(ws)
.into_iter()
.filter(|w| w.contains(&format!("object {id}:")))
.collect();
text(
json!({ "id": id, "object": object_json(ws, &id, &bx), "warnings": warn }),
)
}
},
}
}
"update_object" => {
let id = str_arg(a, "id").unwrap_or_default();
let mut patch = a.clone();
patch.remove("id");
match ws.apply(Op::Update {
id: id.clone(),
patch,
}) {
Err(e) => tool_error(e),
Ok(_) => {
let bx = boxes(ws);
text(json!({ "object": object_json(ws, &id, &bx) }))
}
}
}
"remove_object" => match ws.apply(Op::Remove {
id: str_arg(a, "id").unwrap_or_default(),
}) {
Err(e) => tool_error(e),
Ok(_) => text(json!({ "removed": true, "objects": ws.project.doc.objects.len() })),
},
"duplicate_object" => {
let num = |k: &str| a.get(k).and_then(|v| v.as_f64()).unwrap_or(2.0);
match ws.apply(Op::Duplicate {
id: str_arg(a, "id").unwrap_or_default(),
dx: num("dx"),
dy: num("dy"),
}) {
Err(e) => tool_error(e),
Ok(o) => text(json!({ "id": o.id })),
}
}
"apply_ops" => {
let ops = a
.get("ops")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let parsed: Result<Vec<Op>, String> = ops
.into_iter()
.enumerate()
.map(|(i, v)| serde_json::from_value::<Op>(v).map_err(|e| format!("op {i}: {e}")))
.collect();
match parsed.and_then(|l| ws.apply_all(l)) {
Err(e) => tool_error(e),
Ok(outs) => {
text(json!({ "ids": outs.into_iter().map(|o| o.id).collect::<Vec<_>>() }))
}
}
}
"set_params" => {
let values = a
.get("values")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
match ws.apply(Op::SetCfg { values }) {
Err(e) => tool_error(e),
Ok(_) => text(json!({ "values": ws.project.cfg(None) })),
}
}
"render_view" => {
let p = &ws.project;
let level = str_arg(a, "level").unwrap_or_else(|| p.manifest.default_level());
let rot = a
.get("rotation")
.and_then(|v| v.as_u64())
.unwrap_or(p.manifest.default_rotation as u64) as u8
% 4;
if str_arg(a, "format").as_deref() == Some("svg") {
return match page::svg(p, &level, rot, false) {
Ok(s) => text(Value::String(s)),
Err(e) => tool_error(e.to_string()),
};
}
match p.render(&level, rot, None, false) {
Err(e) => tool_error(e.to_string()),
Ok(v) => text(json!({
"level": level, "rotation": rot, "sprites": v.sprites, "nodes": v.nodes, "sort_cycles": v.cycles,
"render_ms": v.ms, "warnings": v.warnings,
"boxes": v.boxes.iter().map(|(k, b)| (k.clone(), json!(b.map(isokit_core::fmt::round1)))).collect::<Map<_, _>>(),
})),
}
}
"check_project" => {
let f = check(&ws.project);
text(json!({ "findings": f, "ok": !f.iter().any(|x| x.level == "error") }))
}
"build_html" => {
let p = &ws.project;
let out = str_arg(a, "out")
.or_else(|| p.manifest.build.out.clone())
.unwrap_or_else(|| "dist/index.html".into());
let path = p.dir.join(&out);
match page::static_page(p) {
Err(e) => tool_error(e.to_string()),
Ok(html) => {
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
match std::fs::write(&path, html) {
Ok(_) => text(json!({ "path": path.display().to_string() })),
Err(e) => tool_error(format!("{}: {e}", path.display())),
}
}
}
}
"undo" => match ws.undo() {
Ok(done) => text(json!({ "undone": done })),
Err(e) => tool_error(e),
},
"redo" => match ws.redo() {
Ok(done) => text(json!({ "redone": done })),
Err(e) => tool_error(e),
},
other => tool_error(format!("unknown tool \"{other}\"")),
}
}
fn render_warnings(ws: &Workspace) -> Vec<String> {
let p = &ws.project;
p.render(&p.manifest.default_level(), 0, None, false)
.map(|v| v.warnings)
.unwrap_or_default()
}
/// Handle one JSON-RPC message.
pub fn handle(ws: &mut Workspace, msg: &Value) -> Option<Value> {
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(json!({}));
let id = id?; // notifications get no response
let result = match method {
"initialize" => {
let pv = params
.get("protocolVersion")
.and_then(|v| v.as_str())
.unwrap_or(PROTOCOL)
.to_string();
json!({ "protocolVersion": pv, "capabilities": { "tools": { "listChanged": false } },
"serverInfo": { "name": "isokit", "version": env!("CARGO_PKG_VERSION") },
"instructions": INSTRUCTIONS })
}
"ping" => json!({}),
"tools/list" => json!({ "tools": tools() }),
"tools/call" => {
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args = params
.get("arguments")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
call_tool(ws, name, &args)
}
"resources/list" => json!({ "resources": [] }),
"prompts/list" => json!({ "prompts": [] }),
_ => {
return Some(
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("method not found: {method}") } }),
);
}
};
Some(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
}
+440
View File
@@ -0,0 +1,440 @@
//! Edits to the scene document. The Studio and the MCP tools send the same
//! JSON operations: `{"op": "add", "type": "tree", "x": 3, "y": 4}`.
use isokit_script::doc::{Doc, Object, PrefabMeta};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Op {
/// Place a new object of a prefab type.
Add {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
x: f64,
#[serde(default)]
y: f64,
#[serde(default)]
z: f64,
#[serde(default)]
rot: f64,
#[serde(default)]
props: Map<String, Value>,
#[serde(default)]
name: String,
#[serde(default)]
info: String,
#[serde(default)]
tag: String,
#[serde(default)]
levels: Vec<String>,
#[serde(default)]
pts: Option<Vec<[f64; 2]>>,
/// Wanted id (made unique); default from the type.
#[serde(default)]
id: Option<String>,
},
/// Change fields of an object; `props` are merged (null removes a key).
Update {
id: String,
patch: Map<String, Value>,
},
Remove {
id: String,
},
Duplicate {
id: String,
#[serde(default = "two")]
dx: f64,
#[serde(default = "two")]
dy: f64,
},
/// Move an object in the drawing order: "front", "back" or an index.
Reorder {
id: String,
to: Value,
},
/// Set parameter values (null removes the document's value).
SetCfg {
values: Map<String, Value>,
},
/// Replace the whole document.
Replace {
doc: Doc,
},
}
fn two() -> f64 {
2.0
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Outcome {
/// The object created or changed, if any.
pub id: Option<String>,
}
fn finite(v: f64, what: &str) -> Result<f64, String> {
if v.is_finite() {
Ok(v)
} else {
Err(format!("{what} must be a finite number"))
}
}
fn norm_rot(r: f64) -> f64 {
(((r / 90.0).round() as i64).rem_euclid(4) * 90) as f64
}
/// Check property values against the prefab schema (unknown keys pass:
/// project scripts may read them).
pub fn check_props(meta: &PrefabMeta, props: &Map<String, Value>) -> Result<(), String> {
for (k, v) in props {
let Some(d) = meta.prop(k) else { continue };
match d.kind.as_str() {
"float" | "int" => {
let Some(x) = v
.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
else {
return Err(format!("{}.{k}: expected a number, got {v}", meta.kind));
};
if d.min.is_some_and(|m| x < m) || d.max.is_some_and(|m| x > m) {
return Err(format!(
"{}.{k} = {x} is outside {}..{}",
meta.kind,
d.min.map(|m| m.to_string()).unwrap_or_default(),
d.max.map(|m| m.to_string()).unwrap_or_default()
));
}
}
"bool" => {
if !v.is_boolean() {
return Err(format!(
"{}.{k}: expected true or false, got {v}",
meta.kind
));
}
}
"choice" => {
let s = v.as_str().unwrap_or_default();
if !d.choices.is_empty() && !d.choices.iter().any(|c| c == s) {
return Err(format!(
"{}.{k}: \"{s}\" is not one of {}",
meta.kind,
d.choices.join(", ")
));
}
}
_ => {}
}
}
Ok(())
}
fn str_of(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
o => o.to_string(),
}
}
/// Apply a field patch to an object.
pub fn patch_object(
o: &mut Object,
patch: &Map<String, Value>,
meta: Option<&PrefabMeta>,
) -> Result<(), String> {
for (k, v) in patch {
let num = || v.as_f64().ok_or_else(|| format!("{k} must be a number"));
match k.as_str() {
"x" => o.x = finite(num()?, "x")?,
"y" => o.y = finite(num()?, "y")?,
"z" => o.z = finite(num()?, "z")?,
"rot" => o.rot = norm_rot(finite(num()?, "rot")?),
"name" => o.name = str_of(v),
"info" => o.info = str_of(v),
"tag" => o.tag = str_of(v),
"hidden" => o.hidden = v.as_bool().unwrap_or(false),
"locked" => o.locked = v.as_bool().unwrap_or(false),
"levels" => {
o.levels = v.as_array().map(|a| a.iter().map(str_of).filter(|s| !s.is_empty()).collect()).unwrap_or_default()
}
"pts" => {
o.pts = if v.is_null() { None } else { Some(serde_json::from_value(v.clone()).map_err(|e| format!("pts: {e}"))?) }
}
"props" => {
let m = v.as_object().ok_or("props must be an object")?;
let mut next = o.props.clone();
for (pk, pv) in m {
if pv.is_null() {
next.remove(pk);
} else {
next.insert(pk.clone(), pv.clone());
}
}
if let Some(meta) = meta {
check_props(meta, &next)?;
}
o.props = next;
}
"type" => {
let t = str_of(v);
if t.is_empty() {
return Err("type must not be empty".into());
}
o.kind = t;
}
"id" => {}
other => return Err(format!("unknown field \"{other}\" (x, y, z, rot, name, info, tag, levels, props, pts, hidden, locked, type)")),
}
}
Ok(())
}
/// Apply one operation. `catalog` validates types and props.
pub fn apply(doc: &mut Doc, catalog: &[PrefabMeta], op: Op) -> Result<Outcome, String> {
let meta_of = |k: &str| catalog.iter().find(|m| m.kind == k);
match op {
Op::Add {
kind,
x,
y,
z,
rot,
props,
name,
info,
tag,
levels,
pts,
id,
} => {
let Some(meta) = meta_of(&kind) else {
let mut known: Vec<&str> = catalog.iter().map(|m| m.kind.as_str()).collect();
known.sort();
return Err(format!(
"unknown type \"{kind}\"; available: {}",
known.join(", ")
));
};
check_props(meta, &props)?;
let id = doc.free_id(id.as_deref().filter(|s| !s.is_empty()).unwrap_or(&kind));
doc.objects.push(Object {
id: id.clone(),
kind,
name,
x: finite(x, "x")?,
y: finite(y, "y")?,
z: finite(z, "z")?,
rot: norm_rot(rot),
props,
info,
tag,
levels,
pts,
hidden: false,
locked: false,
});
Ok(Outcome { id: Some(id) })
}
Op::Update { id, patch } => {
let kind = doc
.get(&id)
.ok_or_else(|| format!("no object \"{id}\""))?
.kind
.clone();
let kind = patch
.get("type")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or(kind);
let meta = meta_of(&kind);
let o = doc.get_mut(&id).expect("checked");
let mut next = o.clone();
patch_object(&mut next, &patch, meta)?;
*o = next;
Ok(Outcome { id: Some(id) })
}
Op::Remove { id } => {
let n = doc.objects.len();
doc.objects.retain(|o| o.id != id);
if doc.objects.len() == n {
return Err(format!("no object \"{id}\""));
}
Ok(Outcome { id: None })
}
Op::Duplicate { id, dx, dy } => {
let src = doc
.get(&id)
.ok_or_else(|| format!("no object \"{id}\""))?
.clone();
let base = src
.id
.trim_end_matches(|c: char| c.is_ascii_digit() || c == '-')
.to_string();
let nid = doc.free_id(if base.is_empty() { &src.kind } else { &base });
let mut o = src;
o.id = nid.clone();
o.x += dx;
o.y += dy;
o.locked = false;
let at = doc
.objects
.iter()
.position(|q| q.id == id)
.map(|i| i + 1)
.unwrap_or(doc.objects.len());
doc.objects.insert(at, o);
Ok(Outcome { id: Some(nid) })
}
Op::Reorder { id, to } => {
let i = doc
.objects
.iter()
.position(|o| o.id == id)
.ok_or_else(|| format!("no object \"{id}\""))?;
let o = doc.objects.remove(i);
let n = doc.objects.len();
let at = match &to {
Value::String(s) if s == "front" => n,
Value::String(s) if s == "back" => 0,
Value::Number(k) => (k.as_f64().unwrap_or(0.0).max(0.0) as usize).min(n),
_ => return Err("to must be \"front\", \"back\" or an index".into()),
};
doc.objects.insert(at, o);
Ok(Outcome { id: Some(id) })
}
Op::SetCfg { values } => {
for (k, v) in values {
if v.is_null() {
doc.cfg.remove(&k);
} else {
doc.cfg.insert(k, v);
}
}
Ok(Outcome { id: None })
}
Op::Replace { doc: d } => {
*doc = d;
Ok(Outcome { id: None })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use isokit_script::doc::PropDef;
use serde_json::json;
fn catalog() -> Vec<PrefabMeta> {
vec![PrefabMeta {
kind: "tree".into(),
label: "Tree".into(),
props: vec![PropDef {
key: "r".into(),
kind: "float".into(),
default: json!(1.6),
min: Some(0.5),
max: Some(6.0),
..Default::default()
}],
..Default::default()
}]
}
fn op(v: Value) -> Op {
serde_json::from_value(v).unwrap()
}
#[test]
fn add_update_duplicate_remove() {
let mut d = Doc::default();
let c = catalog();
let id = apply(
&mut d,
&c,
op(json!({"op": "add", "type": "tree", "x": 3, "y": 4, "rot": 100})),
)
.unwrap()
.id
.unwrap();
assert_eq!(id, "tree");
assert_eq!(d.objects[0].rot, 90.0);
apply(&mut d, &c, op(json!({"op": "update", "id": "tree", "patch": {"x": 7.5, "props": {"r": 2.0}, "tag": "Oak"}}))).unwrap();
assert_eq!(d.objects[0].x, 7.5);
assert_eq!(d.objects[0].props["r"], json!(2.0));
let dup = apply(&mut d, &c, op(json!({"op": "duplicate", "id": "tree"})))
.unwrap()
.id
.unwrap();
assert_eq!(dup, "tree-2");
assert_eq!(d.objects[1].x, 9.5);
apply(
&mut d,
&c,
op(json!({"op": "update", "id": "tree", "patch": {"props": {"r": null}}})),
)
.unwrap();
assert!(d.objects[0].props.is_empty());
apply(&mut d, &c, op(json!({"op": "remove", "id": "tree"}))).unwrap();
assert_eq!(d.objects.len(), 1);
}
#[test]
fn validation_messages_help_the_caller() {
let mut d = Doc::default();
let c = catalog();
let e = apply(&mut d, &c, op(json!({"op": "add", "type": "ufo"}))).unwrap_err();
assert!(e.contains("available: tree"), "{e}");
let e = apply(
&mut d,
&c,
op(json!({"op": "add", "type": "tree", "props": {"r": 9}})),
)
.unwrap_err();
assert!(e.contains("outside"), "{e}");
apply(&mut d, &c, op(json!({"op": "add", "type": "tree"}))).unwrap();
let e = apply(
&mut d,
&c,
op(json!({"op": "update", "id": "tree", "patch": {"colour": 1}})),
)
.unwrap_err();
assert!(e.contains("unknown field"), "{e}");
let e = apply(
&mut d,
&c,
op(json!({"op": "update", "id": "nope", "patch": {}})),
)
.unwrap_err();
assert!(e.contains("no object"), "{e}");
}
#[test]
fn reorder_and_cfg() {
let mut d = Doc::default();
let c = catalog();
for _ in 0..3 {
apply(&mut d, &c, op(json!({"op": "add", "type": "tree"}))).unwrap();
}
apply(
&mut d,
&c,
op(json!({"op": "reorder", "id": "tree", "to": "front"})),
)
.unwrap();
assert_eq!(d.objects[2].id, "tree");
apply(
&mut d,
&c,
op(json!({"op": "set_cfg", "values": {"labels": false}})),
)
.unwrap();
assert_eq!(d.cfg["labels"], json!(false));
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Export: a self-contained HTML page with every view prerendered, or one
//! view as a standalone SVG file.
use crate::assets;
use crate::Project;
use isokit_core::fmt::esc;
use isokit_script::ScriptError;
use serde_json::{json, Value};
/// JSON safe to put inside `<script type="application/json">`.
fn script_json(v: &Value) -> String {
v.to_string().replace("</", "<\\/")
}
/// The static page: all levels in all four rotations, no server needed.
pub fn static_page(p: &Project) -> Result<String, ScriptError> {
let meta = p.meta();
let mut views = serde_json::Map::new();
for l in &meta.levels {
for rot in 0..4u8 {
let v = p.render(&l.id, rot, None, false)?;
views.insert(
format!("{}/{rot}", l.id),
json!({ "html": v.html, "info": v.info, "bbox": v.bbox, "focus": v.focus, "north": v.north, "boxes": v.boxes }),
);
}
}
let data = json!({
"meta": {
"title": meta.title, "note": meta.note, "levels": meta.levels,
"defaultLevel": meta.default_level, "defaultRotation": meta.default_rotation
},
"views": views,
"cfg": p.cfg(None),
});
let css = format!(
"{}\n{}",
assets::get("viewer/isokit-view.css").unwrap_or_default(),
p.theme.css()
);
let page = assets::get("viewer/page.html").unwrap_or_default();
Ok(page
.replace("{{title}}", &esc(&meta.title))
.replace("{{date}}", &today())
.replace("/*ISOKIT_CSS*/", &css)
.replace("/*ISOKIT_DATA*/", &script_json(&data))
.replace(
"/*ISOKIT_JS*/",
&assets::get("viewer/isokit-view.js").unwrap_or_default(),
))
}
/// One view as a standalone SVG (dark or light scheme, styles inlined).
pub fn svg(p: &Project, level: &str, rot: u8, light: bool) -> Result<String, ScriptError> {
let v = p.render(level, rot, None, false)?;
let css = format!(
"{}\n{}",
assets::get("viewer/isokit-view.css").unwrap_or_default(),
p.theme.css_scoped("svg.iso-svg", Some(light))
);
let b = v.bbox;
let bg = if light { "#e8f4ff" } else { "#0b1428" };
Ok(format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"iso-svg\" viewBox=\"{} {} {} {}\" width=\"{}\" height=\"{}\">\
<style>{}</style><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{bg}\"/>{}</svg>\n",
b[0], b[1], b[2], b[3], (b[2] * 2.0).round(), (b[3] * 2.0).round(), css, b[0], b[1], b[2], b[3], v.html
))
}
/// Today's date (UTC) as YYYY-MM-DD, without a date crate.
pub fn today() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
// civil-from-days (H. Hinnant)
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d)
}
+141
View File
@@ -0,0 +1,141 @@
//! A project kept loaded across edits: reload on file changes, save after
//! every operation, undo and redo. The Studio server and the MCP server
//! each hold one.
use crate::ops::{self, Op, Outcome};
use crate::{version, Error, Project};
use isokit_script::doc::Doc;
use std::path::{Path, PathBuf};
pub struct Workspace {
pub dir: PathBuf,
pub project: Project,
/// Fingerprint of the files the loaded project was read from.
pub version: u64,
/// Error of the last reload attempt (the previous project stays loaded).
pub load_error: Option<String>,
undo: Vec<Doc>,
redo: Vec<Doc>,
}
const UNDO_DEPTH: usize = 200;
impl Workspace {
pub fn open(dir: impl AsRef<Path>) -> Result<Workspace, Error> {
let dir = dir.as_ref().to_path_buf();
let project = Project::load(&dir)?;
let version = version(&dir);
Ok(Workspace {
dir,
project,
version,
load_error: None,
undo: vec![],
redo: vec![],
})
}
/// Reload when a project file changed on disk (another editor, an agent,
/// a git pull). Returns true when something was reloaded.
pub fn refresh(&mut self) -> bool {
let v = version(&self.dir);
if v == self.version {
return false;
}
self.version = v;
match Project::load(&self.dir) {
Ok(p) => {
if p.doc != self.project.doc {
self.push_undo(self.project.doc.clone());
}
self.project = p;
self.load_error = None;
}
Err(e) => self.load_error = Some(e.to_string()),
}
true
}
fn push_undo(&mut self, d: Doc) {
self.undo.push(d);
if self.undo.len() > UNDO_DEPTH {
self.undo.remove(0);
}
self.redo.clear();
}
fn save(&mut self) -> Result<(), String> {
self.project.save_doc().map_err(|e| e.to_string())?;
self.version = version(&self.dir);
Ok(())
}
/// Apply an operation to the document and save it.
pub fn apply(&mut self, op: Op) -> Result<Outcome, String> {
self.refresh();
let before = self.project.doc.clone();
let catalog = self.project.catalog().to_vec();
let out = ops::apply(&mut self.project.doc, &catalog, op)?;
if self.project.doc != before {
self.push_undo(before);
self.save()?;
}
Ok(out)
}
/// Apply several operations as one undo step; stops at the first error
/// (earlier ones stay applied and saved).
pub fn apply_all(&mut self, list: Vec<Op>) -> Result<Vec<Outcome>, String> {
self.refresh();
let before = self.project.doc.clone();
let catalog = self.project.catalog().to_vec();
let mut outs = vec![];
let mut err = None;
for op in list {
match ops::apply(&mut self.project.doc, &catalog, op) {
Ok(o) => outs.push(o),
Err(e) => {
err = Some(e);
break;
}
}
}
if self.project.doc != before {
self.push_undo(before);
self.save()?;
}
match err {
Some(e) => Err(format!("after {} operation(s): {e}", outs.len())),
None => Ok(outs),
}
}
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn undo(&mut self) -> Result<bool, String> {
self.refresh();
let Some(prev) = self.undo.pop() else {
return Ok(false);
};
let cur = std::mem::replace(&mut self.project.doc, prev);
self.redo.push(cur);
self.save()?;
Ok(true)
}
pub fn redo(&mut self) -> Result<bool, String> {
self.refresh();
let Some(next) = self.redo.pop() else {
return Ok(false);
};
let cur = std::mem::replace(&mut self.project.doc, next);
self.undo.push(cur);
self.save()?;
Ok(true)
}
}