From 19f4c83ae8e7d45fb4e545e0f4d82a5c2e6580c4 Mon Sep 17 00:00:00 2001 From: grabowski Date: Sat, 24 Oct 2026 18:36:39 +0700 Subject: [PATCH] Multi-select, select joined walls and fences, pond with swimming fish 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. --- README.md | 7 +- crates/isokit-core/src/fmt.rs | 11 + crates/isokit-core/src/lib.rs | 2 +- crates/isokit-core/src/prim.rs | 23 ++ crates/isokit-core/src/render.rs | 87 +++++++- crates/isokit-core/src/sprite.rs | 14 +- crates/isokit-project/src/api.rs | 12 +- crates/isokit-script/src/api.rs | 26 +++ crates/isokit-script/src/tests.rs | 57 +++++ crates/isokit-script/stdlib/nature.rhai | 134 ++++++++++++ crates/isokit-theme/themes/default.toml | 37 ++++ docs/ROADMAP.md | 5 +- docs/SCRIPTING.md | 1 + examples/house/scene.json | 1 + studio/agent.js | 5 +- studio/index.html | 3 +- studio/studio.css | 4 + studio/studio.js | 275 ++++++++++++++++++++---- tools/check_studio.py | 92 ++++++++ viewer/isokit-view.css | 6 +- viewer/isokit-view.js | 16 +- 21 files changed, 759 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index c8c2fac..b50ae50 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/isokit-core/src/fmt.rs b/crates/isokit-core/src/fmt.rs index 0101b27..22a5e8e 100644 --- a/crates/isokit-core/src/fmt.rs +++ b/crates/isokit-core/src/fmt.rs @@ -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 diff --git a/crates/isokit-core/src/lib.rs b/crates/isokit-core/src/lib.rs index 3a385c8..dc79e7a 100644 --- a/crates/isokit-core/src/lib.rs +++ b/crates/isokit-core/src/lib.rs @@ -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}; diff --git a/crates/isokit-core/src/prim.rs b/crates/isokit-core/src/prim.rs index cbf7be1..9632c52 100644 --- a/crates/isokit-core/src/prim.rs +++ b/crates/isokit-core/src/prim.rs @@ -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, + }, /// 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, 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)> }, } + +/// 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, +} diff --git a/crates/isokit-core/src/render.rs b/crates/isokit-core/src/render.rs index c94ac02..5549c36 100644 --- a/crates/isokit-core/src/render.rs +++ b/crates/isokit-core/src/render.rs @@ -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, + "", + 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, + "", + 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(""); + } + 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::*; diff --git a/crates/isokit-core/src/sprite.rs b/crates/isokit-core/src/sprite.rs index e955c0a..c4578ee 100644 --- a/crates/isokit-core/src/sprite.rs +++ b/crates/isokit-core/src/sprite.rs @@ -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) -> &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, svg: impl Into) -> &mut Self { let at = at.map(|a| self.xf.p3(a)); self.push(Prim::Raw { diff --git a/crates/isokit-project/src/api.rs b/crates/isokit-project/src/api.rs index 26581a2..a9152fe 100644 --- a/crates/isokit-project/src/api.rs +++ b/crates/isokit-project/src/api.rs @@ -23,7 +23,8 @@ pub fn meta(ws: &Workspace) -> 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(ws: &mut Workspace, body: &Value) -> Value { let r = if let Some(list) = body.get("ops").and_then(|v| v.as_array()) { let ops: Result, String> = list @@ -31,18 +32,19 @@ pub fn op(ws: &mut Workspace, body: &Value) -> Value { .map(|v| serde_json::from_value::(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::>()) } else { serde_json::from_value::(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 }), diff --git a/crates/isokit-script/src/api.rs b/crates/isokit-script/src/api.rs index 3ccf65e..96bd44f 100644 --- a/crates/isokit-script/src/api.rs +++ b/crates/isokit-script/src/api.rs @@ -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 { + let mut fish = Vec::with_capacity(list.len()); + for f in &list { + let Some(m) = f.read_lock::() 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 { s.0.borrow_mut().raw(None, svg); ok(s) diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index cc3db8d..52fd2a8 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -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(); diff --git a/crates/isokit-script/stdlib/nature.rhai b/crates/isokit-script/stdlib/nature.rhai index b6f6267..c903732 100644 --- a/crates/isokit-script/stdlib/nature.rhai +++ b/crates/isokit-script/stdlib/nature.rhai @@ -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"); + } + } +} diff --git a/crates/isokit-theme/themes/default.toml b/crates/isokit-theme/themes/default.toml index 964e7e3..16bf48d 100644 --- a/crates/isokit-theme/themes/default.toml +++ b/crates/isokit-theme/themes/default.toml @@ -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" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d3ede08..552f56e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/SCRIPTING.md b/docs/SCRIPTING.md index ff13149..4fe2a72 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -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()` | `` around parts (animation) | | `ext(x, y, z)`, `ext_box(…)` | widen the sort box | diff --git a/examples/house/scene.json b/examples/house/scene.json index 11d0a86..29ef3dd 100644 --- a/examples/house/scene.json +++ b/examples/house/scene.json @@ -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"}, diff --git a/studio/agent.js b/studio/agent.js index f0ad29c..96d3dfb 100644 --- a/studio/agent.js +++ b/studio/agent.js @@ -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") }; } diff --git a/studio/index.html b/studio/index.html index c6c8e9a..43be05e 100644 --- a/studio/index.html +++ b/studio/index.html @@ -65,7 +65,8 @@ document.documentElement.setAttribute("data-theme",t);})(); Drawwalls, 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 Windows, doorsclick 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 StoreysGF, 1F, … cut a house open; on 1F and up, new walls, floors and furniture go onto that storey (3 m each, parameter storey_h) - Selectclick it on the map or in the list + Selectclick 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) + SeveralCtrl+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 Movedrag it, or arrow keys (by the snap step) TurnR / Shift+R, or the ⟲ ⟳ buttons Copy / deleteCtrl+D / Delete diff --git a/studio/studio.css b/studio/studio.css index c303b8b..40334a6 100644 --- a/studio/studio.css +++ b/studio/studio.css @@ -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; } diff --git a/studio/studio.js b/studio/studio.js index d99a307..582d854 100644 --- a/studio/studio.js +++ b/studio/studio.js @@ -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, "&").replace(//g, ">").replace(/"/g, """); } @@ -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 '
  • ' + esc(icon(o.type)) + "" + esc(label(o)) + "" + (o.locked ? "🔒" : "") + "
  • "; + return '
  • ' + esc(icon(o.type)) + "" + esc(label(o)) + "" + (o.locked ? "🔒" : "") + "
  • "; }).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 '" + (full ? '
    ' + html + "
    " : html); } - function propInput(d, v) { - var id = "p-" + d.key, val = v === undefined ? d.default : v; - if (d.type === "bool") return '"; + // 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 '"; 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 '"; + var ch = (d.choices || []).slice(); if (!mixed && val !== undefined && ch.indexOf(val) < 0) ch.unshift(val); + return '"; } - if (d.type === "material") return ''; + if (d.type === "material") return '"; 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 ? '' : ""; - return '
    ' + range + '
    '; + var range = !mixed && d.min !== undefined && d.max !== undefined ? '' : ""; + return '
    ' + range + '
    "; } - return ''; + return '"; } 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 = "

    " + esc(m.icon || ICON_DEFAULT) + "" + esc(label(o)) + "

    " + @@ -627,6 +790,29 @@ '' + (S.meta.materials || []).map(function (x) { return ""; }).join("") + ""; 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 = "

    ▣" + ids.length + " objects selected

    " + + '
    ' + order.map(function (t) { var m = prefab(t); return count[t] + " × " + esc(m ? m.label : t); }).join(", ") + "
    " + + '

    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.

    ' + + '
    ' + + '' + + '' + + '" + + '" + + '
    " + + '

    Placement

    ' + + field("rot", "Turn the group", '
    90°
    ') + "
    " + + (one && one.props.length ? "

    Properties for all " + ids.length + "

    " + 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("") + "
    " : "") + + '' + (S.meta.materials || []).map(function (x) { return ""; }).join("") + ""; + 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 }); diff --git a/tools/check_studio.py b/tools/check_studio.py index b337898..fb160f7 100644 --- a/tools/check_studio.py +++ b/tools/check_studio.py @@ -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)") diff --git a/viewer/isokit-view.css b/viewer/isokit-view.css index e4fa93d..92021bc 100644 --- a/viewer/isokit-view.css +++ b/viewer/isokit-view.css @@ -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) { diff --git a/viewer/isokit-view.js b/viewer/isokit-view.js index 496732d..659947f 100644 --- a/viewer/isokit-view.js +++ b/viewer/isokit-view.js @@ -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"); } }