Multi-select, select joined walls and fences, pond with swimming fish
Deploy web Studio / deploy (push) Successful in 1m27s

Selection: S.sel stays the object picked last; S.selIds holds everything
selected.
- In the object list, Ctrl+click adds or removes an object and
  Shift+click selects a range.
- On the map, Ctrl/Shift+click toggles an object and Shift+drag draws a
  box that selects what lies wholly inside it (locked and hidden objects
  are left out).
- Ctrl+A selects everything that is not locked; Esc selects nothing.
- Clicking a wall or fence a second time selects every piece of the same
  type joined to it; another click goes back to that one piece.
- Dragging one selected object moves them all, together with the windows
  and doors on them, as one undo step. Arrow keys, R (turns the group
  about its middle), Ctrl+D, Delete, Lock and Hide all work on the whole
  selection.
- When all selected objects share a type, the property form edits them
  all; fields where they differ show "mixed".
- The agent's system prompt lists the selected objects.

The viewer outlines several objects with selectObj([ids]) and passes the
click event to onPick. A batch op now answers `ids`, one per operation,
so the copies from a group duplicate get selected.

Pond (Nature): natural, round or square, with a stone or grass edge,
fish, lily pads, spreading ripples and reed clumps. The fish come from a
new Swim primitive (script: s.swim). One matrix maps the object's frame
to the screen. Each fish turns round its ellipse with a CSS rotation,
so off-screen pausing and reduced motion apply as for the other
animations. The house demo gets a koi pond in the back garden.

Tests: ponds in every shape and rotation have the right number of fish,
no NaN and no depth-sort cycles, and stay inside their rectangle.
check_studio covers list and map multi-select, a group drag undone in
one step, joined-fence selection, a mixed-value form applied to all, box
select, delete and undo, Ctrl+A, Esc, and a new pond's fish.
This commit is contained in:
grabowski committed 2026-10-24 18:36:39 +07:00
1 parent e4017ce2a8
commit 19f4c83ae8
21 files changed
+759 -59

No files matched your search

