mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-19 19:54:07 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user