Fences join like walls, fence designs, draw runs corner by corner
Deploy web Studio / deploy (push) Successful in 3m52s
Deploy web Studio / deploy (push) Successful in 3m52s
Joints: line_joint (util.rhai) applies the house-wall rules to fences and site walls, so pieces never overlap and the depth sort stays clean. - A corner is closed: the thicker piece (or, if equal, the one along world x) runs through, and the other stops at its face. - On a straight run, two pieces share one post. - At a T, the stem stops at the other piece's face without a post of its own. - A fence that ends at a site or house wall stops at the wall's face. Fence designs (prop style): mesh, wooden boards, steel bars, mesh on a stone base, cobble wall, and brick wall with pillars. Board, stone and brick joints are drawn on the face turned to the viewer. Brick pillars are separate sprites. Studio: line prefabs (walls, fences, hedges, roads) can be drawn as a run by clicking corner after corner. Double-click or Enter ends the run, clicking the first corner closes it, and Backspace drops the last corner. A run is one undo step. A drag still draws one piece. Corners and ends snap onto the ends and lines of the pieces they meet, and a marker shows where the next corner will snap. The house demo gets a brick front wall with gate pillars and wooden side fences in place of the old east fence, which ran over the road. Tests cover every design's corners, Ts, straight runs and wall stops, with no depth-sort cycles in any rotation, plus site walls. check_studio draws and closes a fence run, undoes it in one step, snaps a dragged spur, and picks a design.
This commit is contained in:
1 parent
7962249191
commit
e4017ce2a8
14 files changed
+559
-27
No files matched your search
@@ -46,7 +46,7 @@ Try the demos: `isokit studio examples/demo` (a works site) and
|
||||
| Do | How |
|
||||
|---|---|
|
||||
| Add | pick an object on the left, click on the map (Shift-click places several) |
|
||||
| Draw | walls, roads, fences, hedges: drag along the map; floors, areas, buildings, roofs, rugs: drag a rectangle |
|
||||
| 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 |
|
||||
| Move | drag it, or arrow keys by the snap step (0.1 / 0.5 / 1 / 5 m) |
|
||||
| Turn | R / Shift+R or ⟲ ⟳ |
|
||||
@@ -72,6 +72,12 @@ The **House** palette has the parts, **Furniture** the interior:
|
||||
4. **Roof** (hip, gable, flat, optional solar) as a rectangle over the walls,
|
||||
and **Stairs**.
|
||||
|
||||
**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
|
||||
close, a T stops at the other's face, a straight run shares one post, and a
|
||||
fence ending at a wall stops at its face.
|
||||
|
||||
Level **GF** shows the house cut open: walls in front of a room are cut low
|
||||
(walls tell inside from outside by the floors next to them), the roof becomes an
|
||||
outline on the ground. For more storeys, view **1F** (2F, …): new walls, floors
|
||||
|
||||
@@ -21,7 +21,9 @@ Houses are built from parts (categories House and Furniture): a floor per room (
|
||||
(x, y = middle of the wall line, rot 0 runs along x, rot 90 along y, w = length; walls meet at their centre lines), \
|
||||
then windows and doors with x, y on a wall line (they sit on that wall and cut their opening; a door swings towards \
|
||||
the wall's +y side, rot + 180 swings it the other way), a roof (w x d = outer size of the walls) and furniture \
|
||||
(fronts face local -y, backs go against a wall). Level gf shows the house cut open; a part's storey is its z / 3 m.";
|
||||
(fronts face local -y, backs go against a wall). Level gf shows the house cut open; a part's storey is its z / 3 m. \
|
||||
Fences (props.style: mesh, wood, bars, half stone, cobble, brick) and site walls join where their centre-line ends \
|
||||
meet, so for a plot boundary put each piece's ends exactly on the next piece's end or line.";
|
||||
|
||||
fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value {
|
||||
json!({ "name": name, "description": description,
|
||||
|
||||
@@ -399,6 +399,122 @@ fn walls_meeting_at_a_t_or_in_line_do_not_overlap() {
|
||||
assert!((n1[0] + 2.075).abs() < 1e-6 && (n2[3] - 2.075).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// A 10 x 6 m fence perimeter, a fence across it (a T at both ends; at the
|
||||
/// north it meets the joint of the north fence's two pieces) and a spur from
|
||||
/// the east fence to a site wall.
|
||||
fn fence_doc(style: &str) -> Doc {
|
||||
let mut doc = Doc::default();
|
||||
for (id, x, y, rot, w) in [
|
||||
("south", 0.0, -3.0, 0.0, 10.0),
|
||||
("north", -2.5, 3.0, 0.0, 5.0),
|
||||
("north2", 2.5, 3.0, 0.0, 5.0),
|
||||
("west", -5.0, 0.0, 90.0, 6.0),
|
||||
("east", 5.0, 0.0, 90.0, 6.0),
|
||||
("inner", 0.0, 0.0, 90.0, 6.0),
|
||||
("spur", 6.5, 0.0, 0.0, 3.0),
|
||||
] {
|
||||
let mut o = obj(id, "fence", x, y, json!({ "w": w, "style": style }));
|
||||
o.rot = rot;
|
||||
doc.objects.push(o);
|
||||
}
|
||||
let mut wall = obj("wall", "wall", 8.0, 0.0, json!({"w": 10.0}));
|
||||
wall.rot = 90.0;
|
||||
doc.objects.push(wall);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fences_join_like_walls_in_every_design() {
|
||||
let r = Runtime::new(&Bundle::default()).unwrap();
|
||||
for (style, ht) in [
|
||||
("mesh", 0.04),
|
||||
("wood", 0.05),
|
||||
("bars", 0.04),
|
||||
("half stone", 0.16),
|
||||
("cobble", 0.22),
|
||||
("brick", 0.18),
|
||||
] {
|
||||
let doc = fence_doc(style);
|
||||
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
||||
assert!(
|
||||
b.scene.warnings.is_empty(),
|
||||
"{style}: {:?}",
|
||||
b.scene.warnings
|
||||
);
|
||||
let bx = |id: &str| b.boxes[id];
|
||||
let near = |a: f64, e: f64| (a - e).abs() < 1e-6;
|
||||
// corners: the fence along x runs through, the other stops at its face
|
||||
let (s, w) = (bx("south"), bx("west"));
|
||||
assert!(
|
||||
near(s[0], -5.0 - ht) && near(s[3], 5.0 + ht),
|
||||
"{style} south {s:?}"
|
||||
);
|
||||
assert!(
|
||||
near(w[1], -3.0 + ht) && near(w[4], 3.0 - ht),
|
||||
"{style} west {w:?}"
|
||||
);
|
||||
// a T at the south, the end of a straight run at the north: it stops at the faces
|
||||
let i = bx("inner");
|
||||
assert!(
|
||||
near(i[1], -3.0 + ht) && near(i[4], 3.0 - ht),
|
||||
"{style} inner {i:?}"
|
||||
);
|
||||
// in line: the lower piece runs through the shared post, the other starts at it
|
||||
let (n1, n2) = (bx("north"), bx("north2"));
|
||||
assert!(
|
||||
near(n1[3], ht) && near(n2[0], ht),
|
||||
"{style} north {n1:?} {n2:?}"
|
||||
);
|
||||
// the spur stops at the east fence and at the site wall's face
|
||||
let sp = bx("spur");
|
||||
assert!(
|
||||
near(sp[0], 5.0 + ht) && near(sp[3], 8.0 - 0.12),
|
||||
"{style} spur {sp:?}"
|
||||
);
|
||||
for rot in 0..4u8 {
|
||||
let b = r.build("site", rot, &cfg(), &doc).unwrap();
|
||||
let out = render(&b.scene, rot, &RenderOpts::default());
|
||||
assert_eq!(out.cycles, 0, "{style}/{rot}: depth-sort cycles");
|
||||
}
|
||||
}
|
||||
// the brick wall has pillars as their own sprites, with brick courses on its faces
|
||||
let b = r.build("site", 0, &cfg(), &fence_doc("brick")).unwrap();
|
||||
assert!(sprites_of(&b, "south") > 4);
|
||||
assert!(render(&b.scene, 0, &RenderOpts::default())
|
||||
.html
|
||||
.contains("iso-seam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_walls_join_at_corners() {
|
||||
let r = Runtime::new(&Bundle::default()).unwrap();
|
||||
let mut doc = Doc::default();
|
||||
for (id, x, y, rot, w) in [
|
||||
("s", 0.0, -3.0, 0.0, 10.0),
|
||||
("n", 0.0, 3.0, 0.0, 10.0),
|
||||
("w", -5.0, 0.0, 90.0, 6.0),
|
||||
("e", 5.0, 0.0, 90.0, 6.0),
|
||||
] {
|
||||
let mut o = obj(id, "wall", x, y, json!({ "w": w }));
|
||||
o.rot = rot;
|
||||
doc.objects.push(o);
|
||||
}
|
||||
let b = r.build("site", 0, &cfg(), &doc).unwrap();
|
||||
let (s, w) = (b.boxes["s"], b.boxes["w"]);
|
||||
assert!(
|
||||
(s[0] + 5.12).abs() < 1e-6 && (s[3] - 5.12).abs() < 1e-6,
|
||||
"{s:?}"
|
||||
);
|
||||
assert!(
|
||||
(w[1] + 2.88).abs() < 1e-6 && (w[4] - 2.88).abs() < 1e-6,
|
||||
"{w:?}"
|
||||
);
|
||||
for rot in 0..4u8 {
|
||||
let b = r.build("site", rot, &cfg(), &doc).unwrap();
|
||||
assert_eq!(render(&b.scene, rot, &RenderOpts::default()).cycles, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_levels_lower_the_walls_in_front_of_rooms() {
|
||||
let r = Runtime::new(&Bundle::default()).unwrap();
|
||||
|
||||
@@ -91,6 +91,7 @@ fn prefab_parking(o) {
|
||||
|
||||
fn meta_wall() {
|
||||
#{ label: "Wall", category: "Site", icon: "▬", shape: "line",
|
||||
help: "Drag along the map, or click corner after corner for a run around a plot. Walls that meet join at corners and T-junctions.",
|
||||
props: [
|
||||
#{ key: "w", type: "float", def: 20.0, min: 0.5, max: 500.0, step: 0.5, label: "Length (m)" },
|
||||
#{ key: "h", type: "float", def: 2.2, min: 0.3, max: 8.0, step: 0.1, label: "Height (m)" },
|
||||
@@ -98,37 +99,179 @@ fn meta_wall() {
|
||||
] }
|
||||
}
|
||||
fn prefab_wall(o) {
|
||||
// long walls in pieces of at most 30 m, each its own sprite for the depth sort
|
||||
// ends joined to the walls they meet; long walls in pieces of at most 30 m,
|
||||
// each its own sprite for the depth sort
|
||||
let p = o.p;
|
||||
let n = (p.w / 30.0).ceiling().to_int();
|
||||
let L = p.w / n;
|
||||
let x0 = -p.w / 2.0 - line_joint(o, -1.0, ["wall"], []).ext;
|
||||
let x1 = p.w / 2.0 + line_joint(o, 1.0, ["wall"], []).ext;
|
||||
let n = ((x1 - x0) / 30.0).ceiling().to_int();
|
||||
let L = (x1 - x0) / n;
|
||||
for i in 0..n {
|
||||
let x0 = -p.w / 2.0 + i * L;
|
||||
sprite().box(x0, -p.t / 2.0, x0 + L, p.t / 2.0, 0.0, p.h, "wallp", #{ top: "wallt" });
|
||||
let a = x0 + i * L;
|
||||
sprite().box(a, -p.t / 2.0, a + L, p.t / 2.0, 0.0, p.h, "wallp", #{ top: "wallt" });
|
||||
}
|
||||
}
|
||||
|
||||
fn fence_styles() { ["mesh", "wood", "bars", "half stone", "cobble", "brick"] }
|
||||
|
||||
/// Half the thickness of a fence design: its posts, base or pillars.
|
||||
fn fence_ht(style) {
|
||||
switch style { "wood" => 0.05, "half stone" => 0.16, "cobble" => 0.22, "brick" => 0.18, _ => 0.04 }
|
||||
}
|
||||
|
||||
fn meta_fence() {
|
||||
#{ label: "Fence", category: "Site", icon: "┼", shape: "line",
|
||||
help: "Drag along the map, or click corner after corner for a run around a plot. Fences that meet join at corners and T-junctions and stop at walls. Designs: mesh, wooden boards, steel bars, mesh on a stone base, cobble wall, brick wall with pillars.",
|
||||
props: [
|
||||
#{ key: "w", type: "float", def: 20.0, min: 1.0, max: 500.0, step: 0.5, label: "Length (m)" },
|
||||
#{ key: "h", type: "float", def: 1.8, min: 0.5, max: 4.0, step: 0.1, label: "Height (m)" },
|
||||
#{ key: "style", type: "choice", def: "mesh", choices: fence_styles(), label: "Design" },
|
||||
] }
|
||||
}
|
||||
fn prefab_fence(o) {
|
||||
let p = o.p;
|
||||
let n = (p.w / 30.0).ceiling().to_int();
|
||||
let L = p.w / n;
|
||||
for i in 0..n {
|
||||
let x0 = -p.w / 2.0 + i * L;
|
||||
let s = sprite();
|
||||
s.box(x0, -0.03, x0 + L, 0.03, 0.0, p.h, "fence");
|
||||
let k = (L / 2.5).floor().to_int();
|
||||
let st = p.style;
|
||||
let ea = line_joint(o, -1.0, ["fence"], ["wall", "house_wall"]);
|
||||
let eb = line_joint(o, 1.0, ["fence"], ["wall", "house_wall"]);
|
||||
let x0 = -p.w / 2.0 - ea.ext;
|
||||
let x1 = p.w / 2.0 + eb.ext;
|
||||
if x1 - x0 < 0.05 { return; }
|
||||
// posts (pillars) evenly along the length; the end ones only where this
|
||||
// fence draws them, kept inside the stretch it covers
|
||||
let ph = fence_ht(st);
|
||||
let gap = if st == "brick" { 3.0 } else { 2.5 };
|
||||
let k = fmax(1.0, (p.w / gap).round()).to_int();
|
||||
let posts = [];
|
||||
if st != "cobble" {
|
||||
for j in 0..(k + 1) {
|
||||
let x = x0 + j * L / fmax(k, 1);
|
||||
s.box(x - 0.04, -0.04, x + 0.04, 0.04, 0.0, p.h + 0.05, "steel");
|
||||
if (j == 0 && !ea.post) || (j == k && !eb.post) { continue; }
|
||||
posts.push(fmin(fmax(-p.w / 2.0 + j * p.w / k, x0 + ph), x1 - ph));
|
||||
}
|
||||
}
|
||||
if st == "brick" { brick_fence(x0, x1, posts, p.h); return; }
|
||||
// pieces of at most 30 m, each its own sprite for the depth sort
|
||||
let n = ((x1 - x0) / 30.0).ceiling().to_int();
|
||||
let L = (x1 - x0) / n;
|
||||
for i in 0..n {
|
||||
let a = x0 + i * L;
|
||||
let b = a + L;
|
||||
let ps = [];
|
||||
for x in posts {
|
||||
if x >= a - 1e-6 && (x < b - 1e-6 || i == n - 1) { ps.push(fmin(fmax(x, a + ph), b - ph)); }
|
||||
}
|
||||
fence_piece(sprite(), a, b, ps, p.h, st, i);
|
||||
}
|
||||
}
|
||||
|
||||
/// One stretch a..b of a fence (not brick) with its posts ps.
|
||||
fn fence_piece(s, a, b, ps, h, st, seed) {
|
||||
let L = b - a;
|
||||
if st == "wood" {
|
||||
s.box(a, -0.025, b, 0.025, 0.0, h, "wood");
|
||||
let boards = [];
|
||||
let m = fmax(1.0, (L / 0.2).round()).to_int();
|
||||
for j in 0..m { boards.push([j * L / m, (j + 1) * L / m, 0.04, h - 0.02]); }
|
||||
s.open([a, -0.025], [b, -0.025], boards, "seam");
|
||||
s.open([b, 0.025], [a, 0.025], boards, "seam");
|
||||
for x in ps { s.box(x - 0.05, -0.05, x + 0.05, 0.05, 0.0, h + 0.06, "woodd"); }
|
||||
} else if st == "bars" {
|
||||
let bars = [];
|
||||
let m = fmax(2.0, (L / 0.16).round()).to_int();
|
||||
for j in 1..m { let u = j * L / m; bars.push([u - 0.014, u + 0.014, 0.0, h - 0.05]); }
|
||||
s.open([a, 0.0], [b, 0.0], bars, "steel");
|
||||
s.open([b, 0.0], [a, 0.0], bars, "steel");
|
||||
s.box(a, -0.025, b, 0.025, 0.1, 0.15, "steel");
|
||||
s.box(a, -0.025, b, 0.025, h - 0.06, h, "steel");
|
||||
for x in ps { s.box(x - 0.04, -0.04, x + 0.04, 0.04, 0.0, h + 0.05, "steel"); }
|
||||
} else if st == "half stone" {
|
||||
let hb = fmin(0.6, h * 0.4);
|
||||
s.box(a, -0.16, b, 0.16, 0.0, hb, "cobble", #{ top: "capst" });
|
||||
let j = stone_joints(L, 0.0, hb, seed);
|
||||
s.open([a, -0.16], [b, -0.16], j, "seam");
|
||||
s.open([b, 0.16], [a, 0.16], j, "seam");
|
||||
s.box(a, -0.03, b, 0.03, hb, h, "fence");
|
||||
for x in ps { s.box(x - 0.04, -0.04, x + 0.04, 0.04, hb, h + 0.05, "steel"); }
|
||||
} else if st == "cobble" {
|
||||
let hc = fmax(0.2, h - 0.08);
|
||||
s.box(a, -0.2, b, 0.2, 0.0, hc, "cobble");
|
||||
s.box(a, -0.22, b, 0.22, hc, h, "capst");
|
||||
let j = stone_joints(L, 0.0, hc, seed);
|
||||
s.open([a, -0.2], [b, -0.2], j, "seam");
|
||||
s.open([b, 0.2], [a, 0.2], j, "seam");
|
||||
} else {
|
||||
// mesh
|
||||
s.box(a, -0.03, b, 0.03, 0.0, h, "fence");
|
||||
for x in ps { s.box(x - 0.04, -0.04, x + 0.04, 0.04, 0.0, h + 0.05, "steel"); }
|
||||
}
|
||||
}
|
||||
|
||||
/// A brick wall between pillars: pillars and wall stretches are separate
|
||||
/// sprites (the pillars are thicker), with a coping on top and brick courses.
|
||||
fn brick_fence(x0, x1, posts, h) {
|
||||
let hw = fmax(0.3, h - 0.06);
|
||||
let u = x0;
|
||||
for x in posts + [x1 + 1.0] {
|
||||
let b = fmin(x - 0.18, x1);
|
||||
if b > u + 0.02 {
|
||||
let n = ((b - u) / 30.0).ceiling().to_int();
|
||||
let L = (b - u) / n;
|
||||
for k in 0..n {
|
||||
let s = sprite();
|
||||
let a = u + k * L;
|
||||
s.box(a, -0.12, a + L, 0.12, 0.0, hw, "brick");
|
||||
s.box(a, -0.14, a + L, 0.14, hw, h, "plinth");
|
||||
let j = brick_joints(L, 0.0, hw, a);
|
||||
s.open([a, -0.12], [a + L, -0.12], j, "seam");
|
||||
s.open([a + L, 0.12], [a, 0.12], j, "seam");
|
||||
}
|
||||
}
|
||||
if x <= x1 {
|
||||
let s = sprite();
|
||||
s.box(x - 0.17, -0.17, x + 0.17, 0.17, 0.0, h + 0.1, "brick");
|
||||
s.box(x - 0.18, -0.18, x + 0.18, 0.18, h + 0.1, h + 0.17, "plinth");
|
||||
u = x + 0.18;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Joints of a stone face L long from z0 to z1: rows of stones of varying length.
|
||||
fn stone_joints(L, z0, z1, seed) {
|
||||
let out = [];
|
||||
let rows = fmax(1.0, ((z1 - z0) / 0.22).round()).to_int();
|
||||
let rh = (z1 - z0) / rows;
|
||||
for r in 0..rows {
|
||||
let k = seed * 31 + r * 17 + 7;
|
||||
let u = 0.0;
|
||||
while u < L - 0.05 {
|
||||
k = (k * 1103 + 12345) % 9973;
|
||||
let u1 = fmin(L, u + 0.28 + (k % 100) / 100.0 * 0.34);
|
||||
if L - u1 < 0.12 { u1 = L; }
|
||||
out.push([u, u1, z0 + r * rh, z0 + (r + 1) * rh]);
|
||||
u = u1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Brick courses on a face L long from z0 to z1, in running bond; `at`
|
||||
/// shifts the bond so neighbouring stretches line up.
|
||||
fn brick_joints(L, z0, z1, at) {
|
||||
let out = [];
|
||||
let rows = fmax(1.0, ((z1 - z0) / 0.25).round()).to_int();
|
||||
let rh = (z1 - z0) / rows;
|
||||
for r in 0..rows {
|
||||
let off = if r % 2 == 0 { 0.0 } else { 0.25 };
|
||||
let u = 0.0;
|
||||
let first = 0.5 - ((at + off) % 0.5 + 0.5) % 0.5;
|
||||
let u1 = if first < 0.05 { first + 0.5 } else { first };
|
||||
while u < L - 0.02 {
|
||||
let e = fmin(L, u1);
|
||||
out.push([u, e, z0 + r * rh, z0 + (r + 1) * rh]);
|
||||
u = e;
|
||||
u1 = e + 0.5;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn meta_lamp() {
|
||||
|
||||
@@ -135,8 +135,6 @@ fn prefab_house_wall(o) {
|
||||
if end > x + 0.01 { wall_run(x, end, t, top, p.mat, cap); }
|
||||
}
|
||||
|
||||
fn along_world_x(rot) { ((rot / 90.0).round().to_int() % 2) == 0 }
|
||||
|
||||
/// How far wall o reaches past end e (-1 or 1) of its centre line. Alone:
|
||||
/// half its thickness, so a corner closes. Where other walls meet it,
|
||||
/// the walls must not overlap (the depth sort needs them apart): along a
|
||||
|
||||
@@ -57,3 +57,66 @@ fn drive(dx, dy, dur, delay) {
|
||||
this.begin("a drive", `--dx:${v[0]}px;--dy:${v[1]}px;animation-duration:${dur}s;animation-delay:-${delay}s`);
|
||||
true
|
||||
}
|
||||
|
||||
/// True when a piece turned by rot runs along world x (rot 0 or 180).
|
||||
fn along_world_x(rot) { ((rot / 90.0).round().to_int() % 2) == 0 }
|
||||
|
||||
/// Half the thickness of a fence or wall piece: what the joints keep apart.
|
||||
fn line_ht(w) {
|
||||
if w.type == "fence" { fence_ht(w.p.style) } else { w.p.t / 2.0 }
|
||||
}
|
||||
|
||||
/// How line piece o (a fence or wall along its local x, length p.w) meets
|
||||
/// other pieces at its end e (-1 or 1): #{ ext, post }, how far it reaches
|
||||
/// past the end and whether it draws the end post. `same`: the types it joins
|
||||
/// with; `stops`: types it ends against. The rules of the house walls, so
|
||||
/// pieces never overlap (the depth sort needs them apart):
|
||||
/// - alone it reaches half its thickness past the end, flush with its post;
|
||||
/// - on a straight run the lower piece runs through and draws the shared
|
||||
/// post, the other starts at that post;
|
||||
/// - at a corner the thicker piece (equal: the one along world x) runs
|
||||
/// through, the other stops at its face;
|
||||
/// - at a T it stops at the other's face without an end post (the other
|
||||
/// piece has its own there); against a `stops` piece it keeps its post.
|
||||
fn line_joint(o, e, same, stops) {
|
||||
let ht = line_ht(o);
|
||||
let p = to_world(e * o.p.w / 2.0, 0.0);
|
||||
let c = to_world(0.0, 0.0);
|
||||
let q = to_world(e * (o.p.w / 2.0 - 0.05), 0.0);
|
||||
let ox = along_world_x(o.rot);
|
||||
let face = 0.0;
|
||||
let has_face = false;
|
||||
let face_post = false;
|
||||
let run = #{};
|
||||
let corner = [];
|
||||
for w in objects_on(same + stops, p[0], p[1], 0.5) {
|
||||
if w.id == o.id || (w.z - o.z).abs() > 0.5 { continue; }
|
||||
let wt = line_ht(w);
|
||||
if w.v.abs() > wt + 0.02 { continue; }
|
||||
let at_end = (w.u.abs() - w.p.w / 2.0).abs() < 0.02;
|
||||
let wx = along_world_x(w.rot);
|
||||
let side = if local_of(w, q[0], q[1])[1] >= w.v { 1.0 } else { -1.0 };
|
||||
let stop = -(wt - side * w.v);
|
||||
if !same.contains(w.type) || (!at_end && wx != ox) {
|
||||
face = stop;
|
||||
has_face = true;
|
||||
face_post = !same.contains(w.type);
|
||||
} else if wx == ox {
|
||||
if at_end { run = #{ lower: if ox { c[0] < w.x } else { c[1] < w.y }, wt: wt }; }
|
||||
} else {
|
||||
corner.push(#{ wt: wt, stop: stop });
|
||||
}
|
||||
}
|
||||
if has_face { return #{ ext: face, post: face_post }; }
|
||||
if run.len() > 0 {
|
||||
return if run.lower { #{ ext: ht, post: true } } else { #{ ext: -run.wt, post: false } };
|
||||
}
|
||||
// two pieces end here from both sides: a straight run this one meets as a T
|
||||
if corner.len() > 1 { return #{ ext: corner[0].stop, post: false }; }
|
||||
if corner.len() == 1 {
|
||||
let k = corner[0];
|
||||
let mine = ht > k.wt + 0.005 || ((ht - k.wt).abs() <= 0.005 && ox);
|
||||
return if mine { #{ ext: ht, post: true } } else { #{ ext: k.stop, post: false } };
|
||||
}
|
||||
#{ ext: ht, post: true }
|
||||
}
|
||||
@@ -249,6 +249,8 @@ wframe = "#c8ccd2"
|
||||
sill = "#9aa0a8"
|
||||
lshade = "#d9c9a0"
|
||||
roofr = "#8a4a3a"
|
||||
cobble = "#6c675f"
|
||||
capst = "#8c877e"
|
||||
|
||||
[palette.light]
|
||||
sky1 = "#a9d4fb"
|
||||
@@ -493,6 +495,8 @@ wframe = "#ffffff"
|
||||
sill = "#e6e2da"
|
||||
lshade = "#f2e6c8"
|
||||
roofr = "#b8613f"
|
||||
cobble = "#aaa195"
|
||||
capst = "#d8d2c6"
|
||||
|
||||
[materials]
|
||||
warn = "#e0412f"
|
||||
@@ -717,6 +721,8 @@ rug3 = "rug3"
|
||||
sill = "sill"
|
||||
lshade = "lshade"
|
||||
roofr = "roofr"
|
||||
cobble = "cobble"
|
||||
capst = "capst"
|
||||
doorw = "furnw"
|
||||
doorg = "door"
|
||||
f-oak = "oak"
|
||||
|
||||
@@ -81,6 +81,10 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
|
||||
8. Waypoint animation: a person or car moving along a path of waypoints
|
||||
9. ~~Build your own house~~ (done, see above)
|
||||
|
||||
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.
|
||||
|
||||
## Phase 2: richer editing
|
||||
|
||||
- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open
|
||||
|
||||
@@ -100,6 +100,22 @@ parts (`stdlib/house.rhai`, `stdlib/furniture.rhai`): `storey_of(z)`,
|
||||
`wall_top(w)`, `opening_at(o)`, `s.slab(a, b, th, z0, z1, mat)`,
|
||||
`s.chair_at(x, y, bx, by, mat)`, `s.office_chair(x, y, mat)`.
|
||||
|
||||
## Joints of walls and fences
|
||||
|
||||
`line_joint(o, e, same, stops)` (util.rhai) tells a line piece (centre line
|
||||
along local x, length `p.w`) how it meets others at its end `e` (-1 or 1):
|
||||
`#{ ext, post }`, how far it reaches past the end and whether it draws its end
|
||||
post. `same` are the types it joins with, `stops` the types it ends against;
|
||||
half thicknesses come from `line_ht(o)` (`p.t / 2`, for fences the design's).
|
||||
Alone a piece reaches half its thickness past the end; on a straight run the
|
||||
lower piece runs through and draws the shared post; at a corner the thicker
|
||||
piece (equal: the one along world x) runs through and the other stops at its
|
||||
face; at a T the stem stops at the face without an end post; against a `stops`
|
||||
piece it stops at the face and keeps its post. Pieces never overlap, which the
|
||||
depth sort needs. `fence` (designs `style`: mesh, wood, bars, half stone,
|
||||
cobble, brick) and the site `wall` use it; `house_wall` has its own version
|
||||
(`wall_ext`, house.rhai).
|
||||
|
||||
## House parts
|
||||
|
||||
`house_wall`, `window`, `door`, `floor`, `roof`, `stairs` and the furniture
|
||||
|
||||
@@ -62,7 +62,11 @@
|
||||
{"id": "plant-terrace", "type": "plant", "x": 10.6, "y": -4.0, "props": {"h": 1.4, "pot": "concb"}},
|
||||
{"id": "lamp-path", "type": "lamp", "x": 0.3, "y": -6.8, "props": {"h": 3.5}},
|
||||
{"id": "hedge-n", "type": "hedge", "x": 2, "y": 13.6, "props": {"w": 34, "d": 0.8, "h": 1.6}, "name": "Hedge"},
|
||||
{"id": "fence-e", "type": "fence", "x": 20.6, "y": 0.5, "rot": 90, "props": {"w": 27.5, "h": 1.4}, "name": "Fence"},
|
||||
{"id": "wall-front-a", "type": "fence", "name": "Front wall", "x": -14.0, "y": -7.2, "props": {"w": 5.2, "h": 1.2, "style": "brick"}},
|
||||
{"id": "wall-front-b", "type": "fence", "name": "Front wall", "x": -4.8, "y": -7.2, "props": {"w": 5.6, "h": 1.2, "style": "brick"}},
|
||||
{"id": "wall-front-c", "type": "fence", "name": "Front wall", "x": 10.3, "y": -7.2, "props": {"w": 20.6, "h": 1.2, "style": "brick"}, "info": "Brick, 1.2 m, with pillars; gaps for the drive and the front path"},
|
||||
{"id": "fence-w", "type": "fence", "name": "Garden fence west", "x": -16.6, "y": 2.95, "rot": 90, "props": {"w": 20.3, "h": 1.8, "style": "wood"}},
|
||||
{"id": "fence-e", "type": "fence", "name": "Garden fence east", "x": 20.6, "y": 2.95, "rot": 90, "props": {"w": 20.3, "h": 1.8, "style": "wood"}},
|
||||
{"id": "tree-1", "type": "tree", "x": -12.5, "y": 9.0, "props": {"r": 2.2}},
|
||||
{"id": "tree-2", "type": "tree", "x": 13.5, "y": 7.5, "props": {"r": 2.4}},
|
||||
{"id": "tree-3", "type": "tree", "x": 16.0, "y": -4.5, "props": {"r": 1.8}},
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ document.documentElement.setAttribute("data-theme",t);})();
|
||||
<h3>isokit Studio</h3>
|
||||
<table>
|
||||
<tr><th>Add</th><td>pick an object on the left, click on the map (Shift-click to place several)</td></tr>
|
||||
<tr><th>Draw</th><td>walls, roads, fences: drag along the map; floors, areas, buildings, roofs: drag a rectangle; Room: drag a rectangle for a floor with walls around it</td></tr>
|
||||
<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>
|
||||
|
||||
+120
-6
@@ -111,6 +111,97 @@
|
||||
return [{ pts: planePts(worldOf(sm.host, sm.u - sm.hw, 0), worldOf(sm.host, sm.u + sm.hw, 0), sm.z + z0, sm.z + z0 + h), cls: "st-plane" }];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ drawing runs of walls and fences
|
||||
// Line prefabs (walls, fences, hedges, roads) are drawn corner by corner. Corners snap onto the
|
||||
// ends and lines of the pieces already there, so the scripts join them (line_joint in util.rhai).
|
||||
var chain = null; // a run being drawn: { m, z, pts: [[x, y], ...] }
|
||||
// metres per screen pixel around a pointer event
|
||||
function pxM(e) { var a = S.view.toWorld(e.clientX, e.clientY, 0), b = S.view.toWorld(e.clientX + 10, e.clientY, 0); return Math.hypot(b[0] - a[0], b[1] - a[1]) / 10; }
|
||||
function linePieces(z) {
|
||||
return S.doc.objects.filter(function (o) { var m = prefab(o.type); return !o.hidden && m && m.shape === "line" && Math.abs((o.z || 0) - z) < 0.5; }).map(function (o) {
|
||||
var L = (+propOf(o, "w") || 0) / 2;
|
||||
return { o: o, a: worldOf(o, -L, 0), b: worldOf(o, L, 0), ax: qOf(o.rot) % 2 === 0 };
|
||||
});
|
||||
}
|
||||
// where a corner goes for pointer world point w: with `from` (the previous corner) the piece keeps
|
||||
// to x or y and its far end snaps onto an end or the line of a piece it meets (or the run's own
|
||||
// corners), else onto the grid; without, onto the nearest end, else onto a line, else the grid
|
||||
function snapCorner(w, z, from, R) {
|
||||
var pcs = linePieces(z), own = chain ? chain.pts : [], best = null, i;
|
||||
if (!from) {
|
||||
pcs.forEach(function (p) {
|
||||
[p.a, p.b].forEach(function (q) { var d = Math.hypot(q[0] - w[0], q[1] - w[1]); if (d < R && (!best || d < best.d)) best = { d: d, p: q }; });
|
||||
});
|
||||
if (best) return [r3(best.p[0]), r3(best.p[1])];
|
||||
pcs.forEach(function (p) {
|
||||
var l = localOf(p.o, w[0], w[1]), L = (+propOf(p.o, "w") || 0) / 2;
|
||||
if (Math.abs(l[0]) <= L && Math.abs(l[1]) < R && (!best || Math.abs(l[1]) < best.d)) best = { d: Math.abs(l[1]), p: worldOf(p.o, Math.max(-L, Math.min(L, snap(l[0]))), 0) };
|
||||
});
|
||||
return best ? [r3(best.p[0]), r3(best.p[1])] : [snap(w[0]), snap(w[1])];
|
||||
}
|
||||
var ax = Math.abs(w[0] - from[0]) >= Math.abs(w[1] - from[1]), k = ax ? 0 : 1, lk = 1 - k;
|
||||
var f = w[k], fix = from[lk], cands = [];
|
||||
pcs.forEach(function (p) {
|
||||
if (p.ax === ax) {
|
||||
// in line with this piece: its ends
|
||||
if (Math.abs(p.a[lk] - fix) < 0.01) { cands.push(p.a[k]); cands.push(p.b[k]); }
|
||||
} else {
|
||||
// across this piece: its centre line, where it spans our line
|
||||
var lo = Math.min(p.a[lk], p.b[lk]), hi = Math.max(p.a[lk], p.b[lk]);
|
||||
if (fix >= lo - 1e-6 && fix <= hi + 1e-6) cands.push(p.a[k]);
|
||||
}
|
||||
});
|
||||
for (i = 0; i < own.length; i++) if (Math.abs(own[i][lk] - fix) < 0.01) cands.push(own[i][k]);
|
||||
cands.forEach(function (c) { var d = Math.abs(c - f); if (Math.abs(c - from[k]) > 0.05 && d < R && (!best || d < best.d)) best = { d: d, c: c }; });
|
||||
var out = [0, 0];
|
||||
out[k] = r3(best ? best.c : snap(f)); out[lk] = fix;
|
||||
return out;
|
||||
}
|
||||
// the preview of one piece from p to q: its footprint, and its face when it has a height
|
||||
function pieceGhost(m, p, q, z) {
|
||||
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, ax = Math.abs(q[1] - p[1]) < 1e-9;
|
||||
var x0 = Math.min(p[0], q[0]), x1 = Math.max(p[0], q[0]), y0 = Math.min(p[1], q[1]), y1 = Math.max(p[1], q[1]);
|
||||
var out = [{ pts: ax ? rectPts(x0, y0 - th / 2, x1, y0 + th / 2, z) : rectPts(x0 - th / 2, y0, x0 + th / 2, y1, z) }];
|
||||
if (h > 0) out.push({ pts: planePts(p, q, z, z + h), cls: "st-plane" });
|
||||
return out;
|
||||
}
|
||||
function cornerMark(p, z) { return { pts: rectPts(p[0] - 0.18, p[1] - 0.18, p[0] + 0.18, p[1] + 0.18, z), cls: "st-plane" }; }
|
||||
function runLength(pts) { var L = 0; for (var i = 1; i < pts.length; i++) L += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); return L; }
|
||||
function drawChain(e) {
|
||||
var c = chain, last = c.pts[c.pts.length - 1], q = snapCorner(worldAt(e, c.z), c.z, last, 12 * pxM(e)), polys = [], i;
|
||||
for (i = 1; i < c.pts.length; i++) Array.prototype.push.apply(polys, pieceGhost(c.m, c.pts[i - 1], c.pts[i], c.z));
|
||||
if (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05) Array.prototype.push.apply(polys, pieceGhost(c.m, last, q, c.z));
|
||||
polys.push(cornerMark(c.pts[0], c.z));
|
||||
var closes = c.pts.length > 2 && Math.hypot(q[0] - c.pts[0][0], q[1] - c.pts[0][1]) < 1e-6;
|
||||
var n = c.pts.length - 1 + (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05 ? 1 : 0);
|
||||
ghost(polys, c.m.label + ": " + n + " piece" + (n === 1 ? "" : "s") + ", " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m · " +
|
||||
(closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") + " · Backspace undoes a corner · Esc cancels");
|
||||
}
|
||||
// the pieces of a run as add ops (pieces longer than the prefab allows are split)
|
||||
function runOps(m, pts, z) {
|
||||
var ops = [], max = +((m.props || []).filter(function (d) { return d.key === "w"; })[0] || {}).max || 1e9;
|
||||
for (var i = 1; i < pts.length; i++) {
|
||||
var p = pts[i - 1], q = pts[i], L = Math.hypot(q[0] - p[0], q[1] - p[1]), n = Math.ceil(L / max - 1e-9), ax = Math.abs(q[1] - p[1]) < 1e-9;
|
||||
if (L < 0.05) continue;
|
||||
for (var j = 0; j < n; j++) {
|
||||
var t = (j + 0.5) / n, add = { op: "add", type: m.type, x: r3(p[0] + (q[0] - p[0]) * t), y: r3(p[1] + (q[1] - p[1]) * t), rot: ax ? 0 : 90, props: { w: clampProp(m, "w", L / n) } };
|
||||
if (z) add.z = z;
|
||||
ops.push(add);
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
function finishChain(keep) {
|
||||
var c = chain; chain = null; ghost(null);
|
||||
if (!c) return;
|
||||
var ops = runOps(c.m, c.pts, c.z);
|
||||
if (ops.length) {
|
||||
var L = runLength(c.pts);
|
||||
opList(ops).then(function (r) { if (r) toast(c.m.label + ": " + ops.length + " piece" + (ops.length === 1 ? "" : "s") + ", " + L.toFixed(1) + " m · Ctrl+Z takes the run back"); });
|
||||
}
|
||||
if (keep) setPlacing(c.m.type); else setPlacing(null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ server state
|
||||
function takeState(r) {
|
||||
if (r.doc) S.doc = r.doc;
|
||||
@@ -182,7 +273,8 @@
|
||||
down: function (e, hit) {
|
||||
if (S.placing) {
|
||||
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z);
|
||||
tool = { m: pm, z: z, a: [snap(a[0]), snap(a[1])], b: null };
|
||||
a = pm.shape === "line" ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e)) : [snap(a[0]), snap(a[1])];
|
||||
tool = { m: pm, z: z, a: a, b: null };
|
||||
return true;
|
||||
}
|
||||
if (!hit.obj) return false;
|
||||
@@ -226,7 +318,8 @@
|
||||
} 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(); 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 };
|
||||
@@ -244,6 +337,8 @@
|
||||
// the hover preview while placing a window or door: where on which wall it goes
|
||||
function hoverPlacing(e) {
|
||||
var m = pmeta(S.placing);
|
||||
if (chain) { drawChain(e); return; }
|
||||
if (m && m.shape === "line") { var z0 = placeZ(m); ghost([cornerMark(snapCorner(worldAt(e, z0), z0, null, 12 * pxM(e)), z0)]); return; }
|
||||
if (!m || !m.mount || !m.mount.length) return;
|
||||
var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w"));
|
||||
ghost(sm ? mountGhost(m, sm) : null);
|
||||
@@ -261,7 +356,8 @@
|
||||
}
|
||||
function drawTool(e) {
|
||||
var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = [];
|
||||
t.b = [snap(w[0]), snap(w[1])];
|
||||
if (chain) { drawChain(e); return; }
|
||||
t.b = m.shape === "line" ? snapCorner(w, z, t.a, 12 * pxM(e)) : [snap(w[0]), snap(w[1])];
|
||||
if (m.shape !== "line" && m.shape !== "rect") return;
|
||||
var g = toolGeom(t);
|
||||
if (g.line) {
|
||||
@@ -283,6 +379,22 @@
|
||||
function finishTool(t, e) {
|
||||
var m = t.m, keep = e.shiftKey, g = t.b ? toolGeom(t) : null, add;
|
||||
var at = worldAt(e, t.z), p = [snap(at[0]), snap(at[1])];
|
||||
if (m.shape === "line") {
|
||||
// a run: a click starts it, each click (or drag) adds a corner, the first corner closes it
|
||||
if (chain) {
|
||||
var last = chain.pts[chain.pts.length - 1], q = snapCorner(at, t.z, last, 12 * pxM(e));
|
||||
if (Math.hypot(q[0] - last[0], q[1] - last[1]) < 0.05) return;
|
||||
chain.pts.push(q);
|
||||
if (chain.pts.length > 3 && Math.hypot(q[0] - chain.pts[0][0], q[1] - chain.pts[0][1]) < 1e-6) finishChain(keep);
|
||||
else drawChain(e);
|
||||
return;
|
||||
}
|
||||
if (!g || !g.ok) { chain = { m: m, z: t.z, pts: [t.a] }; drawChain(e); return; }
|
||||
var ops = runOps(m, [t.a, t.b], t.z);
|
||||
opList(ops).then(function (r) { if (r) toast(m.label + " added" + (keep ? "" : " · Shift to draw several")); });
|
||||
if (!keep) setPlacing(null);
|
||||
return;
|
||||
}
|
||||
if (m === ROOM) {
|
||||
if (!g || !g.ok) { toast("Drag a rectangle on the map to draw a room"); setPlacing(m.type); return; }
|
||||
var fm = prefab("floor"), ops = roomWalls(g.x0, g.y0, g.x1, g.y1, t.z);
|
||||
@@ -326,7 +438,7 @@
|
||||
return out;
|
||||
}
|
||||
function setPlacing(kind) {
|
||||
S.placing = kind; tool = null;
|
||||
S.placing = kind; tool = null; chain = null;
|
||||
if (S.view) ghost(null);
|
||||
document.querySelector(".st-centre").classList.toggle("st-placing", !!kind);
|
||||
Array.prototype.forEach.call(document.querySelectorAll(".st-tile"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-type") === kind)); });
|
||||
@@ -335,7 +447,7 @@
|
||||
var m = pmeta(kind), lab = m ? m.label : kind;
|
||||
h.textContent = m === ROOM ? "Drag a rectangle on the map: a room (floor and walls) · Esc to stop"
|
||||
: m && m.mount && m.mount.length ? "Click on a wall to place: " + lab + " · Esc to stop"
|
||||
: m && m.shape === "line" ? "Drag along the map to draw: " + lab + " (a click gives the default length) · Esc to stop"
|
||||
: m && m.shape === "line" ? "Drag along the map to draw one " + lab + ", or click corner after corner for a run (double-click or Enter ends it, the first corner closes it); ends snap onto walls and fences · Esc to stop"
|
||||
: m && m.shape === "rect" ? "Drag a rectangle on the map: " + lab + " (a click gives the default size) · Esc to stop"
|
||||
: "Click on the map to place: " + lab + " · Esc to stop";
|
||||
h.hidden = false;
|
||||
@@ -397,7 +509,9 @@
|
||||
var k = e.key, ctrl = e.ctrlKey || e.metaKey;
|
||||
if (ctrl && (k === "z" || k === "Z")) { if (e.shiftKey) redo(); else undo(); return true; }
|
||||
if (ctrl && (k === "y" || k === "Y")) { redo(); return true; }
|
||||
if (k === "Escape") { if (S.placing) setPlacing(null); else select(null); return true; }
|
||||
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 (!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; }
|
||||
|
||||
@@ -198,6 +198,61 @@ with sync_playwright() as p:
|
||||
pg.evaluate("__studio.select(null)")
|
||||
pg.wait_for_timeout(400)
|
||||
|
||||
# a fence run drawn corner by corner: a click starts it, the first corner closes it, as one undo step;
|
||||
# the pieces join at their corners; a dragged fence snaps its ends onto the fences it meets
|
||||
pg.evaluate("__studio.select(null)")
|
||||
pg.evaluate("__studio.view.focus([29, -9.5, 0], 3)")
|
||||
pg.wait_for_timeout(400)
|
||||
n3 = len(doc()["objects"])
|
||||
pg.locator('.st-tile[data-type="fence"]').click()
|
||||
for x, y in [(24, -12), (32, -12.15), (32.1, -7), (24, -6.9)]:
|
||||
pg.mouse.click(*at(x, y))
|
||||
pg.wait_for_timeout(150)
|
||||
pg.mouse.move(*at(24.1, -11.9))
|
||||
pg.wait_for_timeout(150)
|
||||
check("4 pieces" in pg.locator("#hint").inner_text() and "close" in pg.locator("#hint").inner_text(),
|
||||
"drawing a run shows its pieces and offers to close it: " + pg.locator("#hint").inner_text()[:60])
|
||||
pg.screenshot(path=str(OUT / "studio-fence-run.jpg"), type="jpeg", quality=80)
|
||||
pg.mouse.click(*at(24.1, -11.9))
|
||||
pg.wait_for_timeout(900)
|
||||
run = doc()["objects"][n3:]
|
||||
got = sorted((o["type"], o["x"], o["y"], o.get("rot", 0), o["props"]["w"]) for o in run)
|
||||
want = sorted([("fence", 28, -12, 0, 8), ("fence", 32, -9.5, 90, 5), ("fence", 28, -7, 0, 8), ("fence", 24, -9.5, 90, 5)])
|
||||
check(got == want, f"clicking the first corner closes the run: four fences {got}")
|
||||
rv = get("/api/render?level=site&rot=1")
|
||||
south = next((o["id"] for o in run if o["y"] == -12), None)
|
||||
west = next((o["id"] for o in run if o["x"] == 24), None)
|
||||
sb, wb = rv["boxes"].get(south, [0] * 6), rv["boxes"].get(west, [0] * 6)
|
||||
check(abs(sb[0] - 23.96) < 1e-6 and abs(sb[3] - 32.04) < 1e-6 and abs(wb[1] + 11.96) < 1e-6,
|
||||
f"the corners join (south runs through, west stops at it): {sb[0]:.2f}..{sb[3]:.2f}, {wb[1]:.2f}")
|
||||
pg.locator("#undo").click()
|
||||
pg.wait_for_timeout(700)
|
||||
check(len(doc()["objects"]) == n3, "one undo takes the whole run back")
|
||||
pg.locator("#redo").click()
|
||||
pg.wait_for_timeout(700)
|
||||
# a spur dragged from the run's east fence to the east boundary fence: both ends snap onto them
|
||||
pg.locator('.st-tile[data-type="fence"]').click()
|
||||
a, b2 = at(32.25, -9.6), at(34.3, -9.6)
|
||||
pg.mouse.move(*a)
|
||||
pg.mouse.down()
|
||||
for i in range(1, 11):
|
||||
pg.mouse.move(a[0] + (b2[0] - a[0]) * i / 10, a[1] + (b2[1] - a[1]) * i / 10)
|
||||
pg.wait_for_timeout(15)
|
||||
pg.mouse.up()
|
||||
pg.wait_for_timeout(800)
|
||||
sp = doc()["objects"][-1]
|
||||
check(sp["type"] == "fence" and abs(sp["x"] - 33.3) < 1e-6 and abs(sp["props"]["w"] - 2.6) < 1e-6,
|
||||
f"a dragged fence snaps its ends onto the fences it meets: x {sp['x']}, w {sp['props']['w']}")
|
||||
# the design is a choice in the properties
|
||||
pg.locator(f'#objects li[data-id="{south}"]').click()
|
||||
pg.wait_for_timeout(300)
|
||||
pg.locator('#right select[data-prop="style"]').select_option("brick")
|
||||
pg.wait_for_timeout(800)
|
||||
check(obj(south)["props"].get("style") == "brick", "a fence's design is picked in its properties")
|
||||
pg.evaluate("__studio.select(null)")
|
||||
pg.wait_for_timeout(500)
|
||||
pg.screenshot(path=str(OUT / "studio-fences.jpg"), type="jpeg", quality=80)
|
||||
|
||||
# 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)")
|
||||
|
||||
@@ -355,7 +355,12 @@
|
||||
e.preventDefault();
|
||||
zoomClient(Math.exp(-e.deltaY * 0.004), e.clientX, e.clientY);
|
||||
}, { passive: false });
|
||||
svg.addEventListener("dblclick", function (e) { e.preventDefault(); zoomClient(1.8, e.clientX, e.clientY); });
|
||||
svg.addEventListener("dblclick", function (e) {
|
||||
e.preventDefault();
|
||||
// an editor tool may take the double click (it finishes a drawn run)
|
||||
if (opts.interact && call(opts.interact.dblclick, e)) return;
|
||||
zoomClient(1.8, e.clientX, e.clientY);
|
||||
});
|
||||
svg.addEventListener("keydown", function (e) {
|
||||
if (opts.onKey && call(opts.onKey, e)) { e.preventDefault(); return; }
|
||||
var k = e.key;
|
||||
|
||||
Reference in new issue
Block a user