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]);