Commit Graph

19400 Commits

Author SHA1 Message Date
Dion Moult ed21dd7ecc web: MODULARIZE build + embedded JS-integration example + selection callback
Restructure the web viewer so the wasm is a reusable module and add a
second example that drives it from ordinary page DOM.

Build:
- Emit IfcViewerWeb.js (a `createIfcViewer` factory, MODULARIZE) + .wasm
  instead of a single baked page (dropped --shell-file); copy the static
  example pages next to it at build time.
- Unbreak the web build: CameraMath.h / ViewportCore.cpp used
  boost::math::constants::pi just for pi, pulling all of boost/math into a
  header shared with the Emscripten build (no Boost in its sysroot). Replace
  with a constexpr kPiF — identical value, no dependency, desktop unaffected.

JS integration (web/ifcviewer.js):
- A small helper wraps the factory: boots the viewer on a canvas, runs the
  RAF loop from onRuntimeInitialized (NOT a post-await .then, which stalls
  Dawn-web's device callback and leaves the device half-initialised), and
  exposes addFile/addUrl, clearScene, model list/progress, and onSelect(...).
- ViewportCore/main_web emit each pick to JS via Module.__ifcvOnSelect
  (object id + IFC GlobalId + model index; empty on deselect); onSelect also
  dispatches an 'ifcviewer:select' DOM event.
- Fix input coords for a non-fullscreen canvas: mousemove/mouseup are
  window-targeted, so convert their coords to canvas-relative via the canvas
  client-rect origin (marquee + box-pick were offset when embedded).

Examples:
- IfcViewerWeb.html: the fullscreen viewer (same DOM/behaviour as before,
  now loading the module) — the Playwright smoke suite still targets it.
- embedded.html: a sized viewer with DOM outside it to add models (file or
  URL), list loaded models with streaming progress, and show the model +
  GlobalId of the clicked object. Starts empty (drops the wasm's embedded
  sample, which the fullscreen page/tests still use).
- index.html links both.

Federation note: the web viewer already streams multiple models into one
scene (a byte-source per file/URL); it doesn't need the desktop Federation
document for this. Verified: 11/11 web smoke tests pass; embedded example
loads models, reports the picked model + GUID, and the marquee aligns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:46:29 +10:00
Dion Moult b2ecfab86e style: theme input/tab-bar, align header height with body rows
- Theme QInputDialog (the New Group / Rename Group popup) so it follows
  the dark theme instead of rendering light.
- Theme the generic QTabBar that QMainWindow creates for tabbed docks
  (previously bright white). The app's own #appTabBar keeps its look via
  more specific selectors.
- Reduce QHeaderView::section vertical padding (7px -> 4px) so table/tree
  header rows match the body row height throughout the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:17:55 +10:00
Dion Moult 93fcdc9a8d viewport: don't clobber the persisted nav preset at startup
initWgpu() runs on the first exposeEvent, after MainWindow has already
applied the nav preset saved in Settings. It then unconditionally
re-applied "blender" whenever WGPU_NAV_PRESET was unset, silently
overriding the user's saved choice — so the applied navigation didn't
match what Settings showed.

Only apply the preset from WGPU_NAV_PRESET when that env override is
actually set; otherwise leave the current preset (MainWindow's persisted
choice, or the blender default). The startup log now reports the effective
orbit/pan bindings rather than a hardcoded name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:17:55 +10:00
Dion Moult 6e86072d5a viewport: select a section plane, highlight it, delete the selected one
Previously Del always removed the most recently added section plane. Now a
plane can be picked and deleted individually:

- ViewportCore tracks a selected plane index, kept valid as planes are
  added (the new one becomes selected), removed, or cleared.
- Clicking a gizmo with the section tool active selects that plane.
- The section gizmo geometry is baked white and coloured via its per-plane
  tint, so the selected plane draws in a bright amber highlight while the
  rest stay red (unchanged look).
- Del/Backspace removes the selected plane, falling back to the most recent
  one when nothing is selected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:48:38 +10:00
Dion Moult 12002ace86 properties: filter psets/quantities by name, property, or value
The filter field now actually filters. Typing shows only the sets whose
name matches, or that contain a matching property name/value — and when
it's a property/value match, only the matching rows are kept (neighbouring
rows are dropped). Matching is case-insensitive.

Set widgets live in a per-section container that's rebuilt from the raw
data on each keystroke, so filtering never recreates the filter field (its
focus and cursor are preserved). Placeholder reads "No properties/
quantities" with no data, "No matching properties/quantities" when the
filter excludes everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:21:11 +10:00
Dion Moult 392af501d1 properties: real empty states + smaller base UI font
- Replace the mock IfcWall placeholder with a "No item selected" empty
  state; the panel only fills in class/attributes/relationships/psets from
  a resolved object, and safely stays empty otherwise.
- Show "No properties" / "No quantities" placeholders (muted, themed via
  secondary_text) when those sets are empty.
- Drop the base application font 10pt -> 9pt to fit more data. Panel titles
  keep their own explicit size and are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:37:17 +10:00
Dion Moult 4afb3892a2 spatial hierarchy: storey elevation + Long Name column + resizable layout
- helpers/placement: port get_storey_elevation (placement Z, falling back
  to the Elevation attribute), matching ifcopenshell.util.placement.
- Add a secondary column: the storey elevation for IfcBuildingStorey,
  otherwise the LongName when filled. Elevations are right-aligned.
