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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,6 @@ 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 -- point 2.5 3` (direct illuminance demo)
- `cargo run -p luxpath-cli -- photometry <file.ies|.ldt>` (what a photometric file says)
- `cargo xtask fuzz [seconds]` (fuzz the photometric readers; 600 s is M1's exit criterion)
- `cargo xtask parity` (feature progress by milestone)
131 changes: 131 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ serde_json = "1"
toml = "0.9"
# IFC (STEP) parsing; MPL-2.0, used unmodified as a dependency (AGENTS.md §1.2)
ifc-lite-core = "20.4"
# tests only
proptest = { version = "1.11", default-features = false, features = ["std"] }

[workspace.lints.rust]
unsafe_code = "forbid"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ GUI-only. luxpath aims to be:

```sh
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
cargo xtask parity # feature progress by milestone
```
Expand Down
7 changes: 4 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Stage: pre-alpha** · next: alpha (M1–M4 + M6 validation) · ~150–250 agent-assisted hours away

> **Last updated:** 2026-10-16 · **Change:** project created (M0 scaffold)
> **Last updated:** 2026-10-21 · **Change:** M1 (photometry readers) done

luxpath's one-page summary. Details live in:

Expand All @@ -24,13 +24,14 @@ luxpath's one-page summary. Details live in:

## Next up

1. **M1:** IES LM-63 and EULUMDAT readers, symmetry expansion, emergency-mode flux.
2. **M2:** grids, sums over luminaires, tilt, vertical illuminance.
1. **M2:** grids, sums over luminaires, tilt, vertical illuminance, photometry plausibility warnings (gap 13).
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 | 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. |
| 2026-10-16 | M0: workspace, layering and parity gates, C-γ intensity table, direct illuminance, demo CLI, docs. |
81 changes: 77 additions & 4 deletions apps/luxpath-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
//! `luxpath`: the command-line tool.
//!
//! Commands: `point` (direct illuminance under an isotropic source, a demonstration) and
//! `inspect-ifc` (what an IFC model contains for an emergency-lighting check). `luxpath check
//! scene.toml` is M3–M4.
//! 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.

use std::process::ExitCode;

use luxpath_calc::{Luminaire, direct_horizontal_illuminance};
use luxpath_geom::Vec3;
use luxpath_photometry::IntensityTable;
use luxpath_photometry::{Format, IntensityTable, MAX_FILE_BYTES, Photometry, Symmetry};

const USAGE: &str = "\
luxpath — open emergency-lighting calculations

