Files
isokit/studio/studio.js
T
grabowski e4017ce2a8
Deploy web Studio / deploy (push) Successful in 3m52s
Fences join like walls, fence designs, draw runs corner by corner
Joints: line_joint (util.rhai) applies the house-wall rules to fences and
site walls, so pieces never overlap and the depth sort stays clean.
- A corner is closed: the thicker piece (or, if equal, the one along
  world x) runs through, and the other stops at its face.
- On a straight run, two pieces share one post.
- At a T, the stem stops at the other piece's face without a post of its
  own.
- A fence that ends at a site or house wall stops at the wall's face.

Fence designs (prop style): mesh, wooden boards, steel bars, mesh on a
stone base, cobble wall, and brick wall with pillars. Board, stone and
brick joints are drawn on the face turned to the viewer. Brick pillars
are separate sprites.

Studio: line prefabs (walls, fences, hedges, roads) can be drawn as a
run by clicking corner after corner. Double-click or Enter ends the run,
clicking the first corner closes it, and Backspace drops the last
corner. A run is one undo step. A drag still draws one piece. Corners
and ends snap onto the ends and lines of the pieces they meet, and a
marker shows where the next corner will snap.

The house demo gets a brick front wall with gate pillars and wooden side
fences in place of the old east fence, which ran over the road. Tests
cover every design's corners, Ts, straight runs and wall stops, with no
depth-sort cycles in any rotation, plus site walls. check_studio draws
and closes a fence run, undoes it in one step, snaps a dragged spur,
and picks a design.
2026-10-24 18:07:40 +07:00