- Columns: Name is drag-resizable (interactive) and defaults to 20% of the
  width, Long Name stretches to fill the rest, and the eye is pinned to the
  right at a fixed width. Header shown so the divider can be grabbed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:33:29 +10:00
Dion Moult 90196dd51d viewport: idle the render loop when only unfetchable chunks remain
The streaming settle burst re-armed the render loop whenever a
non-resident chunk was frustum-visible, but the enqueue only fetches
chunks that are contribution-visible (big enough on screen) and not in a
blocked cooldown. A chunk that is in the frustum but sub-pixel is never
loaded, so visible_pending stayed true forever and the loop spun at full
frame rate with no input.

Match visible_pending to the enqueue's eligibility test: a non-resident
chunk keeps the loop alive only if it's actively loading, or is
contribution-visible and past its cooldown. Sub-pixel / cooldown-blocked
chunks no longer prevent idle; they still stream in when a camera move or
eviction requests a frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:43:07 +10:00
Dion Moult 60cab3e7c4 spatial hierarchy from IFC + active-model concept
Build the spatial hierarchy panel from the loaded model's real IFC
spatial structure instead of mock data:
- helpers/element: add get_spatial_children (IsDecomposedBy -> RelatedObjects,
  filtered to spatial elements) to walk IfcProject -> IfcSite -> IfcBuilding
  -> IfcBuildingStorey -> IfcSpace.
- SessionState: relay dataSourceReady as modelDataSourceReady (the .ifc for a
  sidecar hit loads asynchronously, so the tree can only build once it arrives).
- spatial_hierarchy/View: walk the active model's IFC file into a TreeNode
  tree, naming nodes by Name (fallback to class), mapping site/building/storey
  kinds; siblings sorted with natural (numeric) collation.
- spatial_hierarchy/Panel: tree now fills the panel height (setBodyExpanding +
  Expanding size policy); right-click menu for recursive Expand/Collapse
  Subtree and Expand/Collapse All.

Add the concept of an active model:
- SessionState: activeModelId / setActiveModelId / activeModelChanged; the
  first loaded model is active by default; reassigns/clears on removal.
- Models panel: clicking a model makes it active; its cube icon is drawn with
  the accent colour (makeAccentSvgIcon) via FederationItemModel::setActiveModelId.
- The spatial hierarchy reflects only the active model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:38:42 +10:00
Dion Moult ec285fc32c properties: show real property and quantity sets
Populate the Properties and Quantities sections from the pset helper:
get_psets(psets_only) for Pset_*, get_psets(qtos_only) for Qto_* /
BaseQuantities, inheriting occurrence-over-type values. A toPropertySets
converter drops the internal "id" key and non-scalar values, formats
scalars for single-line cells, and skips empty sets. Placeholders are
cleared once a project is loaded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:22:05 +10:00
Dion Moult 8ac7b4373e properties: real attributes + relationships; keep selection on deselect
Add helpers to src/helpers/element: get_scalar_attributes (primitive
EXPRESS attributes only — entity refs / aggregates omitted), get_type
and get_container (ports of ifcopenshell.util.element), and a public
get_string_attribute for safe by-name reads.

Wire them into the properties panel:
- Attributes section shows the element's direct primitive attributes for
  live entities, or cached GlobalId / Name for geometry-only elements.
- Relationships section shows the construction Type and spatial Container
  by name (falling back to the class when unnamed).
- Placeholders are cleared once a project is loaded, so no mock data leaks.

Also: a deselect (click on empty space -> object_id 0) no longer resets
the panel; it keeps showing the last active object. Project reset/open
still clear it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:14:13 +10:00
Dion Moult c5ea612110 helpers: port util.element.get_predefined_type; show it in properties
Add src/helpers/element.{h,cpp}, a schema-dispatched C++ port of
ifcopenshell.util.element.get_predefined_type: prefers the associated
type element's predefined type (IsTypedBy / IsDefinedBy), falls back to
ElementType / ProcessType when USERDEFINED, then the occurrence's own
PredefinedType / ObjectType. Attribute reads are by-name so they work
across the IfcElement / IfcType* subtypes that carry these attributes.

Wire it into the properties panel entity summary: live IFC entities show
their real predefined type; geometry-only elements (a .ifcview loaded
without its .ifc/.rdb) show "N/A". Clears the placeholder so a stale
predefined type no longer leaks once a project is loaded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:48:09 +10:00
Dion Moult 75c9da5098 ifcviewer: overhaul model/object ID tracking
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>
2026-07-08 14:10:05 +10:00
Thomas Krijnen b441fada90 Merge branch 'ifcviewer-wgpu' of https://github.com/IfcOpenShell/IfcOpenShell into ifcviewer-wgpu 2026-07-03 13:35:06 +02:00
Thomas Krijnen c9ee7695f3 include windows.h 2026-07-03 13:29:08 +02:00
Thomas Krijnen 1e032d188f boost math constants 2026-07-03 13:28:53 +02:00
Thomas Krijnen cdd1ecd15c Update parse examples 2026-07-03 13:28:02 +02:00
Thomas Krijnen 1c69f41e0a Examples now also depend on helpers 2026-07-03 13:27:26 +02:00
Thomas Krijnen 971cef0170 Schema dispatch in helpers 2026-07-03 13:26:30 +02:00
Thomas Krijnen 55e97e5379 Style 2026-07-03 12:14:15 +02:00
Dion Moult a7f6aaa725 docs: rewrite viewport architecture page + add .ifcview format reference
Rewrite the BonsaiViewer viewport architecture page to match the current
renderer (updated type names, streaming/sidecar flow). Add a dedicated
.ifcview sidecar format reference page and link it from the ifcopenshell
formats toctree, and polish the Bonsai intro copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:26:45 +10:00
Dion Moult 66d558ec2d ifcviewer: rename sidecar transfer/record types; drop unused element hierarchy (sidecar v17)
Rename the streamer/sidecar transfer and record types to describe what
they are rather than how they move:

  MeshChunk         -> StreamedMesh
  InstanceChunk     -> StreamedInstance
  InstanceCpu       -> InstanceInfo
  PackedElementInfo -> ElementTableRecord
  uploadMeshChunk   -> uploadStreamedMesh
  uploadInstanceChunk -> uploadStreamedInstance
  buildMeshChunk    -> buildStreamedMesh

