Diagonals: streets, walls, fences, hedges and house walls at any angle
Deploy web Studio / deploy (push) Successful in 1m5s
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:
1 parent
c91d63663b
commit
96d472625f
18 files changed
+1051
-178
No files matched your search
@@ -48,11 +48,11 @@ Try the demos: `isokit studio examples/demo` (a works site),
|
||||
| Do | How |
|
||||
|---|---|
|
||||
| Add | pick an object on the left, click on the map (Shift-click places several) |
|
||||
| Draw | walls, fences, hedges, roads: drag along the map, or click corner after corner for a run around a plot (double-click / Enter ends it, the first corner closes it); ends snap onto the walls and fences they meet; a road run gets a curve in each corner. Floors, areas, buildings, roofs, rugs: drag a rectangle |
|
||||
| Draw | walls, fences, hedges, roads: drag along the map, or click corner after corner for a run around a plot (double-click / Enter ends it, the first corner closes it); ends snap onto the walls and fences they meet; a road run gets a curve in each corner. Pieces keep to x or y, or go diagonal at 45° once the pointer is more than 22.5° off; hold Ctrl for any angle Floors, areas, buildings, roofs, rugs: drag a rectangle |
|
||||
| Select | click it on the map or in the object list; click a wall or fence again to select every piece joined to it |
|
||||
| Several | Ctrl+click (map or list) adds or takes out one, Shift+click in the list a range, Shift+drag on the map a box, Ctrl+A all; then drag one to move them all, arrows, R, Ctrl+D, Delete and the property form work on all of them |
|
||||
| Move | drag it, or arrow keys by the snap step (0.1 / 0.5 / 1 / 5 m) |
|
||||
| Turn | R / Shift+R or ⟲ ⟳ |
|
||||
| Turn | R / Shift+R or ⟲ ⟳ (quarter turns), or type any angle in Rotation |
|
||||
| Edit | the right panel: size, storeys, colours, solar, tooltip text, label, levels |
|
||||
| Copy / delete | Ctrl+D / Delete; Ctrl+Z / Ctrl+Y undo and redo |
|
||||
| Look | drag the ground to pan, Ctrl+wheel or pinch to zoom, ↺ ↻ rotate, Site / GF / 1F levels |
|
||||
@@ -210,6 +210,12 @@ give-way lines for traffic on the left or right. Roads meet by their centre
|
||||
lines, so put the ends on each other. `examples/streets` (Streets demo in the
|
||||
web Studio) is a small town quarter.
|
||||
|
||||
Diagonals: roads, walls, fences, hedges and house walls run at any angle.
|
||||
Where they meet at an angle the corners are mitred, side streets come in at a
|
||||
slant (the sidewalk opens along them), fence corners get a post or pillar, and
|
||||
a road run turning 45° gets a 45° curve. Windows and doors sit on diagonal
|
||||
house walls like on any other.
|
||||
|
||||
Animations (fans, smoke, fish, traffic) step on about ten times a second by
|
||||
default ("light"): running CSS animations repaint the whole drawing every frame,
|
||||
which keeps a slow laptop busy. The ▷ button in the view toolbar switches
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-1
@@ -68,7 +68,8 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
|
||||
(hover preview) and move, turn, copy and delete with them, new parts land on the storey
|
||||
shown, collapsible palette that scrolls on its own
|
||||
- [x] `examples/house` (a furnished house built from parts), House demo in the web Studio
|
||||
- [ ] Openings across two walls; diagonal walls; floor openings for stairs
|
||||
- [x] Diagonal walls (any angle, mitred corners, windows and doors on them)
|
||||
- [ ] Openings across two walls; floor openings for stairs
|
||||
|
||||
## Backlog (2026-10)
|
||||
|
||||
@@ -81,6 +82,8 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push.
|
||||
7. ~~Roundabouts~~ (done, see below)
|
||||
8. Waypoint animation: a person or car moving along a path of waypoints
|
||||
9. ~~Build your own house~~ (done, see above)
|
||||
10. ~~Diagonal streets, walls, fences, hedges~~ (done, see below)
|
||||
11. Wildlife and nature: deer, bushes, beehives and more
|
||||
|
||||
Done since: the middle mouse button only moves the camera; fences and site walls join like house
|
||||
walls (`line_joint`); fence designs (mesh, wood, bars, half stone, cobble, brick with pillars);
|
||||
@@ -119,6 +122,16 @@ apron (tree, grass or paved). A road run drawn corner by corner gets a curve in
|
||||
meta `corner`). Tooltips list the streets each one meets. New `polyline` primitive (open paths, so
|
||||
dashes run on round curves). `examples/streets`, Streets demo in the web Studio.
|
||||
|
||||
Diagonals: object frames turn by any angle (quarter turns stay exact, so scenes along the axes
|
||||
draw as before, byte for byte). Roads, walls, fences, hedges and house walls run at any angle;
|
||||
where a piece off the axes meets another, corners are mitred and T-junctions slanted
|
||||
(`line_meet`, `cut_box`), fence corners get a post or pillar of their own, side streets come in at
|
||||
a slant and open the sidewalk along them, two streets meet in a mitre. The depth sort orders
|
||||
sprites whose plan outline is not their box by outline edges, so a long diagonal wall sorts right
|
||||
against everything on either side. Studio: runs and drags keep to x or y or go diagonal at 45°
|
||||
(Ctrl: any angle), a road run turning 45° gets a 45° curve, Rotation takes any angle. The
|
||||
streets demo has a diagonal lane.
|
||||
|
||||
## Phase 2: richer editing
|
||||
|
||||
- [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open
|
||||
|
||||
+38
-8
@@ -66,7 +66,7 @@ shaded automatically by face direction.
|
||||
| `objects_on(types, x, y, tol)` | … whose centre line (local x, length `p.w`) passes within `p.t / 2 + tol` of the point, with `u, v` = the point in their frame |
|
||||
| `mount_of(o, hosts[, tol])` | the host o sits on (nearest centre line within `p.t / 2 + tol`, default 0.3 m, same height), with `u, v` = o's position in the host's frame; `()` if none |
|
||||
| `mounted(h, types, hosts[, tol])` | the objects of `types` whose host is h, each with `u, v` |
|
||||
| `frame(o)`, `frame(x, y, z, rot)` | draw in another frame from here on (a window draws in its wall's frame) |
|
||||
| `frame(o)`, `frame(x, y, z, rot)` | draw in another frame from here on (a window draws in its wall's frame); `rot` is any angle in degrees (quarter turns stay exact) |
|
||||
| `to_world(x, y)`, `to_local(x, y)`, `local_of(o, x, y)`, `world_of(o, u, v)` | between the current frame (or o's) and world plan coordinates |
|
||||
| `vis(nx, ny)` | is a face with that outward normal turned to the viewer? |
|
||||
| `dv(dx, dy, dz)` | screen vector of a world vector (CSS motion) |
|
||||
@@ -122,6 +122,29 @@ depth sort needs. `fence` (designs `style`: mesh, wood, bars, half stone,
|
||||
cobble, brick) and the site `wall` use it; `house_wall` has its own version
|
||||
(`wall_ext`, house.rhai).
|
||||
|
||||
### Diagonal pieces
|
||||
|
||||
Objects turn by any angle (`rot` in degrees); a box drawn in a turned frame
|
||||
becomes a prism with its own four corners. Where a piece off the axes is
|
||||
involved, `line_meet(o, e, same, stops)` (util.rhai) replaces the rules above
|
||||
for that end: `#{ kind, ext, k, ... }` with kind `axis` (every piece there
|
||||
keeps to the axes: use the old rules, so such scenes draw as before),
|
||||
`free`, `run` (in line), `corner` (a mitre on the bisector; `owner` draws a
|
||||
fence's corner post) or `tee` (stop at the other's face). The end is
|
||||
slanted: the line `v` to the piece's left reaches `ext + k * v` past it.
|
||||
`end_cut(w, e, m)` turns that into `#{ x, k }` and `s.cut_box(x0, k0, x1, k1,
|
||||
y0, y1, z0, z1, mat[, opts])` draws a box with such ends. Fences put their
|
||||
corner post (or brick pillar) in its own sprite and stop their panels at it;
|
||||
stone bases and cobble walls are mitred. Helpers: `on_axes(rot)`,
|
||||
`dir_of(o)`, `dot2(a, b)`.
|
||||
|
||||
The depth sort keeps diagonal pieces right through outlines: a sprite whose
|
||||
plan outline is not its box (turned or mitred pieces) carries its convex
|
||||
outline, and when two boxes overlap an outline edge that separates them
|
||||
orders them, the one on the viewer's side going later. A long diagonal wall
|
||||
therefore sorts right against everything on either side of it, and mitred
|
||||
pieces against each other.
|
||||
|
||||
## Production lines
|
||||
|
||||
`producer`, `conveyor` and `robot_arm` (stdlib/industry.rhai) work together
|
||||
@@ -166,12 +189,16 @@ ground.rhai) meet by their centre lines:
|
||||
arcs when cut and offset, so a curve meets a straight road exactly.
|
||||
- End to end (a curve continuing a road, a road continuing a roundabout arm)
|
||||
the pieces carry on, overlapping by 4 cm so no hairline shows.
|
||||
- A street ending on a road's centre line makes a T: the road leaves a gap in
|
||||
its kerb and sidewalk on that side, the street stops its centre line and
|
||||
kerbs at the road's edge and its sidewalks at the road's sidewalk.
|
||||
- Two roads ending at one point at right angles close the corner: both run
|
||||
over it, the inner sidewalks stop at the other road's kerb, the outer ones
|
||||
go round.
|
||||
- A street ending on a road's centre line makes a T, at any angle: the road
|
||||
leaves a gap in its kerb and sidewalk on that side (slanted along the
|
||||
street coming in), the street ends its asphalt on the road's centre line,
|
||||
its centre line and kerbs at the road's edge and its sidewalks at the
|
||||
road's sidewalk.
|
||||
- Two streets ending at one point at any angle meet in a mitre on the
|
||||
bisector (thin seam patches reach over it, so no hairline shows).
|
||||
- Every part of a street end is a cut `#{ e, k }`: the line `w` to the left
|
||||
looking out reaches `e + k * w` past the end (`street_join`); gaps are
|
||||
`#{ lo, hi, c }` ranges that slant with the side offset (`street_gaps`).
|
||||
- A roundabout's arms end `r + arm_len` from its centre, along its local
|
||||
+x, +y, -x, -y. Its kerbs flare into the ring with a fillet; splitter
|
||||
islands and give-way lines sit on the entry side for `drive` left or right.
|
||||
@@ -220,7 +247,10 @@ These are lessons from the CNX/Mooban views, and the engine relies on them:
|
||||
the same sprite as the storey below. Start walls and furniture standing on
|
||||
them a little above the floor (+0.02 m).
|
||||
- **Movers** (`drive`) run along the axis the sort compares second
|
||||
(`along_x()`). Along the other axis they stay still.
|
||||
(`along_x()`). Along the other axis (and on diagonals) they stay still.
|
||||
- **Diagonal pieces** sort by their outlines (see Diagonal pieces), so they
|
||||
need no extra splitting; things standing exactly on a wall's line still
|
||||
interpenetrate it and sort by their centres.
|
||||
- Street furniture stays off drive areas, and pipe runs end on walls or supports.
|
||||
|
||||
## Themes
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
{"id": "bend", "type": "road_curve", "name": "Corner", "x": 0, "y": 34, "rot": 90, "props": {"walk": "both", "walk_w": 2.5, "d": 6.5, "r": 12, "turn": "right"}},
|
||||
{"id": "park-rd", "type": "road", "name": "Park road", "x": 34, "y": 46, "props": {"walk": "both", "walk_w": 2.5, "d": 6.5, "w": 44}},
|
||||
{"id": "cross-st", "type": "road", "name": "Cross street", "x": 34, "y": 23, "rot": 90, "props": {"w": 46, "d": 5.5, "walk": "both", "walk_w": 2}},
|
||||
{"id": "lane", "type": "road", "name": "Garden lane", "x": 34.0, "y": -10.0, "rot": 315.0, "props": {"w": 28.284, "d": 5.5, "walk": "both", "walk_w": 2}},
|
||||
{"id": "hedge-lane", "type": "hedge", "name": "Lane hedge", "x": 28.95, "y": -14.849, "rot": 315.0, "props": {"w": 12.0, "d": 0.8, "h": 1.2}},
|
||||
{"id": "offices", "type": "building", "name": "Offices", "x": 19, "y": 27, "props": {"w": 14, "d": 13, "floors": 3, "pv": true}},
|
||||
{"id": "flats", "type": "building", "name": "Flats", "x": 46, "y": 23, "props": {"w": 10, "d": 28, "floors": 4, "roof": "gable", "mat": "brick"}},
|
||||
{"id": "school", "type": "building", "name": "School", "x": -33, "y": -21, "props": {"w": 20, "d": 14, "floors": 2, "ac": true}},
|
||||
@@ -30,7 +32,7 @@
|
||||
{"id": "walker-2", "type": "person", "x": -26, "y": -4.7},
|
||||
{"id": "walker-3", "type": "person", "x": 31.6, "y": 30, "props": {"cls": "guest3"}},
|
||||
{"id": "crowd", "type": "crowd", "name": "Pupils", "x": -24, "y": -11, "props": {"n": 6, "r": 1.6}},
|
||||
{"id": "lamp-1", "type": "lamp", "x": 26, "y": -5.2},
|
||||
{"id": "lamp-1", "type": "lamp", "x": 18, "y": -5.2},
|
||||
{"id": "lamp-2", "type": "lamp", "x": 44, "y": -5.2},
|
||||
{"id": "lamp-3", "type": "lamp", "x": -28, "y": 5.2},
|
||||
{"id": "lamp-4", "type": "lamp", "x": -44, "y": 5.2},
|
||||
|
||||
+78
-38
@@ -46,8 +46,18 @@
|
||||
function worldAt(e, z) { return S.view.toWorld(e.clientX, e.clientY, z || 0); }
|
||||
function qOf(rot) { return ((Math.round((rot || 0) / 90) % 4) + 4) % 4; }
|
||||
function turnV(q, x, y) { return q === 0 ? [x, y] : q === 1 ? [-y, x] : q === 2 ? [-x, -y] : [y, -x]; }
|
||||
function localOf(o, x, y) { return turnV((4 - qOf(o.rot)) % 4, x - o.x, y - o.y); }
|
||||
function worldOf(o, u, v) { var d = turnV(qOf(o.rot), u, v); return [o.x + d[0], o.y + d[1]]; }
|
||||
// is rot (degrees) a multiple of 90: does the object keep to the world axes?
|
||||
function onAxes(rot) { var q = (rot || 0) / 90; return Math.abs(q - Math.round(q)) < 1e-5; }
|
||||
// a plan vector turned by rot degrees (quarter turns stay exact, as in the engine)
|
||||
function turnA(rot, x, y) {
|
||||
if (onAxes(rot)) return turnV(qOf(rot), x, y);
|
||||
var a = rot * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
|
||||
return [c * x - s * y, s * x + c * y];
|
||||
}
|
||||
function localOf(o, x, y) { return turnA(-(o.rot || 0), x - o.x, y - o.y); }
|
||||
function worldOf(o, u, v) { var d = turnA(o.rot || 0, u, v); return [o.x + d[0], o.y + d[1]]; }
|
||||
// the angle of plan direction (dx, dy) in degrees, 4 decimals
|
||||
function degOf(dx, dy) { return Math.round(Math.atan2(dy, dx) * 180 / Math.PI * 1e4) / 1e4; }
|
||||
function defOf(m, k) { var d = m && (m.props || []).filter(function (x) { return x.key === k; })[0]; return d ? d.default : undefined; }
|
||||
function propOf(o, k) { var p = o.props || {}; return p[k] !== undefined ? p[k] : defOf(prefab(o.type), k); }
|
||||
function clampProp(m, k, v) {
|
||||
@@ -120,14 +130,21 @@
|
||||
function linePieces(z) {
|
||||
return S.doc.objects.filter(function (o) { var m = prefab(o.type); return !o.hidden && m && m.shape === "line" && Math.abs((o.z || 0) - z) < 0.5; }).map(function (o) {
|
||||
var L = (+propOf(o, "w") || 0) / 2;
|
||||
return { o: o, a: worldOf(o, -L, 0), b: worldOf(o, L, 0), ax: qOf(o.rot) % 2 === 0 };
|
||||
return { o: o, a: worldOf(o, -L, 0), b: worldOf(o, L, 0), ax: qOf(o.rot) % 2 === 0, axis: onAxes(o.rot) };
|
||||
});
|
||||
}
|
||||
// where a corner goes for pointer world point w: with `from` (the previous corner) the piece keeps
|
||||
// to x or y and its far end snaps onto an end or the line of a piece it meets (or the run's own
|
||||
// corners), else onto the grid; without, onto the nearest end, else onto a line, else the grid
|
||||
function snapCorner(w, z, from, R) {
|
||||
// to x or y (within 22.5 degrees) or a diagonal at 45, and its far end snaps onto an end or the
|
||||
// line of a piece it meets (or the run's own corners), else onto the grid; `free` (Ctrl held): any
|
||||
// angle, the end on a piece's end or line or a grid point; without `from`, onto the nearest end,
|
||||
// else onto a line, else the grid
|
||||
function snapCorner(w, z, from, R, free) {
|
||||
var pcs = linePieces(z), own = chain ? chain.pts : [], best = null, i;
|
||||
if (from && free) return snapCorner(w, z, null, R);
|
||||
if (from) {
|
||||
var k8 = Math.round(Math.atan2(w[1] - from[1], w[0] - from[0]) / (Math.PI / 4));
|
||||
if (k8 % 2 !== 0) return snapDiagonal(w, from, R, [Math.cos(k8 * Math.PI / 4), Math.sin(k8 * Math.PI / 4)], pcs, own);
|
||||
}
|
||||
if (!from) {
|
||||
pcs.forEach(function (p) {
|
||||
[p.a, p.b].forEach(function (q) { var d = Math.hypot(q[0] - w[0], q[1] - w[1]); if (d < R && (!best || d < best.d)) best = { d: d, p: q }; });
|
||||
@@ -157,18 +174,36 @@
|
||||
out[k] = r3(best ? best.c : snap(f)); out[lk] = fix;
|
||||
return out;
|
||||
}
|
||||
// the preview of one piece from p to q: its footprint, and its face when it has a height
|
||||
// the far end of a diagonal piece from `from` along unit d: onto a piece's end on that line, a
|
||||
// piece it crosses or the run's own corners within R, else where x lands on the grid
|
||||
function snapDiagonal(w, from, R, d, pcs, own) {
|
||||
var t0 = (w[0] - from[0]) * d[0] + (w[1] - from[1]) * d[1], cands = [], best = null;
|
||||
var onRay = function (q) { var rx = q[0] - from[0], ry = q[1] - from[1]; if (Math.abs(rx * d[1] - ry * d[0]) < 0.01) cands.push(rx * d[0] + ry * d[1]); };
|
||||
pcs.forEach(function (p) {
|
||||
onRay(p.a); onRay(p.b);
|
||||
// where the ray crosses the piece's centre line
|
||||
var ex = p.b[0] - p.a[0], ey = p.b[1] - p.a[1], den = d[0] * ey - d[1] * ex;
|
||||
if (Math.abs(den) < 1e-9) return;
|
||||
var ax = p.a[0] - from[0], ay = p.a[1] - from[1], t = (ax * ey - ay * ex) / den, s = (ax * d[1] - ay * d[0]) / den;
|
||||
if (s >= -1e-6 && s <= 1 + 1e-6) cands.push(t);
|
||||
});
|
||||
own.forEach(onRay);
|
||||
cands.forEach(function (c) { var dd = Math.abs(c - t0); if (c > 0.05 && dd < R && (!best || dd < best.d)) best = { d: dd, t: c }; });
|
||||
var t = best ? best.t : (snap(from[0] + t0 * d[0]) - from[0]) / d[0];
|
||||
return [r3(from[0] + t * d[0]), r3(from[1] + t * d[1])];
|
||||
}
|
||||
// the preview of one piece from p to q (at any angle): its footprint, and its face when it has a height
|
||||
function pieceGhost(m, p, q, z) {
|
||||
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, ax = Math.abs(q[1] - p[1]) < 1e-9;
|
||||
var x0 = Math.min(p[0], q[0]), x1 = Math.max(p[0], q[0]), y0 = Math.min(p[1], q[1]), y1 = Math.max(p[1], q[1]);
|
||||
var out = [{ pts: ax ? rectPts(x0, y0 - th / 2, x1, y0 + th / 2, z) : rectPts(x0 - th / 2, y0, x0 + th / 2, y1, z) }];
|
||||
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0;
|
||||
var L = Math.hypot(q[0] - p[0], q[1] - p[1]) || 1, nx = -(q[1] - p[1]) / L * th / 2, ny = (q[0] - p[0]) / L * th / 2;
|
||||
var out = [{ pts: [[p[0] + nx, p[1] + ny, z], [q[0] + nx, q[1] + ny, z], [q[0] - nx, q[1] - ny, z], [p[0] - nx, p[1] - ny, z]] }];
|
||||
if (h > 0) out.push({ pts: planePts(p, q, z, z + h), cls: "st-plane" });
|
||||
return out;
|
||||
}
|
||||
function cornerMark(p, z) { return { pts: rectPts(p[0] - 0.18, p[1] - 0.18, p[0] + 0.18, p[1] + 0.18, z), cls: "st-plane" }; }
|
||||
function runLength(pts) { var L = 0; for (var i = 1; i < pts.length; i++) L += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); return L; }
|
||||
function drawChain(e) {
|
||||
var c = chain, last = c.pts[c.pts.length - 1], q = snapCorner(worldAt(e, c.z), c.z, last, 12 * pxM(e)), polys = [], i;
|
||||
var c = chain, last = c.pts[c.pts.length - 1], q = snapCorner(worldAt(e, c.z), c.z, last, 12 * pxM(e), freeAngle(c.m, e)), polys = [], i;
|
||||
for (i = 1; i < c.pts.length; i++) Array.prototype.push.apply(polys, pieceGhost(c.m, c.pts[i - 1], c.pts[i], c.z));
|
||||
if (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05) Array.prototype.push.apply(polys, pieceGhost(c.m, last, q, c.z));
|
||||
polys.push(cornerMark(c.pts[0], c.z));
|
||||
@@ -176,14 +211,20 @@
|
||||
var n = c.pts.length - 1 + (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05 ? 1 : 0);
|
||||
ghost(polys, c.m.label + ": " + n + " piece" + (n === 1 ? "" : "s") + ", " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m" +
|
||||
(c.m.corner ? ", corners rounded" : "") + " · " +
|
||||
(closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") + " · Backspace undoes a corner · Esc cancels");
|
||||
(closes ? "click to close the run" : "click the next corner · double-click or Enter to finish · click the first corner to close") +
|
||||
(c.m.directed ? "" : " · diagonals at 45°, Ctrl: any angle") + " · Backspace undoes a corner · Esc cancels");
|
||||
}
|
||||
// the turn of a line piece from p to q: along x or y; directed lines (conveyors) point from p to q
|
||||
// the turn of a line piece from p to q: along x or y, else its angle (0 to 180); directed lines
|
||||
// (conveyors) point from p to q
|
||||
function lineRot(m, p, q) {
|
||||
var ax = Math.abs(q[1] - p[1]) <= Math.abs(q[0] - p[0]);
|
||||
var dx = q[0] - p[0], dy = q[1] - p[1];
|
||||
if (Math.abs(dx) > 1e-9 && Math.abs(dy) > 1e-9) { var a = degOf(dx, dy); return m.directed ? (a + 360) % 360 : (a % 180 + 180) % 180; }
|
||||
var ax = Math.abs(dy) <= Math.abs(dx);
|
||||
if (!m.directed) return ax ? 0 : 90;
|
||||
return ax ? (q[0] >= p[0] ? 0 : 180) : (q[1] >= p[1] ? 90 : 270);
|
||||
}
|
||||
// Ctrl (Cmd) frees the angle of the piece being drawn; directed lines (conveyors) keep to the axes
|
||||
function freeAngle(m, e) { return !!(e && (e.ctrlKey || e.metaKey) && !m.directed); }
|
||||
// the corners of a run rounded by the prefab's corner piece (roads: curves of up to its default
|
||||
// radius, less where the pieces next to a corner are short): [segments [p, q]], [curve add ops]
|
||||
function roundRun(m, pts, z) {
|
||||
@@ -194,26 +235,27 @@
|
||||
var closed = n > 2 && Math.hypot(segs[n - 1][1][0] - segs[0][0][0], segs[n - 1][1][1] - segs[0][0][1]) < 1e-6;
|
||||
var len = segs.map(function (s) { return Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]); });
|
||||
var dir = segs.map(function (s, k) { return [(s[1][0] - s[0][0]) / len[k], (s[1][1] - s[0][1]) / len[k]]; });
|
||||
// turn at the start of segment k (from segment k - 1), 0 where the run goes straight on
|
||||
// turn (radians, + left) at the start of segment k (from segment k - 1), 0 where the run goes on
|
||||
var turn = segs.map(function (s, k) {
|
||||
if (k === 0 && !closed) return 0;
|
||||
var a = dir[(k + n - 1) % n], b = dir[k], cr = a[0] * b[1] - a[1] * b[0];
|
||||
return Math.abs(cr) > 0.5 && a[0] * b[0] + a[1] * b[1] < 0.5 ? cr : 0;
|
||||
var a = dir[(k + n - 1) % n], b = dir[k], th = Math.atan2(a[0] * b[1] - a[1] * b[0], a[0] * b[0] + a[1] * b[1]);
|
||||
return Math.abs(th) > 5 * Math.PI / 180 && Math.abs(th) < 175 * Math.PI / 180 ? th : 0;
|
||||
});
|
||||
var R0 = +defOf(cm, "r") || 10, rmin = Math.max(+((cm.props || []).filter(function (d) { return d.key === "r"; })[0] || {}).min || 0, (+defOf(m, "d") || 0) / 2 + 0.5);
|
||||
for (var k = 0; k < n; k++) {
|
||||
if (!turn[k]) continue;
|
||||
var a = (k + n - 1) % n, nextTurn = turn[(k + 1) % n] && (k + 1 < n || closed);
|
||||
var r = Math.min(R0, len[a] * (turn[a] ? 0.5 : 1), len[k] * (nextTurn ? 0.5 : 1));
|
||||
// the curve meets the legs r * tan(turn / 2) from the corner
|
||||
var a = (k + n - 1) % n, nextTurn = turn[(k + 1) % n] && (k + 1 < n || closed), tn = Math.tan(Math.abs(turn[k]) / 2);
|
||||
var r = Math.min(R0, len[a] * (turn[a] ? 0.5 : 1) / tn, len[k] * (nextTurn ? 0.5 : 1) / tn), T = r * tn;
|
||||
if (r < rmin) continue;
|
||||
var P = segs[k][0], da = dir[a], db = dir[k];
|
||||
var s0 = [P[0] - da[0] * r, P[1] - da[1] * r];
|
||||
var add = { op: "add", type: m.corner, x: r3(s0[0]), y: r3(s0[1]), rot: (Math.round(Math.atan2(da[1], da[0]) * 2 / Math.PI) * 90 + 360) % 360,
|
||||
props: { r: r3(r), angle: 90, turn: turn[k] > 0 ? "left" : "right" } };
|
||||
var s0 = [P[0] - da[0] * T, P[1] - da[1] * T], deg = Math.abs(turn[k]) * 180 / Math.PI;
|
||||
var add = { op: "add", type: m.corner, x: r3(s0[0]), y: r3(s0[1]), rot: (lineRot({ directed: true }, [0, 0], da) + 360) % 360,
|
||||
props: { r: r3(r), angle: Math.abs(deg - Math.round(deg)) < 1e-6 ? Math.round(deg) : r3(deg), turn: turn[k] > 0 ? "left" : "right" } };
|
||||
if (z) add.z = z;
|
||||
curves.push(add);
|
||||
segs[a][1] = s0;
|
||||
segs[k][0] = [P[0] + db[0] * r, P[1] + db[1] * r];
|
||||
segs[k][0] = [P[0] + db[0] * T, P[1] + db[1] * T];
|
||||
}
|
||||
return { segs: segs, curves: curves };
|
||||
}
|
||||
@@ -321,7 +363,7 @@
|
||||
down: function (e, hit) {
|
||||
if (S.placing) {
|
||||
var pm = pmeta(S.placing), z = placeZ(pm), a = worldAt(e, z);
|
||||
a = pm.shape === "line" ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e)) : [snap(a[0]), snap(a[1])];
|
||||
a = pm.shape === "line" ? snapCorner(a, z, chain ? chain.pts[chain.pts.length - 1] : null, 12 * pxM(e), freeAngle(pm, e)) : [snap(a[0]), snap(a[1])];
|
||||
tool = { m: pm, z: z, a: a, b: null };
|
||||
return true;
|
||||
}
|
||||
@@ -406,14 +448,13 @@
|
||||
var z = placeZ(m), sm = snapMount(m, worldAt(e, z), z, null, defOf(m, "w"));
|
||||
ghost(sm ? mountGhost(m, sm) : null);
|
||||
}
|
||||
// a drag while placing: "line" prefabs run from the press to the pointer along the main axis,
|
||||
// "rect" prefabs (and rooms) fill the rectangle
|
||||
// a drag while placing: "line" prefabs run from the press to the pointer (snapped to x, y or a
|
||||
// diagonal by snapCorner), "rect" prefabs (and rooms) fill the rectangle
|
||||
function toolGeom(t) {
|
||||
var a = t.a, b = t.b || t.a, dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
if (t.m.shape === "line") {
|
||||
var ax = Math.abs(dx) >= Math.abs(dy), L = Math.abs(ax ? dx : dy);
|
||||
return { line: true, ax: ax, len: L, x: ax ? (a[0] + b[0]) / 2 : a[0], y: ax ? a[1] : (a[1] + b[1]) / 2, ok: L >= 0.25,
|
||||
rot: lineRot(t.m, a, ax ? [b[0], a[1]] : [a[0], b[1]]) };
|
||||
var L = Math.hypot(dx, dy);
|
||||
return { line: true, len: L, a: a, b: b, x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, ok: L >= 0.25, rot: lineRot(t.m, a, b) };
|
||||
}
|
||||
return { w: Math.abs(dx), d: Math.abs(dy), x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2,
|
||||
x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]), ok: Math.abs(dx) >= 0.25 && Math.abs(dy) >= 0.25 };
|
||||
@@ -421,14 +462,13 @@
|
||||
function drawTool(e) {
|
||||
var t = tool, m = t.m, w = worldAt(e, t.z), z = t.z, polys = [];
|
||||
if (chain) { drawChain(e); return; }
|
||||
t.b = m.shape === "line" ? snapCorner(w, z, t.a, 12 * pxM(e)) : [snap(w[0]), snap(w[1])];
|
||||
t.b = m.shape === "line" ? snapCorner(w, z, t.a, 12 * pxM(e), freeAngle(m, e)) : [snap(w[0]), snap(w[1])];
|
||||
if (m.shape !== "line" && m.shape !== "rect") return;
|
||||
var g = toolGeom(t);
|
||||
if (g.line) {
|
||||
var th = +(defOf(m, "t") || defOf(m, "d")) || 0.3, h = +defOf(m, "h") || 0, L = g.len / 2;
|
||||
polys.push({ pts: g.ax ? rectPts(g.x - L, g.y - th / 2, g.x + L, g.y + th / 2, z) : rectPts(g.x - th / 2, g.y - L, g.x + th / 2, g.y + L, z) });
|
||||
if (h > 0) polys.push({ pts: planePts(g.ax ? [g.x - L, g.y] : [g.x, g.y - L], g.ax ? [g.x + L, g.y] : [g.x, g.y + L], z, z + h), cls: "st-plane" });
|
||||
ghost(polys, m.label + " " + g.len.toFixed(2) + " m" + (m.directed ? ", running from where you started" : "") + " · release to place");
|
||||
polys = pieceGhost(m, g.a, g.b, z);
|
||||
var tilt = onAxes(g.rot) ? "" : ", " + (+g.rot.toFixed(1)) + "°";
|
||||
ghost(polys, m.label + " " + g.len.toFixed(2) + " m" + tilt + (m.directed ? ", running from where you started" : "") + " · release to place" + (m.directed ? "" : " · Ctrl: any angle"));
|
||||
return;
|
||||
}
|
||||
polys.push({ pts: rectPts(g.x0, g.y0, g.x1, g.y1, z) });
|
||||
@@ -446,7 +486,7 @@
|
||||
if (m.shape === "line") {
|
||||
// a run: a click starts it, each click (or drag) adds a corner, the first corner closes it
|
||||
if (chain) {
|
||||
var last = chain.pts[chain.pts.length - 1], q = snapCorner(at, t.z, last, 12 * pxM(e));
|
||||
var last = chain.pts[chain.pts.length - 1], q = snapCorner(at, t.z, last, 12 * pxM(e), freeAngle(m, e));
|
||||
if (Math.hypot(q[0] - last[0], q[1] - last[1]) < 0.05) return;
|
||||
chain.pts.push(q);
|
||||
if (chain.pts.length > 3 && Math.hypot(q[0] - chain.pts[0][0], q[1] - chain.pts[0][1]) < 1e-6) finishChain(keep);
|
||||
@@ -485,7 +525,7 @@
|
||||
[[x0, y0, x1, y0], [x0, y1, x1, y1], [x0, y0, x0, y1], [x1, y0, x1, y1]].forEach(function (e) {
|
||||
var ax = e[1] === e[3], line = ax ? e[1] : e[0], lo = ax ? e[0] : e[1], hi = ax ? e[2] : e[3], cov = [];
|
||||
walls.forEach(function (w) {
|
||||
if ((qOf(w.rot) % 2 === 0) !== ax || Math.abs((ax ? w.y : w.x) - line) > 0.05) return;
|
||||
if (!onAxes(w.rot) || (qOf(w.rot) % 2 === 0) !== ax || Math.abs((ax ? w.y : w.x) - line) > 0.05) return;
|
||||
var c = ax ? w.x : w.y, L = (+propOf(w, "w") || 0) / 2;
|
||||
cov.push([c - L, c + L]);
|
||||
});
|
||||
@@ -667,7 +707,7 @@
|
||||
var o = obj(id); if (!o) return;
|
||||
var dd = Math.max(snapStep(), 1) * 2, dx = dd, dy = dd, h = hostOf(o);
|
||||
// a copy of a window or door goes further along its wall
|
||||
if (h) { var v = turnV(qOf(h.host.rot), (+propOf(o, "w") || 1) + 0.5, 0); dx = r3(v[0]); dy = r3(v[1]); }
|
||||
if (h) { var v = turnA(h.host.rot || 0, (+propOf(o, "w") || 1) + 0.5, 0); dx = r3(v[0]); dy = r3(v[1]); }
|
||||
opList(mountedOn(o).map(function (c) { return { op: "duplicate", id: c.id, dx: dx, dy: dy }; }).concat([{ op: "duplicate", id: id, dx: dx, dy: dy }]));
|
||||
}
|
||||
function moveWith(id, dx, dy, carry, quiet) {
|
||||
@@ -824,7 +864,7 @@
|
||||
S.warnings.filter(function (w) { return w.indexOf("object " + o.id + ":") === 0; }).map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") +
|
||||
'<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("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>" +
|
||||
(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">' +
|
||||
|
||||
@@ -503,6 +503,58 @@ with sync_playwright() as p:
|
||||
pg.wait_for_timeout(900)
|
||||
check(len(doc()["objects"]) == n4 and kerbs() == k0, "one undo takes the road run back")
|
||||
|
||||
# diagonals, east of the site: a fence run snaps its middle leg to 45 degrees, Ctrl frees the angle of a
|
||||
# dragged road, a road run turning 45 degrees gets a 45-degree curve, the rotation can be typed
|
||||
pg.evaluate("__studio.view.focus([48, 8, 0], 1.8)")
|
||||
pg.wait_for_timeout(400)
|
||||
n5 = len(doc()["objects"])
|
||||
pg.locator('.st-tile[data-type="fence"]').click()
|
||||
for x, y in [(38, -10), (44, -10.1), (49.7, -4.2), (50.1, 4)]:
|
||||
pg.mouse.click(*at(x, y))
|
||||
pg.wait_for_timeout(150)
|
||||
check("diagonals at 45°" in pg.locator("#hint").inner_text(), "a run says how diagonals snap")
|
||||
pg.keyboard.press("Enter")
|
||||
pg.wait_for_timeout(1000)
|
||||
got = sorted((o["x"], o["y"], o.get("rot", 0), o["props"]["w"]) for o in doc()["objects"][n5:])
|
||||
check(got == [(41, -10, 0, 6), (47, -7, 45, 8.485), (50, 0, 90, 8)], f"a fence run takes a 45-degree leg: {got}")
|
||||
pg.locator('.st-tile[data-type="road"]').click()
|
||||
pg.keyboard.down("Control")
|
||||
pg.mouse.move(*at(38, 12))
|
||||
pg.mouse.down()
|
||||
for i in range(1, 11):
|
||||
pg.mouse.move(*at(38 + 1.2 * i, 12 + 0.6 * i))
|
||||
pg.wait_for_timeout(20)
|
||||
pg.mouse.up()
|
||||
pg.keyboard.up("Control")
|
||||
pg.wait_for_timeout(1000)
|
||||
free = doc()["objects"][-1]
|
||||
check(free["type"] == "road" and abs(free["rot"] - 26.5651) < 1e-6 and abs(free["props"]["w"] - 13.416) < 1e-6,
|
||||
f"Ctrl draws a road at any angle: rot {free.get('rot')}, w {free['props'].get('w')}")
|
||||
n6 = len(doc()["objects"])
|
||||
pg.locator('.st-tile[data-type="road"]').click()
|
||||
for x, y in [(38, 24), (52, 24), (62.2, 34.1)]:
|
||||
pg.mouse.click(*at(x, y))
|
||||
pg.wait_for_timeout(150)
|
||||
pg.keyboard.press("Enter")
|
||||
pg.wait_for_timeout(1000)
|
||||
got = sorted((o["type"], o["x"], o["y"], o.get("rot", 0), o["props"].get("w", o["props"].get("r")), o["props"].get("angle")) for o in doc()["objects"][n6:])
|
||||
want = sorted([("road", 42.929, 24, 0, 9.858, None), ("road", 58.464, 30.464, 45, 10, None), ("road_curve", 47.858, 24, 0, 10, 45)])
|
||||
check(got == want, f"a road run turning 45 degrees gets a 45-degree curve: {got}")
|
||||
pg.evaluate(f"__studio.select('{free['id']}')")
|
||||
pg.wait_for_timeout(300)
|
||||
rin = pg.locator('#right input[data-f="rot"]')
|
||||
rin.fill("30")
|
||||
rin.press("Enter")
|
||||
pg.wait_for_timeout(800)
|
||||
check(obj(free["id"])["rot"] == 30, f"the rotation can be typed: {obj(free['id'])['rot']}")
|
||||
pg.evaluate("__studio.select(null)")
|
||||
pg.wait_for_timeout(300)
|
||||
pg.screenshot(path=str(OUT / "studio-diagonals.jpg"), type="jpeg", quality=80)
|
||||
for _ in range(4):
|
||||
pg.locator("#undo").click()
|
||||
pg.wait_for_timeout(700)
|
||||
check(len(doc()["objects"]) == n5, "four undos take the diagonals back")
|
||||
|
||||
# an agent edits over MCP: the open Studio follows
|
||||
init = mcp({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}})
|
||||
check(init["result"]["serverInfo"]["name"] == "isokit", "MCP initialize")
|
||||
|
||||
Reference in new issue
Block a user