mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-18 11:20:21 +00:00
Give the web viewer a federation: false origin and per-model transforms
Models now resolve to global coordinates, which alone would make things worse: composed per-instance transforms are float32, and around six million metres that quantises at roughly half a metre. So the first model to load also sets a false origin, derived from where its geometry actually sits, unless a host has set one itself. WebFederation owns the concepts an .ifcfed carries — a federation unit, a false origin, a per-model transform and display name — without the file format. The desktop Federation class is a document model whose sources are local filesystem paths, which mean nothing in a browser; a host page that wants .ifcfed can parse the JSON and drive these calls. Models are keyed by the JS source id rather than the session model id. The source id exists the moment a File or URL is registered, whereas the session id is minted inside the async range-read chain, so keying on it lets a transform be set before the model has streamed and applied when it arrives — the model never visibly jumps. loadSidecarMetadataWeb gained a completion callback to carry that id back out, and addFile/addUrl now return the source id and fire onModelLoaded, where before they were fire-and-forget with no handle and no completion signal. The embedded sample bypasses the source registry, so it is bound separately; otherwise the guess never runs for a page that only ever shows the sample. georef-a and georef-b are the regression fixture: two boxes whose different map conversions resolve to the same real-world point, so a viewer that applies them draws one box's worth of scene and one that ignores them spans 707 m. They carry two meshes each because reorderSidecarByMorton bails out below two and then writes no chunk table, and a sidecar without one cannot stream over byte ranges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#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";
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef WEBFEDERATION_H
|
||||
#define WEBFEDERATION_H
|
||||
|
||||
#include "FederationMath.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
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<int, ModelState> models_;
|
||||
};
|
||||
|
||||
#endif // WEBFEDERATION_H
|
||||
@@ -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;
|
||||
Binary file not shown.
@@ -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;
|
||||
Binary file not shown.
@@ -30,6 +30,7 @@
|
||||
|
||||
#include "ViewportCore.h"
|
||||
#include "WebViewportHost.h"
|
||||
#include "WebFederation.h"
|
||||
#include "Log.h"
|
||||
|
||||
#include <emscripten/emscripten.h>
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,16 @@ CMakeLists.txt) so every example page renders something before the user picks a
|
||||
file. This script is how that fixture is produced, so it can be regenerated
|
||||
rather than being an opaque committed blob:
|
||||
|
||||
python3 make_sample.py # writes sample.ifc + sample.ifcview
|
||||
ninja -C ../../build-web # re-embeds it
|
||||
python3 make_sample.py # writes sample.* and georef-a/b.*
|
||||
ninja -C ../../build-web # re-embeds sample.ifcview
|
||||
|
||||
Also authors georef-a and georef-b: a pair that pins the federation behaviour.
|
||||
The two carry DIFFERENT IfcMapConversions over DIFFERENT local coordinates,
|
||||
chosen so both resolve to the same real-world point. A viewer that applies each
|
||||
model's coordinate operation draws them on top of each other; one that ignores
|
||||
it (as the web viewer did before it seeded georef from the sidecar) draws them
|
||||
~707 m apart. They are small single-box models — the assertion is about where
|
||||
they land, not what they look like.
|
||||
|
||||
It needs ifcopenshell (Python) to author the IFC, and the sidecar_bake tool from
|
||||
the desktop build to convert it:
|
||||
@@ -20,6 +28,7 @@ time, so coincident geometry would make those calls look like no-ops: whatever
|
||||
you hid would still be there, drawn by the object behind it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -28,6 +37,7 @@ from pathlib import Path
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.georeference
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.root
|
||||
@@ -35,7 +45,10 @@ import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
BAKE = HERE / "../../build-viewer/ifcviewer/sidecar_bake"
|
||||
# The sidecar baker. Override with SIDECAR_BAKE when it lives outside the
|
||||
# desktop build tree — there is currently no in-tree `sidecar_bake` target, so
|
||||
# the default below only resolves if one has been added locally.
|
||||
BAKE = Path(os.environ.get("SIDECAR_BAKE", HERE / "../../build-viewer/ifcviewer/sidecar_bake"))
|
||||
|
||||
# (class, name, footprint w x d in m, height in m, placement x/y/z in m)
|
||||
ELEMENTS = [
|
||||
@@ -98,20 +111,121 @@ def build_ifc(path: Path) -> None:
|
||||
f.write(str(path))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not BAKE.exists():
|
||||
sys.exit(f"{BAKE} not found — build it with: ninja -C ../../build-viewer sidecar_bake")
|
||||
# Two models that must coincide once each one's IfcMapConversion is applied.
|
||||
#
|
||||
# With no grid rotation and unit scale 1, the coordinate operation is a pure
|
||||
# translation, so global = local + (eastings, northings, height). Both entries
|
||||
# below sum to the same global point:
|
||||
#
|
||||
# a: local (0, 0) + (1000, 2000) = (1000, 2000)
|
||||
# b: local (500, 500) + ( 500, 1500) = (1000, 2000)
|
||||
#
|
||||
# The local offset between them (~707 m) is the error a viewer shows when it
|
||||
# ignores the coordinate operation, which is far larger than the 2 m boxes —
|
||||
# so the test cannot pass by accident.
|
||||
GEOREF_MODELS = [
|
||||
("georef-a", (0.0, 0.0), (1000.0, 2000.0)),
|
||||
("georef-b", (500.0, 500.0), (500.0, 1500.0)),
|
||||
]
|
||||
|
||||
ifc = HERE / "sample.ifc"
|
||||
build_ifc(ifc)
|
||||
|
||||
def build_georef_ifc(path: Path, local_xy: tuple, map_en: tuple) -> None:
|
||||
"""One 2 m box at `local_xy`, georeferenced by `map_en` eastings/northings."""
|
||||
f = ifcopenshell.api.project.create_file(version="IFC4")
|
||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name=path.stem)
|
||||
ifcopenshell.api.unit.assign_unit(f, length={"is_metric": True, "raw": "METERS"})
|
||||
body = ifcopenshell.api.context.add_context(f, context_type="Model")
|
||||
body = ifcopenshell.api.context.add_context(
|
||||
f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=body
|
||||
)
|
||||
|
||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="Site")
|
||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground floor")
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=site)
|
||||
|
||||
ifcopenshell.api.georeference.add_georeferencing(f)
|
||||
ifcopenshell.api.georeference.edit_georeferencing(
|
||||
f,
|
||||
projected_crs={"Name": "EPSG:3857"},
|
||||
coordinate_operation={
|
||||
"Eastings": map_en[0],
|
||||
"Northings": map_en[1],
|
||||
"OrthogonalHeight": 0.0,
|
||||
# Grid north == project north, and map units == project units, so
|
||||
# the operation reduces to the pure translation described above.
|
||||
"XAxisAbscissa": 1.0,
|
||||
"XAxisOrdinate": 0.0,
|
||||
"Scale": 1.0,
|
||||
},
|
||||
)
|
||||
|
||||
# TWO boxes, deliberately different sizes so they are two distinct meshes.
|
||||
# reorderSidecarByMorton bails out at fewer than two meshes and then writes
|
||||
# no chunk table at all, and a sidecar with no chunk table can never stream
|
||||
# over the web byte-range path — the loader has no locator to fetch with.
|
||||
# A one-box model here would load and simply never draw.
|
||||
for i, (w, d) in enumerate([(2.0, 2.0), (1.0, 3.0)]):
|
||||
element = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name=f"Box{i}")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[element], relating_structure=storey)
|
||||
profile = f.create_entity(
|
||||
"IfcRectangleProfileDef",
|
||||
ProfileType="AREA",
|
||||
XDim=w,
|
||||
YDim=d,
|
||||
Position=f.create_entity(
|
||||
"IfcAxis2Placement2D", Location=f.create_entity("IfcCartesianPoint", Coordinates=(w / 2, d / 2))
|
||||
),
|
||||
)
|
||||
solid = f.create_entity(
|
||||
"IfcExtrudedAreaSolid",
|
||||
SweptArea=profile,
|
||||
Depth=2.0,
|
||||
Position=f.create_entity(
|
||||
"IfcAxis2Placement3D", Location=f.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||||
),
|
||||
ExtrudedDirection=f.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
|
||||
)
|
||||
representation = f.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
ContextOfItems=body,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="SweptSolid",
|
||||
Items=[solid],
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(f, product=element, representation=representation)
|
||||
ifcopenshell.api.geometry.edit_object_placement(
|
||||
f,
|
||||
product=element,
|
||||
matrix=ifcopenshell.util.placement.a2p(
|
||||
(local_xy[0] + i * 3.0, local_xy[1], 0.0), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0)
|
||||
),
|
||||
)
|
||||
f.write(str(path))
|
||||
|
||||
|
||||
def bake(ifc: Path, out: Path) -> None:
|
||||
subprocess.run([str(BAKE), str(ifc)], check=True)
|
||||
|
||||
# sidecar_bake writes <name>.ifcview next to the input.
|
||||
baked = ifc.with_suffix(".ifcview")
|
||||
if not baked.exists():
|
||||
sys.exit(f"sidecar_bake did not produce {baked}")
|
||||
shutil.move(baked, HERE / "sample.ifcview")
|
||||
print(f"wrote {HERE / 'sample.ifcview'}")
|
||||
shutil.move(baked, out)
|
||||
print(f"wrote {out}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not BAKE.exists():
|
||||
sys.exit(f"{BAKE} not found — point SIDECAR_BAKE at a sidecar_bake binary")
|
||||
|
||||
ifc = HERE / "sample.ifc"
|
||||
build_ifc(ifc)
|
||||
bake(ifc, HERE / "sample.ifcview")
|
||||
|
||||
for stem, local_xy, map_en in GEOREF_MODELS:
|
||||
georef_ifc = HERE / f"{stem}.ifc"
|
||||
build_georef_ifc(georef_ifc, local_xy, map_en)
|
||||
bake(georef_ifc, HERE / f"{stem}.ifcview")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user