Give the web viewer a federation: false origin and per-model transforms

Models now resolve to global coordinates, which alone would make things worse:
composed per-instance transforms are float32, and around six million metres
that quantises at roughly half a metre. So the first model to load also sets a
false origin, derived from where its geometry actually sits, unless a host has
set one itself.

WebFederation owns the concepts an .ifcfed carries — a federation unit, a false
origin, a per-model transform and display name — without the file format. The
desktop Federation class is a document model whose sources are local filesystem
paths, which mean nothing in a browser; a host page that wants .ifcfed can parse
the JSON and drive these calls.

Models are keyed by the JS source id rather than the session model id. The
source id exists the moment a File or URL is registered, whereas the session id
is minted inside the async range-read chain, so keying on it lets a transform be
set before the model has streamed and applied when it arrives — the model never
visibly jumps. loadSidecarMetadataWeb gained a completion callback to carry that
id back out, and addFile/addUrl now return the source id and fire onModelLoaded,
where before they were fire-and-forget with no handle and no completion signal.

The embedded sample bypasses the source registry, so it is bound separately;
otherwise the guess never runs for a page that only ever shows the sample.

georef-a and georef-b are the regression fixture: two boxes whose different map
conversions resolve to the same real-world point, so a viewer that applies them
draws one box's worth of scene and one that ignores them spans 707 m. They carry
two meshes each because reorderSidecarByMorton bails out below two and then
writes no chunk table, and a sidecar without one cannot stream over byte ranges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-11 16:40:40 +10:00
parent 935562142e
commit b252cd25f8
11 changed files with 999 additions and 17 deletions
+125 -11
View File
@@ -6,8 +6,16 @@ CMakeLists.txt) so every example page renders something before the user picks a
file. This script is how that fixture is produced, so it can be regenerated
rather than being an opaque committed blob:
python3 make_sample.py # writes sample.ifc + sample.ifcview
ninja -C ../../build-web # re-embeds it
python3 make_sample.py # writes sample.* and georef-a/b.*
ninja -C ../../build-web # re-embeds sample.ifcview
Also authors georef-a and georef-b: a pair that pins the federation behaviour.
The two carry DIFFERENT IfcMapConversions over DIFFERENT local coordinates,
chosen so both resolve to the same real-world point. A viewer that applies each
model's coordinate operation draws them on top of each other; one that ignores
it (as the web viewer did before it seeded georef from the sidecar) draws them
~707 m apart. They are small single-box models — the assertion is about where
they land, not what they look like.
It needs ifcopenshell (Python) to author the IFC, and the sidecar_bake tool from
the desktop build to convert it:
@@ -20,6 +28,7 @@ time, so coincident geometry would make those calls look like no-ops: whatever
you hid would still be there, drawn by the object behind it.
"""
import os
import shutil
import subprocess
import sys
@@ -28,6 +37,7 @@ from pathlib import Path
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.georeference
import ifcopenshell.api.geometry
import ifcopenshell.api.project
import ifcopenshell.api.root
@@ -35,7 +45,10 @@ import ifcopenshell.api.spatial
import ifcopenshell.api.unit
HERE = Path(__file__).parent
BAKE = HERE / "../../build-viewer/ifcviewer/sidecar_bake"
# The sidecar baker. Override with SIDECAR_BAKE when it lives outside the
# desktop build tree — there is currently no in-tree `sidecar_bake` target, so
# the default below only resolves if one has been added locally.
BAKE = Path(os.environ.get("SIDECAR_BAKE", HERE / "../../build-viewer/ifcviewer/sidecar_bake"))
# (class, name, footprint w x d in m, height in m, placement x/y/z in m)
ELEMENTS = [
@@ -98,20 +111,121 @@ def build_ifc(path: Path) -> None:
f.write(str(path))
def main() -> int:
if not BAKE.exists():
sys.exit(f"{BAKE} not found — build it with: ninja -C ../../build-viewer sidecar_bake")
# Two models that must coincide once each one's IfcMapConversion is applied.
#
# With no grid rotation and unit scale 1, the coordinate operation is a pure
# translation, so global = local + (eastings, northings, height). Both entries
# below sum to the same global point:
#
# a: local (0, 0) + (1000, 2000) = (1000, 2000)
# b: local (500, 500) + ( 500, 1500) = (1000, 2000)
#
# The local offset between them (~707 m) is the error a viewer shows when it
# ignores the coordinate operation, which is far larger than the 2 m boxes —
# so the test cannot pass by accident.
GEOREF_MODELS = [
("georef-a", (0.0, 0.0), (1000.0, 2000.0)),
("georef-b", (500.0, 500.0), (500.0, 1500.0)),
]
ifc = HERE / "sample.ifc"
build_ifc(ifc)
def build_georef_ifc(path: Path, local_xy: tuple, map_en: tuple) -> None:
"""One 2 m box at `local_xy`, georeferenced by `map_en` eastings/northings."""
f = ifcopenshell.api.project.create_file(version="IFC4")
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name=path.stem)
ifcopenshell.api.unit.assign_unit(f, length={"is_metric": True, "raw": "METERS"})
body = ifcopenshell.api.context.add_context(f, context_type="Model")
body = ifcopenshell.api.context.add_context(
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=body
)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="Site")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=site)
ifcopenshell.api.georeference.add_georeferencing(f)
ifcopenshell.api.georeference.edit_georeferencing(
f,
projected_crs={"Name": "EPSG:3857"},
coordinate_operation={
"Eastings": map_en[0],
"Northings": map_en[1],
"OrthogonalHeight": 0.0,
# Grid north == project north, and map units == project units, so
# the operation reduces to the pure translation described above.
"XAxisAbscissa": 1.0,
"XAxisOrdinate": 0.0,
"Scale": 1.0,
},
)
# TWO boxes, deliberately different sizes so they are two distinct meshes.
# reorderSidecarByMorton bails out at fewer than two meshes and then writes
# no chunk table at all, and a sidecar with no chunk table can never stream
# over the web byte-range path — the loader has no locator to fetch with.
# A one-box model here would load and simply never draw.
for i, (w, d) in enumerate([(2.0, 2.0), (1.0, 3.0)]):
element = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name=f"Box{i}")
ifcopenshell.api.spatial.assign_container(f, products=[element], relating_structure=storey)
profile = f.create_entity(
"IfcRectangleProfileDef",
ProfileType="AREA",
XDim=w,
YDim=d,
Position=f.create_entity(
"IfcAxis2Placement2D", Location=f.create_entity("IfcCartesianPoint", Coordinates=(w / 2, d / 2))
),
)
solid = f.create_entity(
"IfcExtrudedAreaSolid",
SweptArea=profile,
Depth=2.0,
Position=f.create_entity(
"IfcAxis2Placement3D", Location=f.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
),
ExtrudedDirection=f.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
)
representation = f.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body,
RepresentationIdentifier="Body",
RepresentationType="SweptSolid",
Items=[solid],
)
ifcopenshell.api.geometry.assign_representation(f, product=element, representation=representation)
ifcopenshell.api.geometry.edit_object_placement(
f,
product=element,
matrix=ifcopenshell.util.placement.a2p(
(local_xy[0] + i * 3.0, local_xy[1], 0.0), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0)
),
)
f.write(str(path))
def bake(ifc: Path, out: Path) -> None:
subprocess.run([str(BAKE), str(ifc)], check=True)
# sidecar_bake writes <name>.ifcview next to the input.
baked = ifc.with_suffix(".ifcview")
if not baked.exists():
sys.exit(f"sidecar_bake did not produce {baked}")
shutil.move(baked, HERE / "sample.ifcview")
print(f"wrote {HERE / 'sample.ifcview'}")
shutil.move(baked, out)
print(f"wrote {out}")
def main() -> int:
if not BAKE.exists():
sys.exit(f"{BAKE} not found — point SIDECAR_BAKE at a sidecar_bake binary")
ifc = HERE / "sample.ifc"
build_ifc(ifc)
bake(ifc, HERE / "sample.ifcview")
for stem, local_xy, map_en in GEOREF_MODELS:
georef_ifc = HERE / f"{stem}.ifc"
build_georef_ifc(georef_ifc, local_xy, map_en)
bake(georef_ifc, HERE / f"{stem}.ifcview")
return 0