and the two post-index sidecar metadata blocks:

  "critical" metadata -> "geometry" metadata  (meshes/instances/georef/TOC)
  "deferred" metadata -> "element"  metadata  (elements + string table)
  parseSidecarCritical -> parseSidecarGeometryMetadata
  parseSidecarDeferred -> parseSidecarElementMetadata

The one behavioural change: the element hierarchy (parent_id) was
carried through ElementInfo, ElementTableRecord, and the sidecar element
table but never consumed, so drop it and bump SIDECAR_VERSION 16 -> 17.
No back-compat: regenerate sidecars. sample.ifcview is regenerated at v17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:26:31 +10:00
Dion Moult da5c0b7991 ifcviewer: section-plane cut tool on web — shared gizmo, true-face pick, drag/Del
Full section tool for the web viewport, with the gizmo + interaction shared with
desktop from one codebase.

- True-face surface pick. pickSurfaceAt had always ray-cast the instance AABB (to
  skip a depth readback), so cuts sat in front of the real surface. The pick
  fragment already computes the exact world_pos (it clips sections with it); now
  it OUTPUTS it to a 3rd pick MRT (RGBA32F) that every pick path renders, and
  pickSurfaceAt / pickSurfaceAtAsync read it back (decodeMappedPickPosition;
  ray-AABB kept only as a fallback). The web async pick chains id -> normal ->
  position spontaneous staging maps.
- Web tool: LMB drops a cut at the picked surface (LMB drag still orbits), K
  toggles, Shift+K clears; oriented to the real MRT surface normal. Exports + a
  Section / Clear cuts toolbar pair.
- Shared gizmo: lifted the section-gizmo renderer (SECTION_WGSL + thick-line AA +
  quad+arrow VBO + pack + screen-space hit-test) out of the Qt-coupled
  OverlayRenderer into a Qt-free SectionGizmoRenderer that ViewportCore::render
  draws for BOTH desktop and web (both already render via render()). One identical
  gizmo; OverlayRenderer's now-dead section code removed. Fixed 1 m size (matches
  the desktop constant).
- Interaction (shared): hitTestSectionGizmo (SectionGizmoRenderer::hitTest) +
  beginSectionDrag / updateSectionDrag / endSectionDrag live in ViewportCore.
  Drag a gizmo arrow to slide the plane along its normal; Del/Backspace removes
  the most recent cut. Desktop's ViewportWindow dropped its duplicate hit-test /
  drag math + state and delegates to the core; web wires the same calls.

Tests: sectionPlaneCount add/clear/cap (Catch2, 125); web smoke "click a surface
cuts geometry, clear restores" exercises the shared gizmo + 3-MRT pick (11/11).
Desktop object-pick / marquee unaffected; BonsaiViewer builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:20:50 +10:00
Dion Moult 9ad10c009b Add Bonsai Viewer about license
Replace the settings About placeholder with product, GPL, and third-party license information.

Generated with the assistance of an AI coding tool.
2026-07-03 19:20:50 +10:00
Dion Moult a14cecf68b Document viewer test commands
Add IfcOpenShell-Python and IfcViewer test-running documentation, including desktop CTest targets and web Playwright smoke tests. Move the web test README content into the Sphinx docs.\n\nGenerated with the assistance of an AI coding tool.
2026-07-03 19:20:50 +10:00
Dion Moult 791ff26697 Update Bonsai viewer licensing docs
Update Bonsai viewer headers to identify Bonsai and GPL licensing, add Bonsai Viewer documentation, and document debug output capture.

Generated with the assistance of an AI coding tool.
2026-07-03 19:20:50 +10:00
Dion Moult cd3d70172b ifcviewer: marquee box-select on web + suppress the canvas context menu
Bring rubber-band box-select to the web on the Web preset's select button (RMB).

- Core: factor the pick-pass encode + rect copy out of picksInRect into
  encodeBoxPickToStaging (mirroring how single-pick shares
  encodePickReadbackToStaging), shared by the sync picksInRect (desktop) and a
  new async picksInRectAsync (web) — the latter maps the staging buffer via a
  spontaneous callback because the sync spin-map hangs the JS loop. New
  applyMarqueeToSelection (plain replace / Shift add / Ctrl remove).
- Web main_web: a select-button drag past the click threshold draws a marquee
  rubber-band (a plain DOM <div> positioned in CSS px — no GPU overlay pass,
  which the web lib lacks) and on release box-picks the rect (device px) and
  applies it to the selection. A click (no drag) still single-picks.
