A local-first IES lighting simulator for the browser: import real photometry, model a room, aim your
luminaires, and read the work plane as a heatmap with engineering metrics — direct light plus one diffuse
bounce, calculated on your machine.
Free account · No uploads · Installable offline PWA
LM-63 · Type C
TILT=NONE photometry
Direct + 1 bounce
Convex-room estimate
Local-first
IndexedDB · offline PWA
Plate 01 / PlanAtelier · 6.00 × 4.00 × 3.00 m · as new
Average
467 lx
Uniformity
23%
Target
79% ≥ 300 lx
Fig. 01 — Demo atelier: six 2,400 lm downlights, a 64 × 43 grid and 450 surface patches.
Mean 467 lx as new. At the default 0.80 maintenance factor the same
room reads 373 lx maintained.
01What it does
A lighting study, not a render.
Photometry, geometry and result stay visible and editable. No hidden defaults, no black-box solver.
01 / Input
Import real photometry
Drop in an LM-63 Type C file. The parser reads the angle tables, symmetry and candela, draws a polar
distribution, and surfaces the lumens, watts and maximum candela behind it.
LM-63 · Type C · TILT=NONE
02 / Model
Build the room, aim the light
Set a rectangular room, work-plane height and surface reflectances. Place luminaires by hand, or stamp a
block of them with grid placement, then tune mounting height, aim, tilt and dimming in the inspector.
X/Y · Mount · Aim · Tilt · Dim
03 / Result
Read the work plane
Switch between total, direct and reflected illuminance. Track average, minimum, maximum and uniformity,
check a target-lux threshold, and hover the grid for a point reading. Up to three labelled isolux
contours trace the levels that matter, and a maintenance factor turns as-new figures into the
maintained values you specify against.
Average · Min · Max · U0 · Target
02How it works
From .ies to lux in three moves.
A short loop: edit, debounce, calculate. The worker keeps the interface responsive while the grid runs.
01Parse
Import and validate
Open the IES library and load a Type C file. LuxLab checks the header and angle tables, expands symmetry,
and builds a bilinear candela sampler.
Rejects TILT=INCLUDE · Type A/B
02Model
Place and aim
Set the room and work plane, then place luminaires in plan. Use the inspector for mounting height, aim,
tilt and dimming. The 3D view is read-only.
Convex room · No shadows yet
03Read
Calculate and inspect
Recompute and read the false-colour map. Compare total, direct and reflected views, then check the
metrics and the target threshold.
Direct + one diffuse bounce
03Local-first
The calculation never leaves the tab.
No uploads and no server-side render. An account gates the tool; what you draw never leaves the tab.
Projects and IES source text live in IndexedDB, and the service worker keeps the tool available offline.
Close the lid and the room is still there.
Private by default
No uploads and no tracking cookies. Your project stays browser state, not a server
record; page views are counted anonymously.
Instant feedback
The engine runs in a Web Worker. Edits debounce for 360 ms; only the latest calculation survives.
Install it and unplug
An offline PWA. The app shell, fonts and the lazy 3D preview are precached after one visit.
Portable projects
Exports are self-contained .luxlab.json files with every referenced IES profile embedded.
IES→Parser→Worker→Canvas→IndexedDB
Plate 02 / 3D previewProjects stay in the browser
6 luminaires450 patches467 lx mean as new
Fig. 02 — Work-plane illuminance in the read-only 3D preview. Same 450 patches; rendered locally.
04The plan
Credible studies, at browser speed.
v0.5.1 is tagged and in use. The next work is geometry, shadows and reporting — in the order the method can
support honestly.
The goal
Make a credible IES illumination study as quick as opening a tab: fast enough to iterate, private enough
for client work, and accessible enough that photometry isn't locked inside desktop software.
Shipped now
Shipped
Single rectangular room with a horizontal work plane
Type C, TILT=NONE IES import with polar preview
OrbitX catalogue: 97 IES profiles across 20 families
Direct illuminance plus one diffuse bounce
2D plan editor and read-only 3D preview
Average, min, max, U0 and target compliance
Up to three labelled isolux contours
Maintenance factor: fixed, or the CIE 97 four-factor method
Grid placement for blocks of luminaires
Accounts with roles, an admin dashboard and first-run password change
IndexedDB projects with self-contained JSON export
Installable, fully offline PWA
Next
Unscheduled
→Polygon rooms and multi-room geometry
→Openings, furniture and shadow rays
→CSV and PDF report export
→Glare metrics, including UGR
→Type A/B photometry and TILT data
→Multi-bounce or path-traced verification
No dates. Scope follows what the method can support honestly.
05Method
A short, inspectable pipeline.
Parse, interpolate, integrate, display. Every result is reproducible from the inputs, in the browser.
01LM-63
02Bilinear
03Worker
04Typed arrays
05Canvas
06IndexedDB
01
LM-63 parser
Type C, TILT=NONE, header variants and symmetry expansion. Unsupported photometry is rejected, not approximated.
02
Bilinear candela
Vertical and horizontal interpolation with 360° wrap-around. Dimming and the maintenance factor scale candela linearly, per luminaire.
03
Web Worker
The pure engine runs off the main thread. Edits debounce for 360 ms and the latest request wins.
04
Deterministic heatmaps
The inferno scale is driven by the data's min and max. No random dithering, no per-frame noise.
05
Local IndexedDB
Projects and IES source text persist in the browser. Exports embed every referenced profile.
06
Offline PWA
The app shell, fonts and lazy 3D chunk are precached after the first visit. No CDN in the path.
Calculation
DirectE = I(γ, C) · cos ε / r²One bounceBᵢ = ρᵢ · EᵢEref(p) = Σ Bᵢ Aᵢ cos θᵢ cos θₚ / (π rᵢ²)MaintainedEm = MF · (Ed + Eref), MF = LLMF · LSF · LMF · RSMF
Bilinear candela in γ and C. One bounce only; treat reflected light as an estimate, not a full radiosity
solve.
Fixture check
750 lm declared→748 lm integrated
A public Cooper Lighting MRZ Type C profile was integrated at 1°. The check exercises angle handling and
flux integration — it is not a certification.
Third-party fixture · Not a compliance claim
06Changelog
What shipped, in order.
Generated from the project changelog on every deploy, so the page cannot fall behind the release.
v0.5.1
Added
Self-hosted, cookieless site analytics (Umami) on the landing page and in the tool. The Content-Security-Policy in deploy/Caddyfile was opened for that origin, and the copy that promised "no analytics" now says what actually happens.
Three lux isolines instead of one, each with its own enable checkbox and level, drawn in colour and dash so they can be told apart. Projects saved with the single isoline keep their level, enabled, in the first slot.
A maintenance factor (MF), modelled on the way DIALux does it. The fixed method is one factor for the room, set by slider, by preset or by typing it; the detailed method multiplies the four CIE 97 factors (LLMF × LSF × LMF × RSMF) entered as percentages. The factor is applied per luminaire, so any fixture can carry its own override from the inspector and inherit the room value otherwise. Results become maintained illuminance — heatmap, metrics, isoline and target compliance — rather than as-new, and the metrics panel shows the maintained average beside the as-new figure. New projects start at 0.80; projects saved before this keep 1.00, so their numbers do not move.
A lux isoline on the plan and the 3D work plane. Pick a level — 100, 200, 300, 500 or any value — and the layout draws the contour where the work plane crosses it, labelled in place. It follows the heatmap component selector, so it traces total, direct or reflected light, and changing the level never triggers a recalculation. On the demo room the 200 lx line runs just inside the walls, with 741 lx at the centre and 148 lx in the corners.
Grid placement is an explicit action now, not a live mode. Pick the luminaire you want to repeat — or leave nothing selected to use the active profile — set columns and rows, and press Apply. The block is written into the project at cell centres in one step. The selected luminaire becomes the block, so one light plus 3 × 2 leaves six in the grid rather than one plus six, and every other luminaire stays where it is. Freehand remains the default, and older projects still load as Freehand with no isoline.
Fixed
The selected luminaire's name is a crisp DOM badge pinned to the fixture instead of a canvas-textured sprite. The sprite was sized in metres, so on a phone its 22px label collapsed to a couple of pixels and read as an unreadable black bar over the heatmap; long names also clipped at the texture edge. The badge keeps a constant screen size, truncates with an ellipsis and sits above the fixture rather than on the work plane.
Changed
The IES library is one list instead of two tabs. Your own files and every supplier catalogue sit together, filtered by brand (All, Mine, then one chip per brand), with search across all of them.
Catalogues are data: a brand is a name, an attribution and a set of fixture families (src/lib/catalogue/brands.ts). OrbitX is the first entry; adding another supplier is a new registry entry, not a code change.
Changed
The IES library is preview and file management only. It no longer sets the default profile or assigns one to a luminaire; choosing a profile happens in the Luminaires panel, where the Add picker and the inspector already live.
Changed
Sign-in throttling is far more forgiving: 20 attempts per 15 minutes per identity (was 5) before a 2-minute pause, and the message now says how long to wait. Both values are tunable with LUXLAB_MAX_LOGIN_ATTEMPTS and LUXLAB_LOCKOUT_MINUTES without a code change.
Added
The tool opens on a project list: naming, room size, luminaire count and last edit, with New project and Load demo. Nothing is created or opened on your behalf any more; deleting the last project returns to the list.
New projects ask for their dimensions: room width, depth and height plus the work plane height, validated against the ceiling, instead of always starting at 6 × 4 × 3 m.
Changed
The projects dialog's New project button opens the same dimensions dialog.
Added
Adding a luminaire opens a picker instead of silently reusing the last active profile. It lists both the profiles already in the library and the 97-profile OrbitX catalogue, searchable by name, family, SKU or file; picking a catalogue entry imports it once, exactly as the IES library does. Type A/B catalogue entries are listed but disabled, and the active profile is preselected.
Fixed
The landing page advertised v0.2.0 in its version chip, roadmap line, CTA and footer while v0.5.0 was deployed. It is now stamped from package.json at deploy time, and npm run check:versions (wired into npm run check and the release preflight) fails when the README, DEPLOY or landing page disagree with package.json.
v0.5.0
Added
Admin dashboard actions: reset a password and reveal the generated one once, list and revoke a user's sessions, create an account, and switch sign-up between open, approval and closed.
Change-password dialog in the account menu, with the rule visible while typing, a confirmation field, and the other sessions signed out.
Accounts whose password was set by an administrator are forced to choose their own before the tool opens.
Nightly retention: login attempts after 30 days, audit events after 180, sessions 7 days past expiry.
deploy/ROLLBACK.md: static and service rollback, the forward-only schema policy, and a restore drill rehearsed against a scratch copy rather than production.
The smoke and PWA scripts sign in through the API (LUXLAB_TEST_EMAIL / LUXLAB_TEST_PASSWORD) before asserting, and exit cleanly when the credentials are absent.
Changed
Sign-up asks for an email and a password only; the email is stored as the username. Sign-in still accepts either, so existing accounts keep working.
/tool/sw.js is served with Cache-Control: no-store so a worker update can never be held by a cache.
Fixed
The sign-in and sign-up pages were unstyled: /auth-assets/* resolved against the web root instead of after stripping the prefix, so the stylesheet and script 404'd.
The proxy had cached that 404 by URL, so fixing the route was not enough. Asset URLs are now versioned per release (?v=<version>).
The sign-in page shows LuxLab v<version> · <sha>, so the deployed build is visible before signing in.
GitHub releases now carry the matching CHANGELOG section; notes were only a compare link because commits land straight on main. npm run release syncs the body and npm run release:notes backfills older releases.
v0.4.1
Fixed
The accounts-service symlink is replaced in place. The previous swap left /opt/luxlab-auth/current pointing at itself, so the unit crash-looped with "Too many levels of symbolic links".
"Opened" usage is recorded for any gated document load, because Caddy does not reliably forward X-Forwarded-Uri to the auth service.
Changed
DEPLOY.md records that /usr/local/bin/luxlab-update is a copy and must be reinstalled when deploy/luxlab-update changes.
v0.4.0
Added
Account-based access: a zero-dependency Node service (server/) with SQLite storage for users, roles, sessions, login attempts, audit events and daily usage.
Email-or-username sign-in and an open sign-up page at /login/ and /signup/, with a clear early-development notice.
Caddy forward_auth gating on /tool/*: the app and its assets require a session, while /tool/sw.js and the manifest stay public so PWA updates keep working.
Admin dashboard at /tool/admin: accounts with role and status controls, session revocation, an audit log of logins and admin actions, and per-user usage.
Password reset by email through Resend, plus npm run auth:admin for bootstrapping the first administrator.
Per-account IndexedDB (luxlab.u.<id>) so two accounts in one browser no longer share projects; existing projects are imported once on first sign-in.
Changed
engines.node is now >=24.0.0, because the service relies on unflagged node:sqlite and native TypeScript type stripping.
npm run check also type-checks server/.
Until the accounts service is deployed on a host, the app detects the missing API and keeps running against one local database, so shipping this change cannot lock out a static-only deployment.
Added
npm run changelog (scripts/changelog.mjs) prints or writes the CHANGELOG section for a version; it exits non-zero when the section is missing.
A "Backfill release notes" workflow (workflow_dispatch) copies the matching CHANGELOG section into existing GitHub releases.
Changed
npm run release now annotates the tag with the matching CHANGELOG section instead of a one-line message.
The Release workflow builds the release body from CHANGELOG.md (with a compare link) instead of generated notes, and refreshes the notes and artifact when a release already exists.
Fixed
Release notes and tag annotations can no longer drift from CHANGELOG.md; a tag without a changelog section now fails before anything is pushed.
v0.3.0
Added
Developer tooling: a git-tracked session log (docs/sessions/) with a zero-dependency npm run session CLI (start/note/end/list/latest/sync) and the AGENTS.md cross-computer continuity protocol.
OrbitX catalogue screenshots covering the catalogue browser and the catalogue geometry in the 3D preview.
Changed
Breaking: the landing page is now the site root at / and the tool moved to /tool/. Legacy /welcome/ URLs 308-redirect to /.
The app builds with Vite base: "/tool/", PWA scope /tool/ and manifest start_url: "/tool/"; OrbitX IES and model assets are served under /tool/orbitx.
/sw.js is now a retirement worker: pre-/tool installs unregister, clear the old app caches and reload onto the new root.
Mobile controls use 44 px touch targets across the plan, viewport, segmented controls, fields, switches and sliders; the status bar is larger.
Recompute is demoted to a secondary action and the heatmap scale control is renamed for clarity.
Fixed
Accessibility: a page h1, a mobile drawer with dialog semantics plus focus handling and Escape, and a verified modal focus trap.
Contrast: darker muted text and amber labels, brighter plan dimensions.
The OrbitX catalogue gains token-normalized search, real feedback for Type A/B profiles instead of dead buttons, and the active profile is shown in the Luminaires panel.
Mobile sub-44 px controls went from 20+ to a single 43 px footer link.
07Ready when you are
Start with a room. Leave with a number.
One click from the project list: 6 × 4 × 3 m, six 2,400 lm downlights, 373 lx maintained at the default
0.80 factor. A free account keeps it yours; nothing you draw is uploaded.