USAGE:
luxpath point <mounting height m> <horizontal offset m> [flux lm]
Direct illuminance on the floor from an isotropic 1000 cd/klm source (demonstration).
luxpath photometry <file.ies|file.ldt>
What a photometric file says, as luxpath reads it: flux, symmetry, peak, light output ratio.
luxpath inspect-ifc <model.ifc> [--json]
What an IFC model contains for an emergency-lighting check, and what is missing.
luxpath --version
Expand Down Expand Up @@ -44,6 +46,16 @@ fn main() -> ExitCode {
ExitCode::from(2)
}
},
Some("photometry") => match photometry(args.get(1..).unwrap_or_default()) {
Ok(text) => {
print!("{text}");
ExitCode::SUCCESS
}
Err(message) => {
eprintln!("error: {message}");
ExitCode::from(2)
}
},
Some("inspect-ifc") => match inspect_ifc(args.get(1..).unwrap_or_default()) {
Ok(text) => {
print!("{text}");
Expand Down Expand Up @@ -80,6 +92,67 @@ fn inspect_ifc(args: &[String]) -> Result<String, String> {
}
}

fn photometry(args: &[String]) -> Result<String, String> {
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 p = luxpath_photometry::read(&bytes).map_err(|e| format!("{path}: {e}"))?;
Ok(describe(&p))
}

fn describe(p: &Photometry) -> String {
let format = match p.format() {
Format::Ies(edition) => format!("IES {edition:?}").replace("Lm63_", "LM-63-"),
Format::Eulumdat => "EULUMDAT".to_string(),
};
let symmetry = match p.symmetry() {
Symmetry::None => "none (every C plane stored)",
Symmetry::Rotational => "rotational (one C plane)",
Symmetry::PlaneC0C180 => "about the C0–C180 plane",
Symmetry::PlaneC90C270 => "about the C90–C270 plane",
Symmetry::Quadrants => "quadrants (C0–C90 stored)",
};
let t = p.table();
let n_gamma = t.gamma_angles().len().max(1);
let (peak_at, peak) =
t.values().iter().enumerate().fold((0, 0.0), |best, (i, v)| if *v > best.1 { (i, *v) } else { best });
let peak_c = t.c_angles().get(peak_at / n_gamma).copied().unwrap_or(0.0);
let peak_gamma = t.gamma_angles().get(peak_at % n_gamma).copied().unwrap_or(0.0);
let gamma = |i: Option<&f64>| i.copied().unwrap_or(0.0);
let mut out = String::new();
let mut line = |label: &str, value: String| {
if !value.is_empty() {
out += &format!("{label:<18} {value}\n");
}
};
line("format", format);
line("manufacturer", p.manufacturer.clone());
line("luminaire", p.name.replace('\n', " "));
line("catalogue", p.catalogue.clone());
let kind = if p.is_absolute() { "luminaire flux, absolute photometry" } else { "lamp flux" };
line("rated flux", format!("{:.1} lm ({kind})", p.flux_lm()));
line("symmetry", symmetry.to_string());
line(
"table",
format!(
"{} C planes × {} γ angles (γ {}°–{}°)",
t.c_angles().len(),
t.gamma_angles().len(),
gamma(t.gamma_angles().first()),
gamma(t.gamma_angles().last())
),
);
line("peak", format!("{peak:.1} cd/klm at C{peak_c}° γ{peak_gamma}°"));
line("light output", format!("{:.1} % of the rated flux", p.light_output_ratio() * 100.0));
if let Some(tilt) = p.tilt() {
line("tilt", format!("{} angles, lamp geometry {}", tilt.angles_deg().len(), tilt.lamp_geometry));
}
out
}

fn point(args: &[String]) -> Result<f64, String> {
let number = |i: usize, name: &str| -> Result<Option<f64>, String> {
args.get(i)
Expand Down
40 changes: 40 additions & 0 deletions apps/luxpath-cli/tests/photometry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! End to end: `luxpath photometry` on the committed synthetic fixtures and on a broken file.

#![allow(clippy::unwrap_used)]

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(),
)
}

const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../crates/photometry/tests/fixtures");

#[test]
fn describes_ies_and_eulumdat_files_alike() {
for (file, format) in [("downlight.ies", "IES LM-63-2002"), ("downlight.ldt", "EULUMDAT")] {
let (code, out, err) = luxpath(&["photometry", &format!("{FIXTURES}/{file}")]);
assert_eq!(code, 0, "{err}");
assert!(out.contains(format), "{out}");
assert!(out.contains("1000.0 lm (lamp flux)"), "{out}");
assert!(out.contains("rotational"), "{out}");
assert!(out.contains("300.0 cd/klm at C0° γ0°"), "{out}");
}
}

#[test]
fn a_broken_file_is_an_error_with_its_line() {
let dir = std::env::temp_dir().join(format!("luxpath-cli-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("broken.ies");
std::fs::write(&path, "IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 1 1 2 0 0 0\n1 1 0\n0 90\n0\n100\n").unwrap();
let (code, _, err) = luxpath(&["photometry", path.to_str().unwrap()]);
assert_eq!(code, 2);
assert!(err.contains("line 7: the file ends before the candela values"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
3 changes: 3 additions & 0 deletions crates/photometry/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@ repository.workspace = true
[dependencies]
luxpath-geom.workspace = true

[dev-dependencies]
proptest.workspace = true

[lints]
workspace = true
Loading
Loading