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

+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::*;