diff --git a/README.md b/README.md index 12ace5d..c8c2fac 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index f0101b7..f4a64ae 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -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, diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index f19ff3f..cc3db8d 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -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(); diff --git a/crates/isokit-script/stdlib/ground.rhai b/crates/isokit-script/stdlib/ground.rhai index c80a676..53c799b 100644 --- a/crates/isokit-script/stdlib/ground.rhai +++ b/crates/isokit-script/stdlib/ground.rhai @@ -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() { diff --git a/crates/isokit-script/stdlib/house.rhai b/crates/isokit-script/stdlib/house.rhai index 9472d08..027a25a 100644 --- a/crates/isokit-script/stdlib/house.rhai +++ b/crates/isokit-script/stdlib/house.rhai @@ -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 diff --git a/crates/isokit-script/stdlib/util.rhai b/crates/isokit-script/stdlib/util.rhai index 451120e..aaf3cb5 100644 --- a/crates/isokit-script/stdlib/util.rhai +++ b/crates/isokit-script/stdlib/util.rhai @@ -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 } +} diff --git a/crates/isokit-theme/themes/default.toml b/crates/isokit-theme/themes/default.toml index 7efdc0e..964e7e3 100644 --- a/crates/isokit-theme/themes/default.toml +++ b/crates/isokit-theme/themes/default.toml @@ -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" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b7af77f..d3ede08 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/SCRIPTING.md b/docs/SCRIPTING.md index 234173d..ff13149 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -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 diff --git a/examples/house/scene.json b/examples/house/scene.json index ac5203a..11d0a86 100644 --- a/examples/house/scene.json +++ b/examples/house/scene.json @@ -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}}, diff --git a/studio/index.html b/studio/index.html index 16f2610..c6c8e9a 100644 --- a/studio/index.html +++ b/studio/index.html @@ -62,7 +62,7 @@ document.documentElement.setAttribute("data-theme",t);})();

isokit Studio

- + diff --git a/studio/studio.js b/studio/studio.js index 60f2606..d99a307 100644 --- a/studio/studio.js +++ b/studio/studio.js @@ -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; } diff --git a/tools/check_studio.py b/tools/check_studio.py index 0f79b5f..b337898 100644 --- a/tools/check_studio.py +++ b/tools/check_studio.py @@ -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)") diff --git a/viewer/isokit-view.js b/viewer/isokit-view.js index 5089a87..496732d 100644 --- a/viewer/isokit-view.js +++ b/viewer/isokit-view.js @@ -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;
Addpick an object on the left, click on the map (Shift-click to place several)
Drawwalls, roads, fences: drag along the map; floors, areas, buildings, roofs: drag a rectangle; Room: drag a rectangle for a floor with walls around it
Drawwalls, 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
Windows, doorsclick 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
StoreysGF, 1F, … cut a house open; on 1F and up, new walls, floors and furniture go onto that storey (3 m each, parameter storey_h)
Selectclick it on the map or in the list