Reality capture for AEC, in pure Python — content-first format detection, declared-frame coordinate math, plan-linked walkthroughs, and a browser viewer with no build step.
pip install massingcapture
massingcapture demo --serveOpens http://127.0.0.1:8787 on a project it just built — five views, no build step, nothing fetched
from anywhere but the project:
| Plan | the drawing with every capture position on it, plus the tools to register it, scale it and drag nodes into place |
| Walkthrough | an equirectangular sphere with hotspots to the adjacent nodes |
| Reality | BIM, meshes, point clouds and splat previews in one 3D scene — click an element for its property sets, or measure between two points |
| Compare | one capture session cross-faded against another |
| Layers | what may be measured against, and what may not |
Everything in the demo is synthetic and labelled as such.
On your own data:
massingcapture init ./riverside --name "Riverside Clinic"
massingcapture ingest ~/captures/2026-03-04 --modality handheld
massingcapture serveDrones, mobile robots, handheld SLAM rigs, tripod scanners, 360 cameras and fixed jobsite cameras produce about thirty file formats between them, and no two agree on what a coordinate means. This package is the layer that turns that into a project.
It sits upstream of two sibling projects:
MassingCloud/massingviser— a federated AEC platform in pure Python, whosetwinfamily consumes captured reality and gates its promotion into authored geometry.massingcapture.bridge.massingviseremits exactly the records it expects.MassingCloud/massingifc— the framework-agnostic TypeScript kernel and capability contracts. The scene document here is shaped for a Three.js front end built on it.
Neither is a dependency. The three agree on documents, not imports, so a capture container never needs a viewer installed and a viewer never needs a photogrammetry stack.
The core has no runtime dependencies at all. tests/test_architecture.py fails the build if
that stops being true. What runs on a bare pip install:
| Format detection | 30+ formats, decided from magic bytes and parsed headers |
| E57 | the XML index — scan positions, poses, structured grids, embedded panoramas |
| LAS/LAZ | public header and VLRs — count, extent, scale, and the EPSG code |
| PLY | header, bounds, and which of three things it is |
| glTF/GLB | JSON chunk, accessor bounds, extension list |
| IFC | header, length unit, and the storey list — your floors, for free |
| Images | JPEG/PNG/TIFF dimensions, EXIF GPS, GPano and DJI XMP |
| Video | MP4/MOV duration and resolution, spherical-video markers |
| Coordinates | column-major 4×4, WGS84 geodetic/ECEF/ENU, Horn's absolute orientation |
| Registration | 3D control points and 2D plan fitting, both reporting residuals |
| Walkthrough | connected graph building, navigation, plan linking |
| Delivery | job planner, PLY decimation, GLB and 3D Tiles writing, plan slices |
| Viewer | HTTP API and a WebGL viewer with its own glTF and PLY loaders |
Everything heavier — Open3D, trimesh, OpenCV, pyproj, pye57, laspy, pymavlink — lives in
adapters/ behind a token, is entirely optional, and when absent produces a skip with
instructions rather than a failure.
from massingcapture import adapters
adapters.available() # ('crs', 'mesh')
adapters.missing() # {'pointcloud': "No module named 'open3d'"}The claim above is not documentation — tests/test_architecture.py fails the build if the core
gains a runtime dependency. stdlib_only_offenders() in that file is the reference
implementation, meant to be copied whole, and the tests around it are thin callers with everything
package-specific passed as an argument. Three decisions in it are the ones that matter, each written
down at the call site:
- It takes a path and never imports anything. The question a vendoring team actually has is
whether the subtree their script copies is dependency-free, and that subtree usually is not
importable on its own. Reading
pyproject.tomlanswers a different question, and answers it wrongly in both directions: a project declaring Flask can still have a pure-stdlib core, and a project declaring nothing can still import something vendored beside it. - It separates "imports it anywhere" from "imports it at module scope." A guarded import inside a function is a supported optional integration; the same import at module scope makes the package unimportable without that library. A single check either forbids the legitimate pattern or permits the fatal one.
- Exemptions are paths, not module names, so the allowlist survives the subtree being copied under a different package name — which is precisely what vendoring does to it.
test_the_portable_check_needs_only_a_path demonstrates all three against a tree that exists only
on disk, under a package name installed nowhere.
Which level the zero-dependency claim is made at: the whole of src/massingcapture/ except
adapters/, checked as a directory tree. For this package the project-level and subtree-level
answers coincide, because pyproject.toml declares dependencies = [] — but they can differ, and
the subtree answer is the one that governs vendorability.
The same extension carries point clouds, triangle meshes and Gaussian splats. Classifying on the suffix gets it wrong often enough to matter, and the way it goes wrong is the expensive way.
>>> classify_file("photoreal.ply")
splat/ply (100%) -- PLY header: vertex element carries "f_dc_0" -- spherical-harmonic radiance field
>>> classify_file("scan.ply")
point-cloud/ply (100%) -- PLY header: 4823119 vertices, no facesA handheld scanner produces a point cloud and a splat from the same ten minutes, and the two are not convertible after processing. One is a dimension; the other is a picture. So the project keeps two branches, and the gate lives in the schema where everything can see it:
>>> measurability_reason("splat", purpose=None)
'no-surface'
>>> measurability_reason("splat", derivatives=RealityDerivatives(mesh_uri="mesh.glb"))
None # measurable through the mesh, which came from the same captureThe pipeline never plans a mesh job for a splat. A splat may get a positions preview — the
Gaussian centres and their DC colour, recorded as splat-view rather than glb so nothing
downstream can mistake it for a mesh derived from the same capture. The viewer draws it under a
visual badge, the measure tool will not snap to it, and it refuses even to measure through it
onto a mesh behind. And if all of that were bypassed, the server still answers:
409 photoreal.ply may not back a measurement: visualization-only. A radiance field renders
convincingly and measures badly; a photograph has no depth. Measure against the point
cloud or mesh from the same capture.
Five independent enforcements of one rule, because the failure mode is silent.
massingcapture/
schema/ eight record families, the codec, and the version gate — stdlib only
classify/ content-first detection: magic bytes, parsed headers, then extensions
probe/ E57 index, LAS VLRs, PLY, glTF, IFC storeys, EXIF/GPano, MP4, splats, PTX
transform/ column-major 4×4, WGS84 ENU, Horn's absolute orientation, plan fitting
ingest/ classify → probe → derive → record, plus folder sessions and GPS anchoring
walkthrough/ spanning-tree-first graph building, navigation, plan registration
pipeline/ deterministic job planning, runners, stdlib PLY decimation
providers/ NodeODM, Evercam, EarthCam — over urllib, behind protocols
adapters/ optional: Open3D, trimesh, OpenCV, pyproj, pye57, laspy, pymavlink
bridge/ twin-export for massingviser, scene document for a viewer
server/ the API as a function; stdlib server and optional FastAPI mount
web/ the bundled viewer — raw WebGL, no CDN, no build step
A capture project accumulates five or six frames: the scanner's own, project ENU, each floor plan's 2D space, the BIM's local coordinates, and ECEF. A 4×4 in a manifest without its two frames looks authoritative, composes without complaint, and is wrong in a way that surfaces three weeks later.
So it is unrepresentable:
>>> TransformRecord(id="t1", source="plan-2d", target="plan-2d", matrix=IDENTITY)
ValueError: A transform from "plan-2d" to itself is not a transform.And composition across frames is a graph walk, not a thing you do by hand:
graph = FrameGraph(manifest.transforms)
graph.convert(point, "source-local", "plan-2d", scope=floor.id)Every fit reports its residual, always — rms_error, max_error, and the per-point residuals so a
UI can mark the control point dragging the fit:
>>> fit = fit_rigid_3d(scan_points, survey_points)
>>> residual_summary(fit)
'7 points, RMS 0.0042 m, worst 0.0091 m'Capture systems hand over positions, not graphs. A pure k-nearest graph fragments — two rooms joined by a sparsely-scanned corridor become two components, and a user who walks into one can never reach the other.
So a spanning tree goes down first (nobody gets stranded), near neighbours on top (natural steps),
and stair edges only between adjacent floors where a stair core plausibly is. Long spanning-tree
edges that bridge clusters are marked teleport rather than walk, so the viewer can be honest
instead of implying a door that is not there.
$ massingcapture graph
41 nodes, 96 edges
connected
ODM is called, not embedded. NodeODM is a production task API in front of the ODM engine, already what WebODM and PyODM talk to, and it means the photogrammetry runs in its own container while this package stays a thing that reads headers and writes JSON.
from massingcapture.providers import NodeOdmProvider, import_products
odm = NodeOdmProvider("http://odm:3000")
uuid = odm.create_task(images, name="North facade, 4 March")
odm.wait(uuid, on_progress=lambda info: print(info.status_name, info.progress))
import_products(store, odm.download_products(uuid, folder), session_name="North facade", task_uuid=uuid)The submission asks for GLB, COPC, EPT, DSM, DTM and 3D Tiles explicitly, because those are the derivatives a browser actually wants and converting afterwards loses georeferencing.
ODM is for the outside — envelopes, roofs, façades, terrain, campuses. It does not solve indoor walkthrough UX and this package does not pretend it does; interiors come from pano nodes and handheld capture, and the two meet on the plan and in the BIM.
EarthCam and Evercam are visual coordination systems. They consume registered imagery, drone
products and BIM models; they do not produce measurable geometry. So they are adapters here — a
camera registry, a frame timeline, and calibration metadata — and FixedCameraRecord.calibrated
stays False until something actually solves a pose, so an overlay never claims accuracy it has not
earned.
The timeline is the one collection in a project bounded by time rather than by the building. Everything else is a few floors, a few dozen scans, a few hundred elements; a camera at a frame every ten minutes is 52,560 records a year and never stops. Held in the manifest that came to 15 MB of JSON and 6.5 seconds to parse — paid by every unrelated command, and paid again in full to append one frame.
So frames live in frames/<camera_id>.jsonl, append-only, read a slice at a time, and the manifest
keeps the summary: how many, over what span, how many still awaiting their image. Opening a project
with a camera-year in it went from 6.5 s to 3 ms; with 200,000 frames, from 25 s to 3 ms. latest()
reads backwards from the end of the file, because "show me the latest frame" is the question
anyone actually asks a jobsite camera and answering it should not cost a year of parsing.
An older project with frames still in its manifest has them moved on first open, not dropped — a timeline is the one thing no job can regenerate, because by then the provider has aged the images out.
A floor plan arrives as a PDF or a DXF, and neither is something a browser draws. Until it becomes one, the whole plan-linked half of this — placing scan positions, dragging a node onto the drawing, laying an as-built slice over it — has nothing to work on.
A DXF renders to SVG in the standard library. Vector in, vector out. Blocks are instanced (translation, scale including mirroring, rotation), text stays text, and layer-0 contents inherit the referencing layer as the format requires. Entity types it does not draw are named rather than approximated — a spline drawn as a straight line is a wall in the wrong place — and so is a block referenced but never defined, because the fix is to ask for the missing xref.
And the plan arrives registered. $INSUNITS says what one drawing unit is, so metres per SVG
unit is settled before anybody picks a control point. Calibration goes back to being what it should
be: the fallback for a scanned drawing that cannot say.
A PDF rasterises behind the plan adapter, keeping the sheet geometry that makes it measurable — a
page is 72 points to the inch, so a render at a recorded DPI plus a stated drawing scale gives
metres per pixel exactly. Without a stated scale the plan arrives unregistered on purpose: a sheet
size cannot tell you how big the building is, and the number that would make it look like it could
is exactly the number nobody should invent.
The API is a function — handle(Request) -> Response — with no framework, no globals and no
transport. A test calls it directly; the bundled stdlib server calls it; a FastAPI deployment mounts
it. All three run the same code.
GET /api/scene the whole viewer document
GET /api/walkthrough/nodes/{id} a node with its exits, bearings resolved
GET /api/plan what processing the project implies
GET /api/twin the massingviser twin-import document
GET /api/branches measured vs visual, named
POST /api/plans/{id}/register solve a plan transform from control points
POST /api/plans/{id}/calibrate solve a scale from two points and a distance
POST /api/walkthrough/nodes/{id}/place move a node onto the drawing
GET /api/elements/{global_id} an IFC element's property sets
GET /api/cameras fixed cameras, each with its latest frame
GET /api/cameras/{id}/frames a page of one timeline — offset-paged, never all of it
POST /api/measurements record a dimension — refused off unmeasurable geometry
POST /api/jobs/run run what this deployment can
from massingcapture.server.fastapi_app import create_app
app = create_app("./riverside")docs/formats.md— every format, what is read from it, and what it converts todocs/coordinates.md— the five frames and how to move between themdocs/pipeline.md— planning, running, and writing your own runnerdocs/viewer.md— the five views, the plan editor, and how it degradesdocs/fragments.md— wiring up IFC →.fragconversion
pip install -e ".[dev]" # core only — the interesting case
pytest
ruff check . && ruff format --check .
pip install -e ".[all,dev]" # with every adapter, for the cross-checks below
pytestThe suite writes its own E57s, LAS files, GLBs, JPEGs with EXIF and CRC-paged layouts, byte by byte — so the expected answer is known exactly, because the test wrote it.
Where an optional library is installed, the tests go further and cross-validate: the reference
implementation writes a file and the dependency-free reader parses it, and the two must agree.
pye57 writes an E57 and probe.e57 unpages its index; laspy writes a LAS and a struct header
parser reads its extent and EPSG; trimesh writes a GLB and probe.mesh reads the accessor bounds;
pyproj's EPSG:4326 → EPSG:4978 is held against the hand-written Ferrari solution to a millimetre.
That is worth more than testing either half alone. A misread byte offset produces a plausible
number, not an exception — and it was this pass that caught the E57 writer using zlib.crc32
little-endian where the format wants CRC-32C big-endian, a file this package read perfectly and
every other tool in the world rejected as corrupt.
Both of the above are still closed loops: a writer and a reader that share this repository. To break the loop there is an opt-in corpus of ~5 MB of public files nobody here authored — buildingSMART's certification models, PDAL's conformance clouds, a Rust E57 crate's test data, and the header of an INRIA Gaussian splat.
python tests/corpus.py list # what it fetches, from where, under which licence
python tests/corpus.py fetch # ~5 MB into .corpus/, gitignored
pytest # tests/test_realdata.py stops skippingNothing downloads by default — a clone stays clonable offline, and CI does not spend somebody else's
bandwidth. Files are chosen for the assumptions they can break: a deliberately corrupted page
checksum (the negative control without which verify_pages returning [] proves nothing), one
rabbit in six numeric encodings, a scan in spherical coordinates, a cloud in degrees, another in
feet, and a schema newer than the sniffer. Between them they found four real defects on the first
run, including that IfcMapConversion — the file's own statement of where the building is — was
being discarded entirely.
MIT.