Deploy web Studio / deploy (push) Successful in 3m27s
House parts (stdlib isokit/house): house_wall drawn along a line, with ends that adapt at corners, T-junctions and straight runs so no two walls overlap; window and door that sit on a wall and cut its opening (door styles panel, glass, double, sliding, opening; swing and hinge by rotation); floor (boards, tiles, carpet, stone); roof (hip, gable, flat, optional solar); stairs. Furniture (stdlib isokit/furniture): chair, sofa / armchair, table with chairs, desk with monitor and chair, TV, bed, cupboard, bookshelf, kitchen run, bathtub, shower, toilet, washbasin, plant, rug, floor lamp. Cut-open levels: on gf / 1f the walls in front of a room are cut low (rooms are found by their floors), the roof becomes an outline on the floor, and storeys above are left out. A part's storey comes from z (storey_h, default 3 m). Scripting: objects_of, objects_at, objects_on, mount_of, mounted, frame, to_world, to_local, local_of. Prefab meta: shape (line / rect), mount, storey. Studio: drag to draw lines and rectangles (walls, roads, fences, hedges, floors, areas, buildings, roofs). The Room tool draws a floor plus walls, without doubling walls that already stand on an edge. Windows and doors snap onto walls with a hover preview, and they move, turn, copy and delete with their wall in one undo step. New parts land on the storey the view shows. Palette categories collapse, and the palette scrolls apart from the object list. The MCP instructions explain house building, and list_prefabs shows sits_on. examples/house is a furnished bungalow; the web Studio gets a House demo button. Tests: wall corners and junctions, zero depth-sort cycles in every view, cut levels, and the house demo in every view; check_studio covers the room / window / door / storey flow, check_web the House demo.
619 lines
39 KiB
JavaScript
619 lines
39 KiB
JavaScript
/* isokit Studio: the scene editor. Talks to `isokit studio` over /api, or to
|
||
* window.IsoKitBackend when a page provides one (the web Studio runs the
|
||
* engine as WASM in the page and answers the same requests). */
|
||
(function () {
|
||
"use strict";
|
||
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
// { api(path, body) -> json | Promise, source(meta), agentBox(meta), preview(), saveTitle }
|
||
var B = window.IsoKitBackend || null;
|
||
var S = { meta: null, doc: null, version: null, sel: null, placing: null, view: null, boxes: {}, warnings: [], busy: false, canUndo: false, canRedo: false, port: location.port };
|
||
var ICON_DEFAULT = "▪";
|
||
|
||
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
||
function api(path, body) {
|
||
if (B) return Promise.resolve().then(function () { return B.api(path, body); });
|
||
var o = body === undefined ? { cache: "no-store" } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
||
return fetch(path, o).then(function (r) { return r.json(); });
|
||
}
|
||
var toastT = 0;
|
||
function toast(msg, bad) {
|
||
var t = $("toast");
|
||
t.textContent = msg; t.hidden = false; t.classList.toggle("st-bad", !!bad);
|
||
clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, bad ? 6000 : 2600);
|
||
}
|
||
function snapStep() { return parseFloat($("snap").value) || 0; }
|
||
function snap(v) { var s = snapStep(); return s ? Math.round(v / s) * s : Math.round(v * 100) / 100; }
|
||
function prefab(kind) { return (S.meta.catalog || []).filter(function (m) { return m.type === kind; })[0]; }
|
||
function obj(id) { return (S.doc.objects || []).filter(function (o) { return o.id === id; })[0]; }
|
||
function label(o) { var m = prefab(o.type); return o.name || (m ? m.label : o.type); }
|
||
function icon(kind) { var m = prefab(kind); return (m && m.icon) || ICON_DEFAULT; }
|
||
function r3(v) { return Math.round(v * 1000) / 1000; }
|
||
|
||
// ------------------------------------------------------------ storeys, frames, mounts
|
||
// The same rules as the scripts (stdlib/house.rhai, mount_of in api.rs).
|
||
var ROOM = { type: "@room", label: "Room", category: "House", icon: "⬚", shape: "rect", storey: true, props: [],
|
||
help: "Drag a rectangle: a floor with house walls around it. Walls already standing along an edge are not doubled." };
|
||
function pmeta(kind) { return kind === ROOM.type ? ROOM : prefab(kind); }
|
||
function floorOf(lv) { if (lv === "gf") return 0; var m = /^(\d+)f$/.exec(lv || ""); return m ? +m[1] : -1; }
|
||
function storeyH() {
|
||
var v = (S.doc.cfg || {}).storey_h;
|
||
if (typeof v !== "number") { var d = (S.meta.params || []).filter(function (p) { return p.name === "storey_h"; })[0]; v = d ? +d.default : 3; }
|
||
return v > 0 ? v : 3;
|
||
}
|
||
// height new objects go to: the floor of the storey the view shows, for prefabs that live on storeys
|
||
function placeZ(m) { if (!m || !m.storey) return 0; var k = floorOf(S.view.getLevel()); return k > 0 ? r3(k * storeyH()) : 0; }
|
||
function worldAt(e, z) { return S.view.toWorld(e.clientX, e.clientY, z || 0); }
|
||
function qOf(rot) { return ((Math.round((rot || 0) / 90) % 4) + 4) % 4; }
|
||
function turnV(q, x, y) { return q === 0 ? [x, y] : q === 1 ? [-y, x] : q === 2 ? [-x, -y] : [y, -x]; }
|
||
function localOf(o, x, y) { return turnV((4 - qOf(o.rot)) % 4, x - o.x, y - o.y); }
|
||
function worldOf(o, u, v) { var d = turnV(qOf(o.rot), u, v); return [o.x + d[0], o.y + d[1]]; }
|
||
function defOf(m, k) { var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0]; return d ? d.default : undefined; }
|
||
function propOf(o, k) { var p = o.props || {}; return p[k] !== undefined ? p[k] : defOf(prefab(o.type), k); }
|
||
function clampProp(m, k, v) {
|
||
var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0];
|
||
if (d && d.min !== undefined) v = Math.max(d.min, v);
|
||
if (d && d.max !== undefined) v = Math.min(d.max, v);
|
||
return r3(v);
|
||
}
|
||
// the host an object sits on (a window's wall): nearest centre line within half its thickness + 0.3 m
|
||
function hostOf(o) {
|
||
var m = prefab(o.type), hosts = (m && m.mount) || [], best = null;
|
||
if (!hosts.length) return null;
|
||
S.doc.objects.forEach(function (h) {
|
||
if (h.hidden || h.id === o.id || hosts.indexOf(h.type) < 0 || Math.abs((o.z || 0) - (h.z || 0)) > 0.5) return;
|
||
var l = localOf(h, o.x, o.y), half = (+propOf(h, "w") || 1) / 2, lim = (+propOf(h, "t") || 0) / 2 + 0.3;
|
||
if (Math.abs(l[0]) <= half + 1e-6 && Math.abs(l[1]) <= lim + 1e-6 && (!best || Math.abs(l[1]) < Math.abs(best.v) - 1e-9)) best = { host: h, u: l[0], v: l[1] };
|
||
});
|
||
return best;
|
||
}
|
||
function isHost(kind) { return (S.meta.catalog || []).some(function (m) { return (m.mount || []).indexOf(kind) >= 0; }); }
|
||
// the objects sitting on h; they move, turn, copy and delete with it
|
||
function mountedOn(h) {
|
||
if (!isHost(h.type)) return [];
|
||
return S.doc.objects.filter(function (o) { var r = o.id !== h.id && hostOf(o); return r && r.host.id === h.id; });
|
||
}
|
||
// where a window or door goes when dropped at w: onto the nearest host's centre line (within 1.5 m),
|
||
// along it by the snap step, kept inside the host's length
|
||
function snapMount(m, w, z, selfId, width) {
|
||
var best = null;
|
||
S.doc.objects.forEach(function (h) {
|
||
if (h.hidden || h.id === selfId || (m.mount || []).indexOf(h.type) < 0 || Math.abs((h.z || 0) - z) > 0.5) return;
|
||
var l = localOf(h, w[0], w[1]), half = (+propOf(h, "w") || 1) / 2, u = Math.max(-half, Math.min(half, l[0])), d = Math.hypot(l[0] - u, l[1]);
|
||
if (d < 1.5 && (!best || d < best.d)) best = { host: h, u: u, side: l[1] < 0 ? -1 : 1, d: d, half: half };
|
||
});
|
||
if (!best) return null;
|
||
var s = snapStep(), u = s ? Math.round(best.u / s) * s : best.u, hw = (+width || 1) / 2;
|
||
u = hw >= best.half ? 0 : Math.max(-best.half + hw, Math.min(best.half - hw, u));
|
||
var p = worldOf(best.host, u, 0);
|
||
return { x: r3(p[0]), y: r3(p[1]), z: best.host.z || 0, host: best.host, u: u, side: best.side, hw: hw };
|
||
}
|
||
|
||
// ------------------------------------------------------------ ghosts: previews drawn over the view
|
||
function ghost(polys, text) {
|
||
var svg = S.view.svg, g = svg.querySelector(".st-ghosts");
|
||
if (!polys || !polys.length) { if (g) g.parentNode.removeChild(g); return; }
|
||
if (!g) { g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute("class", "st-ghosts"); svg.appendChild(g); }
|
||
var rot = S.view.getRotation();
|
||
g.innerHTML = polys.map(function (p) {
|
||
return '<path class="st-ghost' + (p.cls ? " " + p.cls : "") + '" d="' + p.pts.map(function (q, i) {
|
||
var s = IsoKit.project(rot, q[0], q[1], q[2] || 0);
|
||
return (i ? "L" : "M") + s[0].toFixed(1) + " " + s[1].toFixed(1);
|
||
}).join("") + 'Z"/>';
|
||
}).join("");
|
||
if (text !== undefined) { var h = $("hint"); h.textContent = text; h.hidden = false; }
|
||
}
|
||
function rectPts(x0, y0, x1, y1, z) { return [[x0, y0, z], [x1, y0, z], [x1, y1, z], [x0, y1, z]]; }
|
||
// an upright quad from a to b (plan points), z0 to z1
|
||
function planePts(a, b, z0, z1) { return [[a[0], a[1], z0], [b[0], b[1], z0], [b[0], b[1], z1], [a[0], a[1], z1]]; }
|
||
function mountGhost(m, sm, o) {
|
||
var z0 = +(o ? propOf(o, "sill") : defOf(m, "sill")) || 0, h = +(o ? propOf(o, "h") : defOf(m, "h")) || 2;
|
||
return [{ pts: planePts(worldOf(sm.host, sm.u - sm.hw, 0), worldOf(sm.host, sm.u + sm.hw, 0), sm.z + z0, sm.z + z0 + h), cls: "st-plane" }];
|
||
}
|
||
|
||
// ------------------------------------------------------------ server state
|
||
function takeState(r) {
|
||
if (r.doc) S.doc = r.doc;
|
||
if (r.version !== undefined) S.version = r.version;
|
||
if (r.can_undo !== undefined) { S.canUndo = r.can_undo; S.canRedo = r.can_redo; }
|
||
$("undo").disabled = !S.canUndo; $("redo").disabled = !S.canRedo;
|
||
}
|
||
function op(o, quiet) {
|
||
$("save").textContent = "Saving…"; $("save").classList.remove("st-err");
|
||
return api("/api/op", o).then(function (r) {
|
||
if (r.error) {
|
||
$("save").textContent = "Not saved"; $("save").classList.add("st-err");
|
||
toast(r.error, true);
|
||
return null;
|
||
}
|
||
takeState(r);
|
||
$("save").textContent = "Saved";
|
||
if (r.id && o.op !== "update") S.sel = r.id;
|
||
if (S.sel && !obj(S.sel)) S.sel = null;
|
||
S.view.refresh();
|
||
renderLists(); if (!quiet) renderRight();
|
||
return r;
|
||
}, function (e) { $("save").textContent = B ? "Not saved" : "Offline"; $("save").classList.add("st-err"); toast((B ? "Could not save: " : "Studio server not reachable: ") + e, true); });
|
||
}
|
||
function update(id, patch, quiet) { return op({ op: "update", id: id, patch: patch }, quiet); }
|
||
function undo() { api("/api/undo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
||
function redo() { api("/api/redo", {}).then(function (r) { takeState(r); afterExternal(); }); }
|
||
function afterExternal() {
|
||
if (S.sel && !obj(S.sel)) S.sel = null;
|
||
S.view.refresh(); renderLists(); renderRight();
|
||
}
|
||
function reloadAll(why) {
|
||
return Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) {
|
||
var levelsChanged = JSON.stringify(rs[0].levels) !== JSON.stringify(S.meta.levels);
|
||
S.meta = rs[0]; takeState(rs[1]);
|
||
if (levelsChanged) { remount(); } else { S.view.source.meta = S.meta; S.view.refresh(); }
|
||
renderPalette(); afterExternal();
|
||
if (why) toast(why);
|
||
});
|
||
}
|
||
// another editor or an agent changed the files: follow along
|
||
function poll() {
|
||
api("/api/version").then(function (r) {
|
||
if (r.version !== S.version && !S.busy) { S.version = r.version; reloadAll(r.why || "Scene updated outside the Studio (agent or file edit)"); }
|
||
}).catch(function () { /* server restarting */ }).then(function () { setTimeout(poll, 800); });
|
||
}
|
||
|
||
// ------------------------------------------------------------ view
|
||
var drag = null, tool = null;
|
||
function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); }
|
||
function mountView() {
|
||
var src = B ? B.source(toViewerMeta(S.meta)) : IsoKit.httpSource("", toViewerMeta(S.meta));
|
||
S.view = IsoKit.mount($("view"), src, {
|
||
note: false, noSelect: true, wheelZoom: true,
|
||
onRender: function (d) {
|
||
if (!d || d.error) { $("stats").textContent = "script error"; return; }
|
||
S.boxes = d.boxes || {}; S.warnings = d.warnings || [];
|
||
$("stats").textContent = d.sprites + " sprites · " + d.nodes + " nodes · " + d.ms + " ms";
|
||
var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s");
|
||
if (S.sel) S.view.selectObj(S.sel);
|
||
},
|
||
onHover: function (w, e) {
|
||
if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1);
|
||
if (S.placing && !tool && e) hoverPlacing(e);
|
||
},
|
||
onPick: function (i) { select(i && i.obj ? i.obj : null); },
|
||
onKey: keyHandler,
|
||
interact: {
|
||
down: function (e, hit) {
|
||
if (S.placing) {
|
||
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z);
|
||
tool = { m: pm, z: z, a: [snap(a[0]), snap(a[1])], b: null };
|
||
return true;
|
||
}
|
||
if (!hit.obj) return false;
|
||
var o = obj(hit.obj);
|
||
if (!o || o.locked) return false;
|
||
var m = prefab(o.type), mount = !!(m && m.mount && m.mount.length), h = mount ? hostOf(o) : null;
|
||
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, m: m, mount: mount,
|
||
rel: h ? qOf((o.rot || 0) - (h.host.rot || 0)) : null, carry: mountedOn(o) };
|
||
return true;
|
||
},
|
||
move: function (e, mv) {
|
||
if (S.placing) { if (tool && mv.world) drawTool(e); return; }
|
||
if (!drag || !mv.world || !drag.w0) return;
|
||
if (!drag.moved) {
|
||
drag.moved = true; select(drag.id, true); drag.els = [];
|
||
// what sits on the object (a wall's windows) moves along
|
||
[drag.id].concat(drag.carry.map(function (c) { return c.id; })).forEach(function (id) { Array.prototype.push.apply(drag.els, moveEls(id)); });
|
||
}
|
||
var tx = drag.ox + mv.world[0] - drag.w0[0], ty = drag.oy + mv.world[1] - drag.w0[1];
|
||
var dx = snap(tx) - drag.ox, dy = snap(ty) - drag.oy;
|
||
drag.snapTo = null;
|
||
if (drag.mount) {
|
||
// windows and doors snap onto the nearest wall
|
||
var o = obj(drag.id), sm = o && snapMount(drag.m, [tx, ty], o.z || 0, o.id, propOf(o, "w"));
|
||
if (sm) { drag.snapTo = sm; dx = sm.x - drag.ox; dy = sm.y - drag.oy; ghost(mountGhost(drag.m, sm, o)); } else ghost(null);
|
||
}
|
||
var a = S.view.screenOf(dx, dy, 0), b = S.view.screenOf(0, 0, 0);
|
||
drag.dx = dx; drag.dy = dy;
|
||
drag.els.forEach(function (g) { g.setAttribute("transform", "translate(" + (a[0] - b[0]).toFixed(1) + " " + (a[1] - b[1]).toFixed(1) + ")"); g.classList.add("iso-moving"); });
|
||
$("cursor").textContent = "x " + (drag.ox + dx).toFixed(2) + " y " + (drag.oy + dy).toFixed(2);
|
||
},
|
||
up: function (e, mv) {
|
||
if (S.placing) { var t = tool; tool = null; ghost(null); if (t && !mv.cancel) finishTool(t, e); return; }
|
||
var d = drag; drag = null; ghost(null);
|
||
if (!d) return;
|
||
if (!d.moved || mv.cancel) { if (d.moved) S.view.refresh(); select(d.id); return; }
|
||
if (d.snapTo) {
|
||
// on a wall: keep the turn relative to the wall it came from (door swing), else open to the side dropped on
|
||
var sm = d.snapTo, rel = d.rel !== null ? d.rel * 90 : (sm.side < 0 ? 180 : 0);
|
||
update(d.id, { x: sm.x, y: sm.y, z: sm.z, rot: ((sm.host.rot || 0) + rel) % 360 });
|
||
} else if (d.dx || d.dy) moveWith(d.id, d.dx, d.dy, d.carry);
|
||
else S.view.refresh();
|
||
},
|
||
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; tool = null; ghost(null); }
|
||
}
|
||
});
|
||
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true };
|
||
}
|
||
function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; }
|
||
function remount() {
|
||
var lv = S.view ? S.view.getLevel() : null, rot = S.view ? S.view.getRotation() : null;
|
||
if (S.view) S.view.destroy();
|
||
mountView();
|
||
if (lv) S.view.setLevel(lv);
|
||
if (rot !== null) S.view.setRotation(rot);
|
||
}
|
||
|
||
// ------------------------------------------------------------ actions
|
||
// the hover preview while placing a window or door: where on which wall it goes
|
||
function hoverPlacing(e) {
|
||
var m = pmeta(S.placing);
|
||
if (!m || !m.mount || !m.mount.length) return;
|
||
var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w"));
|
||
ghost(sm ? mountGhost(m, sm) : null);
|
||
}
|
||
// a drag while placing: "line" prefabs run from the press to the pointer along the main axis,
|
||
// "rect" prefabs (and rooms) fill the rectangle
|
||
function toolGeom(t) {
|
||
var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1];
|
||
if (t.m.shape === "line") {
|
||
var ax = Math.abs(dx) >= Math.abs(dy), L = Math.abs(ax ? dx : dy);
|
||
return { line: true, ax: ax, len: L, x: ax ? (a[0] + b[0]) / 2 : a[0], y: ax ? a[1] : (a[1] + b[1]) / 2, ok: L >= 0.25 };
|
||
}
|
||
return { w: Math.abs(dx), d: Math.abs(dy), x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2,
|
||
x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]), ok: Math.abs(dx) >= 0.25 && Math.abs(dy) >= 0.25 };
|
||
}
|
||
function drawTool(e) {
|
||
var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = [];
|
||
t.b = [snap(w[0]), snap(w[1])];
|
||
if (m.shape !== "line" && m.shape !== "rect") return;
|
||
var g = toolGeom(t);
|
||
if (g.line) {
|
||
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, L = g.len / 2;
|
||
polys.push({ pts: g.ax ? rectPts(g.x - L, g.y - th / 2, g.x + L, g.y + th / 2, z) : rectPts(g.x - th / 2, g.y - L, g.x + th / 2, g.y + L, z) });
|
||
if (h > 0) polys.push({ pts: planePts(g.ax ? [g.x - L, g.y] : [g.x, g.y - L], g.ax ? [g.x + L, g.y] : [g.x, g.y + L], z, z + h), cls: "st-plane" });
|
||
ghost(polys, m.label + " " + g.len.toFixed(2) + " m · release to place");
|
||
return;
|
||
}
|
||
polys.push({ pts: rectPts(g.x0, g.y0, g.x1, g.y1, z) });
|
||
if (m === ROOM) {
|
||
var wh = +defOf(prefab("house_wall"), "h") || 2.8;
|
||
[[g.x0, g.y0, g.x1, g.y0], [g.x0, g.y1, g.x1, g.y1], [g.x0, g.y0, g.x0, g.y1], [g.x1, g.y0, g.x1, g.y1]].forEach(function (s) {
|
||
polys.push({ pts: planePts([s[0], s[1]], [s[2], s[3]], z, z + wh), cls: "st-plane" });
|
||
});
|
||
}
|
||
ghost(polys, m.label + " " + g.w.toFixed(2) + " × " + g.d.toFixed(2) + " m · release to place");
|
||
}
|
||
function finishTool(t, e) {
|
||
var m = t.m, keep = e.shiftKey, g = t.b ? toolGeom(t) : null, add;
|
||
var at = worldAt(e, t.z), p = [snap(at[0]), snap(at[1])];
|
||
if (m === ROOM) {
|
||
if (!g || !g.ok) { toast("Drag a rectangle on the map to draw a room"); setPlacing(m.type); return; }
|
||
var fm = prefab("floor"), ops = roomWalls(g.x0, g.y0, g.x1, g.y1, t.z);
|
||
ops.push({ op: "add", type: "floor", x: r3(g.x), y: r3(g.y), z: t.z, props: { w: clampProp(fm, "w", g.w), d: clampProp(fm, "d", g.d) } });
|
||
op({ ops: ops }).then(function (r) { if (r) toast("Room " + g.w.toFixed(1) + " × " + g.d.toFixed(1) + " m: a floor and " + (ops.length - 1) + " wall" + (ops.length === 2 ? "" : "s")); });
|
||
if (!keep) setPlacing(null);
|
||
return;
|
||
}
|
||
if (g && g.ok && m.shape === "line") add = { type: m.type, x: r3(g.x), y: r3(g.y), rot: g.ax ? 0 : 90, props: { w: clampProp(m, "w", g.len) } };
|
||
else if (g && g.ok && m.shape === "rect") add = { type: m.type, x: r3(g.x), y: r3(g.y), props: { w: clampProp(m, "w", g.w), d: clampProp(m, "d", g.d) } };
|
||
else if (m.mount && m.mount.length) {
|
||
// onto the nearest wall, opening towards the side clicked on; on no wall it stands alone
|
||
var sm = snapMount(m, at, t.z, null, defOf(m, "w"));
|
||
add = sm ? { type: m.type, x: sm.x, y: sm.y, z: sm.z, rot: ((sm.host.rot || 0) + (sm.side < 0 ? 180 : 0)) % 360 } : { type: m.type, x: p[0], y: p[1] };
|
||
} else add = { type: m.type, x: p[0], y: p[1] };
|
||
add.op = "add";
|
||
if (add.z === undefined && t.z) add.z = t.z;
|
||
op(add).then(function (r) { if (r) toast(m.label + " added" + (keep ? "" : " · Shift to place several")); });
|
||
if (!keep) setPlacing(null);
|
||
}
|
||
// walls around a room's rectangle, leaving out the stretches a wall already stands on
|
||
function roomWalls(x0, y0, x1, y1, z) {
|
||
var walls = S.doc.objects.filter(function (o) { return o.type === "house_wall" && !o.hidden && Math.abs((o.z || 0) - z) < 0.5; }), out = [];
|
||
[[x0, y0, x1, y0], [x0, y1, x1, y1], [x0, y0, x0, y1], [x1, y0, x1, y1]].forEach(function (e) {
|
||
var ax = e[1] === e[3], line = ax ? e[1] : e[0], lo = ax ? e[0] : e[1], hi = ax ? e[2] : e[3], cov = [];
|
||
walls.forEach(function (w) {
|
||
if ((qOf(w.rot) % 2 === 0) !== ax || Math.abs((ax ? w.y : w.x) - line) > 0.05) return;
|
||
var c = ax ? w.x : w.y, L = (+propOf(w, "w") || 0) / 2;
|
||
cov.push([c - L, c + L]);
|
||
});
|
||
cov.sort(function (a, b) { return a[0] - b[0]; });
|
||
var segs = [], cur = lo;
|
||
cov.forEach(function (c) { if (c[0] > cur + 0.05) segs.push([cur, Math.min(c[0], hi)]); cur = Math.max(cur, c[1]); });
|
||
if (hi > cur + 0.05) segs.push([cur, hi]);
|
||
segs.forEach(function (s) {
|
||
if (s[1] - s[0] < 0.2) return;
|
||
var mid = r3((s[0] + s[1]) / 2);
|
||
out.push({ op: "add", type: "house_wall", x: ax ? mid : line, y: ax ? line : mid, z: z, rot: ax ? 0 : 90, props: { w: r3(s[1] - s[0]) } });
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
function setPlacing(kind) {
|
||
S.placing = kind; tool = null;
|
||
if (S.view) ghost(null);
|
||
document.querySelector(".st-centre").classList.toggle("st-placing", !!kind);
|
||
Array.prototype.forEach.call(document.querySelectorAll(".st-tile"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-type") === kind)); });
|
||
var h = $("hint");
|
||
if (kind) {
|
||
var m = pmeta(kind), lab = m ? m.label : kind;
|
||
h.textContent = m === ROOM ? "Drag a rectangle on the map: a room (floor and walls) · Esc to stop"
|
||
: m && m.mount && m.mount.length ? "Click on a wall to place: " + lab + " · Esc to stop"
|
||
: m && m.shape === "line" ? "Drag along the map to draw: " + lab + " (a click gives the default length) · Esc to stop"
|
||
: m && m.shape === "rect" ? "Drag a rectangle on the map: " + lab + " (a click gives the default size) · Esc to stop"
|
||
: "Click on the map to place: " + lab + " · Esc to stop";
|
||
h.hidden = false;
|
||
} else h.hidden = true;
|
||
}
|
||
function select(id, quiet) {
|
||
if (id && !obj(id)) id = null;
|
||
S.sel = id;
|
||
S.view.selectObj(id);
|
||
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(li.getAttribute("data-id") === id)); });
|
||
if (!quiet) renderRight();
|
||
}
|
||
function opList(ops, quiet) { return op(ops.length === 1 ? ops[0] : { ops: ops }, quiet); }
|
||
// the object and what sits on it (a wall's windows and doors) as one undo step; the object last, so it stays selected
|
||
function remove(id) {
|
||
var o = obj(id); if (!o) return;
|
||
var carry = mountedOn(o);
|
||
opList(carry.map(function (c) { return { op: "remove", id: c.id }; }).concat([{ op: "remove", id: id }])).then(function (r) {
|
||
if (r) toast("Deleted" + (carry.length ? " with the " + carry.length + " window" + (carry.length === 1 ? "" : "s") + " / door" + (carry.length === 1 ? "" : "s") + " on it" : "") + " · Ctrl+Z brings it back");
|
||
});
|
||
}
|
||
function duplicate(id) {
|
||
var o = obj(id); if (!o) return;
|
||
var dd = Math.max(snapStep(), 1) * 2, dx = dd, dy = dd, h = hostOf(o);
|
||
// a copy of a window or door goes further along its wall
|
||
if (h) { var v = turnV(qOf(h.host.rot), (+propOf(o, "w") || 1) + 0.5, 0); dx = r3(v[0]); dy = r3(v[1]); }
|
||
opList(mountedOn(o).map(function (c) { return { op: "duplicate", id: c.id, dx: dx, dy: dy }; }).concat([{ op: "duplicate", id: id, dx: dx, dy: dy }]));
|
||
}
|
||
function moveWith(id, dx, dy, carry, quiet) {
|
||
var o = obj(id); if (!o) return Promise.resolve(null);
|
||
var ops = (carry || mountedOn(o)).map(function (c) { return { op: "update", id: c.id, patch: { x: r3(c.x + dx), y: r3(c.y + dy) } }; });
|
||
ops.push({ op: "update", id: id, patch: { x: r3(o.x + dx), y: r3(o.y + dy) } });
|
||
return opList(ops, quiet);
|
||
}
|
||
function turn(id, d) {
|
||
var o = obj(id); if (!o) return;
|
||
var q = qOf(d), ops = mountedOn(o).map(function (c) {
|
||
var v = turnV(q, c.x - o.x, c.y - o.y);
|
||
return { op: "update", id: c.id, patch: { x: r3(o.x + v[0]), y: r3(o.y + v[1]), rot: ((c.rot || 0) + d + 360) % 360 } };
|
||
});
|
||
ops.push({ op: "update", id: id, patch: { rot: ((o.rot || 0) + d + 360) % 360 } });
|
||
opList(ops);
|
||
}
|
||
function nudge(id, dx, dy) {
|
||
var o = obj(id); if (!o || o.locked) return;
|
||
var s = snapStep() || 0.5;
|
||
// arrow keys follow the screen: map screen directions onto world axes for the current rotation
|
||
var rot = S.view.getRotation();
|
||
var right = IsoKit.unproject(rot, 1, 0, 0), up = IsoKit.unproject(rot, 0, -1, 0), o0 = IsoKit.unproject(rot, 0, 0, 0);
|
||
var r = [right[0] - o0[0], right[1] - o0[1]], u = [up[0] - o0[0], up[1] - o0[1]];
|
||
var wx = dx * r[0] + dy * u[0], wy = dx * r[1] + dy * u[1];
|
||
// pick the dominant world axis so a key press moves along x or y only
|
||
if (Math.abs(wx) >= Math.abs(wy)) { wy = 0; wx = Math.sign(wx); } else { wx = 0; wy = Math.sign(wy); }
|
||
moveWith(id, wx * s, wy * s, null, true).then(function () { renderRight(); });
|
||
}
|
||
function keyHandler(e) {
|
||
var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
|
||
if (typing || document.querySelector("dialog[open]")) return false;
|
||
var k = e.key, ctrl = e.ctrlKey || e.metaKey;
|
||
if (ctrl && (k === "z" || k === "Z")) { if (e.shiftKey) redo(); else undo(); return true; }
|
||
if (ctrl && (k === "y" || k === "Y")) { redo(); return true; }
|
||
if (k === "Escape") { if (S.placing) setPlacing(null); else select(null); return true; }
|
||
if (!S.sel) return false;
|
||
if (k === "Delete" || k === "Backspace") { remove(S.sel); return true; }
|
||
if (ctrl && (k === "d" || k === "D")) { duplicate(S.sel); return true; }
|
||
if (!ctrl && (k === "r" || k === "R")) { turn(S.sel, e.shiftKey ? -90 : 90); return true; }
|
||
if (k === "ArrowLeft") { nudge(S.sel, -1, 0); return true; }
|
||
if (k === "ArrowRight") { nudge(S.sel, 1, 0); return true; }
|
||
if (k === "ArrowUp") { nudge(S.sel, 0, 1); return true; }
|
||
if (k === "ArrowDown") { nudge(S.sel, 0, -1); return true; }
|
||
return false;
|
||
}
|
||
document.addEventListener("keydown", function (e) {
|
||
if (S.view && e.target === S.view.svg) return; // the viewer calls keyHandler itself
|
||
if (keyHandler(e)) e.preventDefault();
|
||
});
|
||
|
||
// ------------------------------------------------------------ left panel
|
||
function renderPalette() {
|
||
var q = $("psearch").value.trim().toLowerCase(), cats = {}, order = [], list = (S.meta.catalog || []).slice();
|
||
// the Room tool (a floor with walls) leads the House parts
|
||
var hi = list.findIndex(function (m) { return m.category === "House"; });
|
||
if (hi >= 0 && prefab("house_wall") && prefab("floor")) list.splice(hi, 0, ROOM);
|
||
list.forEach(function (m) {
|
||
if (q && (m.label + " " + m.type + " " + m.category + " " + (m.help || "")).toLowerCase().indexOf(q) < 0) return;
|
||
if (!cats[m.category]) { cats[m.category] = []; order.push(m.category); }
|
||
cats[m.category].push(m);
|
||
});
|
||
var closed = paletteClosed();
|
||
$("palette").innerHTML = order.map(function (c) {
|
||
var shut = !q && closed.indexOf(c) >= 0;
|
||
return '<div><div class="st-cat' + (shut ? " st-closed" : "") + '" data-cat="' + esc(c) + '" title="Show / hide">' + esc(c) + " <small>" + cats[c].length + "</small></div>" +
|
||
'<div class="st-tiles"' + (shut ? " hidden" : "") + ">" + cats[c].map(function (m) {
|
||
return '<button type="button" class="st-tile" data-type="' + esc(m.type) + '" title="' + esc(m.help || m.label) + '" aria-pressed="' + (S.placing === m.type) + '"><i>' + esc(m.icon || ICON_DEFAULT) + "</i><span>" + esc(m.label) + "</span></button>";
|
||
}).join("") + "</div></div>";
|
||
}).join("") || '<div class="st-help">No object matches.</div>';
|
||
}
|
||
function renderLists() {
|
||
var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || [];
|
||
$("ocount").textContent = list.length;
|
||
$("objects").innerHTML = list.filter(function (o) {
|
||
return !q || (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0;
|
||
}).slice().reverse().map(function (o) {
|
||
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (o.id === S.sel) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
|
||
}).join("");
|
||
}
|
||
// closed palette categories, remembered in this browser
|
||
function paletteClosed() { try { return JSON.parse(localStorage.getItem("isokit-palette-closed") || "[]"); } catch (e) { return []; } }
|
||
$("palette").addEventListener("click", function (e) {
|
||
var h = e.target.closest(".st-cat");
|
||
if (h) {
|
||
var c = h.getAttribute("data-cat"), list = paletteClosed(), i = list.indexOf(c);
|
||
if (i >= 0) list.splice(i, 1); else list.push(c);
|
||
try { localStorage.setItem("isokit-palette-closed", JSON.stringify(list)); } catch (er) { /* private mode */ }
|
||
renderPalette();
|
||
return;
|
||
}
|
||
var b = e.target.closest(".st-tile"); if (!b) return;
|
||
var k = b.getAttribute("data-type");
|
||
setPlacing(S.placing === k ? null : k);
|
||
S.view.svg.focus();
|
||
});
|
||
$("psearch").addEventListener("input", renderPalette);
|
||
$("osearch").addEventListener("input", renderLists);
|
||
$("objects").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li) select(li.getAttribute("data-id")); });
|
||
$("objects").addEventListener("dblclick", function (e) { var li = e.target.closest("li"); if (li) S.view.focus(li.getAttribute("data-id"), 3); });
|
||
|
||
// ------------------------------------------------------------ right panel
|
||
function field(key, lab, html, full) { return '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : html); }
|
||
function propInput(d, v) {
|
||
var id = "p-" + d.key, val = v === undefined ? d.default : v;
|
||
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + ">";
|
||
if (d.type === "choice" || (d.type === "material" && d.choices && d.choices.length)) {
|
||
var ch = (d.choices || []).slice(); if (val !== undefined && ch.indexOf(val) < 0) ch.unshift(val);
|
||
return '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + ch.map(function (c) { return "<option" + (c === val ? " selected" : "") + ">" + esc(c) + "</option>"; }).join("") + "</select>";
|
||
}
|
||
if (d.type === "material") return '<input id="' + id + '" data-prop="' + esc(d.key) + '" list="materials" value="' + esc(val) + '">';
|
||
if (d.type === "float" || d.type === "int") {
|
||
var step = d.step || (d.type === "int" ? 1 : 0.1), mm = (d.min !== undefined ? ' min="' + d.min + '"' : "") + (d.max !== undefined ? ' max="' + d.max + '"' : "");
|
||
var range = d.min !== undefined && d.max !== undefined ? '<input type="range" data-prop="' + esc(d.key) + '" data-range="1"' + mm + ' step="' + step + '" value="' + esc(val) + '">' : "<span></span>";
|
||
return '<div class="st-num">' + range + '<input type="number" id="' + id + '" data-prop="' + esc(d.key) + '"' + mm + ' step="' + step + '" value="' + esc(val) + '"></div>';
|
||
}
|
||
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '">';
|
||
}
|
||
function renderRight() {
|
||
var R = $("right"), o = S.sel ? obj(S.sel) : null;
|
||
if (!o) { renderProject(R); return; }
|
||
var m = prefab(o.type) || { label: o.type, props: [] }, p = o.props || {};
|
||
var levels = S.meta.levels || [];
|
||
var h = "<h3><i>" + esc(m.icon || ICON_DEFAULT) + "</i>" + esc(label(o)) + "</h3>" +
|
||
'<div class="st-sub">' + esc(m.label) + " · id " + esc(o.id) + "</div>" +
|
||
(m.help ? '<p class="st-help">' + esc(m.help) + "</p>" : "") +
|
||
'<div class="st-actions">' +
|
||
'<button type="button" data-act="dup" title="Ctrl+D">Duplicate</button>' +
|
||
'<button type="button" data-act="focus" title="Zoom to it">Zoom to</button>' +
|
||
'<button type="button" data-act="front" title="Draw after the others (ground items)">To front</button>' +
|
||
'<button type="button" data-act="back" title="Draw before the others (ground items)">To back</button>' +
|
||
'<button type="button" data-act="lock">' + (o.locked ? "Unlock" : "Lock") + "</button>" +
|
||
'<button type="button" data-act="hide">' + (o.hidden ? "Show" : "Hide") + "</button>" +
|
||
'<button type="button" data-act="del" class="st-danger" title="Delete">Delete</button></div>' +
|
||
S.warnings.filter(function (w) { return w.indexOf("object " + o.id + ":") === 0; }).map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") +
|
||
'<h2>Placement</h2><div class="st-form">' +
|
||
field("xy", "Position x y z", '<div class="st-xy"><input type="number" step="any" data-f="x" value="' + o.x + '" title="x (east), m"><input type="number" step="any" data-f="y" value="' + o.y + '" title="y (north), m"><input type="number" step="any" data-f="z" value="' + (o.z || 0) + '" title="z (height), m"></div>') +
|
||
field("rot", "Rotation", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>' + (o.rot || 0) + '°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') +
|
||
"</div>" +
|
||
(m.props.length ? "<h2>Properties</h2><div class=\"st-form\">" + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "</div>" : "") +
|
||
'<h2>Label and tooltip</h2><div class="st-form">' +
|
||
field("name", "Name", '<input data-f="name" value="' + esc(o.name || "") + '" placeholder="' + esc(m.label) + '">') +
|
||
field("tag", "Tag", '<input data-f="tag" value="' + esc(o.tag || "") + '" placeholder="label on the map">') +
|
||
field("info", "Tooltip", '<textarea data-f="info" rows="2" placeholder="shown on hover / tap">' + esc(o.info || "") + "</textarea>") +
|
||
"</div>" +
|
||
(levels.length > 1 ? '<h2>Shown on levels</h2><div class="st-levels">' + levels.map(function (l) {
|
||
return '<label><input type="checkbox" data-level="' + esc(l.id) + '"' + ((o.levels || []).indexOf(l.id) >= 0 ? " checked" : "") + "> " + esc(l.label || l.id) + "</label>";
|
||
}).join("") + '</div><p class="st-help">None ticked: shown on every level.</p>' : "") +
|
||
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
|
||
R.innerHTML = h;
|
||
}
|
||
function renderProject(R) {
|
||
var m = S.meta, cfg = S.doc.cfg || {};
|
||
var base = location.origin;
|
||
R.innerHTML = "<h3>" + esc(m.title) + "</h3>" + '<div class="st-sub">' + (S.doc.objects || []).length + " objects · " + (m.catalog || []).length + " object types</div>" +
|
||
(m.params && m.params.length ? "<h2>Scene options</h2><div class=\"st-form\">" + m.params.map(function (p) {
|
||
var v = cfg[p.name] !== undefined ? cfg[p.name] : p.default;
|
||
return field(p.name, p.label || p.name, propInput({ key: p.name, type: p.type, default: p.default, choices: p.choices, min: p.min, max: p.max, step: p.step }, v).replace(/data-prop=/g, "data-cfg="));
|
||
}).join("") + "</div>" : "") +
|
||
(S.warnings.length ? "<h2>Warnings</h2>" + S.warnings.map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") : "") +
|
||
(B && B.agentBox ? B.agentBox(m) :
|
||
'<div class="st-box"><h2>Connect an AI agent (MCP)</h2>' +
|
||
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live. ✦ Agent (top bar) chats with one right here.</p>' +
|
||
"HTTP (while the Studio runs):<code>" + esc(base) + "/mcp</code>" +
|
||
"stdio (any time):<code>isokit mcp " + esc(m.dir || ".") + "</code></div>") +
|
||
'<p class="st-help">Select an object to edit it, or pick one on the left to add it.</p>';
|
||
}
|
||
$("right").addEventListener("click", function (e) {
|
||
var b = e.target.closest("button[data-act]"); if (!b || !S.sel) return;
|
||
var a = b.getAttribute("data-act"), o = obj(S.sel);
|
||
if (a === "del") remove(S.sel);
|
||
else if (a === "dup") duplicate(S.sel);
|
||
else if (a === "focus") S.view.focus(S.sel, 3);
|
||
else if (a === "front" || a === "back") op({ op: "reorder", id: S.sel, to: a });
|
||
else if (a === "lock") update(S.sel, { locked: !o.locked });
|
||
else if (a === "hide") update(S.sel, { hidden: !o.hidden });
|
||
else if (a === "rotl") turn(S.sel, -90);
|
||
else if (a === "rotr") turn(S.sel, 90);
|
||
});
|
||
var rangeT = 0;
|
||
function formChange(e, live) {
|
||
var t = e.target, o = S.sel ? obj(S.sel) : null;
|
||
if (t.hasAttribute("data-cfg")) {
|
||
var k = t.getAttribute("data-cfg"), v = t.type === "checkbox" ? t.checked : t.type === "number" || t.type === "range" ? parseFloat(t.value) : t.value, vals = {};
|
||
vals[k] = v; op({ op: "set_cfg", values: vals }, true);
|
||
return;
|
||
}
|
||
if (!o) return;
|
||
if (t.hasAttribute("data-f")) {
|
||
var f = t.getAttribute("data-f"), val = t.type === "number" ? parseFloat(t.value) : t.value, patch = {};
|
||
if (t.type === "number" && !isFinite(val)) return;
|
||
patch[f] = val; update(o.id, patch, live);
|
||
} else if (t.hasAttribute("data-prop")) {
|
||
var key = t.getAttribute("data-prop"), d = (prefab(o.type) || { props: [] }).props.filter(function (x) { return x.key === key; })[0] || {};
|
||
var pv = t.type === "checkbox" ? t.checked : (d.type === "float" || d.type === "int") ? parseFloat(t.value) : t.value;
|
||
if ((d.type === "float" || d.type === "int") && !isFinite(pv)) return;
|
||
if (d.type === "int") pv = Math.round(pv);
|
||
// keep the slider and the number box in step
|
||
Array.prototype.forEach.call(document.querySelectorAll('#right [data-prop="' + key + '"]'), function (x) { if (x !== t && x.type !== "checkbox") x.value = pv; });
|
||
var pp = {}; pp[key] = pv;
|
||
if (live) { clearTimeout(rangeT); rangeT = setTimeout(function () { update(o.id, { props: pp }, true); }, 160); }
|
||
else update(o.id, { props: pp }, true);
|
||
} else if (t.hasAttribute("data-level")) {
|
||
var lv = Array.prototype.filter.call(document.querySelectorAll("#right [data-level]"), function (x) { return x.checked; }).map(function (x) { return x.getAttribute("data-level"); });
|
||
update(o.id, { levels: lv });
|
||
}
|
||
}
|
||
$("right").addEventListener("change", function (e) { formChange(e, false); });
|
||
$("right").addEventListener("input", function (e) { if (e.target.type === "range") formChange(e, true); });
|
||
$("right").addEventListener("keydown", function (e) { if (e.key === "Enter" && e.target.tagName === "INPUT") e.target.blur(); });
|
||
|
||
// ------------------------------------------------------------ header
|
||
$("undo").addEventListener("click", undo);
|
||
$("redo").addEventListener("click", redo);
|
||
$("help").addEventListener("click", function () { $("helpdlg").showModal(); });
|
||
$("theme").addEventListener("click", function () {
|
||
var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light";
|
||
document.documentElement.setAttribute("data-theme", t);
|
||
try { localStorage.setItem("isokit-theme", t); } catch (e) { /* private mode */ }
|
||
});
|
||
$("build").addEventListener("click", function () {
|
||
api("/api/build", {}).then(function (r) { if (r.error) toast(r.error, true); else toast(r.message || "Page written: " + r.path); }, function (e) { toast(String(e), true); });
|
||
});
|
||
$("warnbtn").addEventListener("click", function () { select(null); });
|
||
if (B && B.preview) $("preview").addEventListener("click", function (e) { e.preventDefault(); B.preview(); });
|
||
if (B && B.saveTitle) $("save").title = B.saveTitle;
|
||
|
||
// engine version from /api/meta; a web build adds its commit and date
|
||
// (<meta name="isokit-build" content="<sha> <date> [dirty]">, tools/build_web.py)
|
||
function showVersion() {
|
||
var el = $("ver"), m = document.querySelector('meta[name="isokit-build"]');
|
||
var b = m ? m.getAttribute("content").split(" ") : [];
|
||
var text = "isokit " + (S.meta.engine || "?"), tip = "isokit engine " + (S.meta.engine || "?");
|
||
if (b[0]) {
|
||
text += " · " + b[0].slice(0, 7) + (b[2] ? "+" : "");
|
||
tip += ", build " + b[0] + (b[1] ? " (" + b[1] + ")" : "") + (b[2] ? ", with uncommitted changes" : "");
|
||
el.href = "https://git.b4l.co.th/B4L/isokit/commit/" + b[0];
|
||
}
|
||
el.textContent = text;
|
||
el.title = tip;
|
||
}
|
||
|
||
// ------------------------------------------------------------ start
|
||
Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) {
|
||
S.meta = rs[0]; takeState(rs[1]);
|
||
document.title = S.meta.title + " · isokit Studio";
|
||
$("title").textContent = S.meta.title;
|
||
if (S.meta.load_error) toast(S.meta.load_error, true);
|
||
showVersion();
|
||
renderPalette(); renderLists(); mountView(); renderRight();
|
||
poll();
|
||
window.dispatchEvent(new Event("isokit:studio")); // agent.js waits for this
|
||
});
|
||
})();
|