- Web shell.html: the #marquee div + styling, and — the reported bug — a
  contextmenu preventDefault on the canvas so RMB (now the select button) doesn't
  pop the browser menu. (Firefox still forces its native menu on Shift+RightClick;
  that's a browser escape hatch pages can't override.)

Tests: applyMarqueeToSelection replace/add/remove + id-0 (Catch2, 124 total);
web smoke marquee drag → rubber-band shown → selection changes → hidden (10/10).
Desktop picksInRect unchanged in behaviour; BonsaiViewer builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:20:50 +10:00
Dion Moult cb1ab9cbfb Remove wgpu memory probe
Drop the standalone WGPU memory-allocation diagnostic and its conditional CMake target.\n\nGenerated with the assistance of an AI coding tool.
2026-07-03 19:20:50 +10:00
Dion Moult 8dfe00cdf8 Improve viewer variable names
Rename short local variables and parameters in the viewer loading, sidecar, and BonsaiViewer command paths to make their responsibilities clearer.\n\nGenerated with the assistance of an AI coding tool.
2026-07-03 19:20:50 +10:00
Dion Moult f1d97ac5aa ifcviewer: preset-driven nav mouse bindings + a "Web" preset (desktop + web)
Make orbit/pan/select mouse bindings pure data owned by ViewportCore so both
hosts and every preset share one source of truth, and add a "Web" preset. This
rounds out the matrix: the desktop gains a web-style scheme and the web inherits
all presets, with no per-platform hardcoding.

- Core: NavBindings { orbit, pan, select button + modifier } + setNavPreset
  ("blender" default | "rhino" | "revit" | "web") + navBindings(). Select is
  preset-driven too (was hardcoded LMB) so "web" moves it to RMB. web = orbit
  LMB, pan MMB, select RMB (LMB drag orbits with no click/drag ambiguity; RMB
  click-selects / drag-marquees). NavMod uses "Plain" not "None" (X11 #defines
  None to 0L).
- Desktop ViewportWindow: applyNavPreset sources the core table (mapped to Qt);
  marquee-arm / single-pick dispatch keys off select_button_. Default stays
  blender → no behaviour change.
- Desktop config: AppSettings::NavPreset gains Web + navPresetName(); the
  Settings dialog lists it. This also FIXES a pre-existing gap — the preset combo
  was persisted but never applied (only WGPU_NAV_PRESET env worked). MainWindow
  now applies the persisted preset at startup (env override still wins) and live
  on navPresetChanged, so all four presets actually work from the dialog.
- Web main_web: classifyPress routes the pressed button through navBindings()
  (orbit/pan/select), defaulting to the "web" preset; context menu already
  suppressed so RMB is free.

Tests: setNavPreset table (Catch2, 123 total); web smoke select tests use RMB.
BonsaiViewer builds; 9/9 web smoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:20:50 +10:00
Thomas Krijnen a54a8b80d3 Rename to helpers 2026-07-03 11:18:57 +02:00
Thomas Krijnen 5873b05b84 Unify zstd lookup 2026-07-03 10:54:18 +02:00
Thomas Krijnen cf05bbd1bb Merge branch 'datamodel-v1.0' into ifcviewer-wgpu 2026-07-03 10:30:16 +02:00
Dion Moult 4faae025b4 ifcviewer: tests for the new viewing features (fly, x-ray, visibility)
Coverage had lagged the recent feature work. Add both layers:

