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

+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 {