From 91aa877cd67d55a36bfdba01815ea93babbc4922 Mon Sep 17 00:00:00 2001 From: Enrico Sita Date: Wed, 21 Oct 2026 10:27:14 +0200 Subject: [PATCH] M3: scene format version 1 and `luxpath check` TOML scenes (docs/scene-format.md) with photometry and emergency output, luminaires, escape routes, open and high-risk areas, points of emphasis; unknown fields refused, errors located by line and column. `luxpath check` reports illuminance per element with its assumptions and exits 0/1/2/3 (pass, fail, input error, cannot verify; always 3 until M4). Three example scenes with synthetic photometry. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + Cargo.lock | 7 + README.md | 6 +- ROADMAP.md | 5 +- apps/luxpath-cli/Cargo.toml | 3 + apps/luxpath-cli/src/main.rs | 57 +++- apps/luxpath-cli/tests/check.rs | 81 +++++ crates/report/src/lib.rs | 67 +++- crates/rules/Cargo.toml | 2 + crates/rules/src/lib.rs | 195 +++++++++++- crates/rules/src/tests.rs | 80 +++++ crates/scene/Cargo.toml | 2 + crates/scene/src/lib.rs | 514 ++++++++++++++++++++++++++++++- crates/scene/src/tests.rs | 105 +++++++ docs/architecture.md | 4 +- docs/gaps.md | 3 +- docs/roadmap.md | 4 +- docs/scene-format.md | 106 +++++++ docs/status.md | 15 +- examples/corridor.toml | 73 +++++ examples/open-office.toml | 71 +++++ examples/photometry/README.md | 10 + examples/photometry/bulkhead.ldt | 117 +++++++ examples/photometry/corridor.ies | 17 + examples/plant-room.toml | 56 ++++ parity/features.toml | 20 +- 26 files changed, 1580 insertions(+), 41 deletions(-) create mode 100644 apps/luxpath-cli/tests/check.rs create mode 100644 crates/rules/src/tests.rs create mode 100644 crates/scene/src/tests.rs create mode 100644 docs/scene-format.md create mode 100644 examples/corridor.toml create mode 100644 examples/open-office.toml create mode 100644 examples/photometry/README.md create mode 100644 examples/photometry/bulkhead.ldt create mode 100644 examples/photometry/corridor.ies create mode 100644 examples/plant-room.toml diff --git a/CLAUDE.md b/CLAUDE.md index dc889e2..9aa8524 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,7 @@ task id per commit (e.g. `M1.2: EULUMDAT reader`), commit only green states, ope ## Running - `cargo run -p luxpath-cli -- --help` +- `cargo run -p luxpath-cli -- check examples/corridor.toml` (a scene; format in `docs/scene-format.md`) - `cargo run -p luxpath-cli -- point 2.5 3` (direct illuminance demo) - `cargo run -p luxpath-cli -- photometry ` (what a photometric file says) - `cargo xtask fuzz [seconds]` (fuzz the photometric readers; 600 s is M1's exit criterion) diff --git a/Cargo.lock b/Cargo.lock index 923bf1a..9818530 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,6 +224,9 @@ dependencies = [ "luxpath-geom", "luxpath-ifc", "luxpath-photometry", + "luxpath-report", + "luxpath-rules", + "luxpath-scene", "serde_json", ] @@ -260,6 +263,8 @@ name = "luxpath-rules" version = "0.0.1" dependencies = [ "luxpath-calc", + "luxpath-geom", + "luxpath-photometry", "luxpath-scene", ] @@ -269,6 +274,8 @@ version = "0.0.1" dependencies = [ "luxpath-geom", "luxpath-photometry", + "serde", + "toml", ] [[package]] diff --git a/README.md b/README.md index 2f56587..23bb3ec 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ luxpath checks emergency escape lighting designs against **EN 1838:2024**: escap high-risk task areas and points of emphasis. You describe a floor in a readable text file and give it photometric files (IES, EULUMDAT). luxpath computes the illuminance and tells you which requirements pass, which fail and why. -> **Status: pre-alpha (M0).** The calculation core exists and is tested. Reading photometric files, the scene -> format and the EN 1838 checks are next. See [ROADMAP.md](ROADMAP.md). +> **Status: pre-alpha (M3).** Photometric files (IES, EULUMDAT), the calculation engine and the scene format work, +> and `luxpath check` computes a scene's emergency lighting. **No EN 1838 requirement is checked yet** (M4), and +> nothing is validated against another tool (M6): do not rely on the results. See [ROADMAP.md](ROADMAP.md). ## Why @@ -23,6 +24,7 @@ GUI-only. luxpath aims to be: ## Try it ```sh +cargo run -p luxpath-cli -- check examples/corridor.toml # emergency lighting of an example scene cargo run -p luxpath-cli -- point 2.5 3 # direct illuminance 3 m away from a source mounted at 2.5 m cargo run -p luxpath-cli -- photometry crates/photometry/tests/fixtures/downlight.ldt # read an IES or LDT file cargo xtask ci # every quality gate diff --git a/ROADMAP.md b/ROADMAP.md index 17f67b8..c99c11f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha (M1–M4 + M6 validation) · ~150–250 agent-assisted hours away -> **Last updated:** 2026-10-21 · **Change:** M2 (engine) done +> **Last updated:** 2026-10-21 · **Change:** M3 (scene and `luxpath check`) done luxpath's one-page summary. Details live in: @@ -24,7 +24,7 @@ luxpath's one-page summary. Details live in: ## Next up -1. **M3:** the scene format and `luxpath check`; obstruction by walls (gap 16) before M4. +1. **Obstruction by walls** (gap 16), then **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 | 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. | | 2026-10-21 | M1: IES LM-63 (1986–2019) and EULUMDAT readers, symmetry expansion, tilt data, emergency flux, located errors, property tests, a 10-minute fuzz run (22.8 million inputs) without a panic; `luxpath photometry`. Existing crates evaluated first: none usable (docs/architecture.md). | | 2026-10-16 | M7 (early): `luxpath-ifc` and `luxpath inspect-ifc`; 9 public models inspected, findings in docs/research.md §7. | diff --git a/apps/luxpath-cli/Cargo.toml b/apps/luxpath-cli/Cargo.toml index 6d68c6f..e56458d 100644 --- a/apps/luxpath-cli/Cargo.toml +++ b/apps/luxpath-cli/Cargo.toml @@ -16,6 +16,9 @@ luxpath-geom.workspace = true luxpath-photometry.workspace = true luxpath-calc.workspace = true luxpath-ifc.workspace = true +luxpath-scene.workspace = true +luxpath-rules.workspace = true +luxpath-report.workspace = true serde_json.workspace = true [lints] diff --git a/apps/luxpath-cli/src/main.rs b/apps/luxpath-cli/src/main.rs index ecd2b8d..aea7e9c 100644 --- a/apps/luxpath-cli/src/main.rs +++ b/apps/luxpath-cli/src/main.rs @@ -1,8 +1,11 @@ //! `luxpath`: the command-line tool. //! //! Commands: `point` (direct illuminance under an isotropic source, a demonstration), -//! `photometry` (what a photometric file says, as luxpath reads it) and `inspect-ifc` (what an IFC -//! model contains for an emergency-lighting check). `luxpath check scene.toml` is M3–M4. +//! `photometry` (what a photometric file says, as luxpath reads it), `check` (a scene's emergency +//! lighting) and `inspect-ifc` (what an IFC model contains for an emergency-lighting check). +//! +//! `check` exits with 0 (every requirement passes), 1 (one fails), 2 (the input cannot be read) or +//! 3 (cannot verify: data missing, or, until M4, no requirement checked yet). use std::process::ExitCode; @@ -16,6 +19,9 @@ luxpath — open emergency-lighting calculations USAGE: luxpath point [flux lm] Direct illuminance on the floor from an isotropic 1000 cd/klm source (demonstration). + luxpath check + Emergency lighting of a scene: illuminance on every escape route, area and point. + Exit codes: 0 pass, 1 fail, 2 input error, 3 cannot verify. luxpath photometry What a photometric file says, as luxpath reads it: flux, symmetry, peak, light output ratio. luxpath inspect-ifc [--json] @@ -46,6 +52,16 @@ fn main() -> ExitCode { ExitCode::from(2) } }, + Some("check") => match check(args.get(1..).unwrap_or_default()) { + Ok((text, code)) => { + print!("{text}"); + ExitCode::from(code) + } + Err(message) => { + eprintln!("error: {message}"); + ExitCode::from(2) + } + }, Some("photometry") => match photometry(args.get(1..).unwrap_or_default()) { Ok(text) => { print!("{text}"); @@ -92,13 +108,40 @@ fn inspect_ifc(args: &[String]) -> Result { } } +/// Reads a file of at most `limit` bytes. +fn read_limited(path: &std::path::Path, limit: usize) -> Result, String> { + let shown = path.display(); + let size = std::fs::metadata(path).map_err(|e| format!("{shown}: {e}"))?.len(); + if size > limit as u64 { + return Err(format!("{shown}: {size} bytes is more than the {limit}-byte limit")); + } + std::fs::read(path).map_err(|e| format!("{shown}: {e}")) +} + +fn check(args: &[String]) -> Result<(String, u8), String> { + let path = std::path::Path::new(args.first().ok_or("missing the scene file path")?); + let bytes = read_limited(path, luxpath_scene::MAX_SCENE_BYTES)?; + let text = String::from_utf8(bytes).map_err(|_| format!("{}: not UTF-8 text", path.display()))?; + let scene = luxpath_scene::Scene::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?; + let dir = path.parent().unwrap_or(std::path::Path::new(".")); + let mut photometry = Vec::new(); + for reference in &scene.photometry { + let file = dir.join(&reference.file); + let bytes = read_limited(&file, MAX_FILE_BYTES)?; + photometry.push(luxpath_photometry::read(&bytes).map_err(|e| format!("{}: {e}", file.display()))?); + } + let evaluation = luxpath_rules::evaluate(&scene, &photometry)?; + let code = match evaluation.verdict { + luxpath_rules::Verdict::Pass => 0, + luxpath_rules::Verdict::Fail => 1, + luxpath_rules::Verdict::CannotVerify => 3, + }; + Ok((luxpath_report::text(&scene, &evaluation), code)) +} + fn photometry(args: &[String]) -> Result { let path = args.first().ok_or("missing the photometric file path")?; - let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len(); - if size > MAX_FILE_BYTES as u64 { - return Err(format!("{path}: {size} bytes is more than the {MAX_FILE_BYTES}-byte limit")); - } - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let bytes = read_limited(std::path::Path::new(path), MAX_FILE_BYTES)?; let p = luxpath_photometry::read(&bytes).map_err(|e| format!("{path}: {e}"))?; Ok(describe(&p)) } diff --git a/apps/luxpath-cli/tests/check.rs b/apps/luxpath-cli/tests/check.rs new file mode 100644 index 0000000..7232771 --- /dev/null +++ b/apps/luxpath-cli/tests/check.rs @@ -0,0 +1,81 @@ +//! End to end: `luxpath check` on the example scenes and on broken ones. + +#![allow(clippy::unwrap_used)] + +use std::path::PathBuf; +use std::process::Command; + +fn luxpath(args: &[&str]) -> (i32, String, String) { + let out = Command::new(env!("CARGO_BIN_EXE_luxpath")).args(args).output().unwrap(); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into(), + String::from_utf8_lossy(&out.stderr).into(), + ) +} + +fn examples() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples") +} + +/// A scratch directory with the examples' photometry next to the given scene. +fn scratch(name: &str, scene: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("luxpath-check-{name}-{}", std::process::id())); + std::fs::create_dir_all(dir.join("photometry")).unwrap(); + for file in ["bulkhead.ldt", "corridor.ies"] { + std::fs::copy(examples().join("photometry").join(file), dir.join("photometry").join(file)).unwrap(); + } + std::fs::write(dir.join("scene.toml"), scene).unwrap(); + dir +} + +#[test] +fn every_example_is_calculated_and_cannot_be_verified_until_m4() { + for (scene, ids) in [ + ("corridor.toml", &["R1", "E1", "D1", "F1"][..]), + ("open-office.toml", &["R1", "A1", "E1", "C1"][..]), + ("plant-room.toml", &["H1", "S1", "E1"][..]), + ] { + let path = examples().join(scene); + let (code, out, err) = luxpath(&["check", path.to_str().unwrap()]); + assert_eq!(code, 3, "{scene}: {err}"); + for id in ids { + assert!(out.contains(&format!(" {id} ")), "{scene} should report {id}:\n{out}"); + } + 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}"); + } +} + +#[test] +fn a_luminaire_without_emergency_output_is_reported_not_guessed() { + let scene = std::fs::read_to_string(examples().join("open-office.toml")).unwrap(); + let dir = scratch("no-emergency", &scene.replace("emergency = { flux_lm = 200 }\n", "")); + let (code, out, _) = luxpath(&["check", dir.join("scene.toml").to_str().unwrap()]); + assert_eq!(code, 3); + assert!(out.contains("CANNOT VERIFY: photometry `bulkhead` declares no emergency output"), "{out}"); + assert!(!out.contains(" lx,"), "no numbers when the flux is unknown:\n{out}"); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn broken_inputs_exit_with_2_and_say_where() { + let scene = std::fs::read_to_string(examples().join("corridor.toml")).unwrap(); + 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}"); + + 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()]); + assert_eq!(code, 2); + assert!(err.contains("missing.ldt"), "{err}"); + + std::fs::write(dir.join("photometry/bulkhead.ldt"), "not a photometric file\n").unwrap(); + std::fs::write(dir.join("scene.toml"), &scene).unwrap(); + let (code, _, err) = luxpath(&["check", dir.join("scene.toml").to_str().unwrap()]); + assert_eq!(code, 2); + assert!(err.contains("bulkhead.ldt: line"), "{err}"); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/report/src/lib.rs b/crates/report/src/lib.rs index 8ed20aa..b2baced 100644 --- a/crates/report/src/lib.rs +++ b/crates/report/src/lib.rs @@ -1,8 +1,65 @@ //! Reports: what a designer hands over. //! -//! Planned (M5): a plain-text summary for the terminal, JSON for tools and CI, and an SVG plan -//! (false-colour illuminance, escape routes, luminaires, failing points marked). Every report -//! states the standard edition, the calculation assumptions (direct light only, maintenance -//! factor, emergency-mode flux) and that it is not a certificate (`AGENTS.md` §2). +//! Now (M3): [`text`], a plain-text summary for the terminal. Planned (M5): JSON for tools and CI, +//! and an SVG plan (false-colour illuminance, escape routes, luminaires, failing points marked). +//! Every report states the calculation's assumptions and that it is a calculation, not a +//! certificate (`AGENTS.md` §2). //! -//! Layer L4: depends on `luxpath-scene` and `luxpath-rules`. +//! Layer L5: depends on `luxpath-scene` and `luxpath-rules`. + +use luxpath_rules::{ElementKind, Evaluation, Outcome, Verdict}; +use luxpath_scene::{AreaKind, Scene}; + +const DISCLAIMER: &str = + "This is a calculation, not a certificate: a competent designer remains responsible for the design."; + +fn outcome(o: &Outcome) -> String { + match o { + Outcome::Computed(s) => { + let ratio = s.max_to_min().map_or("unbounded (minimum 0 lx)".to_string(), |r| format!("{r:.1}:1")); + format!( + "min {:.2} lx, mean {:.2} lx, max {:.2} lx, max/min {ratio} ({} points)", + s.min_lx, s.mean_lx, s.max_lx, s.points + ) + } + Outcome::CannotVerify(why) => format!("CANNOT VERIFY: {why}"), + } +} + +/// The evaluation as text for the terminal. +pub fn text(scene: &Scene, evaluation: &Evaluation) -> String { + let mut out = format!("luxpath: {}\n", scene.name); + out += "Emergency mode, direct light.\n\n"; + for r in &evaluation.results { + let kind = match r.kind { + ElementKind::Route => "escape route".to_string(), + ElementKind::Area(AreaKind::Open) => "open area".to_string(), + ElementKind::Area(AreaKind::HighRisk) => "high-risk area".to_string(), + ElementKind::Point(k) => format!("point ({})", k.name()), + }; + let name = if r.name.is_empty() { String::new() } else { format!(" \"{}\"", r.name) }; + out += &format!("{} {}{name}\n", kind, r.id); + let plane = if matches!(r.kind, ElementKind::Point(_)) { "horizontal" } else { "floor" }; + out += &format!(" {plane:<10} {}\n", outcome(&r.horizontal)); + if let Some(v) = &r.vertical { + out += &format!(" {:<10} {}\n", "facing", outcome(v)); + } + } + if !evaluation.warnings.is_empty() { + out += "\nWarnings:\n"; + for w in &evaluation.warnings { + out += &format!(" - {w}\n"); + } + } + out += "\nAssumptions:\n"; + for a in &evaluation.assumptions { + out += &format!(" - {a}\n"); + } + let verdict = match evaluation.verdict { + Verdict::Pass => "PASS", + Verdict::Fail => "FAIL", + Verdict::CannotVerify => "CANNOT VERIFY", + }; + out += &format!("\nVerdict: {verdict}: {}\n{DISCLAIMER}\n", evaluation.verdict_reason); + out +} diff --git a/crates/rules/Cargo.toml b/crates/rules/Cargo.toml index b50b1ee..a044e19 100644 --- a/crates/rules/Cargo.toml +++ b/crates/rules/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true [dependencies] luxpath-scene.workspace = true luxpath-calc.workspace = true +luxpath-geom.workspace = true +luxpath-photometry.workspace = true [lints] workspace = true diff --git a/crates/rules/src/lib.rs b/crates/rules/src/lib.rs index 196dd31..f36cb21 100644 --- a/crates/rules/src/lib.rs +++ b/crates/rules/src/lib.rs @@ -1,10 +1,197 @@ -//! Standards checks: turns calculated illuminances into pass/fail findings with evidence. +//! Standards checks: turns a scene's calculated illuminances into findings with evidence. +//! +//! [`evaluate`] computes, for every escape route, area and point of emphasis in a scene, the +//! illuminance statistics in emergency mode, and lists the assumptions behind them. Anything that +//! cannot be computed honestly (a luminaire without a declared emergency output, a tilt outside its +//! file's table, an undefined point) makes the affected results "cannot verify", never a number. //! //! First standard (M4): **EN 1838:2024**, emergency lighting for buildings. Every requirement is -//! a row in `docs/standards-parity.md` with its clause number; every check cites that clause in its -//! finding. Values come from the standard itself, paraphrased: never copy the standard's text or -//! tables into this repository (`AGENTS.md` §1). +//! a row in `docs/standards-parity.md` with its clause number; every check will cite that clause in +//! its finding. Values come from the standard itself, paraphrased: never copy the standard's text or +//! tables into this repository (`AGENTS.md` §1). Until M4, no requirement is checked, so the +//! overall verdict is always [`Verdict::CannotVerify`]. //! //! Later: EN 12464-1 (interior workplaces) and EN 13201 (road lighting), as separate modules. //! //! Layer L4: depends on `luxpath-scene` and `luxpath-calc`. + +use luxpath_calc::{Grid, Luminaire, Stats, illuminance}; +use luxpath_geom::Vec3; +use luxpath_photometry::Photometry; +use luxpath_scene::{AreaKind, PointKind, Scene}; + +/// What one element's illuminance came to. +#[derive(Debug, Clone, PartialEq)] +pub enum Outcome { + Computed(Stats), + /// Why no number can be trusted. + CannotVerify(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ElementKind { + Route, + Area(AreaKind), + Point(PointKind), +} + +/// One route, area or point and its results. +#[derive(Debug, Clone, PartialEq)] +pub struct ElementResult { + pub id: String, + pub name: String, + pub kind: ElementKind, + /// On the horizontal plane (the floor of routes and areas; at the point for points). + pub horizontal: Outcome, + /// On the surface a point faces (safety equipment), when it has a facing direction. + pub vertical: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Pass, + Fail, + CannotVerify, +} + +/// A scene's evaluation. +#[derive(Debug, Clone, PartialEq)] +pub struct Evaluation { + pub results: Vec, + /// What the numbers assume, for the report. + pub assumptions: Vec, + /// Implausible photometric files and the like: shown, not hidden. + pub warnings: Vec, + pub verdict: Verdict, + /// Why the verdict is what it is. + pub verdict_reason: String, +} + +/// Evaluates `scene` with `photometry[i]` read from `scene.photometry[i].file`. +/// +/// Errors only when the inputs do not belong together (a different number of photometries). +pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result { + if photometry.len() != scene.photometry.len() { + return Err(format!( + "the scene names {} photometric files but {} were given", + scene.photometry.len(), + photometry.len() + )); + } + 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(), + format!("maintenance factor {}", scene.maintenance_factor), + format!( + "grids every {} m; {} points across each route's full width; open areas less a {} m border", + calc.spacing_m, calc.route_points_across, calc.open_area_border_m + ), + ]; + let mut warnings = Vec::new(); + let mut problems = Vec::new(); + let mut emergency_flux: Vec> = Vec::new(); + for (reference, p) in scene.photometry.iter().zip(photometry) { + for w in p.warnings() { + warnings.push(format!("{} ({}): {w}", reference.id, reference.file)); + } + let flux = match reference.emergency { + None => { + problems.push(format!("photometry `{}` declares no emergency output", reference.id)); + None + } + Some(output) => match p.emergency_flux_lm(output) { + Ok(lm) => { + assumptions.push(format!( + "`{}`: {:.0} lm in emergency mode (rated {:.0} lm)", + reference.id, + lm, + p.flux_lm() + )); + Some(lm) + } + Err(e) => { + problems.push(format!("photometry `{}`: {e}", reference.id)); + None + } + }, + }; + emergency_flux.push(flux); + } + + let mut luminaires = Vec::new(); + for placed in &scene.luminaires { + let (Some(p), Some(Some(flux))) = (photometry.get(placed.photometry), emergency_flux.get(placed.photometry)) + else { + continue; + }; + match Luminaire::from_photometry( + p, + placed.position, + placed.rotation_deg, + placed.tilt_deg, + *flux, + scene.maintenance_factor, + ) { + Ok(l) => luminaires.push(l), + Err(e) => problems.push(format!("luminaire `{}`: {e}", placed.id)), + } + } + let unverifiable = (!problems.is_empty()).then(|| problems.join("; ")); + + let outcome = |grid: Result| -> Outcome { + if let Some(reason) = &unverifiable { + return Outcome::CannotVerify(reason.clone()); + } + match grid { + Err(e) => Outcome::CannotVerify(e.to_string()), + Ok(grid) => match Stats::of(&illuminance(&luminaires, &grid)) { + Some(stats) => Outcome::Computed(stats), + None => Outcome::CannotVerify("a grid point is at a luminaire's centre".into()), + }, + } + }; + + let mut results = Vec::new(); + for r in &scene.routes { + results.push(ElementResult { + 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)), + vertical: None, + }); + } + for a in &scene.areas { + let border = if a.kind == AreaKind::Open { calc.open_area_border_m } else { 0.0 }; + results.push(ElementResult { + id: a.id.clone(), + name: a.name.clone(), + kind: ElementKind::Area(a.kind), + horizontal: outcome(Grid::area(&a.outline, a.floor_m, calc.spacing_m, border)), + vertical: None, + }); + } + for p in &scene.points { + results.push(ElementResult { + id: p.id.clone(), + name: p.name.clone(), + kind: ElementKind::Point(p.kind), + horizontal: outcome(Grid::new(vec![p.position], Vec3::UP)), + vertical: p.facing.map(|facing| outcome(Grid::new(vec![p.position], facing))), + }); + } + + Ok(Evaluation { + results, + assumptions, + warnings, + verdict: Verdict::CannotVerify, + verdict_reason: "no EN 1838 requirement is checked yet (milestone M4); the values above are calculations only" + .into(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/rules/src/tests.rs b/crates/rules/src/tests.rs new file mode 100644 index 0000000..f86e509 --- /dev/null +++ b/crates/rules/src/tests.rs @@ -0,0 +1,80 @@ +use super::*; +use luxpath_photometry::read_ies; + +/// 1000 cd/klm all round (an isotropic source) from 1000 lm lamps. +fn isotropic() -> Photometry { + read_ies(b"IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 1 1 2 0 0 0\n1 1 0\n0 180\n0\n1000 1000\n").unwrap() +} + +fn scene(emergency: &str) -> Scene { + Scene::parse(&format!( + r#"format = "luxpath-scene" +version = 1 +[project] +name = "t" +maintenance_factor = 0.8 +[calculation] +spacing_m = 1.0 +route_points_across = 1 +[[photometry]] +id = "iso" +file = "iso.ies" +{emergency} +[[luminaire]] +id = "L1" +photometry = "iso" +position = [0, 0, 2] +[[route]] +id = "R1" +path = [[0, 0], [2, 0]] +width_m = 1 +[[point]] +id = "P1" +kind = "fire-fighting" +position = [3, 0, 2] +facing = [-1, 0, 0] +"# + )) + .unwrap() +} + +fn computed(o: &Outcome) -> Stats { + match o { + Outcome::Computed(s) => *s, + Outcome::CannotVerify(why) => panic!("cannot verify: {why}"), + } +} + +#[test] +fn routes_and_points_are_computed_in_emergency_mode() { + let e = evaluate(&scene("emergency = { flux_lm = 500 }"), &[isotropic()]).unwrap(); + // 1000 cd/klm at 500 lm × MF 0.8 is 400 cd. The route's centre line at 0, 1, 2 m from below + // a 2 m mounting: E = 400 · h / d³, so 100, 400·2/5^1.5 = 71.55, 400·2/8^1.5 = 35.36 lx. + let route = computed(&e.results[0].horizontal); + assert!((route.max_lx - 100.0).abs() < 1e-9); + assert!((route.min_lx - 400.0 * 2.0 / 8f64.powf(1.5)).abs() < 1e-9); + assert_eq!(route.points, 3); + // The point 3 m away at the luminaire's height, facing it: E = 400 / 9 lx vertically and 0 + // horizontally (the light arrives level). + assert_eq!(computed(&e.results[1].horizontal).max_lx, 0.0); + assert!((computed(e.results[1].vertical.as_ref().unwrap()).max_lx - 400.0 / 9.0).abs() < 1e-9); + assert_eq!(e.verdict, Verdict::CannotVerify); + assert!(e.assumptions.iter().any(|a| a.contains("500 lm in emergency mode"))); + // An isotropic table emits far more than its lamps: warned. + assert!(e.warnings.iter().any(|w| w.contains("more light than the lamps"))); +} + +#[test] +fn a_missing_emergency_output_leaves_everything_unverifiable() { + let e = evaluate(&scene(""), &[isotropic()]).unwrap(); + for r in &e.results { + assert!(matches!(&r.horizontal, Outcome::CannotVerify(why) if why.contains("declares no emergency output"))); + } + let e = evaluate(&scene("emergency = { ballast_lumen_factor = 1.5 }"), &[isotropic()]).unwrap(); + assert!(matches!(&e.results[0].horizontal, Outcome::CannotVerify(why) if why.contains("ballast lumen factor"))); +} + +#[test] +fn photometry_must_match_the_scene() { + assert!(evaluate(&scene(""), &[]).is_err()); +} diff --git a/crates/scene/Cargo.toml b/crates/scene/Cargo.toml index 0c95f70..a0ea26b 100644 --- a/crates/scene/Cargo.toml +++ b/crates/scene/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true [dependencies] luxpath-geom.workspace = true luxpath-photometry.workspace = true +serde.workspace = true +toml.workspace = true [lints] workspace = true diff --git a/crates/scene/src/lib.rs b/crates/scene/src/lib.rs index d3b6048..0b22ea5 100644 --- a/crates/scene/src/lib.rs +++ b/crates/scene/src/lib.rs @@ -1,12 +1,512 @@ //! The scene model: what is being lit, and with what. //! -//! Planned (M3): rooms as floor polygons with a height, **escape routes** as centre polylines -//! with a width, **open (anti-panic) areas**, **high-risk task areas**, **points of emphasis** -//! (exits, changes of direction and level, fire-fighting and first-aid equipment, call points), -//! and placed luminaires referring to photometric files. Scenes are stored as human-readable, -//! diffable TOML, so a design can live in version control and be checked in CI. +//! 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, +//! **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 +//! live in version control and be checked in CI. //! -//! Core crates never touch the file system: they parse from `&str`/`&[u8]` so they also run in -//! the browser (WASM). Reading files is the apps' job. +//! [`Scene::parse`] checks everything it can without the photometric files: unknown fields, +//! missing values, references, geometry. Errors carry the line and column they concern. +//! +//! Core crates never touch the file system: photometric files are named here and read by the apps +//! (paths are relative to the scene file). That keeps this crate usable in the browser (WASM). //! //! Layer L2: depends on `luxpath-geom` and `luxpath-photometry`. + +use std::collections::HashSet; +use std::ops::Range; + +use luxpath_geom::{Vec2, Vec3}; +use luxpath_photometry::EmergencyOutput; +use serde::Deserialize; +use toml::Spanned; + +/// The scene format version this crate reads. +pub const FORMAT_VERSION: u32 = 1; + +/// Scene files larger than this are refused (real ones are a few kilobytes). +pub const MAX_SCENE_BYTES: usize = 4 << 20; + +/// Why a scene could not be read, and where (1-based line and column; 0 when unknown). +#[derive(Debug, Clone, PartialEq)] +pub struct SceneError { + pub line: usize, + pub column: usize, + pub message: String, +} + +impl std::fmt::Display for SceneError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.line == 0 { + write!(f, "{}", self.message) + } else { + write!(f, "line {}, column {}: {}", self.line, self.column, self.message) + } + } +} + +impl std::error::Error for SceneError {} + +/// The line and column (1-based) of a byte offset. +fn locate(text: &str, offset: usize) -> (usize, usize) { + let before = text.get(..offset.min(text.len())).unwrap_or(text); + let line = before.matches('\n').count() + 1; + let column = before.rsplit('\n').next().map_or(0, |l| l.chars().count()) + 1; + (line, column) +} + +// ------------------------------------------------------------------------------------------------ +// The file, as written +// ------------------------------------------------------------------------------------------------ + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + format: Spanned, + version: Spanned, + project: Spanned, + #[serde(default)] + calculation: Option>, + #[serde(default)] + photometry: Vec>, + #[serde(default)] + luminaire: Vec>, + #[serde(default)] + route: Vec>, + #[serde(default)] + area: Vec>, + #[serde(default)] + point: Vec>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ProjectFile { + name: String, + maintenance_factor: Spanned, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CalculationFile { + spacing_m: Option>, + route_points_across: Option>, + open_area_border_m: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PhotometryFile { + id: Spanned, + file: String, + emergency: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EmergencyFile { + ballast_lumen_factor: Option, + flux_lm: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LuminaireFile { + id: Spanned, + photometry: Spanned, + position: Spanned<[f64; 3]>, + #[serde(default)] + rotation_deg: f64, + #[serde(default)] + tilt_deg: f64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RouteFile { + id: Spanned, + #[serde(default)] + name: String, + path: Spanned>, + width_m: Spanned, + #[serde(default)] + floor_m: f64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AreaFile { + id: Spanned, + #[serde(default)] + name: String, + kind: Spanned, + outline: Spanned>, + #[serde(default)] + floor_m: f64, + normal_lighting_lx: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PointFile { + id: Spanned, + #[serde(default)] + name: String, + kind: Spanned, + position: Spanned<[f64; 3]>, + facing: Option>, +} + +// ------------------------------------------------------------------------------------------------ +// The model +// ------------------------------------------------------------------------------------------------ + +/// A photometric file the scene uses. +#[derive(Debug, Clone, PartialEq)] +pub struct PhotometryRef { + pub id: String, + /// Relative to the scene file. + pub file: String, + /// `None`: not declared, so emergency-mode results cannot be verified. + pub emergency: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PlacedLuminaire { + pub id: String, + /// Index into [`Scene::photometry`]. + pub photometry: usize, + pub position: Vec3, + pub rotation_deg: f64, + pub tilt_deg: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Route { + pub id: String, + pub name: String, + pub path: Vec, + pub width_m: f64, + /// The floor's height, in metres. + pub floor_m: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AreaKind { + /// An open (anti-panic) area. + Open, + /// A high-risk task area. + HighRisk, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Area { + pub id: String, + pub name: String, + pub kind: AreaKind, + pub outline: Vec, + pub floor_m: f64, + /// For high-risk task areas: the maintained illuminance of the normal lighting, in lux. + pub normal_lighting_lx: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PointKind { + Exit, + DirectionChange, + LevelChange, + Intersection, + FireFighting, + FirstAid, + CallPoint, + Other, +} + +impl PointKind { + const NAMES: [(&'static str, PointKind); 8] = [ + ("exit", PointKind::Exit), + ("direction-change", PointKind::DirectionChange), + ("level-change", PointKind::LevelChange), + ("intersection", PointKind::Intersection), + ("fire-fighting", PointKind::FireFighting), + ("first-aid", PointKind::FirstAid), + ("call-point", PointKind::CallPoint), + ("other", PointKind::Other), + ]; + + pub fn name(self) -> &'static str { + Self::NAMES.iter().find(|(_, k)| *k == self).map_or("other", |(n, _)| n) + } +} + +/// A point of emphasis: illuminance is computed on the floor below it (horizontal) and, when it +/// has a facing direction, on that surface (vertical, at safety equipment). +#[derive(Debug, Clone, PartialEq)] +pub struct Point { + pub id: String, + pub name: String, + pub kind: PointKind, + pub position: Vec3, + pub facing: Option, +} + +/// Calculation settings. Defaults are luxpath's, not the standard's; reports show them. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Calculation { + /// Grid spacing over areas and along routes, in metres. + pub spacing_m: f64, + /// Points across a route's width, edges included. + pub route_points_across: usize, + /// Border left out of open areas, in metres. + pub open_area_border_m: f64, +} + +impl Default for Calculation { + fn default() -> Self { + Self { spacing_m: 0.25, route_points_across: 5, open_area_border_m: 0.5 } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Scene { + pub name: String, + pub maintenance_factor: f64, + pub calculation: Calculation, + pub photometry: Vec, + pub luminaires: Vec, + pub routes: Vec, + pub areas: Vec, + pub points: Vec, +} + +/// Errors located by span while the file is turned into a [`Scene`]. +struct Checker<'t> { + text: &'t str, +} + +impl Checker<'_> { + fn err(&self, span: Range, message: impl Into) -> SceneError { + let (line, column) = locate(self.text, span.start); + SceneError { line, column, message: message.into() } + } + + fn finite(&self, value: &Spanned, what: &str) -> Result { + let v = *value.get_ref(); + if v.is_finite() { Ok(v) } else { Err(self.err(value.span(), format!("{what} must be a finite number"))) } + } + + fn positive(&self, value: &Spanned, what: &str) -> Result { + let v = self.finite(value, what)?; + if v > 0.0 { Ok(v) } else { Err(self.err(value.span(), format!("{what} must be above 0"))) } + } + + fn point2(&self, points: &Spanned>, min: usize, what: &str) -> Result, SceneError> { + let out: Vec = points.get_ref().iter().map(|[x, y]| Vec2::new(*x, *y)).collect(); + if out.len() < min { + return Err(self.err(points.span(), format!("{what} needs at least {min} points"))); + } + if !out.iter().all(|p| p.is_finite()) { + return Err(self.err(points.span(), format!("{what} has a coordinate that is not a finite number"))); + } + Ok(out) + } + + fn point3(&self, p: &Spanned<[f64; 3]>, what: &str) -> Result { + let [x, y, z] = *p.get_ref(); + let v = Vec3::new(x, y, z); + if v.is_finite() { Ok(v) } else { Err(self.err(p.span(), format!("{what} must be finite numbers"))) } + } + + fn unique<'a>(&self, seen: &mut HashSet<&'a str>, id: &'a Spanned) -> Result<(), SceneError> { + if id.get_ref().trim().is_empty() { + return Err(self.err(id.span(), "an id must not be empty")); + } + if !seen.insert(id.get_ref().as_str()) { + return Err(self.err(id.span(), format!("the id `{}` is used twice", id.get_ref()))); + } + Ok(()) + } +} + +impl Scene { + /// Reads a scene from TOML text. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_SCENE_BYTES { + return Err(SceneError { + line: 0, + column: 0, + message: format!("the scene is {} bytes, more than the {MAX_SCENE_BYTES}-byte limit", text.len()), + }); + } + let file: File = toml::from_str(text).map_err(|e| { + let (line, column) = e.span().map_or((0, 0), |s| locate(text, s.start)); + SceneError { line, column, message: e.message().trim().to_string() } + })?; + let c = Checker { text }; + if file.format.get_ref() != "luxpath-scene" { + return Err(c.err(file.format.span(), "`format` must be \"luxpath-scene\"")); + } + if *file.version.get_ref() != FORMAT_VERSION { + return Err(c.err( + file.version.span(), + format!( + "this is scene format version {}; this luxpath reads version {FORMAT_VERSION}", + file.version.get_ref() + ), + )); + } + + let project = file.project.get_ref(); + let mf = c.finite(&project.maintenance_factor, "the maintenance factor")?; + if !(mf > 0.0 && mf <= 1.0) { + return Err( + c.err(project.maintenance_factor.span(), "the maintenance factor must be above 0 and at most 1") + ); + } + + let mut calculation = Calculation::default(); + if let Some(calc) = &file.calculation { + let calc = calc.get_ref(); + if let Some(s) = &calc.spacing_m { + calculation.spacing_m = c.positive(s, "the grid spacing")?; + } + if let Some(n) = &calc.route_points_across { + if *n.get_ref() == 0 { + return Err(c.err(n.span(), "a route needs at least one point across")); + } + calculation.route_points_across = *n.get_ref() as usize; + } + if let Some(b) = &calc.open_area_border_m { + let border = c.finite(b, "the open-area border")?; + if border < 0.0 { + return Err(c.err(b.span(), "the open-area border must not be negative")); + } + calculation.open_area_border_m = border; + } + } + + let mut ids = HashSet::new(); + let mut photometry = Vec::new(); + for p in &file.photometry { + let p = p.get_ref(); + c.unique(&mut ids, &p.id)?; + let emergency = match &p.emergency { + None => None, + Some(e) => Some(match (e.get_ref().ballast_lumen_factor, e.get_ref().flux_lm) { + (Some(f), None) => EmergencyOutput::BallastLumenFactor(f), + (None, Some(lm)) => EmergencyOutput::FluxLm(lm), + _ => { + return Err( + c.err(e.span(), "give the emergency output as either `ballast_lumen_factor` or `flux_lm`") + ); + } + }), + }; + photometry.push(PhotometryRef { id: p.id.get_ref().clone(), file: p.file.clone(), emergency }); + } + + let mut luminaires = Vec::new(); + for l in &file.luminaire { + let l = l.get_ref(); + c.unique(&mut ids, &l.id)?; + let index = photometry.iter().position(|p| &p.id == l.photometry.get_ref()).ok_or_else(|| { + c.err(l.photometry.span(), format!("no [[photometry]] has the id `{}`", l.photometry.get_ref())) + })?; + if !l.rotation_deg.is_finite() || !l.tilt_deg.is_finite() { + return Err(c.err(l.id.span(), "the rotation and tilt must be finite numbers")); + } + luminaires.push(PlacedLuminaire { + id: l.id.get_ref().clone(), + photometry: index, + position: c.point3(&l.position, "the position")?, + rotation_deg: l.rotation_deg, + tilt_deg: l.tilt_deg, + }); + } + + let mut routes = Vec::new(); + for r in &file.route { + let r = r.get_ref(); + c.unique(&mut ids, &r.id)?; + 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 mut areas = Vec::new(); + for a in &file.area { + let a = a.get_ref(); + c.unique(&mut ids, &a.id)?; + let kind = match a.kind.get_ref().as_str() { + "open" => AreaKind::Open, + "high-risk" => AreaKind::HighRisk, + other => return Err(c.err(a.kind.span(), format!("`{other}` is not an area kind (open, high-risk)"))), + }; + let normal_lighting_lx = match &a.normal_lighting_lx { + Some(lx) if kind == AreaKind::HighRisk => Some(c.positive(lx, "the normal lighting's illuminance")?), + Some(lx) => return Err(c.err(lx.span(), "`normal_lighting_lx` belongs to high-risk areas only")), + None => None, + }; + if !a.floor_m.is_finite() { + return Err(c.err(a.id.span(), "the floor height must be a finite number")); + } + areas.push(Area { + id: a.id.get_ref().clone(), + name: a.name.clone(), + kind, + outline: c.point2(&a.outline, 3, "an area's outline")?, + floor_m: a.floor_m, + normal_lighting_lx, + }); + } + + let mut points = Vec::new(); + for p in &file.point { + let p = p.get_ref(); + c.unique(&mut ids, &p.id)?; + let kind = + PointKind::NAMES.iter().find(|(n, _)| *n == p.kind.get_ref()).map(|(_, k)| *k).ok_or_else(|| { + let names: Vec<&str> = PointKind::NAMES.iter().map(|(n, _)| *n).collect(); + c.err(p.kind.span(), format!("`{}` is not a point kind ({})", p.kind.get_ref(), names.join(", "))) + })?; + let facing = match &p.facing { + None => None, + Some(f) => Some( + c.point3(f, "the facing direction")? + .normalized() + .ok_or_else(|| c.err(f.span(), "the facing direction must not be zero"))?, + ), + }; + points.push(Point { + id: p.id.get_ref().clone(), + name: p.name.clone(), + kind, + position: c.point3(&p.position, "the position")?, + facing, + }); + } + + Ok(Self { + name: project.name.clone(), + maintenance_factor: mf, + calculation, + photometry, + luminaires, + routes, + areas, + points, + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/scene/src/tests.rs b/crates/scene/src/tests.rs new file mode 100644 index 0000000..fa36a95 --- /dev/null +++ b/crates/scene/src/tests.rs @@ -0,0 +1,105 @@ +use super::*; + +const SCENE: &str = r#"format = "luxpath-scene" +version = 1 + +[project] +name = "Corridor" +maintenance_factor = 0.8 + +[calculation] +spacing_m = 0.5 + +[[photometry]] +id = "bulkhead" +file = "photometry/bulkhead.ldt" +emergency = { ballast_lumen_factor = 0.5 } + +[[photometry]] +id = "spot" +file = "spot.ies" + +[[luminaire]] +id = "L1" +photometry = "bulkhead" +position = [2.0, 1.0, 2.8] +rotation_deg = 90 + +[[route]] +id = "R1" +name = "Corridor to exit A" +path = [[0, 1], [10, 1], [10, 6]] +width_m = 2.0 + +[[area]] +id = "A1" +kind = "high-risk" +outline = [[0, 3], [6, 3], [6, 8], [0, 8]] +normal_lighting_lx = 500 + +[[point]] +id = "P1" +kind = "fire-fighting" +position = [10.5, 6.0, 1.2] +facing = [-2, 0, 0] +"#; + +#[test] +fn a_scene_reads_into_its_model() { + let s = Scene::parse(SCENE).unwrap(); + assert_eq!((s.name.as_str(), s.maintenance_factor), ("Corridor", 0.8)); + assert_eq!(s.calculation, Calculation { spacing_m: 0.5, ..Calculation::default() }); + assert_eq!(s.photometry[0].emergency, Some(EmergencyOutput::BallastLumenFactor(0.5))); + 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_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); + // The facing direction is normalised. + assert_eq!(s.points[0].facing, Some(Vec3::new(-1.0, 0.0, 0.0))); +} + +/// `SCENE` with `from` replaced by `to`. +fn with(from: &str, to: &str) -> String { + assert!(SCENE.contains(from), "{from}"); + SCENE.replacen(from, to, 1) +} + +#[test] +fn malformed_scenes_give_located_errors() { + let cases: Vec<(String, usize, &str)> = vec![ + (with("version = 1", "version = 2"), 2, "version 2"), + (with("maintenance_factor = 0.8", "maintenance_factor = 1.2"), 6, "at most 1"), + (with("maintenance_factor = 0.8\n", ""), 4, "maintenance_factor"), + (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("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("[project]", "[project\n"), 4, ""), + ]; + for (text, line, words) in cases { + let e = Scene::parse(&text).unwrap_err(); + assert_eq!(e.line, line, "{e}"); + assert!(e.column > 0, "{e}"); + assert!(e.to_string().contains(words), "{e} should mention {words}"); + } +} + +#[test] +fn huge_or_hostile_input_is_an_error_not_a_panic() { + assert!(Scene::parse(&"#".repeat(MAX_SCENE_BYTES + 1)).is_err()); + for text in ["", "\u{0}", "[[[", "version = 1e999", "format = \"luxpath-scene\"\nversion = -1"] { + assert!(Scene::parse(text).is_err(), "{text:?}"); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index f367cc7..37bade6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,12 +23,12 @@ new crate isn't listed in `xtask/src/layers.rs`. | Layer | Crate | Responsibility | |---|---|---| -| L0 | `luxpath-geom` | Vectors, angles, later 2D polygons and routes. Conventions: metres, z up, degrees in APIs | +| L0 | `luxpath-geom` | Vectors, angles, plan polygons. Conventions: metres, z up, degrees in APIs | | L1 | `luxpath-photometry` | C-γ intensity tables (cd/klm), IES and EULUMDAT readers, symmetry expansion | | L2 | `luxpath-scene` | Rooms, routes, areas, points of emphasis, placed luminaires; the TOML format | | L3 | `luxpath-calc` | Direct illuminance at points and over grids; statistics | | L3 | `luxpath-ifc` | Reads IFC (BIM) models through `ifc-lite-core`; today the inspector, later scenes built from models | -| L4 | `luxpath-rules` | Standards checks: findings with clause, measured value, threshold, verdict | +| L4 | `luxpath-rules` | Evaluates a scene (results per element, assumptions); from M4 standards checks with clause, value, threshold, verdict | | L5 | `luxpath-report` | Text, JSON, SVG (later PDF) output | | L9 | `luxpath-cli`, `xtask` | Applications and tooling; only these may do I/O or use UI crates | diff --git a/docs/gaps.md b/docs/gaps.md index 7da338b..0b78766 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:** M2 closed rows 3, 8, 9 and 13; added row 16 +> **Last updated:** 2026-10-21 · **Change:** M3 closed row 5 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). @@ -8,7 +8,6 @@ in the same PR that closes it. **[alpha blocker]** rows fail the alpha gate (ROA | # | Gap | Why it ranks here | Milestone | Est. h | |---|---|---|---|---| | 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 | -| 5 | **[alpha blocker]** No scene format or `check` command | Users can't describe a floor | M3 | 15–20 | | 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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4dcd7ad..a7c0431 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Milestones -> **Last updated:** 2026-10-21 · **Change:** M1 and M2 done +> **Last updated:** 2026-10-21 · **Change:** M1–M3 done Estimates are agent-assisted hours (a human directing an agent, reviewing every change) and are rough until M2 calibrates them. Each milestone ends with its exit criteria met, `cargo xtask ci` green, and the parity entries @@ -11,7 +11,7 @@ updated. | M0 | Foundations | Workspace, lints, CI, `xtask` gates (ci, layers, parity), docs, intensity table, direct point illuminance, demo CLI | All gates green; one hand-checked calculation end to end | 5–8 | **done** | | M1 | Photometry | IES LM-63 (1995/2002/2019) and EULUMDAT readers; symmetry expansion; tilt data; emergency-mode flux; validation errors with line numbers; property tests and fuzzing | Synthetic fixtures for every symmetry type read identically from IES and LDT; fuzzing finds no panic in 10 min | 20–35 | **done** | | M2 | Engine | Calculation grids (areas, route bands), sums over luminaires, tilted/wall-mounted luminaires, vertical illuminance, min/avg/max/uniformity statistics | Analytic test cases (isotropic, Lambertian) match to 1e-6; performance: 100 luminaires × 10k points < 100 ms | 20–30 | **done** | -| M3 | Scene and CLI | TOML scene format (versioned), 2D geometry (polygons, routes with width), `luxpath check scene.toml` with exit codes | Three example scenes in `examples/`; malformed scenes give located errors | 20–30 | planned | +| M3 | Scene and CLI | TOML scene format (versioned), 2D geometry (polygons, routes with width), `luxpath check scene.toml` with exit codes | Three example scenes in `examples/`; malformed scenes give located errors | 20–30 | **done** | | M4 | EN 1838:2024 | Every requirement in `docs/standards-parity.md`: escape routes over the full width, uniformity, open areas, high-risk areas, points of emphasis, glare limits, declared duration/response | Each P0 requirement tested on both sides of its threshold; values verified against the standard's text | 25–40 | planned | | M5 | Reports | Text, JSON (stable schema), SVG plan with false colours and failing points; PDF later | A report a designer can read without knowing the tool | 15–25 | planned | | M6 | Validation | Black-box comparison with an independent tool (DIALux evo, Relux) on ≥10 synthetic scenes; divergences explained in `docs/validation.md` | Agreement within a stated tolerance, or a documented reason | 10–20 + human | planned | diff --git a/docs/scene-format.md b/docs/scene-format.md new file mode 100644 index 0000000..ecdd489 --- /dev/null +++ b/docs/scene-format.md @@ -0,0 +1,106 @@ +# Scene format, version 1 + +> **Last updated:** 2026-10-21 · **Change:** version 1 (M3) + +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`. + +Conventions: metres, degrees, lux, lumens; plan coordinates `[x, y]`, positions `[x, y, z]` with **z up** and the +floor usually at `z = 0`. Unknown fields are errors (a typo must not silently drop a value), and every error names +its line and column. + +## Header and project + +```toml +format = "luxpath-scene" # required +version = 1 # required: the format version; newer versions are refused, not guessed + +[project] +name = "Office floor 2" +maintenance_factor = 0.8 # required, above 0 and at most 1: no default +``` + +## Calculation settings (optional) + +```toml +[calculation] +spacing_m = 0.25 # grid spacing over areas and along routes (default 0.25) +route_points_across = 5 # points across a route's full width, edges included (default 5) +open_area_border_m = 0.5 # border left out of open areas (default 0.5) +``` + +The defaults are luxpath's choices, shown in every report. From M4 the checks will use the spacings and borders +EN 1838:2024 sets, verified against its text (docs/standards-parity.md). + +## Photometry + +```toml +[[photometry]] +id = "bulkhead" +file = "photometry/bulkhead.ldt" # IES or EULUMDAT, relative to the scene file +emergency = { ballast_lumen_factor = 0.4 } # or { flux_lm = 120 } +``` + +`emergency` is the output in emergency mode: a ballast lumen factor in (0, 1], or lumens up to the rated flux +(measured like the file's flux: the lamps' for relative photometry, the luminaire's for absolute). Photometric +files do not hold it. **Without it, every result is "cannot verify"**: luxpath does not assume a value. + +## Luminaires + +```toml +[[luminaire]] +id = "L1" +photometry = "bulkhead" # a [[photometry]] id +position = [2.0, 1.0, 2.7] # the photometric centre +rotation_deg = 90 # optional: heading of C0, counter-clockwise from +x (default 0) +tilt_deg = 60 # optional: tilt of the nadir towards C0 (default 0; 90 = aimed horizontally) +``` + +## Escape routes + +```toml +[[route]] +id = "R1" +name = "Corridor to exit A" # optional +path = [[0, 1], [15, 1], [15, 12]] # the centre line, at least two points +width_m = 2.0 +floor_m = 0.0 # optional (default 0) +``` + +Illuminance is computed on the floor over the route's **full width** (`route_points_across` points across). + +## Areas + +```toml +[[area]] +id = "A1" +kind = "open" # "open" (anti-panic) or "high-risk" (high-risk task area) +outline = [[0, 0], [15, 0], [15, 10], [0, 10]] # a polygon, at least three corners +floor_m = 0.0 # optional +normal_lighting_lx = 300 # high-risk areas only: the normal lighting's maintained illuminance +``` + +Open areas are computed less `open_area_border_m` from their edges; high-risk areas over their whole outline. + +## Points of emphasis + +```toml +[[point]] +id = "F1" +name = "Fire extinguisher" # optional +kind = "fire-fighting" # exit, direction-change, level-change, intersection, fire-fighting, + # first-aid, call-point, other +position = [10.0, 0.05, 1.2] +facing = [0, 1, 0] # optional: the surface's outward direction, for vertical illuminance +``` + +Illuminance is computed on a horizontal surface at `position` and, with `facing`, on the surface facing that way. + +## Ids + +Every `id` is unique across the whole scene and not empty: reports and later versions refer to elements by it. + +## Compatibility + +A scene states its `version`. A luxpath that reads version N refuses other versions with a clear message; when the +format changes, the version goes up and `docs/scene-format.md` says how to migrate. diff --git a/docs/status.md b/docs/status.md index e963729..3f3182c 100644 --- a/docs/status.md +++ b/docs/status.md @@ -2,9 +2,17 @@ > **Last updated:** 2026-10-21 -**Milestone:** M2 done (calculation engine); M1 done (photometry readers). **Direction under study:** EN 1838 checks on IFC models +**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. +## 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. +Unknown fields are errors, and every error names its line and column; the maintenance factor and emergency outputs +have no defaults. `luxpath check scene.toml` prints illuminance per route, area and point with the assumptions, and +exits 0 pass, 1 fail, 2 input error, 3 cannot verify; until M4 checks requirements, it always answers 3. Three +example scenes in `examples/`. + ## M2 (2026-10-21) `luxpath-calc` places luminaires with a heading and a tilt (90° = wall-mounted; IES tilt factors applied, a tilt outside the file's table refused), computes direct illuminance on any surface (horizontal, vertical, inclined), @@ -29,11 +37,12 @@ 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. M3 scene:** the TOML scene format and `luxpath check` (then obstruction, gap 16, before M4). +- **C. Obstruction** (gap 16): walls in the scene block light; an alpha blocker before M4's checks. +- **E. Going public:** the publishing checklist (`docs/publishing.md`). - **D. Real files:** photometric files of luminaires the owner's practice uses, privately in `plan/` (gap 12). ## Blockers -- None for M3. **Before M4:** the EN 1838:2024 text (Italian edition UNI EN 1838:2025) is needed to verify values (gap 4). +- None for obstruction. **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 new file mode 100644 index 0000000..227339b --- /dev/null +++ b/examples/corridor.toml @@ -0,0 +1,73 @@ +# An L-shaped corridor, 2 m wide, leading to an exit; synthetic. +# Run: cargo run -p luxpath-cli -- check examples/corridor.toml +format = "luxpath-scene" +version = 1 + +[project] +name = "Example: L-shaped corridor" +maintenance_factor = 0.8 + +[[photometry]] +id = "corridor" +file = "photometry/corridor.ies" +# Self-contained emergency luminaire: 120 lm in emergency mode. +emergency = { flux_lm = 120 } + +[[photometry]] +id = "bulkhead" +file = "photometry/bulkhead.ldt" +# Mains luminaire with an emergency conversion kit: 40 % of its rated flux on battery. +emergency = { ballast_lumen_factor = 0.4 } + +# Along the first leg (east), C0 along the corridor. +[[luminaire]] +id = "L1" +photometry = "corridor" +position = [2.0, 1.0, 2.7] + +[[luminaire]] +id = "L2" +photometry = "corridor" +position = [8.0, 1.0, 2.7] + +[[luminaire]] +id = "L3" +photometry = "corridor" +position = [14.0, 1.0, 2.7] + +# Along the second leg (north), turned to follow it. +[[luminaire]] +id = "L4" +photometry = "corridor" +position = [15.0, 6.0, 2.7] +rotation_deg = 90 + +[[luminaire]] +id = "L5" +photometry = "bulkhead" +position = [15.0, 11.0, 2.7] + +[[route]] +id = "R1" +name = "Corridor to exit A" +path = [[0, 1], [15, 1], [15, 12]] +width_m = 2.0 + +[[point]] +id = "E1" +name = "Exit A" +kind = "exit" +position = [15.0, 12.0, 0.0] + +[[point]] +id = "D1" +name = "Corner" +kind = "direction-change" +position = [15.0, 1.0, 0.0] + +[[point]] +id = "F1" +name = "Fire extinguisher" +kind = "fire-fighting" +position = [10.0, 0.05, 1.2] +facing = [0, 1, 0] diff --git a/examples/open-office.toml b/examples/open-office.toml new file mode 100644 index 0000000..d3e79b2 --- /dev/null +++ b/examples/open-office.toml @@ -0,0 +1,71 @@ +# An open-plan office of 15 m × 10 m (an open, anti-panic area) with a route through it; synthetic. +# Run: cargo run -p luxpath-cli -- check examples/open-office.toml +format = "luxpath-scene" +version = 1 + +[project] +name = "Example: open-plan office" +maintenance_factor = 0.8 + +[calculation] +spacing_m = 0.5 + +[[photometry]] +id = "bulkhead" +file = "photometry/bulkhead.ldt" +emergency = { flux_lm = 200 } + +[[luminaire]] +id = "L1" +photometry = "bulkhead" +position = [2.5, 2.5, 3.0] + +[[luminaire]] +id = "L2" +photometry = "bulkhead" +position = [7.5, 2.5, 3.0] + +[[luminaire]] +id = "L3" +photometry = "bulkhead" +position = [12.5, 2.5, 3.0] + +[[luminaire]] +id = "L4" +photometry = "bulkhead" +position = [2.5, 7.5, 3.0] + +[[luminaire]] +id = "L5" +photometry = "bulkhead" +position = [7.5, 7.5, 3.0] + +[[luminaire]] +id = "L6" +photometry = "bulkhead" +position = [12.5, 7.5, 3.0] + +[[area]] +id = "A1" +name = "Open office" +kind = "open" +outline = [[0, 0], [15, 0], [15, 10], [0, 10]] + +[[route]] +id = "R1" +name = "Aisle to exit B" +path = [[0, 5], [15, 5]] +width_m = 1.2 + +[[point]] +id = "E1" +name = "Exit B" +kind = "exit" +position = [15.0, 5.0, 0.0] + +[[point]] +id = "C1" +name = "Manual call point" +kind = "call-point" +position = [14.95, 3.0, 1.4] +facing = [-1, 0, 0] diff --git a/examples/photometry/README.md b/examples/photometry/README.md new file mode 100644 index 0000000..a0aeb1e --- /dev/null +++ b/examples/photometry/README.md @@ -0,0 +1,10 @@ +# Example photometry + +Synthetic files for the example scenes, generated for luxpath from the formulas below. They describe no real +product, and they are under the repository's licence (MIT OR Apache-2.0). Real manufacturers' files are never +committed (AGENTS.md §1.3). + +| File | Format | Distribution (γ from the nadir, in 5° steps) | Lamp flux | Light output ratio | +|---|---|---|---|---| +| `bulkhead.ldt` | EULUMDAT, rotationally symmetric | I ∝ (1 + 1.5 sin²γ) cos γ below the horizon (a batwing), none above | 250 lm | 80 % | +| `corridor.ies` | IES LM-63-2002, quadrant symmetric (C every 15°) | I ∝ cos γ (1 + 2 sin²γ cos²C) below the horizon: wider along C0 | 300 lm | 85 % | diff --git a/examples/photometry/bulkhead.ldt b/examples/photometry/bulkhead.ldt new file mode 100644 index 0000000..3d8bf62 --- /dev/null +++ b/examples/photometry/bulkhead.ldt @@ -0,0 +1,117 @@ +luxpath examples +1 +1 +1 +0 +37 +5 +synthetic +Synthetic emergency bulkhead (batwing) +EX-BULK +bulkhead.ldt +2026-10-21 luxpath +250 +250 +80 +200 +0 +0 +0 +0 +0 +100 +80.0 +1 +0 +1 +1 +LED +250 +4000 +80 +3 +0.5 +0.5 +0.5 +0.5 +0.5 +0.5 +0.5 +0.5 +0.5 +0.5 +0 +0 +5 +10 +15 +20 +25 +30 +35 +40 +45 +50 +55 +60 +65 +70 +75 +80 +85 +90 +95 +100 +105 +110 +115 +120 +125 +130 +135 +140 +145 +150 +155 +160 +165 +170 +175 +180 +145.8 +146.9 +150 +154.9 +161 +167.5 +173.6 +178.3 +180.8 +180.4 +176.2 +167.7 +154.9 +137.5 +115.9 +90.5 +62.1 +31.6 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 diff --git a/examples/photometry/corridor.ies b/examples/photometry/corridor.ies new file mode 100644 index 0000000..d4580b1 --- /dev/null +++ b/examples/photometry/corridor.ies @@ -0,0 +1,17 @@ +IESNA:LM-63-2002 +[TEST] luxpath synthetic example +[MANUFAC] luxpath examples +[LUMCAT] EX-CORR +[LUMINAIRE] Synthetic escape-route luminaire, wide along C0 +TILT=NONE +1 300 1 37 7 1 2 0.3 0.1 0 +1 1 3 +0 5 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85 90 95 100 105 110 115 120 125 130 135 140 145 150 155 160 165 170 175 180 +0 15 30 45 60 75 90 +54.18 54.8 56.58 59.35 62.83 66.65 70.38 73.59 75.8 76.62 75.7 72.78 67.73 60.51 51.26 40.19 27.66 14.09 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +54.18 54.74 56.36 58.88 62.03 65.47 68.81 71.63 73.51 74.06 72.96 69.99 65 57.99 49.07 38.44 26.44 13.47 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +54.18 54.59 55.77 57.59 59.85 62.26 64.52 66.29 67.23 67.05 65.48 62.36 57.57 51.11 43.08 33.65 23.1 11.75 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +54.18 54.39 54.97 55.84 56.87 57.88 58.65 58.98 58.65 57.47 55.26 51.93 47.41 41.71 34.89 27.11 18.53 9.41 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +54.18 54.18 54.16 54.09 53.89 53.49 52.79 51.68 50.08 47.89 45.05 41.5 37.25 32.3 26.71 20.57 13.97 7.07 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +54.18 54.03 53.57 52.8 51.71 50.28 48.49 46.34 43.8 40.88 37.57 33.87 29.81 25.42 20.72 15.78 10.63 5.35 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +54.18 53.98 53.36 52.34 50.91 49.11 46.92 44.38 41.51 38.31 34.83 31.08 27.09 22.9 18.53 14.02 9.41 4.72 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 diff --git a/examples/plant-room.toml b/examples/plant-room.toml new file mode 100644 index 0000000..0dbaf56 --- /dev/null +++ b/examples/plant-room.toml @@ -0,0 +1,56 @@ +# A plant room with a high-risk task area at a switchboard, lit by wall-mounted luminaires, and stairs down; +# synthetic. Run: cargo run -p luxpath-cli -- check examples/plant-room.toml +format = "luxpath-scene" +version = 1 + +[project] +name = "Example: plant room" +maintenance_factor = 0.8 + +[calculation] +spacing_m = 0.25 + +[[photometry]] +id = "bulkhead" +file = "photometry/bulkhead.ldt" +# Measured in emergency mode already: the full rated flux. +emergency = { ballast_lumen_factor = 1.0 } + +# On the south wall at 2.4 m, aimed into the room (tilted 60°, C0 turned to face north). +[[luminaire]] +id = "W1" +photometry = "bulkhead" +position = [2.0, 0.1, 2.4] +rotation_deg = 90 +tilt_deg = 60 + +[[luminaire]] +id = "W2" +photometry = "bulkhead" +position = [5.0, 0.1, 2.4] +rotation_deg = 90 +tilt_deg = 60 + +[[luminaire]] +id = "C1" +photometry = "bulkhead" +position = [3.5, 4.0, 3.0] + +[[area]] +id = "H1" +name = "Switchboard working area" +kind = "high-risk" +outline = [[1, 0.5], [6, 0.5], [6, 2], [1, 2]] +normal_lighting_lx = 300 + +[[point]] +id = "S1" +name = "Stairs down" +kind = "level-change" +position = [6.5, 5.0, 0.0] + +[[point]] +id = "E1" +name = "Door to corridor" +kind = "exit" +position = [0.0, 4.0, 0.0] diff --git a/parity/features.toml b/parity/features.toml index 12328bb..9cda4e4 100644 --- a/parity/features.toml +++ b/parity/features.toml @@ -167,7 +167,9 @@ title = "Human-readable, versioned TOML scene format" area = "scene" tier = "P0" milestone = "M3" -status = "planned" +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)." [[feature]] id = "scene.escape-routes" @@ -175,7 +177,9 @@ title = "Escape routes as polylines with width" area = "scene" tier = "P0" milestone = "M3" -status = "planned" +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." [[feature]] id = "scene.areas" @@ -183,7 +187,9 @@ title = "Open (anti-panic) areas and high-risk task areas as polygons" area = "scene" tier = "P0" milestone = "M3" -status = "planned" +status = "shipped" +evidence = ["crates/scene/src/tests.rs::a_scene_reads_into_its_model", "apps/luxpath-cli/tests/check.rs::every_example_is_calculated_and_cannot_be_verified_until_m4"] +notes = "Open areas (less a border) and high-risk task areas with the normal lighting's illuminance. Missing: holes in areas (columns, voids)." [[feature]] id = "scene.points-of-emphasis" @@ -191,7 +197,9 @@ title = "Points of emphasis (exits, direction and level changes, safety equipmen area = "scene" tier = "P0" milestone = "M3" -status = "planned" +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 = "Eight kinds, horizontal illuminance at the point and vertical on a facing surface. Which kinds need what, and where, is M4's." # --------------------------------------------------------------------------------------------------------------- # en1838 (values and clause numbers: docs/standards-parity.md) @@ -299,7 +307,9 @@ title = "`luxpath check scene.toml` with CI-friendly exit codes" area = "cli" tier = "P0" milestone = "M3" -status = "planned" +status = "partial" +evidence = ["apps/luxpath-cli/tests/check.rs::every_example_is_calculated_and_cannot_be_verified_until_m4", "apps/luxpath-cli/tests/check.rs::a_luminaire_without_emergency_output_is_reported_not_guessed", "apps/luxpath-cli/tests/check.rs::broken_inputs_exit_with_2_and_say_where"] +notes = "`luxpath check scene.toml`: results per route, area and point with the assumptions; exit 0 pass, 1 fail, 2 input error, 3 cannot verify. Missing: verdicts (M4: until then always 3), JSON output (M5)." [[feature]] id = "app.wasm-calculator"