- test_viewport_camera (Catch2, headless): constructs ViewportCore with a mock
  ViewportHost — construction/teardown touch no GPU (the wgpu teardown lives in
  releaseWgpuModelGpuData, only reached with models loaded), and the camera ops
  are pure — so it unit-tests the SHARED fly math fast and deterministically:
  flyMove (forward step, 5x boost, opposing-key cancel, dt clamp, QE along +Z,
  degenerate-pitch stays finite), flyLook (turn-in-place pins the eye, pitch
  clamp), flyAdjustSpeed (x1.25/notch, [0.05,1000] clamp), toggleXray, and
  hideSelected/showAll. Links the built IfcViewerCore (ViewportCore.cpp is the
  monster TU that can't compile standalone). +9 cases → 122 desktop.

- smoke.spec.mjs (Playwright): fly (enter → W moves the camera → Esc exits),
  x-ray (toggle translucency on/off), and hide-after-pick, driving the exported
  C hooks end-to-end. +3 cases → 9 web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:23:30 +10:00
Dion Moult 68d6280c93 ifcviewer: visibility (hide/isolate/show-all) + X-ray — desktop parity on web
Lift the visibility + X-ray ops into ViewportCore so desktop and web share them.
The cull already reads visibility_ (hidden objects skipped) and the frame uniform
reads xray_alpha_cap_, both per frame, so each op just mutates state + schedules
a frame — no GPU buffers to rebuild, no rendering work:
- hideSelected (hide the selection, then deselect), isolateSelected (hide every
  non-selected object in a visible model), showAll (clear the hidden set),
  toggleXray (flip the alpha cap 1.0<->0.3; cull routes all to the transparent
  pass), xrayActive().
- Desktop ViewportWindow: the H / Shift+H / Alt+H / Alt+X keys and the menu-action
  wrappers now call the core; the four inline/duplicated implementations are gone.
- Web main_web: same keys (H hide · Shift+H isolate · Alt+H show all · Alt+X
  x-ray) + toolbar buttons (Hide / Isolate / Show all / X-ray, the last reflecting
  active state).

Verified on web: x-ray toggles and visibly translucent-izes the scene, hide after
a pick removes geometry, 0 GPU errors. 113/113 desktop + 6/6 web smoke; desktop
app builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:47:03 +10:00
Dion Moult bdfa70468e ifcviewer-web: frame the first model only on load, matching desktop
applyCachedModel already does a one-time viewAll gated by initial_view_applied_
(desktop + web) — it frames the FIRST model loaded and never re-frames as more
arrive. Two web-only divergences from the desktop viewer, fixed here:

- loadSidecarMetadataWeb called viewAll() AGAIN, unconditionally, per model, so
  every federated model that streamed in yanked the camera back to fit the whole
  scene. Drop the redundant call; the shared gate handles first-model framing.

- Nothing ever cleared initial_view_applied_. On web the embedded sample sets it
  at startup, so after clear_scene_c (a ?model= federation load) the gate was
  already tripped and the loaded models never got framed — the camera stayed on
  the prior view. (The redundant per-model viewAll above masked this until it was
  removed.) Clear the flag in resetScene: a fresh scene should auto-frame its
  first model — correct for desktop fresh-opens too.

113/113 desktop + 6/6 web smoke pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:58:51 +10:00
Dion Moult f9acf8be3a ifcviewer: first-person / fly view — share the desktop fly camera with web
DRY the fly camera into ViewportCore so desktop and web share identical math:
- flyMove(fwd,back,right,left,up,down,boost,dt): WASD in the view plane, QE
  along world +Z, forward = eye→target (no snap after orbiting), Shift = 5x,
  dt clamped to 0.1s. flyLook(dx,dy): turn in place (yaw/pitch, eye pinned,
  0.2 deg/px, pitch ±89.9). flyAdjustSpeed(notches): wheel scales speed x1.25.
  fly_move_speed_ + orbitEye now live in the core (orbitEye's duplicate removed
  from ViewportWindow).
- Desktop ViewportWindow: fpsIntegrate / mouse-look / wheel-speed call the core
  methods; behaviour unchanged, BonsaiViewer builds clean.
- Web main_web: Shift+F (or the Fly toolbar button) enters and pointer-locks the
  canvas; W/A/S/D/Q/E + Shift are held-tracked (keydown/keyup) and integrated
  each RAF frame with wall-clock dt; pointer-lock mouse deltas drive flyLook;
  wheel tunes speed. Exit on Esc OR a canvas click (matches desktop) — a
  pointerlockchange handler catches the browser eating the first Esc to release
  the lock (so a single Esc exits), guarded by fly_locked so a denied lock on
  entry doesn't insta-exit. Fly button reflects/ syncs active state.

Verified: web enter→WASD moves→click/Esc exits, 0 GPU errors; 113/113 desktop
core + 6/6 web smoke; desktop app builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:04:35 +10:00
Dion Moult 0b8c787ac0 ifcviewer: v16 zstd-compressed sidecars (~10x smaller over the wire)
The .ifcview data is hugely redundant (repeated double instance matrices,
patterned indices) — measured 12x zstd whole-file. Server Content-Encoding
can't be used (it breaks HTTP Range), so compress PER-CHUNK into the format.

Format (v16): geometry becomes per-chunk zstd(vertices)+zstd(indices) frames —
each independently Range-fetchable, so streaming is intact — and the critical +
deferred metadata blocks are single zstd frames. SidecarChunk carries the
compressed blob offsets/sizes; applyStreamedChunk (render/upload) is UNCHANGED —
decompression slots into the fetch. Full readSidecar (test/tooling) reconstructs
by decompress+scatter. zstd: desktop links libzstd (also compresses at bake);
the web build (Emscripten has no zstd port) FetchContent's the pinned zstd
source and compiles its decompress-only subset for wasm — no vendored blob,
same version as desktop. New SidecarCompress wraps it (compress guarded off
under Emscripten). Both stream paths — desktop StreamingThread worker + sync
fallback (readChunkGeometryCompressed) and web beginWebChunkLoad — decompress;
readSidecarMetadataOnly / the web bootstrap / loadDeferredMetadataWeb decompress
the metadata blocks. streamingByteProgress reports COMPRESSED bytes. MEASURED: a
752 MB v15 federation → 75 MB v16 (10x; per-file 6.7-15.3x); PP-PLP 118→15 MB,
loads 13/13 chunks on web, 0 errors.

Three fixes found while testing big federations on a real server:
- Web-streamed race: streaming_from_web was set in the deferred-header callback
  (a round-trip after the model+chunks exist), so driveStreamingLoads could take
  the sync fopen path meanwhile → "failed to read/decompress chunk 0". Now set
  immediately after applyCachedModel.
- OOM abort on 18 models: the pool grew unbounded until an alloc failed, but on
  web that's an uncatchable bad_alloc abort. Cap total pool capacity
  (setMaxTotalCapacity, 3 GB) so it stops before the heap ceiling, and raise
  MAXIMUM_MEMORY 2→4 GB (wasm32 max) for headroom.
- Web never evicted (grow-or-block only). At the hard budget, fall through to the
  LRU/priority evictor so a big federation stays navigable (highest-contribution
  chunks win) instead of freezing with holes.

113/113 desktop + 6/6 web smoke pass. No back-compat: regenerate sidecars
(desktop bakes v16; scratch conv tool migrates v15→v16).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:03:19 +10:00
Dion Moult 5299f6c13c ifcviewer-web: contribution-cull streaming + combined loaded/needed/total bar
Streaming picked candidates on raw frustum visibility, so viewAll over a big
federation (all models in-frustum) fetched every chunk — even fine chunks that
project to sub-pixel and the renderer never draws. Gate streaming on the SAME
contribution decision the render path already makes.

- New per-chunk contribution_visible_count: instances that passed frustum AND
  the contribution cull (projected radius >= min_radius_px), counted BEFORE HiZ
  — so it's stable while the camera is still (unlike the HiZ-post counters,
  which flip frame-to-frame and would thrash the loader) and shifts only on
  navigation, when the working set should. The candidate gate skips chunks with
  count 0: the network pulls only what's resolvable now; the rest stream in as
  you approach. Verified: fit view needs the geometry, zoomed-out needs 0.

