Files
IfcOpenShell/src/ifcviewer-web/make_sample.py
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

234 lines
9.9 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Author the demo model the web viewer embeds (sample.ifcview).
The web build bakes one small sidecar into MEMFS (--embed-file in
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.* 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:
ninja -C ../../build-viewer sidecar_bake
Three elements — a slab, a wall and a beam — deliberately in DIFFERENT places
and different shapes. The scripting examples hide and re-colour objects one at a
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
from pathlib import Path
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
2026-08-14 10:17:02 +05:00
import ifcopenshell.api.georeference
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
HERE = Path(__file__).parent
# 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 = [
2026-07-26 18:03:09 +10:00
("IfcSlab", "Slab", (6.0, 6.0), 0.2, (-3.0, -3.0, 0.0)),
("IfcWall", "Wall", (5.0, 0.3), 3.0, (-2.5, -2.5, 0.2)),
("IfcBeam", "Beam", (0.3, 5.0), 0.3, (2.0, -2.5, 3.2)),
]
def build_ifc(path: Path) -> None:
f = ifcopenshell.api.project.create_file(version="IFC4")
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="Web viewer sample")
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(
2026-07-26 18:03:09 +10:00
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)
for ifc_class, name, (w, d), height, (x, y, z) in ELEMENTS:
element = ifcopenshell.api.root.create_entity(f, ifc_class=ifc_class, name=name)
2026-07-26 18:03:09 +10:00
ifcopenshell.api.spatial.assign_container(f, products=[element], relating_structure=storey)
# A rectangular profile extruded up — enough to be a recognisable,
# distinctly-placed solid without dragging in a whole modelling stack.
profile = f.create_entity(
2026-07-26 18:03:09 +10:00
"IfcRectangleProfileDef",
ProfileType="AREA",
XDim=w,
YDim=d,
Position=f.create_entity(
2026-07-26 18:03:09 +10:00
"IfcAxis2Placement2D", Location=f.create_entity("IfcCartesianPoint", Coordinates=(w / 2, d / 2))
),
)
solid = f.create_entity(
2026-07-26 18:03:09 +10:00
"IfcExtrudedAreaSolid",
SweptArea=profile,
Depth=height,
Position=f.create_entity(
2026-07-26 18:03:09 +10:00
"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(
2026-07-26 18:03:09 +10:00
"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(
2026-07-26 18:03:09 +10:00
f, product=element, matrix=ifcopenshell.util.placement.a2p((x, y, z), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0))
)
f.write(str(path))
# 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)),
]
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, 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
if __name__ == "__main__":
raise SystemExit(main())