Files
isokit/studio/studio.js
T
grabowski 5ec164a633
Deploy web Studio / deploy (push) Successful in 1m4s
Routes: movers along waypoints (roadmap 8)
New route prefab (routes.rhai): cars, buses, motorbikes, cyclists, people,
deer or elephants move along waypoints, round a loop or there and back.
Each way keeps to the left of the line (or right), corners are rounded
into pieces of at most 45 degrees; there-and-back routes become loops with
U-turns, so the two directions never share a strip.

Engine: Prim::Travel / sprite.begin_travel / script travel(a, b, dur,
windows) ... end(): the mover is drawn at a piece's start and slides to
its end while it is on that piece, hidden otherwise. The renderer writes
keyframes per piece (named by a hash of their content) into one <style>
per view. Each piece is its own sprite with the lane strip as its sort
extent (ext_reset), mitred at the corners, so movers pass in front of and
behind trees, lamps and houses. Movers that look alike and never meet on
a piece share one drawing. iso-travel pauses (not hides) in still,
reduced-motion and ghosted views.

Studio: meta shape "path" draws point by point (snapping like runs) and
saves one object with pts; ending on the first point closes the loop.
MCP: pts in the object schema, route guidance.

Demos: streets gets traffic round the block, a bus, motorbikes, cyclists
and strolling people (replacing the one-axis cars); country gets cars on
the lane.

Tests: Rust 79 (each mover on exactly one piece at every moment, in every
rotation), Studio 125, web 99.
2026-10-25 17:52:17 +07:00

1048 lines
68 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 || (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]; }
// 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]);
},
// 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;
}
// 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 || 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 && 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; }
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 (!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;
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)); });
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 !o.locked && !o.hidden; }).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 (o.locked || o.hidden) 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 && !o.locked; }), 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) {
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);
}
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 || (o.locked && !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;
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>';
}
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="' + (S.selIds.indexOf(o.id) >= 0) + '"' + (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);
// Ctrl+click adds or takes out one, Shift+click selects the range from the last picked one
$("objects").addEventListener("click", function (e) {
var li = e.target.closest("li"); if (!li) return;
var id = li.getAttribute("data-id");
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; }
}
select(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); }
// 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 (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>" +
(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 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 (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") 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 = {}, 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
});
})();