Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
9 changes: 7 additions & 2 deletions crates/rules/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -167,7 +167,12 @@ pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result<Evaluation,
id: r.id.clone(),
name: r.name.clone(),
kind: ElementKind::Route,
horizontal: outcome(Grid::route(&r.path, r.width_m, r.floor_m, calc.spacing_m, calc.route_points_across)),
horizontal: outcome(match &r.shape {
RouteShape::Centre { path, width_m } => {
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,
});
}
Expand Down
13 changes: 13 additions & 0 deletions crates/rules/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
38 changes: 27 additions & 11 deletions crates/scene/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,9 @@ struct RouteFile {
id: Spanned<String>,
#[serde(default)]
name: String,
path: Spanned<Vec<[f64; 2]>>,
width_m: Spanned<f64>,
path: Option<Spanned<Vec<[f64; 2]>>>,
width_m: Option<Spanned<f64>>,
outline: Option<Spanned<Vec<[f64; 2]>>>,
#[serde(default)]
floor_m: f64,
}
Expand Down Expand Up @@ -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<Vec2>, 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<Vec2>),
}

#[derive(Debug, Clone, PartialEq)]
pub struct Route {
pub id: String,
pub name: String,
pub path: Vec<Vec2>,
pub width_m: f64,
pub shape: RouteShape,
/// The floor's height, in metres.
pub floor_m: f64,
}
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 12 additions & 1 deletion crates/scene/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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"),
Expand All @@ -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());
Expand Down
11 changes: 10 additions & 1 deletion docs/scene-format.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion parity/features.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading