New OverlayRenderer module owns every client-supplied overlay primitive
drawn after the main pass: tinted, depth-aware highlight triangles via
its own GL shader, and top-left HUD text via QPainter on a
QOpenGLPaintDevice. Public surface on ViewportWindow is just two
forwarders (setHighlightTriangles, setHudText).
ViewportWindow's MeshLocalPick now exposes the instance's composed
transform so consumers can map mesh-local geometry back to world
space without re-querying. AreaMeasurement uses both: its selection
key is now (object_id, tri) so per-instance highlighting works for
two distinct walls sharing a mesh, and on every pick it rebuilds the
world-space tri list and the HUD readout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a click-to-measure area mode triggered by Ctrl+Shift+A. Each LMB
click expands the picked triangle into its connected coplanar patch
(BFS over shared edges, dot(normal, seed) > 0.9999); re-clicking
removes that patch; Alt+LMB skips expansion for a single triangle.
Picks across different meshes accumulate as separate patches.
ViewportWindow gains pickMeshLocalAt (screen pick → mesh-local hit
via inverse composed transform) and a tool-mode pattern mirroring
the section tool (toggleAreaTool, surfacePickedInTool signal,
areaToolToggled signal, Esc to exit). Per-mesh adjacency is built
lazily on first pick of each mesh and dropped on tool toggle.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds neutral primitives on ViewportWindow (readbackMeshTriangles,
findInstance) so consumers can compute per-object geometry queries
without the library retaining a CPU triangle copy. Measurement.cpp in
ifcviewer-full uses them to sum signed-tetrahedra in mesh-local space,
weighted by |det(placement_3x3)| per instance for mapped-item scaling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Read-only handles reject Flush/CompactRange, so the destructor's status
assertion always fired on shutdown when the streamer's sidecar was
opened with read_only=true. Track the flag and skip the write path; also
guard against a null db when the initial open failed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Federation gains a nested Group tree (id, display_name, visible,
children); models reference a single group via Model::group_id.
Visibility cascades: a model is effectively visible only when its own
flag is on and every ancestor group is visible. Persistence nests
groups directly in the JSON — no parent_id field.
ifcviewer-full surfaces this in the element tree with right-click
menus to create / rename / move / remove groups, move models between
groups, and toggle group visibility.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Right-click a model root in the Elements tree to get Hide/Show and
Remove. Hide flips the federation's per-model visible flag (already
round-tripped to .ifcfed), pushes ViewportWindow::hideModel/showModel,
and italicises + greys the tree root as a visual cue. Remove drops
the model from the viewport, the SceneLoader (streamer + caches), the
MainWindow UI maps and tree, and the Federation — disabled while the
model is the active load.
Visibility is reapplied on each model's load completion (sidecar or
stream), so a federation saved with hidden models opens with them
hidden. clearScene() now also drops SceneLoader state so streamers
no longer leak across federation transitions.
API additions:
- Federation::setModelVisible + modelVisibilityChanged signal
- SceneLoader::removeModel + isLoadingModel
Tests cover the setter (dirty + signal + idempotence + unknown id);
extends the existing round-trip test to actually exercise the
visibility load/save it always claimed to.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Commit f7add7f4 split getAxis2Placement out of an anonymous helper in
Geolocation.cpp into a shared Placement.{h,cpp}, but the test_federation
target's source list wasn't updated. The test binary failed to link
with `undefined reference to getAxis2Placement(express::Base const&)`
from Geolocation::getWcs. Add Placement.cpp to the explicit-source
list — it has no Qt dependency, only ifcparse.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the user adds a model into a fresh, untitled federation that still
has the default (0,0,0, no rotation) FederatedFalseOrigin, derive an
origin from the first instance's placement_transformation (lifted
through CoordinateOperation when enabled) and the helmert grid-north
baked into ModelGeoref::coordinate_operation_meters. Multi-file batches
naturally settle: whichever load finishes first anchors the federation,
the rest see a non-default origin and skip. Saved .ifcfeds keep their
authoritative origin.
Adds Placement.{h,cpp} (port of util/placement.py — a2p,
get_axis2placement, get_local_placement) so Geolocation no longer needs
its own anonymous getAxis2Placement, and xaxis2angleDeg in Geolocation
mirroring util/geolocation.xaxis2angle.
SceneLoader captures the first instance's placement_transformation from
either the sidecar's InstanceCpu[0] or the streamer's first
InstanceChunk, so the guess works on both load paths without re-reading
the IFC.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously, applyCoordinateOperationToViewport — which pushes both
CoordinateOperation and ModelTransformation — was only called on
paths that required the IFC source to be loaded
(onLoadedFromStream and onDataSourceReady). Sidecar-only loads
(loadDataSource off, or no .ifc/.rdb sibling) silently lost both
stages.
Cache the per-model georef + unit scales in the sidecar itself so
the IFC source isn't needed to apply them:
SidecarData gains
coordinate_operation_meters[16] // column-major
project_length_to_meters
map_unit_to_meters
has_coordinate_operation
148 B fixed block written/read between instances and elements.
SIDECAR_VERSION 10 -> 11; existing sidecars rebuild on next load.
MainWindow::writeSidecarForModel populates the block from
loader_->modelGeoref(mid) before writeSidecar.
SceneLoader::applySidecarData restores it into the model's
ModelGeoref + sets has_georef = true, so subsequent
loader_->modelGeoref(mid) calls return the cached data without
needing the IFC.
MainWindow::onLoadedFromSidecar now calls
applyCoordinateOperationToViewport(mid) directly — both
CoordinateOperation and ModelTransformation land at sidecar-load
time, no longer waiting on a possibly-never-arriving data source.
Edits to the IFC's IfcMapConversion don't invalidate the cache —
delete the .ifcview manually if the source's georef changes. This
matches the existing cache-invalidation contract.
Tests: round-trip the new fields through the existing sidecar
fixture; assert SIDECAR_VERSION == 11.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tree -> viewport selection was already wired (onTreeSelectionChanged
calls setSelectedObjectId), but pressing F afterwards routed to the
focused tree widget rather than the viewport, so framing didn't fire.
Add a window-level View > Frame Selected QAction with Qt::Key_F that
delegates to ViewportWindow::focusOnSelectedObject — works regardless
of which child widget has focus. The viewport's own F handler stays
in place for when the viewport itself owns focus.
For debugging coordinate problems, add View > Print Selected Coords
(Ctrl+Shift+P) -> ViewportWindow::printSelectedObjectCoords, which
qInfo's:
- a sample vertex (first vertex of the selected mesh, decoded on
demand from the quantised VBO so no extra CPU storage is needed);
- placement_transformation (the per-instance matrix that maps the
sample vertex from mesh-local into the model's pre-georef frame);
- global = CoordinateOperation . placement_transformation (where
the IFC's own IfcCoordinateOperation has been folded in);
- the sample vertex transformed through both matrices.
The print is a no-op when nothing is selected or GL hasn't initialised.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ModelTransformationDialog edits one federation model at a time. Top
combobox picks the model; below it the form covers the four pieces
of authoring intent:
- AFrame radio: ModelLocal vs ModelGlobal
- Point A: 3 doubles, label switches between "model project length
unit" and "model map unit" with the radio
- Point B: 3 doubles in federation units (label reflects current
FederationConfig.unit_*)
- Rotation: rx/ry/rz in degrees, intrinsic XYZ
- Pivot: 3 doubles in federation units
Switching models discards unsaved form edits — Ok saves the
currently-visible model, Cancel discards. On Ok calls
Federation::setModelTransformation, which fires
modelTransformationChanged → MainWindow recomposes that model in
the viewport.
Reachable from File > Model Transformations.
End-to-end is now editable: open a federation, edit federation unit /
false origin from one dialog, edit any model's transformation from
the other, watch the viewport recompose live. Visual verification
on a real model still pending.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New FederationSettingsDialog edits the federation-wide unit and the
FederatedFalseOrigin (XYZ + Z-rotation in that unit). On Ok it calls
Federation::setConfig + setFederatedFalseOrigin, which fire the
granular Federation signals MainWindow listens to → viewport
recomposes immediately.
Reachable from File > Federation Settings. Unit picker is a fixed
combobox of common length units (metres / mm / cm / km / ft / in /
yd / mi); each item carries (prefix, name) in itemData so saving
round-trips correctly. Per-model ModelTransformation editor still
to come — that's a per-model dialog reachable from the model entry,
not the federation-wide settings.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Federation grows three granular signals so consumers can recompose only
what's affected:
- configChanged() — federation unit changed
- federatedFalseOriginChanged() — stage 3 changed
- modelTransformationChanged(fed_id) — stage 4 changed for one model
Emitted from setConfig / setFederatedFalseOrigin / setModelTransformation
in addition to the existing dirtyChanged.
MainWindow gains applyFederatedFalseOriginToViewport and
applyModelTransformationToViewport helpers. Each composes the matrix
from the current federation state (using composeFederatedFalseOrigin /
composeModelTransformation, which already exist on Federation.h) and
pushes to the viewport's setFederatedFalseOrigin /
setModelTransformation. ModelTransformation reads ModelUnits and the
active CoordinateOperation matrix from SceneLoader::modelGeoref so
ModelLocal-frame `a` lifts correctly through stage 2 when authored.
Wiring:
- federation.federatedFalseOriginChanged -> applyFederatedFalseOriginToViewport
- federation.configChanged -> stage 3 + walk all models for stage 4
- federation.modelTransformationChanged -> stage 4 for that one model
- applyCoordinateOperationToViewport now also re-pushes stage 4 (the
compose result depends on the active stage 2 when a_frame is ModelLocal)
- openFederation() pushes the loaded FederatedFalseOrigin once load
completes; per-model stage 4 falls out of the existing
onLoadedFromStream / onDataSourceReady path.
End-to-end pipeline is now active under the AppSettings toggle: edit
the federation in memory and the viewport recomposes immediately. UI
for editing (form-based dialog) still pending.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AppSettings.applyCoordinateOperation (default false, persisted via
QSettings) controls whether each loaded model's IfcCoordinateOperation
is applied at upload time. Off keeps models in their local engineering
frame (current behaviour). On lifts each model into map coordinates
via the stage-2 georef matrix cached on SceneLoader.
MainWindow:
- applyCoordinateOperationToViewport(mid) reads the toggle, fetches
the model's ModelGeoref, and pushes either the
coordinate_operation_meters matrix or identity to the viewport.
- Called from onLoadedFromStream (streamer path) and onDataSourceReady
(sidecar-hit path, where the IFC arrives asynchronously).
- Subscribed to AppSettings::applyCoordinateOperationChanged: a
runtime toggle walks every loaded model and re-applies, so users
can flip georef on/off without reloading.
SettingsWindow gains a "Apply Coordinate Operation" checkbox alongside
the existing per-load toggles.
Default-off so the change is opt-in — users with georeferenced models
(UTM coords etc.) can flip the toggle to see them in their map frame
once they're ready. Visual verification on a real georeferenced
model still pending.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
InstanceCpu now carries both placement_transformation (raw streamer
output, the iterator's per-shape transform with vertex-rebasing offset
folded in) and transform (the composed FederatedFalseOrigin ·
ModelTransformation · CoordinateOperation · placement_transformation
result that lands in the SSBO). World AABBs are recomputed from the
composed transform — frustum/BVH culling sees the actual rendered
position regardless of stage state.
ViewportWindow gains:
- ModelGpuData::coordinate_operation_meters / model_transformation_meters
- federated_false_origin_meters_ (federation-wide member)
- composeInstanceFromPlacement / recomposeAndUploadModel helpers
- public setFederatedFalseOrigin / setModelCoordinateOperation /
setModelTransformation
Each setter rewrites the affected model's SSBO, refreshes the
reflection flags, and rebuilds the BVH. Defaults are identity, so
behaviour is unchanged until something wires a setter up — that's
the next commit (MainWindow listening to Federation::dirtyChanged
and SceneLoader::modelGeoref ready signals).
Sidecar bumped 9 -> 10: InstanceCpu grew 104 B -> 168 B. Existing
sidecars rebuild on next load. v10 sidecars store
placement_transformation, so they remain reusable across .ifcfeds —
the composed transform on disk is overwritten with the right one
on load.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per-mesh, when the iterator's first source vertex is more than 1 km
from origin (matching bonsai's distance_limit default), pick that
vertex as a rebase offset. buildMeshChunk subtracts the offset from
every emitted vertex (in double precision, narrowed to float at the
end), and each instance's placement matrix is post-multiplied by
T(+offset) so world position is preserved by construction:
T(+offset) · (verts - offset) ≡ T · verts
The offset is stored on the per-mesh MeshAabb so all instances of the
same mesh apply the same compensation. When the mesh's first vert is
near origin (the common case), offset is zero and the work is a no-op
beyond a couple of FP ops per vertex.
Improves float32 precision in the vertex buffer for georeferenced
models (UTM coords etc.) where verts would otherwise have to encode
million-metre magnitudes directly — at 1e6 m, float32 resolves about
6 cm, ruining sub-millimetre detail in the buildings themselves.
Visual verification on a real UTM-coords model still pending — the
math preserves world position by construction but precision claims
warrant a hand-test in the viewer.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds ModelGeoref { ModelUnits units; Eigen::Matrix4d stage2_meters; bool
has_stage2; } and computeModelGeoref(file*) in Federation.{h,cpp}. The
helper reads the project length unit, IfcProjectedCRS.MapUnit, helmert
parameters and WCS, and reduces them to a metres-in/metres-out stage 2
matrix using the existing Geolocation + Unit primitives. When the model
has no IfcMapConversion it returns an identity stage_2 with has_stage2
== false, so the upload pipeline can branch cheaply.
SceneLoader::Model gains a cached ModelGeoref; SceneLoader::modelGeoref
(uint32_t mid) computes lazily on first call (returns nullptr when the
IFC file isn't available yet — happens on the sidecar-hit path before
the data-source thread populates the streamer) and serves from cache
afterwards.
Not yet consumed by the upload pipeline; that's the next commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the structs that were briefly in src/ifcviewer/Federation.{h,cpp}
two commits ago, now folded into the merged Federation alongside the
file persistence layer:
- FederationConfig: federation-wide unit ({prefix, name}). Default
METRE; one-of an IfcSIUnit name with optional prefix or an
IfcConversionBasedUnit name.
- FederationOrigin: stage 3 — XYZ in federation unit + Z-rot.
Composes to R_z · T(-xyz_meters), nominating a point as origin.
- AFrame + ModelTransform: stage 4 intent — A (model project or
map unit, per a_frame), B and pivot (federation unit), full
intrinsic-XYZ Euler rotation in degrees.
- ModelUnits: per-model project_length_to_meters / map_unit_to_meters
cached at load time.
Free functions composeFederationOrigin and composeModelTransform
return Eigen::Matrix4d in metres. composeModelTransform takes the
model's stage-2 georef matrix so it can lift `a` into metres when
authored in ModelLocal.
Federation gains config_, origin_ members + setters that emit
dirtyChanged. Each Model carries a transform_intent. JSON I/O
emits config / origin always; transform_intent only when non-default.
Schema stays "ifcfed/1" — additive, optional, sane defaults.
Five new tests: round-trip of the new fields, default-omission
behaviour, two compose smoke tests for FederationOrigin, and one
verifying the "pivot at B preserves A→B" invariant of
composeModelTransform. All 36 ctest cases pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move src/ifcviewer-full/Federation.{h,cpp} (and its tests) into
src/ifcviewer/ so the lib stays the single source of truth for the
federation data model. Restores the original "agnostic lib usable
from ifcviewer-full and ifcviewer-minimal alike" framing.
Drop the unused per-model transform[16] / has_transform field — it
was round-trip-only with no UI to author it, and is being replaced
by an intent-based ModelTransform in the next commit. No real
.ifcfed in the wild populated this field; old files still load
(unknown JSON keys ignored), they just lose the unused transform.
Replaces the pure-data-model Federation.{h,cpp} that was added a
few commits earlier — that file's structs and compose helpers
return as part of the merged Federation in commit 6.
ifcviewer-full's per-app tests dir is removed (test_federation was
the only one); BUILD_IFCVIEWER_TESTS now wires test_federation in
under src/ifcviewer/tests/, with the Qt6::Core/Gui/Test dependency
declared inline since unlike the other Tier-1 tests it has to pull
Qt in. All 31 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
FederationConfig holds the federation-wide display unit (defaults to
METRE; on load the first model's MapUnit becomes the default).
FederationOrigin captures stage 3 — XYZ in federation unit + Z-rot —
and composes to R_z · T(-xyz_meters), nominating a point as the new
origin and rotating around it. ModelTransform captures stage 4 —
A in model project or map unit (per AFrame), B and pivot in
federation unit, full intrinsic-XYZ Euler rotation — and composes to
T(B - R_pivot · A) · R_pivot, rotating first then translating so the
rotated A lands at B.
ModelUnits caches per-model project/map unit-to-metres scales so the
compose helpers don't need to re-read the IFC each call.
All composed matrices are in metres; user-typed numbers are stored
in source units to round-trip without precision loss, and converted
on compose via Unit.h.
Not yet wired into the streamer or .ifcfed I/O — pure data model and
maths, integrated in subsequent commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
helmertMetersFromParameters builds the helmert transformation as a
meter-input/meter-output 4x4 directly from parsed parameters, bypassing
autoLocal2Global's normalisation step. This preserves
IfcMapConversionScaled.FactorX/Y/Z in the rotation block so the factor
applies to placement translations when the matrix is precomputed
per-model and composed with placements at upload time. For ordinary
IfcMapConversion (factor = 1) this is bit-identical to
autoLocal2Global; only diverges on rare surveyed models with non-unit
factors, where it is the only correct behaviour.
getMapUnit returns IfcCoordinateOperation.TargetCRS.MapUnit so callers
can resolve the unit-to-metres scale via Unit.h's siScaleFromNamedUnit.
autoLocal2Global is unchanged — kept as a clean port of the python
ifcopenshell.util.geolocation reference impl for one-shot
project-units-in / map-units-out callers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors selected helpers from ifcopenshell.util.unit: SI prefix
multipliers, the conversion-based-unit table (foot/inch/etc -> SI
metres), siScaleFromNamedUnit (walks IfcConversionBasedUnit chains
down to IfcSIUnit), getUnitAssignment / getProjectUnit /
calculateUnitScale, and convert / convertUnit. Lives in
src/ifcviewer/ for now alongside Geolocation; will move out when
ifcopenshell.util is ported to C++.
Needed by upcoming Geolocation fix (e/n/h on IfcMapConversion are
in MapUnit, must be converted to metres for the meter-by-default
iterator output) and by the federation module (display-unit
conversion when the user changes the federation unit).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors ifcopenshell.util.geolocation: HelmertTransformation parameters
(IfcMapConversion / IfcMapConversionScaled / IfcRigidOperation, plus
IFC2X3 ePSet_MapConversion), get_wcs from IfcGeometricRepresentationContext,
local2global, and auto_local2global. Lives in src/ifcviewer/ for now;
will move out when ifcopenshell.util is ported to C++.
Not yet wired into the streamer. A subsequent commit fixes the
unit handling for the iterator's meter-by-default output.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous projection-toggle commit short-circuited contribution
culling when projection_ortho_ was set — the formula
r_px = focal_px * r / dist looks like it depends on per-instance
distance, which doesn't apply in ortho. Result: every frustum-
visible object drew, including sub-pixel ones, and FPS tanked on
top-down plan views.
In ortho the projected pixel size of a bounding sphere is constant:
r_px = pixels_per_world * r, where pixels_per_world equals the
existing focal_px / camera_distance_ (the ortho box was sized to
match perspective at the pivot's distance). So the same formula
gives the right answer if we replace per-instance dist with
camera_distance_.
cullModelCpu now does that substitution for both contributionPasses
and pixelRadius (the latter feeds LOD1 selection too — sub-pixel
objects pick LOD1 in ortho the same way they do in perspective).
The "camera inside AABB" early-return is kept; it only fires in
perspective where dist→0 would otherwise blow up r_px, and is
harmless in ortho.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>