- Loading UI reworked from per-model segments to a combined bar over the whole
  federation: dark track = not needed for this view, dim = needed-but-unloaded,
  bright = loaded. "loaded / needed" = how done THIS view is; "needed / total" =
  how much of the model the view requires — "Loading 45 / 90 MB for this view ·
  12% of 718 MB total". Driven by ViewportCore::streamingByteProgress + ifcv_bytes_*.
  Two fixes from testing: (1) show a distinct overhead phase while total==0
  ("Loading model data — X MB · Y/N models ready") so the metadata download
  isn't a dead-looking bar; (2) re-assert display:block every active frame so the
  bar REAPPEARS when navigation reveals new chunks (was only set in
  beginLoadProgress → stayed hidden after the first catch-up). Per-model exports
  kept for a future detailed view.

111/111 unit + 6/6 web smoke pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:28:44 +10:00
Dion Moult 001476c5a9 ifcviewer-web: navigation parity — view all, XYZ views, ortho, zoom-to-selected
The camera math already lived in the shared ViewportCore; the desktop just
bound keys to it. The web build had no keyboard handler and no camera UI, so
none of it was reachable. Wire it up (Tier 1 + 2 of nav parity; fly mode is a
separate follow-up).

Shared core:
- setStandardView(StandardView) — named Front/Back/Left/Right/Top/Bottom wrapper
  over setStandardView(yaw,pitch), so the axis→angle mapping lives in one place.
- frameSelection() — lifts the desktop's "union selected AABBs → frameAabb(1.30)"
  focus logic out of ViewportWindow into the core. Desktop's focusOnSelectedObject
  and the X/Y/Z hotkeys now call these (DRY, behaviour unchanged).

Web:
- main_web gains a keydown handler matching the desktop bindings — Home=view all,
  F=zoom to selected, P=ortho toggle, X/Y/Z (+Shift=negative)=standard views —
  plus exported entry points (view_all_c / frame_selection_c / toggle_projection_c
  / projection_is_ortho_c / standard_view_c) for the toolbar.
- shell.html adds a bottom nav toolbar (Fit / Focus / Persp-Ortho / the six views)
  with the hotkeys in tooltips; the ortho button reflects state.

Verified: Z key and Front button both move the camera, ortho toggles render +
label, zero GPU errors; 111/111 unit + 6/6 web smoke pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:42:45 +10:00
Dion Moult 10ab982032 ifcviewer-web: load many models from the URL + per-model loading panel
Query string: auto-load a whole federation, not one model. Accepts repeated
params (?model=a&model=b&…) and/or a comma list (?models=a,b,c); each becomes
its own streamed byte-source, clearing the embedded sample once then streaming
concurrently into one scene. A failed URL is reported without aborting the rest.

Loading UI: the bare aggregate chunk count didn't reveal the federation state,
so surface per-model progress. ViewportCore::streamingModelCount() +
streamingModelProgress(idx,…) (ordered by model_id = load order) feed
main_web's ifcv_model_count_c / ifcv_model_{resident,total}_c. shell.html draws
a panel: "Loading N models — X done · Y streaming · Z waiting · N MB" plus one
segment per model (blue fill while streaming, green when resident, grey while
its metadata is still pending) — so parallel loading and how many remain are
visible at a glance.

Verified: 10 models from a 10x ?model= query stream in parallel; the panel
steps 10 waiting → streaming → all done. 111/111 unit + 6/6 web smoke pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:11:02 +10:00
Dion Moult 4c38e52741 ifcviewer-web: multi-file loading (federation) via a per-model byte-source
The scene core is already multi-model — models_gpu_ is a map, applyCachedModel
APPENDS, and per-model model_id / object_id rebasing / georef+transformation
are how the desktop federates today. The only web-specific gap was the byte
source: web had ONE global source (__ifcvFile/__ifcvUrl) and reset the scene on
every load, so it could show one file at a time. Desktop meanwhile carries a
per-model source (streaming_file_path).

Mirror that on web: give each model its own web_source_id into a JS source
registry (Module.__ifcvSources[id] = a picked File or a sized remote URL).
beginWebChunkLoad, the metadata bootstrap, and the on-demand deferred fetch all
read from the owning model's source, so several files stream concurrently into
one federated scene — reusing all the shared machinery (viewAll, picking, the
GUID fetch) untouched.

- webReadRangesAsync / ifcvReadRangeInto / ifcvSourceSize take a source id.
- loadSidecarMetadataWeb(source_id, …) appends (no resetScene); main_web
  exposes load_sidecar_from_source_c(id) + clear_scene_c().
- URL size resolution moved to JS (shell.html registers + sizes sources via
  HEAD/Range), retiring the C-side ifcvBeginUrlSource / ifcv_source_ready dance.
