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

+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).
pub fn boxes(s: &Sprite, p: Proj) -> (Bb, Sb, bool) {
let mut bb: Bb = [1e9, 1e9, -1e9, -1e9, 1e9, -1e9];
@@ -623,7 +632,12 @@ pub fn render(scene: &Scene, rot: u8, opts: &RenderOpts) -> Rendered {
}
let idx: Vec<usize> = if layer.opts.sort {
let pairs: Vec<(Bb, Sb)> = bx.iter().map(|(b, s, _)| (*b, *s)).collect();
let o = sort::sort(&pairs, layer.opts.zfirst);
let hulls: Vec<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;
o.order
} 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
//! screen box `sb = [x0, y0, x1, y1]`. Only pairs whose screen boxes overlap
//! are compared; a pair is ordered when the boxes are separated along x,
//! then y, then z (or z first under ground). The resulting graph is drawn in
//! topological order, choosing the smallest centre key among the ready
//! sprites; a cycle is broken at the sprite with the fewest unmet edges.
//! then y, then z (or z first under ground). Sprites whose plan outline is not
//! their box (pieces turned off the axes, mitred ends) also carry their convex
//! outline (`Hull`): when the boxes overlap, an outline edge that separates
//! the two orders them (the one on the viewer's side of it goes later), so a
//! long diagonal wall sorts right against everything on either side. The
//! resulting graph is drawn in topological order, choosing the smallest centre
//! key among the ready sprites; a cycle is broken at the sprite with the fewest
//! unmet edges.
pub type Bb = [f64; 6];
pub type Sb = [f64; 4];
/// Convex plan outline in view space, counter-clockwise.
pub type Hull = Vec<[f64; 2]>;
/// -1: a is drawn before b, 1: after, 0: no separating axis (fall back to the key).
pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 {
/// The convex outline of view-space plan points, or None when it is just
/// the box `bb` (nothing to add to the box test).
pub fn hull_of(pts: &[[f64; 2]], bb: &Bb) -> Option<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;
if zf {
if a[5] <= b[4] + e {
@@ -39,6 +85,11 @@ pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 {
if b[5] <= a[4] + e {
return 1;
}
0
}
/// Order by centre key: -1 a first, 1 after, 0 equal.
fn cmp_key(a: &Bb, b: &Bb) -> i32 {
let ka = a[0] + a[2] + a[1] + a[3];
let kb = b[0] + b[2] + b[1] + b[3];
if ka < kb {
@@ -50,6 +101,53 @@ pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 {
}
}
/// -1: a is drawn before b, 1: after, 0: no separating axis and equal keys.
pub fn cmp_bb(a: &Bb, b: &Bb, zf: bool) -> i32 {
match sep_bb(a, b, zf) {
0 => cmp_key(a, b),
c => c,
}
}
/// Is outline a separated from outline b along one of their edge normals?
/// The one on the viewer's side (towards +x +y in view space) goes later.
fn sep_hull(a: &[[f64; 2]], b: &[[f64; 2]]) -> i32 {
let proj = |p: &[[f64; 2]], d: [f64; 2]| {
p.iter().fold((f64::MAX, f64::MIN), |(lo, hi), q| {
let v = q[0] * d[0] + q[1] * d[1];
(lo.min(v), hi.max(v))
})
};
for poly in [a, b] {
let n = poly.len();
for i in 0..n {
let (p, q) = (poly[i], poly[(i + 1) % n]);
let (dx, dy) = (q[0] - p[0], q[1] - p[1]);
let l = (dx * dx + dy * dy).sqrt();
if l < 1e-9 {
continue;
}
let d = [dy / l, -dx / l];
let s = d[0] + d[1];
if s.abs() < 1e-6 {
continue;
}
let ((alo, ahi), (blo, bhi)) = (proj(a, d), proj(b, d));
if ahi <= blo + 1e-6 {
return if s > 0.0 { -1 } else { 1 };
}
if bhi <= alo + 1e-6 {
return if s > 0.0 { 1 } else { -1 };
}
}
}
0
}
fn rect_of(b: &Bb) -> Hull {
vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
}
/// Result of [`sort`]: the draw order and how many times a cycle had to be broken.
#[derive(Debug, Default)]
pub struct Order {
@@ -58,7 +156,14 @@ pub struct Order {
}
pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
sort_hulls(boxes, &[], zf)
}
/// [`sort`] with the outlines of the sprites that have one (`hulls[i]`;
/// missing entries count as None).
pub fn sort_hulls(boxes: &[(Bb, Sb)], hulls: &[Option<Hull>], zf: bool) -> Order {
let n = boxes.len();
let hull = |i: usize| hulls.get(i).and_then(|h| h.as_ref());
let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
let mut indeg = vec![0usize; n];
let mut done = vec![false; n];
@@ -73,7 +178,16 @@ pub fn sort(boxes: &[(Bb, Sb)], zf: bool) -> Order {
if asb[2] <= bsb[0] || bsb[2] <= asb[0] || asb[3] <= bsb[1] || bsb[3] <= asb[1] {
continue;
}
match cmp_bb(ab, bb, zf) {
let mut c = sep_bb(ab, bb, zf);
if c == 0 && (hull(i).is_some() || hull(j).is_some()) {
let ha = hull(i).cloned().unwrap_or_else(|| rect_of(ab));
let hb = hull(j).cloned().unwrap_or_else(|| rect_of(bb));
c = sep_hull(&ha, &hb);
}
if c == 0 {
c = cmp_key(ab, bb);
}
match c {
c if c < 0 => {
adj[i].push(j);
indeg[j] += 1;
@@ -171,4 +285,32 @@ mod tests {
let hi = bx(1.0, 1.0, 3.0, 3.0, -1.0, 0.0);
assert_eq!(sort(&[hi, lo], true).order, vec![1, 0]);
}
#[test]
fn outlines_order_what_lies_either_side_of_a_diagonal_wall() {
// a wall 0.3 m thick from (0, 10) to (10, 0); its box spans the whole square
let wall = vec![[0.0, 9.8], [9.8, 0.0], [10.2, 0.0], [0.2, 10.0]];
let wb = [0.0, 0.0, 10.2, 10.0, 0.0, 2.0];
let far = [2.0, 2.0, 3.0, 3.0, 0.0, 1.0]; // behind it (smaller x + y)
let near = [7.0, 7.0, 8.0, 8.0, 0.0, 1.0]; // in front
let s = [0.0, 0.0, 10.0, 10.0];
let hw = hull_of(&wall, &wb).expect("a turned wall has an outline");
assert!(
hull_of(&rect_of(&far), &far).is_none(),
"a box is just its box"
);
// both boxes overlap the wall's box; its outline puts the far one first, the near one last
let o = sort_hulls(
&[(near, s), (wb, s), (far, s)],
&[None, Some(hw), None],
false,
);
assert_eq!(o.order, vec![2, 1, 0]);
assert_eq!(o.cycles, 0);
// past the wall's end but behind its line: still drawn before it
assert_eq!(
sep_hull(&rect_of(&[10.5, -3.0, 11.0, -2.0, 0.0, 1.0]), &wall),
-1
);
}
}
+74 -9
View File
@@ -9,25 +9,44 @@ use crate::prim::{Fish, Prim, Style, Zs, P2, P3};
use crate::proj::C;
use serde::{Deserialize, Serialize};
/// Placement frame: translate by (x, y, z) after turning `q` quarter turns
/// counter-clockwise (seen from above).
/// Placement frame: translate by (x, y, z) after turning counter-clockwise
/// (seen from above): `q` quarter turns, or by any angle when `cs` holds its
/// cosine and sine (diagonal streets and walls). Quarter turns stay exact.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Xf {
pub x: f64,
pub y: f64,
pub z: f64,
pub q: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cs: Option<[f64; 2]>,
}
impl Xf {
/// Frame at (x, y, z) turned by `deg` (rounded to a multiple of 90).
/// Frame at (x, y, z) turned by `deg`; within 0.001 degrees of a
/// multiple of 90 it is that quarter turn exactly.
pub fn new(x: f64, y: f64, z: f64, deg: f64) -> Self {
let q = ((deg / 90.0).round() as i64).rem_euclid(4) as u8;
Xf { x, y, z, q }
let qf = (deg / 90.0).round();
let q = (qf as i64).rem_euclid(4) as u8;
let cs = if (deg - qf * 90.0).abs() < 1e-3 || !deg.is_finite() {
None
} else {
let a = deg.to_radians();
Some([a.cos(), a.sin()])
};
Xf { x, y, z, q, cs }
}
/// Does the frame keep to the world axes (a multiple of 90 degrees)?
#[inline]
pub fn axis(&self) -> bool {
self.cs.is_none()
}
/// Turn a plan vector into the world.
#[inline]
pub fn v(&self, x: f64, y: f64) -> P2 {
if let Some([c, s]) = self.cs {
return [c * x - s * y, s * x + c * y];
}
match self.q {
0 => [x, y],
1 => [-y, x],
@@ -38,6 +57,9 @@ impl Xf {
/// World plan vector back into the frame.
#[inline]
pub fn v_inv(&self, x: f64, y: f64) -> P2 {
if let Some([c, s]) = self.cs {
return [c * x + s * y, -s * x + c * y];
}
match self.q {
0 => [x, y],
1 => [y, -x],
@@ -59,8 +81,19 @@ impl Xf {
pub fn z(&self, z: f64) -> f64 {
z + self.z
}
/// An axis-aligned rectangle in the frame as a world rectangle.
/// An axis-aligned rectangle in the frame as a world rectangle (its
/// bounding rectangle when the frame is turned off the axes).
pub fn rect(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> [f64; 4] {
if !self.axis() {
let p = self.pts(&rect(x0, y0, x1, y1));
let f = |k: usize, m: fn(f64, f64) -> f64| p.iter().map(|q| q[k]).fold(p[0][k], m);
return [
f(0, f64::min),
f(1, f64::min),
f(0, f64::max),
f(1, f64::max),
];
}
let a = self.p(x0, y0);
let b = self.p(x1, y1);
[
@@ -188,9 +221,16 @@ impl Sprite {
z1: f64,
st: Style,
) -> &mut Self {
let r = self.xf.rect(x0, y0, x1, y1);
let (z0, z1) = (self.xf.z(z0), self.xf.z(z1));
let pts = rect(r[0], r[1], r[2], r[3]);
let pts = if self.xf.axis() {
let r = self.xf.rect(x0, y0, x1, y1);
rect(r[0], r[1], r[2], r[3])
} else {
// a turned frame: the box's own corners, counter-clockwise
let (a, b) = (x0.min(x1), x0.max(x1));
let (c, d) = (y0.min(y1), y0.max(y1));
self.xf.pts(&rect(a, c, b, d))
};
self.ext_world(&pts, z0);
self.ext_world(&pts, z1);
self.push(Prim::Prism {
@@ -686,9 +726,34 @@ mod tests {
assert_eq!(b, [9.0, 18.0, 1.0, 11.0, 22.0, 4.0]);
}
#[test]
fn frames_turn_by_any_angle() {
let f = Xf::new(1.0, 2.0, 0.0, 45.0);
assert!(!f.axis() && Xf::new(0.0, 0.0, 0.0, 90.0000001).axis());
let w = f.p(2f64.sqrt(), 0.0);
assert!(
(w[0] - 2.0).abs() < 1e-12 && (w[1] - 3.0).abs() < 1e-12,
"{w:?}"
);
// a box turned 45 degrees keeps its own four corners
let mut s = Sprite::new("a", "").with_frame(Xf::new(0.0, 0.0, 0.0, 45.0));
s.cuboid(-1.0, -0.1, 1.0, 0.1, 0.0, 1.0, Style::mat("x"));
match &s.prims[0] {
Prim::Prism { pts, .. } => {
assert_eq!(pts.len(), 4);
let r = 0.9 * 0.5f64.sqrt();
assert!(
(pts[0][0] + r).abs() < 1e-9 && (pts[0][1] + 1.1 * 0.5f64.sqrt()).abs() < 1e-9,
"{pts:?}"
);
}
p => panic!("{p:?}"),
}
}
#[test]
fn frame_round_trips_points() {
for deg in [0.0, 90.0, 180.0, 270.0, -90.0, 450.0] {
for deg in [0.0, 90.0, 180.0, 270.0, -90.0, 450.0, 30.0, -137.5] {
let f = Xf::new(3.0, -4.0, 0.0, deg);
let w = f.p(1.5, 2.5);
let l = f.inv(w[0], w[1]);
+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). \
World coordinates are metres: x east, y north, z up. The scene is a list of objects; each has a type (a prefab), \
a position (x, y = centre of its footprint), a rotation in 90 degree steps and props from the prefab's schema. \
a position (x, y = centre of its footprint), a rotation in degrees (any angle, counter-clockwise from east; \
90 degree steps keep things along the axes) and props from the prefab's schema. \
Start with project_info and list_prefabs, then add_object / update_object. Use render_view (summary) to see where \
everything ended up and check_project to find problems. Edits are saved at once and show live in an open Studio. \
Houses are built from parts (categories House and Furniture): a floor per room (w x d), a house_wall along each edge \
@@ -23,7 +24,9 @@ then windows and doors with x, y on a wall line (they sit on that wall and cut t
the wall's +y side, rot + 180 swings it the other way), a roof (w x d = outer size of the walls) and furniture \
(fronts face local -y, backs go against a wall). Level gf shows the house cut open; a part's storey is its z / 3 m. \
Fences (props.style: mesh, wood, bars, half stone, cobble, brick) and site walls join where their centre-line ends \
meet, so for a plot boundary put each piece's ends exactly on the next piece's end or line. \
meet, so for a plot boundary put each piece's ends exactly on the next piece's end or line. Line pieces (roads, walls, \
fences, hedges, house walls) can run at any angle: a piece from (x0, y0) to (x1, y1) has x, y = the midpoint, \
w = the distance and rot = atan2(y1 - y0, x1 - x0) in degrees; where diagonal pieces meet, corners are mitred. \
Production lines (category Industry): a producer (props.rate per minute, product) puts goods out on its local +x \
side; a conveyor (rot 0 runs towards +x, 90 +y, 180 -x, 270 -y; w = length, props.speed m/s) starting exactly \
at that side carries them, and a conveyor whose start is at another's end carries on with them (corners join). \
@@ -32,8 +35,8 @@ A robot_arm (props.reach, default 2 m) picks goods off the end of the nearest co
them on the start of another conveyor within reach, or on a pallet beside it; place it 1.5 to 2 m past a belt's end. \
Streets (category Ground) join by their centre lines: a road (x, y = middle, rot 0 along x, w = length, d = width, \
props.walk none/both/left/right adds raised sidewalks walk_w wide) ending on another road's centre line makes a \
junction there; a road_curve starts at its x, y heading along local +x (rot 0/90/180/270), bends props.angle degrees \
left or right on radius r and ends where the next road starts; two roads ending at one point make a sharp corner. \
junction there (at any angle); a road_curve starts at its x, y heading along its rot, bends props.angle degrees \
left or right on radius r and ends where the next road starts; two roads ending at one point make a mitred corner. \
A roundabout (props.r ring radius, arm_e/n/w/s, arm_len) has its arm ends r + arm_len from its centre: put the \
roads' ends exactly there, with the same width as road_d.";
@@ -47,7 +50,7 @@ pub fn tools() -> Vec<Value> {
"x": { "type": "number", "description": "east, metres (centre of the footprint)" },
"y": { "type": "number", "description": "north, metres" },
"z": { "type": "number", "description": "base height, metres (default 0)" },
"rot": { "type": "number", "description": "rotation in degrees, multiples of 90" },
"rot": { "type": "number", "description": "rotation in degrees counter-clockwise from east, any angle (diagonal streets, walls, fences, hedges); 0, 90, 180, 270 keep to the axes" },
"props": { "type": "object", "description": "prefab properties (see list_prefabs); in updates they merge, null removes" },
"name": { "type": "string", "description": "name shown in the tooltip" },
"info": { "type": "string", "description": "tooltip text" },
+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 {
(((r / 90.0).round() as i64).rem_euclid(4) * 90) as f64
let q = (r / 90.0).round();
if (r - q * 90.0).abs() < 1e-3 {
return ((q as i64).rem_euclid(4) * 90) as f64;
}
let a = (r.rem_euclid(360.0) * 1e4).round() / 1e4;
if a >= 360.0 {
0.0
} else {
a
}
}
/// Check property values against the prefab schema (unknown keys pass:
@@ -364,7 +375,28 @@ mod tests {
.id
.unwrap();
assert_eq!(id, "tree");
// any angle stays (diagonal streets and walls), normalised to [0, 360)
assert_eq!(d.objects[0].rot, 100.0);
apply(
&mut d,
&c,
op(json!({"op": "update", "id": "tree", "patch": {"rot": -45.000001}})),
)
.unwrap();
assert_eq!(d.objects[0].rot, 315.0);
apply(
&mut d,
&c,
op(json!({"op": "update", "id": "tree", "patch": {"rot": 450.0004}})),
)
.unwrap();
assert_eq!(d.objects[0].rot, 90.0);
apply(
&mut d,
&c,
op(json!({"op": "update", "id": "tree", "patch": {"rot": 100}})),
)
.unwrap();
apply(&mut d, &c, op(json!({"op": "update", "id": "tree", "patch": {"x": 7.5, "props": {"r": 2.0}, "tag": "Oak"}}))).unwrap();
assert_eq!(d.objects[0].x, 7.5);
assert_eq!(d.objects[0].props["r"], json!(2.0));
+1 -1
View File
@@ -27,7 +27,7 @@ pub struct Doc {
}
/// One placed object. Its prefab (`kind`) draws it around (x, y, z), turned
/// by `rot` degrees (multiples of 90), in its own coordinates.
/// by `rot` degrees (any angle; quarter turns are exact), in its own coordinates.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Object {
pub id: String,
+156
View File
@@ -848,6 +848,162 @@ fn streets_curve_join_and_meet_at_roundabouts() {
assert!(html.contains("iso-giveway") && html.contains("iso-kerbf"));
}
/// A piece of `kind` from plan point a to b: x, y = the middle, rot = its angle, w = its length.
fn piece(id: &str, kind: &str, a: [f64; 2], b: [f64; 2], props: Value) -> Object {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let mut p = props;
p["w"] = json!(dx.hypot(dy));
let mut o = obj(id, kind, (a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, p);
o.rot = dy.atan2(dx).to_degrees().rem_euclid(360.0);
o
}
#[test]
fn diagonal_walls_fences_and_hedges_mitre_and_sort() {
let r = Runtime::new(&Bundle::default()).unwrap();
let mut doc = Doc {
objects: vec![
piece("w1", "wall", [-30.0, -20.0], [-20.0, -20.0], json!({})),
piece("w2", "wall", [-20.0, -20.0], [-12.0, -12.0], json!({})),
piece("w3", "wall", [-12.0, -12.0], [-12.0, -2.0], json!({})),
piece("w4", "wall", [-16.0, -16.0], [-22.0, -10.0], json!({})),
obj("p_right", "person", -14.0, -18.0, json!({})),
obj("p_left", "person", -18.0, -15.0, json!({})),
],
..Default::default()
};
for (i, st) in ["mesh", "wood", "bars", "half stone", "cobble", "brick"]
.iter()
.enumerate()
{
let x = i as f64 * 10.0;
doc.objects.push(piece(
&format!("f{i}a"),
"fence",
[x, 5.0],
[x + 6.0, 5.0],
json!({ "style": st }),
));
doc.objects.push(piece(
&format!("f{i}b"),
"fence",
[x + 6.0, 5.0],
[x + 10.0, 11.0],
json!({ "style": st }),
));
}
doc.objects
.push(piece("h1", "hedge", [5.0, -20.0], [12.0, -13.0], json!({})));
doc.objects.push(piece(
"h2",
"hedge",
[12.0, -13.0],
[20.0, -13.0],
json!({}),
));
doc.objects
.push(obj("fl", "floor", 25.0, 18.0, json!({"w": 10, "d": 8})));
for (id, a, b) in [
("hw1", [20.0, 14.0], [30.0, 14.0]),
("hw2", [30.0, 14.0], [30.0, 19.0]),
("hw3", [30.0, 19.0], [27.0, 22.0]),
("hw4", [27.0, 22.0], [20.0, 22.0]),
("hw5", [20.0, 22.0], [20.0, 14.0]),
] {
doc.objects.push(piece(id, "house_wall", a, b, json!({})));
}
let mut win = obj("win", "window", 28.5, 20.5, json!({"w": 1.2}));
win.rot = 135.0;
doc.objects.push(win);
for view in 0..4u8 {
for lv in ["site", "gf"] {
let b = r.build(lv, view, &cfg(), &doc).unwrap();
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
let out = render(&b.scene, view, &RenderOpts::default());
assert_eq!(out.cycles, 0, "{lv}/{view}: depth-sort cycles");
assert!(!out.html.contains("NaN"));
}
// the person on the side of the diagonal wall turned to the viewer is drawn after it,
// the other one before
let b = r.build("site", view, &cfg(), &doc).unwrap();
let html = render(&b.scene, view, &RenderOpts::default()).html;
let at = |id: &str| html.find(&format!("data-obj=\"{id}\"")).unwrap();
// w2 runs along (1, 1); its left side (towards (-1, 1)) holds p_left. In two views the
// wall is seen edge-on: neither side faces the viewer and the order does not matter.
let p = isokit_core::Proj::new(view as i64);
let (left_seen, right_seen) = (p.vis(-1.0, 1.0).is_some(), p.vis(1.0, -1.0).is_some());
if left_seen || right_seen {
let (front, back) = if left_seen {
("p_left", "p_right")
} else {
("p_right", "p_left")
};
assert!(
at(back) < at("w2") && at("w2") < at(front),
"view {view}: {back} < w2 < {front}"
);
}
}
let b = r.build("site", 0, &cfg(), &doc).unwrap();
// the 135-degree corner is mitred: w1's outer face reaches 0.12 * tan(22.5) past the corner
let w1 = b.boxes["w1"];
assert!(
(w1[3] + 20.0 - 0.12 * 22.5f64.to_radians().tan()).abs() < 1e-6,
"{w1:?}"
);
// the window on the diagonal wall cuts its opening there
assert_eq!(sprites_of(&b, "hw3"), 2);
let wb = b.boxes["win"];
assert!(
((wb[0] + wb[3]) / 2.0 - 28.5).abs() < 1e-6 && ((wb[1] + wb[4]) / 2.0 - 20.5).abs() < 1e-6,
"{wb:?}"
);
// diagonal fence corners: the owner draws the corner post as its own sprite (mesh: f0a, the lower id)
assert_eq!(sprites_of(&b, "f0a"), 2);
}
#[test]
fn diagonal_streets_meet_at_mitres_and_slanted_junctions() {
let r = Runtime::new(&Bundle::default()).unwrap();
let walk = json!({"walk": "both"});
let mut doc = Doc {
objects: vec![
piece("a1", "road", [-60.0, -30.0], [-35.0, -30.0], walk.clone()),
piece("a2", "road", [-35.0, -30.0], [-20.0, -15.0], walk.clone()),
piece("m1", "road", [-10.0, -40.0], [40.0, -40.0], walk.clone()),
piece("s1", "road", [5.0, -40.0], [17.0, -28.0], walk.clone()),
],
..Default::default()
};
let mut bend = obj(
"bend",
"road_curve",
10.0,
0.0,
json!({"r": 12, "angle": 45, "walk": "both"}),
);
bend.rot = 0.0;
doc.objects.push(bend);
for view in 0..4u8 {
let b = r.build("site", view, &cfg(), &doc).unwrap();
assert!(b.scene.warnings.is_empty(), "{:?}", b.scene.warnings);
assert_eq!(render(&b.scene, view, &RenderOpts::default()).cycles, 0);
}
let b = r.build("site", 0, &cfg(), &doc).unwrap();
// the 135-degree corner is mitred: a1's outer sidewalk back edge (5 m out) reaches
// 5 * tan(22.5) past it, and its seam patch 6 cm on into a2 (along (1, 1))
let a1 = b.boxes["a1"];
let reach = 5.0 * 22.5f64.to_radians().tan() + 0.06 * 0.5f64.sqrt();
assert!((a1[3] + 35.0 - reach).abs() < 1e-6, "{a1:?}");
// the diagonal side road opens m1's north sidewalk (two pieces there); its asphalt ends
// slanted on m1's centre line
assert_eq!(walks_of(&b, "m1"), 3);
let s1 = b.boxes["s1"];
assert!((s1[1] + 40.0).abs() < 1e-6, "{s1:?}");
let html = render(&b.scene, 0, &RenderOpts::default()).html;
assert!(html.contains("iso-g-walk"));
}
#[test]
fn site_walls_join_at_corners() {
let r = Runtime::new(&Bundle::default()).unwrap();
+121 -26
View File
@@ -103,19 +103,34 @@ fn meta_wall() {
] }
}
fn prefab_wall(o) {
// ends joined to the walls they meet; long walls in pieces of at most 30 m,
// each its own sprite for the depth sort
// ends joined to the walls they meet (mitred and slanted where a wall is
// diagonal); long walls in pieces of at most 30 m, each its own sprite for
// the depth sort
let p = o.p;
let x0 = -p.w / 2.0 - line_joint(o, -1.0, ["wall"], []).ext;
let x1 = p.w / 2.0 + line_joint(o, 1.0, ["wall"], []).ext;
let c0 = wall_end(o, -1.0);
let c1 = wall_end(o, 1.0);
let x0 = c0.x;
let x1 = c1.x;
let n = ((x1 - x0) / 30.0).ceiling().to_int();
let L = (x1 - x0) / n;
for i in 0..n {
let a = x0 + i * L;
sprite().box(a, -p.t / 2.0, a + L, p.t / 2.0, 0.0, p.h, "wallp", #{ top: "wallt" });
let k0 = if i == 0 { c0.k } else { 0.0 };
let k1 = if i == n - 1 { c1.k } else { 0.0 };
sprite().cut_box(a, k0, a + L, k1, -p.t / 2.0, p.t / 2.0, 0.0, p.h, "wallp", #{ top: "wallt" });
}
}
/// Where site wall o ends at e (-1 or 1): #{ x, k } (see end_cut).
fn wall_end(o, e) {
let m = line_meet(o, e, ["wall"], []);
let ht = o.p.t / 2.0;
if m.kind == "axis" { return end_cut(o.p.w, e, #{ ext: line_joint(o, e, ["wall"], []).ext, k: 0.0 }); }
if m.kind == "free" { return end_cut(o.p.w, e, #{ ext: ht, k: 0.0 }); }
if m.kind == "run" { return end_cut(o.p.w, e, #{ ext: if m.lower { ht } else { -m.wt }, k: 0.0 }); }
end_cut(o.p.w, e, m)
}
fn fence_styles() { ["mesh", "wood", "bars", "half stone", "cobble", "brick"] }
/// Half the thickness of a fence design: its posts, base or pillars.
@@ -135,10 +150,13 @@ fn meta_fence() {
fn prefab_fence(o) {
let p = o.p;
let st = p.style;
let ea = line_joint(o, -1.0, ["fence"], ["wall", "house_wall"]);
let eb = line_joint(o, 1.0, ["fence"], ["wall", "house_wall"]);
let x0 = -p.w / 2.0 - ea.ext;
let x1 = p.w / 2.0 + eb.ext;
let ea = fence_end(o, -1.0);
let eb = fence_end(o, 1.0);
// x0, x1: the base's ends (stone, cobble); u0, u1: the panels', rails' and boards'
let x0 = ea.x;
let x1 = eb.x;
let u0 = ea.up;
let u1 = eb.up;
if x1 - x0 < 0.05 { return; }
// posts (pillars) evenly along the length; the end ones only where this
// fence draws them, kept inside the stretch it covers
@@ -149,10 +167,17 @@ fn prefab_fence(o) {
if st != "cobble" {
for j in 0..(k + 1) {
if (j == 0 && !ea.post) || (j == k && !eb.post) { continue; }
posts.push(fmin(fmax(-p.w / 2.0 + j * p.w / k, x0 + ph), x1 - ph));
posts.push(fmin(fmax(-p.w / 2.0 + j * p.w / k, u0 + ph), u1 - ph));
}
}
if st == "brick" { brick_fence(x0, x1, posts, p.h); return; }
// corner posts where a diagonal fence meets this one: their own sprites
for c in [[ea, -1.0], [eb, 1.0]] {
if c[0].corner { fence_corner_post(sprite(), c[1] * p.w / 2.0, p.h, st); }
}
if st == "brick" { brick_fence(u0, u1, posts, p.h); return; }
// diagonal pieces keep a stone base and what stands on it in separate
// sprites, so corner posts on the base sort against both
let split = st == "half stone" && (!on_axes(o.rot) || ea.gen || eb.gen);
// pieces of at most 30 m, each its own sprite for the depth sort
let n = ((x1 - x0) / 30.0).ceiling().to_int();
let L = (x1 - x0) / n;
@@ -160,16 +185,83 @@ fn prefab_fence(o) {
let a = x0 + i * L;
let b = a + L;
let ps = [];
let ua = if i == 0 { u0 } else { a };
let ub = if i == n - 1 { u1 } else { b };
for x in posts {
if x >= a - 1e-6 && (x < b - 1e-6 || i == n - 1) { ps.push(fmin(fmax(x, a + ph), b - ph)); }
if x >= a - 1e-6 && (x < b - 1e-6 || i == n - 1) { ps.push(fmin(fmax(x, ua + ph), ub - ph)); }
}
fence_piece(sprite(), a, b, ps, p.h, st, i);
let cut = #{ a: a, ka: if i == 0 { ea.k } else { 0.0 }, b: b, kb: if i == n - 1 { eb.k } else { 0.0 } };
let s = sprite();
fence_piece(s, if split { sprite() } else { s }, cut, ua, ub, ps, p.h, st, i);
}
}
/// One stretch a..b of a fence (not brick) with its posts ps.
fn fence_piece(s, a, b, ps, h, st, seed) {
/// Post half widths of the fence designs (0: none).
fn fence_post_half(st) {
switch st { "cobble" => 0.0, "wood" => 0.05, "brick" => 0.18, _ => 0.04 }
}
/// Where fence o ends at e (-1 or 1): #{ x, k, up, post, corner, gen }: its
/// base (stone, cobble) ends at x + k * y; panels, rails and boards at up
/// (short of a corner post); post: it draws the end post among its own;
/// corner: it draws the post at a diagonal corner as its own sprite; gen: a
/// joint with a diagonal piece.
fn fence_end(o, e) {
let w = o.p.w;
let st = o.p.style;
let ht = fence_ht(st);
let m = line_meet(o, e, ["fence"], ["wall", "house_wall"]);
let plain = |ext, post, gen| {
let c = end_cut(w, e, #{ ext: ext, k: 0.0 });
#{ x: c.x, k: 0.0, up: c.x, post: post, corner: false, gen: gen }
};
if m.kind == "axis" {
let j = line_joint(o, e, ["fence"], ["wall", "house_wall"]);
return plain.call(j.ext, j.post, false);
}
if m.kind == "free" { return plain.call(ht, true, true); }
if m.kind == "run" { return if m.lower { plain.call(ht, true, true) } else { plain.call(-m.wt, false, true) }; }
let c = end_cut(w, e, m);
let r = #{ x: c.x, k: c.k, up: c.x, post: false, corner: false, gen: true };
if m.kind == "tee" {
// panels stop at the face too; against a wall the fence keeps its post
r.post = m.stop;
return r;
}
// a corner: the base is mitred; the owner's post (or pillar) stands on the corner
let pst = if m.owner { st } else { m.w.p.style };
let pp = fence_post_half(pst);
if pp == 0.0 { return r; }
if m.owner && st == "brick" {
r.up = end_cut(w, e, #{ ext: pp, k: 0.0 }).x;
r.post = true;
return r;
}
let reach = if m.owner { pp } else { pp * (m.cos + m.sin) };
r.up = end_cut(w, e, #{ ext: -reach, k: 0.0 }).x;
r.corner = m.owner && st != "brick";
r
}
/// The post at a diagonal corner, at local x: on the stone base for half stone.
fn fence_corner_post(s, x, h, st) {
if st == "wood" {
s.box(x - 0.05, -0.05, x + 0.05, 0.05, 0.0, h + 0.06, "woodd");
} else {
let z0 = if st == "half stone" { fmin(0.6, h * 0.4) } else { 0.0 };
s.box(x - 0.04, -0.04, x + 0.04, 0.04, z0, h + 0.05, "steel");
}
}
/// One stretch of a fence (not brick): the base (stone, cobble) in sprite s
/// over cut #{ a, ka, b, kb } (slanted ends), what stands on it in sprite su
/// from a to b, with posts ps.
fn fence_piece(s, su, cut, a, b, ps, h, st, seed) {
let L = b - a;
let ca = cut.a;
let cb = cut.b;
let ka = cut.ka;
let kb = cut.kb;
if st == "wood" {
s.box(a, -0.025, b, 0.025, 0.0, h, "wood");
let boards = [];
@@ -189,19 +281,22 @@ fn fence_piece(s, a, b, ps, h, st, seed) {
for x in ps { s.box(x - 0.04, -0.04, x + 0.04, 0.04, 0.0, h + 0.05, "steel"); }
} else if st == "half stone" {
let hb = fmin(0.6, h * 0.4);
s.box(a, -0.16, b, 0.16, 0.0, hb, "cobble", #{ top: "capst" });
let j = stone_joints(L, 0.0, hb, seed);
s.open([a, -0.16], [b, -0.16], j, "seam");
s.open([b, 0.16], [a, 0.16], j, "seam");
s.box(a, -0.03, b, 0.03, hb, h, "fence");
for x in ps { s.box(x - 0.04, -0.04, x + 0.04, 0.04, hb, h + 0.05, "steel"); }
s.cut_box(ca, ka, cb, kb, -0.16, 0.16, 0.0, hb, "cobble", #{ top: "capst" });
let f0 = ca - ka * 0.16;
let f1 = ca + ka * 0.16;
let j = stone_joints(cb - kb * 0.16 - f0, 0.0, hb, seed);
s.open([f0, -0.16], [cb - kb * 0.16, -0.16], j, "seam");
s.open([cb + kb * 0.16, 0.16], [f1, 0.16], stone_joints(cb + kb * 0.16 - f1, 0.0, hb, seed), "seam");
su.box(a, -0.03, b, 0.03, hb, h, "fence");
for x in ps { su.box(x - 0.04, -0.04, x + 0.04, 0.04, hb, h + 0.05, "steel"); }
} else if st == "cobble" {
let hc = fmax(0.2, h - 0.08);
s.box(a, -0.2, b, 0.2, 0.0, hc, "cobble");
s.box(a, -0.22, b, 0.22, hc, h, "capst");
let j = stone_joints(L, 0.0, hc, seed);
s.open([a, -0.2], [b, -0.2], j, "seam");
s.open([b, 0.2], [a, 0.2], j, "seam");
s.cut_box(ca, ka, cb, kb, -0.2, 0.2, 0.0, hc, "cobble");
s.cut_box(ca, ka, cb, kb, -0.22, 0.22, hc, h, "capst");
let f0 = ca - ka * 0.2;
let f1 = ca + ka * 0.2;
s.open([f0, -0.2], [cb - kb * 0.2, -0.2], stone_joints(cb - kb * 0.2 - f0, 0.0, hc, seed), "seam");
s.open([cb + kb * 0.2, 0.2], [f1, 0.2], stone_joints(cb + kb * 0.2 - f1, 0.0, hc, seed), "seam");
} else {
// mesh
s.box(a, -0.03, b, 0.03, 0.0, h, "fence");
+22 -7
View File
@@ -126,13 +126,26 @@ fn prefab_house_wall(o) {
gaps.push(opening_span(o, m.u, m.p.w));
}
gaps.sort(|a, b| if a[0] < b[0] { -1 } else if a[0] > b[0] { 1 } else { 0 });
let x = -p.w / 2.0 - wall_ext(o, -1.0);
let c0 = house_wall_end(o, -1.0);
let c1 = house_wall_end(o, 1.0);
let x = c0.x;
let k = c0.k;
for g in gaps {
if g[0] > x + 0.01 { wall_run(x, g[0], t, top, p.mat, cap); }
if g[0] > x + 0.01 { wall_run(x, k, g[0], 0.0, t, top, p.mat, cap); }
if g[1] > x { k = 0.0; }
x = fmax(x, g[1]);
}
let end = p.w / 2.0 + wall_ext(o, 1.0);
if end > x + 0.01 { wall_run(x, end, t, top, p.mat, cap); }
if c1.x > x + 0.01 { wall_run(x, k, c1.x, c1.k, t, top, p.mat, cap); }
}
/// Where house wall o ends at e (-1 or 1): #{ x, k } (see end_cut); mitred
/// at corners and slanted at T-junctions where a wall is diagonal.
fn house_wall_end(o, e) {
let m = line_meet(o, e, wall_types(), []);
if m.kind == "axis" { return end_cut(o.p.w, e, #{ ext: wall_ext(o, e), k: 0.0 }); }
if m.kind == "free" { return end_cut(o.p.w, e, #{ ext: o.p.t / 2.0, k: 0.0 }); }
if m.kind == "run" { return end_cut(o.p.w, e, #{ ext: 0.0, k: 0.0 }); }
end_cut(o.p.w, e, m)
}
/// How far wall o reaches past end e (-1 or 1) of its centre line. Alone:
@@ -162,12 +175,14 @@ fn wall_ext(o, e) {
ext
}
/// A solid stretch of wall from x0 to x1, one sprite per 30 m at most.
fn wall_run(x0, x1, t, top, mat, cap) {
/// A solid stretch of wall from x0 to x1 (ends slanted by k0, k1), one
/// sprite per 30 m at most.
fn wall_run(x0, k0, x1, k1, t, top, mat, cap) {
let n = ((x1 - x0) / 30.0).ceiling().to_int();
let L = (x1 - x0) / n;
for i in 0..n {
sprite().box(x0 + i * L, -t, x0 + (i + 1) * L, t, 0.0, top, mat, #{ top: cap });
sprite().cut_box(x0 + i * L, if i == 0 { k0 } else { 0.0 }, x0 + (i + 1) * L, if i == n - 1 { k1 } else { 0.0 },
-t, t, 0.0, top, mat, #{ top: cap });
}
}
+9 -1
View File
@@ -47,7 +47,15 @@ fn meta_hedge() {
}
fn prefab_hedge(o) {
let p = o.p;
sprite().box(-p.w / 2.0, -p.d / 2.0, p.w / 2.0, p.d / 2.0, 0.0, p.h, "hedge");
let c0 = hedge_end(o, -1.0);
let c1 = hedge_end(o, 1.0);
sprite().cut_box(c0.x, c0.k, c1.x, c1.k, -p.d / 2.0, p.d / 2.0, 0.0, p.h, "hedge");
}
/// Where hedge o ends at e: mitred or slanted where a diagonal hedge meets it.
fn hedge_end(o, e) {
let m = line_meet(o, e, ["hedge"], []);
if m.kind == "corner" || m.kind == "tee" { end_cut(o.p.w, e, m) } else { end_cut(o.p.w, e, #{ ext: 0.0, k: 0.0 }) }
}
// ---------------------------------------------------------------- pond
+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]
}
/// [a, b] without the ranges in gaps: the pieces left, at least 5 cm long.
fn ranges(a, b, gaps) {
let out = [[a, b]];
for g in gaps {
let next = [];
for r in out {
if g[1] <= r[0] || g[0] >= r[1] { next.push(r); continue; }
if g[0] > r[0] { next.push([r[0], g[0]]); }
if g[1] < r[1] { next.push([g[1], r[1]]); }
}
out = next;
}
out.filter(|r| r[1] - r[0] > 0.05)
}
// ---------------------------------------------------------- kerbs and sidewalks
/// The faces up from line k to height h whose outward normal (to the right
@@ -185,20 +170,21 @@ fn kerb_faces(k, h, right) {
}
/// A sidewalk between kerb line k (plan points with the road on their
/// right) and its back line o (as many points, w to the left): its paving,
/// back edge and the ends where caps says [start, end] (not where another
/// sidewalk carries on). The kerb face goes with the kerb.
fn walk_strip(k, o, caps) {
if k.len() < 2 || o.len() != k.len() { return this; }
/// right) and its back line o (to the left): its paving (with seam patches,
/// polygons laid over mitred joints), back edge and the ends where caps says
/// [start, end] (not where another sidewalk carries on). The kerb face goes
/// with the kerb.
fn walk_strip(k, o, caps, patches) {
if k.len() < 2 || o.len() < 2 { return this; }
let h = walk_h();
this.flat(k + rev(o), h, "g-walk t");
if patches.len() == 0 { this.flat(k + rev(o), h, "g-walk t"); } else { this.flat([k + rev(o)] + patches, h, "g-walk t"); }
let fs = kerb_faces(o, h, false);
let n = k.len();
if caps[0] { fs += kerb_faces([o[0], k[0]], h, true); }
if caps[1] { fs += kerb_faces([k[n - 1], o[n - 1]], h, true); }
if caps[1] { fs += kerb_faces([k[k.len() - 1], o[o.len() - 1]], h, true); }
if fs.len() > 0 { this.poly(fs, "kerbf"); }
this
}
fn walk_strip(k, o, caps) { this.walk_strip(k, o, caps, []) }
// ---------------------------------------------------------- where streets meet
@@ -233,61 +219,94 @@ fn street_ends(o) {
}
/// How street o meets the others at its end e (one of street_ends(o)):
/// #{ asph, dash, kl, kr, wl, wr, free, meet }: how far its asphalt, centre
/// line, kerbs and sidewalks (left and right looking out) reach past e
/// (negative: they stop short of it), whether nothing meets it there, and
/// the id of the street that does.
/// #{ asph, dash, kerb, walk, free, meet, pd }. Each of the first four is a
/// cut #{ e, k }: the line w to the left looking out of the end (its
/// carriageway edge, kerb or sidewalk edge) reaches e + k * w past it
/// (negative: it stops short), so ends can be slanted. free: nothing meets it
/// there; meet: the id of the street that does; pd: at a mitred corner, the
/// direction (o's frame) into the other street, for seam patches.
/// - end to end (a curve carrying a road on): everything overlaps 4 cm;
/// - two streets ending at one point at any angle: a mitre on the bisector;
/// - ending on a road's centre line (a T, at any angle): the asphalt stops on
/// it, centre line and kerbs at its kerb, sidewalks at its sidewalk's back.
fn street_join(o, e, others) {
let j = #{ asph: 0.0, dash: 0.0, kl: 0.0, kr: 0.0, wl: 0.0, wr: 0.0, free: true, meet: "" };
let flat = #{ e: 0.0, k: 0.0 };
let j = #{ asph: flat, dash: flat, kerb: flat, walk: flat, free: true, meet: "", pd: () };
// pieces that carry on overlap by a few cm: edge to edge, the ground shows through as a hairline
let lap = 0.04;
let u = [e.dx, e.dy];
let n = [-e.dy, e.dx];
for f in others {
if (f.x - e.x).abs() > 0.3 || (f.y - e.y).abs() > 0.3 { continue; }
j.free = false;
j.meet = f.id;
let dot = e.dx * f.dx + e.dy * f.dy;
if dot < -0.9 { j.asph = lap; j.kl = lap; j.kr = lap; j.wl = lap; j.wr = lap; }
if dot.abs() > 0.2 { continue; }
// a corner: the other street's body lies to the left (cross > 0) or right
let left = e.dx * -f.dy - e.dy * -f.dx > 0.0;
// its outer side is the one away from this street's body
let mine_on_its_left = f.dx * -e.dy - f.dy * -e.dx > 0.0;
let wo = if mine_on_its_left { f.wr } else { f.wl };
let h = f.d / 2.0;
j.asph = h;
if left { j.kl = -h; j.wl = -h; j.kr = h; j.wr = h + wo; } else { j.kr = -h; j.wr = -h; j.kl = h; j.wl = h + wo; }
let uf = [f.dx, f.dy];
let dot = dot2(u, uf);
if dot < -0.9999 {
let l = #{ e: lap, k: 0.0 };
j.asph = l;
j.kerb = l;
j.walk = l;
continue;
}
if dot > 0.9 { continue; }
// a corner at any angle: every part ends on the bisector
let m = #{ e: 0.0, k: dot2(n, uf) / (1.0 - dot) };
j.asph = m;
j.dash = m;
j.kerb = m;
j.walk = m;
let q0 = local_of(o, e.x, e.y);
let q1 = local_of(o, e.x - f.dx, e.y - f.dy);
j.pd = [q1[0] - q0[0], q1[1] - q0[1]];
return j;
}
// ending on a road's side (a T): kerbs stop at its kerb, sidewalks at its sidewalk
if !j.free { return j; }
// ending on a road's centre line (a T): stop at lines along that road
for c in objects_on(["road"], e.x, e.y, 0.3) {
if c.id == o.id || c.v.abs() > 0.3 || c.u.abs() > c.p.w / 2.0 - 0.3 { continue; }
let l = local_of(c, e.x - e.dx, e.y - e.dy);
let c0 = world_of(c, 0.0, 0.0);
let c1 = world_of(c, 0.0, 1.0);
let yc = [c1[0] - c0[0], c1[1] - c0[1]];
let a = dot2(u, yc);
if a.abs() < 0.05 { continue; }
// o's body lies on c's left (+v) when its way out runs to c's right
let sd = if a < 0.0 { 1.0 } else { -1.0 };
let ws = walk_widths(c.p);
let h = c.p.d / 2.0;
let w = if l[1] > c.v { ws[0] } else { ws[1] };
let w = if sd > 0.0 { ws[0] } else { ws[1] };
let k = -dot2(n, yc) / a;
let at = |tv| #{ e: (tv - c.v) / a, k: k };
j.free = false;
j.meet = c.id;
j.dash = -h;
j.kl = -h;
j.kr = -h;
j.wl = -(h + w) + 0.04;
j.wr = -(h + w) + 0.04;
j.asph = at.call(0.0);
j.dash = at.call(sd * h);
j.kerb = at.call(sd * h);
let wb = at.call(sd * (h + w));
j.walk = #{ e: wb.e + 0.04, k: k };
return j;
}
j
}
/// Where the streets in `others` end on road o's sides: gaps [left, right]
/// in its kerbs and sidewalks, as distance ranges along it from its start,
/// then the ids of those streets.
/// in its kerbs and sidewalks, each #{ lo, hi, c }: from lo + c * v to
/// hi + c * v along it from its start at side offset v (slanted where a
/// street comes in at an angle), then the ids of those streets.
fn street_gaps(o, others) {
let g = [[], [], []];
for f in others {
let l = local_of(o, f.x, f.y);
if l[1].abs() > 0.3 || l[0].abs() > o.p.w / 2.0 - 0.3 { continue; }
// the street's way in, in o's frame, and its carriageway edges across o
let b = local_of(o, f.x - f.dx, f.y - f.dy);
let s = l[0] + o.p.w / 2.0;
let r = [s - f.d / 2.0, s + f.d / 2.0];
let d = [l[0] - b[0], l[1] - b[1]];
let a = -d[1];
if a.abs() < 0.05 { continue; }
let sl = -d[0] / a;
let hw = f.d / 2.0 / a.abs();
let s = l[0] + o.p.w / 2.0 - sl * l[1];
let r = #{ lo: s - hw, hi: s + hw, c: sl };
if b[1] > l[1] { g[0].push(r); } else { g[1].push(r); }
g[2].push(f.id);
}
@@ -314,31 +333,80 @@ fn other_street_ends(o) {
out
}
/// A bound along a street: at side offset v it lies at b.s + b.c * v.
fn bnd_at(b, v) { b.s + b.c * v }
/// Pieces of [a, b] (bounds) without the gaps (street_gaps entries), as
/// they fall at side offset v: [[lo, hi], ...] of bounds, at least 5 cm long.
fn ranges_b(a, b, gaps, v) {
let out = [[a, b]];
for g in gaps {
let glo = #{ s: g.lo, c: g.c };
let ghi = #{ s: g.hi, c: g.c };
let next = [];
for r in out {
if bnd_at(ghi, v) <= bnd_at(r[0], v) || bnd_at(glo, v) >= bnd_at(r[1], v) { next.push(r); continue; }
if bnd_at(glo, v) > bnd_at(r[0], v) { next.push([r[0], glo]); }
if bnd_at(ghi, v) < bnd_at(r[1], v) { next.push([ghi, r[1]]); }
}
out = next;
}
out.filter(|r| bnd_at(r[1], v) - bnd_at(r[0], v) > 0.05)
}
/// A thin patch over the seam from p to q, reaching `lap` along d: laid
/// into the street on the other side of a mitre, so no hairline shows.
fn seam_patch(p, q, d) {
let l = fmax((d[0] * d[0] + d[1] * d[1]).sqrt(), 1e-9);
let x = d[0] / l * 0.06;
let y = d[1] / l * 0.06;
[p, q, [q[0] + x, q[1] + y], [p[0] + x, p[1] + y]]
}
/// Draws street o along centre line c (a line_path or arc_path in its
/// frame, in its way), its carriageway o.p.d wide, joined at its start and
/// end as j0 and j1 (from street_join), with sidewalk gaps [left, right]
/// (distance ranges along c).
/// (from street_gaps).
fn street_path(o, c, j0, j1, gaps) {
let p = o.p;
let L = pl_len(c);
let h = p.d / 2.0;
let ws = walk_widths(p);
let s = sprite();
let ca = pl_cut(c, -j0.asph, L + j1.asph);
s.flat(pl_off(ca, h) + rev(pl_off(ca, -h)), 0.007, "g-asph t");
if p.dashed && L + j1.dash + j0.dash > 0.05 {
s.polyline(pl_off(pl_cut(c, -j0.dash, L + j1.dash), 0.0), 0.012, "dash");
// a part's ends as bounds (v: left of the way; at the start that is the
// right looking out, so w = -v there)
let b0 = |cut| #{ s: -cut.e, c: cut.k };
let b1 = |cut| #{ s: L + cut.e, c: cut.k };
let seg = |a, b, v| pl_off(pl_cut(c, bnd_at(a, v), bnd_at(b, v)), v);
// patches over mitred seams between the lines at v0 and v1 (at the start, the end)
let patches = |l0, l1| {
let out = [];
if type_of(j0.pd) != "()" { out.push(seam_patch(l0[0], l1[0], j0.pd)); }
if type_of(j1.pd) != "()" { out.push(seam_patch(l0[l0.len() - 1], l1[l1.len() - 1], j1.pd)); }
out
};
let A0 = b0.call(j0.asph);
let A1 = b1.call(j1.asph);
let al = seg.call(A0, A1, h);
let ar = seg.call(A0, A1, -h);
let ap = patches.call(al, ar);
if ap.len() == 0 { s.flat(al + rev(ar), 0.007, "g-asph t"); } else { s.flat([al + rev(ar)] + ap, 0.007, "g-asph t"); }
let D0 = b0.call(j0.dash);
let D1 = b1.call(j1.dash);
if p.dashed && bnd_at(D1, 0.0) - bnd_at(D0, 0.0) > 0.05 {
s.polyline(seg.call(D0, D1, 0.0), 0.012, "dash");
}
// side 0: left of the way (the start end's right, the end's left); side 1: right
let K0 = b0.call(j0.kerb);
let K1 = b1.call(j1.kerb);
let W0 = b0.call(j0.walk);
let W1 = b1.call(j1.walk);
// side 0: left of the way; side 1: right
for side in [0, 1] {
let sg = if side == 0 { 1.0 } else { -1.0 };
let k0 = if side == 0 { j0.kr } else { j0.kl };
let k1 = if side == 0 { j1.kl } else { j1.kr };
let w0 = if side == 0 { j0.wr } else { j0.wl };
let w1 = if side == 0 { j1.wl } else { j1.wr };
let vk = sg * h;
let ww = ws[side];
for r in ranges(-k0, L + k1, gaps[side]) {
let line = pl_off(pl_cut(c, r[0], r[1]), sg * h);
for r in ranges_b(K0, K1, gaps[side], vk) {
let line = seg.call(r[0], r[1], vk);
if side == 1 { line.reverse(); }
if ww > 0.0 {
let fs = kerb_faces(line, walk_h(), true);
@@ -348,17 +416,21 @@ fn street_path(o, c, j0, j1, gaps) {
}
}
if ww > 0.0 {
let rs = ranges(-w0, L + w1, gaps[side]);
let vb = sg * (h + ww);
let rs = ranges_b(W0, W1, gaps[side], (vk + vb) / 2.0);
for i in 0..rs.len() {
let r = rs[i];
let cut = pl_cut(c, r[0], r[1]);
let line = pl_off(cut, sg * h);
let back = pl_off(cut, sg * (h + ww));
let line = seg.call(r[0], r[1], vk);
let back = seg.call(r[0], r[1], vb);
// seams only where the piece reaches the street's mitred ends
let wp = [];
if i == 0 && type_of(j0.pd) != "()" { wp.push(seam_patch(line[0], back[0], j0.pd)); }
if i + 1 == rs.len() && type_of(j1.pd) != "()" { wp.push(seam_patch(line[line.len() - 1], back[back.len() - 1], j1.pd)); }
// caps where a piece ends at a gap or a free street end (sides run opposite ways)
let at0 = i > 0 || j0.free;
let at1 = i + 1 < rs.len() || j1.free;
if side == 1 { line.reverse(); back.reverse(); }
s.walk_strip(line, back, if side == 0 { [at0, at1] } else { [at1, at0] });
s.walk_strip(line, back, if side == 0 { [at0, at1] } else { [at1, at0] }, wp);
}
}
}
@@ -377,7 +449,7 @@ fn curve_line(p) {
fn meta_road_curve() {
#{ label: "Road curve", category: "Ground", icon: "◜",
help: "A curved road piece. It starts at its position heading along local +x (turn it in 90 degree steps) and bends left or right; its ends meet roads and other curves end to end. Drawing a road run corner by corner puts one in each corner.",
help: "A curved road piece. It starts at its position heading along local +x (turned by its rotation, any angle) and bends left or right; its ends meet roads and other curves end to end. Drawing a road run corner by corner puts one in each corner.",
props: [
#{ key: "r", type: "float", def: 10.0, min: 3.0, max: 300.0, step: 0.5, label: "Radius (m)" },
#{ key: "angle", type: "float", def: 90.0, min: 5.0, max: 180.0, step: 5.0, label: "Angle (deg)" },
+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).
fn along_world_x(rot) { ((rot / 90.0).round().to_int() % 2) == 0 }
/// Half the thickness of a fence or wall piece: what the joints keep apart.
/// True when rot (degrees) is a multiple of 90: the piece keeps to the world axes.
fn on_axes(rot) {
let q = rot / 90.0;
(q - q.round()).abs() < 1e-5
}
/// World plan direction of a piece's local +x axis.
fn dir_of(o) {
let a = o.rot * PI() / 180.0;
[a.cos(), a.sin()]
}
fn dot2(a, b) { a[0] * b[0] + a[1] * b[1] }
/// Half the thickness of a fence, wall or hedge piece: what the joints keep apart.
fn line_ht(w) {
if w.type == "fence" { fence_ht(w.p.style) } else { w.p.t / 2.0 }
if w.type == "fence" { fence_ht(w.p.style) } else if w.type == "hedge" { w.p.d / 2.0 } else { w.p.t / 2.0 }
}
/// How line piece o meets the pieces of `types` at its end e (-1 or 1), for
/// joints where a piece is turned off the world axes (diagonal walls, fences,
/// hedges). #{ kind: "axis" } when every piece there keeps to the axes (the
/// prefab's own rules apply), else kind
/// - "free": nothing there;
/// - "run": a piece carrying on in line (lower: o's centre comes first along
/// its axis; wt: the other's half thickness);
/// - "corner": a piece ending there at an angle: a mitre, so the two never
/// overlap (k below; owner: o draws a corner post if any, the thicker piece
/// or, equal, the lower id; cos, sin: of the angle between the two axes);
/// - "tee": o ends on a piece's side (or on two pieces in line ending there):
/// it stops at that piece's face (stop: the piece is of a `stops` type).
/// ext and k give the slanted end: the line v to o's left (local y) reaches
/// ext + k * v past the end; the depth sort's outlines keep mitred and
/// slanted pieces apart.
fn line_meet(o, e, same, stops) {
let ht = line_ht(o);
let xo = dir_of(o);
let yo = [-xo[1], xo[0]];
let uo = [e * xo[0], e * xo[1]];
let P = world_of(o, e * o.p.w / 2.0, 0.0);
let axis = on_axes(o.rot);
let tee = ();
let run = ();
let corners = [];
for w in objects_on(same + stops, P[0], P[1], 0.05) {
if w.id == o.id || (w.z - o.z).abs() > 0.5 { continue; }
let wt = line_ht(w);
if w.v.abs() > wt + 0.02 { continue; }
if !on_axes(w.rot) { axis = false; }
let xw = dir_of(w);
let par = dot2(uo, xw).abs() > 0.999;
let at_end = (w.u.abs() - w.p.w / 2.0).abs() < 0.02;
if !par && (!same.contains(w.type) || !at_end) {
if type_of(tee) == "()" { tee = w; }
} else if par && at_end {
run = w;
} else if !par {
corners.push(w);
}
}
if axis { return #{ kind: "axis" }; }
// stop at the face of w on o's side
let face = |w| {
let xw = dir_of(w);
let yw = [-xw[1], xw[0]];
let a = dot2(uo, yw);
let s = if a < 0.0 { 1.0 } else { -1.0 };
#{ ext: (s * line_ht(w) - w.v) / a, k: -dot2(yo, yw) / a }
};
if type_of(tee) != "()" {
let f = face.call(tee);
return #{ kind: "tee", ext: f.ext, k: f.k, stop: !same.contains(tee.type), w: tee };
}
if type_of(run) != "()" {
return #{ kind: "run", ext: 0.0, k: 0.0, lower: dot2([o.x, o.y], xo) < dot2([run.x, run.y], xo), wt: line_ht(run), w: run };
}
if corners.len() > 1 {
// two in line make a straight run o meets as a T; else (a Y) the
// lowest id runs through and the others stop at it
let t = ();
for a in corners {
for b in corners {
if a.id < b.id && dot2(dir_of(a), dir_of(b)).abs() > 0.999 { t = a; }
}
}
if type_of(t) == "()" {
t = corners[0];
for c in corners { if c.id < t.id { t = c; } }
if o.id < t.id { return #{ kind: "free", ext: 0.0, k: 0.0 }; }
}
let f = face.call(t);
return #{ kind: "tee", ext: f.ext, k: f.k, stop: false, w: t };
}
if corners.len() == 1 {
let w = corners[0];
let xw = dir_of(w);
let sw = if w.u > 0.0 { 1.0 } else { -1.0 };
let uw = [sw * xw[0], sw * xw[1]];
let wt = line_ht(w);
return #{ kind: "corner", ext: 0.0, k: dot2(yo, uw) / fmax(1.0 - dot2(uo, uw), 1e-6), w: w, wt: wt,
owner: ht > wt + 0.005 || ((ht - wt).abs() <= 0.005 && o.id < w.id),
cos: dot2(xo, xw).abs(), sin: (xo[0] * xw[1] - xo[1] * xw[0]).abs() };
}
#{ kind: "free", ext: 0.0, k: 0.0 }
}
/// A box from x0 to x1 along local x whose ends may be slanted: at y the
/// start lies at x0 + k0 * y and the end at x1 + k1 * y (k0 = k1 = 0: a box).
fn cut_box(x0, k0, x1, k1, y0, y1, z0, z1, mat, o) {
if k0 == 0.0 && k1 == 0.0 { return this.box(x0, y0, x1, y1, z0, z1, mat, o); }
let a0 = x0 + k0 * y0;
let a1 = x0 + k0 * y1;
let b0 = fmax(x1 + k1 * y0, a0 + 0.001);
let b1 = fmax(x1 + k1 * y1, a1 + 0.001);
this.prism([[a0, y0], [b0, y0], [b1, y1], [a1, y1]], z0, z1, mat, o);
this
}
fn cut_box(x0, k0, x1, k1, y0, y1, z0, z1, mat) {
if k0 == 0.0 && k1 == 0.0 { return this.box(x0, y0, x1, y1, z0, z1, mat); }
let a0 = x0 + k0 * y0;
let a1 = x0 + k0 * y1;
let b0 = fmax(x1 + k1 * y0, a0 + 0.001);
let b1 = fmax(x1 + k1 * y1, a1 + 0.001);
this.prism([[a0, y0], [b0, y0], [b1, y1], [a1, y1]], z0, z1, mat);
this
}
/// A cut from line_meet as the start (e = -1) or end (e = 1) of a piece
/// w long: #{ x, k } with the end at x + k * y.
fn end_cut(w, e, m) {
if e < 0.0 { #{ x: -w / 2.0 - m.ext, k: -m.k } } else { #{ x: w / 2.0 + m.ext, k: m.k } }
}
/// How line piece o (a fence or wall along its local x, length p.w) meets