Files
isokit/studio/studio.js
T
grabowski c8becc2e59
Deploy web Studio / deploy (push) Successful in 1m4s
Point handles for routes and outlines; Area corners; bus goes round right
Studio: one selected object with pts (a route, an outlined area) shows a
handle on each point and a + in the middle of each side: drag a point to
move it, drag a + to add one there, double-click a point to remove it
(open routes keep 2, outlines 3). Points snap to the grid and onto wall,
fence and road ends, at any angle; one undo step each. Handles keep their
screen size (new viewer callback onView on every redraw) and follow
re-renders and selection. The viewer captures the pointer, so a double-
click targets the drawing: the last point pressed is remembered instead.

Prefab meta `outline`: the prefab draws pts as its outline. Area has it:
Edit corners turns its rectangle into four points, Rectangle again
removes them.

Routes: where a path doubles back, the lane offset puts a point on each
side, so it rounds into a U-turn instead of a diagonal across the lanes.
The streets demo bus now runs east, three quarters round the roundabout
(clockwise, left-hand traffic), down south street, turns and comes back
by the short way, instead of going round the wrong way on its way out.

Tests: Rust 81 (a doubling-back route: each mover once, no sort cycles),
Studio 134 (corners, move, add, remove, undo), web 100.
2026-10-25 18:46:47 +07:00

