From 96d472625f08cb755aab8ab2f92bc2f5d9f396ef Mon Sep 17 00:00:00 2001 From: grabowski Date: Sun, 25 Oct 2026 13:22:59 +0700 Subject: [PATCH] Diagonals: streets, walls, fences, hedges and house walls at any angle Object frames turn by any angle (quarter turns stay exact, so scenes along the axes render byte for byte as before); a box in a turned frame is a prism with its own corners. Rotation is kept as given (normalised to 0..360), no longer snapped to quarter turns. Depth sort: sprites whose plan outline is not their box carry their convex outline; when boxes overlap, an outline edge that separates two sprites orders them. A long diagonal wall sorts right against both sides. Joints (line_meet, end_cut, cut_box in util.rhai): where a piece off the axes is involved, corners are mitred and T-junctions slanted; fences put a corner post or pillar in its own sprite and mitre stone bases; Y joints let the lowest id run through. Streets: every end part is a cut e + k*w, so side streets come in at any angle (slanted sidewalk gaps), two streets meet in a mitre with seam patches; fixes a curve's outer sidewalk that was skipped when its kerb and back lines had different point counts. Studio: runs and drags keep to x/y or snap to 45 degree diagonals, Ctrl gives any angle; a road run turning 45 degrees gets a 45 degree curve; Rotation is a number box. Streets demo: a diagonal lane with a hedge. MCP guidance, SCRIPTING/README/ROADMAP. Tests: Rust 76, Studio and web checks green. --- README.md | 10 +- crates/isokit-core/src/render.rs | 16 +- crates/isokit-core/src/sort.rs | 154 ++++++++++++++++- crates/isokit-core/src/sprite.rs | 83 ++++++++- crates/isokit-project/src/mcp.rs | 13 +- crates/isokit-project/src/ops.rs | 34 +++- crates/isokit-script/src/doc.rs | 2 +- crates/isokit-script/src/tests.rs | 156 +++++++++++++++++ crates/isokit-script/stdlib/ground.rhai | 147 +++++++++++++--- crates/isokit-script/stdlib/house.rhai | 29 +++- crates/isokit-script/stdlib/nature.rhai | 10 +- crates/isokit-script/stdlib/streets.rhai | 210 +++++++++++++++-------- crates/isokit-script/stdlib/util.rhai | 132 +++++++++++++- docs/ROADMAP.md | 15 +- docs/SCRIPTING.md | 46 ++++- examples/streets/scene.json | 4 +- studio/studio.js | 116 +++++++++---- tools/check_studio.py | 52 ++++++ 18 files changed, 1051 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 0491e66..f8493c4 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,11 @@ Try the demos: `isokit studio examples/demo` (a works site), | Do | How | |---|---| | 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; a road run gets a curve in each corner. 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; a road run gets a curve in each corner. Pieces keep to x or y, or go diagonal at 45° once the pointer is more than 22.5° off; hold Ctrl for any angle Floors, areas, buildings, roofs, rugs: drag a rectangle | | 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 ⟲ ⟳ | +| Turn | R / Shift+R or ⟲ ⟳ (quarter turns), or type any angle in Rotation | | Edit | the right panel: size, storeys, colours, solar, tooltip text, label, levels | | Copy / delete | Ctrl+D / Delete; Ctrl+Z / Ctrl+Y undo and redo | | Look | drag the ground to pan, Ctrl+wheel or pinch to zoom, ↺ ↻ rotate, Site / GF / 1F levels | @@ -210,6 +210,12 @@ give-way lines for traffic on the left or right. Roads meet by their centre lines, so put the ends on each other. `examples/streets` (Streets demo in the web Studio) is a small town quarter. +Diagonals: roads, walls, fences, hedges and house walls run at any angle. +Where they meet at an angle the corners are mitred, side streets come in at a +slant (the sidewalk opens along them), fence corners get a post or pillar, and +a road run turning 45° gets a 45° curve. Windows and doors sit on diagonal +house walls like on any other. + Animations (fans, smoke, fish, traffic) step on about ten times a second by default ("light"): running CSS animations repaint the whole drawing every frame, which keeps a slow laptop busy. The ▷ button in the view toolbar switches diff --git a/crates/isokit-core/src/render.rs b/crates/isokit-core/src/render.rs index 9745625..41fe835 100644 --- a/crates/isokit-core/src/render.rs +++ b/crates/isokit-core/src/render.rs @@ -563,6 +563,15 @@ impl Ctx<'_> { } } +/// The sprite's convex plan outline in view space when it is not its box. +pub fn hull(s: &Sprite, p: Proj, bb: &Bb) -> Option { + if s.ext.len() < 3 { + return None; + } + let pts: Vec<[f64; 2]> = s.ext.iter().map(|e| p.r(e[0], e[1])).collect(); + sort::hull_of(&pts, bb) +} + /// Sort box of a sprite in a rotation: (view box, screen box, has extent). pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) { let mut bb: Bb = [1e9, 1e9, -1e9, -1e9, 1e9, -1e9]; @@ -623,7 +632,12 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered { } let idx: Vec = if layer.opts.sort { let pairs: Vec<(Bb, Sb)> = bx.iter().map(|(b, s, _)| (*b, *s)).collect(); - let o = sort::sort(&pairs, layer.opts.zfirst); + let hulls: Vec> = live + .iter() + .zip(&bx) + .map(|(s, (b, _, _))| hull(s, p, b)) + .collect(); + let o = sort::sort_hulls(&pairs, &hulls, layer.opts.zfirst); cycles += o.cycles; o.order } else { diff --git a/crates/isokit-core/src/sort.rs b/crates/isokit-core/src/sort.rs index 5008e0a..616f1c3 100644 --- a/crates/isokit-core/src/sort.rs +++ b/crates/isokit-core/src/sort.rs @@ -3,15 +3,61 @@ //! Each sprite has a view-space box `bb = [vx0, vy0, vx1, vy1, z0, z1]` and a //! screen box `sb = [x0, y0, x1, y1]`. Only pairs whose screen boxes overlap //! are compared; a pair is ordered when the boxes are separated along x, -//! then y, then z (or z first under ground). The resulting graph is drawn in -//! topological order, choosing the smallest centre key among the ready -//! sprites; a cycle is broken at the sprite with the fewest unmet edges. +//! then y, then z (or z first under ground). Sprites whose plan outline is not +//! their box (pieces turned off the axes, mitred ends) also carry their convex +//! outline (`Hull`): when the boxes overlap, an outline edge that separates +//! the two orders them (the one on the viewer's side of it goes later), so a +//! long diagonal wall sorts right against everything on either side. The +//! resulting graph is drawn in topological order, choosing the smallest centre +//! key among the ready sprites; a cycle is broken at the sprite with the fewest +//! unmet edges. pub type Bb = [f64; 6]; pub type Sb = [f64; 4]; +/// Convex plan outline in view space, counter-clockwise. +pub type Hull = Vec<[f64; 2]>; -/// -1: a is drawn before b, 1: after, 0: no separating axis (fall back to the key). -pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 { +/// The convex outline of view-space plan points, or None when it is just +/// the box `bb` (nothing to add to the box test). +pub fn hull_of(pts: &[[f64; 2]], bb: &Bb) -> Option { + let mut p: Vec<[f64; 2]> = pts.to_vec(); + p.sort_by(|a, b| a[0].total_cmp(&b[0]).then(a[1].total_cmp(&b[1]))); + p.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9); + if p.len() < 2 { + return None; + } + let cross = |o: [f64; 2], a: [f64; 2], b: [f64; 2]| { + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + }; + let mut h: Vec<[f64; 2]> = Vec::with_capacity(p.len() + 1); + for pass in 0..2 { + let start = h.len(); + let it: Box> = if pass == 0 { + Box::new(p.iter()) + } else { + Box::new(p.iter().rev()) + }; + for &q in it { + while h.len() >= start + 2 && cross(h[h.len() - 2], h[h.len() - 1], q) <= 1e-12 { + h.pop(); + } + h.push(q); + } + h.pop(); + } + let e = 1e-6; + let on_box = |q: &[f64; 2]| { + ((q[0] - bb[0]).abs() < e || (q[0] - bb[2]).abs() < e) + && ((q[1] - bb[1]).abs() < e || (q[1] - bb[3]).abs() < e) + }; + if h.len() == 4 && h.iter().all(on_box) { + return None; + } + (h.len() >= 2).then_some(h) +} + +/// Is a's box separated from b's: -1 a is drawn before b, 1 after, 0 no axis. +fn sep_bb(a: &Bb, b: &Bb, zf: bool) -> i32 { let e = 1e-6; if zf { if a[5] <= b[4] + e { @@ -39,6 +85,11 @@ pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 { if b[5] <= a[4] + e { return 1; } + 0 +} + +/// Order by centre key: -1 a first, 1 after, 0 equal. +fn cmp_key(a: &Bb, b: &Bb) -> i32 { let ka = a[0] + a[2] + a[1] + a[3]; let kb = b[0] + b[2] + b[1] + b[3]; if ka < kb { @@ -50,6 +101,53 @@ pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 { } } +/// -1: a is drawn before b, 1: after, 0: no separating axis and equal keys. +pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 { + match sep_bb(a, b, zf) { + 0 => cmp_key(a, b), + c => c, + } +} + +/// Is outline a separated from outline b along one of their edge normals? +/// The one on the viewer's side (towards +x +y in view space) goes later. +fn sep_hull(a: &[[f64; 2]], b: &[[f64; 2]]) -> i32 { + let proj = |p: &[[f64; 2]], d: [f64; 2]| { + p.iter().fold((f64::MAX, f64::MIN), |(lo, hi), q| { + let v = q[0] * d[0] + q[1] * d[1]; + (lo.min(v), hi.max(v)) + }) + }; + for poly in [a, b] { + let n = poly.len(); + for i in 0..n { + let (p, q) = (poly[i], poly[(i + 1) % n]); + let (dx, dy) = (q[0] - p[0], q[1] - p[1]); + let l = (dx * dx + dy * dy).sqrt(); + if l < 1e-9 { + continue; + } + let d = [dy / l, -dx / l]; + let s = d[0] + d[1]; + if s.abs() < 1e-6 { + continue; + } + let ((alo, ahi), (blo, bhi)) = (proj(a, d), proj(b, d)); + if ahi <= blo + 1e-6 { + return if s > 0.0 { -1 } else { 1 }; + } + if bhi <= alo + 1e-6 { + return if s > 0.0 { 1 } else { -1 }; + } + } + } + 0 +} + +fn rect_of(b: &Bb) -> Hull { + vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] +} + /// Result of [`sort`]: the draw order and how many times a cycle had to be broken. #[derive(Debug, Default)] pub struct Order { @@ -58,7 +156,14 @@ pub struct Order { } pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order { + sort_hulls(boxes, &[], zf) +} + +/// [`sort`] with the outlines of the sprites that have one (`hulls[i]`; +/// missing entries count as None). +pub fn sort_hulls(boxes: &[(Bb, Sb)], hulls: &[Option], zf: bool) -> Order { let n = boxes.len(); + let hull = |i: usize| hulls.get(i).and_then(|h| h.as_ref()); let mut adj: Vec> = vec![vec![]; n]; let mut indeg = vec![0usize; n]; let mut done = vec![false; n]; @@ -73,7 +178,16 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order { if asb[2] <= bsb[0] || bsb[2] <= asb[0] || asb[3] <= bsb[1] || bsb[3] <= asb[1] { continue; } - match cmp_bb(ab, bb, zf) { + let mut c = sep_bb(ab, bb, zf); + if c == 0 && (hull(i).is_some() || hull(j).is_some()) { + let ha = hull(i).cloned().unwrap_or_else(|| rect_of(ab)); + let hb = hull(j).cloned().unwrap_or_else(|| rect_of(bb)); + c = sep_hull(&ha, &hb); + } + if c == 0 { + c = cmp_key(ab, bb); + } + match c { c if c < 0 => { adj[i].push(j); indeg[j] += 1; @@ -171,4 +285,32 @@ mod tests { let hi = bx(1.0, 1.0, 3.0, 3.0, -1.0, 0.0); assert_eq!(sort(&[hi, lo], true).order, vec![1, 0]); } + + #[test] + fn outlines_order_what_lies_either_side_of_a_diagonal_wall() { + // a wall 0.3 m thick from (0, 10) to (10, 0); its box spans the whole square + let wall = vec![[0.0, 9.8], [9.8, 0.0], [10.2, 0.0], [0.2, 10.0]]; + let wb = [0.0, 0.0, 10.2, 10.0, 0.0, 2.0]; + let far = [2.0, 2.0, 3.0, 3.0, 0.0, 1.0]; // behind it (smaller x + y) + let near = [7.0, 7.0, 8.0, 8.0, 0.0, 1.0]; // in front + let s = [0.0, 0.0, 10.0, 10.0]; + let hw = hull_of(&wall, &wb).expect("a turned wall has an outline"); + assert!( + hull_of(&rect_of(&far), &far).is_none(), + "a box is just its box" + ); + // both boxes overlap the wall's box; its outline puts the far one first, the near one last + let o = sort_hulls( + &[(near, s), (wb, s), (far, s)], + &[None, Some(hw), None], + false, + ); + assert_eq!(o.order, vec![2, 1, 0]); + assert_eq!(o.cycles, 0); + // past the wall's end but behind its line: still drawn before it + assert_eq!( + sep_hull(&rect_of(&[10.5, -3.0, 11.0, -2.0, 0.0, 1.0]), &wall), + -1 + ); + } } diff --git a/crates/isokit-core/src/sprite.rs b/crates/isokit-core/src/sprite.rs index efbf328..cdc7015 100644 --- a/crates/isokit-core/src/sprite.rs +++ b/crates/isokit-core/src/sprite.rs @@ -9,25 +9,44 @@ use crate::prim::{Fish, Prim, Style, Zs, P2, P3}; use crate::proj::C; use serde::{Deserialize, Serialize}; -/// Placement frame: translate by (x, y, z) after turning `q` quarter turns -/// counter-clockwise (seen from above). +/// Placement frame: translate by (x, y, z) after turning counter-clockwise +/// (seen from above): `q` quarter turns, or by any angle when `cs` holds its +/// cosine and sine (diagonal streets and walls). Quarter turns stay exact. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Xf { pub x: f64, pub y: f64, pub z: f64, pub q: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cs: Option<[f64; 2]>, } impl Xf { - /// Frame at (x, y, z) turned by `deg` (rounded to a multiple of 90). + /// Frame at (x, y, z) turned by `deg`; within 0.001 degrees of a + /// multiple of 90 it is that quarter turn exactly. pub fn new(x: f64, y: f64, z: f64, deg: f64) -> Self { - let q = ((deg / 90.0).round() as i64).rem_euclid(4) as u8; - Xf { x, y, z, q } + let qf = (deg / 90.0).round(); + let q = (qf as i64).rem_euclid(4) as u8; + let cs = if (deg - qf * 90.0).abs() < 1e-3 || !deg.is_finite() { + None + } else { + let a = deg.to_radians(); + Some([a.cos(), a.sin()]) + }; + Xf { x, y, z, q, cs } + } + /// Does the frame keep to the world axes (a multiple of 90 degrees)? + #[inline] + pub fn axis(&self) -> bool { + self.cs.is_none() } /// Turn a plan vector into the world. #[inline] pub fn v(&self, x: f64, y: f64) -> P2 { + if let Some([c, s]) = self.cs { + return [c * x - s * y, s * x + c * y]; + } match self.q { 0 => [x, y], 1 => [-y, x], @@ -38,6 +57,9 @@ impl Xf { /// World plan vector back into the frame. #[inline] pub fn v_inv(&self, x: f64, y: f64) -> P2 { + if let Some([c, s]) = self.cs { + return [c * x + s * y, -s * x + c * y]; + } match self.q { 0 => [x, y], 1 => [y, -x], @@ -59,8 +81,19 @@ impl Xf { pub fn z(&self, z: f64) -> f64 { z + self.z } - /// An axis-aligned rectangle in the frame as a world rectangle. + /// An axis-aligned rectangle in the frame as a world rectangle (its + /// bounding rectangle when the frame is turned off the axes). pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> [f64; 4] { + if !self.axis() { + let p = self.pts(&rect(x0, y0, x1, y1)); + let f = |k: usize, m: fn(f64, f64) -> f64| p.iter().map(|q| q[k]).fold(p[0][k], m); + return [ + f(0, f64::min), + f(1, f64::min), + f(0, f64::max), + f(1, f64::max), + ]; + } let a = self.p(x0, y0); let b = self.p(x1, y1); [ @@ -188,9 +221,16 @@ impl Sprite { z1: f64, st: Style, ) -> &mut Self { - let r = self.xf.rect(x0, y0, x1, y1); let (z0, z1) = (self.xf.z(z0), self.xf.z(z1)); - let pts = rect(r[0], r[1], r[2], r[3]); + let pts = if self.xf.axis() { + let r = self.xf.rect(x0, y0, x1, y1); + rect(r[0], r[1], r[2], r[3]) + } else { + // a turned frame: the box's own corners, counter-clockwise + let (a, b) = (x0.min(x1), x0.max(x1)); + let (c, d) = (y0.min(y1), y0.max(y1)); + self.xf.pts(&rect(a, c, b, d)) + }; self.ext_world(&pts, z0); self.ext_world(&pts, z1); self.push(Prim::Prism { @@ -686,9 +726,34 @@ mod tests { assert_eq!(b, [9.0, 18.0, 1.0, 11.0, 22.0, 4.0]); } + #[test] + fn frames_turn_by_any_angle() { + let f = Xf::new(1.0, 2.0, 0.0, 45.0); + assert!(!f.axis() && Xf::new(0.0, 0.0, 0.0, 90.0000001).axis()); + let w = f.p(2f64.sqrt(), 0.0); + assert!( + (w[0] - 2.0).abs() < 1e-12 && (w[1] - 3.0).abs() < 1e-12, + "{w:?}" + ); + // a box turned 45 degrees keeps its own four corners + let mut s = Sprite::new("a", "").with_frame(Xf::new(0.0, 0.0, 0.0, 45.0)); + s.cuboid(-1.0, -0.1, 1.0, 0.1, 0.0, 1.0, Style::mat("x")); + match &s.prims[0] { + Prim::Prism { pts, .. } => { + assert_eq!(pts.len(), 4); + let r = 0.9 * 0.5f64.sqrt(); + assert!( + (pts[0][0] + r).abs() < 1e-9 && (pts[0][1] + 1.1 * 0.5f64.sqrt()).abs() < 1e-9, + "{pts:?}" + ); + } + p => panic!("{p:?}"), + } + } + #[test] fn frame_round_trips_points() { - for deg in [0.0, 90.0, 180.0, 270.0, -90.0, 450.0] { + for deg in [0.0, 90.0, 180.0, 270.0, -90.0, 450.0, 30.0, -137.5] { let f = Xf::new(3.0, -4.0, 0.0, deg); let w = f.p(1.5, 2.5); let l = f.inv(w[0], w[1]); diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index 4d0e9af..0af79cb 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -14,7 +14,8 @@ pub const PROTOCOL: &str = "2025-06-18"; const INSTRUCTIONS: &str = "isokit edits an isometric scene (a site plan drawn as a SimCity-style 3D view). \ World coordinates are metres: x east, y north, z up. The scene is a list of objects; each has a type (a prefab), \ -a position (x, y = centre of its footprint), a rotation in 90 degree steps and props from the prefab's schema. \ +a position (x, y = centre of its footprint), a rotation in degrees (any angle, counter-clockwise from east; \ +90 degree steps keep things along the axes) and props from the prefab's schema. \ Start with project_info and list_prefabs, then add_object / update_object. Use render_view (summary) to see where \ everything ended up and check_project to find problems. Edits are saved at once and show live in an open Studio. \ Houses are built from parts (categories House and Furniture): a floor per room (w x d), a house_wall along each edge \ @@ -23,7 +24,9 @@ then windows and doors with x, y on a wall line (they sit on that wall and cut t 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. \ 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. \ +meet, so for a plot boundary put each piece's ends exactly on the next piece's end or line. Line pieces (roads, walls, \ +fences, hedges, house walls) can run at any angle: a piece from (x0, y0) to (x1, y1) has x, y = the midpoint, \ +w = the distance and rot = atan2(y1 - y0, x1 - x0) in degrees; where diagonal pieces meet, corners are mitred. \ Production lines (category Industry): a producer (props.rate per minute, product) puts goods out on its local +x \ side; a conveyor (rot 0 runs towards +x, 90 +y, 180 -x, 270 -y; w = length, props.speed m/s) starting exactly \ at that side carries them, and a conveyor whose start is at another's end carries on with them (corners join). \ @@ -32,8 +35,8 @@ A robot_arm (props.reach, default 2 m) picks goods off the end of the nearest co them on the start of another conveyor within reach, or on a pallet beside it; place it 1.5 to 2 m past a belt's end. \ Streets (category Ground) join by their centre lines: a road (x, y = middle, rot 0 along x, w = length, d = width, \ props.walk none/both/left/right adds raised sidewalks walk_w wide) ending on another road's centre line makes a \ -junction there; a road_curve starts at its x, y heading along local +x (rot 0/90/180/270), bends props.angle degrees \ -left or right on radius r and ends where the next road starts; two roads ending at one point make a sharp corner. \ +junction there (at any angle); a road_curve starts at its x, y heading along its rot, bends props.angle degrees \ +left or right on radius r and ends where the next road starts; two roads ending at one point make a mitred corner. \ A roundabout (props.r ring radius, arm_e/n/w/s, arm_len) has its arm ends r + arm_len from its centre: put the \ roads' ends exactly there, with the same width as road_d."; @@ -47,7 +50,7 @@ pub fn tools() -> Vec { "x": { "type": "number", "description": "east, metres (centre of the footprint)" }, "y": { "type": "number", "description": "north, metres" }, "z": { "type": "number", "description": "base height, metres (default 0)" }, - "rot": { "type": "number", "description": "rotation in degrees, multiples of 90" }, + "rot": { "type": "number", "description": "rotation in degrees counter-clockwise from east, any angle (diagonal streets, walls, fences, hedges); 0, 90, 180, 270 keep to the axes" }, "props": { "type": "object", "description": "prefab properties (see list_prefabs); in updates they merge, null removes" }, "name": { "type": "string", "description": "name shown in the tooltip" }, "info": { "type": "string", "description": "tooltip text" }, diff --git a/crates/isokit-project/src/ops.rs b/crates/isokit-project/src/ops.rs index db4b6f4..9b7e2fb 100644 --- a/crates/isokit-project/src/ops.rs +++ b/crates/isokit-project/src/ops.rs @@ -84,8 +84,19 @@ fn finite(v: f64, what: &str) -> Result { } } +/// Any angle, in [0, 360) to 4 decimals; within 0.001 degrees of a quarter +/// turn it is that quarter turn exactly (pieces along the axes stay exact). fn norm_rot(r: f64) -> f64 { - (((r / 90.0).round() as i64).rem_euclid(4) * 90) as f64 + let q = (r / 90.0).round(); + if (r - q * 90.0).abs() < 1e-3 { + return ((q as i64).rem_euclid(4) * 90) as f64; + } + let a = (r.rem_euclid(360.0) * 1e4).round() / 1e4; + if a >= 360.0 { + 0.0 + } else { + a + } } /// Check property values against the prefab schema (unknown keys pass: @@ -364,7 +375,28 @@ mod tests { .id .unwrap(); assert_eq!(id, "tree"); + // any angle stays (diagonal streets and walls), normalised to [0, 360) + assert_eq!(d.objects[0].rot, 100.0); + apply( + &mut d, + &c, + op(json!({"op": "update", "id": "tree", "patch": {"rot": -45.000001}})), + ) + .unwrap(); + assert_eq!(d.objects[0].rot, 315.0); + apply( + &mut d, + &c, + op(json!({"op": "update", "id": "tree", "patch": {"rot": 450.0004}})), + ) + .unwrap(); assert_eq!(d.objects[0].rot, 90.0); + apply( + &mut d, + &c, + op(json!({"op": "update", "id": "tree", "patch": {"rot": 100}})), + ) + .unwrap(); apply(&mut d, &c, op(json!({"op": "update", "id": "tree", "patch": {"x": 7.5, "props": {"r": 2.0}, "tag": "Oak"}}))).unwrap(); assert_eq!(d.objects[0].x, 7.5); assert_eq!(d.objects[0].props["r"], json!(2.0)); diff --git a/crates/isokit-script/src/doc.rs b/crates/isokit-script/src/doc.rs index f6c6463..1722c9d 100644 --- a/crates/isokit-script/src/doc.rs +++ b/crates/isokit-script/src/doc.rs @@ -27,7 +27,7 @@ pub struct Doc { } /// One placed object. Its prefab (`kind`) draws it around (x, y, z), turned -/// by `rot` degrees (multiples of 90), in its own coordinates. +/// by `rot` degrees (any angle; quarter turns are exact), in its own coordinates. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct Object { pub id: String, diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index 61de62c..51c11b2 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -848,6 +848,162 @@ fn streets_curve_join_and_meet_at_roundabouts() { assert!(html.contains("iso-giveway") && html.contains("iso-kerbf")); } +/// A piece of `kind` from plan point a to b: x, y = the middle, rot = its angle, w = its length. +fn piece(id: &str, kind: &str, a: [f64; 2], b: [f64; 2], props: Value) -> Object { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let mut p = props; + p["w"] = json!(dx.hypot(dy)); + let mut o = obj(id, kind, (a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, p); + o.rot = dy.atan2(dx).to_degrees().rem_euclid(360.0); + o +} + +#[test] +fn diagonal_walls_fences_and_hedges_mitre_and_sort() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let mut doc = Doc { + objects: vec![ + piece("w1", "wall", [-30.0, -20.0], [-20.0, -20.0], json!({})), + piece("w2", "wall", [-20.0, -20.0], [-12.0, -12.0], json!({})), + piece("w3", "wall", [-12.0, -12.0], [-12.0, -2.0], json!({})), + piece("w4", "wall", [-16.0, -16.0], [-22.0, -10.0], json!({})), + obj("p_right", "person", -14.0, -18.0, json!({})), + obj("p_left", "person", -18.0, -15.0, json!({})), + ], + ..Default::default() + }; + for (i, st) in ["mesh", "wood", "bars", "half stone", "cobble", "brick"] + .iter() + .enumerate() + { + let x = i as f64 * 10.0; + doc.objects.push(piece( + &format!("f{i}a"), + "fence", + [x, 5.0], + [x + 6.0, 5.0], + json!({ "style": st }), + )); + doc.objects.push(piece( + &format!("f{i}b"), + "fence", + [x + 6.0, 5.0], + [x + 10.0, 11.0], + json!({ "style": st }), + )); + } + doc.objects + .push(piece("h1", "hedge", [5.0, -20.0], [12.0, -13.0], json!({}))); + doc.objects.push(piece( + "h2", + "hedge", + [12.0, -13.0], + [20.0, -13.0], + json!({}), + )); + doc.objects + .push(obj("fl", "floor", 25.0, 18.0, json!({"w": 10, "d": 8}))); + for (id, a, b) in [ + ("hw1", [20.0, 14.0], [30.0, 14.0]), + ("hw2", [30.0, 14.0], [30.0, 19.0]), + ("hw3", [30.0, 19.0], [27.0, 22.0]), + ("hw4", [27.0, 22.0], [20.0, 22.0]), + ("hw5", [20.0, 22.0], [20.0, 14.0]), + ] { + doc.objects.push(piece(id, "house_wall", a, b, json!({}))); + } + let mut win = obj("win", "window", 28.5, 20.5, json!({"w": 1.2})); + win.rot = 135.0; + doc.objects.push(win); + for view in 0..4u8 { + for lv in ["site", "gf"] { + let b = r.build(lv, view, &cfg(), &doc).unwrap(); + assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); + let out = render(&b.scene, view, &RenderOpts::default()); + assert_eq!(out.cycles, 0, "{lv}/{view}: depth-sort cycles"); + assert!(!out.html.contains("NaN")); + } + // the person on the side of the diagonal wall turned to the viewer is drawn after it, + // the other one before + let b = r.build("site", view, &cfg(), &doc).unwrap(); + let html = render(&b.scene, view, &RenderOpts::default()).html; + let at = |id: &str| html.find(&format!("data-obj=\"{id}\"")).unwrap(); + // w2 runs along (1, 1); its left side (towards (-1, 1)) holds p_left. In two views the + // wall is seen edge-on: neither side faces the viewer and the order does not matter. + let p = isokit_core::Proj::new(view as i64); + let (left_seen, right_seen) = (p.vis(-1.0, 1.0).is_some(), p.vis(1.0, -1.0).is_some()); + if left_seen || right_seen { + let (front, back) = if left_seen { + ("p_left", "p_right") + } else { + ("p_right", "p_left") + }; + assert!( + at(back) < at("w2") && at("w2") < at(front), + "view {view}: {back} < w2 < {front}" + ); + } + } + let b = r.build("site", 0, &cfg(), &doc).unwrap(); + // the 135-degree corner is mitred: w1's outer face reaches 0.12 * tan(22.5) past the corner + let w1 = b.boxes["w1"]; + assert!( + (w1[3] + 20.0 - 0.12 * 22.5f64.to_radians().tan()).abs() < 1e-6, + "{w1:?}" + ); + // the window on the diagonal wall cuts its opening there + assert_eq!(sprites_of(&b, "hw3"), 2); + let wb = b.boxes["win"]; + assert!( + ((wb[0] + wb[3]) / 2.0 - 28.5).abs() < 1e-6 && ((wb[1] + wb[4]) / 2.0 - 20.5).abs() < 1e-6, + "{wb:?}" + ); + // diagonal fence corners: the owner draws the corner post as its own sprite (mesh: f0a, the lower id) + assert_eq!(sprites_of(&b, "f0a"), 2); +} + +#[test] +fn diagonal_streets_meet_at_mitres_and_slanted_junctions() { + let r = Runtime::new(&Bundle::default()).unwrap(); + let walk = json!({"walk": "both"}); + let mut doc = Doc { + objects: vec![ + piece("a1", "road", [-60.0, -30.0], [-35.0, -30.0], walk.clone()), + piece("a2", "road", [-35.0, -30.0], [-20.0, -15.0], walk.clone()), + piece("m1", "road", [-10.0, -40.0], [40.0, -40.0], walk.clone()), + piece("s1", "road", [5.0, -40.0], [17.0, -28.0], walk.clone()), + ], + ..Default::default() + }; + let mut bend = obj( + "bend", + "road_curve", + 10.0, + 0.0, + json!({"r": 12, "angle": 45, "walk": "both"}), + ); + bend.rot = 0.0; + doc.objects.push(bend); + for view in 0..4u8 { + let b = r.build("site", view, &cfg(), &doc).unwrap(); + assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings); + assert_eq!(render(&b.scene, view, &RenderOpts::default()).cycles, 0); + } + let b = r.build("site", 0, &cfg(), &doc).unwrap(); + // the 135-degree corner is mitred: a1's outer sidewalk back edge (5 m out) reaches + // 5 * tan(22.5) past it, and its seam patch 6 cm on into a2 (along (1, 1)) + let a1 = b.boxes["a1"]; + let reach = 5.0 * 22.5f64.to_radians().tan() + 0.06 * 0.5f64.sqrt(); + assert!((a1[3] + 35.0 - reach).abs() < 1e-6, "{a1:?}"); + // the diagonal side road opens m1's north sidewalk (two pieces there); its asphalt ends + // slanted on m1's centre line + assert_eq!(walks_of(&b, "m1"), 3); + let s1 = b.boxes["s1"]; + assert!((s1[1] + 40.0).abs() < 1e-6, "{s1:?}"); + let html = render(&b.scene, 0, &RenderOpts::default()).html; + assert!(html.contains("iso-g-walk")); +} + #[test] fn site_walls_join_at_corners() { let r = Runtime::new(&Bundle::default()).unwrap(); diff --git a/crates/isokit-script/stdlib/ground.rhai b/crates/isokit-script/stdlib/ground.rhai index d6e9d36..a15e82e 100644 --- a/crates/isokit-script/stdlib/ground.rhai +++ b/crates/isokit-script/stdlib/ground.rhai @@ -103,19 +103,34 @@ fn meta_wall() { ] } } fn prefab_wall(o) { - // ends joined to the walls they meet; long walls in pieces of at most 30 m, - // each its own sprite for the depth sort + // ends joined to the walls they meet (mitred and slanted where a wall is + // diagonal); long walls in pieces of at most 30 m, each its own sprite for + // the depth sort let p = o.p; - 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 c0 = wall_end(o, -1.0); + let c1 = wall_end(o, 1.0); + let x0 = c0.x; + let x1 = c1.x; let n = ((x1 - x0) / 30.0).ceiling().to_int(); let L = (x1 - x0) / n; for i in 0..n { 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" }); + let k0 = if i == 0 { c0.k } else { 0.0 }; + let k1 = if i == n - 1 { c1.k } else { 0.0 }; + sprite().cut_box(a, k0, a + L, k1, -p.t / 2.0, p.t / 2.0, 0.0, p.h, "wallp", #{ top: "wallt" }); } } +/// Where site wall o ends at e (-1 or 1): #{ x, k } (see end_cut). +fn wall_end(o, e) { + let m = line_meet(o, e, ["wall"], []); + let ht = o.p.t / 2.0; + if m.kind == "axis" { return end_cut(o.p.w, e, #{ ext: line_joint(o, e, ["wall"], []).ext, k: 0.0 }); } + if m.kind == "free" { return end_cut(o.p.w, e, #{ ext: ht, k: 0.0 }); } + if m.kind == "run" { return end_cut(o.p.w, e, #{ ext: if m.lower { ht } else { -m.wt }, k: 0.0 }); } + end_cut(o.p.w, e, m) +} + fn fence_styles() { ["mesh", "wood", "bars", "half stone", "cobble", "brick"] } /// Half the thickness of a fence design: its posts, base or pillars. @@ -135,10 +150,13 @@ fn meta_fence() { fn prefab_fence(o) { let p = o.p; 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; + let ea = fence_end(o, -1.0); + let eb = fence_end(o, 1.0); + // x0, x1: the base's ends (stone, cobble); u0, u1: the panels', rails' and boards' + let x0 = ea.x; + let x1 = eb.x; + let u0 = ea.up; + let u1 = eb.up; 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 @@ -149,10 +167,17 @@ fn prefab_fence(o) { if st != "cobble" { for j in 0..(k + 1) { 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)); + posts.push(fmin(fmax(-p.w / 2.0 + j * p.w / k, u0 + ph), u1 - ph)); } } - if st == "brick" { brick_fence(x0, x1, posts, p.h); return; } + // corner posts where a diagonal fence meets this one: their own sprites + for c in [[ea, -1.0], [eb, 1.0]] { + if c[0].corner { fence_corner_post(sprite(), c[1] * p.w / 2.0, p.h, st); } + } + if st == "brick" { brick_fence(u0, u1, posts, p.h); return; } + // diagonal pieces keep a stone base and what stands on it in separate + // sprites, so corner posts on the base sort against both + let split = st == "half stone" && (!on_axes(o.rot) || ea.gen || eb.gen); // 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; @@ -160,16 +185,83 @@ fn prefab_fence(o) { let a = x0 + i * L; let b = a + L; let ps = []; + let ua = if i == 0 { u0 } else { a }; + let ub = if i == n - 1 { u1 } else { b }; 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)); } + if x >= a - 1e-6 && (x < b - 1e-6 || i == n - 1) { ps.push(fmin(fmax(x, ua + ph), ub - ph)); } } - fence_piece(sprite(), a, b, ps, p.h, st, i); + let cut = #{ a: a, ka: if i == 0 { ea.k } else { 0.0 }, b: b, kb: if i == n - 1 { eb.k } else { 0.0 } }; + let s = sprite(); + fence_piece(s, if split { sprite() } else { s }, cut, ua, ub, 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) { +/// Post half widths of the fence designs (0: none). +fn fence_post_half(st) { + switch st { "cobble" => 0.0, "wood" => 0.05, "brick" => 0.18, _ => 0.04 } +} + +/// Where fence o ends at e (-1 or 1): #{ x, k, up, post, corner, gen }: its +/// base (stone, cobble) ends at x + k * y; panels, rails and boards at up +/// (short of a corner post); post: it draws the end post among its own; +/// corner: it draws the post at a diagonal corner as its own sprite; gen: a +/// joint with a diagonal piece. +fn fence_end(o, e) { + let w = o.p.w; + let st = o.p.style; + let ht = fence_ht(st); + let m = line_meet(o, e, ["fence"], ["wall", "house_wall"]); + let plain = |ext, post, gen| { + let c = end_cut(w, e, #{ ext: ext, k: 0.0 }); + #{ x: c.x, k: 0.0, up: c.x, post: post, corner: false, gen: gen } + }; + if m.kind == "axis" { + let j = line_joint(o, e, ["fence"], ["wall", "house_wall"]); + return plain.call(j.ext, j.post, false); + } + if m.kind == "free" { return plain.call(ht, true, true); } + if m.kind == "run" { return if m.lower { plain.call(ht, true, true) } else { plain.call(-m.wt, false, true) }; } + let c = end_cut(w, e, m); + let r = #{ x: c.x, k: c.k, up: c.x, post: false, corner: false, gen: true }; + if m.kind == "tee" { + // panels stop at the face too; against a wall the fence keeps its post + r.post = m.stop; + return r; + } + // a corner: the base is mitred; the owner's post (or pillar) stands on the corner + let pst = if m.owner { st } else { m.w.p.style }; + let pp = fence_post_half(pst); + if pp == 0.0 { return r; } + if m.owner && st == "brick" { + r.up = end_cut(w, e, #{ ext: pp, k: 0.0 }).x; + r.post = true; + return r; + } + let reach = if m.owner { pp } else { pp * (m.cos + m.sin) }; + r.up = end_cut(w, e, #{ ext: -reach, k: 0.0 }).x; + r.corner = m.owner && st != "brick"; + r +} + +/// The post at a diagonal corner, at local x: on the stone base for half stone. +fn fence_corner_post(s, x, h, st) { + if st == "wood" { + s.box(x - 0.05, -0.05, x + 0.05, 0.05, 0.0, h + 0.06, "woodd"); + } else { + let z0 = if st == "half stone" { fmin(0.6, h * 0.4) } else { 0.0 }; + s.box(x - 0.04, -0.04, x + 0.04, 0.04, z0, h + 0.05, "steel"); + } +} + +/// One stretch of a fence (not brick): the base (stone, cobble) in sprite s +/// over cut #{ a, ka, b, kb } (slanted ends), what stands on it in sprite su +/// from a to b, with posts ps. +fn fence_piece(s, su, cut, a, b, ps, h, st, seed) { let L = b - a; + let ca = cut.a; + let cb = cut.b; + let ka = cut.ka; + let kb = cut.kb; if st == "wood" { s.box(a, -0.025, b, 0.025, 0.0, h, "wood"); let boards = []; @@ -189,19 +281,22 @@ fn fence_piece(s, a, b, ps, h, st, seed) { 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"); } + s.cut_box(ca, ka, cb, kb, -0.16, 0.16, 0.0, hb, "cobble", #{ top: "capst" }); + let f0 = ca - ka * 0.16; + let f1 = ca + ka * 0.16; + let j = stone_joints(cb - kb * 0.16 - f0, 0.0, hb, seed); + s.open([f0, -0.16], [cb - kb * 0.16, -0.16], j, "seam"); + s.open([cb + kb * 0.16, 0.16], [f1, 0.16], stone_joints(cb + kb * 0.16 - f1, 0.0, hb, seed), "seam"); + su.box(a, -0.03, b, 0.03, hb, h, "fence"); + for x in ps { su.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"); + s.cut_box(ca, ka, cb, kb, -0.2, 0.2, 0.0, hc, "cobble"); + s.cut_box(ca, ka, cb, kb, -0.22, 0.22, hc, h, "capst"); + let f0 = ca - ka * 0.2; + let f1 = ca + ka * 0.2; + s.open([f0, -0.2], [cb - kb * 0.2, -0.2], stone_joints(cb - kb * 0.2 - f0, 0.0, hc, seed), "seam"); + s.open([cb + kb * 0.2, 0.2], [f1, 0.2], stone_joints(cb + kb * 0.2 - f1, 0.0, hc, seed), "seam"); } else { // mesh s.box(a, -0.03, b, 0.03, 0.0, h, "fence"); diff --git a/crates/isokit-script/stdlib/house.rhai b/crates/isokit-script/stdlib/house.rhai index 027a25a..6dc9799 100644 --- a/crates/isokit-script/stdlib/house.rhai +++ b/crates/isokit-script/stdlib/house.rhai @@ -126,13 +126,26 @@ fn prefab_house_wall(o) { gaps.push(opening_span(o, m.u, m.p.w)); } gaps.sort(|a, b| if a[0] < b[0] { -1 } else if a[0] > b[0] { 1 } else { 0 }); - let x = -p.w / 2.0 - wall_ext(o, -1.0); + let c0 = house_wall_end(o, -1.0); + let c1 = house_wall_end(o, 1.0); + let x = c0.x; + let k = c0.k; for g in gaps { - if g[0] > x + 0.01 { wall_run(x, g[0], t, top, p.mat, cap); } + if g[0] > x + 0.01 { wall_run(x, k, g[0], 0.0, t, top, p.mat, cap); } + if g[1] > x { k = 0.0; } x = fmax(x, g[1]); } - let end = p.w / 2.0 + wall_ext(o, 1.0); - if end > x + 0.01 { wall_run(x, end, t, top, p.mat, cap); } + if c1.x > x + 0.01 { wall_run(x, k, c1.x, c1.k, t, top, p.mat, cap); } +} + +/// Where house wall o ends at e (-1 or 1): #{ x, k } (see end_cut); mitred +/// at corners and slanted at T-junctions where a wall is diagonal. +fn house_wall_end(o, e) { + let m = line_meet(o, e, wall_types(), []); + if m.kind == "axis" { return end_cut(o.p.w, e, #{ ext: wall_ext(o, e), k: 0.0 }); } + if m.kind == "free" { return end_cut(o.p.w, e, #{ ext: o.p.t / 2.0, k: 0.0 }); } + if m.kind == "run" { return end_cut(o.p.w, e, #{ ext: 0.0, k: 0.0 }); } + end_cut(o.p.w, e, m) } /// How far wall o reaches past end e (-1 or 1) of its centre line. Alone: @@ -162,12 +175,14 @@ fn wall_ext(o, e) { ext } -/// A solid stretch of wall from x0 to x1, one sprite per 30 m at most. -fn wall_run(x0, x1, t, top, mat, cap) { +/// A solid stretch of wall from x0 to x1 (ends slanted by k0, k1), one +/// sprite per 30 m at most. +fn wall_run(x0, k0, x1, k1, t, top, mat, cap) { let n = ((x1 - x0) / 30.0).ceiling().to_int(); let L = (x1 - x0) / n; for i in 0..n { - sprite().box(x0 + i * L, -t, x0 + (i + 1) * L, t, 0.0, top, mat, #{ top: cap }); + sprite().cut_box(x0 + i * L, if i == 0 { k0 } else { 0.0 }, x0 + (i + 1) * L, if i == n - 1 { k1 } else { 0.0 }, + -t, t, 0.0, top, mat, #{ top: cap }); } } diff --git a/crates/isokit-script/stdlib/nature.rhai b/crates/isokit-script/stdlib/nature.rhai index c903732..ecc52ae 100644 --- a/crates/isokit-script/stdlib/nature.rhai +++ b/crates/isokit-script/stdlib/nature.rhai @@ -47,7 +47,15 @@ fn meta_hedge() { } 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"); + let c0 = hedge_end(o, -1.0); + let c1 = hedge_end(o, 1.0); + sprite().cut_box(c0.x, c0.k, c1.x, c1.k, -p.d / 2.0, p.d / 2.0, 0.0, p.h, "hedge"); +} + +/// Where hedge o ends at e: mitred or slanted where a diagonal hedge meets it. +fn hedge_end(o, e) { + let m = line_meet(o, e, ["hedge"], []); + if m.kind == "corner" || m.kind == "tee" { end_cut(o.p.w, e, m) } else { end_cut(o.p.w, e, #{ ext: 0.0, k: 0.0 }) } } // ---------------------------------------------------------------- pond diff --git a/crates/isokit-script/stdlib/streets.rhai b/crates/isokit-script/stdlib/streets.rhai index 9a30c1b..63a33f7 100644 --- a/crates/isokit-script/stdlib/streets.rhai +++ b/crates/isokit-script/stdlib/streets.rhai @@ -152,21 +152,6 @@ fn pl_end(p, e) { [q[0], q[1], (b[0] - a[0]) / l, (b[1] - a[1]) / l] } -/// [a, b] without the ranges in gaps: the pieces left, at least 5 cm long. -fn ranges(a, b, gaps) { - let out = [[a, b]]; - for g in gaps { - let next = []; - for r in out { - if g[1] <= r[0] || g[0] >= r[1] { next.push(r); continue; } - if g[0] > r[0] { next.push([r[0], g[0]]); } - if g[1] < r[1] { next.push([g[1], r[1]]); } - } - out = next; - } - out.filter(|r| r[1] - r[0] > 0.05) -} - // ---------------------------------------------------------- kerbs and sidewalks /// The faces up from line k to height h whose outward normal (to the right @@ -185,20 +170,21 @@ fn kerb_faces(k, h, right) { } /// A sidewalk between kerb line k (plan points with the road on their -/// right) and its back line o (as many points, w to the left): its paving, -/// back edge and the ends where caps says [start, end] (not where another -/// sidewalk carries on). The kerb face goes with the kerb. -fn walk_strip(k, o, caps) { - if k.len() < 2 || o.len() != k.len() { return this; } +/// right) and its back line o (to the left): its paving (with seam patches, +/// polygons laid over mitred joints), back edge and the ends where caps says +/// [start, end] (not where another sidewalk carries on). The kerb face goes +/// with the kerb. +fn walk_strip(k, o, caps, patches) { + if k.len() < 2 || o.len() < 2 { return this; } let h = walk_h(); - this.flat(k + rev(o), h, "g-walk t"); + if patches.len() == 0 { this.flat(k + rev(o), h, "g-walk t"); } else { this.flat([k + rev(o)] + patches, h, "g-walk t"); } let fs = kerb_faces(o, h, false); - let n = k.len(); if caps[0] { fs += kerb_faces([o[0], k[0]], h, true); } - if caps[1] { fs += kerb_faces([k[n - 1], o[n - 1]], h, true); } + if caps[1] { fs += kerb_faces([k[k.len() - 1], o[o.len() - 1]], h, true); } if fs.len() > 0 { this.poly(fs, "kerbf"); } this } +fn walk_strip(k, o, caps) { this.walk_strip(k, o, caps, []) } // ---------------------------------------------------------- where streets meet @@ -233,61 +219,94 @@ fn street_ends(o) { } /// How street o meets the others at its end e (one of street_ends(o)): -/// #{ asph, dash, kl, kr, wl, wr, free, meet }: how far its asphalt, centre -/// line, kerbs and sidewalks (left and right looking out) reach past e -/// (negative: they stop short of it), whether nothing meets it there, and -/// the id of the street that does. +/// #{ asph, dash, kerb, walk, free, meet, pd }. Each of the first four is a +/// cut #{ e, k }: the line w to the left looking out of the end (its +/// carriageway edge, kerb or sidewalk edge) reaches e + k * w past it +/// (negative: it stops short), so ends can be slanted. free: nothing meets it +/// there; meet: the id of the street that does; pd: at a mitred corner, the +/// direction (o's frame) into the other street, for seam patches. +/// - end to end (a curve carrying a road on): everything overlaps 4 cm; +/// - two streets ending at one point at any angle: a mitre on the bisector; +/// - ending on a road's centre line (a T, at any angle): the asphalt stops on +/// it, centre line and kerbs at its kerb, sidewalks at its sidewalk's back. fn street_join(o, e, others) { - let j = #{ asph: 0.0, dash: 0.0, kl: 0.0, kr: 0.0, wl: 0.0, wr: 0.0, free: true, meet: "" }; + let flat = #{ e: 0.0, k: 0.0 }; + let j = #{ asph: flat, dash: flat, kerb: flat, walk: flat, free: true, meet: "", pd: () }; // pieces that carry on overlap by a few cm: edge to edge, the ground shows through as a hairline let lap = 0.04; + let u = [e.dx, e.dy]; + let n = [-e.dy, e.dx]; for f in others { if (f.x - e.x).abs() > 0.3 || (f.y - e.y).abs() > 0.3 { continue; } j.free = false; j.meet = f.id; - let dot = e.dx * f.dx + e.dy * f.dy; - if dot < -0.9 { j.asph = lap; j.kl = lap; j.kr = lap; j.wl = lap; j.wr = lap; } - if dot.abs() > 0.2 { continue; } - // a corner: the other street's body lies to the left (cross > 0) or right - let left = e.dx * -f.dy - e.dy * -f.dx > 0.0; - // its outer side is the one away from this street's body - let mine_on_its_left = f.dx * -e.dy - f.dy * -e.dx > 0.0; - let wo = if mine_on_its_left { f.wr } else { f.wl }; - let h = f.d / 2.0; - j.asph = h; - if left { j.kl = -h; j.wl = -h; j.kr = h; j.wr = h + wo; } else { j.kr = -h; j.wr = -h; j.kl = h; j.wl = h + wo; } + let uf = [f.dx, f.dy]; + let dot = dot2(u, uf); + if dot < -0.9999 { + let l = #{ e: lap, k: 0.0 }; + j.asph = l; + j.kerb = l; + j.walk = l; + continue; + } + if dot > 0.9 { continue; } + // a corner at any angle: every part ends on the bisector + let m = #{ e: 0.0, k: dot2(n, uf) / (1.0 - dot) }; + j.asph = m; + j.dash = m; + j.kerb = m; + j.walk = m; + let q0 = local_of(o, e.x, e.y); + let q1 = local_of(o, e.x - f.dx, e.y - f.dy); + j.pd = [q1[0] - q0[0], q1[1] - q0[1]]; return j; } - // ending on a road's side (a T): kerbs stop at its kerb, sidewalks at its sidewalk + if !j.free { return j; } + // ending on a road's centre line (a T): stop at lines along that road for c in objects_on(["road"], e.x, e.y, 0.3) { if c.id == o.id || c.v.abs() > 0.3 || c.u.abs() > c.p.w / 2.0 - 0.3 { continue; } - let l = local_of(c, e.x - e.dx, e.y - e.dy); + let c0 = world_of(c, 0.0, 0.0); + let c1 = world_of(c, 0.0, 1.0); + let yc = [c1[0] - c0[0], c1[1] - c0[1]]; + let a = dot2(u, yc); + if a.abs() < 0.05 { continue; } + // o's body lies on c's left (+v) when its way out runs to c's right + let sd = if a < 0.0 { 1.0 } else { -1.0 }; let ws = walk_widths(c.p); let h = c.p.d / 2.0; - let w = if l[1] > c.v { ws[0] } else { ws[1] }; + let w = if sd > 0.0 { ws[0] } else { ws[1] }; + let k = -dot2(n, yc) / a; + let at = |tv| #{ e: (tv - c.v) / a, k: k }; j.free = false; j.meet = c.id; - j.dash = -h; - j.kl = -h; - j.kr = -h; - j.wl = -(h + w) + 0.04; - j.wr = -(h + w) + 0.04; + j.asph = at.call(0.0); + j.dash = at.call(sd * h); + j.kerb = at.call(sd * h); + let wb = at.call(sd * (h + w)); + j.walk = #{ e: wb.e + 0.04, k: k }; return j; } j } /// Where the streets in `others` end on road o's sides: gaps [left, right] -/// in its kerbs and sidewalks, as distance ranges along it from its start, -/// then the ids of those streets. +/// in its kerbs and sidewalks, each #{ lo, hi, c }: from lo + c * v to +/// hi + c * v along it from its start at side offset v (slanted where a +/// street comes in at an angle), then the ids of those streets. fn street_gaps(o, others) { let g = [[], [], []]; for f in others { let l = local_of(o, f.x, f.y); if l[1].abs() > 0.3 || l[0].abs() > o.p.w / 2.0 - 0.3 { continue; } + // the street's way in, in o's frame, and its carriageway edges across o let b = local_of(o, f.x - f.dx, f.y - f.dy); - let s = l[0] + o.p.w / 2.0; - let r = [s - f.d / 2.0, s + f.d / 2.0]; + let d = [l[0] - b[0], l[1] - b[1]]; + let a = -d[1]; + if a.abs() < 0.05 { continue; } + let sl = -d[0] / a; + let hw = f.d / 2.0 / a.abs(); + let s = l[0] + o.p.w / 2.0 - sl * l[1]; + let r = #{ lo: s - hw, hi: s + hw, c: sl }; if b[1] > l[1] { g[0].push(r); } else { g[1].push(r); } g[2].push(f.id); } @@ -314,31 +333,80 @@ fn other_street_ends(o) { out } +/// A bound along a street: at side offset v it lies at b.s + b.c * v. +fn bnd_at(b, v) { b.s + b.c * v } + +/// Pieces of [a, b] (bounds) without the gaps (street_gaps entries), as +/// they fall at side offset v: [[lo, hi], ...] of bounds, at least 5 cm long. +fn ranges_b(a, b, gaps, v) { + let out = [[a, b]]; + for g in gaps { + let glo = #{ s: g.lo, c: g.c }; + let ghi = #{ s: g.hi, c: g.c }; + let next = []; + for r in out { + if bnd_at(ghi, v) <= bnd_at(r[0], v) || bnd_at(glo, v) >= bnd_at(r[1], v) { next.push(r); continue; } + if bnd_at(glo, v) > bnd_at(r[0], v) { next.push([r[0], glo]); } + if bnd_at(ghi, v) < bnd_at(r[1], v) { next.push([ghi, r[1]]); } + } + out = next; + } + out.filter(|r| bnd_at(r[1], v) - bnd_at(r[0], v) > 0.05) +} + +/// A thin patch over the seam from p to q, reaching `lap` along d: laid +/// into the street on the other side of a mitre, so no hairline shows. +fn seam_patch(p, q, d) { + let l = fmax((d[0] * d[0] + d[1] * d[1]).sqrt(), 1e-9); + let x = d[0] / l * 0.06; + let y = d[1] / l * 0.06; + [p, q, [q[0] + x, q[1] + y], [p[0] + x, p[1] + y]] +} + /// Draws street o along centre line c (a line_path or arc_path in its /// frame, in its way), its carriageway o.p.d wide, joined at its start and /// end as j0 and j1 (from street_join), with sidewalk gaps [left, right] -/// (distance ranges along c). +/// (from street_gaps). fn street_path(o, c, j0, j1, gaps) { let p = o.p; let L = pl_len(c); let h = p.d / 2.0; let ws = walk_widths(p); let s = sprite(); - let ca = pl_cut(c, -j0.asph, L + j1.asph); - s.flat(pl_off(ca, h) + rev(pl_off(ca, -h)), 0.007, "g-asph t"); - if p.dashed && L + j1.dash + j0.dash > 0.05 { - s.polyline(pl_off(pl_cut(c, -j0.dash, L + j1.dash), 0.0), 0.012, "dash"); + // a part's ends as bounds (v: left of the way; at the start that is the + // right looking out, so w = -v there) + let b0 = |cut| #{ s: -cut.e, c: cut.k }; + let b1 = |cut| #{ s: L + cut.e, c: cut.k }; + let seg = |a, b, v| pl_off(pl_cut(c, bnd_at(a, v), bnd_at(b, v)), v); + // patches over mitred seams between the lines at v0 and v1 (at the start, the end) + let patches = |l0, l1| { + let out = []; + if type_of(j0.pd) != "()" { out.push(seam_patch(l0[0], l1[0], j0.pd)); } + if type_of(j1.pd) != "()" { out.push(seam_patch(l0[l0.len() - 1], l1[l1.len() - 1], j1.pd)); } + out + }; + let A0 = b0.call(j0.asph); + let A1 = b1.call(j1.asph); + let al = seg.call(A0, A1, h); + let ar = seg.call(A0, A1, -h); + let ap = patches.call(al, ar); + if ap.len() == 0 { s.flat(al + rev(ar), 0.007, "g-asph t"); } else { s.flat([al + rev(ar)] + ap, 0.007, "g-asph t"); } + let D0 = b0.call(j0.dash); + let D1 = b1.call(j1.dash); + if p.dashed && bnd_at(D1, 0.0) - bnd_at(D0, 0.0) > 0.05 { + s.polyline(seg.call(D0, D1, 0.0), 0.012, "dash"); } - // side 0: left of the way (the start end's right, the end's left); side 1: right + let K0 = b0.call(j0.kerb); + let K1 = b1.call(j1.kerb); + let W0 = b0.call(j0.walk); + let W1 = b1.call(j1.walk); + // side 0: left of the way; side 1: right for side in [0, 1] { let sg = if side == 0 { 1.0 } else { -1.0 }; - let k0 = if side == 0 { j0.kr } else { j0.kl }; - let k1 = if side == 0 { j1.kl } else { j1.kr }; - let w0 = if side == 0 { j0.wr } else { j0.wl }; - let w1 = if side == 0 { j1.wl } else { j1.wr }; + let vk = sg * h; let ww = ws[side]; - for r in ranges(-k0, L + k1, gaps[side]) { - let line = pl_off(pl_cut(c, r[0], r[1]), sg * h); + for r in ranges_b(K0, K1, gaps[side], vk) { + let line = seg.call(r[0], r[1], vk); if side == 1 { line.reverse(); } if ww > 0.0 { let fs = kerb_faces(line, walk_h(), true); @@ -348,17 +416,21 @@ fn street_path(o, c, j0, j1, gaps) { } } if ww > 0.0 { - let rs = ranges(-w0, L + w1, gaps[side]); + let vb = sg * (h + ww); + let rs = ranges_b(W0, W1, gaps[side], (vk + vb) / 2.0); for i in 0..rs.len() { let r = rs[i]; - let cut = pl_cut(c, r[0], r[1]); - let line = pl_off(cut, sg * h); - let back = pl_off(cut, sg * (h + ww)); + let line = seg.call(r[0], r[1], vk); + let back = seg.call(r[0], r[1], vb); + // seams only where the piece reaches the street's mitred ends + let wp = []; + if i == 0 && type_of(j0.pd) != "()" { wp.push(seam_patch(line[0], back[0], j0.pd)); } + if i + 1 == rs.len() && type_of(j1.pd) != "()" { wp.push(seam_patch(line[line.len() - 1], back[back.len() - 1], j1.pd)); } // caps where a piece ends at a gap or a free street end (sides run opposite ways) let at0 = i > 0 || j0.free; let at1 = i + 1 < rs.len() || j1.free; if side == 1 { line.reverse(); back.reverse(); } - s.walk_strip(line, back, if side == 0 { [at0, at1] } else { [at1, at0] }); + s.walk_strip(line, back, if side == 0 { [at0, at1] } else { [at1, at0] }, wp); } } } @@ -377,7 +449,7 @@ fn curve_line(p) { fn meta_road_curve() { #{ label: "Road curve", category: "Ground", icon: "◜", - help: "A curved road piece. It starts at its position heading along local +x (turn it in 90 degree steps) and bends left or right; its ends meet roads and other curves end to end. Drawing a road run corner by corner puts one in each corner.", + help: "A curved road piece. It starts at its position heading along local +x (turned by its rotation, any angle) and bends left or right; its ends meet roads and other curves end to end. Drawing a road run corner by corner puts one in each corner.", props: [ #{ key: "r", type: "float", def: 10.0, min: 3.0, max: 300.0, step: 0.5, label: "Radius (m)" }, #{ key: "angle", type: "float", def: 90.0, min: 5.0, max: 180.0, step: 5.0, label: "Angle (deg)" }, diff --git a/crates/isokit-script/stdlib/util.rhai b/crates/isokit-script/stdlib/util.rhai index aaf3cb5..e9d1e5c 100644 --- a/crates/isokit-script/stdlib/util.rhai +++ b/crates/isokit-script/stdlib/util.rhai @@ -61,9 +61,137 @@ fn drive(dx, dy, dur, delay) { /// 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. +/// True when rot (degrees) is a multiple of 90: the piece keeps to the world axes. +fn on_axes(rot) { + let q = rot / 90.0; + (q - q.round()).abs() < 1e-5 +} + +/// World plan direction of a piece's local +x axis. +fn dir_of(o) { + let a = o.rot * PI() / 180.0; + [a.cos(), a.sin()] +} + +fn dot2(a, b) { a[0] * b[0] + a[1] * b[1] } + +/// Half the thickness of a fence, wall or hedge 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 } + if w.type == "fence" { fence_ht(w.p.style) } else if w.type == "hedge" { w.p.d / 2.0 } else { w.p.t / 2.0 } +} + +/// How line piece o meets the pieces of `types` at its end e (-1 or 1), for +/// joints where a piece is turned off the world axes (diagonal walls, fences, +/// hedges). #{ kind: "axis" } when every piece there keeps to the axes (the +/// prefab's own rules apply), else kind +/// - "free": nothing there; +/// - "run": a piece carrying on in line (lower: o's centre comes first along +/// its axis; wt: the other's half thickness); +/// - "corner": a piece ending there at an angle: a mitre, so the two never +/// overlap (k below; owner: o draws a corner post if any, the thicker piece +/// or, equal, the lower id; cos, sin: of the angle between the two axes); +/// - "tee": o ends on a piece's side (or on two pieces in line ending there): +/// it stops at that piece's face (stop: the piece is of a `stops` type). +/// ext and k give the slanted end: the line v to o's left (local y) reaches +/// ext + k * v past the end; the depth sort's outlines keep mitred and +/// slanted pieces apart. +fn line_meet(o, e, same, stops) { + let ht = line_ht(o); + let xo = dir_of(o); + let yo = [-xo[1], xo[0]]; + let uo = [e * xo[0], e * xo[1]]; + let P = world_of(o, e * o.p.w / 2.0, 0.0); + let axis = on_axes(o.rot); + let tee = (); + let run = (); + let corners = []; + for w in objects_on(same + stops, P[0], P[1], 0.05) { + 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; } + if !on_axes(w.rot) { axis = false; } + let xw = dir_of(w); + let par = dot2(uo, xw).abs() > 0.999; + let at_end = (w.u.abs() - w.p.w / 2.0).abs() < 0.02; + if !par && (!same.contains(w.type) || !at_end) { + if type_of(tee) == "()" { tee = w; } + } else if par && at_end { + run = w; + } else if !par { + corners.push(w); + } + } + if axis { return #{ kind: "axis" }; } + // stop at the face of w on o's side + let face = |w| { + let xw = dir_of(w); + let yw = [-xw[1], xw[0]]; + let a = dot2(uo, yw); + let s = if a < 0.0 { 1.0 } else { -1.0 }; + #{ ext: (s * line_ht(w) - w.v) / a, k: -dot2(yo, yw) / a } + }; + if type_of(tee) != "()" { + let f = face.call(tee); + return #{ kind: "tee", ext: f.ext, k: f.k, stop: !same.contains(tee.type), w: tee }; + } + if type_of(run) != "()" { + return #{ kind: "run", ext: 0.0, k: 0.0, lower: dot2([o.x, o.y], xo) < dot2([run.x, run.y], xo), wt: line_ht(run), w: run }; + } + if corners.len() > 1 { + // two in line make a straight run o meets as a T; else (a Y) the + // lowest id runs through and the others stop at it + let t = (); + for a in corners { + for b in corners { + if a.id < b.id && dot2(dir_of(a), dir_of(b)).abs() > 0.999 { t = a; } + } + } + if type_of(t) == "()" { + t = corners[0]; + for c in corners { if c.id < t.id { t = c; } } + if o.id < t.id { return #{ kind: "free", ext: 0.0, k: 0.0 }; } + } + let f = face.call(t); + return #{ kind: "tee", ext: f.ext, k: f.k, stop: false, w: t }; + } + if corners.len() == 1 { + let w = corners[0]; + let xw = dir_of(w); + let sw = if w.u > 0.0 { 1.0 } else { -1.0 }; + let uw = [sw * xw[0], sw * xw[1]]; + let wt = line_ht(w); + return #{ kind: "corner", ext: 0.0, k: dot2(yo, uw) / fmax(1.0 - dot2(uo, uw), 1e-6), w: w, wt: wt, + owner: ht > wt + 0.005 || ((ht - wt).abs() <= 0.005 && o.id < w.id), + cos: dot2(xo, xw).abs(), sin: (xo[0] * xw[1] - xo[1] * xw[0]).abs() }; + } + #{ kind: "free", ext: 0.0, k: 0.0 } +} + +/// A box from x0 to x1 along local x whose ends may be slanted: at y the +/// start lies at x0 + k0 * y and the end at x1 + k1 * y (k0 = k1 = 0: a box). +fn cut_box(x0, k0, x1, k1, y0, y1, z0, z1, mat, o) { + if k0 == 0.0 && k1 == 0.0 { return this.box(x0, y0, x1, y1, z0, z1, mat, o); } + let a0 = x0 + k0 * y0; + let a1 = x0 + k0 * y1; + let b0 = fmax(x1 + k1 * y0, a0 + 0.001); + let b1 = fmax(x1 + k1 * y1, a1 + 0.001); + this.prism([[a0, y0], [b0, y0], [b1, y1], [a1, y1]], z0, z1, mat, o); + this +} +fn cut_box(x0, k0, x1, k1, y0, y1, z0, z1, mat) { + if k0 == 0.0 && k1 == 0.0 { return this.box(x0, y0, x1, y1, z0, z1, mat); } + let a0 = x0 + k0 * y0; + let a1 = x0 + k0 * y1; + let b0 = fmax(x1 + k1 * y0, a0 + 0.001); + let b1 = fmax(x1 + k1 * y1, a1 + 0.001); + this.prism([[a0, y0], [b0, y0], [b1, y1], [a1, y1]], z0, z1, mat); + this +} + +/// A cut from line_meet as the start (e = -1) or end (e = 1) of a piece +/// w long: #{ x, k } with the end at x + k * y. +fn end_cut(w, e, m) { + if e < 0.0 { #{ x: -w / 2.0 - m.ext, k: -m.k } } else { #{ x: w / 2.0 + m.ext, k: m.k } } } /// How line piece o (a fence or wall along its local x, length p.w) meets diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bf87129..b1b7bec 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -68,7 +68,8 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push. (hover preview) and move, turn, copy and delete with them, new parts land on the storey shown, collapsible palette that scrolls on its own - [x] `examples/house` (a furnished house built from parts), House demo in the web Studio -- [ ] Openings across two walls; diagonal walls; floor openings for stairs +- [x] Diagonal walls (any angle, mitred corners, windows and doors on them) +- [ ] Openings across two walls; floor openings for stairs ## Backlog (2026-10) @@ -81,6 +82,8 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push. 7. ~~Roundabouts~~ (done, see below) 8. Waypoint animation: a person or car moving along a path of waypoints 9. ~~Build your own house~~ (done, see above) +10. ~~Diagonal streets, walls, fences, hedges~~ (done, see below) +11. Wildlife and nature: deer, bushes, beehives and more 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); @@ -119,6 +122,16 @@ apron (tree, grass or paved). A road run drawn corner by corner gets a curve in meta `corner`). Tooltips list the streets each one meets. New `polyline` primitive (open paths, so dashes run on round curves). `examples/streets`, Streets demo in the web Studio. +Diagonals: object frames turn by any angle (quarter turns stay exact, so scenes along the axes +draw as before, byte for byte). Roads, walls, fences, hedges and house walls run at any angle; +where a piece off the axes meets another, corners are mitred and T-junctions slanted +(`line_meet`, `cut_box`), fence corners get a post or pillar of their own, side streets come in at +a slant and open the sidewalk along them, two streets meet in a mitre. The depth sort orders +sprites whose plan outline is not their box by outline edges, so a long diagonal wall sorts right +against everything on either side. Studio: runs and drags keep to x or y or go diagonal at 45° +(Ctrl: any angle), a road run turning 45° gets a 45° curve, Rotation takes any angle. The +streets demo has a diagonal lane. + ## 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 9a6f39d..d71b28f 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -66,7 +66,7 @@ shaded automatically by face direction. | `objects_on(types, x, y, tol)` | … whose centre line (local x, length `p.w`) passes within `p.t / 2 + tol` of the point, with `u, v` = the point in their frame | | `mount_of(o, hosts[, tol])` | the host o sits on (nearest centre line within `p.t / 2 + tol`, default 0.3 m, same height), with `u, v` = o's position in the host's frame; `()` if none | | `mounted(h, types, hosts[, tol])` | the objects of `types` whose host is h, each with `u, v` | -| `frame(o)`, `frame(x, y, z, rot)` | draw in another frame from here on (a window draws in its wall's frame) | +| `frame(o)`, `frame(x, y, z, rot)` | draw in another frame from here on (a window draws in its wall's frame); `rot` is any angle in degrees (quarter turns stay exact) | | `to_world(x, y)`, `to_local(x, y)`, `local_of(o, x, y)`, `world_of(o, u, v)` | between the current frame (or o's) and world plan coordinates | | `vis(nx, ny)` | is a face with that outward normal turned to the viewer? | | `dv(dx, dy, dz)` | screen vector of a world vector (CSS motion) | @@ -122,6 +122,29 @@ 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). +### Diagonal pieces + +Objects turn by any angle (`rot` in degrees); a box drawn in a turned frame +becomes a prism with its own four corners. Where a piece off the axes is +involved, `line_meet(o, e, same, stops)` (util.rhai) replaces the rules above +for that end: `#{ kind, ext, k, ... }` with kind `axis` (every piece there +keeps to the axes: use the old rules, so such scenes draw as before), +`free`, `run` (in line), `corner` (a mitre on the bisector; `owner` draws a +fence's corner post) or `tee` (stop at the other's face). The end is +slanted: the line `v` to the piece's left reaches `ext + k * v` past it. +`end_cut(w, e, m)` turns that into `#{ x, k }` and `s.cut_box(x0, k0, x1, k1, +y0, y1, z0, z1, mat[, opts])` draws a box with such ends. Fences put their +corner post (or brick pillar) in its own sprite and stop their panels at it; +stone bases and cobble walls are mitred. Helpers: `on_axes(rot)`, +`dir_of(o)`, `dot2(a, b)`. + +The depth sort keeps diagonal pieces right through outlines: a sprite whose +plan outline is not its box (turned or mitred pieces) carries its convex +outline, and when two boxes overlap an outline edge that separates them +orders them, the one on the viewer's side going later. A long diagonal wall +therefore sorts right against everything on either side of it, and mitred +pieces against each other. + ## Production lines `producer`, `conveyor` and `robot_arm` (stdlib/industry.rhai) work together @@ -166,12 +189,16 @@ ground.rhai) meet by their centre lines: arcs when cut and offset, so a curve meets a straight road exactly. - End to end (a curve continuing a road, a road continuing a roundabout arm) the pieces carry on, overlapping by 4 cm so no hairline shows. -- A street ending on a road's centre line makes a T: the road leaves a gap in - its kerb and sidewalk on that side, the street stops its centre line and - kerbs at the road's edge and its sidewalks at the road's sidewalk. -- Two roads ending at one point at right angles close the corner: both run - over it, the inner sidewalks stop at the other road's kerb, the outer ones - go round. +- A street ending on a road's centre line makes a T, at any angle: the road + leaves a gap in its kerb and sidewalk on that side (slanted along the + street coming in), the street ends its asphalt on the road's centre line, + its centre line and kerbs at the road's edge and its sidewalks at the + road's sidewalk. +- Two streets ending at one point at any angle meet in a mitre on the + bisector (thin seam patches reach over it, so no hairline shows). +- Every part of a street end is a cut `#{ e, k }`: the line `w` to the left + looking out reaches `e + k * w` past the end (`street_join`); gaps are + `#{ lo, hi, c }` ranges that slant with the side offset (`street_gaps`). - A roundabout's arms end `r + arm_len` from its centre, along its local +x, +y, -x, -y. Its kerbs flare into the ring with a fillet; splitter islands and give-way lines sit on the entry side for `drive` left or right. @@ -220,7 +247,10 @@ These are lessons from the CNX/Mooban views, and the engine relies on them: the same sprite as the storey below. Start walls and furniture standing on them a little above the floor (+0.02 m). - **Movers** (`drive`) run along the axis the sort compares second - (`along_x()`). Along the other axis they stay still. + (`along_x()`). Along the other axis (and on diagonals) they stay still. +- **Diagonal pieces** sort by their outlines (see Diagonal pieces), so they + need no extra splitting; things standing exactly on a wall's line still + interpenetrate it and sort by their centres. - Street furniture stays off drive areas, and pipe runs end on walls or supports. ## Themes diff --git a/examples/streets/scene.json b/examples/streets/scene.json index fa9c9c8..2da434c 100644 --- a/examples/streets/scene.json +++ b/examples/streets/scene.json @@ -10,6 +10,8 @@ {"id": "bend", "type": "road_curve", "name": "Corner", "x": 0, "y": 34, "rot": 90, "props": {"walk": "both", "walk_w": 2.5, "d": 6.5, "r": 12, "turn": "right"}}, {"id": "park-rd", "type": "road", "name": "Park road", "x": 34, "y": 46, "props": {"walk": "both", "walk_w": 2.5, "d": 6.5, "w": 44}}, {"id": "cross-st", "type": "road", "name": "Cross street", "x": 34, "y": 23, "rot": 90, "props": {"w": 46, "d": 5.5, "walk": "both", "walk_w": 2}}, + {"id": "lane", "type": "road", "name": "Garden lane", "x": 34.0, "y": -10.0, "rot": 315.0, "props": {"w": 28.284, "d": 5.5, "walk": "both", "walk_w": 2}}, + {"id": "hedge-lane", "type": "hedge", "name": "Lane hedge", "x": 28.95, "y": -14.849, "rot": 315.0, "props": {"w": 12.0, "d": 0.8, "h": 1.2}}, {"id": "offices", "type": "building", "name": "Offices", "x": 19, "y": 27, "props": {"w": 14, "d": 13, "floors": 3, "pv": true}}, {"id": "flats", "type": "building", "name": "Flats", "x": 46, "y": 23, "props": {"w": 10, "d": 28, "floors": 4, "roof": "gable", "mat": "brick"}}, {"id": "school", "type": "building", "name": "School", "x": -33, "y": -21, "props": {"w": 20, "d": 14, "floors": 2, "ac": true}}, @@ -30,7 +32,7 @@ {"id": "walker-2", "type": "person", "x": -26, "y": -4.7}, {"id": "walker-3", "type": "person", "x": 31.6, "y": 30, "props": {"cls": "guest3"}}, {"id": "crowd", "type": "crowd", "name": "Pupils", "x": -24, "y": -11, "props": {"n": 6, "r": 1.6}}, - {"id": "lamp-1", "type": "lamp", "x": 26, "y": -5.2}, + {"id": "lamp-1", "type": "lamp", "x": 18, "y": -5.2}, {"id": "lamp-2", "type": "lamp", "x": 44, "y": -5.2}, {"id": "lamp-3", "type": "lamp", "x": -28, "y": 5.2}, {"id": "lamp-4", "type": "lamp", "x": -44, "y": 5.2}, diff --git a/studio/studio.js b/studio/studio.js index 2978fd5..460ad77 100644 --- a/studio/studio.js +++ b/studio/studio.js @@ -46,8 +46,18 @@ function worldAt(e, z) { return S.view.toWorld(e.clientX, e.clientY, z || 0); } function qOf(rot) { return ((Math.round((rot || 0) / 90) % 4) + 4) % 4; } function turnV(q, x, y) { return q === 0 ? [x, y] : q === 1 ? [-y, x] : q === 2 ? [-x, -y] : [y, -x]; } - function localOf(o, x, y) { return turnV((4 - qOf(o.rot)) % 4, x - o.x, y - o.y); } - function worldOf(o, u, v) { var d = turnV(qOf(o.rot), u, v); return [o.x + d[0], o.y + d[1]]; } + // is rot (degrees) a multiple of 90: does the object keep to the world axes? + function onAxes(rot) { var q = (rot || 0) / 90; return Math.abs(q - Math.round(q)) < 1e-5; } + // a plan vector turned by rot degrees (quarter turns stay exact, as in the engine) + function turnA(rot, x, y) { + if (onAxes(rot)) return turnV(qOf(rot), x, y); + var a = rot * Math.PI / 180, c = Math.cos(a), s = Math.sin(a); + return [c * x - s * y, s * x + c * y]; + } + function localOf(o, x, y) { return turnA(-(o.rot || 0), x - o.x, y - o.y); } + function worldOf(o, u, v) { var d = turnA(o.rot || 0, u, v); return [o.x + d[0], o.y + d[1]]; } + // the angle of plan direction (dx, dy) in degrees, 4 decimals + function degOf(dx, dy) { return Math.round(Math.atan2(dy, dx) * 180 / Math.PI * 1e4) / 1e4; } function defOf(m, k) { var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0]; return d ? d.default : undefined; } function propOf(o, k) { var p = o.props || {}; return p[k] !== undefined ? p[k] : defOf(prefab(o.type), k); } function clampProp(m, k, v) { @@ -120,14 +130,21 @@ 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 }; + return { o: o, a: worldOf(o, -L, 0), b: worldOf(o, L, 0), ax: qOf(o.rot) % 2 === 0, axis: onAxes(o.rot) }; }); } // 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) { + // to x or y (within 22.5 degrees) or a diagonal at 45, 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; `free` (Ctrl held): any + // angle, the end on a piece's end or line or a grid point; without `from`, onto the nearest end, + // else onto a line, else the grid + function snapCorner(w, z, from, R, free) { var pcs = linePieces(z), own = chain ? chain.pts : [], best = null, i; + if (from && free) return snapCorner(w, z, null, R); + if (from) { + var k8 = Math.round(Math.atan2(w[1] - from[1], w[0] - from[0]) / (Math.PI / 4)); + if (k8 % 2 !== 0) return snapDiagonal(w, from, R, [Math.cos(k8 * Math.PI / 4), Math.sin(k8 * Math.PI / 4)], pcs, own); + } 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 }; }); @@ -157,18 +174,36 @@ 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 + // the far end of a diagonal piece from `from` along unit d: onto a piece's end on that line, a + // piece it crosses or the run's own corners within R, else where x lands on the grid + function snapDiagonal(w, from, R, d, pcs, own) { + var t0 = (w[0] - from[0]) * d[0] + (w[1] - from[1]) * d[1], cands = [], best = null; + var onRay = function (q) { var rx = q[0] - from[0], ry = q[1] - from[1]; if (Math.abs(rx * d[1] - ry * d[0]) < 0.01) cands.push(rx * d[0] + ry * d[1]); }; + pcs.forEach(function (p) { + onRay(p.a); onRay(p.b); + // where the ray crosses the piece's centre line + var ex = p.b[0] - p.a[0], ey = p.b[1] - p.a[1], den = d[0] * ey - d[1] * ex; + if (Math.abs(den) < 1e-9) return; + var ax = p.a[0] - from[0], ay = p.a[1] - from[1], t = (ax * ey - ay * ex) / den, s = (ax * d[1] - ay * d[0]) / den; + if (s >= -1e-6 && s <= 1 + 1e-6) cands.push(t); + }); + own.forEach(onRay); + cands.forEach(function (c) { var dd = Math.abs(c - t0); if (c > 0.05 && dd < R && (!best || dd < best.d)) best = { d: dd, t: c }; }); + var t = best ? best.t : (snap(from[0] + t0 * d[0]) - from[0]) / d[0]; + return [r3(from[0] + t * d[0]), r3(from[1] + t * d[1])]; + } + // the preview of one piece from p to q (at any angle): 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) }]; + var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0; + var L = Math.hypot(q[0] - p[0], q[1] - p[1]) || 1, nx = -(q[1] - p[1]) / L * th / 2, ny = (q[0] - p[0]) / L * th / 2; + var out = [{ pts: [[p[0] + nx, p[1] + ny, z], [q[0] + nx, q[1] + ny, z], [q[0] - nx, q[1] - ny, z], [p[0] - nx, p[1] - ny, 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; + var c = chain, last = c.pts[c.pts.length - 1], q = snapCorner(worldAt(e, c.z), c.z, last, 12 * pxM(e), freeAngle(c.m, 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)); @@ -176,14 +211,20 @@ 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" + (c.m.corner ? ", corners rounded" : "") + " · " + - (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"); + (closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") + + (c.m.directed ? "" : " · diagonals at 45°, Ctrl: any angle") + " · Backspace undoes a corner · Esc cancels"); } - // the turn of a line piece from p to q: along x or y; directed lines (conveyors) point from p to q + // the turn of a line piece from p to q: along x or y, else its angle (0 to 180); directed lines + // (conveyors) point from p to q function lineRot(m, p, q) { - var ax = Math.abs(q[1] - p[1]) <= Math.abs(q[0] - p[0]); + var dx = q[0] - p[0], dy = q[1] - p[1]; + if (Math.abs(dx) > 1e-9 && Math.abs(dy) > 1e-9) { var a = degOf(dx, dy); return m.directed ? (a + 360) % 360 : (a % 180 + 180) % 180; } + var ax = Math.abs(dy) <= Math.abs(dx); if (!m.directed) return ax ? 0 : 90; return ax ? (q[0] >= p[0] ? 0 : 180) : (q[1] >= p[1] ? 90 : 270); } + // Ctrl (Cmd) frees the angle of the piece being drawn; directed lines (conveyors) keep to the axes + function freeAngle(m, e) { return !!(e && (e.ctrlKey || e.metaKey) && !m.directed); } // the corners of a run rounded by the prefab's corner piece (roads: curves of up to its default // radius, less where the pieces next to a corner are short): [segments [p, q]], [curve add ops] function roundRun(m, pts, z) { @@ -194,26 +235,27 @@ var closed = n > 2 && Math.hypot(segs[n - 1][1][0] - segs[0][0][0], segs[n - 1][1][1] - segs[0][0][1]) < 1e-6; var len = segs.map(function (s) { return Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]); }); var dir = segs.map(function (s, k) { return [(s[1][0] - s[0][0]) / len[k], (s[1][1] - s[0][1]) / len[k]]; }); - // turn at the start of segment k (from segment k - 1), 0 where the run goes straight on + // turn (radians, + left) at the start of segment k (from segment k - 1), 0 where the run goes on var turn = segs.map(function (s, k) { if (k === 0 && !closed) return 0; - var a = dir[(k + n - 1) % n], b = dir[k], cr = a[0] * b[1] - a[1] * b[0]; - return Math.abs(cr) > 0.5 && a[0] * b[0] + a[1] * b[1] < 0.5 ? cr : 0; + var a = dir[(k + n - 1) % n], b = dir[k], th = Math.atan2(a[0] * b[1] - a[1] * b[0], a[0] * b[0] + a[1] * b[1]); + return Math.abs(th) > 5 * Math.PI / 180 && Math.abs(th) < 175 * Math.PI / 180 ? th : 0; }); var R0 = +defOf(cm, "r") || 10, rmin = Math.max(+((cm.props || []).filter(function (d) { return d.key === "r"; })[0] || {}).min || 0, (+defOf(m, "d") || 0) / 2 + 0.5); for (var k = 0; k < n; k++) { if (!turn[k]) continue; - var a = (k + n - 1) % n, nextTurn = turn[(k + 1) % n] && (k + 1 < n || closed); - var r = Math.min(R0, len[a] * (turn[a] ? 0.5 : 1), len[k] * (nextTurn ? 0.5 : 1)); + // the curve meets the legs r * tan(turn / 2) from the corner + var a = (k + n - 1) % n, nextTurn = turn[(k + 1) % n] && (k + 1 < n || closed), tn = Math.tan(Math.abs(turn[k]) / 2); + var r = Math.min(R0, len[a] * (turn[a] ? 0.5 : 1) / tn, len[k] * (nextTurn ? 0.5 : 1) / tn), T = r * tn; if (r < rmin) continue; var P = segs[k][0], da = dir[a], db = dir[k]; - var s0 = [P[0] - da[0] * r, P[1] - da[1] * r]; - var add = { op: "add", type: m.corner, x: r3(s0[0]), y: r3(s0[1]), rot: (Math.round(Math.atan2(da[1], da[0]) * 2 / Math.PI) * 90 + 360) % 360, - props: { r: r3(r), angle: 90, turn: turn[k] > 0 ? "left" : "right" } }; + var s0 = [P[0] - da[0] * T, P[1] - da[1] * T], deg = Math.abs(turn[k]) * 180 / Math.PI; + var add = { op: "add", type: m.corner, x: r3(s0[0]), y: r3(s0[1]), rot: (lineRot({ directed: true }, [0, 0], da) + 360) % 360, + props: { r: r3(r), angle: Math.abs(deg - Math.round(deg)) < 1e-6 ? Math.round(deg) : r3(deg), turn: turn[k] > 0 ? "left" : "right" } }; if (z) add.z = z; curves.push(add); segs[a][1] = s0; - segs[k][0] = [P[0] + db[0] * r, P[1] + db[1] * r]; + segs[k][0] = [P[0] + db[0] * T, P[1] + db[1] * T]; } return { segs: segs, curves: curves }; } @@ -321,7 +363,7 @@ down: function (e, hit) { if (S.placing) { var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z); - a = pm.shape === "line" ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e)) : [snap(a[0]), snap(a[1])]; + a = pm.shape === "line" ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e), freeAngle(pm, e)) : [snap(a[0]), snap(a[1])]; tool = { m: pm, z: z, a: a, b: null }; return true; } @@ -406,14 +448,13 @@ var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w")); ghost(sm ? mountGhost(m, sm) : null); } - // a drag while placing: "line" prefabs run from the press to the pointer along the main axis, - // "rect" prefabs (and rooms) fill the rectangle + // a drag while placing: "line" prefabs run from the press to the pointer (snapped to x, y or a + // diagonal by snapCorner), "rect" prefabs (and rooms) fill the rectangle function toolGeom(t) { var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1]; if (t.m.shape === "line") { - var ax = Math.abs(dx) >= Math.abs(dy), L = Math.abs(ax ? dx : dy); - return { line: true, ax: ax, len: L, x: ax ? (a[0] + b[0]) / 2 : a[0], y: ax ? a[1] : (a[1] + b[1]) / 2, ok: L >= 0.25, - rot: lineRot(t.m, a, ax ? [b[0], a[1]] : [a[0], b[1]]) }; + var L = Math.hypot(dx, dy); + return { line: true, len: L, a: a, b: b, x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, ok: L >= 0.25, rot: lineRot(t.m, a, b) }; } return { w: Math.abs(dx), d: Math.abs(dy), x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]), ok: Math.abs(dx) >= 0.25 && Math.abs(dy) >= 0.25 }; @@ -421,14 +462,13 @@ function drawTool(e) { var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = []; if (chain) { drawChain(e); return; } - t.b = m.shape === "line" ? snapCorner(w, z, t.a, 12 * pxM(e)) : [snap(w[0]), snap(w[1])]; + t.b = m.shape === "line" ? snapCorner(w, z, t.a, 12 * pxM(e), freeAngle(m, e)) : [snap(w[0]), snap(w[1])]; if (m.shape !== "line" && m.shape !== "rect") return; var g = toolGeom(t); if (g.line) { - var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, L = g.len / 2; - polys.push({ pts: g.ax ? rectPts(g.x - L, g.y - th / 2, g.x + L, g.y + th / 2, z) : rectPts(g.x - th / 2, g.y - L, g.x + th / 2, g.y + L, z) }); - if (h > 0) polys.push({ pts: planePts(g.ax ? [g.x - L, g.y] : [g.x, g.y - L], g.ax ? [g.x + L, g.y] : [g.x, g.y + L], z, z + h), cls: "st-plane" }); - ghost(polys, m.label + " " + g.len.toFixed(2) + " m" + (m.directed ? ", running from where you started" : "") + " · release to place"); + polys = pieceGhost(m, g.a, g.b, z); + var tilt = onAxes(g.rot) ? "" : ", " + (+g.rot.toFixed(1)) + "°"; + ghost(polys, m.label + " " + g.len.toFixed(2) + " m" + tilt + (m.directed ? ", running from where you started" : "") + " · release to place" + (m.directed ? "" : " · Ctrl: any angle")); return; } polys.push({ pts: rectPts(g.x0, g.y0, g.x1, g.y1, z) }); @@ -446,7 +486,7 @@ 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)); + var last = chain.pts[chain.pts.length - 1], q = snapCorner(at, t.z, last, 12 * pxM(e), freeAngle(m, 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); @@ -485,7 +525,7 @@ [[x0, y0, x1, y0], [x0, y1, x1, y1], [x0, y0, x0, y1], [x1, y0, x1, y1]].forEach(function (e) { var ax = e[1] === e[3], line = ax ? e[1] : e[0], lo = ax ? e[0] : e[1], hi = ax ? e[2] : e[3], cov = []; walls.forEach(function (w) { - if ((qOf(w.rot) % 2 === 0) !== ax || Math.abs((ax ? w.y : w.x) - line) > 0.05) return; + if (!onAxes(w.rot) || (qOf(w.rot) % 2 === 0) !== ax || Math.abs((ax ? w.y : w.x) - line) > 0.05) return; var c = ax ? w.x : w.y, L = (+propOf(w, "w") || 0) / 2; cov.push([c - L, c + L]); }); @@ -667,7 +707,7 @@ var o = obj(id); if (!o) return; var dd = Math.max(snapStep(), 1) * 2, dx = dd, dy = dd, h = hostOf(o); // a copy of a window or door goes further along its wall - if (h) { var v = turnV(qOf(h.host.rot), (+propOf(o, "w") || 1) + 0.5, 0); dx = r3(v[0]); dy = r3(v[1]); } + if (h) { var v = turnA(h.host.rot || 0, (+propOf(o, "w") || 1) + 0.5, 0); dx = r3(v[0]); dy = r3(v[1]); } opList(mountedOn(o).map(function (c) { return { op: "duplicate", id: c.id, dx: dx, dy: dy }; }).concat([{ op: "duplicate", id: id, dx: dx, dy: dy }])); } function moveWith(id, dx, dy, carry, quiet) { @@ -824,7 +864,7 @@ S.warnings.filter(function (w) { return w.indexOf("object " + o.id + ":") === 0; }).map(function (w) { return '
' + esc(w) + "
"; }).join("") + '

