diff --git a/ROADMAP.md b/ROADMAP.md index 799d98e..e78df3e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,6 +32,7 @@ luxpath's one-page summary. Details live in: | Date | Change | |---|---| +| 2026-10-21 | Routes can be given as their floor outline (BIM models draw corridors as rooms), for Ashlar's emergency-lighting plugin (R2.9a). | | 2026-10-21 | Walls block light (gap 16): `[[wall]]` in scenes, sight lines tested against every wall piece; the corridor example's minimum drops from 1.45 to 0.60 lx once light no longer passes through its corner; 100 luminaires × 10,000 points × 36 walls in about 90 ms. | | 2026-10-21 | Publishing prepared (docs/publishing.md): history scanned clean, `cargo deny` with a CI job, SECURITY.md, CONTRIBUTING.md, issue templates (wrong result, bug), `publish = false` until a crates.io decision. | | 2026-10-21 | M3: scene format version 1 (docs/scene-format.md: photometry with emergency output, luminaires, routes, open and high-risk areas, points of emphasis; unknown fields refused, errors with line and column); `luxpath check` with exit codes 0/1/2/3, results per element with their assumptions, "cannot verify" when an emergency output is missing; three example scenes with synthetic photometry. | diff --git a/crates/rules/src/lib.rs b/crates/rules/src/lib.rs index 85129fe..bf67eb8 100644 --- a/crates/rules/src/lib.rs +++ b/crates/rules/src/lib.rs @@ -18,7 +18,7 @@ use luxpath_calc::{Grid, Luminaire, Stats, illuminance_with_walls}; use luxpath_geom::Vec3; use luxpath_photometry::Photometry; -use luxpath_scene::{AreaKind, PointKind, Scene}; +use luxpath_scene::{AreaKind, PointKind, RouteShape, Scene}; /// What one element's illuminance came to. #[derive(Debug, Clone, PartialEq)] @@ -167,7 +167,12 @@ pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result { + Grid::route(path, *width_m, r.floor_m, calc.spacing_m, calc.route_points_across) + } + RouteShape::Outline(outline) => Grid::area(outline, r.floor_m, calc.spacing_m, 0.0), + }), vertical: None, }); } diff --git a/crates/rules/src/tests.rs b/crates/rules/src/tests.rs index 6750264..46bd6b1 100644 --- a/crates/rules/src/tests.rs +++ b/crates/rules/src/tests.rs @@ -90,6 +90,19 @@ fn a_wall_between_luminaire_and_point_darkens_it() { assert!(e.assumptions.iter().any(|a| a.contains("walls block the light (1 straight piece)"))); } +#[test] +fn a_route_given_as_its_floor_is_computed_over_all_of_it() { + // A 2 m × 1 m corridor floor at 1 m spacing: cell centres (0.5, 0.5) and (1.5, 0.5). + // From 400 cd at (0, 0, 2): E = 400 · 2 / d³ with d² = 0.5 + 4 and 2.5 + 4. + let route = "[[route]]\nid = \"R2\"\noutline = [[0, 0], [2, 0], [2, 1], [0, 1]]\n"; + let e = evaluate(&scene_with("emergency = { flux_lm = 500 }", route), &[isotropic()]).unwrap(); + let r2 = e.results.iter().find(|r| r.id == "R2").unwrap(); + let s = computed(&r2.horizontal); + assert_eq!(s.points, 2); + assert!((s.max_lx - 800.0 / 4.5f64.powf(1.5)).abs() < 1e-9); + assert!((s.min_lx - 800.0 / 6.5f64.powf(1.5)).abs() < 1e-9); +} + #[test] fn photometry_must_match_the_scene() { assert!(evaluate(&scene(""), &[]).is_err()); diff --git a/crates/scene/src/lib.rs b/crates/scene/src/lib.rs index 405cee3..245cf2d 100644 --- a/crates/scene/src/lib.rs +++ b/crates/scene/src/lib.rs @@ -142,8 +142,9 @@ struct RouteFile { id: Spanned, #[serde(default)] name: String, - path: Spanned>, - width_m: Spanned, + path: Option>>, + width_m: Option>, + outline: Option>>, #[serde(default)] floor_m: f64, } @@ -196,12 +197,20 @@ pub struct PlacedLuminaire { pub tilt_deg: f64, } +/// Where an escape route runs. +#[derive(Debug, Clone, PartialEq)] +pub enum RouteShape { + /// A centre line and the route's full width. + Centre { path: Vec, width_m: f64 }, + /// The route's floor as a polygon (a corridor as a BIM model draws it), computed over all of it. + Outline(Vec), +} + #[derive(Debug, Clone, PartialEq)] pub struct Route { pub id: String, pub name: String, - pub path: Vec, - pub width_m: f64, + pub shape: RouteShape, /// The floor's height, in metres. pub floor_m: f64, } @@ -464,13 +473,20 @@ impl Scene { if !r.floor_m.is_finite() { return Err(c.err(r.id.span(), "the floor height must be a finite number")); } - routes.push(Route { - id: r.id.get_ref().clone(), - name: r.name.clone(), - path: c.point2(&r.path, 2, "a route's path")?, - width_m: c.positive(&r.width_m, "the route width")?, - floor_m: r.floor_m, - }); + let shape = match (&r.path, &r.width_m, &r.outline) { + (Some(path), Some(width), None) => RouteShape::Centre { + path: c.point2(path, 2, "a route's path")?, + width_m: c.positive(width, "the route width")?, + }, + (None, None, Some(outline)) => RouteShape::Outline(c.point2(outline, 3, "a route's outline")?), + _ => { + return Err(c.err( + r.id.span(), + "give a route either `path` and `width_m` (its centre line) or `outline` (its floor)", + )); + } + }; + routes.push(Route { id: r.id.get_ref().clone(), name: r.name.clone(), shape, floor_m: r.floor_m }); } let mut areas = Vec::new(); diff --git a/crates/scene/src/tests.rs b/crates/scene/src/tests.rs index d27a5b8..a83973c 100644 --- a/crates/scene/src/tests.rs +++ b/crates/scene/src/tests.rs @@ -58,7 +58,7 @@ fn a_scene_reads_into_its_model() { assert_eq!(s.photometry[1].emergency, None); assert_eq!(s.luminaires[0].photometry, 0); assert_eq!(s.luminaires[0].rotation_deg, 90.0); - assert_eq!(s.routes[0].path.len(), 3); + assert!(matches!(&s.routes[0].shape, RouteShape::Centre { path, width_m } if path.len() == 3 && *width_m == 2.0)); // One wall path of two pieces, from the floor to 3 m. assert_eq!(s.walls.len(), 2); assert_eq!((s.walls[1].a, s.walls[1].b, s.walls[1].top_m), (Vec2::new(10.0, 0.0), Vec2::new(10.0, 5.0), 3.0)); @@ -87,6 +87,7 @@ fn malformed_scenes_give_located_errors() { (with("id = \"R1\"", "id = \"L1\""), 32, "used twice"), (with("path = [[0, 1], [10, 1], [10, 6]]", "path = [[0, 1]]"), 34, "at least 2 points"), (with("width_m = 2.0", "width_m = -2.0"), 35, "above 0"), + (with("width_m = 2.0", "outline = [[0, 0], [1, 0], [1, 1]]"), 32, "either `path` and `width_m`"), (with("kind = \"high-risk\"", "kind = \"stairs\""), 39, "not an area kind"), (with("kind = \"high-risk\"", "kind = \"open\""), 41, "high-risk areas only"), (with("kind = \"fire-fighting\"", "kind = \"extinguisher\""), 45, "not a point kind"), @@ -106,6 +107,16 @@ fn malformed_scenes_give_located_errors() { } } +#[test] +fn a_route_can_be_its_floor_outline() { + let s = Scene::parse(&with( + "path = [[0, 1], [10, 1], [10, 6]]\nwidth_m = 2.0", + "outline = [[0, 0], [10, 0], [10, 2], [0, 2]]", + )) + .unwrap(); + assert!(matches!(&s.routes[0].shape, RouteShape::Outline(o) if o.len() == 4)); +} + #[test] fn huge_or_hostile_input_is_an_error_not_a_panic() { assert!(Scene::parse(&"#".repeat(MAX_SCENE_BYTES + 1)).is_err()); diff --git a/docs/scene-format.md b/docs/scene-format.md index 17692be..52bdb87 100644 --- a/docs/scene-format.md +++ b/docs/scene-format.md @@ -1,6 +1,6 @@ # Scene format, version 1 -> **Last updated:** 2026-10-21 · **Change:** walls (version 1, an addition older scenes need not use) +> **Last updated:** 2026-10-21 · **Change:** walls, and routes as outlines (version 1 additions older scenes need not use) A scene describes what is lit and with what: a TOML file a person can read, review and diff. `luxpath check scene.toml` evaluates it. Examples: `examples/*.toml`. @@ -84,6 +84,15 @@ floor_m = 0.0 # optional (default 0) Illuminance is computed on the floor over the route's **full width** (`route_points_across` points across). +A route can instead be given as its floor, as BIM models draw corridors; it is then computed over all of it, at the +grid spacing, with no border: + +```toml +[[route]] +id = "R2" +outline = [[0, 0], [16, 0], [16, 2], [0, 2]] +``` + ## Areas ```toml diff --git a/parity/features.toml b/parity/features.toml index e56f39a..59ca7fa 100644 --- a/parity/features.toml +++ b/parity/features.toml @@ -179,7 +179,7 @@ tier = "P0" milestone = "M3" status = "shipped" evidence = ["crates/scene/src/tests.rs::a_scene_reads_into_its_model", "crates/rules/src/tests.rs::routes_and_points_are_computed_in_emergency_mode"] -notes = "Centre polylines with a width, computed over the full width. Missing: routes through open areas (EN 1838:2024's procedure, M4), stairs as sloped routes." +notes = "Centre polylines with a width, computed over the full width, or the route's floor as a polygon (from BIM models). Missing: routes through open areas (EN 1838:2024's procedure, M4), stairs as sloped routes." [[feature]] id = "scene.areas"