diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt
index 21aa7dbb25..357ee84d5d 100644
--- a/src/ifcviewer-web/CMakeLists.txt
+++ b/src/ifcviewer-web/CMakeLists.txt
@@ -77,6 +77,7 @@ add_subdirectory(${IFCVIEWER_DIR} ifcviewer EXCLUDE_FROM_ALL)
add_executable(IfcViewerWeb
main_web.cpp
WebViewportHost.cpp
+ WebFederation.cpp
WebViewportHost.h
)
target_link_libraries(IfcViewerWeb PRIVATE IfcViewerCore)
@@ -114,13 +115,13 @@ target_link_options(IfcViewerWeb PRIVATE
# EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't
# add them to Module. ccall lets the host page (web/ifcviewer.js) pass a JS string (the ?model
# URL) to load_sidecar_from_url_c without manual heap marshalling.
- "-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_set_background_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c']"
+ "-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_set_background_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c','_ifcv_set_federation_unit_c','_ifcv_set_false_origin_c','_ifcv_get_false_origin_c','_ifcv_set_model_transform_c','_ifcv_clear_model_transform_c','_ifcv_set_model_name_c','_ifcv_get_model_georef_c']"
# ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c,
# and the nav-preset name to ifcv_set_nav_preset_c.
# HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large
# sidecar streams by range instead of loading whole). HEAPU32/HEAPF32: the
# scripting API marshals object-id arrays and the camera state through them.
- "-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8','HEAPU32','HEAPF32','UTF8ToString']"
+ "-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8','HEAPU32','HEAPF32','HEAPF64','UTF8ToString']"
# Streaming + chunked geometry want a heap that can grow as buffers
# arrive. 256 MB initial, 2 GB ceiling (matches the wasm32 pointer
# cap; --shared64 / MEMORY64 would lift this later if we need it).
diff --git a/src/ifcviewer-web/WebFederation.cpp b/src/ifcviewer-web/WebFederation.cpp
new file mode 100644
index 0000000000..1732ddee69
--- /dev/null
+++ b/src/ifcviewer-web/WebFederation.cpp
@@ -0,0 +1,151 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#include "WebFederation.h"
+
+#include "Log.h"
+#include "ViewportCore.h"
+
+void WebFederation::setConfig(const FederationConfig& cfg) {
+ config_ = cfg;
+ // The unit is the value space for the false origin and every model
+ // transform, so changing it re-scales both.
+ applyFalseOrigin();
+ for (const auto& [source_id, state] : models_) {
+ if (state.has_transformation) applyModelTransformation(source_id);
+ }
+}
+
+void WebFederation::setFalseOrigin(const FederatedFalseOrigin& origin) {
+ false_origin_ = origin;
+ false_origin_explicit_ = true;
+ applyFalseOrigin();
+}
+
+void WebFederation::setModelTransformation(int source_id, const ModelTransformation& xf) {
+ ModelState& state = models_[source_id];
+ state.transformation = xf;
+ state.has_transformation = true;
+ applyModelTransformation(source_id);
+}
+
+void WebFederation::clearModelTransformation(int source_id) {
+ auto it = models_.find(source_id);
+ if (it == models_.end() || !it->second.has_transformation) return;
+ it->second.has_transformation = false;
+ it->second.transformation = ModelTransformation{};
+ if (it->second.session_model_id != 0) {
+ core_.setModelTransformation(it->second.session_model_id,
+ Eigen::Matrix4d::Identity());
+ }
+}
+
+void WebFederation::setModelName(int source_id, std::string name) {
+ models_[source_id].name = std::move(name);
+}
+
+std::string WebFederation::modelName(int source_id) const {
+ auto it = models_.find(source_id);
+ return it == models_.end() ? std::string() : it->second.name;
+}
+
+std::uint32_t WebFederation::sessionModelId(int source_id) const {
+ auto it = models_.find(source_id);
+ return it == models_.end() ? 0u : it->second.session_model_id;
+}
+
+void WebFederation::onModelLoaded(int source_id, std::uint32_t session_model_id) {
+ ModelState& state = models_[source_id];
+ state.session_model_id = session_model_id;
+
+ // Guess before applying the transform: the guess reads the model's raw
+ // geometry position, and the transform is composed against the origin the
+ // guess produces. Doing it the other way round would compose against the
+ // identity origin and then need a second recompose.
+ if (!false_origin_explicit_ && !guessed_false_origin_) {
+ guessFalseOriginFrom(session_model_id);
+ }
+ if (state.has_transformation) applyModelTransformation(source_id);
+}
+
+void WebFederation::onModelLoadedWithoutSource(std::uint32_t session_model_id) {
+ if (!false_origin_explicit_ && !guessed_false_origin_) {
+ guessFalseOriginFrom(session_model_id);
+ }
+}
+
+void WebFederation::clear() {
+ models_.clear();
+ config_ = FederationConfig{};
+ false_origin_ = FederatedFalseOrigin{};
+ false_origin_explicit_ = false;
+ guessed_false_origin_ = false;
+ core_.setFederatedFalseOrigin(Eigen::Matrix4d::Identity());
+}
+
+void WebFederation::applyFalseOrigin() {
+ core_.setFederatedFalseOrigin(composeFederatedFalseOrigin(false_origin_, config_));
+}
+
+void WebFederation::applyModelTransformation(int source_id) {
+ auto it = models_.find(source_id);
+ if (it == models_.end() || !it->second.has_transformation) return;
+ const std::uint32_t session_model_id = it->second.session_model_id;
+ if (session_model_id == 0) return; // staged; applied from onModelLoaded
+
+ // ModelTransformation::a may be authored in the model's pre-CoordinateOperation
+ // frame, so composing needs the model's units and CoordinateOperation. Both
+ // come from the sidecar, which is what makes this work with no IFC present.
+ ModelGeoref georef;
+ if (!core_.modelGeoref(session_model_id, georef)) return;
+
+ core_.setModelTransformation(
+ session_model_id,
+ composeModelTransformation(it->second.transformation, config_,
+ georef.units,
+ georef.coordinate_operation_meters));
+}
+
+void WebFederation::guessFalseOriginFrom(std::uint32_t session_model_id) {
+ Eigen::Vector3d first_geometry_point_m;
+ if (!core_.firstGeometryPointWorldM(session_model_id, first_geometry_point_m)) return;
+
+ ModelGeoref georef;
+ if (!core_.modelGeoref(session_model_id, georef)) return;
+
+ // Why this is on by default rather than opt-in: the composed per-instance
+ // transform is float32 (InstanceInfo::transform). Now that applyCachedModel
+ // seeds the CoordinateOperation, an un-shifted georeferenced model renders
+ // at its surveyor coordinates, where float precision is ~0.5 m per ULP
+ // around 6e6 m — visibly worse than the local-coordinates behaviour this
+ // replaced. Resolving to global coordinates only makes sense together with
+ // an origin shift.
+ false_origin_ = guessFederatedFalseOrigin(first_geometry_point_m, georef, config_);
+ guessed_false_origin_ = true;
+ applyFalseOrigin();
+
+ // applyCachedModel's auto-viewAll framed the camera against the pre-shift
+ // position; the recompose above moved every instance, so re-frame.
+ core_.viewAll();
+
+ Log::info().nospace()
+ << "web federation: guessed false origin ("
+ << false_origin_.xyz.x() << ", " << false_origin_.xyz.y() << ", "
+ << false_origin_.xyz.z() << ") rz=" << false_origin_.rz_deg << "deg";
+}
diff --git a/src/ifcviewer-web/WebFederation.h b/src/ifcviewer-web/WebFederation.h
new file mode 100644
index 0000000000..04c439c7f6
--- /dev/null
+++ b/src/ifcviewer-web/WebFederation.h
@@ -0,0 +1,118 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#ifndef WEBFEDERATION_H
+#define WEBFEDERATION_H
+
+#include "FederationMath.h"
+
+#include
+#include
+#include
+
+class ViewportCore;
+
+// Federation state for the web viewer: the concepts a host page needs to place
+// several models in one scene — a federation unit, a false origin, and a
+// per-model transform and display name.
+//
+// Deliberately NOT an .ifcfed reader. The desktop Federation class is a
+// document model (QObject, JSON persistence, groups, cloud manifests) and its
+// model sources are local filesystem paths, which mean nothing in a browser.
+// What the web needs is the underlying concepts, exposed so JS can drive them;
+// a host page that wants .ifcfed can parse the JSON itself and call these.
+//
+// Models are keyed by the JS-side source id, NOT the session model id. The
+// source id exists synchronously the moment a File or URL is registered,
+// whereas the session model id is minted deep inside the async range-read
+// chain. Keying on the source id is what lets a caller set a transform BEFORE
+// the load finishes, so the model appears already in place instead of jumping
+// once its state is applied afterwards.
+class WebFederation {
+public:
+ explicit WebFederation(ViewportCore& core) : core_(core) {}
+
+ // ---- Federation-wide -----------------------------------------------
+
+ void setConfig(const FederationConfig& cfg);
+ const FederationConfig& config() const { return config_; }
+
+ // Marks the origin as explicitly authored, which suppresses the automatic
+ // guess in onModelLoaded. A host that sets an origin means it.
+ void setFalseOrigin(const FederatedFalseOrigin& origin);
+ const FederatedFalseOrigin& falseOrigin() const { return false_origin_; }
+ bool falseOriginIsExplicit() const { return false_origin_explicit_; }
+
+ // ---- Per-model -------------------------------------------------------
+
+ void setModelTransformation(int source_id, const ModelTransformation& xf);
+ void clearModelTransformation(int source_id);
+ void setModelName(int source_id, std::string name);
+ std::string modelName(int source_id) const;
+
+ // Session model id for a source, or 0 when that source has not finished
+ // loading. Session ids start at 1, so 0 is unambiguous.
+ std::uint32_t sessionModelId(int source_id) const;
+
+ // ---- Lifecycle -------------------------------------------------------
+
+ // Call when a sidecar load completes. Binds the source to its session model
+ // id, applies whatever state was staged against the source id, and — for
+ // the first model only, and only when no origin was set explicitly — runs
+ // the false-origin guess.
+ void onModelLoaded(int source_id, std::uint32_t session_model_id);
+
+ // A model that did not come from a JS source — the embedded sample, read
+ // synchronously from MEMFS. It has no source id and so cannot carry a
+ // per-model transform (a host that wants to place a model adds it as a
+ // source), but it must still take part in the false-origin guess.
+ // Otherwise a page showing the sample renders it unshifted, which for a
+ // georeferenced model means out at its surveyor coordinates.
+ void onModelLoadedWithoutSource(std::uint32_t session_model_id);
+
+ // Drop all state. Pairs with ViewportCore::resetScene so a fresh
+ // federation does not inherit the previous one's origin or transforms.
+ void clear();
+
+private:
+ // Push the composed false-origin matrix; every model recomposes against it.
+ void applyFalseOrigin();
+ // Push one model's composed transform. No-op until the model has loaded,
+ // since composing needs its georef.
+ void applyModelTransformation(int source_id);
+ // Position + grid-north heading that puts the first model near the origin
+ // instead of out at its surveyor coordinates.
+ void guessFalseOriginFrom(std::uint32_t session_model_id);
+
+ struct ModelState {
+ std::uint32_t session_model_id = 0; // 0 until loaded
+ std::string name;
+ ModelTransformation transformation;
+ bool has_transformation = false;
+ };
+
+ ViewportCore& core_;
+ FederationConfig config_;
+ FederatedFalseOrigin false_origin_;
+ bool false_origin_explicit_ = false;
+ bool guessed_false_origin_ = false;
+ std::unordered_map models_;
+};
+
+#endif // WEBFEDERATION_H
diff --git a/src/ifcviewer-web/georef-a.ifc b/src/ifcviewer-web/georef-a.ifc
new file mode 100644
index 0000000000..6453ac09b2
--- /dev/null
+++ b/src/ifcviewer-web/georef-a.ifc
@@ -0,0 +1,57 @@
+ISO-10303-21;
+HEADER;
+FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1');
+FILE_NAME('/dev/null','2026-08-11T14:35:24+10:00',(''),(''),'IfcOpenShell 0.0.0','IfcOpenShell 0.0.0','Nobody');
+FILE_SCHEMA(('IFC4'));
+ENDSEC;
+DATA;
+#1=IFCPROJECT('1SkyF2B$PEf9SMqG9FODRV',$,'georef-a',$,$,$,$,(#10),#5);
+#2=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
+#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
+#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
+#5=IFCUNITASSIGNMENT((#2,#3,#4));
+#6=IFCCARTESIANPOINT((0.,0.,0.));
+#7=IFCDIRECTION((0.,0.,1.));
+#8=IFCDIRECTION((1.,0.,0.));
+#9=IFCAXIS2PLACEMENT3D(#6,#7,#8);
+#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$);
+#11=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
+#12=IFCSITE('3ffnKCUKX9CAjIqUaYTnpN',$,'Site',$,$,$,$,$,$,$,$,$,$,$);
+#13=IFCBUILDINGSTOREY('0vmAlhGIXA5fdyxJzjqpjG',$,'Ground floor',$,$,$,$,$,$,$);
+#14=IFCRELAGGREGATES('31y14pu714iBKWvJVP79eH',$,$,$,#1,(#12));
+#15=IFCRELAGGREGATES('3cdPhHGwvCEh4FHakbwCZM',$,$,$,#12,(#13));
+#16=IFCPROJECTEDCRS('EPSG:3857',$,$,$,$,$,$);
+#17=IFCMAPCONVERSION(#10,#16,1000.,2000.,0.,1.,0.,1.);
+#18=IFCWALL('13r0IXtWf5pf18Q1EGzHXl',$,'Box0',$,$,#33,#28,$,$);
+#19=IFCRELCONTAINEDINSPATIALSTRUCTURE('2IeOMcfczBTxTvNDbBkz_N',$,$,$,(#18,#34),#13);
+#20=IFCCARTESIANPOINT((1.,1.));
+#21=IFCAXIS2PLACEMENT2D(#20,$);
+#22=IFCRECTANGLEPROFILEDEF(.AREA.,$,#21,2.,2.);
+#23=IFCCARTESIANPOINT((0.,0.,0.));
+#24=IFCAXIS2PLACEMENT3D(#23,$,$);
+#25=IFCDIRECTION((0.,0.,1.));
+#26=IFCEXTRUDEDAREASOLID(#22,#24,#25,2.);
+#27=IFCSHAPEREPRESENTATION(#11,'Body','SweptSolid',(#26));
+#28=IFCPRODUCTDEFINITIONSHAPE($,$,(#27));
+#29=IFCCARTESIANPOINT((0.,0.,0.));
+#30=IFCDIRECTION((0.,0.,1.));
+#31=IFCDIRECTION((1.,0.,0.));
+#32=IFCAXIS2PLACEMENT3D(#29,#30,#31);
+#33=IFCLOCALPLACEMENT($,#32);
+#34=IFCWALL('22CLYZYiz8ZhbpLaYDVIu6',$,'Box1',$,$,#48,#43,$,$);
+#35=IFCCARTESIANPOINT((0.5,1.5));
+#36=IFCAXIS2PLACEMENT2D(#35,$);
+#37=IFCRECTANGLEPROFILEDEF(.AREA.,$,#36,1.,3.);
+#38=IFCCARTESIANPOINT((0.,0.,0.));
+#39=IFCAXIS2PLACEMENT3D(#38,$,$);
+#40=IFCDIRECTION((0.,0.,1.));
+#41=IFCEXTRUDEDAREASOLID(#37,#39,#40,2.);
+#42=IFCSHAPEREPRESENTATION(#11,'Body','SweptSolid',(#41));
+#43=IFCPRODUCTDEFINITIONSHAPE($,$,(#42));
+#44=IFCCARTESIANPOINT((3.,0.,0.));
+#45=IFCDIRECTION((0.,0.,1.));
+#46=IFCDIRECTION((1.,0.,0.));
+#47=IFCAXIS2PLACEMENT3D(#44,#45,#46);
+#48=IFCLOCALPLACEMENT($,#47);
+ENDSEC;
+END-ISO-10303-21;
diff --git a/src/ifcviewer-web/georef-a.ifcview b/src/ifcviewer-web/georef-a.ifcview
new file mode 100644
index 0000000000..0e809d48de
Binary files /dev/null and b/src/ifcviewer-web/georef-a.ifcview differ
diff --git a/src/ifcviewer-web/georef-b.ifc b/src/ifcviewer-web/georef-b.ifc
new file mode 100644
index 0000000000..c3ffeaf1b8
--- /dev/null
+++ b/src/ifcviewer-web/georef-b.ifc
@@ -0,0 +1,57 @@
+ISO-10303-21;
+HEADER;
+FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1');
+FILE_NAME('/dev/null','2026-08-11T14:35:24+10:00',(''),(''),'IfcOpenShell 0.0.0','IfcOpenShell 0.0.0','Nobody');
+FILE_SCHEMA(('IFC4'));
+ENDSEC;
+DATA;
+#1=IFCPROJECT('3kMv1xc2D0zfGhuD7FJc7A',$,'georef-b',$,$,$,$,(#10),#5);
+#2=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
+#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
+#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
+#5=IFCUNITASSIGNMENT((#3,#4,#2));
+#6=IFCCARTESIANPOINT((0.,0.,0.));
+#7=IFCDIRECTION((0.,0.,1.));
+#8=IFCDIRECTION((1.,0.,0.));
+#9=IFCAXIS2PLACEMENT3D(#6,#7,#8);
+#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$);
+#11=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
+#12=IFCSITE('1lVkZeYvf3nx2tYgLv$SdT',$,'Site',$,$,$,$,$,$,$,$,$,$,$);
+#13=IFCBUILDINGSTOREY('0iiAS1DBn6v8EGOBJWSqqC',$,'Ground floor',$,$,$,$,$,$,$);
+#14=IFCRELAGGREGATES('2sBRiMcXD79vM7P_wYIIbN',$,$,$,#1,(#12));
+#15=IFCRELAGGREGATES('3vHijxTAnBuPl$j8x_TN2C',$,$,$,#12,(#13));
+#16=IFCPROJECTEDCRS('EPSG:3857',$,$,$,$,$,$);
+#17=IFCMAPCONVERSION(#10,#16,500.,1500.,0.,1.,0.,1.);
+#18=IFCWALL('3DkP2KRu5AIRxhhAz$DcQH',$,'Box0',$,$,#33,#28,$,$);
+#19=IFCRELCONTAINEDINSPATIALSTRUCTURE('24zNOk3lz7IgIjAa82Bhqb',$,$,$,(#34,#18),#13);
+#20=IFCCARTESIANPOINT((1.,1.));
+#21=IFCAXIS2PLACEMENT2D(#20,$);
+#22=IFCRECTANGLEPROFILEDEF(.AREA.,$,#21,2.,2.);
+#23=IFCCARTESIANPOINT((0.,0.,0.));
+#24=IFCAXIS2PLACEMENT3D(#23,$,$);
+#25=IFCDIRECTION((0.,0.,1.));
+#26=IFCEXTRUDEDAREASOLID(#22,#24,#25,2.);
+#27=IFCSHAPEREPRESENTATION(#11,'Body','SweptSolid',(#26));
+#28=IFCPRODUCTDEFINITIONSHAPE($,$,(#27));
+#29=IFCCARTESIANPOINT((500.,500.,0.));
+#30=IFCDIRECTION((0.,0.,1.));
+#31=IFCDIRECTION((1.,0.,0.));
+#32=IFCAXIS2PLACEMENT3D(#29,#30,#31);
+#33=IFCLOCALPLACEMENT($,#32);
+#34=IFCWALL('2ueyz_jIr2QgMKs4v0fWl2',$,'Box1',$,$,#48,#43,$,$);
+#35=IFCCARTESIANPOINT((0.5,1.5));
+#36=IFCAXIS2PLACEMENT2D(#35,$);
+#37=IFCRECTANGLEPROFILEDEF(.AREA.,$,#36,1.,3.);
+#38=IFCCARTESIANPOINT((0.,0.,0.));
+#39=IFCAXIS2PLACEMENT3D(#38,$,$);
+#40=IFCDIRECTION((0.,0.,1.));
+#41=IFCEXTRUDEDAREASOLID(#37,#39,#40,2.);
+#42=IFCSHAPEREPRESENTATION(#11,'Body','SweptSolid',(#41));
+#43=IFCPRODUCTDEFINITIONSHAPE($,$,(#42));
+#44=IFCCARTESIANPOINT((503.,500.,0.));
+#45=IFCDIRECTION((0.,0.,1.));
+#46=IFCDIRECTION((1.,0.,0.));
+#47=IFCAXIS2PLACEMENT3D(#44,#45,#46);
+#48=IFCLOCALPLACEMENT($,#47);
+ENDSEC;
+END-ISO-10303-21;
diff --git a/src/ifcviewer-web/georef-b.ifcview b/src/ifcviewer-web/georef-b.ifcview
new file mode 100644
index 0000000000..76eeb02ed0
Binary files /dev/null and b/src/ifcviewer-web/georef-b.ifcview differ
diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp
index 1fe1e4f7df..ebf57d2f4c 100644
--- a/src/ifcviewer-web/main_web.cpp
+++ b/src/ifcviewer-web/main_web.cpp
@@ -30,6 +30,7 @@
#include "ViewportCore.h"
#include "WebViewportHost.h"
+#include "WebFederation.h"
#include "Log.h"
#include
@@ -57,6 +58,9 @@ enum class NavKind { None, Orbit, Pan, Select };
struct AppState {
WebViewportHost host{ kCanvasSelector };
ViewportCore core{ &host };
+ // Federation concepts (unit, false origin, per-model transforms) that the
+ // host page drives via the ifcv_federation_* exports below.
+ WebFederation federation{ core };
int last_w = 0;
int last_h = 0;
// Set true by the init callback once the device + pipelines are up.
@@ -545,7 +549,23 @@ extern "C" EMSCRIPTEN_KEEPALIVE void raf_tick_c(void* user) {
// completion callback. Call clear_scene_c first to replace instead of append.
extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_source_c(int source_id) {
if (!g_app || !g_app->ready) return;
- g_app->core.loadSidecarMetadataWeb(source_id, "source");
+ // Label the model with whatever name the host page set for this source, so
+ // logs identify it rather than saying "source" five times over.
+ std::string label = g_app->federation.modelName(source_id);
+ if (label.empty()) label = "source " + std::to_string(source_id);
+
+ g_app->core.loadSidecarMetadataWeb(source_id, std::move(label),
+ [source_id](std::uint32_t session_model_id) {
+ if (!g_app) return;
+ // Binds source -> session model, applies any transform staged
+ // before the load finished, and guesses the false origin off the
+ // first model. Only then tell JS, so a handler that reacts sees a
+ // fully placed model.
+ g_app->federation.onModelLoaded(source_id, session_model_id);
+ EM_ASM({
+ if (Module.__ifcvOnModelLoaded) Module.__ifcvOnModelLoaded($0, $1);
+ }, source_id, int(session_model_id));
+ });
}
// Drop all loaded models (used by the host page (web/ifcviewer.js) to replace the embedded sample /
@@ -553,6 +573,9 @@ extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_source_c(int source_id) {
extern "C" EMSCRIPTEN_KEEPALIVE void clear_scene_c() {
if (!g_app || !g_app->ready) return;
g_app->core.resetScene();
+ // Source ids are re-minted from zero by the host page's next registration
+ // pass, so stale per-source transforms would land on the wrong models.
+ g_app->federation.clear();
}
// Viewport-navigation entry points for the the host page (web/ifcviewer.js) toolbar (buttons that
@@ -692,6 +715,95 @@ extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_clear_colors_c() {
if (g_app && g_app->ready) g_app->core.clearObjectColors();
}
+// ---- Federation ---------------------------------------------------------
+//
+// The concepts an .ifcfed carries, minus the file format: a federation unit, a
+// false origin, and a per-model transform + display name. A host page that
+// wants to read .ifcfed JSON (or a cloud manifest) parses it in JS and drives
+// these. Models are addressed by the JS source id — the value registered
+// before loading — so a transform can be set before the model has streamed.
+//
+// Angles are degrees, xyz/b/pivot are in the federation unit and `a` is in the
+// model's project or map unit depending on a_frame, matching the desktop
+// authoring model exactly (see FederationMath.h).
+
+// Federation unit, e.g. ("METRE","") or ("foot",""), or ("METRE","MILLI").
+extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_federation_unit_c(const char* name,
+ const char* prefix) {
+ if (!g_app || !name) return;
+ FederationConfig cfg;
+ cfg.unit_name = name;
+ cfg.unit_prefix = prefix ? prefix : "";
+ g_app->federation.setConfig(cfg);
+}
+
+// Nominate xyz (federation unit) as the origin, with an optional grid-north
+// heading. Setting this suppresses the automatic first-model guess.
+extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_false_origin_c(double x, double y, double z,
+ double rz_deg) {
+ if (!g_app) return;
+ FederatedFalseOrigin origin;
+ origin.xyz = Eigen::Vector3d(x, y, z);
+ origin.rz_deg = rz_deg;
+ g_app->federation.setFalseOrigin(origin);
+}
+
+// Reads back the active origin — including one the guess produced — as
+// [x, y, z, rz_deg, explicit]. `explicit` is 1 when a host set it.
+extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_get_false_origin_c(double* out) {
+ if (!g_app || !out) return;
+ const FederatedFalseOrigin& o = g_app->federation.falseOrigin();
+ out[0] = o.xyz.x(); out[1] = o.xyz.y(); out[2] = o.xyz.z();
+ out[3] = o.rz_deg;
+ out[4] = g_app->federation.falseOriginIsExplicit() ? 1.0 : 0.0;
+}
+
+// "Rotate about pivot, then translate so point a lands on point b."
+// a_frame: 0 = ModelLocal (a is pre-CoordinateOperation, project units),
+// 1 = ModelGlobal (a is post-CoordinateOperation, map units).
+extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_model_transform_c(
+ int source_id, int a_frame,
+ double ax, double ay, double az,
+ double bx, double by, double bz,
+ double rx, double ry, double rz,
+ double px, double py, double pz) {
+ if (!g_app) return;
+ ModelTransformation xf;
+ xf.a_frame = (a_frame == 0) ? AFrame::ModelLocal : AFrame::ModelGlobal;
+ xf.a = Eigen::Vector3d(ax, ay, az);
+ xf.b = Eigen::Vector3d(bx, by, bz);
+ xf.rxyz_deg = Eigen::Vector3d(rx, ry, rz);
+ xf.pivot = Eigen::Vector3d(px, py, pz);
+ g_app->federation.setModelTransformation(source_id, xf);
+}
+
+extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_clear_model_transform_c(int source_id) {
+ if (g_app) g_app->federation.clearModelTransformation(source_id);
+}
+
+extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_model_name_c(int source_id, const char* name) {
+ if (g_app && name) g_app->federation.setModelName(source_id, name);
+}
+
+// The model's CoordinateOperation as baked into its sidecar, so a host can see
+// what georeferencing a model actually carries: out[0] is 1 when the model has
+// one, out[1..16] the 4x4 in metres (column-major), out[17] the project length
+// unit scale and out[18] the map unit scale. Returns 0 when the source has not
+// finished loading.
+extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_get_model_georef_c(int source_id, double* out) {
+ if (!g_app || !out) return 0;
+ const std::uint32_t session_model_id = g_app->federation.sessionModelId(source_id);
+ if (session_model_id == 0) return 0;
+ ModelGeoref georef;
+ if (!g_app->core.modelGeoref(session_model_id, georef)) return 0;
+ out[0] = georef.has_coordinate_operation ? 1.0 : 0.0;
+ const Eigen::Matrix4d& m = georef.coordinate_operation_meters;
+ for (int i = 0; i < 16; ++i) out[1 + i] = m.data()[i];
+ out[17] = georef.units.project_length_to_meters;
+ out[18] = georef.units.map_unit_to_meters;
+ return 1;
+}
+
// Every object in the scene, as JSON. Asynchronous: the element tables are
// fetched lazily per model on web (first paint must not wait on them), so this
// makes sure they are all resident and only then hands the page its array via
@@ -832,7 +944,12 @@ int main(int /*argc*/, char** /*argv*/) {
// synchronous MEMFS read; user-picked files go through the
// Blob.slice byte-range path (load_sidecar_from_blob_c) so large
// sidecars never enter the wasm heap.
- if (!g_app->core.loadSidecarFromPath("/sample.ifcview")) {
+ if (const std::uint32_t sample_id = g_app->core.loadSidecarFromPath("/sample.ifcview")) {
+ // Bypasses the source registry, so tell the federation directly —
+ // otherwise the first-model false-origin guess never runs for a
+ // page that only ever shows the sample.
+ g_app->federation.onModelLoadedWithoutSource(sample_id);
+ } else {
Log::warn() << "ifcviewer-web: sample sidecar load failed";
}
diff --git a/src/ifcviewer-web/make_sample.py b/src/ifcviewer-web/make_sample.py
index 5442ca3415..37fb4f4d4e 100644
--- a/src/ifcviewer-web/make_sample.py
+++ b/src/ifcviewer-web/make_sample.py
@@ -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 .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
diff --git a/src/ifcviewer-web/tests/federation.spec.mjs b/src/ifcviewer-web/tests/federation.spec.mjs
new file mode 100644
index 0000000000..748517323a
--- /dev/null
+++ b/src/ifcviewer-web/tests/federation.spec.mjs
@@ -0,0 +1,239 @@
+import { test, expect } from '@playwright/test';
+
+// The federation half of the scripting API: false origin, per-model transform,
+// and the model-loaded event.
+//
+// Why this exists: the web viewer used to render every model in its own local
+// coordinates, because nothing applied a model's IfcCoordinateOperation. Two
+// federated models whose map conversions resolve to the same real-world point
+// came out misaligned. Models now resolve to global coordinates on load, and
+// the first one sets a false origin so the scene stays near the origin —
+// composed per-instance transforms are float32, and surveyor coordinates would
+// otherwise quantise at around half a metre.
+//
+// Runs against the embedded sample, which carries NO IfcCoordinateOperation.
+// That is deliberate here: it pins the federation plumbing (staging, applying,
+// events, units) without needing a georeferenced fixture. The georef values
+// themselves are covered by the sidecar round-trip tests in
+// src/ifcviewer/tests/test_sidecar_cache.cpp.
+
+async function open(page) {
+ const errors = [];
+ page.on('console', (msg) => {
+ const t = msg.text();
+ if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) errors.push(t);
+ });
+ page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
+
+ await page.goto('/scripting.html');
+ await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null,
+ { timeout: 30_000 });
+ await page.waitForFunction(() => {
+ const v = window.viewer;
+ if (!v.modelCount()) return false;
+ const p = v.modelProgress(0);
+ return p.total > 0 && p.resident === p.total;
+ }, null, { timeout: 30_000 });
+ return errors;
+}
+
+// viewAll frames the union of resident chunks' world AABBs. Shifting the false
+// origin moves every instance, which changes what the cull considers visible
+// and can therefore change residency. Settle before each measurement, or the
+// two frames being compared describe different subsets of the model.
+async function settled(page) {
+ await page.waitForFunction(() => {
+ const v = window.viewer;
+ if (!v.modelCount()) return false;
+ for (let i = 0; i < v.modelCount(); ++i) {
+ const p = v.modelProgress(i);
+ if (!(p.total > 0 && p.resident === p.total)) return false;
+ }
+ return true;
+ }, null, { timeout: 30_000 });
+}
+
+async function frameAndReadTarget(page) {
+ // Frame FIRST, then wait. Chunks stream on visibility, so a model sitting off
+ // screen — which is exactly what happens right after the origin moves — never
+ // becomes resident and a settle-then-frame order would deadlock. The initial
+ // viewAll can work from the metadata AABBs, which are known before any
+ // geometry has landed.
+ await page.evaluate(() => window.viewer.viewAll());
+ await settled(page);
+ await page.evaluate(() => window.viewer.viewAll());
+ return page.evaluate(() => window.viewer.getCamera().target);
+}
+
+test('false origin: guessed for the first model, and overridable', async ({ page }) => {
+ const errors = await open(page);
+
+ // The sample loads before any host code runs, so by now the automatic guess
+ // has already happened. It must NOT be flagged explicit — that flag is what
+ // tells the viewer a host has taken over placement.
+ const guessed = await page.evaluate(() => window.viewer.getFalseOrigin());
+ expect(guessed).not.toBeNull();
+ expect(guessed.explicit).toBe(false);
+ expect(guessed.xyz).toHaveLength(3);
+ guessed.xyz.forEach((v) => expect(Number.isFinite(v)).toBe(true));
+ expect(Number.isFinite(guessed.rzDeg)).toBe(true);
+
+ // Setting one takes over: the value round-trips and the flag flips, which is
+ // what suppresses the guess for any later first-model load.
+ const set = await page.evaluate(() => {
+ window.viewer.setFalseOrigin({ xyz: [10, 20, 30], rzDeg: 45 });
+ return window.viewer.getFalseOrigin();
+ });
+ expect(set.xyz[0]).toBeCloseTo(10, 6);
+ expect(set.xyz[1]).toBeCloseTo(20, 6);
+ expect(set.xyz[2]).toBeCloseTo(30, 6);
+ expect(set.rzDeg).toBeCloseTo(45, 6);
+ expect(set.explicit).toBe(true);
+
+ expect(errors).toEqual([]);
+});
+
+test('false origin shifts the scene by the negated offset', async ({ page }) => {
+ const errors = await open(page);
+
+ // A false origin nominates a point as the new origin, so geometry moves by
+ // -offset. This is the assertion that would fail if the origin were stored
+ // but never composed into the per-instance transforms.
+ await page.evaluate(() => window.viewer.setFalseOrigin({ xyz: [0, 0, 0], rzDeg: 0 }));
+ const before = await frameAndReadTarget(page);
+
+ await page.evaluate(() => window.viewer.setFalseOrigin({ xyz: [100, 0, 0], rzDeg: 0 }));
+ const after = await frameAndReadTarget(page);
+
+ expect(after[0] - before[0]).toBeCloseTo(-100, 1);
+ expect(after[1] - before[1]).toBeCloseTo(0, 1);
+ expect(after[2] - before[2]).toBeCloseTo(0, 1);
+
+ expect(errors).toEqual([]);
+});
+
+test('model transform moves one model and clears back', async ({ page }) => {
+ const errors = await open(page);
+
+ // The embedded sample bypasses the source registry, so it has no source id to
+ // address. Add the same sidecar as a real source — the flow a host page
+ // actually uses — and wait for the load event to hand back its ids.
+ const detail = await page.evaluate(async () => {
+ const v = window.viewer;
+ const loaded = new Promise((resolve) => {
+ const off = v.onModelLoaded((d) => { off(); resolve(d); });
+ });
+ const sid = await v.addUrl('/sample.ifcview', { replace: true, name: 'placed' });
+ const d = await loaded;
+ // Pin the origin so the automatic guess cannot move things underneath us.
+ v.setFalseOrigin({ xyz: [0, 0, 0], rzDeg: 0 });
+ return { ...d, sid };
+ });
+
+ // onModelLoaded must carry the source id it was asked for, and a real
+ // session model id (the core numbers them from 1).
+ expect(detail.sourceId).toBe(detail.sid);
+ expect(detail.sessionModelId).toBeGreaterThan(0);
+
+ const base = await frameAndReadTarget(page);
+
+ // "Take the point a=(0,0,0) and put it at b=(50,0,0)."
+ await page.evaluate((sid) => window.viewer.setModelTransform(
+ sid, { a: [0, 0, 0], b: [50, 0, 0], aFrame: 'global' }), detail.sid);
+ const shifted = await frameAndReadTarget(page);
+
+ await page.evaluate((sid) => window.viewer.clearModelTransform(sid), detail.sid);
+ const cleared = await frameAndReadTarget(page);
+
+ expect(shifted[0] - base[0]).toBeCloseTo(50, 1);
+ // Clearing restores the untransformed placement rather than leaving the model
+ // where it was put.
+ expect(cleared[0]).toBeCloseTo(base[0], 1);
+
+ expect(errors).toEqual([]);
+});
+
+test('georef readback reports the sample carries no coordinate operation', async ({ page }) => {
+ const errors = await open(page);
+
+ const georef = await page.evaluate(async () => {
+ const v = window.viewer;
+ const loaded = new Promise((resolve) => {
+ const off = v.onModelLoaded((d) => { off(); resolve(d); });
+ });
+ const sid = await v.addUrl('/sample.ifcview', { replace: true });
+ await loaded;
+ return v.getModelGeoref(sid);
+ });
+
+ expect(georef).not.toBeNull();
+ // make_sample.py authors no IfcMapConversion, so the flag is down and the
+ // matrix stays the identity placeholder.
+ expect(georef.hasCoordinateOperation).toBe(false);
+ expect(georef.matrix).toHaveLength(16);
+ expect(georef.matrix[0]).toBeCloseTo(1, 9);
+ expect(georef.matrix[5]).toBeCloseTo(1, 9);
+ expect(georef.matrix[10]).toBeCloseTo(1, 9);
+ expect(georef.matrix[15]).toBeCloseTo(1, 9);
+ expect(georef.projectLengthToMeters).toBeGreaterThan(0);
+ expect(georef.mapUnitToMeters).toBeGreaterThan(0);
+
+ expect(errors).toEqual([]);
+});
+
+// The bug this whole path exists to fix: two models whose DIFFERENT
+// IfcMapConversions resolve to the SAME real-world point must be drawn on top
+// of each other.
+//
+// georef-a and georef-b (see make_sample.py) are 2 m boxes whose local
+// positions differ by ~707 m and whose coordinate operations cancel that out
+// exactly. A viewer that applies each model's operation frames one box's worth
+// of scene after loading both; one that ignores it — as this viewer did before
+// applyCachedModel seeded georef from the sidecar — frames ~707 m of empty
+// space between them. The gap is two orders of magnitude larger than the
+// geometry, so this cannot pass by accident.
+test('models with different map conversions resolving to one point align', async ({ page }) => {
+ const errors = await open(page);
+
+ // Pin the origin AFTER the replacing load: replace:true clears the scene,
+ // which resets federation state including an explicitly-set origin. Pinning
+ // matters because the automatic guess is derived from the FIRST model, so
+ // leaving it on would shift the scene between the two measurements.
+ await page.evaluate(async () => {
+ const v = window.viewer;
+ const loaded = new Promise((resolve) => {
+ const off = v.onModelLoaded(() => { off(); resolve(); });
+ });
+ await v.addUrl('/georef-a.ifcview', { replace: true, name: 'a' });
+ await loaded;
+ v.setFalseOrigin({ xyz: [0, 0, 0], rzDeg: 0 });
+ });
+ const alone = await frameAndReadTarget(page);
+ const aloneDistance = await page.evaluate(() => window.viewer.getCamera().distance);
+
+ // Append the second model. Aligned, it adds nothing to the scene's extent.
+ await page.evaluate(async () => {
+ const v = window.viewer;
+ const loaded = new Promise((resolve) => {
+ const off = v.onModelLoaded(() => { off(); resolve(); });
+ });
+ await v.addUrl('/georef-b.ifcview', { name: 'b' });
+ await loaded;
+ });
+ const both = await frameAndReadTarget(page);
+ const bothDistance = await page.evaluate(() => window.viewer.getCamera().distance);
+
+ // Same centre: within a box's width, not hundreds of metres away.
+ expect(Math.abs(both[0] - alone[0])).toBeLessThan(2);
+ expect(Math.abs(both[1] - alone[1])).toBeLessThan(2);
+ expect(Math.abs(both[2] - alone[2])).toBeLessThan(2);
+
+ // And the framing does not blow out to span a 707 m gap.
+ expect(bothDistance).toBeLessThan(aloneDistance * 2);
+
+ // Both models really are in the scene — otherwise the assertions above would
+ // pass trivially on a failed second load.
+ expect(await page.evaluate(() => window.viewer.modelCount())).toBe(2);
+
+ expect(errors).toEqual([]);
+});
diff --git a/src/ifcviewer-web/web/ifcviewer.js b/src/ifcviewer-web/web/ifcviewer.js
index a088c42f8f..4753cac38f 100644
--- a/src/ifcviewer-web/web/ifcviewer.js
+++ b/src/ifcviewer-web/web/ifcviewer.js
@@ -82,6 +82,7 @@
const selectListeners = [];
const selectionListeners = [];
+ const modelLoadedListeners = [];
let api = null; // built below; the RAF loop only reads it after that
let live = false;
let resolveReady;
@@ -228,6 +229,21 @@
} catch (_) { /* older browsers */ }
};
+ // The wasm calls this once a model is fully in the scene, with the source
+ // id it was loaded from and the session model id the core assigned it. By
+ // the time it fires the federation layer has already applied any staged
+ // transform and (for the first model) the false-origin guess, so a handler
+ // sees the model where it will actually sit rather than mid-placement.
+ Module.__ifcvOnModelLoaded = function (sourceId, sessionModelId) {
+ const detail = { sourceId: sourceId | 0, sessionModelId: sessionModelId >>> 0 };
+ modelLoadedListeners.forEach(function (cb) {
+ try { cb(detail); } catch (e) { console.error(e); }
+ });
+ try {
+ document.dispatchEvent(new CustomEvent('ifcviewer:modelloaded', { detail: detail }));
+ } catch (_) { /* older browsers */ }
+ };
+
// Completion side of ifcv_request_objects_c: the element tables have all
// landed and the scene's objects are ready as JSON. `token` matches the
// request to its pending Promise.
@@ -506,13 +522,125 @@
// Add a model to the scene. `replace: true` drops the current scene first;
// otherwise it appends (a lightweight federation of streamed models).
+ // Returns the source id: the federation handle for this model. It is
+ // valid immediately, before the model has streamed, so a transform or
+ // name can be set against it right away — see setModelTransform.
addFile: async function (file, o) {
if (o && o.replace) this.clearScene();
- Module._load_sidecar_from_source_c(registerFile(file));
+ const sid = registerFile(file);
+ if (o && o.name) this.setModelName(sid, o.name);
+ Module._load_sidecar_from_source_c(sid);
+ return sid;
},
addUrl: async function (url, o) {
if (o && o.replace) this.clearScene();
- Module._load_sidecar_from_source_c(await registerUrl(url));
+ const sid = await registerUrl(url);
+ if (o && o.name) this.setModelName(sid, o.name);
+ Module._load_sidecar_from_source_c(sid);
+ return sid;
+ },
+
+ // ---- Federation ------------------------------------------------------
+ //
+ // The concepts an .ifcfed file carries, without the file format: a
+ // federation unit, a false origin, and a per-model transform. Parse
+ // .ifcfed (or any manifest) in your own code and drive these.
+ //
+ // Models resolve to global coordinates by default: each one's
+ // IfcCoordinateOperation is baked into its sidecar and applied on load,
+ // so models with different map conversions line up. The first model also
+ // sets a false origin automatically, which keeps the scene near the
+ // origin — necessary because per-instance transforms are float32 and
+ // surveyor coordinates would otherwise quantise to ~0.5 m. Call
+ // setFalseOrigin yourself to override; that suppresses the guess.
+
+ onModelLoaded: function (cb) {
+ modelLoadedListeners.push(cb);
+ return function () {
+ const i = modelLoadedListeners.indexOf(cb);
+ if (i >= 0) modelLoadedListeners.splice(i, 1);
+ };
+ },
+
+ // Federation unit: an IfcSIUnit name with optional SI prefix
+ // ({name:'METRE', prefix:'MILLI'}) or a conversion-based unit
+ // ({name:'foot'}). This is the value space for the false origin and for
+ // a transform's b/pivot.
+ setFederationUnit: function (u) {
+ u = u || {};
+ Module.ccall('ifcv_set_federation_unit_c', null, ['string', 'string'],
+ [u.name || 'METRE', u.prefix || '']);
+ },
+
+ // Nominate a point as the scene origin, with an optional grid-north
+ // heading in degrees. Setting this turns off the automatic guess.
+ setFalseOrigin: function (o) {
+ o = o || {};
+ const xyz = o.xyz || [0, 0, 0];
+ Module._ifcv_set_false_origin_c(xyz[0], xyz[1], xyz[2], o.rzDeg || 0);
+ },
+
+ // The active origin, including one the automatic guess produced.
+ // `explicit` is true when it was set rather than guessed.
+ getFalseOrigin: function () {
+ const ptr = Module._malloc(5 * 8);
+ try {
+ Module._ifcv_get_false_origin_c(ptr);
+ const d = Module.HEAPF64.subarray(ptr >>> 3, (ptr >>> 3) + 5);
+ return { xyz: [d[0], d[1], d[2]], rzDeg: d[3], explicit: d[4] !== 0 };
+ } finally {
+ Module._free(ptr);
+ }
+ },
+
+ // Place a model: rotate it about `pivot`, then translate so that point
+ // `a` lands on point `b`.
+ //
+ // `aFrame` picks the frame `a` is expressed in: 'global' (default) means
+ // post-CoordinateOperation, in the model's map unit — i.e. real-world
+ // coordinates. 'local' means pre-CoordinateOperation, in the model's own
+ // project unit. b, pivot and the origin are in the federation unit;
+ // rotation is degrees, intrinsic XYZ.
+ //
+ // Safe to call before the model has finished streaming; it is applied
+ // when the load completes, so the model never visibly jumps.
+ setModelTransform: function (sourceId, xf) {
+ xf = xf || {};
+ const a = xf.a || [0, 0, 0], b = xf.b || [0, 0, 0];
+ const r = xf.rotationDeg || [0, 0, 0], p = xf.pivot || [0, 0, 0];
+ Module._ifcv_set_model_transform_c(
+ sourceId | 0, xf.aFrame === 'local' ? 0 : 1,
+ a[0], a[1], a[2], b[0], b[1], b[2],
+ r[0], r[1], r[2], p[0], p[1], p[2]);
+ },
+
+ clearModelTransform: function (sourceId) {
+ Module._ifcv_clear_model_transform_c(sourceId | 0);
+ },
+
+ setModelName: function (sourceId, name) {
+ Module.ccall('ifcv_set_model_name_c', null, ['number', 'string'],
+ [sourceId | 0, String(name)]);
+ },
+
+ // The georeferencing a model actually carries, read back from its
+ // sidecar: {hasCoordinateOperation, matrix (16, column-major, metres),
+ // projectLengthToMeters, mapUnitToMeters}. Null until the model has
+ // finished loading.
+ getModelGeoref: function (sourceId) {
+ const ptr = Module._malloc(19 * 8);
+ try {
+ if (!Module._ifcv_get_model_georef_c(sourceId | 0, ptr)) return null;
+ const d = Module.HEAPF64.subarray(ptr >>> 3, (ptr >>> 3) + 19);
+ return {
+ hasCoordinateOperation: d[0] !== 0,
+ matrix: Array.from(d.subarray(1, 17)),
+ projectLengthToMeters: d[17],
+ mapUnitToMeters: d[18],
+ };
+ } finally {
+ Module._free(ptr);
+ }
},
};