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); + } }, };