Point handles for routes and outlines; Area corners; bus goes round right
Deploy web Studio / deploy (push) Successful in 1m4s

Studio: one selected object with pts (a route, an outlined area) shows a
handle on each point and a + in the middle of each side: drag a point to
move it, drag a + to add one there, double-click a point to remove it
(open routes keep 2, outlines 3). Points snap to the grid and onto wall,
fence and road ends, at any angle; one undo step each. Handles keep their
screen size (new viewer callback onView on every redraw) and follow
re-renders and selection. The viewer captures the pointer, so a double-
click targets the drawing: the last point pressed is remembered instead.

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

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

Tests: Rust 81 (a doubling-back route: each mover once, no sort cycles),
Studio 134 (corners, move, add, remove, undo), web 100.
This commit is contained in:
grabowski committed 2026-10-25 18:46:47 +07:00
1 parent 540b37020c
commit c8becc2e59
12 files changed
+189 -11

No files matched your search

+1
View File
@@ -53,6 +53,7 @@ Try the demos: `isokit studio examples/demo` (a works site),
| Several | Ctrl+click (map or list) adds or takes out one, Shift+click in the list a range, Shift+drag on the map a box, Ctrl+A all; then drag one to move them all, arrows, R, Ctrl+D, Delete and the property form work on all of them | | Several | Ctrl+click (map or list) adds or takes out one, Shift+click in the list a range, Shift+drag on the map a box, Ctrl+A all; then drag one to move them all, arrows, R, Ctrl+D, Delete and the property form work on all of them |
| Group | select several, then ▤ Group or Ctrl+G; Ctrl+Shift+G ungroups. In the object list a group folds open and shut, a click on it selects all of it (drag one on the map to move them all), its eye and lock hide or lock all of it; drag rows into or out of a group | | Group | select several, then ▤ Group or Ctrl+G; Ctrl+Shift+G ungroups. In the object list a group folds open and shut, a click on it selects all of it (drag one on the map to move them all), its eye and lock hide or lock all of it; drag rows into or out of a group |
| Order | the object list is the drawing order of ground pieces (areas, beds, ponds, roads): higher lies on top. Drag rows, or ▲ ▼ / Ctrl+] Ctrl+[ (with Shift: to the front / back) | | Order | the object list is the drawing order of ground pieces (areas, beds, ponds, roads): higher lies on top. Drag rows, or ▲ ▼ / Ctrl+] Ctrl+[ (with Shift: to the front / back) |
| Points | a selected route or outlined area shows its points: drag one to move it, drag a + (middle of a side) to add one, double-click one to remove it; points snap to the grid and onto wall, fence and road ends. An Area's Edit corners turns its rectangle into points |
| Move | drag it, or arrow keys by the snap step (0.1 / 0.5 / 1 / 5 m) | | Move | drag it, or arrow keys by the snap step (0.1 / 0.5 / 1 / 5 m) |
| Turn | R / Shift+R or ⟲ ⟳ (quarter turns), or type any angle in Rotation | | Turn | R / Shift+R or ⟲ ⟳ (quarter turns), or type any angle in Rotation |
| Edit | the right panel: size, storeys, colours, solar, tooltip text, label, levels | | Edit | the right panel: size, storeys, colours, solar, tooltip text, label, levels |
+4
View File
@@ -155,6 +155,10 @@ pub struct PrefabMeta {
/// Studio puts one in each corner of a run drawn corner by corner. /// Studio puts one in each corner of a run drawn corner by corner.
#[serde(default, skip_serializing_if = "String::is_empty")] #[serde(default, skip_serializing_if = "String::is_empty")]
pub corner: String, pub corner: String,
/// The prefab draws the object's `pts` as its outline when it has them (areas):
/// the Studio offers to turn its rectangle into corners that can be dragged.
#[serde(default, skip_serializing_if = "is_false")]
pub outline: bool,
#[serde(default)] #[serde(default)]
pub props: Vec<PropDef>, pub props: Vec<PropDef>,
/// Module the prefab comes from ("isokit/site", "lib/kiosk.rhai"). /// Module the prefab comes from ("isokit/site", "lib/kiosk.rhai").
+18 -2
View File
@@ -936,8 +936,24 @@ fn route_movers_show_once_each_at_every_moment() {
); );
deer.rot = 30.0; deer.rot = 30.0;
deer.pts = Some(vec![[-6.0, 0.0], [6.0, 0.0]]); deer.pts = Some(vec![[-6.0, 0.0], [6.0, 0.0]]);
// a loop that doubles back twice (a spur up to a stop and back, a U-turn at the west end):
// each way keeps to its side, so the strips never overlap
let mut spur = obj(
"spur",
"route",
0.0,
40.0,
json!({"kind": "bus", "n": 2, "closed": true}),
);
spur.pts = Some(vec![[-20.0, 0.0], [20.0, 0.0], [20.0, 15.0], [20.0, 0.0]]);
let doc = Doc { let doc = Doc {
objects: vec![cars, walk, deer, obj("t1", "tree", 0.0, -14.0, json!({}))], objects: vec![
cars,
walk,
deer,
spur,
obj("t1", "tree", 0.0, -14.0, json!({})),
],
..Default::default() ..Default::default()
}; };
for view in 0..4u8 { for view in 0..4u8 {
@@ -947,7 +963,7 @@ fn route_movers_show_once_each_at_every_moment() {
assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles"); assert_eq!(out.cycles, 0, "view {view}: depth-sort cycles");
// one <style> with every keyframe set, each used // one <style> with every keyframe set, each used
assert_eq!(out.html.matches("<style>").count(), 1); assert_eq!(out.html.matches("<style>").count(), 1);
for (id, n) in [("cars", 4), ("walk", 3), ("deer", 2)] { for (id, n) in [("cars", 4), ("walk", 3), ("deer", 2), ("spur", 2)] {
let wins = travel_windows(&b, id); let wins = travel_windows(&b, id);
assert!(!wins.is_empty(), "{id}"); assert!(!wins.is_empty(), "{id}");
// at any moment, each mover is on exactly one piece // at any moment, each mover is on exactly one piece
+2 -2
View File
@@ -26,8 +26,8 @@ fn prefab_site(o) {
} }
fn meta_area() { fn meta_area() {
#{ label: "Area", category: "Ground", icon: "▱", shape: "rect", #{ label: "Area", category: "Ground", icon: "▱", shape: "rect", outline: true,
help: "A paved or planted area lying on the ground.", help: "A paved or planted area lying on the ground. Edit corners turns the rectangle into points you can drag, add (drag a +) and remove (double-click), for any outline.",
props: [ props: [
#{ key: "w", type: "float", def: 10.0, min: 0.5, max: 1000.0, step: 0.5, label: "Width (m)" }, #{ key: "w", type: "float", def: 10.0, min: 0.5, max: 1000.0, step: 0.5, label: "Width (m)" },
#{ key: "d", type: "float", def: 6.0, min: 0.5, max: 1000.0, step: 0.5, label: "Depth (m)" }, #{ key: "d", type: "float", def: 6.0, min: 0.5, max: 1000.0, step: 0.5, label: "Depth (m)" },
+8 -3
View File
@@ -84,7 +84,8 @@ fn route_pts(o, closed) {
out out
} }
/// The line `d` metres to the left of a path (mitred corners). /// The line `d` metres to the left of a path (mitred corners). Where the path
/// doubles back, the offset line gets a point on each side: a U-turn once rounded.
fn rt_offset(pts, d, closed) { fn rt_offset(pts, d, closed) {
if d == 0.0 { return pts; } if d == 0.0 { return pts; }
let n = pts.len(); let n = pts.len();
@@ -97,8 +98,12 @@ fn rt_offset(pts, d, closed) {
if type_of(n1) == "()" { n1 = n2; } if type_of(n1) == "()" { n1 = n2; }
if type_of(n2) == "()" { n2 = n1; } if type_of(n2) == "()" { n2 = n1; }
let s = 1.0 + n1[0] * n2[0] + n1[1] * n2[1]; let s = 1.0 + n1[0] * n2[0] + n1[1] * n2[1];
let m = if s < 0.3 { n2 } else { rt_mul(rt_add(n1, n2), 1.0 / s) }; if s < 0.3 {
out.push(rt_add(pts[i], rt_mul(m, d))); out.push(rt_add(pts[i], rt_mul(n1, d)));
out.push(rt_add(pts[i], rt_mul(n2, d)));
} else {
out.push(rt_add(pts[i], rt_mul(rt_mul(rt_add(n1, n2), 1.0 / s), d)));
}
} }
out out
} }
+3 -2
View File
@@ -162,8 +162,9 @@ per row, Ctrl+G / Ctrl+Shift+G, drag rows to reorder or into a group, ▲ ▼ an
## Phase 2: richer editing ## Phase 2: richer editing
- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open - [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open
- [ ] Path objects in the Studio: click points for areas, roads (polyline with width), fences, walls, - [ ] Path objects in the Studio: click points for roads (polyline with width), fences, walls, pipe
pipe runs; vertex handles (routes are drawn point by point: meta `shape: "path"`) runs. Done: routes drawn point by point (meta `shape: "path"`); point handles on every object
with `pts` (move, add on a side, double-click removes); areas get Edit corners (meta `outline`)
- [x] Multi-select (box select, Ctrl+click, Shift range), group move, turn, copy, delete, edit - [x] Multi-select (box select, Ctrl+click, Shift range), group move, turn, copy, delete, edit
- [x] Groups in the object tree (fold, hide, lock, drag in and out) and the drawing order (drag, ▲ ▼) - [x] Groups in the object tree (fold, hide, lock, drag in and out) and the drawing order (drag, ▲ ▼)
- [ ] Align / distribute the selection - [ ] Align / distribute the selection
+9
View File
@@ -168,6 +168,15 @@ spread within `r` of the object, each facing its rotation give or take 70°,
each its own sprite. Flocks of birds, butterflies and bees use `swim` with a each its own sprite. Flocks of birds, butterflies and bees use `swim` with a
`shape`. `shape`.
## Points (`pts`)
An object's `pts` are plan points relative to its x, y in its turned frame. A prefab that draws
them as its outline sets `outline: true` in its meta (Area): the Studio then offers Edit corners,
which turns its `w` x `d` rectangle into four points, and Rectangle again, which removes them.
Every selected object with `pts` shows handles: drag a point, drag the + of a side to add one,
double-click a point to remove it (an open route keeps 2, a closed outline 3). Route points say
whether they close with `props.closed`; any other `pts` are a closed outline.
## Groups and the drawing order ## Groups and the drawing order
Objects of type `group` are never passed to scripts (they draw nothing), nor is anything inside a Objects of type `group` are never passed to scripts (they draw nothing), nor is anything inside a
+1 -1
View File
@@ -33,7 +33,7 @@
{"id": "lamp-4", "type": "lamp", "x": -44, "y": 5.2}, {"id": "lamp-4", "type": "lamp", "x": -44, "y": 5.2},
{"id": "lamp-5", "type": "lamp", "x": 5.2, "y": -28}, {"id": "lamp-5", "type": "lamp", "x": 5.2, "y": -28},
{"id": "traffic", "type": "route", "name": "Traffic round the block", "x": 17.0, "y": 23.0, "pts": [[3.0, -23.0], [17.0, -23.0], [17.0, 23.0], [-5.0, 23.0], [-11.0, 21.392], [-15.392, 17.0], [-17.0, 11.0], [-17.0, -3.0], [-12.408, -11.913], [-5.913, -18.408]], "props": {"kind": "car", "n": 4, "closed": true, "path": false}}, {"id": "traffic", "type": "route", "name": "Traffic round the block", "x": 17.0, "y": 23.0, "pts": [[3.0, -23.0], [17.0, -23.0], [17.0, 23.0], [-5.0, 23.0], [-11.0, 21.392], [-15.392, 17.0], [-17.0, 11.0], [-17.0, -3.0], [-12.408, -11.913], [-5.913, -18.408]], "props": {"kind": "car", "n": 4, "closed": true, "path": false}},
{"id": "bus-line", "type": "route", "name": "Bus line", "x": -25.0, "y": -20.0, "pts": [[-25.0, 20.0], [5.0, 20.0], [13.913, 15.408], [20.408, 8.913], [25.0, 0.0], [25.0, -20.0]], "props": {"kind": "bus", "n": 1, "path": false, "colour": "car4"}}, {"id": "bus-line", "type": "route", "name": "Bus line", "x": -19.456, "y": -14.457, "pts": [[-30.544, 14.457], [-0.544, 14.457], [8.369, 19.049], [14.864, 25.544], [24.048, 25.544], [30.543, 19.049], [30.543, 9.865], [24.048, 3.37], [19.456, -5.543], [19.456, -25.543], [19.456, -5.543], [14.864, 3.37], [8.369, 9.865], [-0.544, 14.457]], "props": {"kind": "bus", "n": 1, "path": false, "colour": "car4", "closed": true}},
{"id": "motos", "type": "route", "name": "Motorbikes", "x": 35.75, "y": -11.75, "pts": [[-8.25, 8.25], [8.25, -8.25]], "props": {"kind": "motorbike", "n": 3, "path": false}}, {"id": "motos", "type": "route", "name": "Motorbikes", "x": 35.75, "y": -11.75, "pts": [[-8.25, 8.25], [8.25, -8.25]], "props": {"kind": "motorbike", "n": 3, "path": false}},
{"id": "cyclists", "type": "route", "name": "Cyclists", "x": 46.5, "y": 46.0, "pts": [[-9.5, 0.0], [9.5, 0.0]], "props": {"kind": "cyclist", "n": 2, "path": false}}, {"id": "cyclists", "type": "route", "name": "Cyclists", "x": 46.5, "y": 46.0, "pts": [[-9.5, 0.0], [9.5, 0.0]], "props": {"kind": "cyclist", "n": 2, "path": false}},
{"id": "strollers", "type": "route", "name": "People strolling", "x": 38.0, "y": 23.0, "pts": [[0.0, -17.0], [0.0, 17.0]], "props": {"kind": "person", "n": 3, "path": false}} {"id": "strollers", "type": "route", "name": "People strolling", "x": 38.0, "y": 23.0, "pts": [[0.0, -17.0], [0.0, 17.0]], "props": {"kind": "person", "n": 3, "path": false}}
+6
View File
@@ -185,6 +185,12 @@ body.st-listdrag, body.st-listdrag * { cursor: grabbing !important; }
/* long object lists: rows out of sight are not laid out or painted */ /* long object lists: rows out of sight are not laid out or painted */
#objects li { content-visibility: auto; contain-intrinsic-size: auto 28px; } #objects li { content-visibility: auto; contain-intrinsic-size: auto 28px; }
/* point handles of the selected route or outline: points (drag, double-click removes), a + per side (drag adds) */
.iso-svg .st-hline { fill: none; stroke: var(--iso-accent); stroke-width: 1.5; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; pointer-events: none; }
.iso-svg .st-hpt { fill: #fff; stroke: var(--iso-accent); stroke-width: 2; vector-effect: non-scaling-stroke; cursor: grab; }
.iso-svg .st-hpt:hover, .iso-svg .st-hpt.st-on { fill: var(--iso-accent); }
.iso-svg .st-hmid { fill: var(--iso-accent); fill-opacity: .45; stroke: #fff; stroke-width: 1; vector-effect: non-scaling-stroke; cursor: copy; }
.iso-svg .st-hmid:hover { fill-opacity: 1; }
/* previews while placing: footprints, wall planes, where a window lands */ /* previews while placing: footprints, wall planes, where a window lands */
.iso-svg .st-ghosts { pointer-events: none; } .iso-svg .st-ghosts { pointer-events: none; }
.iso-svg .st-ghost { fill: color-mix(in srgb, var(--iso-accent) 16%, transparent); stroke: var(--iso-accent); stroke-width: 1.5; stroke-dasharray: 5 3; vector-effect: non-scaling-stroke; stroke-linejoin: round; } .iso-svg .st-ghost { fill: color-mix(in srgb, var(--iso-accent) 16%, transparent); stroke: var(--iso-accent); stroke-width: 1.5; stroke-dasharray: 5 3; vector-effect: non-scaling-stroke; stroke-linejoin: round; }
+100 -1
View File
@@ -375,7 +375,9 @@
$("stats").textContent = d.sprites + " sprites · " + d.nodes + " nodes · " + d.ms + " ms"; $("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 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]); var ids = selIds(); if (ids.length) S.view.selectObj(ids.length > 1 ? ids : ids[0]);
drawHandles();
}, },
onView: function () { if (!ptDrag) drawHandles(); },
// once per frame at most: pointers send moves faster than that; the x/y readout about ten // 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) // times a second (each new text costs a layout and a paint of the page)
onHover: function (w, e) { onHover: function (w, e) {
@@ -396,6 +398,7 @@
tool = { m: pm, z: z, a: a, b: null }; tool = { m: pm, z: z, a: a, b: null };
return true; return true;
} }
if (ptDown(e)) return true;
// Ctrl / Shift: a click adds or takes out an object, a drag draws a box around more // 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 (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; if (!hit.obj) return false;
@@ -415,6 +418,7 @@
}, },
move: function (e, mv) { move: function (e, mv) {
if (S.placing) { if (tool && mv.world) drawTool(e); return; } if (S.placing) { if (tool && mv.world) drawTool(e); return; }
if (ptDrag) { ptMove(e); return; }
if (drag && drag.box) { drawBand(drag, e); return; } if (drag && drag.box) { drawBand(drag, e); return; }
if (!drag || !mv.world || !drag.w0) return; if (!drag || !mv.world || !drag.w0) return;
if (!drag.moved) { if (!drag.moved) {
@@ -439,6 +443,7 @@
}, },
up: function (e, mv) { up: function (e, mv) {
if (S.placing) { var t = tool; tool = null; ghost(null); if (t && !mv.cancel) finishTool(t, e); return; } if (S.placing) { var t = tool; tool = null; ghost(null); if (t && !mv.cancel) finishTool(t, e); return; }
if (ptDrag) { ptUp(mv); return; }
var d = drag; drag = null; ghost(null); var d = drag; drag = null; ghost(null);
if (!d) return; if (!d) return;
if (d.box) { endBand(d, e, mv); return; } if (d.box) { endBand(d, e, mv); return; }
@@ -453,7 +458,7 @@
else S.view.refresh(); 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); }, 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; } dblclick: function (e) { if (ptDelete(e)) return true; if (!chain) return false; finishChain(e.shiftKey); return true; }
} }
}); });
window.__studio = { S: S, view: S.view, op: op, select: select, selectMany: selectMany, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true }; 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 };
@@ -572,6 +577,7 @@
} }
function setPlacing(kind) { function setPlacing(kind) {
S.placing = kind; tool = null; chain = null; S.placing = kind; tool = null; chain = null;
drawHandles();
if (S.view) ghost(null); if (S.view) ghost(null);
document.querySelector(".st-centre").classList.toggle("st-placing", !!kind); 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)); }); Array.prototype.forEach.call(document.querySelectorAll(".st-tile"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-type") === kind)); });
@@ -601,6 +607,7 @@
S.view.selectObj(ids.length > 1 ? ids : S.sel); 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)); }); Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(ids.indexOf(li.getAttribute("data-id")) >= 0)); });
otools(); otools();
drawHandles();
if (!quiet) renderRight(); if (!quiet) renderRight();
} }
function select(id, quiet) { function select(id, quiet) {
@@ -726,6 +733,93 @@
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]; })); 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); S.view.focus([(x0 + x1) / 2, (y0 + y1) / 2, 0], 2);
} }
// ------------------------------------------------------------ point handles (objects with pts)
// One selected object with points (a route's waypoints, an area's outline) shows a handle on
// each point and a "+" in the middle of each side: drag a point to move it, drag a "+" to add
// one there, double-click a point to remove it. Points snap like a run's corners; one undo step each.
// ptPress: the last point pressed; the viewer captures the pointer, so a double-click's target
// is the drawing, not the handle
var ptDrag = null, ptPress = null;
function ptsOf(o) {
if (!o || S.placing || selIds().length !== 1 || lockedEff(o) || hiddenEff(o) || !o.pts || o.pts.length < 2) return null;
var m = prefab(o.type);
if (!m) return null;
// routes say whether they close; any other outline (areas) is a closed polygon
var closed = m.shape === "path" ? !!propOf(o, "closed") && o.pts.length >= 3 : true;
return { o: o, m: m, closed: closed, z: o.z || 0, min: closed ? 3 : 2, w: o.pts.map(function (q) { return worldOf(o, q[0], q[1]); }) };
}
function drawHandles(pts) {
var svg = S.view && S.view.svg, g = svg && svg.querySelector(".st-handles"), inf = ptsOf(S.sel ? obj(S.sel) : null);
if (!svg) return;
if (!inf) { if (g) g.parentNode.removeChild(g); return; }
if (!g) { g = document.createElementNS("http://www.w3.org/2000/svg", "g"); g.setAttribute("class", "st-handles"); }
svg.appendChild(g); // last, above the drawing and the ghosts
var rot = S.view.getRotation(), k = S.view.screenPx(), w = pts || inf.w, n = w.length, out = [], i;
var sc = w.map(function (p) { return IsoKit.project(rot, p[0], p[1], inf.z); });
out.push('<path class="st-hline" d="' + sc.map(function (s, j) { return (j ? "L" : "M") + s[0].toFixed(1) + " " + s[1].toFixed(1); }).join("") + (inf.closed ? "Z" : "") + '"/>');
var sides = inf.closed ? n : n - 1;
for (i = 0; i < sides; i++) {
var a = sc[i], b = sc[(i + 1) % n];
out.push('<circle class="st-hmid" data-mid="' + i + '" cx="' + ((a[0] + b[0]) / 2).toFixed(1) + '" cy="' + ((a[1] + b[1]) / 2).toFixed(1) + '" r="' + (4 * k).toFixed(2) + '"><title>Drag to add a point here</title></circle>');
}
for (i = 0; i < n; i++) {
out.push('<circle class="st-hpt' + (ptDrag && ptDrag.i === i ? " st-on" : "") + '" data-pt="' + i + '" cx="' + sc[i][0].toFixed(1) + '" cy="' + sc[i][1].toFixed(1) + '" r="' + (5.5 * k).toFixed(2) + '"><title>Point ' + (i + 1) + ": drag to move, double-click to remove</title></circle>");
}
g.innerHTML = out.join("");
}
// a press on a handle starts a point drag (a "+" first adds its point)
function ptDown(e) {
var el = e.target && e.target.closest ? e.target.closest(".st-hpt, .st-hmid") : null;
var inf = el && ptsOf(S.sel ? obj(S.sel) : null);
if (!inf) return false;
var pts = inf.w.map(function (p) { return p.slice(); }), i;
if (el.hasAttribute("data-mid")) {
var j = +el.getAttribute("data-mid"), a = pts[j], b = pts[(j + 1) % pts.length];
i = j + 1; pts.splice(i, 0, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]);
} else i = +el.getAttribute("data-pt");
ptPress = el.hasAttribute("data-mid") ? null : { id: inf.o.id, i: i, t: Date.now() };
ptDrag = { inf: inf, pts: pts, i: i, add: el.hasAttribute("data-mid"), moved: false };
// redrawn on the first move only: replacing the handle under the pointer now would lose
// the double-click that removes it
return true;
}
// a point snaps to the grid and onto the ends and lines of walls, fences and roads, at any angle
function ptMove(e) {
var d = ptDrag, inf = d.inf;
var q = snapCorner(worldAt(e, inf.z), inf.z, null, 12 * pxM(e));
d.pts[d.i] = q; d.moved = true;
drawHandles(d.pts);
$("cursor").textContent = "point " + (d.i + 1) + ": x " + q[0].toFixed(2) + " y " + q[1].toFixed(2);
}
function ptUp(mv) {
var d = ptDrag; ptDrag = null;
if (!d) return;
if (!d.moved && !d.add) return;
if (mv.cancel) { drawHandles(); return; }
setPts(d.inf.o, d.pts, d.add ? "Point added" : "Point " + (d.i + 1) + " moved");
}
function setPts(o, w, msg) {
var pts = w.map(function (p) { var l = localOf(o, p[0], p[1]); return [r3(l[0]), r3(l[1])]; });
update(o.id, { pts: pts }).then(function (r) { if (r) toast(msg + " · Ctrl+Z takes it back"); });
}
function ptDelete(e) {
var el = e.target && e.target.closest ? e.target.closest(".st-hpt") : null, inf = ptsOf(S.sel ? obj(S.sel) : null);
var i = el ? +el.getAttribute("data-pt") : ptPress && inf && ptPress.id === inf.o.id && Date.now() - ptPress.t < 800 ? ptPress.i : -1;
ptPress = null;
if (!inf || i < 0 || i >= inf.w.length) return false;
if (inf.w.length <= inf.min) { toast("A " + (inf.closed ? "closed outline" : "route") + " needs at least " + inf.min + " points", true); return true; }
var w = inf.w.slice();
w.splice(i, 1);
setPts(inf.o, w, "Point " + (i + 1) + " removed");
return true;
}
// an outline prefab drawn as a rectangle: its four corners become points
function editCorners(o) {
var w = +propOf(o, "w") || 1, d = +propOf(o, "d") || 1;
update(o.id, { pts: [[-w / 2, -d / 2], [w / 2, -d / 2], [w / 2, d / 2], [-w / 2, d / 2]].map(function (q) { return [r3(q[0]), r3(q[1])]; }) })
.then(function () { toast("Corners: drag a point, drag a + to add one, double-click a point to remove it"); });
}
// ------------------------------------------------------------ groups and the drawing order // ------------------------------------------------------------ groups and the drawing order
// A group is an object of type "group" that draws nothing; what is in it carries its id in // A group is an object of type "group" that draws nothing; what is in it carries its id in
// `group` (groups nest). The object list is a tree built from the document order: a group's // `group` (groups nest). The object list is a tree built from the document order: a group's
@@ -1091,6 +1185,9 @@
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("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>') + 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>" + "</div>" +
(o.pts && o.pts.length >= 2 ? '<h2>Points <small>' + o.pts.length + '</small></h2><p class="st-help">Drag a point on the map to move it, drag a + to add one, double-click a point to remove it.</p>' +
(m.outline ? '<div class="st-actions"><button type="button" data-act="unpts" title="Back to a rectangle of width × length">Rectangle again</button></div>' : "")
: m.outline ? '<h2>Outline</h2><div class="st-actions"><button type="button" data-act="pts" title="Turn the rectangle into four corners you can drag">Edit corners</button></div>' : "") +
(m.props.length ? "<h2>Properties</h2><div class=\"st-form\">" + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "</div>" : "") + (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">' + '<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("name", "Name", '<input data-f="name" value="' + esc(o.name || "") + '" placeholder="' + esc(m.label) + '">') +
@@ -1184,6 +1281,8 @@
else if (a === "front" || a === "back") moveOrder(a, [S.sel]); else if (a === "front" || a === "back") moveOrder(a, [S.sel]);
else if (a === "lock") update(S.sel, { locked: !o.locked }); else if (a === "lock") update(S.sel, { locked: !o.locked });
else if (a === "hide") update(S.sel, { hidden: !o.hidden }); else if (a === "hide") update(S.sel, { hidden: !o.hidden });
else if (a === "pts") editCorners(o);
else if (a === "unpts") update(S.sel, { pts: null });
else if (a === "rotl") turn(S.sel, -90); else if (a === "rotl") turn(S.sel, -90);
else if (a === "rotr") turn(S.sel, 90); else if (a === "rotr") turn(S.sel, 90);
}); });
+35
View File
@@ -661,6 +661,41 @@ with sync_playwright() as p:
pg.evaluate("__studio.select(null)") pg.evaluate("__studio.select(null)")
pg.wait_for_timeout(300) pg.wait_for_timeout(300)
# point handles: the yard (an area, 24 x 18 m at 14, 4) gets corners; one is dragged, a point is
# added by dragging the + of a side and removed again by a double-click; undos restore the rectangle
pg.evaluate("__studio.view.focus([14, 4, 0], 1.6)")
pg.wait_for_timeout(400)
pg.evaluate("__studio.select('yard')")
pg.wait_for_timeout(400)
pg.locator('#right button[data-act="pts"]').click()
pg.wait_for_timeout(900)
y0 = obj("yard").get("pts")
nh = pg.evaluate("() => [document.querySelectorAll('.st-hpt').length, document.querySelectorAll('.st-hmid').length]")
check(y0 == [[-12, -9], [12, -9], [12, 9], [-12, 9]] and nh == [4, 4], f"Edit corners: the yard becomes 4 points with handles: {y0}, {nh}")
def handle_drag(a, b):
pg.mouse.move(*at(*a))
pg.mouse.down()
pg.mouse.move(*at((a[0] + b[0]) / 2, (a[1] + b[1]) / 2), steps=4)
pg.mouse.move(*at(*b), steps=4)
pg.mouse.up()
pg.wait_for_timeout(900)
handle_drag((26, 13), (24, 16))
y1 = obj("yard")["pts"]
handle_drag((14, -5), (14, -8))
y2 = obj("yard")["pts"]
pg.mouse.dblclick(*at(14, -8))
pg.wait_for_timeout(900)
y3 = obj("yard")["pts"]
check(y1[2] == [10, 12] and y2 == [[-12, -9], [0, -12], [12, -9], [10, 12], [-12, 9]] and y3 == [[-12, -9], [12, -9], [10, 12], [-12, 9]],
f"handles move a corner {y1[2]}, add a point on a side {y2[1] if len(y2) > 4 else y2}, a double-click removes it ({len(y3)} left)")
pg.screenshot(path=str(OUT / "studio-handles.jpg"), type="jpeg", quality=80)
for _ in range(4):
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check("pts" not in obj("yard"), "four undos give the yard its rectangle back")
pg.evaluate("__studio.select(null)")
pg.wait_for_timeout(300)
# an agent edits over MCP: the open Studio follows # an agent edits over MCP: the open Studio follows
init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}) init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}})
check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize") check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize")
+2
View File
@@ -280,6 +280,8 @@
if (k !== lastK) { lastK = k; var tg = svg.querySelector(".iso-tags"); (tg || svg).style.setProperty("--iso-k", k); } if (k !== lastK) { lastK = k; var tg = svg.querySelector(".iso-tags"); (tg || svg).style.setProperty("--iso-k", k); }
atFit = vb.w >= fitW * 0.995; atFit = vb.w >= fitW * 0.995;
svg.style.touchAction = atFit ? "pan-y" : "none"; svg.style.touchAction = atFit ? "pan-y" : "none";
// an editor keeps its overlays (point handles) at their screen size
if (opts.onView) call(opts.onView, vb);
} }
function apply() { function apply() {
if (!vb) return; if (!vb) return;