Placement

' + field("xy", "Position x y z", '
') + - field("rot", "Rotation", '
' + (o.rot || 0) + '°
') + + field("rot", "Rotation", '
°
') + "
" + (m.props.length ? "

Properties

" + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "
" : "") + '

Label and tooltip

' + diff --git a/tools/check_studio.py b/tools/check_studio.py index 97af45c..af5ff91 100644 --- a/tools/check_studio.py +++ b/tools/check_studio.py @@ -503,6 +503,58 @@ with sync_playwright() as p: pg.wait_for_timeout(900) check(len(doc()["objects"]) == n4 and kerbs() == k0, "one undo takes the road run back") + # diagonals, east of the site: a fence run snaps its middle leg to 45 degrees, Ctrl frees the angle of a + # dragged road, a road run turning 45 degrees gets a 45-degree curve, the rotation can be typed + pg.evaluate("__studio.view.focus([48, 8, 0], 1.8)") + pg.wait_for_timeout(400) + n5 = len(doc()["objects"]) + pg.locator('.st-tile[data-type="fence"]').click() + for x, y in [(38, -10), (44, -10.1), (49.7, -4.2), (50.1, 4)]: + pg.mouse.click(*at(x, y)) + pg.wait_for_timeout(150) + check("diagonals at 45°" in pg.locator("#hint").inner_text(), "a run says how diagonals snap") + pg.keyboard.press("Enter") + pg.wait_for_timeout(1000) + got = sorted((o["x"], o["y"], o.get("rot", 0), o["props"]["w"]) for o in doc()["objects"][n5:]) + check(got == [(41, -10, 0, 6), (47, -7, 45, 8.485), (50, 0, 90, 8)], f"a fence run takes a 45-degree leg: {got}") + pg.locator('.st-tile[data-type="road"]').click() + pg.keyboard.down("Control") + pg.mouse.move(*at(38, 12)) + pg.mouse.down() + for i in range(1, 11): + pg.mouse.move(*at(38 + 1.2 * i, 12 + 0.6 * i)) + pg.wait_for_timeout(20) + pg.mouse.up() + pg.keyboard.up("Control") + pg.wait_for_timeout(1000) + free = doc()["objects"][-1] + check(free["type"] == "road" and abs(free["rot"] - 26.5651) < 1e-6 and abs(free["props"]["w"] - 13.416) < 1e-6, + f"Ctrl draws a road at any angle: rot {free.get('rot')}, w {free['props'].get('w')}") + n6 = len(doc()["objects"]) + pg.locator('.st-tile[data-type="road"]').click() + for x, y in [(38, 24), (52, 24), (62.2, 34.1)]: + pg.mouse.click(*at(x, y)) + pg.wait_for_timeout(150) + pg.keyboard.press("Enter") + pg.wait_for_timeout(1000) + got = sorted((o["type"], o["x"], o["y"], o.get("rot", 0), o["props"].get("w", o["props"].get("r")), o["props"].get("angle")) for o in doc()["objects"][n6:]) + want = sorted([("road", 42.929, 24, 0, 9.858, None), ("road", 58.464, 30.464, 45, 10, None), ("road_curve", 47.858, 24, 0, 10, 45)]) + check(got == want, f"a road run turning 45 degrees gets a 45-degree curve: {got}") + pg.evaluate(f"__studio.select('{free['id']}')") + pg.wait_for_timeout(300) + rin = pg.locator('#right input[data-f="rot"]') + rin.fill("30") + rin.press("Enter") + pg.wait_for_timeout(800) + check(obj(free["id"])["rot"] == 30, f"the rotation can be typed: {obj(free['id'])['rot']}") + pg.evaluate("__studio.select(null)") + pg.wait_for_timeout(300) + pg.screenshot(path=str(OUT / "studio-diagonals.jpg"), type="jpeg", quality=80) + for _ in range(4): + pg.locator("#undo").click() + pg.wait_for_timeout(700) + check(len(doc()["objects"]) == n5, "four undos take the diagonals back") + # an agent edits over MCP: the open Studio follows init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}) check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize")