Diagonals: streets, walls, fences, hedges and house walls at any angle
Deploy web Studio / deploy (push) Successful in 1m5s

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.
This commit is contained in:
grabowski committed 2026-10-25 13:22:59 +07:00
1 parent c91d63663b
commit 96d472625f
18 files changed
+1051 -178

No files matched your search

+8 -2
View File
@@ -48,11 +48,11 @@ Try the demos: `isokit studio examples/demo` (a works site),
| Do | How | | Do | How |
|---|---| |---|---|
| Add | pick an object on the left, click on the map (Shift-click places several) | | 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 | | 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 | | 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) | | 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 | | Edit | the right panel: size, storeys, colours, solar, tooltip text, label, levels |
| Copy / delete | Ctrl+D / Delete; Ctrl+Z / Ctrl+Y undo and redo | | 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 | | 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 lines, so put the ends on each other. `examples/streets` (Streets demo in the
web Studio) is a small town quarter. 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 Animations (fans, smoke, fish, traffic) step on about ten times a second by
default ("light"): running CSS animations repaint the whole drawing every frame, default ("light"): running CSS animations repaint the whole drawing every frame,
which keeps a slow laptop busy. The ▷ button in the view toolbar switches which keeps a slow laptop busy. The ▷ button in the view toolbar switches
+15 -1
View File
@@ -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<sort::Hull> {
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). /// Sort box of a sprite in a rotation: (view box, screen box, has extent).
pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) { pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) {
let mut bb: Bb = [1e9, 1e9, -1e9, -1e9, 1e9, -1e9]; 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<usize> = if layer.opts.sort { let idx: Vec<usize> = if layer.opts.sort {
let pairs: Vec<(Bb, Sb)> = bx.iter().map(|(b, s, _)| (*b, *s)).collect(); let pairs: Vec<(Bb, Sb)> = bx.iter().map(|(b, s, _)| (*b, *s)).collect();
let o = sort::sort(&pairs, layer.opts.zfirst); let hulls: Vec<Option<sort::Hull>> = 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; cycles += o.cycles;
o.order o.order
} else { } else {
+148 -6
View File
@@ -3,15 +3,61 @@
//! Each sprite has a view-space box `bb = [vx0, vy0, vx1, vy1, z0, z1]` and a //! 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 //! 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, //! 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 //! then y, then z (or z first under ground). Sprites whose plan outline is not
//! topological order, choosing the smallest centre key among the ready //! their box (pieces turned off the axes, mitred ends) also carry their convex
//! sprites; a cycle is broken at the sprite with the fewest unmet edges. //! 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 Bb = [f64; 6];
pub type Sb = [f64; 4]; 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). /// The convex outline of view-space plan points, or None when it is just
pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 { /// the box `bb` (nothing to add to the box test).
pub fn hull_of(pts: &[[f64; 2]], bb: &Bb) -> Option<Hull> {
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<dyn Iterator<Item = &[f64; 2]>> = 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; let e = 1e-6;
if zf { if zf {
if a[5] <= b[4] + e { 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 { if b[5] <= a[4] + e {
return 1; 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 ka = a[0] + a[2] + a[1] + a[3];
let kb = b[0] + b[2] + b[1] + b[3]; let kb = b[0] + b[2] + b[1] + b[3];
if ka < kb { 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. /// Result of [`sort`]: the draw order and how many times a cycle had to be broken.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct Order { pub struct Order {
@@ -58,7 +156,14 @@ pub struct Order {
} }
pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> 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<Hull>], zf: bool) -> Order {
let n = boxes.len(); let n = boxes.len();
let hull = |i: usize| hulls.get(i).and_then(|h| h.as_ref());
let mut adj: Vec<Vec<usize>> = vec![vec![]; n]; let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
let mut indeg = vec![0usize; n]; let mut indeg = vec![0usize; n];
let mut done = vec![false; 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] { if asb[2] <= bsb[0] || bsb[2] <= asb[0] || asb[3] <= bsb[1] || bsb[3] <= asb[1] {
continue; 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 => { c if c < 0 => {
adj[i].push(j); adj[i].push(j);
indeg[j] += 1; indeg[j] += 1;
@@ -171,4 +285,32 @@ mod tests {
let hi = bx(1.0, 1.0, 3.0, 3.0, -1.0, 0.0); 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]); 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
);
}
} }
+74 -9
View File
@@ -9,25 +9,44 @@ use crate::prim::{Fish, Prim, Style, Zs, P2, P3};
use crate::proj::C; use crate::proj::C;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Placement frame: translate by (x, y, z) after turning `q` quarter turns /// Placement frame: translate by (x, y, z) after turning counter-clockwise
/// counter-clockwise (seen from above). /// (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)] #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Xf { pub struct Xf {
pub x: f64, pub x: f64,
pub y: f64, pub y: f64,
pub z: f64, pub z: f64,
pub q: u8, pub q: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cs: Option<[f64; 2]>,
} }
impl Xf { 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 { 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; let qf = (deg / 90.0).round();
Xf { x, y, z, q } 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. /// Turn a plan vector into the world.
#[inline] #[inline]
pub fn v(&self, x: f64, y: f64) -> P2 { 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 { match self.q {
0 => [x, y], 0 => [x, y],
1 => [-y, x], 1 => [-y, x],
@@ -38,6 +57,9 @@ impl Xf {
/// World plan vector back into the frame. /// World plan vector back into the frame.
#[inline] #[inline]
pub fn v_inv(&self, x: f64, y: f64) -> P2 { 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 { match self.q {
0 => [x, y], 0 => [x, y],
1 => [y, -x], 1 => [y, -x],
@@ -59,8 +81,19 @@ impl Xf {
pub fn z(&self, z: f64) -> f64 { pub fn z(&self, z: f64) -> f64 {
z + self.z 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] { 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 a = self.p(x0, y0);
let b = self.p(x1, y1); let b = self.p(x1, y1);
[ [
@@ -188,9 +221,16 @@ impl Sprite {
z1: f64, z1: f64,
st: Style, st: Style,
) -> &mut Self { ) -> &mut Self {
let r = self.xf.rect(x0, y0, x1, y1);
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1)); 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, z0);
self.ext_world(&pts, z1); self.ext_world(&pts, z1);
self.push(Prim::Prism { 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]); 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] #[test]
fn frame_round_trips_points() { 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 f = Xf::new(3.0, -4.0, 0.0, deg);
let w = f.p(1.5, 2.5); let w = f.p(1.5, 2.5);
let l = f.inv(w[0], w[1]); let l = f.inv(w[0], w[1]);
+8 -5
View File
@@ -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). \ 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), \ 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 \ 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. \ 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 \ 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 \ 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 \ 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 \ 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 \ 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). \ 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. \ 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, \ 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 \ 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 \ 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 sharp corner. \ 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 \ 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."; roads' ends exactly there, with the same width as road_d.";
@@ -47,7 +50,7 @@ pub fn tools() -> Vec<Value> {
"x": { "type": "number", "description": "east, metres (centre of the footprint)" }, "x": { "type": "number", "description": "east, metres (centre of the footprint)" },
"y": { "type": "number", "description": "north, metres" }, "y": { "type": "number", "description": "north, metres" },
"z": { "type": "number", "description": "base height, metres (default 0)" }, "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" }, "props": { "type": "object", "description": "prefab properties (see list_prefabs); in updates they merge, null removes" },
"name": { "type": "string", "description": "name shown in the tooltip" }, "name": { "type": "string", "description": "name shown in the tooltip" },
"info": { "type": "string", "description": "tooltip text" }, "info": { "type": "string", "description": "tooltip text" },
+33 -1
View File
@@ -84,8 +84,19 @@ fn finite(v: f64, what: &str) -> Result<f64, String> {
} }
} }
/// 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 { 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: /// Check property values against the prefab schema (unknown keys pass:
@@ -364,7 +375,28 @@ mod tests {
.id .id
.unwrap(); .unwrap();
assert_eq!(id, "tree"); 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); 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(); 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].x, 7.5);
assert_eq!(d.objects[0].props["r"], json!(2.0)); assert_eq!(d.objects[0].props["r"], json!(2.0));
+1 -1
View File
@@ -27,7 +27,7 @@ pub struct Doc {
} }
/// One placed object. Its prefab (`kind`) draws it around (x, y, z), turned /// 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)] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Object { pub struct Object {
pub id: String, pub id: String,
+156
View File
@@ -848,6 +848,162 @@ fn streets_curve_join_and_meet_at_roundabouts() {
assert!(html.contains("iso-giveway") && html.contains("iso-kerbf")); 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] #[test]
fn site_walls_join_at_corners() { fn site_walls_join_at_corners() {
let r = Runtime::new(&Bundle::default()).unwrap(); let r = Runtime::new(&Bundle::default()).unwrap();
+121 -26
View File
@@ -103,19 +103,34 @@ fn meta_wall() {
] } ] }
} }
fn prefab_wall(o) { fn prefab_wall(o) {
// ends joined to the walls they meet; long walls in pieces of at most 30 m, // ends joined to the walls they meet (mitred and slanted where a wall is
// each its own sprite for the depth sort // diagonal); long walls in pieces of at most 30 m, each its own sprite for
// the depth sort
let p = o.p; let p = o.p;
let x0 = -p.w / 2.0 - line_joint(o, -1.0, ["wall"], []).ext; let c0 = wall_end(o, -1.0);
let x1 = p.w / 2.0 + line_joint(o, 1.0, ["wall"], []).ext; 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 n = ((x1 - x0) / 30.0).ceiling().to_int();
let L = (x1 - x0) / n; let L = (x1 - x0) / n;
for i in 0..n { for i in 0..n {
let a = x0 + i * L; 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"] } fn fence_styles() { ["mesh", "wood", "bars", "half stone", "cobble", "brick"] }
/// Half the thickness of a fence design: its posts, base or pillars. /// Half the thickness of a fence design: its posts, base or pillars.
@@ -135,10 +150,13 @@ fn meta_fence() {
fn prefab_fence(o) { fn prefab_fence(o) {
let p = o.p; let p = o.p;
let st = p.style; let st = p.style;
let ea = line_joint(o, -1.0, ["fence"], ["wall", "house_wall"]); let ea = fence_end(o, -1.0);
let eb = line_joint(o, 1.0, ["fence"], ["wall", "house_wall"]); let eb = fence_end(o, 1.0);
let x0 = -p.w / 2.0 - ea.ext; // x0, x1: the base's ends (stone, cobble); u0, u1: the panels', rails' and boards'
let x1 = p.w / 2.0 + eb.ext; let x0 = ea.x;
let x1 = eb.x;
let u0 = ea.up;
let u1 = eb.up;
if x1 - x0 < 0.05 { return; } if x1 - x0 < 0.05 { return; }
// posts (pillars) evenly along the length; the end ones only where this // posts (pillars) evenly along the length; the end ones only where this
// fence draws them, kept inside the stretch it covers // fence draws them, kept inside the stretch it covers
@@ -149,10 +167,17 @@ fn prefab_fence(o) {
if st != "cobble" { if st != "cobble" {
for j in 0..(k + 1) { for j in 0..(k + 1) {
if (j == 0 && !ea.post) || (j == k && !eb.post) { continue; } 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 // pieces of at most 30 m, each its own sprite for the depth sort
let n = ((x1 - x0) / 30.0).ceiling().to_int(); let n = ((x1 - x0) / 30.0).ceiling().to_int();
let L = (x1 - x0) / n; let L = (x1 - x0) / n;
@@ -160,16 +185,83 @@ fn prefab_fence(o) {
let a = x0 + i * L; let a = x0 + i * L;
let b = a + L; let b = a + L;
let ps = []; let ps = [];
let ua = if i == 0 { u0 } else { a };
let ub = if i == n - 1 { u1 } else { b };
for x in posts { 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. /// Post half widths of the fence designs (0: none).
fn fence_piece(s, a, b, ps, h, st, seed) { 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 L = b - a;
let ca = cut.a;
let cb = cut.b;
let ka = cut.ka;
let kb = cut.kb;
if st == "wood" { if st == "wood" {
s.box(a, -0.025, b, 0.025, 0.0, h, "wood"); s.box(a, -0.025, b, 0.025, 0.0, h, "wood");
let boards = []; 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"); } 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" { } else if st == "half stone" {
let hb = fmin(0.6, h * 0.4); let hb = fmin(0.6, h * 0.4);
s.box(a, -0.16, b, 0.16, 0.0, hb, "cobble", #{ top: "capst" }); s.cut_box(ca, ka, cb, kb, -0.16, 0.16, 0.0, hb, "cobble", #{ top: "capst" });
let j = stone_joints(L, 0.0, hb, seed); let f0 = ca - ka * 0.16;
s.open([a, -0.16], [b, -0.16], j, "seam"); let f1 = ca + ka * 0.16;
s.open([b, 0.16], [a, 0.16], j, "seam"); let j = stone_joints(cb - kb * 0.16 - f0, 0.0, hb, seed);
s.box(a, -0.03, b, 0.03, hb, h, "fence"); s.open([f0, -0.16], [cb - kb * 0.16, -0.16], j, "seam");
for x in ps { s.box(x - 0.04, -0.04, x + 0.04, 0.04, hb, h + 0.05, "steel"); } 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" { } else if st == "cobble" {
let hc = fmax(0.2, h - 0.08); let hc = fmax(0.2, h - 0.08);
s.box(a, -0.2, b, 0.2, 0.0, hc, "cobble"); s.cut_box(ca, ka, cb, kb, -0.2, 0.2, 0.0, hc, "cobble");
s.box(a, -0.22, b, 0.22, hc, h, "capst"); s.cut_box(ca, ka, cb, kb, -0.22, 0.22, hc, h, "capst");
let j = stone_joints(L, 0.0, hc, seed); let f0 = ca - ka * 0.2;
s.open([a, -0.2], [b, -0.2], j, "seam"); let f1 = ca + ka * 0.2;
s.open([b, 0.2], [a, 0.2], j, "seam"); 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 { } else {
// mesh // mesh
s.box(a, -0.03, b, 0.03, 0.0, h, "fence"); s.box(a, -0.03, b, 0.03, 0.0, h, "fence");
+22 -7
View File
@@ -126,13 +126,26 @@ fn prefab_house_wall(o) {
gaps.push(opening_span(o, m.u, m.p.w)); 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 }); 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 { 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]); x = fmax(x, g[1]);
} }
let end = p.w / 2.0 + wall_ext(o, 1.0); if c1.x > x + 0.01 { wall_run(x, k, c1.x, c1.k, t, top, p.mat, cap); }
if end > x + 0.01 { wall_run(x, end, 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: /// 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 ext
} }
/// A solid stretch of wall from x0 to x1, one sprite per 30 m at most. /// A solid stretch of wall from x0 to x1 (ends slanted by k0, k1), one
fn wall_run(x0, x1, t, top, mat, cap) { /// 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 n = ((x1 - x0) / 30.0).ceiling().to_int();
let L = (x1 - x0) / n; let L = (x1 - x0) / n;
for i in 0..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 });
} }
} }
+9 -1
View File
@@ -47,7 +47,15 @@ fn meta_hedge() {
} }
fn prefab_hedge(o) { fn prefab_hedge(o) {
let p = o.p; 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 // ---------------------------------------------------------------- pond
+141 -69
View File
@@ -152,21 +152,6 @@ fn pl_end(p, e) {
[q[0], q[1], (b[0] - a[0]) / l, (b[1] - a[1]) / l] [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 // ---------------------------------------------------------- kerbs and sidewalks
/// The faces up from line k to height h whose outward normal (to the right /// 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 /// 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, /// right) and its back line o (to the left): its paving (with seam patches,
/// back edge and the ends where caps says [start, end] (not where another /// polygons laid over mitred joints), back edge and the ends where caps says
/// sidewalk carries on). The kerb face goes with the kerb. /// [start, end] (not where another sidewalk carries on). The kerb face goes
fn walk_strip(k, o, caps) { /// with the kerb.
if k.len() < 2 || o.len() != k.len() { return this; } fn walk_strip(k, o, caps, patches) {
if k.len() < 2 || o.len() < 2 { return this; }
let h = walk_h(); 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 fs = kerb_faces(o, h, false);
let n = k.len();
if caps[0] { fs += kerb_faces([o[0], k[0]], h, true); } 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"); } if fs.len() > 0 { this.poly(fs, "kerbf"); }
this this
} }
fn walk_strip(k, o, caps) { this.walk_strip(k, o, caps, []) }
// ---------------------------------------------------------- where streets meet // ---------------------------------------------------------- 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)): /// 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 /// #{ asph, dash, kerb, walk, free, meet, pd }. Each of the first four is a
/// line, kerbs and sidewalks (left and right looking out) reach past e /// cut #{ e, k }: the line w to the left looking out of the end (its
/// (negative: they stop short of it), whether nothing meets it there, and /// carriageway edge, kerb or sidewalk edge) reaches e + k * w past it
/// the id of the street that does. /// (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) { 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 // pieces that carry on overlap by a few cm: edge to edge, the ground shows through as a hairline
let lap = 0.04; let lap = 0.04;
let u = [e.dx, e.dy];
let n = [-e.dy, e.dx];
for f in others { for f in others {
if (f.x - e.x).abs() > 0.3 || (f.y - e.y).abs() > 0.3 { continue; } if (f.x - e.x).abs() > 0.3 || (f.y - e.y).abs() > 0.3 { continue; }
j.free = false; j.free = false;
j.meet = f.id; j.meet = f.id;
let dot = e.dx * f.dx + e.dy * f.dy; let uf = [f.dx, f.dy];
if dot < -0.9 { j.asph = lap; j.kl = lap; j.kr = lap; j.wl = lap; j.wr = lap; } let dot = dot2(u, uf);
if dot.abs() > 0.2 { continue; } if dot < -0.9999 {
// a corner: the other street's body lies to the left (cross > 0) or right let l = #{ e: lap, k: 0.0 };
let left = e.dx * -f.dy - e.dy * -f.dx > 0.0; j.asph = l;
// its outer side is the one away from this street's body j.kerb = l;
let mine_on_its_left = f.dx * -e.dy - f.dy * -e.dx > 0.0; j.walk = l;
let wo = if mine_on_its_left { f.wr } else { f.wl }; continue;
let h = f.d / 2.0; }
j.asph = h; if dot > 0.9 { continue; }
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; } // 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; 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) { 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; } 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 ws = walk_widths(c.p);
let h = c.p.d / 2.0; 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.free = false;
j.meet = c.id; j.meet = c.id;
j.dash = -h; j.asph = at.call(0.0);
j.kl = -h; j.dash = at.call(sd * h);
j.kr = -h; j.kerb = at.call(sd * h);
j.wl = -(h + w) + 0.04; let wb = at.call(sd * (h + w));
j.wr = -(h + w) + 0.04; j.walk = #{ e: wb.e + 0.04, k: k };
return j; return j;
} }
j j
} }
/// Where the streets in `others` end on road o's sides: gaps [left, right] /// 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, /// in its kerbs and sidewalks, each #{ lo, hi, c }: from lo + c * v to
/// then the ids of those streets. /// 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) { fn street_gaps(o, others) {
let g = [[], [], []]; let g = [[], [], []];
for f in others { for f in others {
let l = local_of(o, f.x, f.y); 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; } 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 b = local_of(o, f.x - f.dx, f.y - f.dy);
let s = l[0] + o.p.w / 2.0; let d = [l[0] - b[0], l[1] - b[1]];
let r = [s - f.d / 2.0, s + f.d / 2.0]; 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); } if b[1] > l[1] { g[0].push(r); } else { g[1].push(r); }
g[2].push(f.id); g[2].push(f.id);
} }
@@ -314,31 +333,80 @@ fn other_street_ends(o) {
out 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 /// 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 /// 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] /// 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) { fn street_path(o, c, j0, j1, gaps) {
let p = o.p; let p = o.p;
let L = pl_len(c); let L = pl_len(c);
let h = p.d / 2.0; let h = p.d / 2.0;
let ws = walk_widths(p); let ws = walk_widths(p);
let s = sprite(); let s = sprite();
let ca = pl_cut(c, -j0.asph, L + j1.asph); // a part's ends as bounds (v: left of the way; at the start that is the
s.flat(pl_off(ca, h) + rev(pl_off(ca, -h)), 0.007, "g-asph t"); // right looking out, so w = -v there)
if p.dashed && L + j1.dash + j0.dash > 0.05 { let b0 = |cut| #{ s: -cut.e, c: cut.k };
s.polyline(pl_off(pl_cut(c, -j0.dash, L + j1.dash), 0.0), 0.012, "dash"); 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] { for side in [0, 1] {
let sg = if side == 0 { 1.0 } else { -1.0 }; let sg = if side == 0 { 1.0 } else { -1.0 };
let k0 = if side == 0 { j0.kr } else { j0.kl }; let vk = sg * h;
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 ww = ws[side]; let ww = ws[side];
for r in ranges(-k0, L + k1, gaps[side]) { for r in ranges_b(K0, K1, gaps[side], vk) {
let line = pl_off(pl_cut(c, r[0], r[1]), sg * h); let line = seg.call(r[0], r[1], vk);
if side == 1 { line.reverse(); } if side == 1 { line.reverse(); }
if ww > 0.0 { if ww > 0.0 {
let fs = kerb_faces(line, walk_h(), true); let fs = kerb_faces(line, walk_h(), true);
@@ -348,17 +416,21 @@ fn street_path(o, c, j0, j1, gaps) {
} }
} }
if ww > 0.0 { 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() { for i in 0..rs.len() {
let r = rs[i]; let r = rs[i];
let cut = pl_cut(c, r[0], r[1]); let line = seg.call(r[0], r[1], vk);
let line = pl_off(cut, sg * h); let back = seg.call(r[0], r[1], vb);
let back = pl_off(cut, sg * (h + ww)); // 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) // caps where a piece ends at a gap or a free street end (sides run opposite ways)
let at0 = i > 0 || j0.free; let at0 = i > 0 || j0.free;
let at1 = i + 1 < rs.len() || j1.free; let at1 = i + 1 < rs.len() || j1.free;
if side == 1 { line.reverse(); back.reverse(); } 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() { fn meta_road_curve() {
#{ label: "Road curve", category: "Ground", icon: "◜", #{ 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: [ props: [
#{ key: "r", type: "float", def: 10.0, min: 3.0, max: 300.0, step: 0.5, label: "Radius (m)" }, #{ 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)" }, #{ key: "angle", type: "float", def: 90.0, min: 5.0, max: 180.0, step: 5.0, label: "Angle (deg)" },
+130 -2
View File
@@ -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). /// 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 } 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) { 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 /// How line piece o (a fence or wall along its local x, length p.w) meets
+14 -1
View File
@@ -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 (hover preview) and move, turn, copy and delete with them, new parts land on the storey
shown, collapsible palette that scrolls on its own shown, collapsible palette that scrolls on its own
- [x] `examples/house` (a furnished house built from parts), House demo in the web Studio - [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) ## 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) 7. ~~Roundabouts~~ (done, see below)
8. Waypoint animation: a person or car moving along a path of waypoints 8. Waypoint animation: a person or car moving along a path of waypoints
9. ~~Build your own house~~ (done, see above) 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 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); 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 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. 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 ## Phase 2: richer editing
- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open - [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open
+38 -8
View File
@@ -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 | | `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 | | `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` | | `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 | | `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? | | `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) | | `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 cobble, brick) and the site `wall` use it; `house_wall` has its own version
(`wall_ext`, house.rhai). (`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 ## Production lines
`producer`, `conveyor` and `robot_arm` (stdlib/industry.rhai) work together `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. 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) - 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. 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 - A street ending on a road's centre line makes a T, at any angle: the road
its kerb and sidewalk on that side, the street stops its centre line and leaves a gap in its kerb and sidewalk on that side (slanted along the
kerbs at the road's edge and its sidewalks at the road's sidewalk. street coming in), the street ends its asphalt on the road's centre line,
- Two roads ending at one point at right angles close the corner: both run its centre line and kerbs at the road's edge and its sidewalks at the
over it, the inner sidewalks stop at the other road's kerb, the outer ones road's sidewalk.
go round. - 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 - 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 +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. 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 the same sprite as the storey below. Start walls and furniture standing on
them a little above the floor (+0.02 m). them a little above the floor (+0.02 m).
- **Movers** (`drive`) run along the axis the sort compares second - **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. - Street furniture stays off drive areas, and pipe runs end on walls or supports.
## Themes ## Themes
+3 -1
View File
@@ -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": "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": "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": "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": "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": "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}}, {"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-2", "type": "person", "x": -26, "y": -4.7},
{"id": "walker-3", "type": "person", "x": 31.6, "y": 30, "props": {"cls": "guest3"}}, {"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": "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-2", "type": "lamp", "x": 44, "y": -5.2},
{"id": "lamp-3", "type": "lamp", "x": -28, "y": 5.2}, {"id": "lamp-3", "type": "lamp", "x": -28, "y": 5.2},
{"id": "lamp-4", "type": "lamp", "x": -44, "y": 5.2}, {"id": "lamp-4", "type": "lamp", "x": -44, "y": 5.2},
+78 -38
View File
@@ -46,8 +46,18 @@
function worldAt(e, z) { return S.view.toWorld(e.clientX, e.clientY, z || 0); } 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 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 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); } // is rot (degrees) a multiple of 90: does the object keep to the world axes?
function worldOf(o, u, v) { var d = turnV(qOf(o.rot), u, v); return [o.x + d[0], o.y + d[1]]; } 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 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 propOf(o, k) { var p = o.props || {}; return p[k] !== undefined ? p[k] : defOf(prefab(o.type), k); }
function clampProp(m, k, v) { function clampProp(m, k, v) {
@@ -120,14 +130,21 @@
function linePieces(z) { 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) { 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; 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 // 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 // to x or y (within 22.5 degrees) or a diagonal at 45, and its far end snaps onto an end or the
// corners), else onto the grid; without, onto the nearest end, else onto a line, else the grid // line of a piece it meets (or the run's own corners), else onto the grid; `free` (Ctrl held): any
function snapCorner(w, z, from, R) { // 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; 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) { if (!from) {
pcs.forEach(function (p) { 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 }; }); [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; out[k] = r3(best ? best.c : snap(f)); out[lk] = fix;
return out; 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) { 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 th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0;
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 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: ax ? rectPts(x0, y0 - th / 2, x1, y0 + th / 2, z) : rectPts(x0 - th / 2, y0, x0 + th / 2, y1, z) }]; 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" }); if (h > 0) out.push({ pts: planePts(p, q, z, z + h), cls: "st-plane" });
return out; 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 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 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) { 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)); 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)); 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)); 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); 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" + 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" : "") + " · " + (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) { 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; if (!m.directed) return ax ? 0 : 90;
return ax ? (q[0] >= p[0] ? 0 : 180) : (q[1] >= p[1] ? 90 : 270); 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 // 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] // radius, less where the pieces next to a corner are short): [segments [p, q]], [curve add ops]
function roundRun(m, pts, z) { 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 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 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]]; }); 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) { var turn = segs.map(function (s, k) {
if (k === 0 && !closed) return 0; 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]; 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(cr) > 0.5 && a[0] * b[0] + a[1] * b[1] < 0.5 ? cr : 0; 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); 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++) { for (var k = 0; k < n; k++) {
if (!turn[k]) continue; if (!turn[k]) continue;
var a = (k + n - 1) % n, nextTurn = turn[(k + 1) % n] && (k + 1 < n || closed); // the curve meets the legs r * tan(turn / 2) from the corner
var r = Math.min(R0, len[a] * (turn[a] ? 0.5 : 1), len[k] * (nextTurn ? 0.5 : 1)); 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; if (r < rmin) continue;
var P = segs[k][0], da = dir[a], db = dir[k]; var P = segs[k][0], da = dir[a], db = dir[k];
var s0 = [P[0] - da[0] * r, P[1] - da[1] * r]; 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: (Math.round(Math.atan2(da[1], da[0]) * 2 / Math.PI) * 90 + 360) % 360, 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: 90, turn: turn[k] > 0 ? "left" : "right" } }; 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; if (z) add.z = z;
curves.push(add); curves.push(add);
segs[a][1] = s0; 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 }; return { segs: segs, curves: curves };
} }
@@ -321,7 +363,7 @@
down: function (e, hit) { down: function (e, hit) {
if (S.placing) { if (S.placing) {
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z); 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 }; tool = { m: pm, z: z, a: a, b: null };
return true; return true;
} }
@@ -406,14 +448,13 @@
var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w")); var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w"));
ghost(sm ? mountGhost(m, sm) : null); ghost(sm ? mountGhost(m, sm) : null);
} }
// a drag while placing: "line" prefabs run from the press to the pointer along the main axis, // a drag while placing: "line" prefabs run from the press to the pointer (snapped to x, y or a
// "rect" prefabs (and rooms) fill the rectangle // diagonal by snapCorner), "rect" prefabs (and rooms) fill the rectangle
function toolGeom(t) { function toolGeom(t) {
var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1]; var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1];
if (t.m.shape === "line") { if (t.m.shape === "line") {
var ax = Math.abs(dx) >= Math.abs(dy), L = Math.abs(ax ? dx : dy); var L = Math.hypot(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, 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) };
rot: lineRot(t.m, a, ax ? [b[0], a[1]] : [a[0], b[1]]) };
} }
return { w: Math.abs(dx), d: Math.abs(dy), x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, 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 }; 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) { function drawTool(e) {
var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = []; var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = [];
if (chain) { drawChain(e); return; } 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; if (m.shape !== "line" && m.shape !== "rect") return;
var g = toolGeom(t); var g = toolGeom(t);
if (g.line) { if (g.line) {
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, L = g.len / 2; polys = pieceGhost(m, g.a, g.b, z);
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) }); var tilt = onAxes(g.rot) ? "" : ", " + (+g.rot.toFixed(1)) + "°";
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" + tilt + (m.directed ? ", running from where you started" : "") + " · release to place" + (m.directed ? "" : " · Ctrl: any angle"));
ghost(polys, m.label + " " + g.len.toFixed(2) + " m" + (m.directed ? ", running from where you started" : "") + " · release to place");
return; return;
} }
polys.push({ pts: rectPts(g.x0, g.y0, g.x1, g.y1, z) }); polys.push({ pts: rectPts(g.x0, g.y0, g.x1, g.y1, z) });
@@ -446,7 +486,7 @@
if (m.shape === "line") { if (m.shape === "line") {
// a run: a click starts it, each click (or drag) adds a corner, the first corner closes it // a run: a click starts it, each click (or drag) adds a corner, the first corner closes it
if (chain) { 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; if (Math.hypot(q[0] - last[0], q[1] - last[1]) < 0.05) return;
chain.pts.push(q); 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); 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) { [[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 = []; 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) { 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; var c = ax ? w.x : w.y, L = (+propOf(w, "w") || 0) / 2;
cov.push([c - L, c + L]); cov.push([c - L, c + L]);
}); });
@@ -667,7 +707,7 @@
var o = obj(id); if (!o) return; var o = obj(id); if (!o) return;
var dd = Math.max(snapStep(), 1) * 2, dx = dd, dy = dd, h = hostOf(o); 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 // 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 }])); 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) { 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 '<div class="st-warn">' + esc(w) + "</div>"; }).join("") + S.warnings.filter(function (w) { return w.indexOf("object " + o.id + ":") === 0; }).map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") +
'<h2>Placement</h2><div class="st-form">' + '<h2>Placement</h2><div class="st-form">' +
field("xy", "Position x y z", '<div class="st-xy"><input type="number" step="any" data-f="x" value="' + o.x + '" title="x (east), m"><input type="number" step="any" data-f="y" value="' + o.y + '" title="y (north), m"><input type="number" step="any" data-f="z" value="' + (o.z || 0) + '" title="z (height), m"></div>') + field("xy", "Position x y z", '<div class="st-xy"><input type="number" step="any" data-f="x" value="' + o.x + '" title="x (east), m"><input type="number" step="any" data-f="y" value="' + o.y + '" title="y (north), m"><input type="number" step="any" data-f="z" value="' + (o.z || 0) + '" title="z (height), m"></div>') +
field("rot", "Rotation", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>' + (o.rot || 0) + '°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') + field("rot", "Rotation", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><input type="number" step="any" data-f="rot" value="' + (+(o.rot || 0).toFixed(4)) + '" title="degrees, counter-clockwise from east; any angle">°<button type="button" data-act="rotr" title="R">⟳</button></div>') +
"</div>" + "</div>" +
(m.props.length ? "<h2>Properties</h2><div class=\"st-form\">" + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "</div>" : "") + (m.props.length ? "<h2>Properties</h2><div class=\"st-form\">" + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "</div>" : "") +
'<h2>Label and tooltip</h2><div class="st-form">' + '<h2>Label and tooltip</h2><div class="st-form">' +
+52
View File
@@ -503,6 +503,58 @@ with sync_playwright() as p:
pg.wait_for_timeout(900) pg.wait_for_timeout(900)
check(len(doc()["objects"]) == n4 and kerbs() == k0, "one undo takes the road run back") 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 # 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"}}}) 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") check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize")