Multi-select, select joined walls and fences, pond with swimming fish
Deploy web Studio / deploy (push) Successful in 1m27s
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:
1 parent
e4017ce2a8
commit
19f4c83ae8
21 files changed
+759
-59
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,17 @@ lshade = "#d9c9a0"
|
||||
roofr = "#8a4a3a"
|
||||
cobble = "#6c675f"
|
||||
capst = "#8c877e"
|
||||
pondw = "#24607a"
|
||||
ponddp = "#1a4c63"
|
||||
pondbank = "#4a3f2f"
|
||||
lily = "#3f7a3a"
|
||||
lilyf = "#e7a1c0"
|
||||
reed = "#4f6e30"
|
||||
cattail = "#5a3e2a"
|
||||
koi = "#e8742a"
|
||||
koiw = "#e9e4da"
|
||||
koig = "#d9a63a"
|
||||
ripple = "#8cc3d8"
|
||||
|
||||
[palette.light]
|
||||
sky1 = "#a9d4fb"
|
||||
@@ -497,6 +508,17 @@ lshade = "#f2e6c8"
|
||||
roofr = "#b8613f"
|
||||
cobble = "#aaa195"
|
||||
capst = "#d8d2c6"
|
||||
pondw = "#5aa7c4"
|
||||
ponddp = "#4791b0"
|
||||
pondbank = "#8d7a5c"
|
||||
lily = "#6fae5a"
|
||||
lilyf = "#f4b6d2"
|
||||
reed = "#5e8a3a"
|
||||
cattail = "#7a5536"
|
||||
koi = "#f08a3c"
|
||||
koiw = "#fbf8f2"
|
||||
koig = "#efc35a"
|
||||
ripple = "#d6f1fa"
|
||||
|
||||
[materials]
|
||||
warn = "#e0412f"
|
||||
@@ -723,6 +745,13 @@ lshade = "lshade"
|
||||
roofr = "roofr"
|
||||
cobble = "cobble"
|
||||
capst = "capst"
|
||||
pondw = "pondw"
|
||||
ponddp = "ponddp"
|
||||
pondbank = "pondbank"
|
||||
lily = "lily"
|
||||
lilyf = "lilyf"
|
||||
reed = "reed"
|
||||
cattail = "cattail"
|
||||
doorw = "furnw"
|
||||
doorg = "door"
|
||||
f-oak = "oak"
|
||||
@@ -748,6 +777,14 @@ f-conc = "conc"
|
||||
".iso-svg .iso-glassw" = "--b: var(--iso-glassw); fill-opacity: var(--iso-glasswo)"
|
||||
".iso-svg path.iso-glassw.iso-t" = "fill-opacity: .5"
|
||||
".iso-svg .iso-win" = "fill: var(--iso-win); stroke: none"
|
||||
".iso-svg .iso-school" = "pointer-events: none"
|
||||
".iso-svg .iso-swimbox" = "fill: none; stroke: none"
|
||||
".iso-svg .iso-fish" = "stroke: none; fill-opacity: .92"
|
||||
".iso-svg .iso-koi" = "fill: var(--iso-koi)"
|
||||
".iso-svg .iso-koiw" = "fill: var(--iso-koiw)"
|
||||
".iso-svg .iso-koig" = "fill: var(--iso-koig)"
|
||||
".iso-svg .iso-ripple" = "fill: none; stroke: var(--iso-ripple); stroke-width: .5"
|
||||
".iso-svg path.iso-reed, .iso-svg path.iso-cattail" = "stroke: none"
|
||||
".iso-svg .iso-win.iso-lit" = "fill: var(--iso-winlit)"
|
||||
".iso-svg .iso-glass" = "fill: var(--iso-glass); fill-opacity: var(--iso-glasso); stroke: none"
|
||||
".iso-svg .iso-door" = "fill: var(--iso-door); stroke: none"
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ shaded automatically by face direction.
|
||||
| `circle(x, y, z, r_px, cls[, dx, dy])`, `stem(x, y, z0, z1, w_px, cls)` | screen-space details (crowns, poles) |
|
||||
| `person(x, y, z[, cls])`, `sit(x, y, z, cls)`, `head(x, y, z, r)`, `led(x, y, z[, cls])` | figures |
|
||||
| `puffs(x, y, z[, kind[, n, dur]])`, `fan(x, y, z, r)` | animated steam / smoke / fans |
|
||||
| `swim(x, y, z, [#{ rx, ry, dur, delay, cw, len, cls }, ...])` | fish going round ellipses (radii rx, ry along the object's axes) about (x, y) on the plane at z, one round per `dur` s, clockwise when `cw`; `cls` koi / koiw / koig (the pond) |
|
||||
| `seq([[x, y, \|q\| …], …])` | items drawn back to front by their anchor |
|
||||
| `group(cls[, css], \|q\| …)`, `begin(cls[, css])` … `end()` | `<g>` around parts (animation) |
|
||||
| `ext(x, y, z)`, `ext_box(…)` | widen the sort box |
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
{"id": "path", "type": "area", "x": -1.0, "y": -6.05, "props": {"w": 1.4, "d": 2.9, "mat": "g-path"}, "name": "Front path"},
|
||||
{"id": "terrace", "type": "area", "x": 8.6, "y": -2.25, "props": {"w": 5.0, "d": 4.5, "mat": "g-pad"}, "name": "Terrace"},
|
||||
{"id": "garden", "type": "area", "x": 2, "y": 9.2, "props": {"w": 30, "d": 7.5, "mat": "g-garden"}, "name": "Back garden"},
|
||||
{"id": "pond", "type": "pond", "name": "Fish pond", "x": -4.5, "y": 9.4, "props": {"w": 6.5, "d": 3.8, "fish": 6, "lilies": 4}, "info": "Koi pond, 6.5 x 3.8 m"},
|
||||
{"id": "floor-living", "type": "floor", "x": -3.0, "y": -2.0, "props": {"w": 6, "d": 5, "mat": "f-oak"}, "name": "Living room"},
|
||||
{"id": "floor-kitchen", "type": "floor", "x": 3.0, "y": -2.0, "props": {"w": 6, "d": 5, "mat": "f-tile"}, "name": "Kitchen and dining"},
|
||||
{"id": "floor-bedroom", "type": "floor", "x": -3.5, "y": 2.5, "props": {"w": 5, "d": 4, "mat": "f-carpet"}, "name": "Bedroom"},
|
||||
|
||||
+4
-1
@@ -262,6 +262,9 @@
|
||||
}
|
||||
function system() {
|
||||
var s = st(), S = s.S, sel = S.sel && s.obj(S.sel), v = s.view;
|
||||
var many = (S.selIds || []).map(function (id) { return s.obj(id); }).filter(Boolean);
|
||||
var selText = many.length > 1 ? "Selected " + many.length + " objects (\"these\", \"them\"): " + JSON.stringify(many.slice(0, 30).map(compact)) + (many.length > 30 ? " and " + (many.length - 30) + " more" : "") + "."
|
||||
: sel ? "Selected (\"this\", \"it\"): " + JSON.stringify(compact(sel)) : "Nothing is selected.";
|
||||
return {
|
||||
role: "system",
|
||||
content: [
|
||||
@@ -270,7 +273,7 @@
|
||||
"How to work: look before you build (list_objects shows what is where with world boxes; list_prefabs with a type gives its props). Place things inside the site, on free ground, and use the boxes to avoid overlaps. Put related edits into one apply_ops call so they undo as one step. After bigger changes, check with render_view or check_project. Use select_object to show the user what you mean. Ask back when a request is unclear instead of guessing at big changes.",
|
||||
"Answer in the language of the user's latest message, short and plain, without tables: say what you changed and give object ids in backticks (the user can click them). To show the result, write the answer first and call select_object in the same step: that ends your turn.",
|
||||
"The Studio right now: project \"" + S.meta.title + "\", " + (S.doc.objects || []).length + " objects; view level \"" + v.getLevel() + "\", rotation " + v.getRotation() + "; snap step " + (s.snapStep() || "off") + ". " +
|
||||
(sel ? "Selected (\"this\", \"it\"): " + JSON.stringify(compact(sel)) : "Nothing is selected.") + " " + screenAxes(v.getRotation())
|
||||
selText + " " + screenAxes(v.getRotation())
|
||||
].filter(Boolean).join("\n\n")
|
||||
};
|
||||
}
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
<tr><th>Draw</th><td>walls, fences, hedges, roads: drag along the map for one piece, or click corner after corner for a run (double-click or Enter ends it, clicking the first corner closes it, Backspace drops the last corner); ends snap onto the walls and fences they meet, which then join at corners and T-junctions. Floors, areas, buildings, roofs: drag a rectangle; Room: drag a rectangle for a floor with walls around it</td></tr>
|
||||
<tr><th>Windows, doors</th><td>click on a wall: they snap onto it, cut their opening and move, turn, copy and delete with the wall; R turns a door to the other hinge and swing side</td></tr>
|
||||
<tr><th>Storeys</th><td>GF, 1F, … cut a house open; on 1F and up, new walls, floors and furniture go onto that storey (3 m each, parameter <code>storey_h</code>)</td></tr>
|
||||
<tr><th>Select</th><td>click it on the map or in the list</td></tr>
|
||||
<tr><th>Select</th><td>click it on the map or in the list; click a wall or fence again to select every piece joined to it (again: just that one)</td></tr>
|
||||
<tr><th>Several</th><td>Ctrl+click (map or list) adds or takes out one, Shift+click in the list selects a range, Shift+drag on the map selects what lies inside the box, Ctrl+A everything not locked, Esc none. Drag one of them, arrow keys, R, Ctrl+D and Delete then work on all; the form edits a property for all of them</td></tr>
|
||||
<tr><th>Move</th><td>drag it, or arrow keys (by the snap step)</td></tr>
|
||||
<tr><th>Turn</th><td>R / Shift+R, or the ⟲ ⟳ buttons</td></tr>
|
||||
<tr><th>Copy / delete</th><td>Ctrl+D / Delete</td></tr>
|
||||
|
||||
@@ -159,6 +159,10 @@ input[type=checkbox] { accent-color: var(--accent); }
|
||||
.ag-grip { position: absolute; left: 0; top: 0; width: 14px; height: 14px; cursor: nwse-resize; touch-action: none; background: linear-gradient(135deg, var(--mute) 0 2px, transparent 2px 4px, var(--mute) 4px 5px, transparent 5px); opacity: .5; border-top-left-radius: 8px; }
|
||||
.ag-grip:hover { opacity: 1; }
|
||||
|
||||
/* the selection box (Shift / Ctrl + drag on the map) */
|
||||
.st-band { position: fixed; z-index: 30; pointer-events: none; border: 1px dashed var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
#objects { user-select: none; }
|
||||
|
||||
/* previews while placing: footprints, wall planes, where a window lands */
|
||||
.iso-svg .st-ghosts { pointer-events: none; }
|
||||
.iso-svg .st-ghost { fill: color-mix(in srgb, var(--iso-accent) 16%, transparent); stroke: var(--iso-accent); stroke-width: 1.5; stroke-dasharray: 5 3; vector-effect: non-scaling-stroke; stroke-linejoin: round; }
|
||||
|
||||
+236
-39
@@ -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, ">").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 '<li data-id="' + esc(o.id) + '" aria-selected="' + (o.id === S.sel) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
|
||||
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (S.selIds.indexOf(o.id) >= 0) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
|
||||
}).join("");
|
||||
}
|
||||
// closed palette categories, remembered in this browser
|
||||
@@ -574,29 +724,42 @@
|
||||
});
|
||||
$("psearch").addEventListener("input", renderPalette);
|
||||
$("osearch").addEventListener("input", renderLists);
|
||||
$("objects").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li) select(li.getAttribute("data-id")); });
|
||||
// Ctrl+click adds or takes out one, Shift+click selects the range from the last picked one
|
||||
$("objects").addEventListener("click", function (e) {
|
||||
var li = e.target.closest("li"); if (!li) return;
|
||||
var id = li.getAttribute("data-id");
|
||||
if (e.ctrlKey || e.metaKey) { toggleSel(id); return; }
|
||||
if (e.shiftKey && S.sel) {
|
||||
var all = Array.prototype.map.call(document.querySelectorAll("#objects li"), function (x) { return x.getAttribute("data-id"); }), a = all.indexOf(S.sel), b = all.indexOf(id);
|
||||
if (a >= 0 && b >= 0) { selectMany(all.slice(Math.min(a, b), Math.max(a, b) + 1), S.sel); return; }
|
||||
}
|
||||
select(id);
|
||||
});
|
||||
$("objects").addEventListener("dblclick", function (e) { var li = e.target.closest("li"); if (li) S.view.focus(li.getAttribute("data-id"), 3); });
|
||||
|
||||
// ------------------------------------------------------------ right panel
|
||||
function field(key, lab, html, full) { return '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : html); }
|
||||
function propInput(d, v) {
|
||||
var id = "p-" + d.key, val = v === undefined ? d.default : v;
|
||||
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + ">";
|
||||
// a property's input; mixed: the selected objects differ here, so it shows no value until set
|
||||
function propInput(d, v, mixed) {
|
||||
var id = "p-" + d.key, val = mixed ? "" : v === undefined ? d.default : v, ph = mixed ? ' placeholder="mixed"' : "";
|
||||
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + (mixed ? ' data-mixed="1"' : "") + ">";
|
||||
if (d.type === "choice" || (d.type === "material" && d.choices && d.choices.length)) {
|
||||
var ch = (d.choices || []).slice(); if (val !== undefined && ch.indexOf(val) < 0) ch.unshift(val);
|
||||
return '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + ch.map(function (c) { return "<option" + (c === val ? " selected" : "") + ">" + esc(c) + "</option>"; }).join("") + "</select>";
|
||||
var ch = (d.choices || []).slice(); if (!mixed && val !== undefined && ch.indexOf(val) < 0) ch.unshift(val);
|
||||
return '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + (mixed ? '<option value="" selected disabled>mixed</option>' : "") +
|
||||
ch.map(function (c) { return "<option" + (!mixed && c === val ? " selected" : "") + ">" + esc(c) + "</option>"; }).join("") + "</select>";
|
||||
}
|
||||
if (d.type === "material") return '<input id="' + id + '" data-prop="' + esc(d.key) + '" list="materials" value="' + esc(val) + '">';
|
||||
if (d.type === "material") return '<input id="' + id + '" data-prop="' + esc(d.key) + '" list="materials" value="' + esc(val) + '"' + ph + ">";
|
||||
if (d.type === "float" || d.type === "int") {
|
||||
var step = d.step || (d.type === "int" ? 1 : 0.1), mm = (d.min !== undefined ? ' min="' + d.min + '"' : "") + (d.max !== undefined ? ' max="' + d.max + '"' : "");
|
||||
var range = d.min !== undefined && d.max !== undefined ? '<input type="range" data-prop="' + esc(d.key) + '" data-range="1"' + mm + ' step="' + step + '" value="' + esc(val) + '">' : "<span></span>";
|
||||
return '<div class="st-num">' + range + '<input type="number" id="' + id + '" data-prop="' + esc(d.key) + '"' + mm + ' step="' + step + '" value="' + esc(val) + '"></div>';
|
||||
var range = !mixed && d.min !== undefined && d.max !== undefined ? '<input type="range" data-prop="' + esc(d.key) + '" data-range="1"' + mm + ' step="' + step + '" value="' + esc(val) + '">' : "<span></span>";
|
||||
return '<div class="st-num">' + range + '<input type="number" id="' + id + '" data-prop="' + esc(d.key) + '"' + mm + ' step="' + step + '" value="' + esc(val) + '"' + ph + "></div>";
|
||||
}
|
||||
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '">';
|
||||
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '"' + ph + ">";
|
||||
}
|
||||
function renderRight() {
|
||||
var R = $("right"), o = S.sel ? obj(S.sel) : null;
|
||||
var R = $("right"), o = S.sel ? obj(S.sel) : null, ids = selIds();
|
||||
if (!o) { renderProject(R); return; }
|
||||
if (ids.length > 1) { renderMany(R, ids); return; }
|
||||
var m = prefab(o.type) || { label: o.type, props: [] }, p = o.props || {};
|
||||
var levels = S.meta.levels || [];
|
||||
var h = "<h3><i>" + esc(m.icon || ICON_DEFAULT) + "</i>" + esc(label(o)) + "</h3>" +
|
||||
@@ -627,6 +790,29 @@
|
||||
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
|
||||
R.innerHTML = h;
|
||||
}
|
||||
// several objects: what they are, actions for all, and the properties when they are all one type
|
||||
function renderMany(R, ids) {
|
||||
var os = ids.map(obj), count = {}, order = [];
|
||||
os.forEach(function (o) { if (!count[o.type]) { count[o.type] = 0; order.push(o.type); } count[o.type]++; });
|
||||
var one = order.length === 1 ? prefab(order[0]) : null, locked = os.every(function (o) { return o.locked; }), hidden = os.every(function (o) { return o.hidden; });
|
||||
R.innerHTML = "<h3><i>▣</i>" + ids.length + " objects selected</h3>" +
|
||||
'<div class="st-sub">' + order.map(function (t) { var m = prefab(t); return count[t] + " × " + esc(m ? m.label : t); }).join(", ") + "</div>" +
|
||||
'<p class="st-help">Drag one of them to move them all; arrow keys nudge, R turns the group, Delete removes them. Ctrl+click or Shift+click adds or takes out one, Shift+drag on the map adds those in a box, Esc clears.</p>' +
|
||||
'<div class="st-actions">' +
|
||||
'<button type="button" data-act="dup" title="Ctrl+D">Duplicate</button>' +
|
||||
'<button type="button" data-act="focus">Zoom to</button>' +
|
||||
'<button type="button" data-act="lock">' + (locked ? "Unlock" : "Lock") + "</button>" +
|
||||
'<button type="button" data-act="hide">' + (hidden ? "Show" : "Hide") + "</button>" +
|
||||
'<button type="button" data-act="del" class="st-danger">Delete ' + ids.length + "</button></div>" +
|
||||
'<h2>Placement</h2><div class="st-form">' +
|
||||
field("rot", "Turn the group", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>90°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') + "</div>" +
|
||||
(one && one.props.length ? "<h2>Properties <small>for all " + ids.length + "</small></h2><div class=\"st-form\">" + one.props.map(function (d) {
|
||||
var vals = os.map(function (o) { return JSON.stringify(propOf(o, d.key)); }), same = vals.every(function (v) { return v === vals[0]; });
|
||||
return field(d.key, d.label || d.key, propInput(d, propOf(os[0], d.key), !same));
|
||||
}).join("") + "</div>" : "") +
|
||||
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
|
||||
Array.prototype.forEach.call(R.querySelectorAll("input[data-mixed]"), function (x) { x.indeterminate = true; });
|
||||
}
|
||||
function renderProject(R) {
|
||||
var m = S.meta, cfg = S.doc.cfg || {};
|
||||
var base = location.origin;
|
||||
@@ -645,7 +831,16 @@
|
||||
}
|
||||
$("right").addEventListener("click", function (e) {
|
||||
var b = e.target.closest("button[data-act]"); if (!b || !S.sel) return;
|
||||
var a = b.getAttribute("data-act"), o = obj(S.sel);
|
||||
var a = b.getAttribute("data-act"), o = obj(S.sel), ids = selIds();
|
||||
if (ids.length > 1) {
|
||||
if (a === "del") removeMany(ids);
|
||||
else if (a === "dup") duplicateMany(ids);
|
||||
else if (a === "focus") focusMany(ids);
|
||||
else if (a === "lock") setAll(ids, "locked");
|
||||
else if (a === "hide") setAll(ids, "hidden");
|
||||
else if (a === "rotl" || a === "rotr") turnGroup(ids, a === "rotl" ? -90 : 90);
|
||||
return;
|
||||
}
|
||||
if (a === "del") remove(S.sel);
|
||||
else if (a === "dup") duplicate(S.sel);
|
||||
else if (a === "focus") S.view.focus(S.sel, 3);
|
||||
@@ -675,9 +870,11 @@
|
||||
if (d.type === "int") pv = Math.round(pv);
|
||||
// keep the slider and the number box in step
|
||||
Array.prototype.forEach.call(document.querySelectorAll('#right [data-prop="' + key + '"]'), function (x) { if (x !== t && x.type !== "checkbox") x.value = pv; });
|
||||
var pp = {}; pp[key] = pv;
|
||||
if (live) { clearTimeout(rangeT); rangeT = setTimeout(function () { update(o.id, { props: pp }, true); }, 160); }
|
||||
else update(o.id, { props: pp }, true);
|
||||
var pp = {}, all = selIds().length > 1 ? selIds() : [o.id];
|
||||
pp[key] = pv;
|
||||
var put = function () { opList(all.map(function (id) { return { op: "update", id: id, patch: { props: pp } }; }), true); };
|
||||
if (live) { clearTimeout(rangeT); rangeT = setTimeout(put, 160); }
|
||||
else put();
|
||||
} else if (t.hasAttribute("data-level")) {
|
||||
var lv = Array.prototype.filter.call(document.querySelectorAll("#right [data-level]"), function (x) { return x.checked; }).map(function (x) { return x.getAttribute("data-level"); });
|
||||
update(o.id, { levels: lv });
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user