- shell.html: source registry + "Open" (replace) / "Add" (append) buttons,
  multi-file selection; ?model= registers a URL source then loads.

Verified: two sidecars from two sources stream into one scene, both fully
resident, zero GPU errors. 111/111 unit + 6/6 web smoke pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:03:24 +10:00
Dion Moult 681de6f817 ifcviewer-web: pick logs the object's IFC GUID via the on-demand deferred fetch
First real consumer of the v15 deferred property block, and an end-to-end
demonstration that on-demand property loading works. On a left-click pick,
logSelectedObjectGuidWeb ensures the owning model's deferred block is loaded
(loadDeferredMetadataWeb — a network fetch the FIRST time, cached after) and
logs the picked object's GUID to the console.

Fix uncovered while wiring it: applyCachedModel rebases instance object_ids to
a per-model global base (object_id_base) to keep them unique across models,
but the deferred elements carry the sidecar's original local ids — so a lookup
by the picked (global) id missed. Store object_id_base on the model and rebase
the elements by it when the deferred block loads.

Verified: with a streamed model, the deferred block is fetched ONLY after the
first pick (not at load), and the pick logs a valid 22-char IFC GUID. 111/111
unit + 6/6 web smoke pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:21:33 +10:00
Dion Moult 13ec2be807 ifcviewer-web: streaming loading bar (metadata → geometry progress)
Users had no feedback during the seconds before/while a model streams. Add a
top progress strip + caption driven by the streaming state:
- "Loading model… N MB" while the critical metadata downloads (no chunks yet),
- "Loading geometry — R / T chunks · N MB" as chunks go resident,
- "Loaded — T chunks · N MB", then it hides.

ViewportCore::streamingProgress(resident, total) sums chunk residency across
models; main_web exports ifcv_chunks_resident_c / ifcv_chunks_total_c for
shell.html to poll each RAF. The EM_JS range reader accumulates
Module.__ifcvBytesLoaded so the caption can show MB downloaded. Shown only for
streamed loads (?model= URL / picked file), not the embedded sample.

6/6 web smoke pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:54:42 +10:00
Dion Moult 41a85a70ba ifcviewer: v15 — defer property metadata off the first-paint path
First-paint over a network is metadata-bound: the whole post-index metadata
(~10 MB on a 118 MB model) had to download before any geometry. But ~25% of
it — elements + string_table, the IFC element tree (names/GUIDs/hierarchy) —
is used only for UI/picking, never for rendering (ViewportCore never touches
it).

v15 splits the post-index metadata into a render-CRITICAL block (meshes,
instances, georef, chunk TOC) preceded by its byte length, then a DEFERRED
block (elements + string_table). The web loader reads only the critical block
before painting; the deferred block sits at a known, self-describing offset
([critical end, EOF)) and is fetched on demand. Desktop reads both (local).

Web on-demand path is wired and complete (not yet called — no UI consumer):
loadDeferredMetadataWeb(model_id) range-fetches + parses the deferred block
into ModelGpuData.elements/string_table, at most once; the first consumer
will be "show the selected object's name" on pick. No background prefetch —
view-only sessions never download the property data (saves 2.64 MB on this
model).

parseSidecarTail split into parseSidecarCritical + parseSidecarDeferred (pure,
unit-tested); StreamingSidecar gains the critical-block locator. Measured
(118 MB model): critical metadata 10.35 -> 7.71 MB, deferred 2.64 MB off the
path; first paint 10.5 -> 9.6 s @ 24 Mbps. (Instances still dominate the
critical block — the next metadata lever.) Format -> v15, no back-compat;
regenerate sidecars. 111/111 unit + 6/6 web smoke pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:01:15 +10:00
Dion Moult e1be2f208c ifcviewer: v14 chunk-contiguous sidecar + progressive network streaming
Makes large-model streaming over a network actually good — fixing read
amplification, then first-paint latency — building on the byte-range work.

v14 layout + TOC (SidecarLayout, pure + unit-tested)
  The loader chunks meshes by spatial Morton order, but the sidecar stored
  geometry in mesh-id order, so a chunk's meshes were scattered through the
  file: streaming one chunk meant either hundreds of tiny range requests or
  reading (and discarding) everything between them — a 113 MB model fetched
  ~340 MB, a 531 MB model 2.25 GB (4.2x). Fix: at bake, reorder meshes into
  the loader's chunk order and rebuild vertex/index(LOD0+LOD1)/instance
  sections so each chunk is one CONTIGUOUS byte range, and bake a chunk TOC
  ({first_mesh, mesh_count}). The loader builds chunks straight from the TOC
  rather than re-deriving the plan — the float Morton quantisation isn't
  bit-identical across toolchains (x86 baker vs wasm loader), so a re-derived
  plan scatters the chunks. Format bumped to v14 (regenerate sidecars). The
  reorder buckets instances by per-instance mesh_id (the baker never sets
  MeshInfo.first_instance — trusting it scrambled every transform → geometry
  at the origin). Multiset-verified on a 28,900-instance model: every
  instance's placement + geometry preserved. Result: 531 MB fetches 531 MB
  (1.0x) in 72 requests (was 2036).