1366 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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, selIds: [], 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
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 || (o.type === "group" ? "Group" : m ? m.label : o.type); }
function icon(kind) { var m = prefab(kind); return kind === "group" ? "▤" : (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]; }
// is rot (degrees) a multiple of 90: does the object keep to the world axes?
function onAxes(rot) { var q = (rot || 0) / 90; return Math.abs(q - Math.round(q)) < 1e-5; }
// a plan vector turned by rot degrees (quarter turns stay exact, as in the engine)
function turnA(rot, x, y) {
if (onAxes(rot)) return turnV(qOf(rot), x, y);
var a = rot * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
return [c * x - s * y, s * x + c * y];
}
function localOf(o, x, y) { return turnA(-(o.rot || 0), x - o.x, y - o.y); }
function worldOf(o, u, v) { var d = turnA(o.rot || 0, u, v); return [o.x + d[0], o.y + d[1]]; }
// the angle of plan direction (dx, dy) in degrees, 4 decimals
function degOf(dx, dy) { return Math.round(Math.atan2(dy, dx) * 180 / Math.PI * 1e4) / 1e4; }
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" }];
}
// ------------------------------------------------------------ drawing runs of walls and fences
// Line prefabs (walls, fences, hedges, roads) are drawn corner by corner. Corners snap onto the
// ends and lines of the pieces already there, so the scripts join them (line_joint in util.rhai).
var chain = null; // a run being drawn: { m, z, pts: [[x, y], ...] }
// metres per screen pixel around a pointer event
function pxM(e) { var a = S.view.toWorld(e.clientX, e.clientY, 0), b = S.view.toWorld(e.clientX + 10, e.clientY, 0); return Math.hypot(b[0] - a[0], b[1] - a[1]) / 10; }
function linePieces(z) {
return S.doc.objects.filter(function (o) { var m = prefab(o.type); return !o.hidden && m && m.shape === "line" && Math.abs((o.z || 0) - z) < 0.5; }).map(function (o) {
var L = (+propOf(o, "w") || 0) / 2;
return { o: o, a: worldOf(o, -L, 0), b: worldOf(o, L, 0), ax: qOf(o.rot) % 2 === 0, axis: onAxes(o.rot) };
});
}
// where a corner goes for pointer world point w: with `from` (the previous corner) the piece keeps
// to x or y (within 22.5 degrees) or a diagonal at 45, and its far end snaps onto an end or the
// line of a piece it meets (or the run's own corners), else onto the grid; `free` (Ctrl held): any
// angle, the end on a piece's end or line or a grid point; without `from`, onto the nearest end,
// else onto a line, else the grid
function snapCorner(w, z, from, R, free) {
var pcs = linePieces(z), own = chain ? chain.pts : [], best = null, i;
if (from && free) return snapCorner(w, z, null, R);
if (from) {
var k8 = Math.round(Math.atan2(w[1] - from[1], w[0] - from[0]) / (Math.PI / 4));
if (k8 % 2 !== 0) return snapDiagonal(w, from, R, [Math.cos(k8 * Math.PI / 4), Math.sin(k8 * Math.PI / 4)], pcs, own);
}
if (!from) {
pcs.forEach(function (p) {
[p.a, p.b].forEach(function (q) { var d = Math.hypot(q[0] - w[0], q[1] - w[1]); if (d < R && (!best || d < best.d)) best = { d: d, p: q }; });
});
if (best) return [r3(best.p[0]), r3(best.p[1])];
pcs.forEach(function (p) {
var l = localOf(p.o, w[0], w[1]), L = (+propOf(p.o, "w") || 0) / 2;
if (Math.abs(l[0]) <= L && Math.abs(l[1]) < R && (!best || Math.abs(l[1]) < best.d)) best = { d: Math.abs(l[1]), p: worldOf(p.o, Math.max(-L, Math.min(L, snap(l[0]))), 0) };
});
return best ? [r3(best.p[0]), r3(best.p[1])] : [snap(w[0]), snap(w[1])];
}
var ax = Math.abs(w[0] - from[0]) >= Math.abs(w[1] - from[1]), k = ax ? 0 : 1, lk = 1 - k;
var f = w[k], fix = from[lk], cands = [];
pcs.forEach(function (p) {
if (p.ax === ax) {
// in line with this piece: its ends
if (Math.abs(p.a[lk] - fix) < 0.01) { cands.push(p.a[k]); cands.push(p.b[k]); }
} else {
// across this piece: its centre line, where it spans our line
var lo = Math.min(p.a[lk], p.b[lk]), hi = Math.max(p.a[lk], p.b[lk]);
if (fix >= lo - 1e-6 && fix <= hi + 1e-6) cands.push(p.a[k]);
}
});
for (i = 0; i < own.length; i++) if (Math.abs(own[i][lk] - fix) < 0.01) cands.push(own[i][k]);
cands.forEach(function (c) { var d = Math.abs(c - f); if (Math.abs(c - from[k]) > 0.05 && d < R && (!best || d < best.d)) best = { d: d, c: c }; });
var out = [0, 0];
out[k] = r3(best ? best.c : snap(f)); out[lk] = fix;
return out;
}
// the far end of a diagonal piece from `from` along unit d: onto a piece's end on that line, a
// piece it crosses or the run's own corners within R, else where x lands on the grid
function snapDiagonal(w, from, R, d, pcs, own) {
var t0 = (w[0] - from[0]) * d[0] + (w[1] - from[1]) * d[1], cands = [], best = null;
var onRay = function (q) { var rx = q[0] - from[0], ry = q[1] - from[1]; if (Math.abs(rx * d[1] - ry * d[0]) < 0.01) cands.push(rx * d[0] + ry * d[1]); };
pcs.forEach(function (p) {
onRay(p.a); onRay(p.b);
// where the ray crosses the piece's centre line
var ex = p.b[0] - p.a[0], ey = p.b[1] - p.a[1], den = d[0] * ey - d[1] * ex;
if (Math.abs(den) < 1e-9) return;
var ax = p.a[0] - from[0], ay = p.a[1] - from[1], t = (ax * ey - ay * ex) / den, s = (ax * d[1] - ay * d[0]) / den;
if (s >= -1e-6 && s <= 1 + 1e-6) cands.push(t);
});
own.forEach(onRay);
cands.forEach(function (c) { var dd = Math.abs(c - t0); if (c > 0.05 && dd < R && (!best || dd < best.d)) best = { d: dd, t: c }; });
var t = best ? best.t : (snap(from[0] + t0 * d[0]) - from[0]) / d[0];
return [r3(from[0] + t * d[0]), r3(from[1] + t * d[1])];
}
// the preview of one piece from p to q (at any angle): its footprint, and its face when it has a height
function pieceGhost(m, p, q, z) {
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0;
var L = Math.hypot(q[0] - p[0], q[1] - p[1]) || 1, nx = -(q[1] - p[1]) / L * th / 2, ny = (q[0] - p[0]) / L * th / 2;
var out = [{ pts: [[p[0] + nx, p[1] + ny, z], [q[0] + nx, q[1] + ny, z], [q[0] - nx, q[1] - ny, z], [p[0] - nx, p[1] - ny, z]] }];
if (h > 0) out.push({ pts: planePts(p, q, z, z + h), cls: "st-plane" });
return out;
}
function cornerMark(p, z) { return { pts: rectPts(p[0] - 0.18, p[1] - 0.18, p[0] + 0.18, p[1] + 0.18, z), cls: "st-plane" }; }
function runLength(pts) { var L = 0; for (var i = 1; i < pts.length; i++) L += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); return L; }
function drawChain(e) {
var c = chain, last = c.pts[c.pts.length - 1], q = snapCorner(worldAt(e, c.z), c.z, last, 12 * pxM(e), freeAngle(c.m, e)), polys = [], i;
for (i = 1; i < c.pts.length; i++) Array.prototype.push.apply(polys, pieceGhost(c.m, c.pts[i - 1], c.pts[i], c.z));
if (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05) Array.prototype.push.apply(polys, pieceGhost(c.m, last, q, c.z));
polys.push(cornerMark(c.pts[0], c.z));
var closes = c.pts.length > 2 && Math.hypot(q[0] - c.pts[0][0], q[1] - c.pts[0][1]) < 1e-6;
var n = c.pts.length - 1 + (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05 ? 1 : 0);
if (c.m.shape === "path") {
ghost(polys, c.m.label + ": " + (n + 1) + " points, " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m · " +
(closes ? "click to close the loop" : "click the next point · double-click or Enter to finish (there and back) · click the first point for a loop") +
" · diagonals at 45°, Ctrl: any angle · Backspace undoes a point · Esc cancels");
return;
}
ghost(polys, c.m.label + ": " + n + " piece" + (n === 1 ? "" : "s") + ", " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m" +
(c.m.corner ? ", corners rounded" : "") + " · " +
(closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") +
(c.m.directed ? "" : " · diagonals at 45°, Ctrl: any angle") + " · Backspace undoes a corner · Esc cancels");
}
// the turn of a line piece from p to q: along x or y, else its angle (0 to 180); directed lines
// (conveyors) point from p to q
function lineRot(m, p, q) {
var dx = q[0] - p[0], dy = q[1] - p[1];
if (Math.abs(dx) > 1e-9 && Math.abs(dy) > 1e-9) { var a = degOf(dx, dy); return m.directed ? (a + 360) % 360 : (a % 180 + 180) % 180; }
var ax = Math.abs(dy) <= Math.abs(dx);
if (!m.directed) return ax ? 0 : 90;
return ax ? (q[0] >= p[0] ? 0 : 180) : (q[1] >= p[1] ? 90 : 270);
}
// prefabs drawn corner by corner: "line" pieces (walls, fences, roads: one object per piece) and
// "path" objects (routes: one object whose pts are the points clicked)
function isRun(m) { return !!m && (m.shape === "line" || m.shape === "path"); }
// the add op of a path object through world points: x, y in the middle of them, pts relative
// to it; ending on the first point closes it (props.closed, when the prefab has that prop)
function pathOp(m, pts, z) {
var p = pts.slice(), closed = p.length > 3 && Math.hypot(p[p.length - 1][0] - p[0][0], p[p.length - 1][1] - p[0][1]) < 1e-6;
if (closed) p.pop();
var xs = p.map(function (q) { return q[0]; }), ys = p.map(function (q) { return q[1]; });
var cx = r3((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2), cy = r3((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2);
var add = { op: "add", type: m.type, x: cx, y: cy, pts: p.map(function (q) { return [r3(q[0] - cx), r3(q[1] - cy)]; }), props: {} };
if ((m.props || []).some(function (d) { return d.key === "closed"; })) add.props.closed = closed;
if (z) add.z = z;
return add;
}
// Ctrl (Cmd) frees the angle of the piece being drawn; directed lines (conveyors) keep to the axes
function freeAngle(m, e) { return !!(e && (e.ctrlKey || e.metaKey) && !m.directed); }
// the corners of a run rounded by the prefab's corner piece (roads: curves of up to its default
// radius, less where the pieces next to a corner are short): [segments [p, q]], [curve add ops]
function roundRun(m, pts, z) {
var segs = [], curves = [], cm = m.corner && prefab(m.corner), i;
for (i = 1; i < pts.length; i++) if (Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]) >= 0.05) segs.push([pts[i - 1].slice(), pts[i].slice()]);
var n = segs.length;
if (!cm || n < 2) return { segs: segs, curves: curves };
var closed = n > 2 && Math.hypot(segs[n - 1][1][0] - segs[0][0][0], segs[n - 1][1][1] - segs[0][0][1]) < 1e-6;
var len = segs.map(function (s) { return Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]); });
var dir = segs.map(function (s, k) { return [(s[1][0] - s[0][0]) / len[k], (s[1][1] - s[0][1]) / len[k]]; });
// turn (radians, + left) at the start of segment k (from segment k - 1), 0 where the run goes on
var turn = segs.map(function (s, k) {
if (k === 0 && !closed) return 0;
var a = dir[(k + n - 1) % n], b = dir[k], th = Math.atan2(a[0] * b[1] - a[1] * b[0], a[0] * b[0] + a[1] * b[1]);
return Math.abs(th) > 5 * Math.PI / 180 && Math.abs(th) < 175 * Math.PI / 180 ? th : 0;
});
var R0 = +defOf(cm, "r") || 10, rmin = Math.max(+((cm.props || []).filter(function (d) { return d.key === "r"; })[0] || {}).min || 0, (+defOf(m, "d") || 0) / 2 + 0.5);
for (var k = 0; k < n; k++) {
if (!turn[k]) continue;
// the curve meets the legs r * tan(turn / 2) from the corner
var a = (k + n - 1) % n, nextTurn = turn[(k + 1) % n] && (k + 1 < n || closed), tn = Math.tan(Math.abs(turn[k]) / 2);
var r = Math.min(R0, len[a] * (turn[a] ? 0.5 : 1) / tn, len[k] * (nextTurn ? 0.5 : 1) / tn), T = r * tn;
if (r < rmin) continue;
var P = segs[k][0], da = dir[a], db = dir[k];
var s0 = [P[0] - da[0] * T, P[1] - da[1] * T], deg = Math.abs(turn[k]) * 180 / Math.PI;
var add = { op: "add", type: m.corner, x: r3(s0[0]), y: r3(s0[1]), rot: (lineRot({ directed: true }, [0, 0], da) + 360) % 360,
props: { r: r3(r), angle: Math.abs(deg - Math.round(deg)) < 1e-6 ? Math.round(deg) : r3(deg), turn: turn[k] > 0 ? "left" : "right" } };
if (z) add.z = z;
curves.push(add);
segs[a][1] = s0;
segs[k][0] = [P[0] + db[0] * T, P[1] + db[1] * T];
}
return { segs: segs, curves: curves };
}
// the pieces of a run as add ops (pieces longer than the prefab allows are split; corners rounded)
function runOps(m, pts, z) {
var ops = [], max = +((m.props || []).filter(function (d) { return d.key === "w"; })[0] || {}).max || 1e9, rr = roundRun(m, pts, z);
for (var i = 0; i < rr.segs.length; i++) {
var p = rr.segs[i][0], q = rr.segs[i][1], L = Math.hypot(q[0] - p[0], q[1] - p[1]), n = Math.ceil(L / max - 1e-9);
if (L < 0.05) continue;
for (var j = 0; j < n; j++) {
var t = (j + 0.5) / n, add = { op: "add", type: m.type, x: r3(p[0] + (q[0] - p[0]) * t), y: r3(p[1] + (q[1] - p[1]) * t), rot: lineRot(m, p, q), props: { w: clampProp(m, "w", L / n) } };
if (z) add.z = z;
ops.push(add);
}
}
return ops.concat(rr.curves);
}
function finishChain(keep) {
var c = chain; chain = null; ghost(null);
if (!c) return;
if (c.m.shape === "path") {
if (c.pts.length >= 2) {
var po = pathOp(c.m, c.pts, c.z), np = po.pts.length, closed = !!(po.props && po.props.closed);
op(po).then(function (r) { if (r) toast(c.m.label + ": " + np + " points, " + runLength(c.pts).toFixed(1) + " m" + (closed ? ", a loop" : ", there and back") + " · Ctrl+Z takes it back"); });
}
if (keep) setPlacing(c.m.type); else setPlacing(null);
return;
}
var ops = runOps(c.m, c.pts, c.z);
if (ops.length) {
var L = runLength(c.pts);
opList(ops).then(function (r) { if (r) toast(c.m.label + ": " + ops.length + " piece" + (ops.length === 1 ? "" : "s") + ", " + L.toFixed(1) + " m · Ctrl+Z takes the run back"); });
}
if (keep) setPlacing(c.m.type); else setPlacing(null);
}
// ------------------------------------------------------------ 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";
var adds = (o.ops || [o]).some(function (x) { return x.op === "add" || x.op === "duplicate"; });
if (r.id && adds) { S.sel = r.id; S.selIds = [r.id]; }
pruneSel();
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() {
pruneSel();
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, hov = null, hovRaf = 0, hovText = "", hovLate = 0;
function showCursor() { hovLate = 0; var el = $("cursor"); if (el.textContent !== hovText) el.textContent = hovText; }
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");
var ids = selIds(); if (ids.length) S.view.selectObj(ids.length > 1 ? ids : ids[0]);
drawHandles();
},
onView: function () { if (!ptDrag) drawHandles(); },
// once per frame at most: pointers send moves faster than that; the x/y readout about ten
// times a second (each new text costs a layout and a paint of the page)
onHover: function (w, e) {
hov = { w: w, e: e };
if (!hovRaf) hovRaf = requestAnimationFrame(function () {
var h = hov; hovRaf = 0;
if (h.w) { hovText = "x " + h.w[0].toFixed(1) + " y " + h.w[1].toFixed(1); if (!hovLate) hovLate = setTimeout(showCursor, 100); }
if (S.placing && !tool && h.e) hoverPlacing(h.e);
});
},
onPick: function (i, e) { if (i && i.obj) clickObj(i.obj, e); else select(null); },
onKey: keyHandler,
interact: {
down: function (e, hit) {
if (S.placing) {
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z);
a = isRun(pm) ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e), freeAngle(pm, e)) : [snap(a[0]), snap(a[1])];
tool = { m: pm, z: z, a: a, b: null };
return true;
}
if (ptDown(e)) return true;
// Ctrl / Shift: a click adds or takes out an object, a drag draws a box around more
if (e.shiftKey || e.ctrlKey || e.metaKey) { drag = { box: true, x0: e.clientX, y0: e.clientY, hit: hit.obj }; return true; }
if (!hit.obj) return false;
var o = obj(hit.obj), ids = selIds(), group = !!o && ids.length > 1 && ids.indexOf(o.id) >= 0;
if (group) {
// a drag on one of several selected objects moves them all
var set = moveSet(ids);
if (!set.objs.length) return false;
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, group: set, carry: [] };
return true;
}
if (!o || lockedEff(o)) 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 (ptDrag) { ptMove(e); return; }
if (drag && drag.box) { drawBand(drag, e); return; }
if (!drag || !mv.world || !drag.w0) return;
if (!drag.moved) {
drag.moved = true; drag.els = [];
if (!drag.group) select(drag.id, true);
// what sits on the objects (a wall's windows) moves along
var moving = drag.group ? drag.group.objs.concat(drag.group.carry) : [obj(drag.id)].concat(drag.carry);
moving.forEach(function (x) { if (x) Array.prototype.push.apply(drag.els, moveEls(x.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; }
if (ptDrag) { ptUp(mv); return; }
var d = drag; drag = null; ghost(null);
if (!d) return;
if (d.box) { endBand(d, e, mv); return; }
if (mv.cancel) { if (d.moved) S.view.refresh(); return; }
if (!d.moved) { clickObj(d.id, e); 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) && d.group) moveMany(d.group, d.dx, d.dy);
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(); if (drag && drag.box) band(null); drag = null; tool = null; ghost(null); },
dblclick: function (e) { if (ptDelete(e)) return true; if (!chain) return false; finishChain(e.shiftKey); return true; }
}
});
window.__studio = { S: S, view: S.view, op: op, select: select, selectMany: selectMany, 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 (chain) { drawChain(e); return; }
if (m && isRun(m)) { var z0 = placeZ(m); ghost([cornerMark(snapCorner(worldAt(e, z0), z0, null, 12 * pxM(e)), z0)]); return; }
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 (snapped to x, y or a
// diagonal by snapCorner), "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 (isRun(t.m)) {
var L = Math.hypot(dx, dy);
return { line: true, len: L, a: a, b: b, x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, ok: L >= 0.25, rot: lineRot(t.m, a, b) };
}
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 = [];
if (chain) { drawChain(e); return; }
t.b = isRun(m) ? snapCorner(w, z, t.a, 12 * pxM(e), freeAngle(m, e)) : [snap(w[0]), snap(w[1])];
if (!isRun(m) && m.shape !== "rect") return;
var g = toolGeom(t);
if (g.line) {
polys = pieceGhost(m, g.a, g.b, z);
var tilt = onAxes(g.rot) ? "" : ", " + (+g.rot.toFixed(1)) + "°";
ghost(polys, m.label + " " + g.len.toFixed(2) + " m" + tilt + (m.directed ? ", running from where you started" : "") + " · release to place" + (m.directed ? "" : " · Ctrl: any angle"));
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 (isRun(m)) {
// a run: a click starts it, each click (or drag) adds a corner, the first corner closes it
if (chain) {
var last = chain.pts[chain.pts.length - 1], q = snapCorner(at, t.z, last, 12 * pxM(e), freeAngle(m, e));
if (Math.hypot(q[0] - last[0], q[1] - last[1]) < 0.05) return;
chain.pts.push(q);
if (chain.pts.length > 3 && Math.hypot(q[0] - chain.pts[0][0], q[1] - chain.pts[0][1]) < 1e-6) finishChain(keep);
else drawChain(e);
return;
}
if (!g || !g.ok) { chain = { m: m, z: t.z, pts: [t.a] }; drawChain(e); return; }
var ops = m.shape === "path" ? [pathOp(m, [t.a, t.b], t.z)] : runOps(m, [t.a, t.b], t.z);
opList(ops).then(function (r) { if (r) toast(m.label + " added" + (keep ? "" : " · Shift to draw several")); });
if (!keep) setPlacing(null);
return;
}
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.rot, 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 (!onAxes(w.rot) || (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; chain = null;
drawHandles();
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 one " + lab + ", or click corner after corner for a run (double-click or Enter ends it, the first corner closes it); ends snap onto walls and fences · Esc to stop"
: m && m.shape === "path" ? "Click point after point on the map for the " + lab + " (double-click or Enter ends it; the first point again makes a loop); points snap onto road and wall ends · 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;
}
// ------------------------------------------------------------ selection
// S.sel: the object picked last (its form shows), S.selIds: everything selected (S.sel among them)
function selIds() { return (S.selIds || []).filter(function (id) { return obj(id); }); }
function pruneSel() {
S.selIds = selIds();
if (S.sel && !obj(S.sel)) S.sel = null;
if (S.sel && S.selIds.indexOf(S.sel) < 0) S.selIds.push(S.sel);
if (!S.sel && S.selIds.length) S.sel = S.selIds[S.selIds.length - 1];
}
function showSel(quiet) {
var ids = selIds();
S.view.selectObj(ids.length > 1 ? ids : S.sel);
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(ids.indexOf(li.getAttribute("data-id")) >= 0)); });
otools();
drawHandles();
if (!quiet) renderRight();
}
function select(id, quiet) {
if (id && !obj(id)) id = null;
S.sel = id; S.selIds = id ? [id] : [];
showSel(quiet);
}
function selectMany(ids, primary, quiet) {
var seen = {};
ids = ids.filter(function (id) { if (seen[id] || !obj(id)) return false; seen[id] = 1; return true; });
if (ids.length < 2) { select(ids[0] || null, quiet); return; }
S.selIds = ids; S.sel = primary && ids.indexOf(primary) >= 0 ? primary : ids[ids.length - 1];
showSel(quiet);
}
function toggleSel(id) {
var ids = selIds(), i = ids.indexOf(id);
if (i >= 0) { ids.splice(i, 1); selectMany(ids, S.sel === id ? ids[ids.length - 1] : S.sel); } else selectMany(ids.concat([id]), id);
}
// the walls (fences, ...) joined to o: same type, an end on the other's centre line; o first
function connectedRun(o) {
var m = prefab(o.type);
if (!m || m.shape !== "line") return [o.id];
var pcs = S.doc.objects.filter(function (x) { return x.type === o.type && !x.hidden && Math.abs((x.z || 0) - (o.z || 0)) < 0.5; });
function ends(x) { var L = (+propOf(x, "w") || 0) / 2; return [worldOf(x, -L, 0), worldOf(x, L, 0)]; }
function onLine(p, x) { var l = localOf(x, p[0], p[1]); return Math.abs(l[0]) <= (+propOf(x, "w") || 0) / 2 + 0.05 && Math.abs(l[1]) <= 0.3; }
function joined(a, b) { return ends(a).some(function (p) { return onLine(p, b); }) || ends(b).some(function (p) { return onLine(p, a); }); }
var out = [o], todo = [o];
while (todo.length) {
var a = todo.pop();
pcs.forEach(function (b) { if (out.indexOf(b) < 0 && joined(a, b)) { out.push(b); todo.push(b); } });
}
return out.map(function (x) { return x.id; });
}
// a click on an object: Ctrl / Shift add it or take it out; a second click on a wall or fence
// selects every piece joined to it, a click on one of those selects just that one again
function clickObj(id, e) {
if (e && (e.ctrlKey || e.metaKey || e.shiftKey)) { toggleSel(id); return; }
var ids = selIds();
if (ids.length === 1 && ids[0] === id) {
var run = connectedRun(obj(id));
if (run.length > 1) { selectMany(run, id); toast(run.length + " joined pieces selected · click one of them for just that one"); return; }
}
select(id);
}
function selectAll() {
selectMany((S.doc.objects || []).filter(function (o) { return !lockedEff(o) && !hiddenEff(o) && !isGroup(o); }).map(function (o) { return o.id; }));
}
// the selection box drawn with Shift / Ctrl + drag
function band(r) {
var b = $("stband");
if (!r) { if (b) b.parentNode.removeChild(b); return; }
if (!b) { b = document.createElement("div"); b.id = "stband"; b.className = "st-band"; document.body.appendChild(b); }
b.style.left = r.x0 + "px"; b.style.top = r.y0 + "px"; b.style.width = (r.x1 - r.x0) + "px"; b.style.height = (r.y1 - r.y0) + "px";
}
function bandRect(d, e) { return { x0: Math.min(d.x0, e.clientX), y0: Math.min(d.y0, e.clientY), x1: Math.max(d.x0, e.clientX), y1: Math.max(d.y0, e.clientY) }; }
function drawBand(d, e) { band(bandRect(d, e)); }
function endBand(d, e, mv) {
band(null);
if (mv.cancel) return;
if (!mv.moved) { if (d.hit) toggleSel(d.hit); return; }
// every object drawn wholly inside the box (not locked or hidden)
var r = bandRect(d, e), found = (S.doc.objects || []).filter(function (o) {
if (lockedEff(o) || hiddenEff(o) || isGroup(o)) return false;
var els = moveEls(o.id), u = null;
Array.prototype.forEach.call(els, function (el) {
var b = el.getBoundingClientRect();
if (!b.width && !b.height) return;
u = u ? { l: Math.min(u.l, b.left), t: Math.min(u.t, b.top), r: Math.max(u.r, b.right), b: Math.max(u.b, b.bottom) } : { l: b.left, t: b.top, r: b.right, b: b.bottom };
});
return u && u.l >= r.x0 && u.r <= r.x1 && u.t >= r.y0 && u.b <= r.y1;
}).map(function (o) { return o.id; });
if (!found.length) { toast("No object lies wholly inside the box"); return; }
selectMany(selIds().concat(found), found[found.length - 1]);
toast(selIds().length + " objects selected");
}
// what a group move takes along: the selected objects that are not locked, and what sits on them
function moveSet(ids) {
var objs = ids.map(obj).filter(function (o) { return o && !lockedEff(o) && !isGroup(o); }), seen = {}, carry = [];
objs.forEach(function (o) { seen[o.id] = 1; });
objs.forEach(function (o) { mountedOn(o).forEach(function (c) { if (!seen[c.id]) { seen[c.id] = 1; carry.push(c); } }); });
return { objs: objs, carry: carry };
}
function moveMany(set, dx, dy, quiet) {
return opList(set.carry.concat(set.objs).map(function (o) { return { op: "update", id: o.id, patch: { x: r3(o.x + dx), y: r3(o.y + dy) } }; }), quiet);
}
// turn the group about the middle of its objects, by the snap step
function turnGroup(ids, d) {
var set = moveSet(ids), all = set.objs.concat(set.carry);
if (!set.objs.length) return;
var xs = set.objs.map(function (o) { return o.x; }), ys = set.objs.map(function (o) { return o.y; });
var cx = snap((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2), cy = snap((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2), q = qOf(d);
opList(all.map(function (o) {
var v = turnV(q, o.x - cx, o.y - cy);
return { op: "update", id: o.id, patch: { x: r3(cx + v[0]), y: r3(cy + v[1]), rot: ((o.rot || 0) + d + 360) % 360 } };
}));
}
function extraOn(ids) {
var seen = {}, out = [];
ids.forEach(function (id) { var o = obj(id); if (o) mountedOn(o).forEach(function (c) { if (!seen[c.id] && ids.indexOf(c.id) < 0) { seen[c.id] = 1; out.push(c.id); } }); });
return out;
}
function removeMany(ids) {
var extra = extraOn(ids);
opList(extra.concat(ids).map(function (id) { return { op: "remove", id: id }; })).then(function (r) {
if (r) toast("Deleted " + ids.length + " objects" + (extra.length ? " and the " + extra.length + " windows / doors on them" : "") + " · Ctrl+Z brings them back");
});
}
function duplicateMany(ids) {
ids = ids.filter(function (id) { return !isGroup(obj(id)); });
var dd = Math.max(snapStep(), 1) * 2, extra = extraOn(ids);
opList(extra.concat(ids).map(function (id) { return { op: "duplicate", id: id, dx: dd, dy: dd }; })).then(function (r) {
if (r && r.ids) selectMany(r.ids.slice(-ids.length));
});
}
function setAll(ids, key) {
var v = !ids.every(function (id) { return obj(id)[key]; });
opList(ids.map(function (id) { var p = {}; p[key] = v; return { op: "update", id: id, patch: p }; }));
}
function focusMany(ids) {
var bs = ids.map(function (id) { return S.boxes[id]; }).filter(Boolean);
if (!bs.length) return;
var x0 = Math.min.apply(null, bs.map(function (b) { return b[0]; })), x1 = Math.max.apply(null, bs.map(function (b) { return b[3]; }));
var y0 = Math.min.apply(null, bs.map(function (b) { return b[1]; })), y1 = Math.max.apply(null, bs.map(function (b) { return b[4]; }));
S.view.focus([(x0 + x1) / 2, (y0 + y1) / 2, 0], 2);
}
// ------------------------------------------------------------ point handles (objects with pts)
// One selected object with points (a route's waypoints, an area's outline) shows a handle on
// each point and a "+" in the middle of each side: drag a point to move it, drag a "+" to add
// one there, double-click a point to remove it. Points snap like a run's corners; one undo step each.
// ptPress: the last point pressed; the viewer captures the pointer, so a double-click's target
// is the drawing, not the handle
var ptDrag = null, ptPress = null;
function ptsOf(o) {
if (!o || S.placing || selIds().length !== 1 || lockedEff(o) || hiddenEff(o) || !o.pts || o.pts.length < 2) return null;
var m = prefab(o.type);
if (!m) return null;
// routes say whether they close; any other outline (areas) is a closed polygon
var closed = m.shape === "path" ? !!propOf(o, "closed") && o.pts.length >= 3 : true;
return { o: o, m: m, closed: closed, z: o.z || 0, min: closed ? 3 : 2, w: o.pts.map(function (q) { return worldOf(o, q[0], q[1]); }) };
}
function drawHandles(pts) {
var svg = S.view && S.view.svg, g = svg && svg.querySelector(".st-handles"), inf = ptsOf(S.sel ? obj(S.sel) : null);
if (!svg) return;
if (!inf) { if (g) g.parentNode.removeChild(g); return; }
if (!g) { g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute("class", "st-handles"); }
svg.appendChild(g); // last, above the drawing and the ghosts
var rot = S.view.getRotation(), k = S.view.screenPx(), w = pts || inf.w, n = w.length, out = [], i;
var sc = w.map(function (p) { return IsoKit.project(rot, p[0], p[1], inf.z); });
out.push('<path class="st-hline" d="' + sc.map(function (s, j) { return (j ? "L" : "M") + s[0].toFixed(1) + " " + s[1].toFixed(1); }).join("") + (inf.closed ? "Z" : "") + '"/>');
var sides = inf.closed ? n : n - 1;
for (i = 0; i < sides; i++) {
var a = sc[i], b = sc[(i + 1) % n];
out.push('<circle class="st-hmid" data-mid="' + i + '" cx="' + ((a[0] + b[0]) / 2).toFixed(1) + '" cy="' + ((a[1] + b[1]) / 2).toFixed(1) + '" r="' + (4 * k).toFixed(2) + '"><title>Drag to add a point here</title></circle>');
}
for (i = 0; i < n; i++) {
out.push('<circle class="st-hpt' + (ptDrag && ptDrag.i === i ? " st-on" : "") + '" data-pt="' + i + '" cx="' + sc[i][0].toFixed(1) + '" cy="' + sc[i][1].toFixed(1) + '" r="' + (5.5 * k).toFixed(2) + '"><title>Point ' + (i + 1) + ": drag to move, double-click to remove</title></circle>");
}
g.innerHTML = out.join("");
}
// a press on a handle starts a point drag (a "+" first adds its point)
function ptDown(e) {
var el = e.target && e.target.closest ? e.target.closest(".st-hpt, .st-hmid") : null;
var inf = el && ptsOf(S.sel ? obj(S.sel) : null);
if (!inf) return false;
var pts = inf.w.map(function (p) { return p.slice(); }), i;
if (el.hasAttribute("data-mid")) {
var j = +el.getAttribute("data-mid"), a = pts[j], b = pts[(j + 1) % pts.length];
i = j + 1; pts.splice(i, 0, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]);
} else i = +el.getAttribute("data-pt");
ptPress = el.hasAttribute("data-mid") ? null : { id: inf.o.id, i: i, t: Date.now() };
ptDrag = { inf: inf, pts: pts, i: i, add: el.hasAttribute("data-mid"), moved: false };
// redrawn on the first move only: replacing the handle under the pointer now would lose
// the double-click that removes it
return true;
}
// a point snaps to the grid and onto the ends and lines of walls, fences and roads, at any angle
function ptMove(e) {
var d = ptDrag, inf = d.inf;
var q = snapCorner(worldAt(e, inf.z), inf.z, null, 12 * pxM(e));
d.pts[d.i] = q; d.moved = true;
drawHandles(d.pts);
$("cursor").textContent = "point " + (d.i + 1) + ": x " + q[0].toFixed(2) + " y " + q[1].toFixed(2);
}
function ptUp(mv) {
var d = ptDrag; ptDrag = null;
if (!d) return;
if (!d.moved && !d.add) return;
if (mv.cancel) { drawHandles(); return; }
setPts(d.inf.o, d.pts, d.add ? "Point added" : "Point " + (d.i + 1) + " moved");
}
function setPts(o, w, msg) {
var pts = w.map(function (p) { var l = localOf(o, p[0], p[1]); return [r3(l[0]), r3(l[1])]; });
update(o.id, { pts: pts }).then(function (r) { if (r) toast(msg + " · Ctrl+Z takes it back"); });
}
function ptDelete(e) {
var el = e.target && e.target.closest ? e.target.closest(".st-hpt") : null, inf = ptsOf(S.sel ? obj(S.sel) : null);
var i = el ? +el.getAttribute("data-pt") : ptPress && inf && ptPress.id === inf.o.id && Date.now() - ptPress.t < 800 ? ptPress.i : -1;
ptPress = null;
if (!inf || i < 0 || i >= inf.w.length) return false;
if (inf.w.length <= inf.min) { toast("A " + (inf.closed ? "closed outline" : "route") + " needs at least " + inf.min + " points", true); return true; }
var w = inf.w.slice();
w.splice(i, 1);
setPts(inf.o, w, "Point " + (i + 1) + " removed");
return true;
}
// an outline prefab drawn as a rectangle: its four corners become points
function editCorners(o) {
var w = +propOf(o, "w") || 1, d = +propOf(o, "d") || 1;
update(o.id, { pts: [[-w / 2, -d / 2], [w / 2, -d / 2], [w / 2, d / 2], [-w / 2, d / 2]].map(function (q) { return [r3(q[0]), r3(q[1])]; }) })
.then(function () { toast("Corners: drag a point, drag a + to add one, double-click a point to remove it"); });
}
// ------------------------------------------------------------ groups and the drawing order
// A group is an object of type "group" that draws nothing; what is in it carries its id in
// `group` (groups nest). The object list is a tree built from the document order: a group's
// members lie just before the group object, so a group draws as one block at its place, and
// ground pieces (areas, beds, ponds, roads) later in the order lie on top. The list shows the
// front first. Every change to the tree is one op batch: group changes and the whole order.
var GROUP = "group", ICON_GROUP = "▤", openGroups = {}, closedGroups = {};
function isGroup(o) { return !!o && o.type === GROUP; }
function parentOf(o) { var g = o && o.group ? obj(o.group) : null; return isGroup(g) && g !== o ? g : null; }
function ancestors(o) { var out = [], g = parentOf(o); while (g && out.length < 64 && out.indexOf(g) < 0) { out.push(g); g = parentOf(g); } return out; }
function hiddenEff(o) { return !!o && (!!o.hidden || ancestors(o).some(function (g) { return g.hidden; })); }
function lockedEff(o) { return !!o && (!!o.locked || ancestors(o).some(function (g) { return g.locked; })); }
// what is in group gid: its direct members, or (deep) everything below it, groups included
function membersOf(gid, deep) {
var out = [], todo = [gid];
while (todo.length) {
var g = todo.shift();
(S.doc.objects || []).forEach(function (o) {
if (o.group === g && out.indexOf(o) < 0 && o.id !== gid) { out.push(o); if (deep && isGroup(o)) todo.push(o.id); }
});
}
return out;
}
// the tree, back to front: [{ o, kids }]; a group cycle (only by hand-edited files) stays at the root
function tree() {
var byId = {}, roots = [];
(S.doc.objects || []).forEach(function (o) { byId[o.id] = { o: o, kids: [] }; });
(S.doc.objects || []).forEach(function (o) {
var p = parentOf(o), cyc = p && ancestors(o).indexOf(o) >= 0;
(p && !cyc && byId[p.id] ? byId[p.id].kids : roots).push(byId[o.id]);
});
return roots;
}
// the document order of a tree: each group after what is in it
function flatten(nodes, out) { nodes.forEach(function (n) { flatten(n.kids, out); out.push(n.o.id); }); return out; }
// where a node is: { node, list (its siblings), parent (group id or "") }
function findNode(nodes, id, parent) {
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].o.id === id) return { node: nodes[i], list: nodes, parent: parent || "" };
var f = findNode(nodes[i].kids, id, nodes[i].o.id);
if (f) return f;
}
return null;
}
// the selected objects that are not inside another selected group
function topSel(ids) {
ids = (ids || selIds()).filter(function (id) { return obj(id); });
return ids.filter(function (id) { return !ancestors(obj(id)).some(function (g) { return ids.indexOf(g.id) >= 0; }); });
}
function docIndex(id) { return (S.doc.objects || []).map(function (o) { return o.id; }).indexOf(id); }
// send a changed tree: first `pre` (adds, removes), then group changes, then the order
function commitTree(roots, changes, pre, msg) {
var ops = (pre || []).concat(changes || []), ids = flatten(roots, []);
var cur = (S.doc.objects || []).map(function (o) { return o.id; }).filter(function (id) { return ids.indexOf(id) >= 0 || (pre || []).every(function (p) { return p.op !== "remove" || p.id !== id; }); });
if (ids.join("\n") !== cur.join("\n")) ops.push({ op: "order", ids: ids });
if (!ops.length) return Promise.resolve(null);
return op({ ops: ops }).then(function (r) { if (r && msg) toast(msg); return r; });
}
function groupSelection() {
var ids = topSel();
if (ids.length < 2) { toast("Select two or more objects to group them (Ctrl+click in the list or on the map)"); return; }
var roots = tree(), locs = ids.map(function (id) { return findNode(roots, id); }).filter(Boolean);
locs.sort(function (a, b) { return docIndex(a.node.o.id) - docIndex(b.node.o.id); });
var front = locs[locs.length - 1], k = 1;
while (obj("group-" + k)) k++;
var gid = "group-" + k, n = (S.doc.objects || []).filter(isGroup).length + 1;
var gnode = { o: { id: gid, type: GROUP, group: front.parent }, kids: [] };
// the group takes the place of the front-most of them, in its parent
front.list.splice(front.list.indexOf(front.node) + 1, 0, gnode);
locs.forEach(function (l) { l.list.splice(l.list.indexOf(l.node), 1); gnode.kids.push(l.node); });
var changes = locs.map(function (l) { return { op: "update", id: l.node.o.id, patch: { group: gid } }; });
commitTree(roots, changes, [{ op: "add", type: GROUP, id: gid, name: "Group " + n, group: front.parent }], "Grouped " + locs.length + " · Ctrl+Shift+G ungroups").then(function (r) { if (r) selectGroup(gid); });
}
// the group the selection is or is in
function selGroup() {
var o = S.sel ? obj(S.sel) : null;
return isGroup(o) ? o : parentOf(o);
}
function ungroup(g) {
g = g || selGroup();
if (!g) { toast("Select a group (or something in one) to ungroup it"); return; }
var roots = tree(), loc = findNode(roots, g.id), kids = loc.node.kids;
Array.prototype.splice.apply(loc.list, [loc.list.indexOf(loc.node), 1].concat(kids));
// removing a group moves what was in it up into its parent (ops.rs)
commitTree(roots, [], [{ op: "remove", id: g.id }], "Ungrouped " + label(g) + ": " + kids.length + " object" + (kids.length === 1 ? "" : "s") + " · Ctrl+Z groups them again").then(function (r) {
if (r) selectMany(kids.map(function (n) { return n.o.id; }));
});
}
function selectGroup(gid) { selectMany([gid].concat(membersOf(gid, true).map(function (o) { return o.id; })), gid); }
// forward / backward one place among their siblings, or to the front / back of them
function moveOrder(how, ids) {
ids = topSel(ids);
if (!ids.length) return;
var roots = tree(), lists = [];
ids.forEach(function (id) { var l = findNode(roots, id); if (l && lists.indexOf(l.list) < 0) lists.push(l.list); });
lists.forEach(function (list) {
var sel = list.filter(function (n) { return ids.indexOf(n.o.id) >= 0; }), rest = list.filter(function (n) { return ids.indexOf(n.o.id) < 0; }), i;
if (how === "front" || how === "back") { list.length = 0; Array.prototype.push.apply(list, how === "front" ? rest.concat(sel) : sel.concat(rest)); return; }
if (how === "forward") { for (i = list.length - 2; i >= 0; i--) if (sel.indexOf(list[i]) >= 0 && sel.indexOf(list[i + 1]) < 0) { var a = list[i]; list[i] = list[i + 1]; list[i + 1] = a; } }
else { for (i = 1; i < list.length; i++) if (sel.indexOf(list[i]) >= 0 && sel.indexOf(list[i - 1]) < 0) { var b = list[i]; list[i] = list[i - 1]; list[i - 1] = b; } }
});
var words = { front: "to the front", back: "to the back", forward: "forward", backward: "backward" };
commitTree(roots, [], [], null).then(function (r) { if (r) toast("Moved " + words[how] + " · higher in the list draws on top (ground pieces)"); });
}
// rows dropped above (in front of), below (behind) or into a group row
function dropRows(ids, t) {
ids = topSel(ids);
var tgt = obj(t.id);
if (!tgt || ids.indexOf(t.id) >= 0) return;
if (ids.some(function (id) { return ancestors(tgt).some(function (g) { return g.id === id; }); })) { toast("A group cannot go into itself", true); return; }
var roots = tree(), locs = ids.map(function (id) { return findNode(roots, id); }).filter(Boolean);
locs.sort(function (a, b) { return docIndex(a.node.o.id) - docIndex(b.node.o.id); });
var nodes = locs.map(function (l) { l.list.splice(l.list.indexOf(l.node), 1); return l.node; });
var dest, at, parent;
if (t.where === "into") { var g = findNode(roots, t.id); dest = g.node.kids; at = dest.length; parent = t.id; }
else { var l = findNode(roots, t.id); dest = l.list; parent = l.parent; at = dest.indexOf(l.node) + (t.where === "above" ? 1 : 0); }
Array.prototype.splice.apply(dest, [at, 0].concat(nodes));
var changes = nodes.filter(function (n) { return (n.o.group || "") !== parent; }).map(function (n) { return { op: "update", id: n.o.id, patch: { group: parent } }; });
commitTree(roots, changes, [], t.where === "into" ? "Moved into " + label(tgt) : null);
}
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 = turnA(h.host.rot || 0, (+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), many = selIds().length > 1;
if (!o || (lockedEff(o) && !many)) 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); }
if (many) moveMany(moveSet(selIds()), wx * s, wy * s, true).then(function () { renderRight(); });
else 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 (chain && k === "Enter") { finishChain(e.shiftKey); return true; }
if (chain && k === "Backspace") { chain.pts.pop(); if (!chain.pts.length) { chain = null; ghost(null); setPlacing(S.placing); } else ghost(null); return true; }
if (k === "Escape") { if (chain) { chain = null; ghost(null); setPlacing(S.placing); } else if (S.placing) setPlacing(null); else select(null); return true; }
if (ctrl && (k === "a" || k === "A")) { selectAll(); return true; }
if (!S.sel) return false;
var many = selIds().length > 1;
// groups and the drawing order: Ctrl+G, Ctrl+Shift+G; Ctrl+] / Ctrl+[ forward / backward, with Shift to the front / back
if (ctrl && (k === "g" || k === "G")) { if (e.shiftKey) ungroup(); else groupSelection(); return true; }
if (ctrl && (k === "]" || k === "}")) { moveOrder(e.shiftKey ? "front" : "forward"); return true; }
if (ctrl && (k === "[" || k === "{")) { moveOrder(e.shiftKey ? "back" : "backward"); return true; }
if (k === "Delete" || k === "Backspace") { if (many) removeMany(selIds()); else remove(S.sel); return true; }
if (ctrl && (k === "d" || k === "D")) { if (many) duplicateMany(selIds()); else duplicate(S.sel); return true; }
if (!ctrl && (k === "r" || k === "R")) { if (many) turnGroup(selIds(), e.shiftKey ? -90 : 90); else 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>';
}
// the object tree, front first: groups fold open and shut, every row has a hide and a lock toggle
function renderLists() {
var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || [], out = [];
$("ocount").textContent = list.length;
function hit(o) { return (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0; }
function shows(n) { return !q || hit(n.o) || n.kids.some(shows); }
function walk(nodes, depth) {
for (var i = nodes.length - 1; i >= 0; i--) {
var n = nodes[i], o = n.o, g = isGroup(o);
if (!shows(n)) continue;
// groups start open; a filter opens every group that holds a match
var open = g && (q ? true : !closedGroups[o.id]);
var cls = (g ? "st-group" : "") + (o.hidden ? " st-hidden" : hiddenEff(o) ? " st-hidden st-inh" : "") + (lockedEff(o) ? " st-locked" : "");
out.push('<li data-id="' + esc(o.id) + '" aria-selected="' + (S.selIds.indexOf(o.id) >= 0) + '" class="' + cls.trim() + '" style="--d:' + depth + '" title="' + esc(o.id) + '">' +
(g ? '<b class="st-tw" data-tw title="' + (open ? "Fold" : "Unfold") + '">' + (open ? "▾" : "▸") + "</b>" : '<b class="st-tw"></b>') +
"<i>" + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span>" +
(g ? "<small>" + membersOf(o.id, true).filter(function (x) { return !isGroup(x); }).length + "</small>" : "") +
'<button type="button" class="st-eye' + (o.hidden ? " st-on" : "") + '" data-eye title="' + (o.hidden ? "Show" : "Hide") + '">' + (o.hidden ? "◌" : "◉") + "</button>" +
'<button type="button" class="st-lk' + (o.locked ? " st-on" : "") + '" data-lk title="' + (o.locked ? "Unlock" : "Lock") + '">' + (o.locked ? "🔒" : "🔓") + "</button></li>");
if (g && open) walk(n.kids, depth + 1);
}
}
walk(tree(), 0);
$("objects").innerHTML = out.join("");
otools();
}
// the list's tool strip follows the selection
function otools() {
var sel = selIds();
$("ogroup").disabled = topSel(sel).length < 2;
$("oungroup").disabled = !selGroup();
$("ofwd").disabled = $("obwd").disabled = !sel.length;
}
// 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);
// Ctrl+click adds or takes out one, Shift+click selects the range from the last picked one
$("objects").addEventListener("click", function (e) {
if (listDragged) { listDragged = false; return; }
var li = e.target.closest("li"); if (!li) return;
var id = li.getAttribute("data-id"), o = obj(id);
if (!o) return;
if (e.target.closest("[data-tw]")) { if (closedGroups[id]) delete closedGroups[id]; else closedGroups[id] = 1; renderLists(); return; }
if (e.target.closest("[data-eye]")) { update(id, { hidden: !o.hidden }); return; }
if (e.target.closest("[data-lk]")) { update(id, { locked: !o.locked }); return; }
if (e.ctrlKey || e.metaKey) { toggleSel(id); return; }
if (e.shiftKey && S.sel) {
var all = Array.prototype.map.call(document.querySelectorAll("#objects li"), function (x) { return x.getAttribute("data-id"); }), a = all.indexOf(S.sel), b = all.indexOf(id);
if (a >= 0 && b >= 0) { selectMany(all.slice(Math.min(a, b), Math.max(a, b) + 1), S.sel); return; }
}
// a group row selects the group and everything in it, so a drag on the map moves it all
if (isGroup(o)) selectGroup(id); else select(id);
});
$("objects").addEventListener("dblclick", function (e) {
var li = e.target.closest("li"); if (!li || e.target.closest("button,[data-tw]")) return;
var id = li.getAttribute("data-id");
if (isGroup(obj(id))) focusMany(membersOf(id, true).map(function (o) { return o.id; })); else S.view.focus(id, 3);
});
// rows dragged in the list: above or below another row (in front of or behind it), or into a
// group (the middle of its row); the selected rows go together when the one dragged is selected
var listDrag = null, listDragged = false;
function clearDrop() { Array.prototype.forEach.call(document.querySelectorAll("#objects .st-drop-above, #objects .st-drop-below, #objects .st-drop-in"), function (x) { x.classList.remove("st-drop-above", "st-drop-below", "st-drop-in"); }); }
$("objects").addEventListener("pointerdown", function (e) {
var li = e.target.closest("li");
if (e.button !== 0 || !li || e.target.closest("button,[data-tw]") || e.shiftKey || e.ctrlKey || e.metaKey) return;
listDrag = { id: li.getAttribute("data-id"), y0: e.clientY, on: false, t: null };
});
document.addEventListener("pointermove", function (e) {
var d = listDrag; if (!d) return;
if (!d.on && Math.abs(e.clientY - d.y0) < 6) return;
d.on = true; document.body.classList.add("st-listdrag");
clearDrop();
var el = document.elementFromPoint(e.clientX, e.clientY), li = el && el.closest ? el.closest("#objects li") : null;
if (!li || li.getAttribute("data-id") === d.id) { d.t = null; return; }
var r = li.getBoundingClientRect(), f = (e.clientY - r.top) / r.height, g = li.classList.contains("st-group");
var where = g && f > 0.28 && f < 0.72 ? "into" : f < 0.5 ? "above" : "below";
li.classList.add(where === "into" ? "st-drop-in" : "st-drop-" + where);
d.t = { id: li.getAttribute("data-id"), where: where };
});
document.addEventListener("pointerup", function () {
var d = listDrag; listDrag = null;
if (!d) return;
document.body.classList.remove("st-listdrag"); clearDrop();
if (!d.on) return;
listDragged = true; setTimeout(function () { listDragged = false; }, 0);
if (d.t) dropRows(selIds().indexOf(d.id) >= 0 ? selIds() : [d.id], d.t);
});
$("ogroup").addEventListener("click", function () { groupSelection(); });
$("oungroup").addEventListener("click", function () { ungroup(); });
$("ofwd").addEventListener("click", function (e) { moveOrder(e.shiftKey ? "front" : "forward"); });
$("obwd").addEventListener("click", function (e) { moveOrder(e.shiftKey ? "back" : "backward"); });
// ------------------------------------------------------------ right panel
function field(key, lab, html, full) { return '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : html); }
// a property's input; mixed: the selected objects differ here, so it shows no value until set
function propInput(d, v, mixed) {
var id = "p-" + d.key, val = mixed ? "" : v === undefined ? d.default : v, ph = mixed ? ' placeholder="mixed"' : "";
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + (mixed ? ' data-mixed="1"' : "") + ">";
if (d.type === "choice" || (d.type === "material" && d.choices && d.choices.length)) {
var ch = (d.choices || []).slice(); if (!mixed && val !== undefined && ch.indexOf(val) < 0) ch.unshift(val);
return '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + (mixed ? '<option value="" selected disabled>mixed</option>' : "") +
ch.map(function (c) { return "<option" + (!mixed && 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) + '"' + ph + ">";
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 = !mixed && 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) + '"' + ph + "></div>";
}
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '"' + ph + ">";
}
function renderRight() {
var R = $("right"), o = S.sel ? obj(S.sel) : null, ids = selIds();
if (!o) { renderProject(R); return; }
if (isGroup(o)) { renderGroup(R, o); return; }
if (ids.length > 1) { renderMany(R, ids); 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><input type="number" step="any" data-f="rot" value="' + (+(o.rot || 0).toFixed(4)) + '" title="degrees, counter-clockwise from east; any angle">°<button type="button" data-act="rotr" title="R">⟳</button></div>') +
"</div>" +
(o.pts && o.pts.length >= 2 ? '<h2>Points <small>' + o.pts.length + '</small></h2><p class="st-help">Drag a point on the map to move it, drag a + to add one, double-click a point to remove it.</p>' +
(m.outline ? '<div class="st-actions"><button type="button" data-act="unpts" title="Back to a rectangle of width × length">Rectangle again</button></div>' : "")
: m.outline ? '<h2>Outline</h2><div class="st-actions"><button type="button" data-act="pts" title="Turn the rectangle into four corners you can drag">Edit corners</button></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;
}
// several objects: what they are, actions for all, and the properties when they are all one type
function renderGroup(R, g) {
var kids = membersOf(g.id, false), all = membersOf(g.id, true).filter(function (o) { return !isGroup(o); });
R.innerHTML = "<h3><i>" + ICON_GROUP + "</i>" + esc(label(g)) + "</h3>" +
'<div class="st-sub">Group · ' + kids.length + " inside, " + all.length + " object" + (all.length === 1 ? "" : "s") + " in all · id " + esc(g.id) + "</div>" +
'<p class="st-help">Everything in the group is selected: drag one of them on the map to move them all, R turns them, arrow keys nudge. Hiding or locking the group hides or locks all of it. In the list, drag rows into or out of it; higher in the list draws on top (ground pieces).</p>' +
'<div class="st-actions">' +
'<button type="button" data-act="focus">Zoom to</button>' +
'<button type="button" data-act="ungroup" title="Ctrl+Shift+G">Ungroup</button>' +
'<button type="button" data-act="front" title="Ctrl+Shift+]">To front</button>' +
'<button type="button" data-act="back" title="Ctrl+Shift+[">To back</button>' +
'<button type="button" data-act="lock">' + (g.locked ? "Unlock" : "Lock") + "</button>" +
'<button type="button" data-act="hide">' + (g.hidden ? "Show" : "Hide") + "</button>" +
'<button type="button" data-act="del" class="st-danger">Delete with contents</button></div>' +
'<h2>Group</h2><div class="st-form">' + field("name", "Name", '<input data-f="name" value="' + esc(g.name || "") + '" placeholder="Group">') + "</div>";
}
function renderMany(R, ids) {
var os = ids.map(obj), count = {}, order = [];
os.forEach(function (o) { if (!count[o.type]) { count[o.type] = 0; order.push(o.type); } count[o.type]++; });
var one = order.length === 1 ? prefab(order[0]) : null, locked = os.every(function (o) { return o.locked; }), hidden = os.every(function (o) { return o.hidden; });
R.innerHTML = "<h3><i>▣</i>" + ids.length + " objects selected</h3>" +
'<div class="st-sub">' + order.map(function (t) { var m = prefab(t); return count[t] + " × " + esc(m ? m.label : t); }).join(", ") + "</div>" +
'<p class="st-help">Drag one of them to move them all; arrow keys nudge, R turns the group, Delete removes them. Ctrl+click or Shift+click adds or takes out one, Shift+drag on the map adds those in a box, Esc clears.</p>' +
'<div class="st-actions">' +
'<button type="button" data-act="dup" title="Ctrl+D">Duplicate</button>' +
'<button type="button" data-act="focus">Zoom to</button>' +
'<button type="button" data-act="lock">' + (locked ? "Unlock" : "Lock") + "</button>" +
'<button type="button" data-act="hide">' + (hidden ? "Show" : "Hide") + "</button>" +
'<button type="button" data-act="del" class="st-danger">Delete ' + ids.length + "</button></div>" +
'<h2>Placement</h2><div class="st-form">' +
field("rot", "Turn the group", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>90°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') + "</div>" +
(one && one.props.length ? "<h2>Properties <small>for all " + ids.length + "</small></h2><div class=\"st-form\">" + one.props.map(function (d) {
var vals = os.map(function (o) { return JSON.stringify(propOf(o, d.key)); }), same = vals.every(function (v) { return v === vals[0]; });
return field(d.key, d.label || d.key, propInput(d, propOf(os[0], d.key), !same));
}).join("") + "</div>" : "") +
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
Array.prototype.forEach.call(R.querySelectorAll("input[data-mixed]"), function (x) { x.indeterminate = true; });
}
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. ✦&nbsp;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), ids = selIds();
if (isGroup(o)) {
if (a === "focus") focusMany(membersOf(o.id, true).map(function (x) { return x.id; }));
else if (a === "ungroup") ungroup(o);
else if (a === "front" || a === "back") moveOrder(a, [o.id]);
else if (a === "lock") update(o.id, { locked: !o.locked });
else if (a === "hide") update(o.id, { hidden: !o.hidden });
else if (a === "del") removeMany([o.id].concat(membersOf(o.id, true).map(function (x) { return x.id; })));
return;
}
if (ids.length > 1) {
if (a === "del") removeMany(ids);
else if (a === "dup") duplicateMany(ids);
else if (a === "focus") focusMany(ids);
else if (a === "lock") setAll(ids, "locked");
else if (a === "hide") setAll(ids, "hidden");
else if (a === "rotl" || a === "rotr") turnGroup(ids, a === "rotl" ? -90 : 90);
return;
}
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") moveOrder(a, [S.sel]);
else if (a === "lock") update(S.sel, { locked: !o.locked });
else if (a === "hide") update(S.sel, { hidden: !o.hidden });
else if (a === "pts") editCorners(o);
else if (a === "unpts") update(S.sel, { pts: null });
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 = {}, all = selIds().length > 1 ? selIds() : [o.id];
pp[key] = pv;
var put = function () { opList(all.map(function (id) { return { op: "update", id: id, patch: { props: pp } }; }), true); };
if (live) { clearTimeout(rangeT); rangeT = setTimeout(put, 160); }
else put();
} 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
});
})();