Point handles for routes and outlines; Area corners; bus goes round right
Deploy web Studio / deploy (push) Successful in 1m4s
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:
1 parent
540b37020c
commit
c8becc2e59
12 files changed
+189
-11
No files matched your search
@@ -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 |
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)" },
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user