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

+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();