A Blender 4.2 add-on for planning ceiling-mounted projector arrays against curved and flat walls. It answers the questions an AV design engineer actually has to answer before ordering brackets:
Where does each projector hang? What lens does it need? Does the image reach the ends of the wall? How wide is the blend? How bright will it be?
Three 7000 lm projectors on a 12.57 m × 3 m curved wall (8 m radius, 90° arc). Coloured areas are the sampled image footprints, white bands are the blend zones, and the dark strip along the bottom is wall the 16:9 images do not reach. All of it computed, not drawn by hand.
This is v0.5. The table below is the whole truth about what works.
| Capability | Notes |
|---|---|
| Throw geometry | Analytic TR = D/W, image size, aspect, and field of view. |
| Lens shift | Vertical and horizontal, with an explicit convention and a datasheet-percentage converter. Limits are checked and warned about. |
| Curved-wall surfaces | Vertical-axis cylindrical wall segments; exact ray/cylinder intersection with arc and height bounds. |
| Flat-wall surfaces | True planar walls (projection.create_flat_wall) — exact ray/plane intersection, no large-radius approximation. Same (s, z) parameterisation as curved walls. |
| Image footprints | The frustum is ray-cast onto the wall on an N×N grid. Reports arc span, height, throw spread, incidence angles, and spill. |
| Projector aiming | Aim-at-target, plus a level (lens-shift) and a tilt mounting mode. |
| Array planning | Lay N projectors across a wall at a requested overlap. Solves numerically for the standoff distance that produces the required arc width. |
| Coverage / gaps / blend zones | Rasterised over the wall in arc-length × height. Reports lit area, dark bands, blend widths, and overlap count. |
| Line-of-sight occlusion | Blocking-hit ray-cast from each projector aperture to each coverage cell through a BVH over user-selected obstacle objects (columns, beams, trusses). Reports occluded sample fraction per projector, flags affected cells, and draws a red shadow overlay. |
| Projector & lens spec library | Curated catalog of verified venue/staging projectors (Christie, Barco, Panasonic, Epson) with lens lineups, throw ratio ranges, shift limits, transmission factors, and datasheet URLs. Model picker populates planning inputs and warns (never clamps) on out-of-range optics; extensible via CSV import. |
| Lumens derate chain | Explicit ISO/IEC 21118 production limit (default 80%), picture mode factor, lamp/laser aging, and lens transmission derates. Reports rated, typical, and worst-case performance bands in both lux and nits. |
| Blend luminance modelling | Optional linear or gamma-shaped ramp (exponent 0.5–1.5 per Dataton WATCHOUT) across overlaps with theoretical mid-zone error prediction, overlap guidance (<5%, 5–10%, 10–20%, >20%), and on-site calibration checklist. |
| Ambient effective contrast | Ambient illuminance (lux) × screen gain → veiling luminance; per-cell effective contrast (L_white + L_amb) / (L_black + L_amb) using native contrast. Evaluated against AVIXA ISCR categories (ANSI/AVIXA V201.01:2021). |
| ANSI/IEC 9-point output | Samples 3×3 equal zone centers for total light output (lumens) and center-to-corner uniformity ratio in datasheet terms (nits and foot-lamberts). |
| Structured handoff export | Serializes full coverage, photometry, and rigging schedules (coordinates, angles, throws, shifts, specs, 3D corners) to machine-readable JSON (schema_version: 1) and CSV. |
| Warp grid export | Per-projector corner-pin points and warp mesh grids (wall (s, z) lattice with image UVs, UV-mapped OBJ meshes) for media processors — design-phase targets, not calibration. |
| DISCAS viewer audit | Per-seat viewer distance and off-axis angle checks per ANSI/INFOCOMM V202.01: BDM 10-arcminute font legibility, ADM 1-arcminute single-pixel resolution, and closest viewer limits. |
| Angle-aware gain profile | Optional SMPTE RP 94 gain models: Lambertian (scalar), peaked (lobe on the screen normal) and retroflective (lobe toward the projector), parameterised by peak gain + half-gain angle + off-axis floor. Per-seat DISCAS luminance applies the curve; viewers outside the half-gain cone and peak gain > 1.3 on a flat wall are flagged. Parametric idealisations at medium confidence — vendor gain-curve charts not consulted. |
| Measured-vs-predicted calibration | Record ≥ 3 on-site lux readings at known wall positions (numeric (s, z) entry or 3D-cursor projection) and the tool fits a scalar correction factor, applies it to every prediction, and keeps the residual visible in every report: per-reading measured-vs-model, ratio dispersion, worst residual. Readings fit against predicted + ambient; exclusions are loud; the photometry is never claimed verified. |
| Brightness | Illuminance and luminance from real per-point distance and incidence. First-order estimate — see the assumptions below. |
| Realtime parametric scene | Generated flat and curved walls are driven by an owned Geometry Nodes group. Wall, array, projector, and analysis controls automatically converge cameras, overlays, computed fields, and the report after a short idle debounce. |
| Non-destructive scene output | Generated content is owner-tagged and organised in dedicated collections. Live refresh reconciles only owned array cameras and overlays; manual projectors and user collections are preserved. |
More than 340 tests run under plain CPython against the production modules, plus a headless Blender smoke workflow. Both run in CI.
- Imported meshes are supported as frontal targets only (new in v0.4). Tag any imported mesh object with Set as Target Wall and analysis ray-casts against it via a BVH. The mesh must be mostly frontal to the projectors: folds, overhangs, domes, and columns are rejected with an error naming the offending region rather than silently mangled. Modifier stacks are applied — the evaluated mesh you see in the viewport is the surface analysis uses.
- Brightness and contrast are engineering estimates, not full radiosity simulations. Every report states its assumptions: uniform frustum intensity, Lambertian screen reflectance, uniform ambient illuminance across the screen, and native projector contrast. Inter-reflections between surfaces and directional ambient light angles are deliberately not modelled.
- Blend-zone geometry is always reported; the luminance ramp is opt-in. The add-on tells you where images overlap and how wide the overlap is. Without the ramp (the default) overlapping light adds linearly; with Linear blend ramp or Gamma blend ramp it models complementary ramps as described above. Real processors require on-site grayscale validation and color calibration.
- Target transforms are constrained. Generated walls may be translated, but rotation or unapplied scale is rejected because the implemented surface is a vertical circular cylinder, not an arbitrary transformed mesh.
These were claimed by earlier versions of this README and are removed because they did not exist and, in some cases, are not physically meaningful. See ADR 0002 for the reasoning.
- Phase synchronisation between projectors (not a real phenomenon for incoherent sources — you want genlock and colour matching, which live in the signal chain)
- Thermal CFD / thermal placement visualisation
- Ambient-light AI compensation
- VR/AR integration
- DWG/DXF/RVT CAD import (use Blender's own importers, then mark the surface)
An earlier web-projection-system/ React app duplicated this maths in
TypeScript. It has been retired — see
ADR 0001, which includes the commands
to recover it from git history. The Blender add-on is the only product here.
Blender 4.2 LTS or newer.
-
Build the Blender extension archive. Its
blender_manifest.tomland__init__.pymust be at the zip root:git clone https://github.com/Saml1211/Blender-PJ-System.git cd Blender-PJ-System blender --background --factory-startup --command extension build \ --source-dir blender_projection_system \ --output-filepath projection_planner.zip -
In Blender: Edit ▸ Preferences ▸ Get Extensions ▸ ▾ ▸ Install from Disk…, then pick the zip. Enable Projection Planner under Add-ons if Blender does not enable it automatically.
If Blender reports acquire(): cookie doesn't exist!, the current Blender
session lost the temporary directory used by its extension-repository lock.
Quit every Blender window and retry in a fresh session; rebuilding the zip does
not repair that session-level lock error, though the archive must still pass
the validation command below.
- Open the 3D viewport sidebar with N and select the Projection tab.
For development, symlink instead of copying so edits take effect on reload.
The examples use Blender 4.2; replace that path segment with your active
Blender version when needed:
# Windows (run as administrator)
New-Item -ItemType SymbolicLink `
-Path "$env:APPDATA\Blender Foundation\Blender\4.2\scripts\addons\blender_projection_system" `
-Target "$PWD\blender_projection_system"# macOS
ln -s "$PWD/blender_projection_system" \
~/Library/Application\ Support/Blender/4.2/scripts/addons/
# Linux
ln -s "$PWD/blender_projection_system" ~/.config/blender/4.2/scripts/addons/This reproduces the screenshot above, and is the same sequence the CI smoke test runs.
Projection ▸ 1. Projection Target ▸ Create Curved Wall
| Field | Value |
|---|---|
| Radius | 8 m |
| Height | 3 m |
| Arc | 90° |
| Concave | ✅ (projectors sit inside the arc) |
The panel confirms arc length 12.57 m, area 37.7 m². The wall is created in
the PJ Targets collection and set as the analysis target automatically.
Projection ▸ 2. Projectors
| Field | Value |
|---|---|
| Throw Ratio | 1.2 |
| Aspect | 16 : 9 |
| Lumens | 7000 |
| Max Vertical Shift | 0.6 (i.e. ±120% in datasheet terms) |
| Mount Mode | Level + Lens Shift |
| Mount Height | 3.2 m |
| Image Centre Above Wall Base | 1.65 m |
| Projectors | 3 |
| Overlap | 0.15 |
On the lens-shift convention: this add-on expresses shift as a fraction of the full image dimension, so
0.5puts the optical axis on the image edge. Many datasheets call that same geometry "100% offset". Halve the datasheet number to get this field.
As soon as a target exists, the array updates automatically. With the values above you get three projectors, each hung at 3.2 m. Continue editing any wall, lens, mounting, count, or overlap control and the cameras settle to the new solution after a short idle debounce. Refresh Projector Array remains as an explicit recovery/scripting control, not a required step:
PJ_01 mount x=+1.899 y=-1.024 z=3.200 throw 5.842 m image 4.869 × 2.739 m shift -56.6%
PJ_02 mount x=+2.158 y=+0.000 z=3.200 throw 5.842 m image 4.869 × 2.739 m shift -56.6%
PJ_03 mount x=+1.899 y=+1.024 z=3.200 throw 5.842 m image 4.869 × 2.739 m shift -56.6%
Coverage overlays, calculated projector fields, and the Report panel update automatically with the array. Projection ▸ 3. Analysis ▸ Refresh Coverage forces the same shared calculation immediately when needed. The report shows:
Wall 'PJ_CurvedWall': 12.57 m arc x 3.00 m high (37.7 m2)
Coverage: 89.0% of wall area (33.6 m2 lit, 4.1 m2 dark)
Horizontal coverage: 100.0% of the arc; lit band 0.30-3.00 m high
Overlap: 9.5% of the wall, max 2 projector(s) on one spot
PJ_01: arc 0.00 - 4.65 m (4.65 m wide)
PJ_02: arc 3.96 - 8.61 m (4.65 m wide)
PJ_03: arc 7.91 - 12.57 m (4.65 m wide)
blend PJ_01 | PJ_02: 0.70 m (15% / 15% of image width)
blend PJ_02 | PJ_03: 0.70 m (15% / 15% of image width)
Brightness: mean 197 nits (57.5 fL), range 145-391 nits, uniformity 0.37
Read the warnings — they are the useful part. This design reports that the 16:9 images leave a 0.30 m unlit strip along the bottom of a 3 m wall, and that illuminance uniformity is 0.37 because the wall ends are struck at up to 28°. Both are true and both are decisions for you, not errors.
Copy Report puts the whole thing on the clipboard for a design document.
| Overlay | Meaning |
|---|---|
| Coloured areas | Each projector's sampled image footprint |
| White bands | Blend zones where two images overlap |
| Near-black bands | Dark gaps no projector reaches (none in this example) |
| Wireframe cones | Lens to image corners |
Clear Analysis removes only the PJ Analysis collection. Your wall and
projectors are untouched.
The tool is most useful when it says no. Set Image Centre Above Wall Base to 1.5 m
and re-plan: it now reports that each projector needs −62.1% vertical shift
against a 60% lens limit, and tells you to lower the mount, raise the image
centre, or switch to tilt mounting. That warning is why the quick start uses
1.65 m.
pip install pytest==8.3.5 ruff==0.9.10
ruff check blender_projection_system tests # lint
python -m compileall -q blender_projection_system tests
pytest -v
blender --factory-startup --command extension validate blender_projection_systemThe tests import the production modules directly — there are no mock reimplementations of the formulas. Several anchor the curved-wall code against textbook flat-screen results:
test_a_near_flat_wall_reproduces_the_throw_formula_image_size— sampling a 100 km-radius wall must returnW = D/TR.test_mean_illuminance_on_a_flat_wall_conserves_luminous_flux— the sampled lux, averaged over a flat image, must equallumens / area.test_the_curved_wall_error_shrinks_as_the_radius_grows— curvature error must converge monotonically to zero as the wall flattens.
blender -b --factory-startup --python-exit-code 1 --python tests/blender_smoke.pyIt covers repeated and duplicate registration, the end-to-end planning and
analysis operators, generated-wall synchronisation, collection ownership,
camera transforms, generated geometry, and a cross-check that the Blender
layer and core/ return the same coverage numbers. It exits non-zero on
failure, so CI can rely on it.
blender_projection_system/
├── core/ # Pure Python. Never imports bpy. All the maths.
│ ├── throw.py # throw ratio, image size, lens shift, frustum rays
│ ├── surfaces.py # cylindrical & planar walls, ray intersection
│ ├── pose.py # projector placement and orientation
│ ├── footprint.py # frustum -> surface sampling, back-projection
│ ├── coverage.py # gaps, blend zones, illuminance grid
│ ├── array.py # multi-projector layout and standoff solve
│ └── photometry.py # illuminance, luminance, stated assumptions
├── properties.py # Blender property groups
├── operators.py # The workflow. Delegates all arithmetic to core/
├── ui.py # Sidebar panels
└── visualization.py # Mesh generation for the overlays
The core/ boundary is enforced by a test, not just convention: it is why the
maths is testable without Blender, and why there is exactly one implementation
of every formula.
Units are SI throughout — metres, radians, lumens, lux, candela. Angles are
radians unless a name ends in _deg.
See CONTRIBUTING.md. Two rules matter most:
- New formulas go in
core/, with a test that imports them. A test that redefines the formula inside the test file proves nothing — that is exactly what v0.1 shipped. - Do not claim a capability the code does not have. If it is an estimate, say what it assumes.
MIT — see LICENSE.
