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
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ 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.

## Progress log

| 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. |
Expand Down
6 changes: 4 additions & 2 deletions apps/luxpath-cli/tests/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
}

Expand All @@ -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()]);
Expand Down
24 changes: 16 additions & 8 deletions crates/calc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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};

Expand Down Expand Up @@ -138,15 +141,15 @@ 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<f64> {
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`.
pub fn direct_horizontal_illuminance(luminaire: &Luminaire, point: Vec3) -> Option<f64> {
direct_illuminance(luminaire, point, Vec3::UP)
}

fn direct(luminaire: &Luminaire, frame: &Frame, point: Vec3, unit_normal: Vec3) -> Option<f64> {
fn direct(luminaire: &Luminaire, frame: &Frame, point: Vec3, unit_normal: Vec3, walls: &[Wall]) -> Option<f64> {
if !point.is_finite() {
return None;
}
Expand All @@ -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);
Expand All @@ -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<Option<f64>> {
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<Option<f64>> {
let mut sums: Vec<Option<f64>> = 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);
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions crates/calc/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
48 changes: 43 additions & 5 deletions crates/calc/tests/performance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Luminaire>, Grid) {
let c: Vec<f64> = (0..72).map(|i| f64::from(i) * 5.0).collect();
let g: Vec<f64> = (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();
Expand All @@ -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<Option<f64>>) -> 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");
}
64 changes: 64 additions & 0 deletions crates/geom/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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));
Expand Down
16 changes: 12 additions & 4 deletions crates/rules/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -81,8 +81,16 @@ pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result<Evaluation,
let calc = scene.calculation;
let mut assumptions = vec![
"direct light only: no reflections from walls, floor or ceiling".to_string(),
"luminaires are points, and nothing blocks their light (walls between a luminaire and a point are ignored)"
.to_string(),
"luminaires are points".to_string(),
if scene.walls.is_empty() {
"nothing blocks the light: the scene has no walls".to_string()
} else {
let n = scene.walls.len();
format!(
"walls block the light ({n} straight piece{}); furniture, doors and other obstacles do not",
if n == 1 { "" } else { "s" }
)
},
format!("maintenance factor {}", scene.maintenance_factor),
format!(
"grids every {} m; {} points across each route's full width; open areas less a {} m border",
Expand Down Expand Up @@ -146,7 +154,7 @@ pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result<Evaluation,
}
match grid {
Err(e) => 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()),
},
Expand Down
16 changes: 16 additions & 0 deletions crates/rules/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +37,7 @@ id = "P1"
kind = "fire-fighting"
position = [3, 0, 2]
facing = [-1, 0, 0]
{extra}
"#
))
.unwrap()
Expand Down Expand Up @@ -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());
Expand Down
Loading
Loading