Rename the two overloaded model identifiers and make object_id
assignment single-authority, fixing a pick -> properties mismatch.
Identifiers:
- Per-model UUID fed_id -> model_id; the uint32 runtime handle
model_id -> session_model_id (SessionState accessors + mirror hashes
renamed to match). "fed_id" was a misnomer -- the federation is the
whole collection, not one model.
object_id assignment (fixes wrong class on click):
- Producers (GeometryStreamer, .ifcview sidecar) now stamp model-LOCAL
object_ids; ViewportCore::applyCachedModel is the sole authority that
assigns the session-global id (base + local). Removed
SceneLoader::next_object_id_, GeometryStreamer::lastObjectId(), and the
streamer's start_object_id parameter.
- The element table is stamped by the same base on both load paths
(applySidecarData and onStreamerFinished), so registry ids match the
ids pick returns. Previously the sidecar path double-rebased instances
vs the registry (click IfcSite -> showed IfcDoor); the live-stream path
had the same latent mismatch. Both closed.
Naming / cleanup:
- SceneLoader::addFiles -> queueModels; startStreamLoadFor ->
loadFromGeometryStreamer; readSidecarMetadataOnly -> readSidecarMetadata.
- Federation::addModel takes an explicit display_name (no QFileInfo
fallback); callers pass QFileInfo(path).fileName().
- Disambiguate cryptic short locals (d->sidecar, m->model, c->chunk, ...)
in SceneLoader, Federation, ViewportWindow, AreaMeasurement,
SectionGizmoRenderer, and the SidecarData/SidecarReadPlan spots in
ViewportCore.
Tests: 125/125 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace Qt math wrappers with Eigen across ViewportWindow, OverlayRenderer,
Federation, and the bonsai-side viewport modules. Eigen was already the
canonical type for the actually-important matrix work (InstanceCompose,
ModelGpuData, federation matrices); QVector3D/QVector4D/QMatrix4x4 were
leftover from when Qt was the path of least resistance. They offered
nothing over Eigen for our use case beyond a few graphics helpers
(lookAt / perspective / ortho) which were 30 lines to write.
Substitutions:
QMatrix4x4 → Eigen::Matrix4f
QVector2D → Eigen::Vector2f
QVector3D → Eigen::Vector3f
QVector4D → Eigen::Vector4f
API rewrites:
.lengthSquared() → .squaredNorm()
.length() → .norm()
.isNull() → .isZero()
.setToIdentity() → .setIdentity()
.constData() → .data()
.toVector3D() → .head<3>()
.inverted(&ok) → tryInvert4f(M, out)
Q::dotProduct(a,b) → a.dot(b)
Q::crossProduct(a,b) → a.cross(b)
QMat4x4(... row-major) → Eigen::Map<const Matrix4f>(col-major buf)
QMat4x4().lookAt(...) → lookAtRH(eye, target, up)
QMat4x4().perspective(.) → perspectiveYFovGL(fovy, aspect, n, f)
QMat4x4().ortho(...) → orthoGL(l, r, b, t, n, f)
Default-init divergence handled explicitly (QMatrix4x4() = identity,
QVector3D() = zero; Eigen leaves both uninitialized). Public API
(CameraState, HomeView, ViewportWindow::computeObjectAabb, the
addSectionPlaneAtSurface / pickSurfaceAt / raycast signatures) follows
through to Eigen too; bonsai-side View.cpp and Commands.cpp updated to
match.
Camera helpers (lookAtRH, perspectiveYFovGL, orthoGL, tryInvert4f)
extracted to a new CameraMath.h so OverlayRenderer's gizmo MVP and
ViewportWindow's buildViewProj share the same definitions. Federation
drops its <QVector3D> include in favour of <Eigen/Dense> (already had
the latter for the georef matrices).
Builds: desktop IfcViewerMinimal ✓, BonsaiViewer ✓, web IfcViewerWeb ✓.
Tests: 100/100 pass. Closes#78 + #79; opens the door for #80-#83.
Two independent threads that landed on this branch.
## 1. Federation false-origin guess: arm-on-add + frame-on-origin
The cc54237f5 fix moved the guess off the refresh() fan-in (terminating
the stack-overflow recursion on Holter Tower), but the gating was still
`modelIds().size() == 1` — broken for batch-add-into-empty-session
because the session registry is populated synchronously by all
addModel calls before any modelGeometryReady lands. Result: load 3
files at once → none of the per-model geometry-ready events ever
finds `size() == 1` → guess never fires → camera + federation origin
stay at surveyor coords.
Rework:
* **Arm/consume at the command boundary.** modules/models/Commands
exposes `armFederatedFalseOriginGuess` / `consumeFederatedFalseOrigin
Guess` (function pair — arming is a one-shot, raw-bool would let a
peek-without-clear silently break the contract). `addModel` and the
cloud-callback in `addModelFromCloud` arm if `modelIds().isEmpty()`
at the moment they're about to register federation entries. The
first geometry-ready then consumes the arm and runs the guess —
batch add or single, works the same way.
* **Lazy first geometry point from mesh AABB centre.** Drop
SceneLoader's `firstPlacement(mid)` / `first_placement` /
`has_first_placement` and the two capture sites entirely. The
viewport keeps CPU-side MeshInfo + InstanceCpu for picking /
measurement; compute the anchor on demand via new const accessor
`WgpuViewportWindow::firstGeometryPointWorldM(mid, out)` =
instance0.placement × meshes[instance0.mesh_id].aabb_centre.
This is more representative than the placement translation
(placements often live far from the actual geometry due to long
ObjectPlacement chains / intermediate local frames), and lighter
storage-wise (lazy, vs. 128 B per model held just-in-case).
* **`guessFederatedFalseOrigin` math signature: Matrix4d → Vector3d.**
The function only ever consumed `.block<3,1>(0,3)`; the Matrix4d
API surface was a strictly-larger-than-necessary contract. Vector3d
matches what the function actually needs.
* **`WgpuViewportWindow::frameOnFederatedOrigin(mid, max_distance_m)`**
replaces the post-shift use of viewAll() that the ViewportView
almost reached for. The federated false origin sits at (0,0,0) in
post-shift space by construction, so the camera targets there
directly; distance fits the model's post-shift AABB diagonal with
viewAll's padding math, clamped to `max_distance_m` so a model
with one crazy-coord outlier vertex can't pull the camera back so
far the real geometry becomes a pixel. Called with 100 m cap from
the guess. Unlike viewAll() this only iterates the one model the
guess fired for — the "load 10 models, viewAll shows nothing"
failure mode is structurally avoided.
* **ViewportView::tryGuessFirstModelFalseOrigin** renamed to
`guessFederatedFalseOriginFromFirstModel` and the body restructured
to consume the arm, look up the anchor via the viewport, mutate
the federation origin (which propagates through SessionState's
federatedFalseOriginChanged relay → refresh() → recompose all
instance world AABBs), then `frameOnFederatedOrigin(mid, 100)`.
* **Internal guards preserved.** filePath skip (project files own
the origin), current==defaults skip (don't clobber a user who
set the origin manually then removed the model), placement /
georef availability checks — defense-in-depth around the arm, not
the primary gate. The arm-only flow means re-arming on add-into-
empty-session is naturally re-firable: add → remove → add will
retry if the previous guess returned defaults.
## 2. wgpu present-mode: prefer Immediate above FifoRelaxed
On Linux Vulkan stacks where the driver / compositor doesn't advertise
Mailbox (confirmed on the user's setup — capability log added in this
patch reports just `fifo, fifo_relaxed, immediate`), Fifo's 2–3 frame
queue doubles input-to-photon latency the moment WASD activates
(~16 ms render-body fully consumes the budget, so the queue is held
deep). On a 60 Hz display this reads as "less smooth than the 100 fps
HUD suggests" during fly-mode mouse-look-while-moving — confirmed by
WGPU_FLY_DEBUG dt traces (rock-solid 16-17 ms cadence, so it's not
frame pacing — it's latency).
Promote Immediate above FifoRelaxed in the preference order so that
when Mailbox is unavailable we pick the no-queue option (can tear
under fast motion, but tearing on architectural geometry is usually
invisible while the latency win is immediately felt). Also log the
full advertised capability list on first configure so future "why
isn't Mailbox available?" diagnostics don't need a code patch.
Mailbox remains first preference; Fifo remains the spec-guaranteed
final fallback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements the viewer side of CLOUD_SYNC_PROTOCOL.md: connector
discovery, JSON-RPC stdio host, and Open/Save/Sync/Add cloud workflows
wired through the ribbon, Models panel right-click, and Settings tab.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Use IfcMapConversion.Scale as the source of truth for converting map coordinates to metres, instead of deriving that scale from IfcProjectedCRS.MapUnit. Bump the sidecar version because cached georef matrices and unit scales may differ under the new interpretation.
Generated with the assistance of an AI coding tool.
Promotes five env-var-driven knobs to AppSettings + the settings dialog
(min pixel radius, motion min pixel radius, LOD1 pixel threshold, HiZ
resolution, HiZ on/off). Defaults: motion min pixel radius is now 10
(was 0/disabled) and IFC_HIZ_MOTION is on by default — the strict
view-projection gate reverts via env var =0 when chasing HiZ
correctness bugs. ViewportWindow connects each *Changed signal so
changes invalidate cached cull state and take effect on the next
frame.
Removes "Load Property Data Source" and "Apply Coordinate Operation"
from the settings dialog: both are now hardcoded on. The basic-info
property fallback (used when there's no live IFC source for an object,
e.g. .ifcview without a sibling) now triggers organically when
ElementRegistry::findEntity returns null instead of being gated on a
user toggle. Federation::guessFederatedFalseOrigin lost its
apply_coordinate_operation parameter and now uses
georef.has_coordinate_operation directly.
src/ifcviewer/settings.rst documents the remaining diagnostic env vars
(IFC_HIZ_MOTION, IFC_CULL_THREADS, IFC_SKIP_MDI, IFC_MAX_SUBDRAWS,
IFC_FPS_HITCH_MS, IFC_SUBDRAW_DIAG, IFC_LOD_*) plus a cross-walk from
the old promoted-knob env-var names to their new QSettings keys.
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>
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>
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>
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>