+6 -1
View File
@@ -47,7 +47,8 @@ Try the demos: `isokit studio examples/demo` (a works site) and
|---|---|
| Add | pick an object on the left, click on the map (Shift-click places several) |
| Draw | walls, fences, hedges, roads: drag along the map, or click corner after corner for a run around a plot (double-click / Enter ends it, the first corner closes it); ends snap onto the walls and fences they meet. Floors, areas, buildings, roofs, rugs: drag a rectangle |
| Select | click it on the map or in the object list |
| Select | click it on the map or in the object list; click a wall or fence again to select every piece joined to it |
| 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 |
| Move | drag it, or arrow keys by the snap step (0.1 / 0.5 / 1 / 5 m) |
| Turn | R / Shift+R or ⟲ ⟳ |
| Edit | the right panel: size, storeys, colours, solar, tooltip text, label, levels |
@@ -72,6 +73,10 @@ The **House** palette has the parts, **Furniture** the interior:
4. **Roof** (hip, gable, flat, optional solar) as a rectangle over the walls,
and **Stairs**.
**Garden**: the Pond (natural, round or square; stone or grass edge) has fish
swimming round under lily pads, ripples and reeds; trees, hedges and rows of
trees are in Nature.
**Fences and walls** around the plot: the Fence has six designs (mesh, wooden
boards, steel bars, mesh on a stone base, cobble wall, brick wall with
pillars). Fences and site walls whose ends meet join like house walls: corners
+11
View File
@@ -28,6 +28,17 @@ pub fn r1s(v: f64) -> String {
s
}
/// `v` rounded to 0.001, without trailing zeros or `-0` (`NaN` when not finite):
/// for small sizes in a local frame (fish, scaled groups).
pub fn r3s(v: f64) -> String {
if !v.is_finite() {
return "NaN".into();
}
let t = (v * 1000.0).round() / 1000.0;
let s = format!("{:.3}", if t == 0.0 { 0.0 } else { t });
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// Round to 0.1 as a number.
pub fn round1(v: f64) -> f64 {
(v * 10.0 + 0.5).floor() / 10.0
+1 -1
View File
@@ -23,7 +23,7 @@ pub mod scene;
pub mod sort;
pub mod sprite;
pub use prim::{Prim, Style, Zs, P2, P3};
pub use prim::{Fish, Prim, Style, Zs, P2, P3};
pub use proj::{shade, Proj, C, CZ};
pub use render::{render, RenderOpts, Rendered};
pub use scene::{Info, Layer, LayerOpts, Scene, Tag};
+23
View File
@@ -179,6 +179,15 @@ pub enum Prim {
},
/// Spinning fan lying in the plane at height at.z (CSS animation).
Fan { at: P3, r: f64 },
/// Fish swimming round ellipses on the plane at height z (CSS
/// animation): centre c, the ellipses' x axis along u (a world unit
/// vector, the object's local x).
Swim {
c: P2,
z: f64,
u: P2,
fish: Vec<Fish>,
},
/// Raw SVG translated to a world point (or untranslated without one).
/// `{uid}` in the text is replaced by the view's unique id.
Raw { at: Option<P3>, svg: String },
@@ -191,3 +200,17 @@ pub enum Prim {
/// Items drawn back to front by the depth of their plan anchor.
Seq { items: Vec<(P2, Vec<Prim>)> },
}
/// One fish of a [`Prim::Swim`]: it goes round an ellipse of radii (rx, ry)
/// once every `dur` seconds, `delay` seconds into its round at the start,
/// counter-clockwise (seen from above) unless `cw`; `len` is its length.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Fish {
pub rx: f64,
pub ry: f64,
pub dur: f64,
pub delay: f64,
pub cw: bool,
pub len: f64,
pub cls: String,
}
+86 -1
View File
@@ -1,6 +1,6 @@
//! Projection of a scene to SVG for one view rotation.
use crate::fmt::{esc, hash, r1, r1s, round1};
use crate::fmt::{esc, hash, r1, r1s, r3s, round1};
use crate::prim::{Prim, Style, P2};
use crate::proj::{shade, Proj, C};
use crate::scene::{Info, Scene};
@@ -279,6 +279,56 @@ impl Ctx<'_> {
self.out.push_str("\"/>");
}
/// Fish in the plane at height z: one matrix maps the object's frame
/// (metres, x along u) to the screen; each fish sits at (1, 0) of a
/// unit circle that a CSS rotation turns and a scale stretches to its
/// ellipse. An invisible diagonal centres the turning group's box on
/// the circle's middle (`transform-box: fill-box`, as for the fan).
fn swim(&mut self, c: P2, z: f64, u: P2, fish: &[crate::prim::Fish]) {
if fish.is_empty() {
return;
}
let p = self.p;
let o = p.s(c[0], c[1], z);
let a = p.s(c[0] + u[0], c[1] + u[1], z);
let b = p.s(c[0] - u[1], c[1] + u[0], z);
let _ = write!(
self.out,
"<g class=\"iso-school\" transform=\"matrix({} {} {} {} {} {})\">",
r3s(a[0] - o[0]),
r3s(a[1] - o[1]),
r3s(b[0] - o[0]),
r3s(b[1] - o[1]),
r1s(o[0]),
r1s(o[1])
);
for f in fish {
let (rx, ry, l) = (f.rx.max(0.05), f.ry.max(0.05), f.len.max(0.05));
let e = 1.0 + l / rx.min(ry) + 0.05;
// a fish nose first along +y (counter-clockwise) or -y
let k = if f.cw { -l } else { l };
// body widest behind the head, a narrow tail root, a forked tail fin
let (b, t) = (body_path(k, l), tail_path(k, l));
let _ = write!(
self.out,
"<g transform=\"scale({} {})\"><g class=\"iso-a iso-swim\" style=\"animation-duration:{}s;animation-delay:-{}s{}\"><path class=\"iso-swimbox\" d=\"M{} {}L{} {}\"/><g transform=\"translate(1 0) scale({} {})\"><path class=\"iso-fish iso-{}\" d=\"{b}{t}\"/></g></g></g>",
r3s(rx),
r3s(ry),
r3s(f.dur.max(1.0)),
r3s(f.delay.max(0.0)),
if f.cw { ";animation-direction:reverse" } else { "" },
r3s(-e),
r3s(-e),
r3s(e),
r3s(e),
r3s(1.0 / rx),
r3s(1.0 / ry),
esc(&f.cls),
);
}
self.out.push_str("</g>");
}
fn disc(&mut self, c: P2, z: f64, r: f64, cls: &str, id: Option<&str>) {
let s = self.p.s(c[0], c[1], z);
let rx = r * C * SQ2;
@@ -461,6 +511,7 @@ impl Ctx<'_> {
-a
);
}
Prim::Swim { c, z, u, fish } => self.swim(*c, *z, *u, fish),
Prim::Raw { at, svg } => {
let svg = svg.replace("{uid}", self.uid);
match at {
@@ -696,6 +747,40 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
}
}
/// A fish body nose first along +y (k = length, negative: along -y), l wide.
fn body_path(k: f64, l: f64) -> String {
format!(
"M0 {}C{} {} {} {} {} {}L{} {}C{} {} {} {} 0 {}Z",
r3s(0.5 * k),
r3s(0.24 * l),
r3s(0.38 * k),
r3s(0.24 * l),
r3s(-0.1 * k),
r3s(0.05 * l),
r3s(-0.28 * k),
r3s(-0.05 * l),
r3s(-0.28 * k),
r3s(-0.24 * l),
r3s(-0.1 * k),
r3s(-0.24 * l),
r3s(0.38 * k),
r3s(0.5 * k)
)
}
/// The forked tail fin behind [`body_path`].
fn tail_path(k: f64, l: f64) -> String {
format!(
"M0 {}L{} {}L0 {}L{} {}Z",
r3s(-0.24 * k),
r3s(0.2 * l),
r3s(-0.54 * k),
r3s(-0.42 * k),
r3s(-0.2 * l),
r3s(-0.54 * k)
)
}
#[cfg(test)]
mod tests {
use super::*;
+13 -1
View File
@@ -5,7 +5,7 @@
//! an object placed at (x, y, z) and turned in 90 degree steps draws around
//! its own origin, and the sprite stores world coordinates.
use crate::prim::{Prim, Style, Zs, P2, P3};
use crate::prim::{Fish, Prim, Style, Zs, P2, P3};
use crate::proj::C;
use serde::{Deserialize, Serialize};
@@ -476,6 +476,18 @@ impl Sprite {
self.push(Prim::Fan { at, r })
}
/// Fish swimming round ellipses about (cx, cy) on the plane at height z;
/// the ellipses follow the frame's axes.
pub fn swim(&mut self, cx: f64, cy: f64, z: f64, fish: Vec<Fish>) -> &mut Self {
let rx = fish.iter().map(|f| f.rx + f.len).fold(0.0, f64::max);
let ry = fish.iter().map(|f| f.ry + f.len).fold(0.0, f64::max);
self.ext_box(cx - rx, cy - ry, cx + rx, cy + ry, z, z);
let c = self.xf.p(cx, cy);
let z = self.xf.z(z);
let u = self.xf.v(1.0, 0.0);
self.push(Prim::Swim { c, z, u, fish })
}
pub fn raw(&mut self, at: Option<P3>, svg: impl Into<String>) -> &mut Self {
let at = at.map(|a| self.xf.p3(a));
self.push(Prim::Raw {
+7 -5
View File
@@ -23,7 +23,8 @@ pub fn meta<S: Store>(ws: &Workspace<S>) -> Value {
}
/// One operation, or `{ "ops": [...] }` as one undo step. Answers the new
/// state with `ok` and the id of the object made or changed, or `error`.
/// state with `ok`, the id of the object made or changed (the last one) and
/// `ids` (one per operation that made or changed an object), or `error`.
pub fn op<S: Store>(ws: &mut Workspace<S>, body: &Value) -> Value {
let r = if let Some(list) = body.get("ops").and_then(|v| v.as_array()) {
let ops: Result<Vec<Op>, String> = list
@@ -31,18 +32,19 @@ pub fn op<S: Store>(ws: &mut Workspace<S>, body: &Value) -> Value {
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
.collect();
ops.and_then(|l| ws.apply_all(l))
.map(|o| o.last().and_then(|x| x.id.clone()))
.map(|o| o.into_iter().filter_map(|x| x.id).collect::<Vec<_>>())
} else {
serde_json::from_value::<Op>(body.clone())
.map_err(|e| format!("bad operation: {e}"))
.and_then(|op| ws.apply(op))
.map(|o| o.id)
.map(|o| o.id.into_iter().collect())
};
match r {
Ok(id) => {
Ok(ids) => {
let mut s = state(ws);
s["ok"] = json!(true);
s["id"] = json!(id);
s["id"] = json!(ids.last());
s["ids"] = json!(ids);
s
}
Err(e) => json!({ "error": e }),
+26
View File
@@ -432,6 +432,32 @@ pub fn register_sprite(e: &mut Engine) {
ok(s)
},
);
// fish round ellipses: list of #{ rx, ry, dur, delay, cw, len, cls }
e.register_fn(
"swim",
|s: &mut SpriteH, x: D, y: D, z: D, list: Array| -> RR<SpriteH> {
let mut fish = Vec::with_capacity(list.len());
for f in &list {
let Some(m) = f.read_lock::<Map>() else {
return err("swim: expected a list of maps");
};
fish.push(isokit_core::Fish {
rx: fnum(&m, "rx"),
ry: fnum(&m, "ry"),
dur: fnum(&m, "dur"),
delay: fnum(&m, "delay"),
cw: m.get("cw").and_then(|v| v.as_bool().ok()).unwrap_or(false),
len: m.get("len").and_then(|v| n(v).ok()).unwrap_or(0.4),
cls: m
.get("cls")
.map(|v| v.to_string())
.unwrap_or_else(|| "koi".into()),
});
}
s.0.borrow_mut().swim(n(&x)?, n(&y)?, n(&z)?, fish);
ok(s)
},
);
e.register_fn("raw", |s: &mut SpriteH, svg: &str| -> RR<SpriteH> {
s.0.borrow_mut().raw(None, svg);
ok(s)
+57
View File
@@ -485,6 +485,63 @@ fn fences_join_like_walls_in_every_design() {
.contains("iso-seam"));
}
#[test]
fn ponds_have_fish_swimming_in_every_shape() {
let r = Runtime::new(&Bundle::default()).unwrap();
for (shape, edge, rot) in [
("natural", "stone", 0.0),
("round", "grass", 90.0),
("square", "stone", 0.0),
] {
let mut doc = Doc::default();
let mut o = obj(
"pond",
"pond",
3.0,
-2.0,
json!({"w": 9, "d": 5, "shape": shape, "edge": edge, "fish": 6, "lilies": 4}),
);
o.rot = rot;
doc.objects.push(o);
for view in 0..4u8 {
let b = r.build("site", view, &cfg(), &doc).unwrap();
assert!(
b.scene.warnings.is_empty(),
"{shape}: {:?}",
b.scene.warnings
);
let out = render(&b.scene, view, &RenderOpts::default());
assert_eq!(
out.html.matches("iso-a iso-swim").count(),
6,
"{shape}/{view}"
);
assert!(!out.html.contains("NaN"), "{shape}/{view}");
assert!(out.html.contains("iso-fish iso-koi") && out.html.contains("iso-lily"));
assert!(out.html.contains("iso-a iso-rip"));
assert_eq!(out.cycles, 0);
}
// the pond stays inside its 9 x 5 m (turned: 5 x 9 m) rectangle; the reeds stand on its edge
let bx = r.build("site", 0, &cfg(), &doc).unwrap().boxes["pond"];
let (hw, hd) = if rot == 0.0 { (4.5, 2.5) } else { (2.5, 4.5) };
assert!(
bx[0] >= 3.0 - hw - 1e-6
&& bx[3] <= 3.0 + hw + 1e-6
&& bx[1] >= -2.0 - hd - 1e-6
&& bx[4] <= -2.0 + hd + 1e-6,
"{shape}: {bx:?}"
);
}
// no fish: no school
let mut doc = Doc::default();
doc.objects
.push(obj("p", "pond", 0.0, 0.0, json!({"fish": 0})));
let b = r.build("site", 0, &cfg(), &doc).unwrap();
assert!(!render(&b.scene, 0, &RenderOpts::default())
.html
.contains("iso-swim"));
}
#[test]
fn site_walls_join_at_corners() {
let r = Runtime::new(&Bundle::default()).unwrap();
+134
View File
@@ -49,3 +49,137 @@ fn prefab_hedge(o) {
let p = o.p;
sprite().box(-p.w / 2.0, -p.d / 2.0, p.w / 2.0, p.d / 2.0, 0.0, p.h, "hedge");
}
// ---------------------------------------------------------------- pond
fn meta_pond() {
#{ label: "Pond", category: "Nature", icon: "◍", shape: "rect",
help: "A garden pond with fish swimming round under lily pads, reeds at the back and a stone or grass edge. Drag a rectangle on the map.",
props: [
#{ key: "w", type: "float", def: 6.0, min: 1.5, max: 60.0, step: 0.5, label: "Width (m)" },
#{ key: "d", type: "float", def: 4.0, min: 1.5, max: 60.0, step: 0.5, label: "Length (m)" },
#{ key: "shape", type: "choice", def: "natural", choices: ["natural", "round", "square"], label: "Shape" },
#{ key: "fish", type: "int", def: 5, min: 0, max: 16, step: 1, label: "Fish" },
#{ key: "lilies", type: "int", def: 3, min: 0, max: 12, step: 1, label: "Lily pads" },
#{ key: "reeds", type: "bool", def: true, label: "Reeds" },
#{ key: "edge", type: "choice", def: "stone", choices: ["stone", "grass"], label: "Edge" },
] }
}
/// The pond's outline inset by `ins` metres: a kidney ("natural"), an
/// ellipse ("round") or a rectangle with round corners ("square").
fn pond_outline(p, ins) {
let a = p.w / 2.0 - ins;
let b = p.d / 2.0 - ins;
let pts = [];
if p.shape == "square" {
let r = fmax(0.05, fmin(a, b) * 0.35);
for k in 0..4 {
let cx = if k == 0 || k == 3 { a - r } else { -a + r };
let cy = if k < 2 { b - r } else { -b + r };
for j in 0..7 {
let t = (k * 90 + j * 15) * PI() / 180.0;
pts.push([cx + r * t.cos(), cy + r * t.sin()]);
}
}
return pts;
}
for j in 0..48 {
let t = j * PI() / 24.0;
let k = 1.0;
if p.shape == "natural" {
// a dent at the back (+y) and a slight wobble, never past the box
let dt = (t - PI() / 2.0) / 0.6;
k = (1.0 - 0.3 * (-dt * dt).exp()) * (0.965 + 0.035 * (3.0 * t + 1.0).sin());
}
pts.push([a * k * t.cos(), b * k * t.sin()]);
}
pts
}
fn prefab_pond(o) {
let p = o.p;
let a = p.w / 2.0;
let b = p.d / 2.0;
let rim = if p.edge == "stone" { fmin(0.35, fmin(a, b) * 0.18) } else { fmin(0.2, fmin(a, b) * 0.1) };
layer("ground");
let s = sprite();
let outer = pond_outline(p, 0.0);
let inner = pond_outline(p, rim);
if p.edge == "stone" {
s.flat([outer], 0.03, "capst t");
// joints across the stone rim
let segs = [];
let step = if outer.len() > 30 { 2 } else { 1 };
for j in range(0, outer.len(), step) { segs.push([outer[j][0], outer[j][1], inner[j][0], inner[j][1]]); }
s.lines(segs, 0.031, "seam");
} else {
s.flat([outer], 0.02, "pondbank t");
}
s.flat([inner], 0.032, "pondw t");
s.flat([pond_outline(p, rim + fmin(a, b) * 0.28)], 0.033, "ponddp t");
// the water the fish keep to: an ellipse well inside, off the natural shape's dent
let wa = (a - rim) * 0.72;
let wb = (b - rim) * 0.72;
let oy = if p.shape == "natural" { -0.12 * b } else { 0.0 };
if p.shape == "natural" { wb *= 0.82; }
let n = p.fish;
for i in 0..n {
let f = 0.45 + 0.55 * ((i * 37 + 11) % 10) / 10.0;
let rx = wa * f;
let ry = wb * f;
let cx = 0.0;
// long ponds: smaller rounds spread along the long side
if rx > ry * 1.6 { cx = (((i * 53) % 9) / 8.0 - 0.5) * (rx - ry * 1.6) * 2.0; rx = ry * 1.6; }
if ry > rx * 1.6 { ry = rx * 1.6; }
let len = fmin(0.9, fmax(0.3, fmin(a, b) * 0.24)) * (0.85 + 0.1 * (i % 3));
let dur = fmax(8.0, 2.0 * PI() * (rx + ry) / 2.0 / 0.32);
s.swim(cx, oy, 0.036, [#{ rx: rx, ry: ry, dur: dur, delay: (i * 7.3) % dur, cw: i % 3 == 1, len: len,
cls: ["koi", "koiw", "koig"][i % 3] }]);
}
// lily pads over the fish, some in flower; rings spreading where a fish came up
for i in 0..p.lilies {
let t = i * 2.4 + 0.7;
let q = 0.35 + 0.4 * ((i * 29 + 3) % 10) / 10.0;
let x = wa * q * t.cos();
let y = oy + wb * q * t.sin();
s.disc(x, y, 0.04, fmin(0.4, fmax(0.18, fmin(a, b) * 0.1)), "lily t");
if i % 2 == 0 { s.disc(x + 0.08, y + 0.05, 0.05, 0.07, "lilyf t"); }
}
if n > 0 {
for i in 0..fmin(3, n).to_int() {
let t = i * 2.1 + 1.9;
s.begin("a rip", `animation-delay:-${i * 1.7}s`);
s.disc(wa * 0.5 * t.cos(), oy + wb * 0.5 * t.sin(), 0.04, fmin(0.6, fmin(a, b) * 0.15), "ripple");
s.end();
}
}
// reeds in clumps on the back edge, upright, so with the other sprites
if p.reeds {
layer("main");
for t in [1.15, 1.95, 2.55] {
let q = pond_outline(p, rim * 0.5 + 0.3);
let j = ((t / (2.0 * PI())) * q.len()).round().to_int() % q.len();
let c = q[j];
// tapered blades leaning out from the clump, cattail heads on some
let blades = [];
let heads = [];
for k in 0..7 {
let x = c[0] + ((k * 37) % 7 - 3) * 0.04;
let y = c[1] + ((k * 53) % 5 - 2) * 0.04;
let h = 0.75 + ((k * 31) % 6) * 0.09;
let lx = ((k * 17) % 7 - 3) * 0.06;
let ly = ((k * 23) % 5 - 2) * 0.06;
blades.push([[x - 0.06, y, 0.03], [x + 0.06, y, 0.03], [x + lx, y + ly, h]]);
blades.push([[x, y - 0.06, 0.03], [x, y + 0.06, 0.03], [x + lx, y + ly, h]]);
if k % 3 == 0 {
let f = 0.62;
let hx = x + lx * f;
let hy = y + ly * f;
heads.push([[hx - 0.035, hy, h * f], [hx + 0.035, hy, h * f], [hx + 0.035 + lx * 0.18, hy + ly * 0.18, h * (f + 0.18)], [hx - 0.035 + lx * 0.18, hy + ly * 0.18, h * (f + 0.18)]]);
}
}
sprite().poly(blades, "reed t").poly(heads, "cattail t");
}
}
}
+37
View File
@@ -251,6 +251,17 @@ lshade = "#d9c9a0"
roofr = "#8a4a3a"
cobble = "#6c675f"
capst = "#8c877e"
pondw = "#24607a"
ponddp = "#1a4c63"
pondbank = "#4a3f2f"
lily = "#3f7a3a"
lilyf = "#e7a1c0"
reed = "#4f6e30"
cattail = "#5a3e2a"
koi = "#e8742a"
koiw = "#e9e4da"
koig = "#d9a63a"
ripple = "#8cc3d8"
[palette.light]
sky1 = "#a9d4fb"
@@ -497,6 +508,17 @@ lshade = "#f2e6c8"
roofr = "#b8613f"
cobble = "#aaa195"
capst = "#d8d2c6"
pondw = "#5aa7c4"
ponddp = "#4791b0"
pondbank = "#8d7a5c"
lily = "#6fae5a"
lilyf = "#f4b6d2"
reed = "#5e8a3a"
cattail = "#7a5536"
koi = "#f08a3c"
koiw = "#fbf8f2"
koig = "#efc35a"
ripple = "#d6f1fa"
[materials]
warn = "#e0412f"
@@ -723,6 +745,13 @@ lshade = "lshade"
roofr = "roofr"
cobble = "cobble"
capst = "capst"
pondw = "pondw"
ponddp = "ponddp"
pondbank = "pondbank"
lily = "lily"
lilyf = "lilyf"
reed = "reed"
cattail = "cattail"
doorw = "furnw"
doorg = "door"
f-oak = "oak"
@@ -748,6 +777,14 @@ f-conc = "conc"
".iso-svg .iso-glassw" = "--b: var(--iso-glassw); fill-opacity: var(--iso-glasswo)"
".iso-svg path.iso-glassw.iso-t" = "fill-opacity: .5"
".iso-svg .iso-win" = "fill: var(--iso-win); stroke: none"
".iso-svg .iso-school" = "pointer-events: none"
".iso-svg .iso-swimbox" = "fill: none; stroke: none"
".iso-svg .iso-fish" = "stroke: none; fill-opacity: .92"
".iso-svg .iso-koi" = "fill: var(--iso-koi)"
".iso-svg .iso-koiw" = "fill: var(--iso-koiw)"
".iso-svg .iso-koig" = "fill: var(--iso-koig)"
".iso-svg .iso-ripple" = "fill: none; stroke: var(--iso-ripple); stroke-width: .5"
".iso-svg path.iso-reed, .iso-svg path.iso-cattail" = "stroke: none"
".iso-svg .iso-win.iso-lit" = "fill: var(--iso-winlit)"
".iso-svg .iso-glass" = "fill: var(--iso-glass); fill-opacity: var(--iso-glasso); stroke: none"
".iso-svg .iso-door" = "fill: var(--iso-door); stroke: none"
+4 -1
View File
@@ -83,7 +83,10 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
Done since: the middle mouse button only moves the camera; fences and site walls join like house
walls (`line_joint`); fence designs (mesh, wood, bars, half stone, cobble, brick with pillars);
runs of walls and fences drawn corner by corner, ends snapping onto the pieces they meet.
runs of walls and fences drawn corner by corner, ends snapping onto the pieces they meet;
multi-select (Ctrl+click on the map and in the list, Shift range, Shift+drag box, Ctrl+A; group drag,
nudge, turn, copy, delete and property edits; a second click on a wall or fence selects the joined
run); the Pond with fish swimming round (`swim` primitive, CSS rotation), lily pads, ripples, reeds.
## Phase 2: richer editing
+1
View File
@@ -87,6 +87,7 @@ shaded automatically by face direction.
| `circle(x, y, z, r_px, cls[, dx, dy])`, `stem(x, y, z0, z1, w_px, cls)` | screen-space details (crowns, poles) |
| `person(x, y, z[, cls])`, `sit(x, y, z, cls)`, `head(x, y, z, r)`, `led(x, y, z[, cls])` | figures |
| `puffs(x, y, z[, kind[, n, dur]])`, `fan(x, y, z, r)` | animated steam / smoke / fans |
| `swim(x, y, z, [#{ rx, ry, dur, delay, cw, len, cls }, ...])` | fish going round ellipses (radii rx, ry along the object's axes) about (x, y) on the plane at z, one round per `dur` s, clockwise when `cw`; `cls` koi / koiw / koig (the pond) |
| `seq([[x, y, \|q\| …], …])` | items drawn back to front by their anchor |
| `group(cls[, css], \|q\| …)`, `begin(cls[, css])` … `end()` | `<g>` around parts (animation) |
| `ext(x, y, z)`, `ext_box(…)` | widen the sort box |
+1
View File
@@ -7,6 +7,7 @@
{"id": "path", "type": "area", "x": -1.0, "y": -6.05, "props": {"w": 1.4, "d": 2.9, "mat": "g-path"}, "name": "Front path"},
{"id": "terrace", "type": "area", "x": 8.6, "y": -2.25, "props": {"w": 5.0, "d": 4.5, "mat": "g-pad"}, "name": "Terrace"},
{"id": "garden", "type": "area", "x": 2, "y": 9.2, "props": {"w": 30, "d": 7.5, "mat": "g-garden"}, "name": "Back garden"},
{"id": "pond", "type": "pond", "name": "Fish pond", "x": -4.5, "y": 9.4, "props": {"w": 6.5, "d": 3.8, "fish": 6, "lilies": 4}, "info": "Koi pond, 6.5 x 3.8 m"},
{"id": "floor-living", "type": "floor", "x": -3.0, "y": -2.0, "props": {"w": 6, "d": 5, "mat": "f-oak"}, "name": "Living room"},
{"id": "floor-kitchen", "type": "floor", "x": 3.0, "y": -2.0, "props": {"w": 6, "d": 5, "mat": "f-tile"}, "name": "Kitchen and dining"},
{"id": "floor-bedroom", "type": "floor", "x": -3.5, "y": 2.5, "props": {"w": 5, "d": 4, "mat": "f-carpet"}, "name": "Bedroom"},
+4 -1
View File
@@ -262,6 +262,9 @@
}
function system() {
var s = st(), S = s.S, sel = S.sel && s.obj(S.sel), v = s.view;
var many = (S.selIds || []).map(function (id) { return s.obj(id); }).filter(Boolean);
var selText = many.length > 1 ? "Selected " + many.length + " objects (\"these\", \"them\"): " + JSON.stringify(many.slice(0, 30).map(compact)) + (many.length > 30 ? " and " + (many.length - 30) + " more" : "") + "."
: sel ? "Selected (\"this\", \"it\"): " + JSON.stringify(compact(sel)) : "Nothing is selected.";
return {
role: "system",
content: [
@@ -270,7 +273,7 @@
"How to work: look before you build (list_objects shows what is where with world boxes; list_prefabs with a type gives its props). Place things inside the site, on free ground, and use the boxes to avoid overlaps. Put related edits into one apply_ops call so they undo as one step. After bigger changes, check with render_view or check_project. Use select_object to show the user what you mean. Ask back when a request is unclear instead of guessing at big changes.",
"Answer in the language of the user's latest message, short and plain, without tables: say what you changed and give object ids in backticks (the user can click them). To show the result, write the answer first and call select_object in the same step: that ends your turn.",
"The Studio right now: project \"" + S.meta.title + "\", " + (S.doc.objects || []).length + " objects; view level \"" + v.getLevel() + "\", rotation " + v.getRotation() + "; snap step " + (s.snapStep() || "off") + ". " +
(sel ? "Selected (\"this\", \"it\"): " + JSON.stringify(compact(sel)) : "Nothing is selected.") + " " + screenAxes(v.getRotation())
selText + " " + screenAxes(v.getRotation())
].filter(Boolean).join("\n\n")
};
}
+2 -1
View File
@@ -65,7 +65,8 @@ document.documentElement.setAttribute("data-theme",t);})();
<tr><th>Draw</th><td>walls, fences, hedges, roads: drag along the map for one piece, or click corner after corner for a run (double-click or Enter ends it, clicking the first corner closes it, Backspace drops the last corner); ends snap onto the walls and fences they meet, which then join at corners and T-junctions. Floors, areas, buildings, roofs: drag a rectangle; Room: drag a rectangle for a floor with walls around it</td></tr>
<tr><th>Windows, doors</th><td>click on a wall: they snap onto it, cut their opening and move, turn, copy and delete with the wall; R turns a door to the other hinge and swing side</td></tr>
<tr><th>Storeys</th><td>GF, 1F, … cut a house open; on 1F and up, new walls, floors and furniture go onto that storey (3 m each, parameter <code>storey_h</code>)</td></tr>
<tr><th>Select</th><td>click it on the map or in the list</td></tr>
<tr><th>Select</th><td>click it on the map or in the list; click a wall or fence again to select every piece joined to it (again: just that one)</td></tr>
<tr><th>Several</th><td>Ctrl+click (map or list) adds or takes out one, Shift+click in the list selects a range, Shift+drag on the map selects what lies inside the box, Ctrl+A everything not locked, Esc none. Drag one of them, arrow keys, R, Ctrl+D and Delete then work on all; the form edits a property for all of them</td></tr>
<tr><th>Move</th><td>drag it, or arrow keys (by the snap step)</td></tr>
<tr><th>Turn</th><td>R / Shift+R, or the ⟲ ⟳ buttons</td></tr>
<tr><th>Copy / delete</th><td>Ctrl+D / Delete</td></tr>
+4
View File
@@ -159,6 +159,10 @@ input[type=checkbox] { accent-color: var(--accent); }
.ag-grip { position: absolute; left: 0; top: 0; width: 14px; height: 14px; cursor: nwse-resize; touch-action: none; background: linear-gradient(135deg, var(--mute) 0 2px, transparent 2px 4px, var(--mute) 4px 5px, transparent 5px); opacity: .5; border-top-left-radius: 8px; }
.ag-grip:hover { opacity: 1; }
/* the selection box (Shift / Ctrl + drag on the map) */
.st-band { position: fixed; z-index: 30; pointer-events: none; border: 1px dashed var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
#objects { user-select: none; }
/* previews while placing: footprints, wall planes, where a window lands */
.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; }
+236 -39
View File
@@ -7,7 +7,7 @@
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 S = { meta: null, doc: null, version: null, sel: null, selIds: [], placing: null, view: null, boxes: {}, warnings: [], busy: false, canUndo: false, canRedo: false, port: location.port };
var ICON_DEFAULT = "▪";
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
@@ -219,8 +219,9 @@
}
takeState(r);
$("save").textContent = "Saved";
if (r.id && o.op !== "update") S.sel = r.id;
if (S.sel && !obj(S.sel)) S.sel = null;
var adds = (o.ops || [o]).some(function (x) { return x.op === "add" || x.op === "duplicate"; });
if (r.id && adds) { S.sel = r.id; S.selIds = [r.id]; }
pruneSel();
S.view.refresh();
renderLists(); if (!quiet) renderRight();
return r;
@@ -230,7 +231,7 @@
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;
pruneSel();
S.view.refresh(); renderLists(); renderRight();
}
function reloadAll(why) {
@@ -261,13 +262,13 @@
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);
var ids = selIds(); if (ids.length) S.view.selectObj(ids.length > 1 ? ids : ids[0]);
},
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); },
onPick: function (i, e) { if (i && i.obj) clickObj(i.obj, e); else select(null); },
onKey: keyHandler,
interact: {
down: function (e, hit) {
@@ -277,8 +278,17 @@
tool = { m: pm, z: z, a: a, b: null };
return true;
}
// Ctrl / Shift: a click adds or takes out an object, a drag draws a box around more
if (e.shiftKey || e.ctrlKey || e.metaKey) { drag = { box: true, x0: e.clientX, y0: e.clientY, hit: hit.obj }; return true; }
if (!hit.obj) return false;
var o = obj(hit.obj);
var o = obj(hit.obj), ids = selIds(), group = !!o && ids.length > 1 && ids.indexOf(o.id) >= 0;
if (group) {
// a drag on one of several selected objects moves them all
var set = moveSet(ids);
if (!set.objs.length) return false;
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, group: set, carry: [] };
return true;
}
if (!o || o.locked) return false;
var m = prefab(o.type), mount = !!(m && m.mount && m.mount.length), h = mount ? hostOf(o) : null;
drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null, m: m, mount: mount,
@@ -287,11 +297,14 @@
},
move: function (e, mv) {
if (S.placing) { if (tool && mv.world) drawTool(e); return; }
if (drag && drag.box) { drawBand(drag, e); return; }
if (!drag || !mv.world || !drag.w0) return;
if (!drag.moved) {
drag.moved = true; 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)); });
drag.moved = true; drag.els = [];
if (!drag.group) select(drag.id, true);
// what sits on the objects (a wall's windows) moves along
var moving = drag.group ? drag.group.objs.concat(drag.group.carry) : [obj(drag.id)].concat(drag.carry);
moving.forEach(function (x) { if (x) Array.prototype.push.apply(drag.els, moveEls(x.id)); });
}
var tx = drag.ox + mv.world[0] - drag.w0[0], ty = drag.oy + mv.world[1] - drag.w0[1];
var dx = snap(tx) - drag.ox, dy = snap(ty) - drag.oy;
@@ -310,19 +323,22 @@
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.box) { endBand(d, e, mv); return; }
if (mv.cancel) { if (d.moved) S.view.refresh(); return; }
if (!d.moved) { clickObj(d.id, e); return; }
if (d.snapTo) {
// on a wall: keep the turn relative to the wall it came from (door swing), else open to the side dropped on
var sm = d.snapTo, rel = d.rel !== null ? d.rel * 90 : (sm.side < 0 ? 180 : 0);
update(d.id, { x: sm.x, y: sm.y, z: sm.z, rot: ((sm.host.rot || 0) + rel) % 360 });
} else if (d.dx || d.dy) moveWith(d.id, d.dx, d.dy, d.carry);
} else if ((d.dx || d.dy) && d.group) moveMany(d.group, d.dx, d.dy);
else if (d.dx || d.dy) moveWith(d.id, d.dx, d.dy, d.carry);
else S.view.refresh();
},
cancel: function () { if (drag && drag.els) S.view.refresh(); 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; }
}
});
window.__studio = { S: S, view: S.view, op: op, select: select, 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 };
}
function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; }
function remount() {
@@ -453,12 +469,142 @@
h.hidden = false;
} else h.hidden = true;
}
// ------------------------------------------------------------ selection
// S.sel: the object picked last (its form shows), S.selIds: everything selected (S.sel among them)
function selIds() { return (S.selIds || []).filter(function (id) { return obj(id); }); }
function pruneSel() {
S.selIds = selIds();
if (S.sel && !obj(S.sel)) S.sel = null;
if (S.sel && S.selIds.indexOf(S.sel) < 0) S.selIds.push(S.sel);
if (!S.sel && S.selIds.length) S.sel = S.selIds[S.selIds.length - 1];
}
function showSel(quiet) {
var ids = selIds();
S.view.selectObj(ids.length > 1 ? ids : S.sel);
Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(ids.indexOf(li.getAttribute("data-id")) >= 0)); });
if (!quiet) renderRight();
}
function select(id, quiet) {
if (id && !obj(id)) id = null;
S.sel = id;
S.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();
S.sel = id; S.selIds = id ? [id] : [];
showSel(quiet);
}
function selectMany(ids, primary, quiet) {
var seen = {};
ids = ids.filter(function (id) { if (seen[id] || !obj(id)) return false; seen[id] = 1; return true; });
if (ids.length < 2) { select(ids[0] || null, quiet); return; }
S.selIds = ids; S.sel = primary && ids.indexOf(primary) >= 0 ? primary : ids[ids.length - 1];
showSel(quiet);
}
function toggleSel(id) {
var ids = selIds(), i = ids.indexOf(id);
if (i >= 0) { ids.splice(i, 1); selectMany(ids, S.sel === id ? ids[ids.length - 1] : S.sel); } else selectMany(ids.concat([id]), id);
}
// the walls (fences, ...) joined to o: same type, an end on the other's centre line; o first
function connectedRun(o) {
var m = prefab(o.type);
if (!m || m.shape !== "line") return [o.id];
var pcs = S.doc.objects.filter(function (x) { return x.type === o.type && !x.hidden && Math.abs((x.z || 0) - (o.z || 0)) < 0.5; });
function ends(x) { var L = (+propOf(x, "w") || 0) / 2; return [worldOf(x, -L, 0), worldOf(x, L, 0)]; }
function onLine(p, x) { var l = localOf(x, p[0], p[1]); return Math.abs(l[0]) <= (+propOf(x, "w") || 0) / 2 + 0.05 && Math.abs(l[1]) <= 0.3; }
function joined(a, b) { return ends(a).some(function (p) { return onLine(p, b); }) || ends(b).some(function (p) { return onLine(p, a); }); }
var out = [o], todo = [o];
while (todo.length) {
var a = todo.pop();
pcs.forEach(function (b) { if (out.indexOf(b) < 0 && joined(a, b)) { out.push(b); todo.push(b); } });
}
return out.map(function (x) { return x.id; });
}
// a click on an object: Ctrl / Shift add it or take it out; a second click on a wall or fence
// selects every piece joined to it, a click on one of those selects just that one again
function clickObj(id, e) {
if (e && (e.ctrlKey || e.metaKey || e.shiftKey)) { toggleSel(id); return; }
var ids = selIds();
if (ids.length === 1 && ids[0] === id) {
var run = connectedRun(obj(id));
if (run.length > 1) { selectMany(run, id); toast(run.length + " joined pieces selected · click one of them for just that one"); return; }
}
select(id);
}
function selectAll() {
selectMany((S.doc.objects || []).filter(function (o) { return !o.locked && !o.hidden; }).map(function (o) { return o.id; }));
}
// the selection box drawn with Shift / Ctrl + drag
function band(r) {
var b = $("stband");
if (!r) { if (b) b.parentNode.removeChild(b); return; }
if (!b) { b = document.createElement("div"); b.id = "stband"; b.className = "st-band"; document.body.appendChild(b); }
b.style.left = r.x0 + "px"; b.style.top = r.y0 + "px"; b.style.width = (r.x1 - r.x0) + "px"; b.style.height = (r.y1 - r.y0) + "px";
}
function bandRect(d, e) { return { x0: Math.min(d.x0, e.clientX), y0: Math.min(d.y0, e.clientY), x1: Math.max(d.x0, e.clientX), y1: Math.max(d.y0, e.clientY) }; }
function drawBand(d, e) { band(bandRect(d, e)); }
function endBand(d, e, mv) {
band(null);
if (mv.cancel) return;
if (!mv.moved) { if (d.hit) toggleSel(d.hit); return; }
// every object drawn wholly inside the box (not locked or hidden)
var r = bandRect(d, e), found = (S.doc.objects || []).filter(function (o) {
if (o.locked || o.hidden) return false;
var els = moveEls(o.id), u = null;
Array.prototype.forEach.call(els, function (el) {
var b = el.getBoundingClientRect();
if (!b.width && !b.height) return;
u = u ? { l: Math.min(u.l, b.left), t: Math.min(u.t, b.top), r: Math.max(u.r, b.right), b: Math.max(u.b, b.bottom) } : { l: b.left, t: b.top, r: b.right, b: b.bottom };
});
return u && u.l >= r.x0 && u.r <= r.x1 && u.t >= r.y0 && u.b <= r.y1;
}).map(function (o) { return o.id; });
if (!found.length) { toast("No object lies wholly inside the box"); return; }
selectMany(selIds().concat(found), found[found.length - 1]);
toast(selIds().length + " objects selected");
}
// what a group move takes along: the selected objects that are not locked, and what sits on them
function moveSet(ids) {
var objs = ids.map(obj).filter(function (o) { return o && !o.locked; }), seen = {}, carry = [];
objs.forEach(function (o) { seen[o.id] = 1; });
objs.forEach(function (o) { mountedOn(o).forEach(function (c) { if (!seen[c.id]) { seen[c.id] = 1; carry.push(c); } }); });
return { objs: objs, carry: carry };
}
function moveMany(set, dx, dy, quiet) {
return opList(set.carry.concat(set.objs).map(function (o) { return { op: "update", id: o.id, patch: { x: r3(o.x + dx), y: r3(o.y + dy) } }; }), quiet);
}
// turn the group about the middle of its objects, by the snap step
function turnGroup(ids, d) {
var set = moveSet(ids), all = set.objs.concat(set.carry);
if (!set.objs.length) return;
var xs = set.objs.map(function (o) { return o.x; }), ys = set.objs.map(function (o) { return o.y; });
var cx = snap((Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2), cy = snap((Math.min.apply(null, ys) + Math.max.apply(null, ys)) / 2), q = qOf(d);
opList(all.map(function (o) {
var v = turnV(q, o.x - cx, o.y - cy);
return { op: "update", id: o.id, patch: { x: r3(cx + v[0]), y: r3(cy + v[1]), rot: ((o.rot || 0) + d + 360) % 360 } };
}));
}
function extraOn(ids) {
var seen = {}, out = [];
ids.forEach(function (id) { var o = obj(id); if (o) mountedOn(o).forEach(function (c) { if (!seen[c.id] && ids.indexOf(c.id) < 0) { seen[c.id] = 1; out.push(c.id); } }); });
return out;
}
function removeMany(ids) {
var extra = extraOn(ids);
opList(extra.concat(ids).map(function (id) { return { op: "remove", id: id }; })).then(function (r) {
if (r) toast("Deleted " + ids.length + " objects" + (extra.length ? " and the " + extra.length + " windows / doors on them" : "") + " · Ctrl+Z brings them back");
});
}
function duplicateMany(ids) {
var dd = Math.max(snapStep(), 1) * 2, extra = extraOn(ids);
opList(extra.concat(ids).map(function (id) { return { op: "duplicate", id: id, dx: dd, dy: dd }; })).then(function (r) {
if (r && r.ids) selectMany(r.ids.slice(-ids.length));
});
}
function setAll(ids, key) {
var v = !ids.every(function (id) { return obj(id)[key]; });
opList(ids.map(function (id) { var p = {}; p[key] = v; return { op: "update", id: id, patch: p }; }));
}
function focusMany(ids) {
var bs = ids.map(function (id) { return S.boxes[id]; }).filter(Boolean);
if (!bs.length) return;
var x0 = Math.min.apply(null, bs.map(function (b) { return b[0]; })), x1 = Math.max.apply(null, bs.map(function (b) { return b[3]; }));
var y0 = Math.min.apply(null, bs.map(function (b) { return b[1]; })), y1 = Math.max.apply(null, bs.map(function (b) { return b[4]; }));
S.view.focus([(x0 + x1) / 2, (y0 + y1) / 2, 0], 2);
}
function opList(ops, quiet) { return op(ops.length === 1 ? ops[0] : { ops: ops }, quiet); }
// the object and what sits on it (a wall's windows and doors) as one undo step; the object last, so it stays selected
@@ -492,7 +638,8 @@
opList(ops);
}
function nudge(id, dx, dy) {
var o = obj(id); if (!o || o.locked) return;
var o = obj(id), many = selIds().length > 1;
if (!o || (o.locked && !many)) return;
var s = snapStep() || 0.5;
// arrow keys follow the screen: map screen directions onto world axes for the current rotation
var rot = S.view.getRotation();
@@ -501,7 +648,8 @@
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(); });
if (many) moveMany(moveSet(selIds()), wx * s, wy * s, true).then(function () { renderRight(); });
else moveWith(id, wx * s, wy * s, null, true).then(function () { renderRight(); });
}
function keyHandler(e) {
var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
@@ -512,10 +660,12 @@
if (chain && k === "Enter") { finishChain(e.shiftKey); return true; }
if (chain && k === "Backspace") { chain.pts.pop(); if (!chain.pts.length) { chain = null; ghost(null); setPlacing(S.placing); } else ghost(null); return true; }
if (k === "Escape") { if (chain) { chain = null; ghost(null); setPlacing(S.placing); } else if (S.placing) setPlacing(null); else select(null); return true; }
if (ctrl && (k === "a" || k === "A")) { selectAll(); return true; }
if (!S.sel) return false;
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; }
var many = selIds().length > 1;
if (k === "Delete" || k === "Backspace") { if (many) removeMany(selIds()); else remove(S.sel); return true; }
if (ctrl && (k === "d" || k === "D")) { if (many) duplicateMany(selIds()); else duplicate(S.sel); return true; }
if (!ctrl && (k === "r" || k === "R")) { if (many) turnGroup(selIds(), e.shiftKey ? -90 : 90); else turn(S.sel, e.shiftKey ? -90 : 90); return true; }
if (k === "ArrowLeft") { nudge(S.sel, -1, 0); return true; }
if (k === "ArrowRight") { nudge(S.sel, 1, 0); return true; }
if (k === "ArrowUp") { nudge(S.sel, 0, 1); return true; }
@@ -553,7 +703,7 @@
$("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>";
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (S.selIds.indexOf(o.id) >= 0) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
}).join("");
}
// closed palette categories, remembered in this browser
@@ -574,29 +724,42 @@
});
$("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")); });
// Ctrl+click adds or takes out one, Shift+click selects the range from the last picked one
$("objects").addEventListener("click", function (e) {
var li = e.target.closest("li"); if (!li) return;
var id = li.getAttribute("data-id");
if (e.ctrlKey || e.metaKey) { toggleSel(id); return; }
if (e.shiftKey && S.sel) {
var all = Array.prototype.map.call(document.querySelectorAll("#objects li"), function (x) { return x.getAttribute("data-id"); }), a = all.indexOf(S.sel), b = all.indexOf(id);
if (a >= 0 && b >= 0) { selectMany(all.slice(Math.min(a, b), Math.max(a, b) + 1), S.sel); return; }
}
select(id);
});
$("objects").addEventListener("dblclick", function (e) { var li = e.target.closest("li"); if (li) S.view.focus(li.getAttribute("data-id"), 3); });
// ------------------------------------------------------------ right panel
function field(key, lab, html, full) { return '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : html); }
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" : "") + ">";
// a property's input; mixed: the selected objects differ here, so it shows no value until set
function propInput(d, v, mixed) {
var id = "p-" + d.key, val = mixed ? "" : v === undefined ? d.default : v, ph = mixed ? ' placeholder="mixed"' : "";
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + (mixed ? ' data-mixed="1"' : "") + ">";
if (d.type === "choice" || (d.type === "material" && d.choices && d.choices.length)) {
var ch = (d.choices || []).slice(); if (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>";
var ch = (d.choices || []).slice(); if (!mixed && val !== undefined && ch.indexOf(val) < 0) ch.unshift(val);
return '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + (mixed ? '<option value="" selected disabled>mixed</option>' : "") +
ch.map(function (c) { return "<option" + (!mixed && c === val ? " selected" : "") + ">" + esc(c) + "</option>"; }).join("") + "</select>";
}
if (d.type === "material") return '<input id="' + id + '" data-prop="' + esc(d.key) + '" list="materials" value="' + esc(val) + '">';
if (d.type === "material") return '<input id="' + id + '" data-prop="' + esc(d.key) + '" list="materials" value="' + esc(val) + '"' + ph + ">";
if (d.type === "float" || d.type === "int") {
var step = d.step || (d.type === "int" ? 1 : 0.1), mm = (d.min !== undefined ? ' min="' + d.min + '"' : "") + (d.max !== undefined ? ' max="' + d.max + '"' : "");
var range = 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>';
var range = !mixed && d.min !== undefined && d.max !== undefined ? '<input type="range" data-prop="' + esc(d.key) + '" data-range="1"' + mm + ' step="' + step + '" value="' + esc(val) + '">' : "<span></span>";
return '<div class="st-num">' + range + '<input type="number" id="' + id + '" data-prop="' + esc(d.key) + '"' + mm + ' step="' + step + '" value="' + esc(val) + '"' + ph + "></div>";
}
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '">';
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '"' + ph + ">";
}
function renderRight() {
var R = $("right"), o = S.sel ? obj(S.sel) : null;
var R = $("right"), o = S.sel ? obj(S.sel) : null, ids = selIds();
if (!o) { renderProject(R); return; }
if (ids.length > 1) { renderMany(R, ids); return; }
var m = prefab(o.type) || { label: o.type, props: [] }, p = o.props || {};
var levels = S.meta.levels || [];
var h = "<h3><i>" + esc(m.icon || ICON_DEFAULT) + "</i>" + esc(label(o)) + "</h3>" +
@@ -627,6 +790,29 @@
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
R.innerHTML = h;
}
// several objects: what they are, actions for all, and the properties when they are all one type
function renderMany(R, ids) {
var os = ids.map(obj), count = {}, order = [];
os.forEach(function (o) { if (!count[o.type]) { count[o.type] = 0; order.push(o.type); } count[o.type]++; });
var one = order.length === 1 ? prefab(order[0]) : null, locked = os.every(function (o) { return o.locked; }), hidden = os.every(function (o) { return o.hidden; });
R.innerHTML = "<h3><i>▣</i>" + ids.length + " objects selected</h3>" +
'<div class="st-sub">' + order.map(function (t) { var m = prefab(t); return count[t] + " × " + esc(m ? m.label : t); }).join(", ") + "</div>" +
'<p class="st-help">Drag one of them to move them all; arrow keys nudge, R turns the group, Delete removes them. Ctrl+click or Shift+click adds or takes out one, Shift+drag on the map adds those in a box, Esc clears.</p>' +
'<div class="st-actions">' +
'<button type="button" data-act="dup" title="Ctrl+D">Duplicate</button>' +
'<button type="button" data-act="focus">Zoom to</button>' +
'<button type="button" data-act="lock">' + (locked ? "Unlock" : "Lock") + "</button>" +
'<button type="button" data-act="hide">' + (hidden ? "Show" : "Hide") + "</button>" +
'<button type="button" data-act="del" class="st-danger">Delete ' + ids.length + "</button></div>" +
'<h2>Placement</h2><div class="st-form">' +
field("rot", "Turn the group", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>90°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') + "</div>" +
(one && one.props.length ? "<h2>Properties <small>for all " + ids.length + "</small></h2><div class=\"st-form\">" + one.props.map(function (d) {
var vals = os.map(function (o) { return JSON.stringify(propOf(o, d.key)); }), same = vals.every(function (v) { return v === vals[0]; });
return field(d.key, d.label || d.key, propInput(d, propOf(os[0], d.key), !same));
}).join("") + "</div>" : "") +
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
Array.prototype.forEach.call(R.querySelectorAll("input[data-mixed]"), function (x) { x.indeterminate = true; });
}
function renderProject(R) {
var m = S.meta, cfg = S.doc.cfg || {};
var base = location.origin;
@@ -645,7 +831,16 @@
}
$("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);
var a = b.getAttribute("data-act"), o = obj(S.sel), ids = selIds();
if (ids.length > 1) {
if (a === "del") removeMany(ids);
else if (a === "dup") duplicateMany(ids);
else if (a === "focus") focusMany(ids);
else if (a === "lock") setAll(ids, "locked");
else if (a === "hide") setAll(ids, "hidden");
else if (a === "rotl" || a === "rotr") turnGroup(ids, a === "rotl" ? -90 : 90);
return;
}
if (a === "del") remove(S.sel);
else if (a === "dup") duplicate(S.sel);
else if (a === "focus") S.view.focus(S.sel, 3);
@@ -675,9 +870,11 @@
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);
var pp = {}, all = selIds().length > 1 ? selIds() : [o.id];
pp[key] = pv;
var put = function () { opList(all.map(function (id) { return { op: "update", id: id, patch: { props: pp } }; }), true); };
if (live) { clearTimeout(rangeT); rangeT = setTimeout(put, 160); }
else put();
} else if (t.hasAttribute("data-level")) {
var lv = Array.prototype.filter.call(document.querySelectorAll("#right [data-level]"), function (x) { return x.checked; }).map(function (x) { return x.getAttribute("data-level"); });
update(o.id, { levels: lv });
+92
View File
@@ -253,6 +253,98 @@ with sync_playwright() as p:
pg.wait_for_timeout(500)
pg.screenshot(path=str(OUT / "studio-fences.jpg"), type="jpeg", quality=80)
# several objects at once: Ctrl+click in the list and on the map, a group drag as one step, Shift+drag
# draws a box; a second click on a fence selects every fence joined to it, whose design changes for all
sel = lambda: sorted(pg.evaluate("__studio.S.selIds"))
at3 = lambda x, y, z: pg.evaluate(to_client, [x, y, z])
pg.evaluate("__studio.select(null)")
pg.locator('#objects li[data-id="truck"]').click()
pg.locator('#objects li[data-id="genset"]').click(modifiers=["Control"])
pg.wait_for_timeout(300)
check(sel() == ["genset", "truck"] and "2 objects selected" in pg.locator("#right h3").inner_text(),
f"Ctrl+click in the list adds a second object: {sel()}")
check(pg.locator("#objects li[aria-selected=true]").count() == 2 and pg.locator(".iso-svg .iso-osel").count() >= 2,
"both are marked in the list and outlined on the map")
pg.evaluate("__studio.view.focus('truck', 2.5)")
pg.wait_for_timeout(400)
t0, g0 = obj("truck"), obj("genset")
tb = pg.locator('.iso-svg g[data-obj="truck"] path.iso-t').first.bounding_box()
mx, my = tb["x"] + tb["width"] / 2, tb["y"] + tb["height"] / 2
a0, a1 = at(t0["x"], t0["y"]), at(t0["x"] + 2, t0["y"])
pg.mouse.move(mx, my)
pg.mouse.down()
for i in range(1, 11):
pg.mouse.move(mx + (a1[0] - a0[0]) * i / 10, my + (a1[1] - a0[1]) * i / 10)
pg.wait_for_timeout(15)
pg.mouse.up()
pg.wait_for_timeout(800)
check(obj("truck")["x"] == t0["x"] + 2 and obj("genset")["x"] == g0["x"] + 2 and sel() == ["genset", "truck"],
f"dragging one moves both and keeps them selected: truck {obj('truck')['x']}, generator {obj('genset')['x']}")
pg.locator("#undo").click()
pg.wait_for_timeout(700)
check(obj("truck")["x"] == t0["x"] and obj("genset")["x"] == g0["x"], "one undo puts both back")
# the fence run: a click selects one piece, a second click every fence joined to it
pg.evaluate("__studio.select(null)")
pg.evaluate("__studio.view.focus([29, -9.5, 0], 3)")
pg.wait_for_timeout(400)
pg.mouse.click(*at3(28, -12, 0.9))
pg.wait_for_timeout(400)
check(sel() == [south], f"a click selects one fence: {sel()}")
pg.mouse.click(*at3(28, -12, 0.9))
pg.wait_for_timeout(400)
joined = set(o["id"] for o in run) | {sp["id"], "fence-east"}
check(set(sel()) == joined, f"a second click selects every fence joined to it ({len(sel())} pieces)")
pg.screenshot(path=str(OUT / "studio-multi.jpg"), type="jpeg", quality=80)
check(pg.locator('#right select[data-prop="style"]').input_value() == "" and pg.locator('#right input[type=number][data-prop="w"]').input_value() == "",
"where the pieces differ (design, length) the form says mixed")
pg.locator('#right select[data-prop="style"]').select_option("wood")
pg.wait_for_timeout(800)
check(all(obj(i)["props"].get("style") == "wood" for i in joined), "a design picked for the selection goes to all of them")
pg.mouse.click(*at3(28, -12, 0.9))
pg.wait_for_timeout(400)
check(sel() == [south], "a click on one of them selects just that one again")
# Shift+drag: a box around the run selects what lies wholly inside it
pg.evaluate("__studio.select(null)")
corners = [at(23, -13), at(33, -13), at(33, -6), at(23, -6)]
x0, x1 = min(c[0] for c in corners), max(c[0] for c in corners)
y0, y1 = min(c[1] for c in corners) - 40, max(c[1] for c in corners)
pg.keyboard.down("Shift")
pg.mouse.move(x0, y0)
pg.mouse.down()
for i in range(1, 11):
pg.mouse.move(x0 + (x1 - x0) * i / 10, y0 + (y1 - y0) * i / 10)
pg.wait_for_timeout(15)
check(pg.locator("#stband").count() == 1, "Shift+drag draws a selection box")
pg.mouse.up()
pg.keyboard.up("Shift")
pg.wait_for_timeout(400)
boxed = set(sel())
check(set(o["id"] for o in run) <= boxed and "site" not in boxed and pg.locator("#stband").count() == 0,
f"the box selects the run's fences, not the locked ground ({len(boxed)} objects)")
# Delete removes them all as one step
n4 = len(doc()["objects"])
pg.locator(".iso-svg").focus()
pg.keyboard.press("Delete")
pg.wait_for_timeout(800)
check(len(doc()["objects"]) == n4 - len(boxed) and sel() == [], f"Delete removes all {len(boxed)}")
pg.keyboard.press("Control+z")
pg.wait_for_timeout(800)
check(len(doc()["objects"]) == n4, "one undo brings them all back")
pg.keyboard.press("Control+a")
pg.wait_for_timeout(300)
check(len(sel()) == len([o for o in doc()["objects"] if not o.get("locked") and not o.get("hidden")]), "Ctrl+A selects everything not locked")
pg.keyboard.press("Escape")
pg.wait_for_timeout(300)
check(sel() == [], "Esc clears the selection")
# a pond: fish swim round in it (CSS animation, one rotating group per fish)
pg.evaluate("__studio.op({op: 'add', type: 'pond', x: -29, y: 13})")
pg.wait_for_timeout(900)
check(pg.locator('.iso-svg g[data-obj] .iso-swim').count() == 5 and pg.evaluate("getComputedStyle(document.querySelector('.iso-svg .iso-swim')).animationName") == "iso-spin",
"a new pond has five fish swimming round")
pg.locator("#undo").click()
pg.wait_for_timeout(600)
pg.evaluate("__studio.select(null)")
# the middle mouse button only moves the camera: it never drags, selects or places anything
pg.locator('#objects li[data-id="truck"]').click()
pg.evaluate("__studio.view.focus('truck', 3)")
+5 -1
View File
@@ -108,6 +108,9 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; }
.iso-svg .iso-shuttle { animation: iso-shuttle 48s ease-in-out infinite; }
.iso-svg .iso-drive { animation: iso-drive 16s linear infinite; }
.iso-svg .iso-ledr, .iso-svg .iso-ledo { animation: iso-blink 2.4s ease-in-out infinite; }
/* fish: each turns round its pond's middle (the box is centred there), at its own speed */
.iso-svg .iso-swim { transform-box: fill-box; transform-origin: center; animation: iso-spin 20s linear infinite; }
.iso-svg .iso-rip { transform-box: fill-box; transform-origin: center; animation: iso-rip 5.1s ease-out infinite; }
.iso-svg .iso-park { display: none; }
.iso-off .iso-a, .iso-still .iso-a, .iso-off .iso-drive, .iso-still .iso-drive { animation-play-state: paused !important; }
.iso-still .iso-park { display: inline; }
@@ -117,6 +120,7 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; }
12% { opacity: .75; }
100% { transform: translate(5px, -28px) scale(2); opacity: 0; }
}
@keyframes iso-rip { 0% { transform: scale(.2); opacity: 0; } 15% { opacity: .9; } 100% { transform: scale(1.5); opacity: 0; } }
@keyframes iso-glint { 0%, 84%, 100% { opacity: 0; } 91% { opacity: .9; } }
@keyframes iso-blink { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes iso-move { from { transform: translate(0, 0); } to { transform: translate(var(--dx), var(--dy)); } }
@@ -133,7 +137,7 @@ html[data-theme=light] .iso-svg .iso-glint { display: inline; }
}
@media (prefers-reduced-motion: reduce) {
.iso-svg .iso-a { animation: none !important; }
.iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke, .iso-svg .iso-disc, .iso-svg .iso-glint { display: none; }
.iso-svg .iso-puff, .iso-svg .iso-puff2, .iso-svg .iso-smoke, .iso-svg .iso-disc, .iso-svg .iso-glint, .iso-svg .iso-rip { display: none; }
.iso-svg .iso-park { display: inline; }
}
@media (max-width: 640px) {
+9 -7
View File
@@ -12,7 +12,7 @@
* { html, info, bbox: [x, y, w, h], focus: [x, y], north, boxes, warnings }
*
* view.update(cfg) view.refresh() view.setLevel(id) view.setRotation(r) view.rotate(d)
* view.select(id) view.selectObj(id) view.focus(id | [x, y, z], zoom) view.fit()
* view.select(id) view.selectObj(id | [ids]) view.focus(id | [x, y, z], zoom) view.fit()
* view.toWorld(clientX, clientY, z) -> [x, y] view.ready() -> Promise
* view.info(id) view.stats() view.fullscreen(on) view.destroy()
*/
@@ -258,21 +258,23 @@
if (!id) return;
Array.prototype.forEach.call(q("data-id", id), function (e) { if (!e.classList.contains("iso-tag")) e.classList.add("iso-sel"); });
}
// outline an object, or several (an array of ids)
function selectObj(id) {
Array.prototype.forEach.call(svg.querySelectorAll(".iso-osel"), function (e) { e.classList.remove("iso-osel"); });
st.selObj = id || null;
if (id) Array.prototype.forEach.call(q("data-obj", id), function (e) { e.classList.add("iso-osel"); });
var ids = Array.isArray(id) ? id : id ? [id] : [];
st.selObj = ids.length ? id : null;
ids.forEach(function (i) { Array.prototype.forEach.call(q("data-obj", i), function (e) { e.classList.add("iso-osel"); }); });
}
function targetId(t) { var e = t && t.closest ? t.closest("[data-id]") : null; return e && svg.contains(e) ? e.getAttribute("data-id") : null; }
function targetObj(t) { var e = t && t.closest ? t.closest("[data-obj]") : null; return e && svg.contains(e) ? e.getAttribute("data-obj") : null; }
function targetSrc(t) { var e = t && t.closest ? t.closest("[data-src]") : null; return e && svg.contains(e) ? e.getAttribute("data-src") : null; }
function pick(t, cx, cy) {
function pick(t, cx, cy, ev) {
var id = targetId(t), obj = targetObj(t);
if (!id && !obj) { select(null); hideTip(); call(opts.onPick, null); return; }
if (!id && !obj) { select(null); hideTip(); call(opts.onPick, null, ev); return; }
var info = infoOf(id || obj);
if (!opts.noSelect) select(id);
if (!opts.noTip) showTip(info, cx, cy);
call(opts.onPick, { id: info.id || id, name: info.name, detail: info.detail, rows: info.rows || [], obj: obj, src: targetSrc(t) });
call(opts.onPick, { id: info.id || id, name: info.name, detail: info.detail, rows: info.rows || [], obj: obj, src: targetSrc(t) }, ev);
}
// client point -> world plan at height z
function toWorld(cx, cy, z) {
@@ -340,7 +342,7 @@
if (npt() < 2) pinch = null;
if (wasDown) {
if (claimed) call(opts.interact.up, e, { moved: moved, world: toWorld(e.clientX, e.clientY, 0), cancel: e.type !== "pointerup" });
else if (!moved && !down.cam && e.type === "pointerup") pick(e.target, e.clientX, e.clientY);
else if (!moved && !down.cam && e.type === "pointerup") pick(e.target, e.clientX, e.clientY, e);
}
if (npt() === 0) { down = null; claimed = false; wrap.classList.remove("iso-dragging"); }
}