Progressive streaming (concurrency cap + small chunks)
  Even at 1x, geometry appeared only after ~the whole model arrived: the
  browser multiplexes every in-flight Range request over one HTTP/2 conn, so
  unbounded concurrency (9 in flight) split the bandwidth and nothing finished
  until the end (measured: first paint after 113 of 118 MB / 35 s @ 24 Mbps).
  Cap concurrent chunk loads (kMaxWebInflightChunks=2): the priority-sorted
  top chunks finish and paint first, then the next → first paint 9 s. Chunk
  size dropped 16->4 MB (cheap now that each chunk is one read; matches Cesium
  3D Tiles / xeokit / SVF2) for smoother progression. First-paint is now
  metadata-bound (~10 MB tail) — the next lever.

111/111 unit (new test_sidecar_layout: geometry preserved, contiguous layout,
Morton-identity) + 6/6 web smoke pass; desktop bake (SceneLoader) reorders
before writeSidecar; embedded web sample regenerated to v14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:22:47 +10:00
Dion Moult 46a695266a ifcviewer-web: export HEAPU8 for heap-size diagnostics
Lets tooling read the wasm heap size (e.g. to verify a large sidecar
streams by byte range instead of loading whole, and to watch memory while
battle-testing big models over the network). Standard emscripten runtime
method, zero size cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:26:12 +10:00
Dion Moult 9db42df81c ifcviewer-web: stop large-model network streaming thrash (grow before fetch)
Battle-testing real sidecars over HTTP Range exposed severe thrash: a
531 MB model re-fetched 2.25 GB (4×) and never converged — viewAll puts
the whole model in frustum, so every chunk wants to be resident, and the
web async path made it worse two ways:

  - A web load only consumes pool space when it COMPLETES (async), so the
    per-frame issuance over-committed the pool; completions then failed
    applyStreamedChunk on a full pool, the chunk re-candidated with no
    cooldown, and re-fetched every frame.
  - Pool growth is itself async on web (provisional sub-buffers validated
    off the JS event loop), so even fetched chunks failed to alloc until
    the pool caught up, and re-fetched.

Fix: gate web chunk issuance on VALIDATED free space + in-flight
reservation, and grow the pool BEFORE fetching:

  - streaming_web_inflight_bytes_ reserves each in-flight load's footprint
    so we never have more bytes in flight than the pool can place.
  - When a visible chunk doesn't fit validated free, don't fetch — call
    pool_.requestGrowth() (BufferPool: drives the async provisional grow
    without allocating) and short-back-off; the chunk is fetched once,
    after space exists. When the pool is saturated (model > GPU memory),
    long-cooldown so a never-fitting chunk isn't re-fetched. Gating before
    the evictor also kills phase-2 visible↔visible swap thrash.
  - On async load failure, cool down (short if the pool can still grow,
    long if saturated) instead of re-candidating next frame.

Result (manual battle tool, host.mjs + real files): 531 MB now loads
23/23 chunks, 322 MB loads 14/14 — resident climbs monotonically with
ZERO thrash warnings and a stable resident set, vs the old re-fetch loop.
The whole model resides on the GPU and stays. (Remaining ~3× ramp
over-fetch — per-chunk re-loads during the async-growth ramp + read
amplification from chunk byte-locality — is a separate efficiency
follow-up, not thrash.) 6/6 web smoke + 107/107 unit pass; desktop
unaffected (the gate is web-only; requestGrowth is a no-op wrapper there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:26:12 +10:00
Dion Moult 2c5e2d1685 ifcviewer-web: fetch a chunk's vertex + index ranges concurrently
beginWebChunkLoad read the vertex ranges, then in the completion callback
read the index ranges, then applied — two serial round trips per chunk.
On a network that's the dominant per-chunk latency. Now both reads fire
at once and a small shared join (payloads + per-read done/ok flags) runs
the apply when the second lands, halving per-chunk RTT. Model re-lookup
still happens at apply time, so a resetScene mid-flight is dropped safely.

Per-chunk concurrency stacks with the existing across-chunk concurrency
(driveStreamingLoads issues several loads per frame); the browser caps
simultaneous connections per origin, so no explicit in-flight cap is
needed. 6/6 web smoke + 107/107 unit pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:50:37 +10:00
Dion Moult 23dc5dac48 ifcviewer-web: stream remote sidecars over HTTP Range (?model=URL)
Adds a network byte-source alongside the local Blob one. The async-chunk
infra is source-agnostic — only the two JS primitives knew it was a Blob —
so this generalises them and reuses everything else:

  - ifcvReadRangeInto: local → Blob.slice; remote → fetch() with a Range
    header (206). If a server ignores Range and returns 200, the requested
    window is sliced out so it still works (without the bandwidth saving).
  - ifcvFileSize: Blob size, or the URL's total length resolved up front.
  - ifcvBeginUrlSource: resolves total size (HEAD Content-Length, else a
    0-0 ranged GET's Content-Range) then fires _ifcv_source_ready.
  - The metadata bootstrap is extracted into a source-agnostic
    loadSidecarMetadataWeb(label); loadSidecarFromBlobWeb / FromUrlWeb are
    thin entries. streaming_from_blob → streaming_from_web (now covers both).

main_web exports load_sidecar_from_url_c(url); shell.html reads a
?model=URL query param and ccalls it once the app is live (same-origin
needs no CORS; cross-origin hosts must send CORS + Accept-Ranges).

Test: serve.mjs now answers HEAD + Range (206) and falls back to the
ifcviewer-web source dir for sample.ifcview (embedded in the wasm, not in
build-web). New smoke case loads ?model=/sample.ifcview and asserts it
renders via the Range path. 6/6 web smoke + 107/107 unit pass; desktop
unaffected (web-guarded; only the shared field rename touches it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:44:37 +10:00