From 5acb2d3f984ad1d18b785068b9e42c7300c97a3b Mon Sep 17 00:00:00 2001 From: Enrico Sita Date: Wed, 21 Oct 2026 12:45:19 +0200 Subject: [PATCH] Walls block light (gap 16) [[wall]] polylines with a height in scenes; a luminaire whose sight line to a point crosses a wall piece gives that point nothing. The corridor example's route minimum drops from 1.45 to 0.60 lx. 100 luminaires x 10,000 points x 36 walls in ~90 ms. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- apps/luxpath-cli/tests/check.rs | 6 ++- crates/calc/src/lib.rs | 24 ++++++++---- crates/calc/src/tests.rs | 15 ++++++++ crates/calc/tests/performance.rs | 48 +++++++++++++++++++++--- crates/geom/src/lib.rs | 64 ++++++++++++++++++++++++++++++++ crates/rules/src/lib.rs | 16 ++++++-- crates/rules/src/tests.rs | 16 ++++++++ crates/scene/src/lib.rs | 34 ++++++++++++++++- crates/scene/src/tests.rs | 24 ++++++++---- docs/gaps.md | 6 +-- docs/scene-format.md | 17 ++++++++- docs/status.md | 9 ++++- examples/corridor.toml | 11 ++++++ examples/open-office.toml | 6 +++ examples/plant-room.toml | 12 ++++++ parity/features.toml | 6 +-- 17 files changed, 280 insertions(+), 37 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7ba58d9..799d98e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ luxpath's one-page summary. Details live in: ## Next up -1. **Obstruction by walls** (gap 16), then **M4:** the EN 1838:2024 checks (needs the standard's text, gap 4). +1. **M4:** the EN 1838:2024 checks (needs the standard's text, gap 4). 2. **Real photometric files** from the owner's practice, kept in `plan/` (gap 12). 3. **Open question:** buy EN 1838:2024 (around €100 from a national standards body) to verify the requirement values. @@ -32,6 +32,7 @@ luxpath's one-page summary. Details live in: | Date | Change | |---|---| +| 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. | | 2026-10-21 | M2: luminaires with heading and tilt (IES tilt factors applied), illuminance on any surface, area and full-width route grids summed over luminaires, statistics that are unverifiable when any point is; isotropic and Lambertian cases within 1e-6; 100 luminaires × 10,000 points in about 55 ms (release, in `cargo xtask ci`); photometry plausibility warnings. | diff --git a/apps/luxpath-cli/tests/check.rs b/apps/luxpath-cli/tests/check.rs index 7232771..f129920 100644 --- a/apps/luxpath-cli/tests/check.rs +++ b/apps/luxpath-cli/tests/check.rs @@ -44,7 +44,7 @@ fn every_example_is_calculated_and_cannot_be_verified_until_m4() { } assert!(!out.contains("CANNOT VERIFY:") || out.contains("Verdict: CANNOT VERIFY"), "{out}"); assert!(out.contains("min ") && out.contains("not a certificate"), "{out}"); - assert!(out.contains("nothing blocks their light"), "the assumptions are stated:\n{out}"); + assert!(out.contains("walls block the light"), "the assumptions are stated:\n{out}"); } } @@ -65,7 +65,9 @@ fn broken_inputs_exit_with_2_and_say_where() { let dir = scratch("broken", &scene.replace("width_m = 2.0", "width_m = \"wide\"")); let (code, _, err) = luxpath(&["check", dir.join("scene.toml").to_str().unwrap()]); assert_eq!(code, 2); - assert!(err.contains("scene.toml: line 54, column 11"), "{err}"); + // The error points at the line of `width_m` in the example, wherever it is. + let line = scene.lines().position(|l| l.starts_with("width_m = 2.0")).unwrap() + 1; + assert!(err.contains(&format!("scene.toml: line {line}, column 11")), "{err}"); std::fs::write(dir.join("scene.toml"), scene.replace("photometry/bulkhead.ldt", "photometry/missing.ldt")).unwrap(); let (code, _, err) = luxpath(&["check", dir.join("scene.toml").to_str().unwrap()]); diff --git a/crates/calc/src/lib.rs b/crates/calc/src/lib.rs index aff95b9..11d236a 100644 --- a/crates/calc/src/lib.rs +++ b/crates/calc/src/lib.rs @@ -8,15 +8,17 @@ //! ``` //! //! where `I` is the luminaire's intensity towards the point (cd), `d` the distance (m) and `θ` the -//! angle between the arriving light and `n` (light from behind the surface counts 0). Reflected -//! light is not included: emergency-lighting tools leave it out by default (see `docs/research.md`), -//! and inter-reflection is a later milestone (M9). +//! angle between the arriving light and `n` (light from behind the surface counts 0). A luminaire +//! whose line of sight to the point crosses a [`Wall`] contributes nothing there. Reflected light is +//! not included: emergency-lighting tools leave it out by default (see `docs/research.md`), and +//! inter-reflection is a later milestone (M9). //! //! - [`Luminaire`]: a placed, oriented (heading and tilt) distribution with its flux; //! [`Luminaire::from_photometry`] applies the file's tilt factors. //! - [`Grid`]: points with one surface normal: over an area (a polygon, less a border), along a //! route (its full width), or given one by one (vertical illuminance at equipment). -//! - [`illuminance`] at every grid point, summed over luminaires; [`Stats`] (min, mean, max, the +//! - [`illuminance`] at every grid point, summed over luminaires ([`illuminance_with_walls`] with +//! walls in the way); [`Stats`] (min, mean, max, the //! uniformity ratios). //! //! An undefined result (a point at a luminaire's centre, a non-finite input) is `None` for that @@ -30,6 +32,7 @@ mod stats; pub use grid::{Grid, GridError, MAX_GRID_POINTS}; pub use stats::Stats; +pub use luxpath_geom::Wall; use luxpath_geom::{Vec3, wrap_degrees}; use luxpath_photometry::{IntensityTable, Photometry}; @@ -138,7 +141,7 @@ impl Frame { /// or an input is not finite. Light arriving from behind the surface gives 0 lux. pub fn direct_illuminance(luminaire: &Luminaire, point: Vec3, normal: Vec3) -> Option { let normal = normal.normalized()?; - direct(luminaire, &luminaire.frame()?, point, normal) + direct(luminaire, &luminaire.frame()?, point, normal, &[]) } /// Direct illuminance (lux) on a horizontal surface facing up at `point`. @@ -146,7 +149,7 @@ pub fn direct_horizontal_illuminance(luminaire: &Luminaire, point: Vec3) -> Opti direct_illuminance(luminaire, point, Vec3::UP) } -fn direct(luminaire: &Luminaire, frame: &Frame, point: Vec3, unit_normal: Vec3) -> Option { +fn direct(luminaire: &Luminaire, frame: &Frame, point: Vec3, unit_normal: Vec3, walls: &[Wall]) -> Option { if !point.is_finite() { return None; } @@ -155,7 +158,7 @@ fn direct(luminaire: &Luminaire, frame: &Frame, point: Vec3, unit_normal: Vec3) let dir = to_point.normalized()?; // The light travels along `dir`; it lights the surface when it arrives against the normal. let cos_theta = -dir.dot(unit_normal); - if cos_theta <= 0.0 { + if cos_theta <= 0.0 || walls.iter().any(|w| w.blocks(luminaire.position, point)) { return Some(0.0); } let local = frame.to_local(dir); @@ -169,13 +172,18 @@ fn direct(luminaire: &Luminaire, frame: &Frame, point: Vec3, unit_normal: Vec3) /// Illuminance (lux) at every point of `grid`, summed over `luminaires`; `None` at a point where /// any luminaire's contribution is undefined. pub fn illuminance(luminaires: &[Luminaire], grid: &Grid) -> Vec> { + illuminance_with_walls(luminaires, &[], grid) +} + +/// [`illuminance`] with `walls` blocking the light that would cross them. +pub fn illuminance_with_walls(luminaires: &[Luminaire], walls: &[Wall], grid: &Grid) -> Vec> { let mut sums: Vec> = vec![Some(0.0); grid.points().len()]; let Some(normal) = grid.normal().normalized() else { return vec![None; grid.points().len()] }; for luminaire in luminaires { let Some(frame) = luminaire.frame() else { return vec![None; grid.points().len()] }; for (sum, point) in sums.iter_mut().zip(grid.points()) { if let Some(total) = sum { - *sum = direct(luminaire, &frame, *point, normal).map(|e| *total + e); + *sum = direct(luminaire, &frame, *point, normal, walls).map(|e| *total + e); } } } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index f305c90..4e58925 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -141,6 +141,21 @@ fn grids_sum_every_luminaire() { assert_eq!(illuminance(&[], &grid), vec![Some(0.0), Some(0.0)]); } +#[test] +fn a_wall_hides_a_luminaire_from_the_points_behind_it() { + // A corridor turning a corner: the luminaire at (0, 0, 2.5); a 3 m wall along x = 2 from y = −1 to 4. + // The point (4, 0, 0) is behind it and gets nothing; (1, 0, 0) is in front and gets the + // unobstructed 1000 · 2.5 / d³ with d² = 1 + 6.25. + let lum = Luminaire { position: Vec3::new(0.0, 0.0, 2.5), ..isotropic_at(2.5) }; + let wall = Wall { a: Vec2::new(2.0, -1.0), b: Vec2::new(2.0, 4.0), bottom_m: 0.0, top_m: 3.0 }; + let grid = Grid::new(vec![Vec3::new(4.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)], Vec3::UP).unwrap(); + let e = illuminance_with_walls(std::slice::from_ref(&lum), &[wall], &grid); + assert_eq!(e[0], Some(0.0)); + assert!(close(e[1].unwrap(), 1000.0 * 2.5 / 7.25f64.powf(1.5))); + // Without the wall, the hidden point is lit. + assert!(illuminance(&[lum], &grid)[0].unwrap() > 0.0); +} + #[test] fn an_undefined_point_makes_the_statistics_unverifiable() { let lum = isotropic_at(2.0); diff --git a/crates/calc/tests/performance.rs b/crates/calc/tests/performance.rs index c2b06bb..a79ad93 100644 --- a/crates/calc/tests/performance.rs +++ b/crates/calc/tests/performance.rs @@ -6,13 +6,11 @@ use std::time::{Duration, Instant}; -use luxpath_calc::{Grid, Luminaire, Stats, illuminance}; +use luxpath_calc::{Grid, Luminaire, Stats, Wall, illuminance, illuminance_with_walls}; use luxpath_geom::{Vec2, Vec3}; use luxpath_photometry::IntensityTable; -#[test] -#[ignore = "timing: run in release (cargo xtask ci does)"] -fn a_hundred_luminaires_over_ten_thousand_points_within_budget() { +fn scene() -> (Vec, Grid) { let c: Vec = (0..72).map(|i| f64::from(i) * 5.0).collect(); let g: Vec = (0..=180).map(f64::from).collect(); let values = c.iter().flat_map(|c| g.iter().map(move |g| 100.0 + c / 10.0 + (180.0 - g))).collect(); @@ -30,14 +28,54 @@ fn a_hundred_luminaires_over_ten_thousand_points_within_budget() { let floor = [Vec2::new(0.0, 0.0), Vec2::new(50.0, 0.0), Vec2::new(50.0, 50.0), Vec2::new(0.0, 50.0)]; let grid = Grid::area(&floor, 0.0, 0.5, 0.0).unwrap(); assert_eq!(grid.points().len(), 10_000); + (luminaires, grid) +} +/// The best of five runs of `f`. +fn best_of_five(f: impl Fn() -> Vec>) -> Duration { let mut best = Duration::MAX; for _ in 0..5 { let start = Instant::now(); - let e = illuminance(&luminaires, &grid); + let e = f(); best = best.min(start.elapsed()); assert!(Stats::of(&e).is_some()); } + best +} + +#[test] +#[ignore = "timing: run in release (cargo xtask ci does)"] +fn a_hundred_luminaires_over_ten_thousand_points_within_budget() { + let (luminaires, grid) = scene(); + let best = best_of_five(|| illuminance(&luminaires, &grid)); eprintln!("100 luminaires × 10,000 points: {best:?} (best of 5)"); assert!(best < Duration::from_millis(100), "{best:?} is over the 100 ms budget"); } + +#[test] +#[ignore = "timing: run in release (cargo xtask ci does)"] +fn walls_stay_within_budget() { + // The 50 m floor cut into 5 m rooms by nine wall lines each way, each in two pieces around a 2 m doorway: 36 walls. + let (luminaires, grid) = scene(); + let mut walls = Vec::new(); + for line in 1..10 { + let at = f64::from(line) * 5.0; + for (from, to) in [(0.0, 23.0), (25.0, 50.0)] { + walls.push(Wall { + a: luxpath_geom::Vec2::new(at, from), + b: luxpath_geom::Vec2::new(at, to), + bottom_m: 0.0, + top_m: 3.0, + }); + walls.push(Wall { + a: luxpath_geom::Vec2::new(from, at), + b: luxpath_geom::Vec2::new(to, at), + bottom_m: 0.0, + top_m: 3.0, + }); + } + } + let best = best_of_five(|| illuminance_with_walls(&luminaires, &walls, &grid)); + eprintln!("100 luminaires × 10,000 points × {} walls: {best:?} (best of 5)", walls.len()); + assert!(best < Duration::from_millis(500), "{best:?} is over the 500 ms budget"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 7717721..49fab7b 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -145,6 +145,47 @@ pub fn distance_to_boundary(p: Vec2, polygon: &[Vec2]) -> f64 { edges(polygon).map(|(a, b)| distance_to_segment(p, a, b)).fold(f64::INFINITY, f64::min) } +/// Sight lines crossing a wall closer than this (metres) to either end are not blocked by it: a point +/// on a wall's face, or a luminaire fixed to it, sees its own side. +pub const WALL_CLEARANCE_M: f64 = 1e-3; + +fn cross(a: Vec2, b: Vec2) -> f64 { + a.x * b.y - a.y * b.x +} + +/// An opaque wall: a vertical panel on the plan segment from `a` to `b`, from `bottom_m` up to +/// `top_m`. It has no thickness (drawn on its centre line or its face); door openings are gaps +/// between walls. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Wall { + pub a: Vec2, + pub b: Vec2, + pub bottom_m: f64, + pub top_m: f64, +} + +impl Wall { + /// Whether the straight line from `from` to `to` passes through the wall (its ends count as + /// wall: light grazing a corner is blocked). Lines running along the wall, vertical lines and + /// non-finite input are not blocked. + pub fn blocks(&self, from: Vec3, to: Vec3) -> bool { + let d = Vec2::new(to.x - from.x, to.y - from.y); + let e = self.b - self.a; + let denom = cross(d, e); + let length = d.length(); + if !denom.is_finite() || denom.abs() <= f64::EPSILON * length * e.length() || length == 0.0 { + return false; + } + let w = self.a - Vec2::new(from.x, from.y); + let (t, s) = (cross(w, e) / denom, cross(w, d) / denom); + if !(0.0..=1.0).contains(&s) || t * length <= WALL_CLEARANCE_M || (1.0 - t) * length <= WALL_CLEARANCE_M { + return false; + } + let z = from.z + t * (to.z - from.z); + z >= self.bottom_m && z <= self.top_m + } +} + /// Wraps an angle in degrees into `[0, 360)`. Non-finite input returns `0.0`. pub fn wrap_degrees(deg: f64) -> f64 { if !deg.is_finite() { @@ -193,6 +234,29 @@ mod tests { assert_eq!(distance_to_boundary(Vec2::new(3.5, 1.0), &l), 0.5); } + #[test] + fn walls_block_sight_lines_through_them_and_only_those() { + // A wall along x = 2 from y = −1 to 1, from the floor to 3 m. + let wall = Wall { a: Vec2::new(2.0, -1.0), b: Vec2::new(2.0, 1.0), bottom_m: 0.0, top_m: 3.0 }; + let lum = Vec3::new(0.0, 0.0, 2.5); + assert!(wall.blocks(lum, Vec3::new(4.0, 0.0, 0.0))); + // Beside the wall's end, past it. + assert!(!wall.blocks(lum, Vec3::new(4.0, 3.0, 0.0))); + // Same side as the luminaire. + assert!(!wall.blocks(lum, Vec3::new(1.5, 0.0, 0.0))); + // Over the top: a 1 m wall, crossed halfway at z = 2.5 + 0.5 · (0 − 2.5) = 1.25. + let low = Wall { top_m: 1.0, ..wall }; + assert!(!low.blocks(lum, Vec3::new(4.0, 0.0, 0.0))); + assert!(Wall { top_m: 1.25, ..wall }.blocks(lum, Vec3::new(4.0, 0.0, 0.0))); + // A point on the wall's face is not hidden by it; nor is a luminaire fixed to it. + assert!(!wall.blocks(lum, Vec3::new(2.0, 0.5, 0.0))); + assert!(!wall.blocks(Vec3::new(2.0, 0.0, 2.5), Vec3::new(0.0, 0.0, 0.0))); + // Straight down, along the wall, or with NaN: not blocked. + assert!(!wall.blocks(Vec3::new(5.0, 0.0, 3.0), Vec3::new(5.0, 0.0, 0.0))); + assert!(!wall.blocks(Vec3::new(2.0, -5.0, 1.0), Vec3::new(2.0, 5.0, 1.0))); + assert!(!wall.blocks(lum, Vec3::new(f64::NAN, 0.0, 0.0))); + } + #[test] fn segment_distances_clamp_to_the_ends() { let (a, b) = (Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0)); diff --git a/crates/rules/src/lib.rs b/crates/rules/src/lib.rs index f36cb21..85129fe 100644 --- a/crates/rules/src/lib.rs +++ b/crates/rules/src/lib.rs @@ -15,7 +15,7 @@ //! //! Layer L4: depends on `luxpath-scene` and `luxpath-calc`. -use luxpath_calc::{Grid, Luminaire, Stats, illuminance}; +use luxpath_calc::{Grid, Luminaire, Stats, illuminance_with_walls}; use luxpath_geom::Vec3; use luxpath_photometry::Photometry; use luxpath_scene::{AreaKind, PointKind, Scene}; @@ -81,8 +81,16 @@ pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result Result Outcome::CannotVerify(e.to_string()), - Ok(grid) => match Stats::of(&illuminance(&luminaires, &grid)) { + Ok(grid) => match Stats::of(&illuminance_with_walls(&luminaires, &scene.walls, &grid)) { Some(stats) => Outcome::Computed(stats), None => Outcome::CannotVerify("a grid point is at a luminaire's centre".into()), }, diff --git a/crates/rules/src/tests.rs b/crates/rules/src/tests.rs index f86e509..6750264 100644 --- a/crates/rules/src/tests.rs +++ b/crates/rules/src/tests.rs @@ -7,6 +7,10 @@ fn isotropic() -> Photometry { } fn scene(emergency: &str) -> Scene { + scene_with(emergency, "") +} + +fn scene_with(emergency: &str, extra: &str) -> Scene { Scene::parse(&format!( r#"format = "luxpath-scene" version = 1 @@ -33,6 +37,7 @@ id = "P1" kind = "fire-fighting" position = [3, 0, 2] facing = [-1, 0, 0] +{extra} "# )) .unwrap() @@ -74,6 +79,17 @@ fn a_missing_emergency_output_leaves_everything_unverifiable() { assert!(matches!(&e.results[0].horizontal, Outcome::CannotVerify(why) if why.contains("ballast lumen factor"))); } +#[test] +fn a_wall_between_luminaire_and_point_darkens_it() { + // A 3 m wall along x = 2.5 hides the point at x = 3 from the only luminaire at x = 0. + let wall = "[[wall]]\nid = \"W1\"\npath = [[2.5, -1], [2.5, 1]]\nheight_m = 3\n"; + let e = evaluate(&scene_with("emergency = { flux_lm = 500 }", wall), &[isotropic()]).unwrap(); + assert_eq!(computed(e.results[1].vertical.as_ref().unwrap()).max_lx, 0.0); + // The route (x from 0 to 2) is on the luminaire's side and unchanged. + assert!((computed(&e.results[0].horizontal).max_lx - 100.0).abs() < 1e-9); + assert!(e.assumptions.iter().any(|a| a.contains("walls block the light (1 straight piece)"))); +} + #[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 0b22ea5..405cee3 100644 --- a/crates/scene/src/lib.rs +++ b/crates/scene/src/lib.rs @@ -2,6 +2,7 @@ //! //! A scene is a TOML file (format version 1, `docs/scene-format.md`): the project's maintenance //! factor, the photometric files it uses (with each one's emergency output), placed luminaires, +//! **walls** (which block light; doorways are gaps between them), //! **escape routes** (centre polylines with a width), **areas** (open/anti-panic and high-risk task //! areas, as polygons), **points of emphasis** (exits, changes of direction and level, safety //! equipment), and calculation settings. Scenes are human-readable and diffable, so a design can @@ -18,7 +19,7 @@ use std::collections::HashSet; use std::ops::Range; -use luxpath_geom::{Vec2, Vec3}; +use luxpath_geom::{Vec2, Vec3, Wall}; use luxpath_photometry::EmergencyOutput; use serde::Deserialize; use toml::Spanned; @@ -74,6 +75,8 @@ struct File { #[serde(default)] luminaire: Vec>, #[serde(default)] + wall: Vec>, + #[serde(default)] route: Vec>, #[serde(default)] area: Vec>, @@ -123,6 +126,16 @@ struct LuminaireFile { tilt_deg: f64, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WallFile { + id: Spanned, + path: Spanned>, + height_m: Spanned, + #[serde(default)] + floor_m: f64, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RouteFile { @@ -276,6 +289,8 @@ pub struct Scene { pub calculation: Calculation, pub photometry: Vec, pub luminaires: Vec, + /// Every straight piece of every `[[wall]]`. + pub walls: Vec, pub routes: Vec, pub areas: Vec, pub points: Vec, @@ -426,6 +441,22 @@ impl Scene { }); } + let mut walls = Vec::new(); + for w in &file.wall { + let w = w.get_ref(); + c.unique(&mut ids, &w.id)?; + if !w.floor_m.is_finite() { + return Err(c.err(w.id.span(), "the floor height must be a finite number")); + } + let height = c.positive(&w.height_m, "the wall height")?; + let path = c.point2(&w.path, 2, "a wall's path")?; + for pair in path.windows(2) { + if let (Some(a), Some(b)) = (pair.first(), pair.get(1)) { + walls.push(Wall { a: *a, b: *b, bottom_m: w.floor_m, top_m: w.floor_m + height }); + } + } + } + let mut routes = Vec::new(); for r in &file.route { let r = r.get_ref(); @@ -501,6 +532,7 @@ impl Scene { calculation, photometry, luminaires, + walls, routes, areas, points, diff --git a/crates/scene/src/tests.rs b/crates/scene/src/tests.rs index fa36a95..d27a5b8 100644 --- a/crates/scene/src/tests.rs +++ b/crates/scene/src/tests.rs @@ -25,6 +25,11 @@ photometry = "bulkhead" position = [2.0, 1.0, 2.8] rotation_deg = 90 +[[wall]] +id = "W1" +path = [[0, 0], [10, 0], [10, 5]] +height_m = 3 + [[route]] id = "R1" name = "Corridor to exit A" @@ -54,6 +59,9 @@ fn a_scene_reads_into_its_model() { assert_eq!(s.luminaires[0].photometry, 0); assert_eq!(s.luminaires[0].rotation_deg, 90.0); assert_eq!(s.routes[0].path.len(), 3); + // 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)); assert_eq!(s.areas[0].kind, AreaKind::HighRisk); assert_eq!(s.areas[0].normal_lighting_lx, Some(500.0)); assert_eq!(s.points[0].kind, PointKind::FireFighting); @@ -76,16 +84,18 @@ fn malformed_scenes_give_located_errors() { (with("spacing_m = 0.5", "spacing = 0.5"), 9, "unknown field"), (with("spacing_m = 0.5", "spacing_m = 0"), 9, "above 0"), (with("photometry = \"bulkhead\"", "photometry = \"bulk\""), 22, "no [[photometry]] has the id `bulk`"), - (with("id = \"R1\"", "id = \"L1\""), 27, "used twice"), - (with("path = [[0, 1], [10, 1], [10, 6]]", "path = [[0, 1]]"), 29, "at least 2 points"), - (with("width_m = 2.0", "width_m = -2.0"), 30, "above 0"), - (with("kind = \"high-risk\"", "kind = \"stairs\""), 34, "not an area kind"), - (with("kind = \"high-risk\"", "kind = \"open\""), 36, "high-risk areas only"), - (with("kind = \"fire-fighting\"", "kind = \"extinguisher\""), 40, "not a point kind"), - (with("facing = [-2, 0, 0]", "facing = [0, 0, 0]"), 42, "must not be zero"), + (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("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"), + (with("facing = [-2, 0, 0]", "facing = [0, 0, 0]"), 47, "must not be zero"), (with("emergency = { ballast_lumen_factor = 0.5 }", "emergency = { }"), 14, "either"), (with("position = [2.0, 1.0, 2.8]", "position = [2.0, 1.0]"), 23, ""), (with("format = \"luxpath-scene\"", "format = \"dialux\""), 1, "luxpath-scene"), + (with("height_m = 3", "height_m = 0"), 29, "above 0"), + (with("path = [[0, 0], [10, 0], [10, 5]]", "path = [[0, 0]]"), 28, "at least 2 points"), (with("[project]", "[project\n"), 4, ""), ]; for (text, line, words) in cases { diff --git a/docs/gaps.md b/docs/gaps.md index 0b78766..9a00702 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: what luxpath cannot do yet -> **Last updated:** 2026-10-21 · **Change:** M3 closed row 5 +> **Last updated:** 2026-10-21 · **Change:** walls block light: row 16 narrowed to point luminaires (no longer an alpha blocker) Every known shortfall, ranked by user impact. This is the work list: pick from the top, and remove or update a row in the same PR that closes it. **[alpha blocker]** rows fail the alpha gate (ROADMAP.md). @@ -10,9 +10,9 @@ in the same PR that closes it. **[alpha blocker]** rows fail the alpha gate (ROA | 4 | **[alpha blocker]** Requirement values not verified against EN 1838:2024 text | Values in `standards-parity.md` come from secondary sources | M4 | 3 + buying the standard | | 6 | **[alpha blocker]** No EN 1838 checks | The project's purpose | M4 | 25–40 | | 7 | **[alpha blocker]** No independent validation | Trust: nobody should rely on unvalidated numbers | M6 | 10–20 + human | -| 16 | **[alpha blocker]** Luminaires are points; no obstruction | A wrong pass: direct light from a point ignores the luminaire's size (fine beyond about five times its size) and walls between it and the floor; a corridor around a corner reads lit when it is not | M4 | 6–10 | | 10 | No reports beyond the terminal | Designers hand over documents | M5 | 15–25 | | 11 | No CI on a hosted repository yet | The workflow exists but the repository isn't published | — | 1 | | 12 | **[alpha blocker]** Readers tried on only two real files | Synthetic files test the formats as we read them; manufacturers' files (kept in `plan/`, never committed) test them as written. Wanted: a few dozen IES and LDT files, including emergency luminaires | M6 | 2–4 + files from the owner | | 14 | Format details from our reading, not the specification texts | The LM-63 factor that changed meaning between editions, and the C0 orientation of each format, are unverified like the EN 1838 values (gap 4); LM-63-2019 is sold by the IES | M6 | 2 + buying LM-63-2019 | -| 15 | Refused file variants | IES type A and B photometry, `TILT=`, and EULUMDAT files with several lamp sets are refused with a located error | M7 | 4–8 | \ No newline at end of file +| 15 | Refused file variants | IES type A and B photometry, `TILT=`, and EULUMDAT files with several lamp sets are refused with a located error | M7 | 4–8 | +| 16 | Luminaires are points | Direct light from a point ignores the luminaire's size: fine beyond about five times its largest dimension, optimistic or pessimistic closer (a long linear luminaire over a narrow route) | M6 | 4–8 | \ No newline at end of file diff --git a/docs/scene-format.md b/docs/scene-format.md index ecdd489..17692be 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:** version 1 (M3) +> **Last updated:** 2026-10-21 · **Change:** walls (version 1, an addition 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`. @@ -56,6 +56,21 @@ rotation_deg = 90 # optional: heading of C0, counter-clockwise from tilt_deg = 60 # optional: tilt of the nadir towards C0 (default 0; 90 = aimed horizontally) ``` +## Walls + +```toml +[[wall]] +id = "W-outer" +path = [[0, 0], [16, 0], [16, 12]] # a polyline: each straight piece is a wall +height_m = 3.0 +floor_m = 0.0 # optional: where the wall starts (default 0) +``` + +Walls are opaque and have no thickness: draw them on their centre line or the face that matters. A luminaire whose +line of sight to a point crosses a wall gives that point nothing; points and luminaires on a wall's face (within +1 mm) are not hidden by it. Doorways are gaps between walls; a low wall (a cabinet, a parapet) blocks only the light +passing below its top. Furniture, open doors and people are not modelled. + ## Escape routes ```toml diff --git a/docs/status.md b/docs/status.md index 56301cf..971f429 100644 --- a/docs/status.md +++ b/docs/status.md @@ -5,6 +5,12 @@ **Milestone:** M3 done (scene and `luxpath check`); M1–M2 done (photometry readers, engine). **Direction under study:** EN 1838 checks on IFC models (docs/research.md §6–7). The IFC inspector (`luxpath inspect-ifc`, an early piece of M7) is done. +## Walls (2026-10-21) +Scenes have `[[wall]]`s (polylines with a height); a luminaire whose sight line to a point crosses one gives that +point nothing. In the corridor example the minimum on the route drops from 1.45 lx to 0.60 lx once light no longer +passes through the corner: the wrong pass gap 16 warned about. Next: Ashlar's emergency-lighting plugin (R2.9a) +builds scenes from BIM models with this engine. + ## M3 (2026-10-21) Scenes are TOML files, version 1 (`docs/scene-format.md`): photometric files with their emergency output, luminaires (heading, tilt), escape routes, open and high-risk areas, points of emphasis, calculation settings. @@ -37,12 +43,11 @@ FireExit flags. Luminaires collapse into few types (410 → 5), so a photometry - **A. Evidence:** inspect one real project IFC from a practising office (privately, in `plan/`). - **B. IFC scene:** read luminaire positions and space outlines (geometry) and define the mapping file (type name or product code → photometric file, emergency yes/no). -- **C. Obstruction** (gap 16): walls in the scene block light; an alpha blocker before M4's checks. - **E. Going public:** the repository side is ready; the owner's switches are in `docs/publishing.md`. - **D. Real files:** photometric files of luminaires the owner's practice uses, privately in `plan/` (gap 12). ## Blockers -- None for obstruction. **Before M4:** the EN 1838:2024 text (Italian edition UNI EN 1838:2025) is needed to verify values (gap 4). +- None. **Before M4:** the EN 1838:2024 text (Italian edition UNI EN 1838:2025) is needed to verify values (gap 4). ## Open questions (owner) - Practitioner questions in `docs/research.md` §5. diff --git a/examples/corridor.toml b/examples/corridor.toml index 227339b..76ee645 100644 --- a/examples/corridor.toml +++ b/examples/corridor.toml @@ -47,6 +47,17 @@ id = "L5" photometry = "bulkhead" position = [15.0, 11.0, 2.7] +# The corridor's walls: 2 m wide, first east from x = 0 to 16, then north to the exit at y = 12. +[[wall]] +id = "W-outer" +path = [[0, 0], [16, 0], [16, 12]] +height_m = 3.0 + +[[wall]] +id = "W-inner" +path = [[0, 2], [14, 2], [14, 12]] +height_m = 3.0 + [[route]] id = "R1" name = "Corridor to exit A" diff --git a/examples/open-office.toml b/examples/open-office.toml index d3e79b2..925aa5e 100644 --- a/examples/open-office.toml +++ b/examples/open-office.toml @@ -45,6 +45,12 @@ id = "L6" photometry = "bulkhead" position = [12.5, 7.5, 3.0] +# The office's outer walls, with exit B as a 1.2 m gap in the east wall. +[[wall]] +id = "W1" +path = [[15, 4.4], [15, 0], [0, 0], [0, 10], [15, 10], [15, 5.6]] +height_m = 3.2 + [[area]] id = "A1" name = "Open office" diff --git a/examples/plant-room.toml b/examples/plant-room.toml index 0dbaf56..204e741 100644 --- a/examples/plant-room.toml +++ b/examples/plant-room.toml @@ -36,6 +36,18 @@ id = "C1" photometry = "bulkhead" position = [3.5, 4.0, 3.0] +# The room's walls, 7 m × 6 m, with the door to the corridor (1 m) in the west wall; a 1.5 m-high +# switchboard cabinet stands along the north edge of the working area. +[[wall]] +id = "W-room" +path = [[0, 3.5], [0, 0], [7, 0], [7, 6], [0, 6], [0, 4.5]] +height_m = 3.2 + +[[wall]] +id = "W-cabinet" +path = [[1, 2.2], [6, 2.2]] +height_m = 1.5 + [[area]] id = "H1" name = "Switchboard working area" diff --git a/parity/features.toml b/parity/features.toml index 9cda4e4..e56f39a 100644 --- a/parity/features.toml +++ b/parity/features.toml @@ -108,8 +108,8 @@ area = "calc" tier = "P0" milestone = "M0" status = "shipped" -evidence = ["crates/calc/src/tests.rs::off_axis_points_follow_the_cosine_law", "crates/calc/src/tests.rs::a_lambertian_source_matches_its_closed_form", "crates/calc/src/tests.rs::rotation_selects_the_c_plane"] -notes = "On any surface normal, isotropic and Lambertian sources within 1e-6 of their closed forms. Missing: obstruction (walls between luminaire and point), area sources (luminaires are points)." +evidence = ["crates/calc/src/tests.rs::off_axis_points_follow_the_cosine_law", "crates/calc/src/tests.rs::a_lambertian_source_matches_its_closed_form", "crates/calc/src/tests.rs::rotation_selects_the_c_plane", "crates/calc/src/tests.rs::a_wall_hides_a_luminaire_from_the_points_behind_it", "crates/geom/src/lib.rs::walls_block_sight_lines_through_them_and_only_those"] +notes = "On any surface normal, isotropic and Lambertian sources within 1e-6 of their closed forms; walls block light (`illuminance_with_walls`). Missing: area sources (luminaires are points), obstacles other than walls." [[feature]] id = "calc.grids" @@ -169,7 +169,7 @@ tier = "P0" milestone = "M3" status = "shipped" evidence = ["crates/scene/src/tests.rs::a_scene_reads_into_its_model", "crates/scene/src/tests.rs::malformed_scenes_give_located_errors", "crates/scene/src/tests.rs::huge_or_hostile_input_is_an_error_not_a_panic"] -notes = "Version 1 (docs/scene-format.md): unknown fields refused, errors with line and column, no default for the maintenance factor or emergency output. Missing: walls (gap 16), several floors in one scene, a migration tool (none needed yet)." +notes = "Version 1 (docs/scene-format.md): walls, unknown fields refused, errors with line and column, no default for the maintenance factor or emergency output. Missing: several floors in one scene, a migration tool (none needed yet)." [[feature]] id = "scene.escape-routes"