diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml index 3004b54..5b9765f 100644 --- a/.gitea/workflows/deploy-web.yml +++ b/.gitea/workflows/deploy-web.yml @@ -22,6 +22,7 @@ on: - "examples/demo/**" - "examples/house/**" - "examples/factory/**" + - "examples/streets/**" - "tools/build_web.py" - "Cargo.toml" - "Cargo.lock" diff --git a/README.md b/README.md index a5329ad..0491e66 100644 --- a/README.md +++ b/README.md @@ -39,15 +39,16 @@ isokit build C:/dev/myproject/site-view # dist/index.html, no server needed ``` Try the demos: `isokit studio examples/demo` (a works site), -`isokit studio examples/house` (a furnished house built from parts) and -`isokit studio examples/factory` (presses, conveyors and robot arms). +`isokit studio examples/house` (a furnished house built from parts), +`isokit studio examples/factory` (presses, conveyors and robot arms) and +`isokit studio examples/streets` (sidewalks, curves, junctions, a roundabout). ### Studio (for everyone) | 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. 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. 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) | @@ -201,6 +202,14 @@ off and puts them on the next belt or a pallet, in step with them. Tooltips show the numbers. `examples/factory` (Factory demo in the web Studio) has a few lines. +Streets: a Road can have sidewalks on both sides or one (12 cm high paving +with kerbs). A Road curve carries a street round a bend, a road ending on +another road's centre line makes a junction (the sidewalk opens there), and a +Roundabout has up to four arms with flared kerbs, splitter islands and +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. + 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 @@ -236,6 +245,7 @@ web/ web Studio shell: WASM backend for studio.js, projects, f examples/demo demo project (works site) examples/house house built from parts (walls, windows, doors, floors, furniture, roof) examples/factory production yard: producers, conveyors, robot arms +examples/streets town quarter: sidewalks, curves, junctions, a roundabout deploy/Caddyfile the web Studio's vhost on the buildfor.life web server (s1) ``` diff --git a/crates/isokit-core/src/prim.rs b/crates/isokit-core/src/prim.rs index 9632c52..81c26af 100644 --- a/crates/isokit-core/src/prim.rs +++ b/crates/isokit-core/src/prim.rs @@ -145,11 +145,13 @@ pub enum Prim { two_sided: bool, }, /// 3D polygons merged into one path with a fixed class (PV modules, - /// signs, markings on slopes): no shading, no culling. + /// signs, markings on slopes): no shading, no culling. `open`: lines + /// that are not closed (a dashed line round a curve stays one dash run). Poly { polys: Vec>, cls: String, id: Option, + open: bool, }, /// Screen-space circle of radius r (screen units) at a world point, /// shifted by off (screen units): heads, lamps, LEDs, tree crowns. diff --git a/crates/isokit-core/src/render.rs b/crates/isokit-core/src/render.rs index 57a1c4b..9745625 100644 --- a/crates/isokit-core/src/render.rs +++ b/crates/isokit-core/src/render.rs @@ -415,14 +415,19 @@ impl Ctx<'_> { id, two_sided, } => self.face(pts, mat, id.as_deref(), *two_sided), - Prim::Poly { polys, cls, id } => { + Prim::Poly { + polys, + cls, + id, + open, + } => { if polys.is_empty() { return; } let p = self.p; self.path_open(cls, id.as_deref()); for q in polys { - p.d(&mut self.out, q, false); + p.d(&mut self.out, q, *open); } self.out.push_str("\"/>"); } diff --git a/crates/isokit-core/src/sprite.rs b/crates/isokit-core/src/sprite.rs index 0fcd8af..efbf328 100644 --- a/crates/isokit-core/src/sprite.rs +++ b/crates/isokit-core/src/sprite.rs @@ -447,6 +447,21 @@ impl Sprite { /// 3D polygons as one path with a fixed class. pub fn poly(&mut self, polys: Vec>, cls: &str, id: Option) -> &mut Self { + self.poly_path(polys, cls, id, false) + } + + /// Open 3D polylines as one path (markings along curves: one dash run). + pub fn polyline(&mut self, lines: Vec>, cls: &str, id: Option) -> &mut Self { + self.poly_path(lines, cls, id, true) + } + + fn poly_path( + &mut self, + polys: Vec>, + cls: &str, + id: Option, + open: bool, + ) -> &mut Self { let polys: Vec> = polys .into_iter() .map(|p| p.into_iter().map(|q| self.xf.p3(q)).collect()) @@ -458,6 +473,7 @@ impl Sprite { polys, cls: cls.into(), id, + open, }) } diff --git a/crates/isokit-project/src/mcp.rs b/crates/isokit-project/src/mcp.rs index f36af7f..4d0e9af 100644 --- a/crates/isokit-project/src/mcp.rs +++ b/crates/isokit-project/src/mcp.rs @@ -29,7 +29,13 @@ side; a conveyor (rot 0 runs towards +x, 90 +y, 180 -x, 270 -y; w = length, prop at that side carries them, and a conveyor whose start is at another's end carries on with them (corners join). \ The gap between goods is speed / rate, so set the rate on the producer and the speeds on the belts. \ A robot_arm (props.reach, default 2 m) picks goods off the end of the nearest conveyor within its reach and puts \ -them on the start of another conveyor within reach, or on a pallet beside it; place it 1.5 to 2 m past a belt's end."; +them on the start of another conveyor within reach, or on a pallet beside it; place it 1.5 to 2 m past a belt's end. \ +Streets (category Ground) join by their centre lines: a road (x, y = middle, rot 0 along x, w = length, d = width, \ +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. \ +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."; fn tool(name: &str, description: &str, props: Value, required: &[&str]) -> Value { json!({ "name": name, "description": description, diff --git a/crates/isokit-project/tests/demo.rs b/crates/isokit-project/tests/demo.rs index 06fce4d..d94e5e4 100644 --- a/crates/isokit-project/tests/demo.rs +++ b/crates/isokit-project/tests/demo.rs @@ -72,8 +72,8 @@ fn demo_passes_the_check() { assert!(f.is_empty(), "{f:?}"); } -/// The house and factory demos: every view draws every object without warnings or -/// depth-sort cycles (walls and belts must not overlap), and the projects pass the check. +/// The house, factory and streets demos: every view draws every object without warnings +/// or depth-sort cycles (walls and belts must not overlap), and the projects pass the check. #[test] fn house_demo_renders_every_view_cleanly() { example_renders_every_view_cleanly("house"); @@ -84,6 +84,11 @@ fn factory_demo_renders_every_view_cleanly() { example_renders_every_view_cleanly("factory"); } +#[test] +fn streets_demo_renders_every_view_cleanly() { + example_renders_every_view_cleanly("streets"); +} + fn example_renders_every_view_cleanly(name: &str) { let p = Project::load( Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/isokit-script/src/api.rs b/crates/isokit-script/src/api.rs index 45bb2cd..b374406 100644 --- a/crates/isokit-script/src/api.rs +++ b/crates/isokit-script/src/api.rs @@ -321,6 +321,16 @@ pub fn register_sprite(e: &mut Engine) { ok(s) }, ); + // open polylines through plan points at height z (or 3D points without z) + e.register_fn( + "polyline", + |s: &mut SpriteH, p: D, z: D, cls: &str| -> RR { + let z = n(&z)?; + let lines = polys3_or_flat(&p, z)?; + s.0.borrow_mut().polyline(lines, &classes(cls), None); + ok(s) + }, + ); e.register_fn("poly", |s: &mut SpriteH, p: D, cls: &str| -> RR { s.0.borrow_mut().poly(polys3(&p)?, &classes(cls), None); ok(s) diff --git a/crates/isokit-script/src/conv.rs b/crates/isokit-script/src/conv.rs index 278762d..0658641 100644 --- a/crates/isokit-script/src/conv.rs +++ b/crates/isokit-script/src/conv.rs @@ -90,6 +90,15 @@ pub fn polys3(d: &Dynamic) -> RR>> { } } +/// One line or several, of plan points `[x, y]` (put at height z) or 3D points. +pub fn polys3_or_flat(d: &Dynamic, z: f64) -> RR>> { + polys(d).map(|ls| { + ls.into_iter() + .map(|l| l.into_iter().map(|p| [p[0], p[1], z]).collect()) + .collect() + }) +} + /// `[[a, b, c, d], ...]` (segments, opening lists) pub fn quads(d: &Dynamic, what: &str) -> RR> { arr(d, what)? diff --git a/crates/isokit-script/src/doc.rs b/crates/isokit-script/src/doc.rs index 172b565..f6c6463 100644 --- a/crates/isokit-script/src/doc.rs +++ b/crates/isokit-script/src/doc.rs @@ -123,6 +123,10 @@ pub struct PrefabMeta { /// turns it to point from where the drag starts to where it ends. #[serde(default, skip_serializing_if = "is_false")] pub directed: bool, + /// A prefab that rounds the corners of a run (roads: "road_curve"): the + /// Studio puts one in each corner of a run drawn corner by corner. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub corner: String, #[serde(default)] pub props: Vec, /// Module the prefab comes from ("isokit/site", "lib/kiosk.rhai"). diff --git a/crates/isokit-script/src/stdlib.rs b/crates/isokit-script/src/stdlib.rs index 64de7e1..9c25edc 100644 --- a/crates/isokit-script/src/stdlib.rs +++ b/crates/isokit-script/src/stdlib.rs @@ -8,6 +8,7 @@ pub const MODULES: &[(&str, &str)] = &[ ("isokit/util", include_str!("../stdlib/util.rhai")), ("isokit/ground", include_str!("../stdlib/ground.rhai")), + ("isokit/streets", include_str!("../stdlib/streets.rhai")), ("isokit/nature", include_str!("../stdlib/nature.rhai")), ("isokit/building", include_str!("../stdlib/building.rhai")), ("isokit/vehicles", include_str!("../stdlib/vehicles.rhai")), diff --git a/crates/isokit-script/src/tests.rs b/crates/isokit-script/src/tests.rs index dbb5b33..61de62c 100644 --- a/crates/isokit-script/src/tests.rs +++ b/crates/isokit-script/src/tests.rs @@ -732,6 +732,122 @@ fn robot_arms_pass_goods_from_belt_to_belt() { assert!(out.info["robot1"].rows[1].1.contains("waiting")); } +fn street_doc() -> Doc { + let mut doc = Doc::default(); + let walk = json!({"walk": "both"}); + for (id, kind, x, y, rot, props) in [ + ( + "main", + "road", + -10.0, + 0.0, + 0.0, + json!({"w": 40, "walk": "both"}), + ), + ( + "side", + "road", + -14.0, + -12.0, + 90.0, + json!({"w": 24, "walk": "both"}), + ), + ("bend", "road_curve", 10.0, 0.0, 0.0, walk.clone()), + ( + "north", + "road", + 20.0, + 18.0, + 90.0, + json!({"w": 16, "walk": "both"}), + ), + ( + "c1", + "road", + -30.0, + 20.0, + 0.0, + json!({"w": 20, "walk": "both"}), + ), + ( + "c2", + "road", + -20.0, + 30.0, + 90.0, + json!({"w": 20, "walk": "both"}), + ), + ("rb", "roundabout", -54.0, 0.0, 0.0, json!({"arm_n": false})), + ] { + let mut o = obj(id, kind, x, y, props); + o.rot = rot; + doc.objects.push(o); + } + doc +} + +/// Sidewalk strips of object id (paving pieces). +fn walks_of(b: &crate::Built, id: &str) -> usize { + b.scene + .layers + .iter() + .flat_map(|l| l.sprites.iter()) + .filter(|s| s.obj.as_deref() == Some(id)) + .flat_map(|s| s.prims.iter()) + .filter(|p| matches!(p, isokit_core::Prim::Flat { cls, .. } if cls.contains("g-walk"))) + .count() +} + +#[test] +fn streets_curve_join_and_meet_at_roundabouts() { + let r = Runtime::new(&Bundle::default()).unwrap(); + assert_eq!(r.prefab("road").unwrap().corner, "road_curve"); + let doc = street_doc(); + for view in 0..4u8 { + let b = r.build("site", 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, "view {view}: depth-sort cycles"); + assert!(!out.html.contains("NaN")); + } + let b = r.build("site", 0, &cfg(), &doc).unwrap(); + let near = |a: [f64; 6], want: [f64; 6]| a.iter().zip(want).all(|(a, w)| (a - w).abs() < 1e-3); + // the side road ends on the main street's centre line; the main street's south + // sidewalk leaves a gap for it (two pieces), its north one runs through + assert_eq!(walks_of(&b, "main"), 3); + assert_eq!(walks_of(&b, "side"), 2); + let sd = b.boxes["side"]; + assert!(near(sd, [-19.0, -24.0, 0.0, -9.0, 0.0, 0.12]), "{sd:?}"); + // the curve carries the street on round 90 degrees, overlapping both by 4 cm + // along its centre line (6 cm at its outer edge); the main street meets the + // roundabout's east arm end to end + let bd = b.boxes["bend"]; + assert!(near(bd, [9.94, -5.0, 0.0, 25.0, 10.06, 0.12]), "{bd:?}"); + let mn = b.boxes["main"]; + assert!(near(mn, [-30.04, -5.0, 0.0, 10.04, 5.0, 0.12]), "{mn:?}"); + // two roads ending at one corner close it: the outer sidewalks go round it + let (c1, c2) = (b.boxes["c1"], b.boxes["c2"]); + assert!( + (c1[3] + 15.0).abs() < 1e-6 && (c1[1] - 15.0).abs() < 1e-6, + "{c1:?}" + ); + assert!( + (c2[1] - 15.0).abs() < 1e-6 && (c2[3] + 15.0).abs() < 1e-6, + "{c2:?}" + ); + // the roundabout: arms east, west and south ending 24 m out, the ring's sidewalk to the north + let rb = b.boxes["rb"]; + assert!( + (rb[0] + 78.0).abs() < 1e-6 + && (rb[3] + 30.0).abs() < 1e-6 + && (rb[1] + 24.0).abs() < 1e-6 + && (rb[4] - 16.0).abs() < 0.01, + "{rb:?}" + ); + let html = render(&b.scene, 0, &RenderOpts::default()).html; + assert!(html.contains("iso-giveway") && html.contains("iso-kerbf")); +} + #[test] fn site_walls_join_at_corners() { let r = Runtime::new(&Bundle::default()).unwrap(); diff --git a/crates/isokit-script/stdlib/ground.rhai b/crates/isokit-script/stdlib/ground.rhai index 53c799b..d6e9d36 100644 --- a/crates/isokit-script/stdlib/ground.rhai +++ b/crates/isokit-script/stdlib/ground.rhai @@ -46,24 +46,28 @@ fn prefab_area(o) { } fn meta_road() { - #{ label: "Road", category: "Ground", icon: "═", shape: "line", - help: "A straight road along its length, with a dashed centre line.", + #{ label: "Road", category: "Ground", icon: "═", shape: "line", corner: "road_curve", + help: "A straight road along its length, with a dashed centre line and sidewalks if you like (left and right looking along local +x). Roads ending on another road's side make a junction; a run drawn corner by corner gets a curve in each corner.", props: [ #{ key: "w", type: "float", def: 40.0, min: 2.0, max: 2000.0, step: 1.0, label: "Length (m)" }, #{ key: "d", type: "float", def: 6.0, min: 2.0, max: 40.0, step: 0.5, label: "Width (m)" }, #{ key: "dashed", type: "bool", def: true, label: "Centre line" }, #{ key: "kerb", type: "bool", def: true, label: "Kerb lines" }, + #{ key: "walk", type: "choice", def: "none", choices: walk_sides(), label: "Sidewalks" }, + #{ key: "walk_w", type: "float", def: 2.0, min: 0.8, max: 8.0, step: 0.1, label: "Walk width (m)" }, ] } } fn prefab_road(o) { let p = o.p; - let w = p.w / 2.0; - let d = p.d / 2.0; layer("ground"); - let s = sprite(); - s.flat(rect(-w, -d, w, d), 0.007, "g-asph t"); - if p.dashed { s.lines([[-w, 0.0, w, 0.0]], 0.012, "dash"); } - if p.kerb { s.lines([[-w, -d, w, -d], [-w, d, w, d]], 0.012, "kerb"); } + let others = other_street_ends(o); + let ends = street_ends(o); + let c = line_path([[-p.w / 2.0, 0.0], [p.w / 2.0, 0.0]]); + let j0 = street_join(o, ends[0], others); + let j1 = street_join(o, ends[1], others); + let gaps = street_gaps(o, others); + street_path(o, c, j0, j1, gaps); + street_info(p, `${num1(p.w)} m long, ${num1(p.d)} m wide`, [j0.meet, j1.meet], gaps[2]); } fn meta_parking() { diff --git a/crates/isokit-script/stdlib/streets.rhai b/crates/isokit-script/stdlib/streets.rhai new file mode 100644 index 0000000..9a30c1b --- /dev/null +++ b/crates/isokit-script/stdlib/streets.rhai @@ -0,0 +1,566 @@ +// isokit/streets: roads drawn along a centre line (straight roads, curves, +// roundabout arms) with kerbs and raised sidewalks, and how streets meet: +// - end to end they carry on seamlessly (a curve continues a road); +// - a road ending on another road's side leaves a gap in that road's +// sidewalk and kerb, and stops its own kerbs and sidewalks at it; +// - two roads ending at one corner close it: each runs over the corner, the +// inner sidewalks stop at the other road's kerb, the outer ones go round. +// Everything lies on the unsorted ground layer, so nothing can upset the +// depth sort; sidewalks are 12 cm high paving with their kerb face drawn +// where it is turned to the viewer. + +fn walk_sides() { ["none", "both", "left", "right"] } +fn walk_h() { 0.12 } +fn street_kinds() { ["road", "road_curve", "roundabout"] } + +/// Sidewalk widths [left, right] of a street looking along its local +x. +fn walk_widths(p) { + let w = p.walk_w; + if p.walk == "both" { [w, w] } else if p.walk == "left" { [w, 0.0] } else if p.walk == "right" { [0.0, w] } else { [0.0, 0.0] } +} + +// ---------------------------------------------------------- polylines + +fn seg_len(a, b) { ((b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])).sqrt() } + +fn path_len(c) { + let l = 0.0; + for i in 1..c.len() { l += seg_len(c[i - 1], c[i]); } + l +} + +fn rev(a) { + let r = a; + r.reverse(); + r +} + +/// The point at distance s along polyline c; before its start and past its +/// end it goes on straight along the first and last segment. +fn path_at(c, s) { + let n = c.len(); + if s <= 0.0 { + let a = c[0]; + let b = c[1]; + let l = seg_len(a, b); + return [a[0] + (b[0] - a[0]) / l * s, a[1] + (b[1] - a[1]) / l * s]; + } + let acc = 0.0; + for i in 1..n { + let a = c[i - 1]; + let b = c[i]; + let l = seg_len(a, b); + if acc + l >= s || i == n - 1 { + let t = (s - acc) / l; + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + } + acc += l; + } + c[n - 1] +} + +/// The part of polyline c from distance s0 to s1 along it (either may lie +/// beyond its ends). +fn path_cut(c, s0, s1) { + let out = []; + if s1 - s0 < 0.01 { return out; } + out.push(path_at(c, s0)); + let acc = 0.0; + for i in 0..c.len() { + if i > 0 { acc += seg_len(c[i - 1], c[i]); } + if acc > s0 + 0.01 && acc < s1 - 0.01 { out.push(c[i]); } + } + out.push(path_at(c, s1)); + out +} + +/// Polyline c moved sideways by off (positive: to the left of its way): +/// each segment along its own normal, neighbours meeting at their mitre. +fn path_offset(c, off) { + let n = c.len(); + let ns = []; + for i in 1..n { + let dx = c[i][0] - c[i - 1][0]; + let dy = c[i][1] - c[i - 1][1]; + let l = fmax((dx * dx + dy * dy).sqrt(), 1e-12); + ns.push([-dy / l, dx / l]); + } + let out = []; + for i in 0..n { + let m = if i == 0 { ns[0] } else if i == n - 1 { ns[n - 2] } else { + let a = ns[i - 1]; + let b = ns[i]; + let k = 1.0 / fmax(1.0 + a[0] * b[0] + a[1] * b[1], 0.2); + [(a[0] + b[0]) * k, (a[1] + b[1]) * k] + }; + out.push([c[i][0] + m[0] * off, c[i][1] + m[1] * off]); + } + out +} + +/// Points of the arc round (cx, cy), radius r, from angle a0 to a1 +/// (radians, either way round): chords of at most 1.5 m and 6 degrees. +fn arc_pts(cx, cy, r, a0, a1) { + let da = (a1 - a0).abs(); + let n = fmin(160.0, fmax(2.0, fmax((da * r / 1.5).ceiling(), (da / 0.105).ceiling()))).to_int(); + let out = []; + for i in 0..(n + 1) { + let t = a0 + (a1 - a0) * i / n; + out.push([cx + r * t.cos(), cy + r * t.sin()]); + } + out +} + +// A street's centre line: #{ arc: false, pts } (a polyline) or #{ arc: true, +// cx, cy, r, a0, a1 } (an arc from angle a0 to a1, either way round). Arcs +// stay arcs when cut and moved sideways, so kerbs and sidewalks meet the +// streets at their ends exactly (a polyline's end chord is a few degrees off). + +fn line_path(pts) { #{ arc: false, pts: pts } } +fn arc_path(cx, cy, r, a0, a1) { #{ arc: true, cx: cx, cy: cy, r: r, a0: a0, a1: a1 } } + +fn pl_len(p) { if p.arc { p.r * (p.a1 - p.a0).abs() } else { path_len(p.pts) } } + +/// The part of centre line p from distance s0 to s1 along it. +fn pl_cut(p, s0, s1) { + if !p.arc { return line_path(path_cut(p.pts, s0, s1)); } + let dir = if p.a1 >= p.a0 { 1.0 } else { -1.0 }; + arc_path(p.cx, p.cy, p.r, p.a0 + dir * s0 / p.r, p.a0 + dir * s1 / p.r) +} + +/// Plan points of centre line p moved sideways by off (positive: left of its way). +fn pl_off(p, off) { + if !p.arc { return path_offset(p.pts, off); } + let dir = if p.a1 >= p.a0 { 1.0 } else { -1.0 }; + arc_pts(p.cx, p.cy, p.r - dir * off, p.a0, p.a1) +} + +/// [x, y, tx, ty] at the start (e = 0) or end (e = 1) of p: the point and +/// the unit direction of the way there. +fn pl_end(p, e) { + if p.arc { + let dir = if p.a1 >= p.a0 { 1.0 } else { -1.0 }; + let a = if e == 0 { p.a0 } else { p.a1 }; + return [p.cx + p.r * a.cos(), p.cy + p.r * a.sin(), -dir * a.sin(), dir * a.cos()]; + } + let c = p.pts; + let n = c.len(); + let a = if e == 0 { c[0] } else { c[n - 2] }; + let b = if e == 0 { c[1] } else { c[n - 1] }; + let l = seg_len(a, b); + let q = if e == 0 { a } else { b }; + [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 +/// of k, or to its left) is turned to the viewer: polygons for poly(). +fn kerb_faces(k, h, right) { + let fs = []; + for i in 1..k.len() { + let a = k[i - 1]; + let b = k[i]; + let nx = b[1] - a[1]; + let ny = a[0] - b[0]; + if !right { nx = -nx; ny = -ny; } + if vis(nx, ny) { fs.push([[a[0], a[1], 0.0], [b[0], b[1], 0.0], [b[0], b[1], h], [a[0], a[1], h]]); } + } + fs +} + +/// 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; } + let h = walk_h(); + this.flat(k + rev(o), 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 fs.len() > 0 { this.poly(fs, "kerbf"); } + this +} + +// ---------------------------------------------------------- where streets meet + +/// The open ends of street o in world plan coordinates: [#{ x, y, dx, dy, +/// d, wl, wr }] with (dx, dy) the unit direction out of the street, d its +/// carriageway width and wl, wr its sidewalk widths left and right looking out. +fn street_ends(o) { + let ends = []; + let p = o.p; + let add = |u, v, du, dv, d, wl, wr| { + let a = world_of(o, u, v); + let b = world_of(o, u + du, v + dv); + ends.push(#{ x: a[0], y: a[1], dx: b[0] - a[0], dy: b[1] - a[1], d: d, wl: wl, wr: wr, id: o.id }); + }; + if o.type == "road" || o.type == "road_curve" { + let ws = walk_widths(p); + let c = if o.type == "road" { line_path([[-p.w / 2.0, 0.0], [p.w / 2.0, 0.0]]) } else { curve_line(p) }; + let a = pl_end(c, 0); + let b = pl_end(c, 1); + add.call(a[0], a[1], -a[2], -a[3], p.d, ws[1], ws[0]); + add.call(b[0], b[1], b[2], b[3], p.d, ws[0], ws[1]); + } else if o.type == "roundabout" { + let w = if p.walk { p.walk_w } else { 0.0 }; + let R = p.r + p.arm_len; + for a in round_arms(p) { + let ca = a.cos(); + let sa = a.sin(); + add.call(R * ca, R * sa, ca, sa, p.road_d, w, w); + } + } + ends +} + +/// 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. +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: "" }; + // pieces that carry on overlap by a few cm: edge to edge, the ground shows through as a hairline + let lap = 0.04; + 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; } + return j; + } + // ending on a road's side (a T): kerbs stop at its kerb, sidewalks at its sidewalk + 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 ws = walk_widths(c.p); + let h = c.p.d / 2.0; + let w = if l[1] > c.v { ws[0] } else { ws[1] }; + 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; + 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. +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; } + 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]; + if b[1] > l[1] { g[0].push(r); } else { g[1].push(r); } + g[2].push(f.id); + } + g +} + +/// Tooltip rows of a street: its size, sidewalks and the streets it meets +/// (ids, at its ends and along its sides). +fn street_info(p, what, ends, sides) { + info_row("Street", what); + let ws = walk_widths(p); + if ws[0] > 0.0 && ws[1] > 0.0 { info_row("Sidewalks", `${num1(p.walk_w)} m on both sides`); } else if ws[0] > 0.0 { info_row("Sidewalks", `${num1(p.walk_w)} m on the left`); } else if ws[1] > 0.0 { info_row("Sidewalks", `${num1(p.walk_w)} m on the right`); } + let meets = []; + for id in ends + sides { if id != "" && !meets.contains(id) { meets.push(id); } } + if meets.len() > 0 { info_row("Meets", meets.reduce(|s, x| if s == "" { x } else { s + ", " + x }, "")); } +} + +/// The ends of every other street on this level. +fn other_street_ends(o) { + let out = []; + for c in objects_of(street_kinds()) { + if c.id != o.id { out += street_ends(c); } + } + out +} + +/// 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). +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"); + } + // side 0: left of the way (the start end's right, the end's left); 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 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); + if side == 1 { line.reverse(); } + if ww > 0.0 { + let fs = kerb_faces(line, walk_h(), true); + if fs.len() > 0 { s.poly(fs, "kerbf"); } + } else if p.kerb { + s.polyline(line, 0.012, "kerb"); + } + } + if ww > 0.0 { + let rs = ranges(-w0, L + w1, gaps[side]); + 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)); + // 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 +} + +// ---------------------------------------------------------- curves + +/// The centre line of curve p in its frame: from (0, 0) along +x, turning +/// left (towards +y) or right by p.angle degrees on radius p.r. +fn curve_line(p) { + let A = p.angle * PI() / 180.0; + let sg = if p.turn == "right" { -1.0 } else { 1.0 }; + arc_path(0.0, sg * p.r, p.r, -sg * PI() / 2.0, -sg * PI() / 2.0 + sg * A) +} + +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.", + 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)" }, + #{ key: "turn", type: "choice", def: "left", choices: ["left", "right"], label: "Turns" }, + #{ key: "d", type: "float", def: 6.0, min: 2.0, max: 40.0, step: 0.5, label: "Width (m)" }, + #{ key: "dashed", type: "bool", def: true, label: "Centre line" }, + #{ key: "kerb", type: "bool", def: true, label: "Kerb lines" }, + #{ key: "walk", type: "choice", def: "none", choices: walk_sides(), label: "Sidewalks" }, + #{ key: "walk_w", type: "float", def: 2.0, min: 0.8, max: 8.0, step: 0.1, label: "Walk width (m)" }, + ] } +} +fn prefab_road_curve(o) { + let p = o.p; + if p.r <= p.d / 2.0 + 0.2 { warn(`${o.id}: radius ${p.r} m is too tight for a ${p.d} m road`); } + layer("ground"); + let c = curve_line(p); + let others = other_street_ends(o); + let ends = street_ends(o); + let j0 = street_join(o, ends[0], others); + let j1 = street_join(o, ends[1], others); + street_path(o, c, j0, j1, [[], []]); + street_info(p, `${num1(p.angle)} degree turn to the ${p.turn}, ${num1(p.r)} m radius, ${num1(p.d)} m wide`, [j0.meet, j1.meet], []); +} + +// ---------------------------------------------------------- roundabouts + +/// The angles (radians, in the roundabout's frame) of its arms. +fn round_arms(p) { + let out = []; + if p.arm_e { out.push(0.0); } + if p.arm_n { out.push(PI() / 2.0); } + if p.arm_w { out.push(PI()); } + if p.arm_s { out.push(PI() * 1.5); } + out +} + +/// (x, y) turned by angle a. +fn turned(a, x, y) { [x * a.cos() - y * a.sin(), x * a.sin() + y * a.cos()] } + +fn meta_roundabout() { + #{ label: "Roundabout", category: "Ground", icon: "◎", + help: "A roundabout with up to four arms (east, north, west, south in its frame) ending Radius + Arm length from its centre, where straight roads carry on. Kerbs flare into the ring; splitter islands and give-way lines sit at the entries (traffic on the left or the right).", + props: [ + #{ key: "r", type: "float", def: 14.0, min: 6.0, max: 60.0, step: 0.5, label: "Radius (m)" }, + #{ key: "lane", type: "float", def: 7.0, min: 4.0, max: 16.0, step: 0.5, label: "Ring width (m)" }, + #{ key: "lanes", type: "int", def: 1, min: 1, max: 2, step: 1, label: "Ring lanes" }, + #{ key: "arm_e", type: "bool", def: true, label: "Arm east" }, + #{ key: "arm_n", type: "bool", def: true, label: "Arm north" }, + #{ key: "arm_w", type: "bool", def: true, label: "Arm west" }, + #{ key: "arm_s", type: "bool", def: true, label: "Arm south" }, + #{ key: "arm_len", type: "float", def: 10.0, min: 4.0, max: 80.0, step: 0.5, label: "Arm length (m)" }, + #{ key: "road_d", type: "float", def: 6.0, min: 3.0, max: 20.0, step: 0.5, label: "Arm width (m)" }, + #{ key: "island", type: "choice", def: "tree", choices: ["tree", "grass", "paved"], label: "Island" }, + #{ key: "splitters", type: "bool", def: true, label: "Splitter islands" }, + #{ key: "drive", type: "choice", def: "left", choices: ["left", "right"], label: "Traffic on the" }, + #{ key: "walk", type: "bool", def: true, label: "Sidewalks" }, + #{ key: "walk_w", type: "float", def: 2.0, min: 0.8, max: 8.0, step: 0.1, label: "Walk width (m)" }, + ] } +} +fn prefab_roundabout(o) { + let p = o.p; + let r = p.r; + let ri = fmax(1.5, r - p.lane); + let h = p.road_d / 2.0; + let R = r + p.arm_len; + let ww = if p.walk { p.walk_w } else { 0.0 }; + let arms = round_arms(p); + // arm ends where a street carries on: no sidewalk end faces there + let others = other_street_ends(o); + let carried = |a| { + let e = world_of(o, R * a.cos(), R * a.sin()); + others.some(|f| (f.x - e[0]).abs() < 0.3 && (f.y - e[1]).abs() < 0.3) + }; + layer("ground"); + let s = sprite(); + // flared kerbs: a fillet of radius f between each arm edge and the ring + let f = fmax(ww + 1.0, fmin(6.0, p.arm_len * 0.6)); + let cx = ((r + f) * (r + f) - (h + f) * (h + f)).sqrt(); + if cx > R - 0.3 { + f = fmax(0.5, ww + 0.5); + cx = ((r + f) * (r + f) - (h + f) * (h + f)).sqrt(); + } + let phi = plan_angle(cx, h + f); + let xi = (r * r - h * h).sqrt(); + // carriageway: ring, arms and the flares between them + s.flat([arc_pts(0.0, 0.0, r, 0.0, 2.0 * PI()), arc_pts(0.0, 0.0, ri, 0.0, 2.0 * PI())], 0.007, "g-asph t"); + // each arm with its flares is one outline reaching into the ring's + // asphalt (pieces sharing an edge would show a hairline seam) + let flares = []; + for a in arms { + let fr = turned(a, cx, -(h + f)); + let fl = turned(a, cx, h + f); + let out = [turned(a, R, -h), turned(a, cx, -h)]; + out += arc_pts(fr[0], fr[1], f, a + PI() / 2.0, a + PI() - phi); + out += [turned(a, xi - 0.5, -h), turned(a, xi - 0.5, h)]; + out += arc_pts(fl[0], fl[1], f, a - PI() + phi, a - PI() / 2.0); + out += [turned(a, cx, h), turned(a, R, h)]; + flares.push(out); + } + if flares.len() > 0 { s.flat(flares, 0.007, "g-asph t"); } + // the kerb line round the outside, sector by sector between the arms + let chains = []; + let caps = []; + if arms.len() == 0 { + chains.push(arc_pts(0.0, 0.0, r, 2.0 * PI(), 0.0)); + caps.push([false, false]); + } else { + for i in 0..arms.len() { + let a = arms[i]; + let b = if i + 1 < arms.len() { arms[i + 1] } else { arms[0] + 2.0 * PI() }; + let ch = [turned(a, R, h), turned(a, cx, h)]; + let fa = turned(a, cx, h + f); + ch += arc_pts(fa[0], fa[1], f, a - PI() / 2.0, a - PI() + phi); + ch += arc_pts(0.0, 0.0, r, a + phi, b - phi); + let fb = turned(b, cx, -(h + f)); + ch += arc_pts(fb[0], fb[1], f, b + PI() - phi, b + PI() / 2.0); + ch.push(turned(b, R, -h)); + // drawn with the road on its right: from arm b's end round to arm a's + chains.push(rev(ch)); + caps.push([!carried.call(b), !carried.call(a)]); + } + } + for i in 0..chains.len() { + let ch = chains[i]; + if ww > 0.0 { + let fs = kerb_faces(ch, walk_h(), true); + if fs.len() > 0 { s.poly(fs, "kerbf"); } + s.walk_strip(ch, path_offset(ch, ww), caps[i]); + } else { + s.polyline(ch, 0.012, "kerb"); + } + } + // markings: give-way lines across the entry lanes, the lane line, the arms' centre lines + let entry = if p.drive == "right" { 1.0 } else { -1.0 }; + let sl = if p.splitters { fmin(6.0, p.arm_len - 1.5) } else { 0.0 }; + let segs = []; + let gw = []; + for a in arms { + let x0 = r + 0.5; + let ia = if p.splitters { 1.0 } else { 0.2 }; + let g0 = turned(a, x0, entry * ia); + let g1 = turned(a, x0, entry * (h - 0.25)); + gw.push([g0[0], g0[1], g1[0], g1[1]]); + let d0 = turned(a, x0 + sl + 0.6, 0.0); + let d1 = turned(a, R, 0.0); + if R - (x0 + sl + 0.6) > 0.5 { segs.push([d0[0], d0[1], d1[0], d1[1]]); } + } + if gw.len() > 0 { s.lines(gw, 0.012, "giveway"); } + if segs.len() > 0 { s.lines(segs, 0.012, "dash"); } + if p.lanes > 1 { s.polyline(arc_pts(0.0, 0.0, (r + ri) / 2.0, 0.0, 2.0 * PI()), 0.012, "lane"); } + // the raised islands: splitters at the entries, the centre island with its apron + let kh = walk_h(); + if p.splitters { + for a in arms { + let x0 = r + 0.8; + let isl = [turned(a, x0, -0.9), turned(a, x0 + sl, -0.25), turned(a, x0 + sl, 0.25), turned(a, x0, 0.9)]; + s.flat(isl, kh, "g-walk t"); + let fs = kerb_faces(isl + [isl[0]], kh, true); + if fs.len() > 0 { s.poly(fs, "kerbf"); } + } + } + let ring = arc_pts(0.0, 0.0, ri, 0.0, 2.0 * PI()); + let apron = fmin(1.5, ri * 0.35); + let top = if p.island == "paved" { "g-walk" } else { "g-grass" }; + s.flat(ring, kh, "g-apron t"); + s.flat(arc_pts(0.0, 0.0, ri - apron, 0.0, 2.0 * PI()), kh + 0.01, top + " t"); + let fs = kerb_faces(ring, kh, true); + if fs.len() > 0 { s.poly(fs, "kerbf"); } + if p.island == "tree" { + layer("main"); + sprite().tree_at(0.0, 0.0, fmin(3.0, (ri - apron) * 0.7)); + } + let names = ["east", "north", "west", "south"]; + let on = [p.arm_e, p.arm_n, p.arm_w, p.arm_s]; + let which = []; + for i in 0..4 { if on[i] { which.push(names[i]); } } + info_row("Ring", `${num1(r)} m radius, ${num1(p.lane)} m wide, ${p.lanes} lane${if p.lanes > 1 { "s" } else { "" }}`); + if arms.len() > 0 { info_row("Arms", `${arms.len()}: ${which.reduce(|s, x| if s == "" { x } else { s + ", " + x }, "")}; ${num1(p.road_d)} m wide`); } + info_row("Traffic", `on the ${p.drive}, round the island ${if p.drive == "left" { "clockwise" } else { "anticlockwise" }}`); + let meets = []; + for a in arms { + let e = world_of(o, R * a.cos(), R * a.sin()); + for f in others { if (f.x - e[0]).abs() < 0.3 && (f.y - e[1]).abs() < 0.3 && !meets.contains(f.id) { meets.push(f.id); } } + } + if meets.len() > 0 { info_row("Meets", meets.reduce(|s, x| if s == "" { x } else { s + ", " + x }, "")); } +} diff --git a/crates/isokit-theme/themes/default.toml b/crates/isokit-theme/themes/default.toml index 98d7dc5..8055d7d 100644 --- a/crates/isokit-theme/themes/default.toml +++ b/crates/isokit-theme/themes/default.toml @@ -272,6 +272,10 @@ mach3 = "#5b6370" mach4 = "#b8692b" robot = "#e07a1f" robotb = "#3a3f48" +# streets: sidewalk paving, kerb stone faces, roundabout apron +walk = "#555c6b" +kerbf = "#7d8494" +apron = "#4a4840" [palette.light] sky1 = "#a9d4fb" @@ -538,6 +542,9 @@ mach3 = "#8a93a1" mach4 = "#e08a43" robot = "#f08c2b" robotb = "#5a616d" +walk = "#d9d6cf" +kerbf = "#a5a9b1" +apron = "#b9ad98" [materials] warn = "#e0412f" @@ -548,6 +555,8 @@ g-asph = "asph" g-conc = "conc" g-pad = "pad" g-path = "path" +g-walk = "walk" +g-apron = "apron" plinth = "plinth" wall = "wall" wall2 = "wall2" @@ -809,6 +818,9 @@ f-conc = "conc" ".iso-svg .iso-win" = "fill: var(--iso-win); stroke: none" ".iso-svg .iso-school" = "pointer-events: none" ".iso-svg .iso-swimbox" = "fill: none; stroke: none" +".iso-svg .iso-kerbf" = "fill: var(--iso-kerbf); stroke: none" +".iso-svg .iso-lane" = "fill: none; stroke: var(--iso-dash); stroke-width: .6; stroke-dasharray: 4 6" +".iso-svg .iso-giveway" = "fill: none; stroke: var(--iso-dash); stroke-width: .9; stroke-dasharray: 2 2" ".iso-svg .iso-ledr" = "fill: var(--iso-led-r); stroke: none" ".iso-svg .iso-ledg" = "fill: var(--iso-led-g); stroke: none" ".iso-svg .iso-ledo" = "fill: var(--iso-led-o); stroke: none" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bb49e44..bf87129 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,7 +34,8 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push. - [x] Code editor for scripts, prefabs, theme and manifest, with script errors by file and line - [x] MCP tools inside the page (`isokit.mcp`), WebMCP registration when the browser offers it - [x] `tools/build_web.py` (static output), `tools/check_web.py` (44 checks, Edge) -- [ ] Deploy a hosted copy; render in a Web Worker for very large scenes +- [x] Hosted copy at https://isokit.buildfor.life/ (deployed by `.gitea/workflows/deploy-web.yml`) +- [ ] Render in a Web Worker for very large scenes ## Agent window (done) @@ -75,9 +76,9 @@ Each phase ends with tests, a Studio/viewer check in Edge, commit and push. 2. ~~Conveyors~~ (done, see below) 3. ~~Producers with adjustable production speed~~ (done, see below) 4. ~~Robotic arms~~ (done, see below) -5. Rounded streets: curved road segments -6. Sidewalks along streets -7. Roundabouts +5. ~~Rounded streets: curved road segments~~ (done, see below) +6. ~~Sidewalks along streets~~ (done, see below) +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) @@ -108,12 +109,23 @@ another belt or on a pallet, one move per item (or a set cycle), waiting while t stands; the belt after it runs in step. Drawn as a 16-pose flipbook (inverse kinematics, links as the new native `beam` primitive) shown by a stepped CSS animation. +Streets (stdlib/streets.rhai): roads take sidewalks (none, both, left, right; 12 cm high paving with +kerb faces); Road curve (radius, angle, left / right) carries a street round a bend, its kerbs and +sidewalks drawn as true arcs so the joints are seamless; a road ending on another road's centre +line opens that road's kerb and sidewalk (T junction); two roads ending at one corner close it. +Roundabout: ring radius and width, one or two lanes, up to four arms with flared kerbs, splitter +islands and give-way lines on the entry side (traffic on the left or right), central island with +apron (tree, grass or paved). A road run drawn corner by corner gets a curve in each corner (prefab +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. + ## Phase 2: richer editing - [x] Interior prefabs (see Build your own house); a building `rooms` prop is still open - [ ] Path objects in the Studio: click points for areas, roads (polyline with width), fences, walls, pipe runs; vertex handles -- [ ] Multi-select (box select, Shift-click), group move, align/distribute +- [x] Multi-select (box select, Ctrl+click, Shift range), group move, turn, copy, delete, edit +- [ ] Align / distribute the selection - [ ] Copy/paste between projects; prefab thumbnails in the palette (rendered icons) - [ ] Measure tool and grid overlay; numeric resize handles on the selection - [ ] Studio editor for `theme.toml` (colour pickers) and for project parameters diff --git a/docs/SCRIPTING.md b/docs/SCRIPTING.md index 7e4f2ca..9a6f39d 100644 --- a/docs/SCRIPTING.md +++ b/docs/SCRIPTING.md @@ -88,6 +88,7 @@ shaded automatically by face direction. | `flat(poly or polys, z, cls[, id])` | flat polygon(s); later ones cut holes | | `open(a, b, [[u0, u1, z0, z1], …], cls[, id])` | windows / doors on the face a→b (outward normal on the right) | | `lines(segs, z, cls)`, `disc(cx, cy, z, r, cls[, id])` | markings | +| `polyline(lines, z, cls)` | open lines through plan points at height z (or 3D points): a dashed line stays one dash run round a curve | | `circle(x, y, z, r_px, cls[, dx, dy])`, `stem(x, y, z0, z1, w_px, cls)` | screen-space details (crowns, poles) | | `person(x, y, z[, cls])`, `sit(x, y, z, cls)`, `head(x, y, z, r)`, `led(x, y, z[, cls])` | figures | | `puffs(x, y, z[, kind[, n, dur]])`, `fan(x, y, z, r)` | animated steam / smoke / fans | @@ -153,6 +154,34 @@ through the document, like house parts: Poses pointing away from the viewer are drawn before the base, the others after it. +## Streets + +`road`, `road_curve` and `roundabout` (stdlib/streets.rhai; `road` lives in +ground.rhai) meet by their centre lines: + +- Each street is a centre line (a polyline or a true arc) with the + carriageway `d` wide around it; `walk` (none, both, left, right, looking + along local +x) adds sidewalks `walk_w` wide behind the kerbs: 12 cm high + paving with the kerb face drawn where it is turned to the viewer. Arcs stay + 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 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. +- The prefab meta `corner: "road_curve"` on `road` tells the Studio to round + each corner of a road run with a curve (up to its default radius, less + where the pieces next to a corner are short). +- `street_ends(o)`, `street_join(o, end, others)`, `street_gaps(o, others)` + and `street_path(o, line, j0, j1, gaps)` are there for your own street + prefabs; tooltips list the streets each one meets (`Meets`). + ## House parts `house_wall`, `window`, `door`, `floor`, `roof`, `stairs` and the furniture diff --git a/examples/streets/isokit.toml b/examples/streets/isokit.toml new file mode 100644 index 0000000..25e49a2 --- /dev/null +++ b/examples/streets/isokit.toml @@ -0,0 +1,29 @@ +# isokit streets demo: roads with sidewalks, a rounded corner, junctions and a roundabout. +# Open it with `isokit studio examples/streets`. +title = "isokit streets" +note = "Streets drawn along their centre lines: curves carry them round corners, side streets open the sidewalks they join, and the roundabout's kerbs flare into its arms. Tap a street for its numbers. Drag to pan, Ctrl + wheel or pinch to zoom." +default_level = "site" +default_rotation = 1 +prelude = [] +theme = [] + +[[level]] +id = "site" +label = "Site" +title = "A town quarter" + +[[param]] +name = "labels" +type = "bool" +default = true +label = "Labels" + +[[param]] +name = "running" +type = "bool" +default = true +label = "Animation" + +[build] +out = "dist/index.html" +budget = 6000 diff --git a/examples/streets/scene.json b/examples/streets/scene.json new file mode 100644 index 0000000..fa9c9c8 --- /dev/null +++ b/examples/streets/scene.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "objects": [ + {"id": "site", "type": "site", "name": "Quarter", "x": 0, "y": 4, "locked": true, "props": {"w": 112, "d": 92, "mat": "g-grass"}}, + {"id": "rb", "type": "roundabout", "name": "Roundabout", "x": 0, "y": 0, "props": {"r": 12, "lane": 7, "arm_len": 8, "road_d": 6.5, "walk_w": 2.5}}, + {"id": "east-ave", "type": "road", "name": "East avenue", "x": 36, "y": 0, "props": {"walk": "both", "walk_w": 2.5, "d": 6.5, "w": 32}}, + {"id": "west-st", "type": "road", "name": "West street", "x": -36, "y": 0, "props": {"walk": "both", "walk_w": 2.5, "d": 6.5, "w": 32}}, + {"id": "south-st", "type": "road", "name": "South street", "x": 0, "y": -31, "rot": 90, "props": {"walk": "both", "walk_w": 2.5, "d": 6.5, "w": 22}}, + {"id": "north-st", "type": "road", "name": "North street", "x": 0, "y": 27, "rot": 90, "props": {"walk": "both", "walk_w": 2.5, "d": 6.5, "w": 14}}, + {"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": "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}}, + {"id": "shop", "type": "building", "name": "Corner shop", "x": -14, "y": 30, "props": {"w": 10, "d": 9, "floors": 1}}, + {"id": "parking", "type": "parking", "name": "Car park", "x": -36, "y": 22, "props": {"n": 8}}, + {"id": "pond", "type": "pond", "name": "Park pond", "x": 32, "y": -24}, + {"id": "tree-1", "type": "tree", "x": 16, "y": -16, "props": {"r": 2.2}}, + {"id": "tree-2", "type": "tree", "x": 20, "y": -33, "props": {"r": 2.0}}, + {"id": "tree-3", "type": "tree", "x": 46, "y": -14}, + {"id": "tree-4", "type": "tree", "x": 48, "y": -34, "props": {"r": 2.4}}, + {"id": "trees-west", "type": "tree_row", "name": "Avenue trees", "x": -36, "y": 9, "props": {"w": 28}}, + {"id": "car-1", "type": "car", "x": 30, "y": 1.6, "props": {"moving": true, "travel": 22, "dur": 6, "colour": "car1"}}, + {"id": "car-2", "type": "car", "x": 44, "y": -1.6, "rot": 180, "props": {"moving": true, "travel": 22, "dur": 7}}, + {"id": "car-3", "type": "car", "x": -1.6, "y": -30, "rot": 90, "props": {"colour": "car3"}}, + {"id": "bus", "type": "truck", "name": "Delivery", "x": -40, "y": 1.6, "props": {"len": 7}}, + {"id": "moto", "type": "moto", "x": 1.6, "y": 26, "rot": 270}, + {"id": "walker-1", "type": "person", "x": 24, "y": 4.6, "props": {"cls": "guest1"}}, + {"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-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}, + {"id": "lamp-5", "type": "lamp", "x": 5.2, "y": -28} + ] +} diff --git a/studio/studio.js b/studio/studio.js index a30675a..2978fd5 100644 --- a/studio/studio.js +++ b/studio/studio.js @@ -174,7 +174,8 @@ polys.push(cornerMark(c.pts[0], c.z)); var closes = c.pts.length > 2 && Math.hypot(q[0] - c.pts[0][0], q[1] - c.pts[0][1]) < 1e-6; var n = c.pts.length - 1 + (Math.hypot(q[0] - last[0], q[1] - last[1]) > 0.05 ? 1 : 0); - ghost(polys, c.m.label + ": " + n + " piece" + (n === 1 ? "" : "s") + ", " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m · " + + ghost(polys, c.m.label + ": " + n + " piece" + (n === 1 ? "" : "s") + ", " + (runLength(c.pts) + Math.hypot(q[0] - last[0], q[1] - last[1])).toFixed(1) + " m" + + (c.m.corner ? ", corners rounded" : "") + " · " + (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"); } // the turn of a line piece from p to q: along x or y; directed lines (conveyors) point from p to q @@ -183,11 +184,44 @@ if (!m.directed) return ax ? 0 : 90; return ax ? (q[0] >= p[0] ? 0 : 180) : (q[1] >= p[1] ? 90 : 270); } - // the pieces of a run as add ops (pieces longer than the prefab allows are split) + // 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) { + var segs = [], curves = [], cm = m.corner && prefab(m.corner), i; + for (i = 1; i < pts.length; i++) if (Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]) >= 0.05) segs.push([pts[i - 1].slice(), pts[i].slice()]); + var n = segs.length; + if (!cm || n < 2) return { segs: segs, curves: curves }; + 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 + 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 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)); + 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" } }; + 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]; + } + return { segs: segs, curves: curves }; + } + // the pieces of a run as add ops (pieces longer than the prefab allows are split; corners rounded) function runOps(m, pts, z) { - var ops = [], max = +((m.props || []).filter(function (d) { return d.key === "w"; })[0] || {}).max || 1e9; - for (var i = 1; i < pts.length; i++) { - var p = pts[i - 1], q = pts[i], L = Math.hypot(q[0] - p[0], q[1] - p[1]), n = Math.ceil(L / max - 1e-9); + var ops = [], max = +((m.props || []).filter(function (d) { return d.key === "w"; })[0] || {}).max || 1e9, rr = roundRun(m, pts, z); + for (var i = 0; i < rr.segs.length; i++) { + var p = rr.segs[i][0], q = rr.segs[i][1], L = Math.hypot(q[0] - p[0], q[1] - p[1]), n = Math.ceil(L / max - 1e-9); if (L < 0.05) continue; for (var j = 0; j < n; j++) { var t = (j + 0.5) / n, add = { op: "add", type: m.type, x: r3(p[0] + (q[0] - p[0]) * t), y: r3(p[1] + (q[1] - p[1]) * t), rot: lineRot(m, p, q), props: { w: clampProp(m, "w", L / n) } }; @@ -195,7 +229,7 @@ ops.push(add); } } - return ops; + return ops.concat(rr.curves); } function finishChain(keep) { var c = chain; chain = null; ghost(null); diff --git a/tools/build_web.py b/tools/build_web.py index 62fd264..c6cf6bd 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -149,6 +149,7 @@ def main(): (web / "demo.json").write_text(json.dumps(demo_files(), ensure_ascii=False), encoding="utf-8") (web / "house.json").write_text(json.dumps(demo_files("house"), ensure_ascii=False), encoding="utf-8") (web / "factory.json").write_text(json.dumps(demo_files("factory"), ensure_ascii=False), encoding="utf-8") + (web / "streets.json").write_text(json.dumps(demo_files("streets"), ensure_ascii=False), encoding="utf-8") h = hashlib.sha256() for p in sorted((out / "assets").rglob("*")): diff --git a/tools/check_studio.py b/tools/check_studio.py index 992b825..97af45c 100644 --- a/tools/check_studio.py +++ b/tools/check_studio.py @@ -471,6 +471,38 @@ with sync_playwright() as p: pg.evaluate("__studio.select(null)") pg.wait_for_timeout(300) + # a road run drawn corner by corner off the main road: a curve rounds its corner, the road it + # starts on gets a junction (its south kerb opens), and one undo step takes the run back + pg.evaluate("__studio.view.focus([6, -27, 0], 2.2)") + pg.wait_for_timeout(400) + n4 = len(doc()["objects"]) + kerbs = lambda: pg.locator('.iso-svg g[data-obj="road"] .iso-kerb').count() + k0 = kerbs() + pg.locator('.st-tile[data-type="road"]').click() + for x, y in [(-2, -19.3), (-2.1, -32), (20, -32.1)]: + pg.mouse.click(*at(x, y)) + pg.wait_for_timeout(150) + pg.mouse.move(*at(20, -36)) + pg.wait_for_timeout(150) + check("corners rounded" in pg.locator("#hint").inner_text(), "a road run says its corners get rounded: " + pg.locator("#hint").inner_text()[:50]) + pg.keyboard.press("Enter") + pg.wait_for_timeout(1200) + run = doc()["objects"][n4:] + got = sorted((o["type"], o["x"], o["y"], o.get("rot", 0), o["props"].get("w", o["props"].get("r"))) for o in run) + want = sorted([("road", -2, -20.75, 90, 2.5), ("road", 14, -32, 0, 12), ("road_curve", -2, -22, 270, 10)]) + check(got == want, f"the run's corner gets a curve: {got}") + rv = get("/api/render?level=site&rot=0") + stub = next((o["id"] for o in run if o["type"] == "road" and o["x"] == -2), None) + sb = rv["boxes"].get(stub, [0] * 6) + check(abs(sb[4] + 19.5) < 1e-6 and kerbs() == k0 + 1, + f"the run starts on the main road's centre line and opens its kerb: {sb[4]:.2f}, {k0} -> {kerbs()} kerb lines") + pg.evaluate("__studio.select(null)") + pg.wait_for_timeout(300) + pg.screenshot(path=str(OUT / "studio-road-run.jpg"), type="jpeg", quality=80) + pg.locator("#undo").click() + pg.wait_for_timeout(900) + check(len(doc()["objects"]) == n4 and kerbs() == k0, "one undo takes the road run 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") diff --git a/tools/check_web.py b/tools/check_web.py index fc39f7d..8048236 100644 --- a/tools/check_web.py +++ b/tools/check_web.py @@ -326,6 +326,22 @@ try: check("(from robot-b)" in loads and flips == 32, f"robot arms pass the goods on ({flips} poses): " + loads) shot(pg, "web-factory.jpg") + # ---------------------------------------------------------------- the streets demo: sidewalks, curves, a roundabout + pg.locator("#wb-projects").click() + pg.locator("#wb-nstreets").click() + pg.wait_for_load_state() + ready(pg) + pg.wait_for_timeout(500) + ns = len(doc(pg)["objects"]) + walks = pg.evaluate("() => document.querySelectorAll('.iso-svg .iso-g-walk').length") + gw = pg.evaluate("() => document.querySelectorAll('.iso-svg .iso-giveway').length") + check(title(pg) == "isokit streets" and ns > 25 and walks > 15 and gw == 1 and pg.evaluate("() => __studio.S.warnings.length") == 0, + f"Streets demo opens a town quarter ({ns} objects, {walks} sidewalk pieces, give-way lines, no warnings)") + meets = pg.evaluate("() => Object.fromEntries((__studio.view.info('cross-st') || {rows: []}).rows)['Meets'] || ''") + ring = pg.evaluate("() => Object.fromEntries((__studio.view.info('rb') || {rows: []}).rows)['Arms'] || ''") + check(meets == "east-ave, park-rd" and ring.startswith("4:"), f"streets know what they meet: cross-st meets {meets}; roundabout arms {ring}") + shot(pg, "web-streets.jpg") + # ---------------------------------------------------------------- a system asking for less motion # (Windows: Animation effects off): the view starts still, but the button stays and turns it on rctx = b.new_context(viewport={"width": 1200, "height": 800}, reduced_motion="reduce") diff --git a/web/web.js b/web/web.js index 7fd98d2..70204db 100644 --- a/web/web.js +++ b/web/web.js @@ -367,7 +367,7 @@ function injectUI() { '

Projects

' + '
' + '
    ' + - '
    ' + + '
    ' + '
    Import: drop a project folder or .zip here, or ' + (canOpenFolder ? '' : "") + "
    " + '

    Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (isokit mcp), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "

    " + @@ -419,6 +419,7 @@ function wireProjects() { $("wb-ndemo").addEventListener("click", async function () { openProject((await createProject(await demoFiles())).id); }); $("wb-nhouse").addEventListener("click", async function () { openProject((await createProject(await demoFiles("house.json"))).id); }); $("wb-nfactory").addEventListener("click", async function () { openProject((await createProject(await demoFiles("factory.json"))).id); }); + $("wb-nstreets").addEventListener("click", async function () { openProject((await createProject(await demoFiles("streets.json"))).id); }); $("wb-izip").addEventListener("click", function () { $("wb-fzip").click(); }); $("wb-idir").addEventListener("click", function () { $("wb-fdir").click(); }); $("wb-fzip").addEventListener("change", function () { var f = this.files[0]; if (f) importZip(f); this.value = ""; });