733 lines
47 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, 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]; }
function localOf(o, x, y) { return turnV((4 - qOf(o.rot)) % 4, x - o.x, y - o.y); }
function worldOf(o, u, v) { var d = turnV(qOf(o.rot), u, v); return [o.x + d[0], o.y + d[1]]; }
function defOf(m, k) { var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0]; return d ? d.default : undefined; }
function propOf(o, k) { var p = o.props || {}; return p[k] !== undefined ? p[k] : defOf(prefab(o.type), k); }
function clampProp(m, k, v) {
var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0];
if (d && d.min !== undefined) v = Math.max(d.min, v);
if (d && d.max !== undefined) v = Math.min(d.max, v);
return r3(v);
}
// the host an object sits on (a window's wall): nearest centre line within half its thickness + 0.3 m
function hostOf(o) {
var m = prefab(o.type), hosts = (m && m.mount) || [], best = null;
if (!hosts.length) return null;
S.doc.objects.forEach(function (h) {
if (h.hidden || h.id === o.id || hosts.indexOf(h.type) < 0 || Math.abs((o.z || 0) - (h.z || 0)) > 0.5) return;
var l = localOf(h, o.x, o.y), half = (+propOf(h, "w") || 1) / 2, lim = (+propOf(h, "t") || 0) / 2 + 0.3;
if (Math.abs(l[0]) <= half + 1e-6 && Math.abs(l[1]) <= lim + 1e-6 && (!best || Math.abs(l[1]) < Math.abs(best.v) - 1e-9)) best = { host: h, u: l[0], v: l[1] };
});
return best;
}
function isHost(kind) { return (S.meta.catalog || []).some(function (m) { return (m.mount || []).indexOf(kind) >= 0; }); }
// the objects sitting on h; they move, turn, copy and delete with it
function mountedOn(h) {
if (!isHost(h.type)) return [];
return S.doc.objects.filter(function (o) { var r = o.id !== h.id && hostOf(o); return r && r.host.id === h.id; });
}
// where a window or door goes when dropped at w: onto the nearest host's centre line (within 1.5 m),
// along it by the snap step, kept inside the host's length
function snapMount(m, w, z, selfId, width) {
var best = null;
S.doc.objects.forEach(function (h) {
if (h.hidden || h.id === selfId || (m.mount || []).indexOf(h.type) < 0 || Math.abs((h.z || 0) - z) > 0.5) return;
var l = localOf(h, w[0], w[1]), half = (+propOf(h, "w") || 1) / 2, u = Math.max(-half, Math.min(half, l[0])), d = Math.hypot(l[0] - u, l[1]);
if (d < 1.5 && (!best || d < best.d)) best = { host: h, u: u, side: l[1] < 0 ? -1 : 1, d: d, half: half };
});
if (!best) return null;
var s = snapStep(), u = s ? Math.round(best.u / s) * s : best.u, hw = (+width || 1) / 2;
u = hw >= best.half ? 0 : Math.max(-best.half + hw, Math.min(best.half - hw, u));
var p = worldOf(best.host, u, 0);
return { x: r3(p[0]), y: r3(p[1]), z: best.host.z || 0, host: best.host, u: u, side: best.side, hw: hw };
}
// ------------------------------------------------------------ ghosts: previews drawn over the view
function ghost(polys, text) {
var svg = S.view.svg, g = svg.querySelector(".st-ghosts");
if (!polys || !polys.length) { if (g) g.parentNode.removeChild(g); return; }
if (!g) { g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute("class", "st-ghosts"); svg.appendChild(g); }
var rot = S.view.getRotation();
g.innerHTML = polys.map(function (p) {
return '<path class="st-ghost' + (p.cls ? " " + p.cls : "") + '" d="' + p.pts.map(function (q, i) {
var s = IsoKit.project(rot, q[0], q[1], q[2] || 0);
return (i ? "L" : "M") + s[0].toFixed(1) + " " + s[1].toFixed(1);
}).join("") + 'Z"/>';
}).join("");
if (text !== undefined) { var h = $("hint"); h.textContent = text; h.hidden = false; }
}
function rectPts(x0, y0, x1, y1, z) { return [[x0, y0, z], [x1, y0, z], [x1, y1, z], [x0, y1, z]]; }
// an upright quad from a to b (plan points), z0 to z1
function planePts(a, b, z0, z1) { return [[a[0], a[1], z0], [b[0], b[1], z0], [b[0], b[1], z1], [a[0], a[1], z1]]; }
function mountGhost(m, sm, o) {
var z0 = +(o ? propOf(o, "sill") : defOf(m, "sill")) || 0, h = +(o ? propOf(o, "h") : defOf(m, "h")) || 2;
return [{ pts: planePts(worldOf(sm.host, sm.u - sm.hw, 0), worldOf(sm.host, sm.u + sm.hw, 0), sm.z + z0, sm.z + z0 + h), cls: "st-plane" }];
}
// ------------------------------------------------------------ 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 };
});
}
// where a corner goes for pointer world point w: with `from` (the previous corner) the piece keeps
// to x or y 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; without, onto the nearest end, else onto a line, else the grid
function snapCorner(w, z, from, R) {
var pcs = linePieces(z), own = chain ? chain.pts : [], best = null, i;
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 preview of one piece from p to q: 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, ax = Math.abs(q[1] - p[1]) < 1e-9;
var x0 = Math.min(p[0], q[0]), x1 = Math.max(p[0], q[0]), y0 = Math.min(p[1], q[1]), y1 = Math.max(p[1], q[1]);
var out = [{ pts: ax ? rectPts(x0, y0 - th / 2, x1, y0 + th / 2, z) : rectPts(x0 - th / 2, y0, x0 + th / 2, y1, 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)), 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);
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 · " +
(closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") + " · Backspace undoes a corner · Esc cancels");
}
// the pieces of a run as add ops (pieces longer than the prefab allows are split)
function runOps(m, pts, z) {
var ops = [], max = +((m.props || []).filter(function (d) { return d.key === "w"; })[0] || {}).max || 1e9;
for (var i = 1; i < pts.length; i++) {
var p = pts[i - 1], q = pts[i], L = Math.hypot(q[0] - p[0], q[1] - p[1]), n = Math.ceil(L / max - 1e-9), ax = Math.abs(q[1] - p[1]) < 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: ax ? 0 : 90, props: { w: clampProp(m, "w", L / n) } };
if (z) add.z = z;
ops.push(add);
}
}
return ops;
}
function finishChain(keep) {
var c = chain; chain = null; ghost(null);
if (!c) 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";
if (r.id && o.op !== "update") S.sel = r.id;
if (S.sel && !obj(S.sel)) S.sel = null;
S.view.refresh();
renderLists(); if (!quiet) renderRight();
return r;
}, function (e) { $("save").textContent = B ? "Not saved" : "Offline"; $("save").classList.add("st-err"); toast((B ? "Could not save: " : "Studio server not reachable: ") + e, true); });
}
function update(id, patch, quiet) { return op({ op: "update", id: id, patch: patch }, quiet); }
function undo() { api("/api/undo", {}).then(function (r) { takeState(r); afterExternal(); }); }
function redo() { api("/api/redo", {}).then(function (r) { takeState(r); afterExternal(); }); }
function afterExternal() {
if (S.sel && !obj(S.sel)) S.sel = null;
S.view.refresh(); renderLists(); renderRight();
}
function reloadAll(why) {
return Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) {
var levelsChanged = JSON.stringify(rs[0].levels) !== JSON.stringify(S.meta.levels);
S.meta = rs[0]; takeState(rs[1]);
if (levelsChanged) { remount(); } else { S.view.source.meta = S.meta; S.view.refresh(); }
renderPalette(); afterExternal();
if (why) toast(why);
});
}
// another editor or an agent changed the files: follow along
function poll() {
api("/api/version").then(function (r) {
if (r.version !== S.version && !S.busy) { S.version = r.version; reloadAll(r.why || "Scene updated outside the Studio (agent or file edit)"); }
}).catch(function () { /* server restarting */ }).then(function () { setTimeout(poll, 800); });
}
// ------------------------------------------------------------ view
var drag = null, tool = null;
function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); }
function mountView() {
var src = B ? B.source(toViewerMeta(S.meta)) : IsoKit.httpSource("", toViewerMeta(S.meta));
S.view = IsoKit.mount($("view"), src, {
note: false, noSelect: true, wheelZoom: true,
onRender: function (d) {
if (!d || d.error) { $("stats").textContent = "script error"; return; }
S.boxes = d.boxes || {}; S.warnings = d.warnings || [];
$("stats").textContent = d.sprites + " sprites · " + d.nodes + " nodes · " + d.ms + " ms";
var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s");
if (S.sel) S.view.selectObj(S.sel);
},
onHover: function (w, e) {
if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1);
if (S.placing && !tool && e) hoverPlacing(e);
},
onPick: function (i) { select(i && i.obj ? i.obj : null); },
onKey: keyHandler,
interact: {
down: function (e, hit) {
if (S.placing) {
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z);
a = pm.shape === "line" ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e)) : [snap(a[0]), snap(a[1])];
tool = { m: pm, z: z, a: a, b: null };
return true;
}
if (!hit.obj) return false;
var o = obj(hit.obj);
if (!o || o.locked) return false;
var m = prefab(o.type), mount = !!(m && m.mount && m.mount.length), h = mount ? hostOf(o) : null;
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, m: m, mount: mount,
rel: h ? qOf((o.rot || 0) - (h.host.rot || 0)) : null, carry: mountedOn(o) };
return true;
},
move: function (e, mv) {
if (S.placing) { if (tool && mv.world) drawTool(e); return; }
if (!drag || !mv.world || !drag.w0) return;
if (!drag.moved) {
drag.moved = true; select(drag.id, true); drag.els = [];
// what sits on the object (a wall's windows) moves along
[drag.id].concat(drag.carry.map(function (c) { return c.id; })).forEach(function (id) { Array.prototype.push.apply(drag.els, moveEls(id)); });
}
var tx = drag.ox + mv.world[0] - drag.w0[0], ty = drag.oy + mv.world[1] - drag.w0[1];
var dx = snap(tx) - drag.ox, dy = snap(ty) - drag.oy;
drag.snapTo = null;
if (drag.mount) {
// windows and doors snap onto the nearest wall
var o = obj(drag.id), sm = o && snapMount(drag.m, [tx, ty], o.z || 0, o.id, propOf(o, "w"));
if (sm) { drag.snapTo = sm; dx = sm.x - drag.ox; dy = sm.y - drag.oy; ghost(mountGhost(drag.m, sm, o)); } else ghost(null);
}
var a = S.view.screenOf(dx, dy, 0), b = S.view.screenOf(0, 0, 0);
drag.dx = dx; drag.dy = dy;
drag.els.forEach(function (g) { g.setAttribute("transform", "translate(" + (a[0] - b[0]).toFixed(1) + " " + (a[1] - b[1]).toFixed(1) + ")"); g.classList.add("iso-moving"); });
$("cursor").textContent = "x " + (drag.ox + dx).toFixed(2) + " y " + (drag.oy + dy).toFixed(2);
},
up: function (e, mv) {
if (S.placing) { var t = tool; tool = null; ghost(null); if (t && !mv.cancel) finishTool(t, e); return; }
var d = drag; drag = null; ghost(null);
if (!d) return;
if (!d.moved || mv.cancel) { if (d.moved) S.view.refresh(); select(d.id); return; }
if (d.snapTo) {
// on a wall: keep the turn relative to the wall it came from (door swing), else open to the side dropped on
var sm = d.snapTo, rel = d.rel !== null ? d.rel * 90 : (sm.side < 0 ? 180 : 0);
update(d.id, { x: sm.x, y: sm.y, z: sm.z, rot: ((sm.host.rot || 0) + rel) % 360 });
} else if (d.dx || d.dy) moveWith(d.id, d.dx, d.dy, d.carry);
else S.view.refresh();
},
cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; tool = null; ghost(null); },
dblclick: function (e) { if (!chain) return false; finishChain(e.shiftKey); return true; }
}
});
window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true };
}
function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; }
function remount() {
var lv = S.view ? S.view.getLevel() : null, rot = S.view ? S.view.getRotation() : null;
if (S.view) S.view.destroy();
mountView();
if (lv) S.view.setLevel(lv);
if (rot !== null) S.view.setRotation(rot);
}
// ------------------------------------------------------------ actions
// the hover preview while placing a window or door: where on which wall it goes
function hoverPlacing(e) {
var m = pmeta(S.placing);
if (chain) { drawChain(e); return; }
if (m && m.shape === "line") { 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 along the main axis,
// "rect" prefabs (and rooms) fill the rectangle
function toolGeom(t) {
var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1];
if (t.m.shape === "line") {
var ax = Math.abs(dx) >= Math.abs(dy), L = Math.abs(ax ? dx : dy);
return { line: true, ax: ax, len: L, x: ax ? (a[0] + b[0]) / 2 : a[0], y: ax ? a[1] : (a[1] + b[1]) / 2, ok: L >= 0.25 };
}
return { w: Math.abs(dx), d: Math.abs(dy), x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2,
x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]), ok: Math.abs(dx) >= 0.25 && Math.abs(dy) >= 0.25 };
}
function drawTool(e) {
var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = [];
if (chain) { drawChain(e); return; }
t.b = m.shape === "line" ? snapCorner(w, z, t.a, 12 * pxM(e)) : [snap(w[0]), snap(w[1])];
if (m.shape !== "line" && m.shape !== "rect") return;
var g = toolGeom(t);
if (g.line) {
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, L = g.len / 2;
polys.push({ pts: g.ax ? rectPts(g.x - L, g.y - th / 2, g.x + L, g.y + th / 2, z) : rectPts(g.x - th / 2, g.y - L, g.x + th / 2, g.y + L, z) });
if (h > 0) polys.push({ pts: planePts(g.ax ? [g.x - L, g.y] : [g.x, g.y - L], g.ax ? [g.x + L, g.y] : [g.x, g.y + L], z, z + h), cls: "st-plane" });
ghost(polys, m.label + " " + g.len.toFixed(2) + " m · release to place");
return;
}
polys.push({ pts: rectPts(g.x0, g.y0, g.x1, g.y1, z) });
if (m === ROOM) {
var wh = +defOf(prefab("house_wall"), "h") || 2.8;
[[g.x0, g.y0, g.x1, g.y0], [g.x0, g.y1, g.x1, g.y1], [g.x0, g.y0, g.x0, g.y1], [g.x1, g.y0, g.x1, g.y1]].forEach(function (s) {
polys.push({ pts: planePts([s[0], s[1]], [s[2], s[3]], z, z + wh), cls: "st-plane" });
});
}
ghost(polys, m.label + " " + g.w.toFixed(2) + " × " + g.d.toFixed(2) + " m · release to place");
}
function finishTool(t, e) {
var m = t.m, keep = e.shiftKey, g = t.b ? toolGeom(t) : null, add;
var at = worldAt(e, t.z), p = [snap(at[0]), snap(at[1])];
if (m.shape === "line") {
// 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));
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 = 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.ax ? 0 : 90, props: { w: clampProp(m, "w", g.len) } };
else if (g && g.ok && m.shape === "rect") add = { type: m.type, x: r3(g.x), y: r3(g.y), props: { w: clampProp(m, "w", g.w), d: clampProp(m, "d", g.d) } };
else if (m.mount && m.mount.length) {
// onto the nearest wall, opening towards the side clicked on; on no wall it stands alone
var sm = snapMount(m, at, t.z, null, defOf(m, "w"));
add = sm ? { type: m.type, x: sm.x, y: sm.y, z: sm.z, rot: ((sm.host.rot || 0) + (sm.side < 0 ? 180 : 0)) % 360 } : { type: m.type, x: p[0], y: p[1] };
} else add = { type: m.type, x: p[0], y: p[1] };
add.op = "add";
if (add.z === undefined && t.z) add.z = t.z;
op(add).then(function (r) { if (r) toast(m.label + " added" + (keep ? "" : " · Shift to place several")); });
if (!keep) setPlacing(null);
}
// walls around a room's rectangle, leaving out the stretches a wall already stands on
function roomWalls(x0, y0, x1, y1, z) {
var walls = S.doc.objects.filter(function (o) { return o.type === "house_wall" && !o.hidden && Math.abs((o.z || 0) - z) < 0.5; }), out = [];
[[x0, y0, x1, y0], [x0, y1, x1, y1], [x0, y0, x0, y1], [x1, y0, x1, y1]].forEach(function (e) {
var ax = e[1] === e[3], line = ax ? e[1] : e[0], lo = ax ? e[0] : e[1], hi = ax ? e[2] : e[3], cov = [];
walls.forEach(function (w) {
if ((qOf(w.rot) % 2 === 0) !== ax || Math.abs((ax ? w.y : w.x) - line) > 0.05) return;
var c = ax ? w.x : w.y, L = (+propOf(w, "w") || 0) / 2;
cov.push([c - L, c + L]);
});
cov.sort(function (a, b) { return a[0] - b[0]; });
var segs = [], cur = lo;
cov.forEach(function (c) { if (c[0] > cur + 0.05) segs.push([cur, Math.min(c[0], hi)]); cur = Math.max(cur, c[1]); });
if (hi > cur + 0.05) segs.push([cur, hi]);
segs.forEach(function (s) {
if (s[1] - s[0] < 0.2) return;
var mid = r3((s[0] + s[1]) / 2);
out.push({ op: "add", type: "house_wall", x: ax ? mid : line, y: ax ? line : mid, z: z, rot: ax ? 0 : 90, props: { w: r3(s[1] - s[0]) } });
});
});
return out;
}
function setPlacing(kind) {
S.placing = kind; tool = null; 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 === "rect" ? "Drag a rectangle on the map: " + lab + " (a click gives the default size) · Esc to stop"
: "Click on the map to place: " + lab + " · Esc to stop";
h.hidden = false;
} else h.hidden = true;
}
function select(id, quiet) {
if (id && !obj(id)) id = null;
S.sel = id;
S.view.selectObj(id);
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(li.getAttribute("data-id") === id)); });
if (!quiet) renderRight();
}
function opList(ops, quiet) { return op(ops.length === 1 ? ops[0] : { ops: ops }, quiet); }
// the object and what sits on it (a wall's windows and doors) as one undo step; the object last, so it stays selected
function remove(id) {
var o = obj(id); if (!o) return;
var carry = mountedOn(o);
opList(carry.map(function (c) { return { op: "remove", id: c.id }; }).concat([{ op: "remove", id: id }])).then(function (r) {
if (r) toast("Deleted" + (carry.length ? " with the " + carry.length + " window" + (carry.length === 1 ? "" : "s") + " / door" + (carry.length === 1 ? "" : "s") + " on it" : "") + " · Ctrl+Z brings it back");
});
}
function duplicate(id) {
var o = obj(id); if (!o) return;
var dd = Math.max(snapStep(), 1) * 2, dx = dd, dy = dd, h = hostOf(o);
// a copy of a window or door goes further along its wall
if (h) { var v = turnV(qOf(h.host.rot), (+propOf(o, "w") || 1) + 0.5, 0); dx = r3(v[0]); dy = r3(v[1]); }
opList(mountedOn(o).map(function (c) { return { op: "duplicate", id: c.id, dx: dx, dy: dy }; }).concat([{ op: "duplicate", id: id, dx: dx, dy: dy }]));
}
function moveWith(id, dx, dy, carry, quiet) {
var o = obj(id); if (!o) return Promise.resolve(null);
var ops = (carry || mountedOn(o)).map(function (c) { return { op: "update", id: c.id, patch: { x: r3(c.x + dx), y: r3(c.y + dy) } }; });
ops.push({ op: "update", id: id, patch: { x: r3(o.x + dx), y: r3(o.y + dy) } });
return opList(ops, quiet);
}
function turn(id, d) {
var o = obj(id); if (!o) return;
var q = qOf(d), ops = mountedOn(o).map(function (c) {
var v = turnV(q, c.x - o.x, c.y - o.y);
return { op: "update", id: c.id, patch: { x: r3(o.x + v[0]), y: r3(o.y + v[1]), rot: ((c.rot || 0) + d + 360) % 360 } };
});
ops.push({ op: "update", id: id, patch: { rot: ((o.rot || 0) + d + 360) % 360 } });
opList(ops);
}
function nudge(id, dx, dy) {
var o = obj(id); if (!o || o.locked) return;
var s = snapStep() || 0.5;
// arrow keys follow the screen: map screen directions onto world axes for the current rotation
var rot = S.view.getRotation();
var right = IsoKit.unproject(rot, 1, 0, 0), up = IsoKit.unproject(rot, 0, -1, 0), o0 = IsoKit.unproject(rot, 0, 0, 0);
var r = [right[0] - o0[0], right[1] - o0[1]], u = [up[0] - o0[0], up[1] - o0[1]];
var wx = dx * r[0] + dy * u[0], wy = dx * r[1] + dy * u[1];
// pick the dominant world axis so a key press moves along x or y only
if (Math.abs(wx) >= Math.abs(wy)) { wy = 0; wx = Math.sign(wx); } else { wx = 0; wy = Math.sign(wy); }
moveWith(id, wx * s, wy * s, null, true).then(function () { renderRight(); });
}
function keyHandler(e) {
var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
if (typing || document.querySelector("dialog[open]")) return false;
var k = e.key, ctrl = e.ctrlKey || e.metaKey;
if (ctrl && (k === "z" || k === "Z")) { if (e.shiftKey) redo(); else undo(); return true; }
if (ctrl && (k === "y" || k === "Y")) { redo(); return true; }
if (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 (!S.sel) return false;
if (k === "Delete" || k === "Backspace") { remove(S.sel); return true; }
if (ctrl && (k === "d" || k === "D")) { duplicate(S.sel); return true; }
if (!ctrl && (k === "r" || k === "R")) { turn(S.sel, e.shiftKey ? -90 : 90); return true; }
if (k === "ArrowLeft") { nudge(S.sel, -1, 0); return true; }
if (k === "ArrowRight") { nudge(S.sel, 1, 0); return true; }
if (k === "ArrowUp") { nudge(S.sel, 0, 1); return true; }
if (k === "ArrowDown") { nudge(S.sel, 0, -1); return true; }
return false;
}
document.addEventListener("keydown", function (e) {
if (S.view && e.target === S.view.svg) return; // the viewer calls keyHandler itself
if (keyHandler(e)) e.preventDefault();
});
// ------------------------------------------------------------ left panel
function renderPalette() {
var q = $("psearch").value.trim().toLowerCase(), cats = {}, order = [], list = (S.meta.catalog || []).slice();
// the Room tool (a floor with walls) leads the House parts
var hi = list.findIndex(function (m) { return m.category === "House"; });
if (hi >= 0 && prefab("house_wall") && prefab("floor")) list.splice(hi, 0, ROOM);
list.forEach(function (m) {
if (q && (m.label + " " + m.type + " " + m.category + " " + (m.help || "")).toLowerCase().indexOf(q) < 0) return;
if (!cats[m.category]) { cats[m.category] = []; order.push(m.category); }
cats[m.category].push(m);
});
var closed = paletteClosed();
$("palette").innerHTML = order.map(function (c) {
var shut = !q && closed.indexOf(c) >= 0;
return '<div><div class="st-cat' + (shut ? " st-closed" : "") + '" data-cat="' + esc(c) + '" title="Show / hide">' + esc(c) + " <small>" + cats[c].length + "</small></div>" +
'<div class="st-tiles"' + (shut ? " hidden" : "") + ">" + cats[c].map(function (m) {
return '<button type="button" class="st-tile" data-type="' + esc(m.type) + '" title="' + esc(m.help || m.label) + '" aria-pressed="' + (S.placing === m.type) + '"><i>' + esc(m.icon || ICON_DEFAULT) + "</i><span>" + esc(m.label) + "</span></button>";
}).join("") + "</div></div>";
}).join("") || '<div class="st-help">No object matches.</div>';
}
function renderLists() {
var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || [];
$("ocount").textContent = list.length;
$("objects").innerHTML = list.filter(function (o) {
return !q || (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0;
}).slice().reverse().map(function (o) {
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (o.id === S.sel) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
}).join("");
}
// closed palette categories, remembered in this browser
function paletteClosed() { try { return JSON.parse(localStorage.getItem("isokit-palette-closed") || "[]"); } catch (e) { return []; } }
$("palette").addEventListener("click", function (e) {
var h = e.target.closest(".st-cat");
if (h) {
var c = h.getAttribute("data-cat"), list = paletteClosed(), i = list.indexOf(c);
if (i >= 0) list.splice(i, 1); else list.push(c);
try { localStorage.setItem("isokit-palette-closed", JSON.stringify(list)); } catch (er) { /* private mode */ }
renderPalette();
return;
}
var b = e.target.closest(".st-tile"); if (!b) return;
var k = b.getAttribute("data-type");
setPlacing(S.placing === k ? null : k);
S.view.svg.focus();
});
$("psearch").addEventListener("input", renderPalette);
$("osearch").addEventListener("input", renderLists);
$("objects").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li) select(li.getAttribute("data-id")); });
$("objects").addEventListener("dblclick", function (e) { var li = e.target.closest("li"); if (li) S.view.focus(li.getAttribute("data-id"), 3); });
// ------------------------------------------------------------ right panel
function field(key, lab, html, full) { return '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : html); }
function propInput(d, v) {
var id = "p-" + d.key, val = v === undefined ? d.default : v;
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + ">";
if (d.type === "choice" || (d.type === "material" && d.choices && d.choices.length)) {
var ch = (d.choices || []).slice(); if (val !== undefined && ch.indexOf(val) < 0) ch.unshift(val);
return '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + ch.map(function (c) { return "<option" + (c === val ? " selected" : "") + ">" + esc(c) + "</option>"; }).join("") + "</select>";
}
if (d.type === "material") return '<input id="' + id + '" data-prop="' + esc(d.key) + '" list="materials" value="' + esc(val) + '">';
if (d.type === "float" || d.type === "int") {
var step = d.step || (d.type === "int" ? 1 : 0.1), mm = (d.min !== undefined ? ' min="' + d.min + '"' : "") + (d.max !== undefined ? ' max="' + d.max + '"' : "");
var range = d.min !== undefined && d.max !== undefined ? '<input type="range" data-prop="' + esc(d.key) + '" data-range="1"' + mm + ' step="' + step + '" value="' + esc(val) + '">' : "<span></span>";
return '<div class="st-num">' + range + '<input type="number" id="' + id + '" data-prop="' + esc(d.key) + '"' + mm + ' step="' + step + '" value="' + esc(val) + '"></div>';
}
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '">';
}
function renderRight() {
var R = $("right"), o = S.sel ? obj(S.sel) : null;
if (!o) { renderProject(R); return; }
var m = prefab(o.type) || { label: o.type, props: [] }, p = o.props || {};
var levels = S.meta.levels || [];
var h = "<h3><i>" + esc(m.icon || ICON_DEFAULT) + "</i>" + esc(label(o)) + "</h3>" +
'<div class="st-sub">' + esc(m.label) + " · id " + esc(o.id) + "</div>" +
(m.help ? '<p class="st-help">' + esc(m.help) + "</p>" : "") +
'<div class="st-actions">' +
'<button type="button" data-act="dup" title="Ctrl+D">Duplicate</button>' +
'<button type="button" data-act="focus" title="Zoom to it">Zoom to</button>' +
'<button type="button" data-act="front" title="Draw after the others (ground items)">To front</button>' +
'<button type="button" data-act="back" title="Draw before the others (ground items)">To back</button>' +
'<button type="button" data-act="lock">' + (o.locked ? "Unlock" : "Lock") + "</button>" +
'<button type="button" data-act="hide">' + (o.hidden ? "Show" : "Hide") + "</button>" +
'<button type="button" data-act="del" class="st-danger" title="Delete">Delete</button></div>' +
S.warnings.filter(function (w) { return w.indexOf("object " + o.id + ":") === 0; }).map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") +
'<h2>Placement</h2><div class="st-form">' +
field("xy", "Position x y z", '<div class="st-xy"><input type="number" step="any" data-f="x" value="' + o.x + '" title="x (east), m"><input type="number" step="any" data-f="y" value="' + o.y + '" title="y (north), m"><input type="number" step="any" data-f="z" value="' + (o.z || 0) + '" title="z (height), m"></div>') +
field("rot", "Rotation", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>' + (o.rot || 0) + '°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') +
"</div>" +
(m.props.length ? "<h2>Properties</h2><div class=\"st-form\">" + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "</div>" : "") +
'<h2>Label and tooltip</h2><div class="st-form">' +
field("name", "Name", '<input data-f="name" value="' + esc(o.name || "") + '" placeholder="' + esc(m.label) + '">') +
field("tag", "Tag", '<input data-f="tag" value="' + esc(o.tag || "") + '" placeholder="label on the map">') +
field("info", "Tooltip", '<textarea data-f="info" rows="2" placeholder="shown on hover / tap">' + esc(o.info || "") + "</textarea>") +
"</div>" +
(levels.length > 1 ? '<h2>Shown on levels</h2><div class="st-levels">' + levels.map(function (l) {
return '<label><input type="checkbox" data-level="' + esc(l.id) + '"' + ((o.levels || []).indexOf(l.id) >= 0 ? " checked" : "") + "> " + esc(l.label || l.id) + "</label>";
}).join("") + '</div><p class="st-help">None ticked: shown on every level.</p>' : "") +
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
R.innerHTML = h;
}
function renderProject(R) {
var m = S.meta, cfg = S.doc.cfg || {};
var base = location.origin;
R.innerHTML = "<h3>" + esc(m.title) + "</h3>" + '<div class="st-sub">' + (S.doc.objects || []).length + " objects · " + (m.catalog || []).length + " object types</div>" +
(m.params && m.params.length ? "<h2>Scene options</h2><div class=\"st-form\">" + m.params.map(function (p) {
var v = cfg[p.name] !== undefined ? cfg[p.name] : p.default;
return field(p.name, p.label || p.name, propInput({ key: p.name, type: p.type, default: p.default, choices: p.choices, min: p.min, max: p.max, step: p.step }, v).replace(/data-prop=/g, "data-cfg="));
}).join("") + "</div>" : "") +
(S.warnings.length ? "<h2>Warnings</h2>" + S.warnings.map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") : "") +
(B && B.agentBox ? B.agentBox(m) :
'<div class="st-box"><h2>Connect an AI agent (MCP)</h2>' +
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live. ✦&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);
if (a === "del") remove(S.sel);
else if (a === "dup") duplicate(S.sel);
else if (a === "focus") S.view.focus(S.sel, 3);
else if (a === "front" || a === "back") op({ op: "reorder", id: S.sel, to: a });
else if (a === "lock") update(S.sel, { locked: !o.locked });
else if (a === "hide") update(S.sel, { hidden: !o.hidden });
else if (a === "rotl") turn(S.sel, -90);
else if (a === "rotr") turn(S.sel, 90);
});
var rangeT = 0;
function formChange(e, live) {
var t = e.target, o = S.sel ? obj(S.sel) : null;
if (t.hasAttribute("data-cfg")) {
var k = t.getAttribute("data-cfg"), v = t.type === "checkbox" ? t.checked : t.type === "number" || t.type === "range" ? parseFloat(t.value) : t.value, vals = {};
vals[k] = v; op({ op: "set_cfg", values: vals }, true);
return;
}
if (!o) return;
if (t.hasAttribute("data-f")) {
var f = t.getAttribute("data-f"), val = t.type === "number" ? parseFloat(t.value) : t.value, patch = {};
if (t.type === "number" && !isFinite(val)) return;
patch[f] = val; update(o.id, patch, live);
} else if (t.hasAttribute("data-prop")) {
var key = t.getAttribute("data-prop"), d = (prefab(o.type) || { props: [] }).props.filter(function (x) { return x.key === key; })[0] || {};
var pv = t.type === "checkbox" ? t.checked : (d.type === "float" || d.type === "int") ? parseFloat(t.value) : t.value;
if ((d.type === "float" || d.type === "int") && !isFinite(pv)) return;
if (d.type === "int") pv = Math.round(pv);
// keep the slider and the number box in step
Array.prototype.forEach.call(document.querySelectorAll('#right [data-prop="' + key + '"]'), function (x) { if (x !== t && x.type !== "checkbox") x.value = pv; });
var pp = {}; pp[key] = pv;
if (live) { clearTimeout(rangeT); rangeT = setTimeout(function () { update(o.id, { props: pp }, true); }, 160); }
else update(o.id, { props: pp }, true);
} else if (t.hasAttribute("data-level")) {
var lv = Array.prototype.filter.call(document.querySelectorAll("#right [data-level]"), function (x) { return x.checked; }).map(function (x) { return x.getAttribute("data-level"); });
update(o.id, { levels: lv });
}
}
$("right").addEventListener("change", function (e) { formChange(e, false); });
$("right").addEventListener("input", function (e) { if (e.target.type === "range") formChange(e, true); });
$("right").addEventListener("keydown", function (e) { if (e.key === "Enter" && e.target.tagName === "INPUT") e.target.blur(); });
// ------------------------------------------------------------ header
$("undo").addEventListener("click", undo);
$("redo").addEventListener("click", redo);
$("help").addEventListener("click", function () { $("helpdlg").showModal(); });
$("theme").addEventListener("click", function () {
var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
try { localStorage.setItem("isokit-theme", t); } catch (e) { /* private mode */ }
});
$("build").addEventListener("click", function () {
api("/api/build", {}).then(function (r) { if (r.error) toast(r.error, true); else toast(r.message || "Page written: " + r.path); }, function (e) { toast(String(e), true); });
});
$("warnbtn").addEventListener("click", function () { select(null); });
if (B && B.preview) $("preview").addEventListener("click", function (e) { e.preventDefault(); B.preview(); });
if (B && B.saveTitle) $("save").title = B.saveTitle;
// engine version from /api/meta; a web build adds its commit and date
// (<meta name="isokit-build" content="<sha> <date> [dirty]">, tools/build_web.py)
function showVersion() {
var el = $("ver"), m = document.querySelector('meta[name="isokit-build"]');
var b = m ? m.getAttribute("content").split(" ") : [];
var text = "isokit " + (S.meta.engine || "?"), tip = "isokit engine " + (S.meta.engine || "?");
if (b[0]) {
text += " · " + b[0].slice(0, 7) + (b[2] ? "+" : "");
tip += ", build " + b[0] + (b[1] ? " (" + b[1] + ")" : "") + (b[2] ? ", with uncommitted changes" : "");
el.href = "https://git.b4l.co.th/B4L/isokit/commit/" + b[0];
}
el.textContent = text;
el.title = tip;
}
// ------------------------------------------------------------ start
Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) {
S.meta = rs[0]; takeState(rs[1]);
document.title = S.meta.title + " · isokit Studio";
$("title").textContent = S.meta.title;
if (S.meta.load_error) toast(S.meta.load_error, true);
showVersion();
renderPalette(); renderLists(); mountView(); renderRight();
poll();
window.dispatchEvent(new Event("isokit:studio")); // agent.js waits for this
});
})();