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