@nika-js/onlymap 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.md +39 -14
  3. package/THIRD-PARTY-LICENSES.md +27 -0
  4. package/dist/{LercDecode.es-fbo6h5rt.js → LercDecode.es-BOJJr6Gx.js} +1 -1
  5. package/dist/{basemap-CprSan9q.js → basemap-BofXgXxy.js} +13 -3
  6. package/dist/basemap.d.ts +10 -1
  7. package/dist/cityjson-D_V5GY8b.js +332 -0
  8. package/dist/clip-box-controller.d.ts +94 -0
  9. package/dist/clip-box.d.ts +109 -0
  10. package/dist/crs-Ciu7Xs7a.js +108 -0
  11. package/dist/crs.d.ts +50 -0
  12. package/dist/ctx.d.ts +8 -1
  13. package/dist/data-layer.d.ts +11 -3
  14. package/dist/draw-controller.d.ts +75 -1
  15. package/dist/draw.d.ts +22 -4
  16. package/dist/elements/om-map.d.ts +45 -2
  17. package/dist/elements/om-overlay.d.ts +13 -1
  18. package/dist/elements/om-widget.d.ts +4 -0
  19. package/dist/feature-colors.d.ts +41 -0
  20. package/dist/geodesy.d.ts +29 -3
  21. package/dist/{geoparquet-Bm8pfxyo.js → geoparquet-DapHATA0.js} +1 -1
  22. package/dist/html-data.d.ts +2 -2
  23. package/dist/ifc-Dc-TdSJr.js +877 -0
  24. package/dist/ifc.d.ts +406 -0
  25. package/dist/{index-Bz-rbmhj.js → index-7B-6Cbzu.js} +31619 -26565
  26. package/dist/{index-DSRIZR81.js → index-BF9iO8Tq.js} +1 -1
  27. package/dist/{index-GOwMjqm6.js → index-BmX6IId3.js} +1 -1
  28. package/dist/{index-UzmNhuS0.js → index-SPJVn_n_.js} +1 -1
  29. package/dist/{index-CCQttJzO.js → index-xmJjZxQJ.js} +2 -2
  30. package/dist/index.d.ts +13 -2
  31. package/dist/ir-snapshot.d.ts +3 -1
  32. package/dist/layer-registry.d.ts +14 -0
  33. package/dist/layers/bim-layer.d.ts +127 -0
  34. package/dist/layers/feature-mesh-layer.d.ts +288 -0
  35. package/dist/layers/gltf-scene-walk.d.ts +55 -0
  36. package/dist/layers/popup-layer.d.ts +13 -0
  37. package/dist/legend-spec.d.ts +1 -1
  38. package/dist/{lerc-CuifOkoA.js → lerc-C6k7EzSN.js} +2 -2
  39. package/dist/license.d.ts +12 -5
  40. package/dist/measure-controller.d.ts +458 -4
  41. package/dist/meshopt_simplifier-Co6uRDDA.js +370 -0
  42. package/dist/onlymap.standalone.js +57077 -50729
  43. package/dist/onlymapjs.js +72 -69
  44. package/dist/parse-manifest.d.ts +3 -0
  45. package/dist/programmatic.d.ts +39 -5
  46. package/dist/{raster-dysYIQ_k.js → raster-Cuzhibe2.js} +2 -2
  47. package/dist/{raster-pipeline-ZdCOGfqV.js → raster-pipeline-DLrnJK8y.js} +1 -1
  48. package/dist/react/om-layer.d.ts +6 -1
  49. package/dist/react/om-overlay.d.ts +9 -0
  50. package/dist/react.js +176 -169
  51. package/dist/region-export-controller.d.ts +37 -0
  52. package/dist/region-export.d.ts +56 -0
  53. package/dist/runtime-core.d.ts +260 -3
  54. package/dist/selection.d.ts +44 -1
  55. package/dist/site-placement.d.ts +107 -0
  56. package/dist/snapping.d.ts +88 -0
  57. package/dist/terrain-heightfield.d.ts +53 -0
  58. package/dist/terrain-sample.d.ts +30 -0
  59. package/dist/terrain.d.ts +6 -1
  60. package/dist/testing.d.ts +6 -2
  61. package/dist/tile3d-metadata.d.ts +185 -0
  62. package/dist/units.d.ts +27 -0
  63. package/dist/version.d.ts +1 -1
  64. package/dist/volumetrics-run.d.ts +13 -0
  65. package/dist/volumetrics-worker.d.ts +1 -0
  66. package/dist/volumetrics.d.ts +201 -0
  67. package/dist/widget-registry.d.ts +11 -0
  68. package/dist/widgets/ifc-clash.d.ts +18 -0
  69. package/dist/widgets/ifc.d.ts +41 -0
  70. package/dist/{zarr-OewK7k2K.js → zarr-CUzEX8fB.js} +79 -66
  71. package/docs/3d-assets.md +48 -0
  72. package/docs/live-data.md +9 -1
  73. package/docs/testing.md +4 -2
  74. package/llms.txt +10 -5
  75. package/onlymapjs.html-data.json +313 -2
  76. package/package.json +6 -2
  77. package/skills/onlymapjs/SKILL.md +14 -2
  78. package/skills/onlymapjs/references/react.md +2 -2
  79. package/skills/onlymapjs/references/syntax.md +314 -15
  80. package/dist/cityjson-urQeujQv.js +0 -407
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@nika-js/onlymap",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Declarative deck.gl maps for HTML and React — interactive WebGL mapping with GeoJSON/CSV/Arrow data, MapLibre basemaps, widgets, popups, and live streams from a custom-element manifest or typed React components. TypeScript, no build step.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
9
  "author": "NikaGeospatial",
10
- "homepage": "https://www.nikaplanet.com/onlymap",
10
+ "homepage": "https://onlymap.nikaplanet.com",
11
11
  "repository": {
12
12
  "type": "git",
13
13
  "url": "git+https://github.com/NikaGeospatial/onlymapjs.git"
@@ -116,9 +116,11 @@
116
116
  "test:e2e": "playwright test",
117
117
  "check-layout": "playwright test e2e/layout-audit.spec.ts",
118
118
  "gen:html-data": "vite-node dev/generate-html-data.ts",
119
+ "vendor:web-ifc": "node dev/tools/vendor-web-ifc.mjs",
119
120
  "gen:public": "npm run gen:html-data && vite-node dev/build-public.ts",
120
121
  "gen:site": "vite-node dev/build-site.ts",
121
122
  "test:public": "npm run gen:public -- --dry-run --strict",
123
+ "test:site": "npm run gen:public -- --out dist-public --strict && npm run gen:site -- --in dist-public --dry-run --strict --site-url https://onlymap.nikaplanet.com --cname onlymap.nikaplanet.com",
122
124
  "deploy:telemetry": "wrangler deploy --config cloud/workers/telemetry/wrangler.toml",
123
125
  "dev:telemetry": "wrangler dev --config cloud/workers/telemetry/wrangler.toml",
124
126
  "deploy:examples": "wrangler deploy --config cloud/workers/examples/wrangler.toml",
@@ -139,6 +141,7 @@
139
141
  "@loaders.gl/csv": "^4.4.3",
140
142
  "@loaders.gl/kml": "^4.4.3",
141
143
  "@loaders.gl/shapefile": "^4.4.3",
144
+ "@luma.gl/engine": "9.3.5",
142
145
  "@playwright/test": "^1.61.1",
143
146
  "@reduxjs/toolkit": "^2.12.0",
144
147
  "@types/d3-array": "^3.2.2",
@@ -163,6 +166,7 @@
163
166
  "hyparquet-compressors": "^1.1.1",
164
167
  "jotai": "^2.20.2",
165
168
  "maplibre-gl": "^5.24.0",
169
+ "meshoptimizer": "^1.2.0",
166
170
  "mobx": "^6.16.1",
167
171
  "playwright": "^1.61.1",
168
172
  "proj4": "^2.20.8",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: onlymapjs
3
- description: Build, edit, debug, or review OnlyMapJS declarative HTML maps and dashboards, or React maps via the @nika-js/onlymap/react adapter. Use when a user asks for an interactive map, deck.gl-style visualization, geospatial dashboard, live fleet/telemetry map, choropleth, popup/tooltip map, map story/tour, manual drawing/sketch map, 3D map assets, a React map component, a map page shared as a single HTML file (incl. no-JS fallbacks for chat/email previews), a responsive/mobile map whose controls auto-fold on narrow screens, auditing a map's widget layout with the check-layout tool, syncing OnlyMapJS map/camera state into an app state store (Redux, MobX, Zustand, Jotai — the getStore contract), or help with OnlyMapJS syntax, validation, widgets, data formats, testing, or publishing examples.
3
+ description: Build, edit, debug, or review OnlyMapJS declarative HTML maps and dashboards, or React maps via the @nika-js/onlymap/react adapter. Use when a user asks for an interactive map, deck.gl-style visualization, geospatial dashboard, live fleet/telemetry map, choropleth, popup/tooltip map, map story/tour, manual drawing/sketch map, 3D map assets, a React map component, a map page shared as a single HTML file (incl. no-JS fallbacks for chat/email previews), a responsive/mobile map whose controls auto-fold on narrow screens, auditing a map's widget layout with the check-layout tool, syncing OnlyMapJS map/camera state into an app state store (Redux, MobX, Zustand, Jotai — the getStore contract), BIM/IFC models (loading .ifc files in the browser, 3D Tiles per-element picking, isolate/hide/ghost, clash detection, model federation), or help with OnlyMapJS syntax, validation, widgets, data formats, testing, or publishing examples.
4
4
  ---
5
5
 
6
6
  # OnlyMapJS
@@ -23,7 +23,7 @@ Use OnlyMapJS as a declarative HTML map library. Write custom elements such as `
23
23
  </script>
24
24
  ```
25
25
 
26
- For no-build CDN pages, use the single-file standalone bundle from a raw-file CDN — `https://unpkg.com/@nika-js/onlymap@0.6.0` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.0/dist/onlymapjs.css">`. Never a rebundling CDN (esm.sh, skypack): re-bundling duplicates the deck.gl/luma.gl runtime and every layer fails shader compilation.
26
+ For no-build CDN pages, use the single-file standalone bundle from a raw-file CDN — `https://unpkg.com/@nika-js/onlymap@0.6.2` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.2/dist/onlymapjs.css">`. Never a rebundling CDN (esm.sh, skypack): re-bundling duplicates the deck.gl/luma.gl runtime and every layer fails shader compilation.
27
27
 
28
28
  ## React Projects
29
29
 
@@ -35,6 +35,10 @@ import { OmMap, OmLayer, OmWidget, OmOverlay, useOmMap } from "@nika-js/onlymap/
35
35
 
36
36
  The adapter inverts several HTML-manifest rules: props are camelCase deck.gl props, accessors are plain JS functions (`getFillColor={d => ...}` — no `$field` expression language, no `js` opt-in), and interactions are `onClick`/`onHover` handlers plus React state, not `<om-behavior>` or state-mutating actions. Load `references/react.md` before writing React map code.
37
37
 
38
+ ## Native and JSON Bridges
39
+
40
+ When a trusted native/cross-process host drives `MapController`, keep descriptors JSON-safe. A schema-declared accessor prop may use the same restricted expression grammar as HTML, for example `props: { getPosition: "[$lon, $lat]" }`; ordinary scalar string props remain strings. Use `snapshotDescriptorIR(descriptors)` for deterministic parity checks without fetching URL data. Call `controller.suspend()` on background and `resume()` on foreground; removing/changing a live descriptor or destroying the controller releases its reference-counted fetch/poll/socket handle. React components should continue using function accessors. For app-scoped licenses, pass `configureLicense(key, { appId })` only with an identifier obtained from trusted platform build metadata, never from bridge/page input.
41
+
38
42
  ## Required References
39
43
 
40
44
  Load the smallest reference needed for the task:
@@ -73,13 +77,21 @@ Load the smallest reference needed for the task:
73
77
  - Group adjacent map buttons (zoom + undo + toggle into one control group) -> just place compact button widgets in the same `position` slot; they auto-cluster. `cluster="false"` opts one out. Do NOT build a wrapper widget.
74
78
  - GeoTIFF/COG raster (DEM, satellite imagery, NDVI) -> `<om-layer type="COGLayer" src="…tif">` with `min`/`max`/`colormap` for single-band data (see syntax.md — `src`, not `data`).
75
79
  - Zarr / GeoZarr raster (chunked N-D arrays — climate/weather grids, datacubes) -> `<om-layer type="ZarrLayer" src="…​.zarr" variable="…" select="time=0">` with `min`/`max`/`colormap` (same raster styling as COGLayer). Pick the `variable` and pin every non-spatial dim in `select`. A GeoZarr store georeferences itself; a plain Zarr needs manual `bounds` + `crs` + `spatial-dims`. `src`, not `data` — and it can be any absolute URL to a remote store, which needs no server setup but must be CORS-enabled (fetched in the browser) and public (authenticated stores are a follow-up). See syntax.md.
80
+ - BIM model / .ifc file / 3D Tiles with per-element picking -> `<om-layer type="BIMLayer" src="…​.ifc" pickable>` (parses the IFC in-browser; web-ifc WASM is CDN-fetched on first use) or `type="Tile3DLayer" tileset="…" pick-features` for a pre-built tileset. Author `terrain` explicitly (`terrain="mapterhorn"`, or `terrain="off"` for flat ground) — a model that resolves real elevation (IfcMapConversion + OrthogonalHeight) on a terrain-less map raises an error through the validation channel at load time, and the library never writes attributes for you. Style by property with `feature-color-by`/`feature-color-scale` (+ `feature-palette`); isolate/hide/ghost elements declaratively with `feature-filter-field` + `isolate-features`/`hide-features`/`ghost-features` (undoable, story-steppable). See syntax.md.
81
+ - Browse/inspect/clash-check BIM models -> widgets: `ifc-loader` (drop zone, `federate` for multi-model coordination), `ifc-browser` (group/count/colour by any property field), `feature-inspector` (per-element properties on pick; `ifc-inspector` is an alias), `ifc-clash` (AABB clash overlay between two co-registered models). See syntax.md.
76
82
  - Geotagged drone JPEG -> `<om-layer type="ImageOverlay" src="…jpg" georeference="exif">`; for saved/collaborative maps persist the processed image and reconstruct with explicit `bounds` (see syntax.md; this is visualization-grade, not orthorectification).
77
83
  - Dashed line/route/boundary (or any dashed stroke) -> the `dash` attribute on a path-stroking layer: `dash="[6, 3]"` (or SVG-style `dash="6 3"`, + optional `dash-justified`) on `PathLayer`/`GeoJsonLayer`/`PolygonLayer`/`TripsLayer`. `[dashLength, gapLength]` in line-width units. Do NOT hand-wire deck's `PathStyleExtension`/`getDashArray` — the attribute mounts it; `dash` on a non-path layer is ignored with a warning.
78
84
  - CityJSON/CityJSONSeq per-face surfaces mode (`?om-surfaces=1`) -> always pair the `SolidPolygonLayer` with a companion `<om-layer type="PathLayer">` using `get-path="$outline"` to make roof/wall edges visible. This mode is unlit and `SolidPolygonLayer`'s own `wireframe` prop does nothing here (deck only builds wireframe geometry when `extruded: true`); the `PathLayer` is the only way to see face edges. Give it the same `filter-field`/`filter-range` as the fill layer so filtered-out buildings' outlines disappear too. See syntax.md.
79
85
  - Live entity updates -> `wss://` stream with `key` and optional `source` decoder.
80
86
  - REST snapshot that changes over time -> `refresh="5s"`.
81
87
  - User sketching -> `data="draw:sketch"` layer plus `<om-widget type="draw" target="sketch">`.
88
+ - Export part of a loaded 3D model (BIM/`Tile3DLayer`) as a portable file (issue #34) -> `<om-widget type="draw" modes="polygon" target="<name>" export-3d>` (or `export-3d="b3dm"` for Cesium/3D-Tiles pipelines). Outline a footprint over the loaded content, close it, "Export 3D" clips every loaded tile's triangles to that footprint (plain 2D clip — no elevation-picking needed) and downloads a GLB re-framed to a local coordinate frame at the footprint's own centroid, each triangle carrying its own source color (vertex colors). Separate capability from `save` (that's the drawn shape's GeoJSON, not the 3D content inside it). No textures — BIM/IFC materials are flat colors, not textured meshes. Only currently-visible 3D Tiles/BIM layers are included in the export — hiding a layer (`visible="false"`, or the `toggle-layer` action) excludes it, with distinct console warnings for "nothing loaded yet" vs. "everything hidden."
89
+ - Cut into / reveal the inside of a loaded 3D scene (BIM model, 3D Tiles, or any layer) with a box (issue #34) -> `<om-map clip-box-min="[lng,lat,elev]" clip-box-max="[lng,lat,elev]">` clips every layer to that axis-aligned box by default (`clip="off"` on an `<om-layer>` opts it out); `clip-box-invert` shows the outside instead, `clip-box-highlight` dims clipped-out geometry rather than discarding it (non-destructive preview). Works on any layer type including georeferenced `Tile3DLayer`/`BIMLayer` content — the point is cutting into a dense BIM scene, not just flat GeoJSON extrusions. Attribute-backed (undoable, story-steppable) via the `set-clip-box {min, max, invert?, highlight?}` action (`{clear: true}` removes it); `<om-widget type="clip-box">` is the native UI — six number inputs (min/max × lng/lat/elevation) + invert/highlight checkboxes + a clear button. v1 is axis-aligned only; rotated boxes are a documented follow-up, not this release.
90
+ - Snap a drawn/measured vertex to a nearby feature's own vertex/edge/midpoint (issue #34 Part A) -> `<om-map snap="vertex edge midpoint" snap-tolerance="12">` (px, default 12). NOT a spatial index — it refines whatever feature deck's own hover/click pick already found under the cursor (free, every frame) to that ONE feature's nearest vertex/edge/edge-midpoint, on the CPU, only while snapping is on. Applies to every layer by default (`snap="off"` on any `<om-layer>` opts it out, same shape as `clip="off"`) AND to a `BIMLayer`'s own edge/crease overlay (its real wall corners/edges, converted from the model's local mesh coordinates to real `[lng,lat]` automatically) — the raw triangle MESH itself is not a snap target (no comparable "nearest vertex" concept for an arbitrarily-picked point on a dense surface). Vertex beats midpoint beats edge on range conflicts; Space suppresses snapping momentarily (standard CAD/GIS convention). No action to wire — it's live-reactive like `terrain`/`clip-box-*`, and works automatically with any drawing/measuring tool that already routes through `om-map-point`.
82
91
  - Measure geodesic distance or area (a ruler / area tool) -> `<om-widget type="measure" modes="distance area" units="metric|imperial|nautical">`. Click the map to place points; it shows live per-segment + total labels and dispatches an `om-measure` event (`detail` = the readout). Reuses the draw capture stack, so measure and draw are mutually exclusive. Do NOT hand-roll distance math off canvas pixels; the `scale-bar` widget also takes `units` now.
92
+ - Measure cut/fill volume (earthworks/stockpiles — how much material a shape holds, or how much to add/remove to reach a target elevation) -> `modes="distance area volume"` on the same `measure` widget. Outline a footprint (closes like `area`); the math is a REAL per-cell grid integration against the map's terrain DEM (metric tangent-plane grid at the DEM's own GSD, scanline point-in-polygon, bilinear seam-correct sampling, worker-offloaded) — mixed cut AND fill within one footprint on undulating ground, with `cellSizeM`/`gsdM`/`cutErrorM3`/`fillErrorM3` (± = per-cell cellArea × 1.5 × GSD) and `nodataFraction` published on the readout. `base-surface` picks the reference: `custom` (default — a draggable gizmo target plane, re-summed live per drag frame) or boundary-derived stockpile strategies with NO gizmo (`triangulated` boundary TIN — recommend this for "measure this pile/mound", `plane`, `lowest`, `highest`, `average`). Reads out `cutMeters3`/`fillMeters3`/`netMeters3` (signed, fill−cut)/`totalMeters3` (unsigned, cut+fill) — always RAW geometric volumes, never altered by `swell`/`shrink`. REQUIRES `terrain` on `<om-map>` — a `volume` mode with none is a validation warning (falls back to a flat-plane approximation with no error figures). Volume-only attributes, all no-ops (validation warns) without `volume` in `modes`: `base-surface`; `profile` (elevation samples around the footprint's own perimeter, live while sketching — dispatched on `profileSeries` for a `dynamic-chart` widget to plot — that widget is generic: `<om-widget type="dynamic-chart" on="om-measure" series-field="profileSeries" width="320">` reads `event.detail[seriesField]` on every matching event and redraws, and "freezes" for free when a later event simply omits that field); `deadband` (zeroes a Cut/Fill figure below the threshold); `density`/`swell`/`shrink` populate a SEPARATE Material section (Bank/Loose/Compacted convention — `cutAdjustedMeters3` = raw × swell, `fillAdjustedMeters3` = raw ÷ shrink, `cutMassKg`/`fillMassKg` from the raw, mass-conserving volume), shown only once one of the three is configured — never baked into the primary Cut/Fill/Net/Total numbers. A `stale` readout field flags the window between a footprint committing and its integration resolving.
93
+ - Need a real ELEVATION on a click/hover (a z readout, a tooltip showing the height of the building face under the cursor, a coordinate that lands on 3D content rather than the ground behind it) -> `pickable="3d"` on the layer instead of a bare `pickable` (issue #34): it opts the layer into deck's depth-pick pass, and the resolved coordinate carries a third component. Read it as `{{z}}` in an `<om-overlay>` / `show-tooltip` template or `ctx.selection.coordinate` in a widget script. `terrain` sets this on itself. `{{z}}` is ABSENT (not `0`) when no layer in the scene ran the depth pass for that pick — do not treat a missing elevation as sea level.
94
+ - Small transient overlay that tracks the cursor near a map edge (a snap tip, a live readout badge) -> add `clip-to-map` to the `<om-overlay>`: it hides when the overlay's own BOX would spill past the map viewport, not just when its anchor leaves. Without it an overhanging absolutely-positioned box inflates the page's scrollable overflow and the scrollbar -> map resize -> reprojection loop shows as view jitter. Opt-in on purpose — an authored popup near an edge normally wants to keep showing its visible half.
83
95
  - Capture raw map clicks/hovers yourself (measure distance, drop a pin where the user clicks, a custom rectangle/circle AOI, snap-to-feature) -> listen for the **`om-map-point`** DOM event on `<om-map>`: `mapEl.addEventListener('om-map-point', e => { const { coordinate, kind } = e.detail; /* [lng,lat] or null; kind is "click"|"hover" */ })`. It fires on EVERY click/hover including empty-map clicks. NEVER read deck.gl internals (`getMap()`, `deckInstance`, `deck.viewManager`) or unproject canvas pixels by hand — those are not on `<om-map>` and will silently return nothing. The built-in `draw` widget covers polygon/line/point sketching; `om-map-point` is for tools it doesn't. (`MapController` twin: the `onMapPoint` option. See patterns.md.)
84
96
  - Page may travel as a file (shared, emailed, downloaded) or be embedded -> add an `<om-fallback>` child to `<om-map>`. Chat-app and email previews render HTML with JavaScript disabled (iOS QuickLook), so the map cannot boot there; the fallback is what recipients see instead. It is hidden automatically once the map boots. Good practice on every complete page — without one, the stylesheet shows a generic text-only banner.
85
97
 
@@ -49,9 +49,9 @@ function StatsPanel({ onToggle }) {
49
49
  ## Component surface
50
50
 
51
51
  - **`<OmMap>`** — `center`/`zoom`/`pitch`/`bearing` (initial; later changes move the camera, unchanged props never fight user panning), `basemap`, `headless`, `widgetStyle` (layout-token sugar, the `widget-style` attribute's twin: `"gap:10 opacity:0.9"` → `--om-widget-*` custom properties), `widgetsHidden` (hide-all: hides authored managed widget wrappers without removal, so widget state survives; provider attribution and the license badge stay visible in their slots), `widgetsFold` (default true; map-width responsive side drawers, set false to opt out), `onReady`, `onViewStateChange`, `onRuntimeError`. Give it a size via `style`/`className`. `ref` exposes the imperative `MapController` handle: `flyTo`, `setView`, `emit`, `getLayers`, `getSelection`, `injectPick`, `ready` (promise), `project`.
52
- - **`<OmLayer>`** — `id` + `type` (any registered deck.gl layer type) + deck props. `data`: stable inline reference or URL string (full Data Layer: CSV/Arrow/Shapefile/KML formats, `ws(s)://` streams via `source`/`streamKey`/`flush`, `refresh` polling). `label`/`color` feed `ctx.layers`; `filterField`/`filterRange` = GPU filter; `onClick`/`onHover` receive the flattened picked object (`onHover(null)` = pointer left).
52
+ - **`<OmLayer>`** — `id` + `type` (any registered deck.gl layer type) + deck props. `data`: stable inline reference or URL string (full Data Layer: CSV/Arrow/Shapefile/KML formats, `ws(s)://` streams via `source`/`streamKey`/`flush`, `refresh` polling). `dash`/`dashJustified` mirror the HTML path-style attributes. `label`/`color` feed `ctx.layers`; `filterField`/`filterRange` = GPU filter; `onClick`/`onHover` receive the flattened picked object (`onHover(null)` = pointer left). Conditional unmount/removal releases that layer's live-transport ownership; the final owner closes it.
53
53
  - **`<OmWidget>`** — positioning shell: `position` takes one of 8 managed slots (logical, RTL-aware: `top-start|top-center|top-end|center-start|center-end|bottom-start|bottom-center|bottom-end`; legacy corners `top-left` etc. alias) + arbitrary JSX. Same-slot widgets stack with flush edges and a shared gap; at map widths ≤640px they auto-fold into per-side drawers (`fold="never"` exempts an essential control); provider attribution joins `bottom-end` and the license badge joins `bottom-start` as in-flow members; `order={1}` sets deterministic in-slot ordering. `position="manual"` renders a plain block at the JSX site — note it sits inside OmMap's overflow-hidden box, so for UI OUTSIDE the map render your own element next to `<OmMap>` and drive the map via `useOmMap()`/the ref instead. (Automatic button-clustering of adjacent compact widgets, and collision-dim of slots under an open overlay, are HTML-lane only — in React, compose your own control group in JSX and dim via your own state.)
54
- - **`<OmOverlay>`** — geo-anchored HTML with managed projection/tracking/culling. `anchor={[lng, lat]}` or `anchorFrom="selection"` (+ `layer` to scope which picks move it); children may be `(selection) => JSX`; `anchorOffset` (default `bottom-center`); `interactive={false}` for hover-following tooltips.
54
+ - **`<OmOverlay>`** — geo-anchored HTML with managed projection/tracking/culling. `anchor={[lng, lat]}` or `anchorFrom="selection"` (+ `layer` to scope which picks move it, + `selectionType="click"|"hover"` to scope which pick TYPE — a click-opened popup wants `selectionType="click"`, else hovering any pickable feature drags and re-renders it; a click on empty space still dismisses); children may be `(selection) => JSX`; `anchorOffset` (default `bottom-center`); `interactive={false}` for hover-following tooltips.
55
55
  - **`useOmMap(watch?)`** — the same `ctx` contract HTML widget scripts get, typed: `layers`, `viewport`, `selection`, `emit`, `data()`, `dataInViewport()`, `stats()`. Watch tokens: `"viewport"`, `"selection"`, `"layers"`, `"data:<layerId>"`. Rides `useSyncExternalStore` (tearing-safe; `ctx` identity stable until a watched token fires).
56
56
  - **`controller.getStore(token)`** — framework-free `{subscribe, getSnapshot}` per watch token (cached plain-data snapshots; viewport snapshots carry `origin: "user"|"programmatic"` for echo-free two-way binding). Use it to sync map state into Redux/MobX/Zustand/Jotai — recipes in docs/external-stores.md; never mirror row arrays into a store (use the `data:<id>` version stamp + `ctx.data()`).
57
57
 
@@ -16,8 +16,8 @@ Vite/npm project:
16
16
  Static CDN page (raw-file CDNs only — unpkg/jsDelivr; never esm.sh or another rebundling CDN, which duplicates the WebGL runtime and breaks layer shaders):
17
17
 
18
18
  ```html
19
- <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.0/dist/onlymapjs.css">
20
- <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.6.0"></script>
19
+ <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.2/dist/onlymapjs.css">
20
+ <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.6.2"></script>
21
21
  ```
22
22
 
23
23
  Always include `onlymapjs.css` — it carries the MapLibre basemap styles and the no-JS fallback rules (`<om-fallback>` / default banner). For the fallback to work in script-disabled previews it must load without JavaScript: a real `<link rel="stylesheet">` or inlined `<style>` on no-build pages (a bundler-emitted stylesheet is fine in npm projects).
@@ -42,8 +42,10 @@ Common attributes:
42
42
  - `map-id="<random UUID>"` — identifies the map artifact for usage telemetry (not a visitor id). Include one on new complete pages, freshly generated — never copied from an example; deleting it is the opt-out.
43
43
  - `telemetry="off"` disables usage telemetry for this map (global: `OmMap.configureTelemetry({ disabled: true })`)
44
44
  - `license-key="om_live_…"` — lifts the free-plan limits (5 layers, 25k rows per layer — a layer past the layer cap doesn't render, a layer past the row cap renders its first 25k rows plus a dismissible on-map notice; caps apply only on HOSTED http(s) pages — localhost/file:// and other dev contexts run uncapped, badge stays) and removes the corner badge; publishable origin-restricted token, safe in page source (or `OmMap.configureLicense(key)` once). Free-plan violations don't break the map: the offending layer isn't rendered and validation names the limit.
45
- - `terrain="terrarium|<preset>|<{z}/{x}/{y} DEM URL>|off"` — 3D elevation surface. `terrarium` is keyless (AWS); `maptiler-terrain` needs `basemap-key`/`configureBasemap`; raw DEM URLs need `terrain-decoder` (`terrarium`, `mapbox-rgb`, or `{rScaler,gScaler,bScaler,offset}` JSON). `terrain-exaggeration` scales relief (1 = true); `terrain-max-zoom` = the provider's REAL tileset cap; `terrain-texture` drapes a `{z}/{x}/{y}` imagery template. Geographic layers drape automatically; per-layer `terrain="drape|offset|off"` overrides (3D-model layers default to `offset`). Terrain REPLACES an active basemap while on (restored when off) — validation warns. Register presets with `OmMap.registerTerrain(name, {...})`; `set-terrain` action + `terrain` watch token; attribute-backed (undoable).
45
+ - `terrain="terrarium|<preset>|<{z}/{x}/{y} DEM URL>|off"` — 3D elevation surface. `terrarium` is keyless (AWS); `mapterhorn` is also keyless and carries a CARTO Positron drape by default; `maptiler-terrain` needs `basemap-key`/`configureBasemap`; raw DEM URLs need `terrain-decoder` (`terrarium`, `mapbox-rgb`, or `{rScaler,gScaler,bScaler,offset}` JSON). `terrain-exaggeration` scales relief (1 = true); `terrain-max-zoom` = the provider's REAL tileset cap; `terrain-texture` drapes a `{z}/{x}/{y}` imagery template. Geographic layers drape automatically; per-layer `terrain="drape|offset|off"` overrides (3D-model layers default to `offset`). Terrain REPLACES an active basemap while on (restored when off) — validation warns. Register presets with `OmMap.registerTerrain(name, {...})`; `set-terrain` action + `terrain` watch token; attribute-backed (undoable). **BIM requires explicit terrain**: a model that resolves real elevation on a map with no `terrain` attribute raises an ERROR through the validation channel at load time — the library never writes `terrain` for you; author a preset (`mapterhorn` pairs a keyless DEM with a CARTO drape) or an explicit `terrain="off"` for flat-ground siting.
46
46
  - `lighting="daylight|studio|flat|custom"` — scene lighting for 3D content (extruded polygons, models); absent = deck defaults. Preset seeds values; `lighting-ambient`, `lighting-sun` (intensity; 0 removes the sun), `lighting-sun-azimuth` (° CW from north), `lighting-sun-elevation` (° above horizon), `lighting-camera` (model-inspection fill) override individual fields; `lighting-sun-date` (ISO 8601 or epoch ms) computes the sun from solar position at the map center and wins over azimuth/elevation. Attribute-backed: changes are undoable, and the `set-lighting {lighting, sunAzimuth, …}` action makes lighting story-steppable (`lighting="default"` removes the whole attribute set; a bare preset is a clean reset). `<om-widget type="lighting">` is the native UI. Widget scripts can `watch = ["lighting"]`.
47
+ - `clip-box-min="[lng, lat, elevation]"` + `clip-box-max="[lng, lat, elevation]"` (issue #34) — a real axis-aligned 3D box clipping the whole scene: geometry outside it is discarded (order of the two corners doesn't matter). Every layer is clipped once a box is configured; opt a specific layer out with `clip="off"` on its `<om-layer>`. `clip-box-invert` shows what's OUTSIDE the box instead; `clip-box-highlight` dims clipped-out geometry instead of discarding it (non-destructive preview — nothing disappears). Works on ANY layer type, including georeferenced `Tile3DLayer`/`BIMLayer` content, so a dense BIM scene can be cut open to see what's inside — not just flat `GeoJsonLayer` extrusions. Attribute-backed (undoable, story-steppable) via `set-clip-box {min, max, invert?, highlight?}` (`{clear: true}` removes the box) and `<om-widget type="clip-box">` (six number inputs + invert/highlight checkboxes + clear button). v1 is axis-aligned only — rotated boxes are a documented follow-up.
48
+ - `snap="vertex edge midpoint"` + `snap-tolerance="12"` (issue #34 Part A) — XY snapping for draw/measure vertex capture: refines whatever deck already picked under the cursor to the nearest vertex, edge, or edge midpoint of THAT feature's own geometry, within `snap-tolerance` pixels (default 12) — not a spatial index, only the already-picked feature is searched. Applies to every layer by default; `snap="off"` on any `<om-layer>` opts it out, mirroring `clip="off"`. Also snaps to a `BIMLayer`'s own edge/crease overlay (its real wall corners/edges, converted from local mesh coordinates automatically) — the raw triangle mesh itself is not a snap target. Vertex beats midpoint beats edge on range conflicts. Hold Space to place a point nearby without snapping. No action/attribute-backed toggle beyond the attribute itself; live-reactive like `terrain`/`clip-box-*`.
47
49
  - `widgets-dim="off"` disables collision-dim — by default a widget slot dims (`--om-widget-opacity-dimmed`, 0.35) while an open `<om-overlay>` popup covers it, rather than the popup dodging (attribution/toggle slots never dim).
48
50
  - `headless width="800" height="600"` for test harness use
49
51
 
@@ -71,7 +73,7 @@ Core attributes:
71
73
  - `type="ScatterplotLayer"` — any bundled layer type.
72
74
  - `data="./points.json"` — URL, stream, draw store, or omit for inline JSON.
73
75
  - `label="Earthquakes"` and `color="#b30000"` — legend metadata.
74
- - `pickable` — enable click/hover behaviors.
76
+ - `pickable` — enable click/hover behaviors. `pickable="3d"` (issue #34) additionally opts the layer into deck's DEPTH-pick pass, so a click/hover's resolved coordinate carries a real third (elevation) component instead of the ray∩z=0-plane guess a flat pick gives you — a click on a building face resolves ON the face, not on the ground behind it. `terrain` sets this on itself. The elevation flows through `ctx.selection.coordinate` and `{{z}}` in `<om-overlay>`/`show-tooltip` templates; `{{z}}` is ABSENT (not `0`) when no layer in the scene ran the depth pass for that pick, so "no elevation" is distinguishable from sea level.
75
77
  - `visible="false"` or `opacity="0"` — initial visibility/opacity.w
76
78
 
77
79
  Accessors:
@@ -137,7 +139,7 @@ For an epoch-millisecond filter, format the built-in widget's numeric labels dec
137
139
 
138
140
  Use the `type` value exactly:
139
141
 
140
- `A5Layer`, `ArcLayer`, `BitmapLayer`, `COGLayer`, `ColumnLayer`, `ContourLayer`, `GeoJsonLayer`, `GeohashLayer`, `GreatCircleLayer`, `GridCellLayer`, `GridLayer`, `H3ClusterLayer`, `H3HexagonLayer`, `HeatmapLayer`, `HexagonLayer`, `IconLayer`, `ImageOverlay`, `LineLayer`, `MVTLayer`, `PathLayer`, `PointCloudLayer`, `PolygonLayer`, `PopupLayer`, `QuadkeyLayer`, `S2Layer`, `ScatterplotLayer`, `ScenegraphLayer`, `ScreenGridLayer`, `SimpleMeshLayer`, `SolidPolygonLayer`, `TerrainLayer`, `TextLayer`, `Tile3DLayer`, `TileLayer`, `TripsLayer`, `ZarrLayer`.
142
+ `A5Layer`, `ArcLayer`, `BIMLayer`, `BitmapLayer`, `COGLayer`, `ColumnLayer`, `ContourLayer`, `GeoJsonLayer`, `GeohashLayer`, `GreatCircleLayer`, `GridCellLayer`, `GridLayer`, `H3ClusterLayer`, `H3HexagonLayer`, `HeatmapLayer`, `HexagonLayer`, `IconLayer`, `ImageOverlay`, `LineLayer`, `MVTLayer`, `PathLayer`, `PointCloudLayer`, `PolygonLayer`, `PopupLayer`, `QuadkeyLayer`, `S2Layer`, `ScatterplotLayer`, `ScenegraphLayer`, `ScreenGridLayer`, `SimpleMeshLayer`, `SolidPolygonLayer`, `TerrainLayer`, `TextLayer`, `Tile3DLayer`, `TileLayer`, `TripsLayer`, `ZarrLayer`.
141
143
 
142
144
  Common choices:
143
145
 
@@ -147,6 +149,7 @@ Common choices:
147
149
  - Aggregation: `HeatmapLayer`, `HexagonLayer`, `GridLayer`, `ScreenGridLayer`.
148
150
  - Tiles: `TileLayer`, `MVTLayer`, `Tile3DLayer`.
149
151
  - 3D models: `ScenegraphLayer`, `SimpleMeshLayer`, `PointCloudLayer`, `Tile3DLayer`.
152
+ - BIM source files (`.ifc`, loaded in-browser, no pre-conversion step): `BIMLayer`.
150
153
  - GeoTIFF/COG rasters: `COGLayer`.
151
154
  - Zarr / GeoZarr rasters (chunked N-D arrays): `ZarrLayer`.
152
155
  - Geotagged drone JPEGs: `ImageOverlay`.
@@ -163,7 +166,7 @@ Common choices:
163
166
  - `src` (required) — the GeoTIFF URL. NOT `data`: rasters stream tiles by HTTP Range request through the layer's own reader; they are never parsed rows (`$field`, `ctx.data()`, `ctx.stats()`, filters do not apply).
164
167
  - Sources must be Cloud-Optimized GeoTIFFs (`gdal_translate -of COG` otherwise).
165
168
  - `min`/`max` — the rescale window mapped onto the colormap. Defaults to 0–255, so ALWAYS set them for float or 16-bit data (DEMs, NDVI, temperature).
166
- - `colormap` — single-band ramps from the bundled sprite: `gray` (default), `viridis`, `plasma`, `inferno`, `magma`, `cividis`, `rdylgn`, `rdbu`, `spectral`, `terrain`, `jet`, `turbo`. Sources with 3+ bands composite as RGB and ignore it.
169
+ - `colormap` — single-band ramps from the bundled sprite: `gray` (default), `viridis`, `plasma`, `inferno`, `magma`, `cividis`, `rdylgn`, `rdbu`, `spectral`, `terrain`, `jet`, `turbo`, `ylorrd` (ColorBrewer yellow→orange→red — a gentler sequential ramp than turbo/jet). Sources with 3+ bands composite as RGB and ignore it.
167
170
  - `nodata` — overrides the source's nodata sentinel; nodata pixels render transparent.
168
171
  - Plain 8-bit RGB COGs (satellite truecolor) need no styling attributes at all.
169
172
  - Restretch/recolor (min/max/colormap edits) are GPU uniform updates — tiles are not refetched. The legend widget renders the colormap ramp automatically when `colormap` + `min`/`max` are authored.
@@ -351,14 +354,24 @@ Built-ins:
351
354
  - `scale-bar` — `units="metric|imperial|nautical"` (default metric) picks the length system; snaps to a nice round distance.
352
355
  - `attribution`
353
356
  - `filter`
354
- - `draw`
355
- - `measure` — geodesic ruler: `modes="distance area"` (space-separated; default both), `units="metric|imperial|nautical"`. Click the map to place points; live per-segment + total labels render on the map, and a totals panel + a `units` toggle sit in the widget. Distance is haversine on the WGS84 mean sphere (≤0.56% vs. the true geodesic); area is the spherical-excess integral. Nautical shows nmi for length and falls back to metric for area. Reuses the draw capture stack (measure and draw are mutually exclusive); the geometry is ephemeral (never saved, never an undo step). Consume the reading programmatically via the `om-measure` event on `<om-map>` (`detail = {mode, units, totalMeters, segments, areaMeters2, perimeterMeters, poleWarning}`).
357
+ - `draw` — sketch-capture toolbar: `modes="point line polygon"` (default all three), `target="<name>"` (default `sketch`, bound via `data="draw:<target>"`), `save="both|download|file-system"`, `autosave="<localStorage key>"`. `export-3d` (bare = GLB default, `="b3dm"` wraps it for Cesium/3D-Tiles pipelines) adds an "Export 3D" button (spec: issue #34 — region export) — deliberately separate from `save` (that's the drawn shape's own GeoJSON; `export-3d` exports the 3D `Tile3DLayer`/`BIMLayer` content found INSIDE the drawn footprint). Outline a polygon over loaded 3D content, close it, click "Export 3D": clips every loaded tile's triangles to the footprint (a plain 2D clip — no elevation-picking involved), re-frames them to a local coordinate frame at the footprint's own centroid (portable — opens correctly in Blender/three.js/etc. without ECEF-scale support), and downloads it, each triangle carrying its own source color (baked as vertex colors). No textures — BIM/IFC materials are flat colors, not textured meshes. The export only pulls in currently-VISIBLE 3D Tiles/BIM layers — one hidden via `visible="false"` (or the `toggle-layer` action) is excluded, with a distinct console warning distinguishing "nothing has loaded yet" from "everything loaded is hidden." Validation warns on an unrecognized `export-3d` value.
358
+ - `clip-box` — native UI over the map's `clip-box-*` scene-state attributes (see the `<om-map>` section above): six number inputs (min/max × lng/lat/elevation), invert/highlight checkboxes, and a clear button, all wired through `set-clip-box`. Manifest is the source of truth the panel re-syncs from the attributes on every render, so undo/redo and story-scrub move the inputs too.
359
+ - `measure` — geodesic ruler: `modes="distance area volume"` (space-separated; default `distance area`), `units="metric|imperial|nautical"`. Click the map to place points; live per-segment + total labels render on the map, and a totals panel + a `units` toggle sit in the widget. Distance is haversine on the WGS84 mean sphere (≤0.56% vs. the true geodesic); area is the spherical-excess integral. Nautical shows nmi for length and falls back to metric for area. Reuses the draw capture stack (measure and draw are mutually exclusive); the geometry is ephemeral (never saved, never an undo step). Consume the reading programmatically via the `om-measure` event on `<om-map>` (`detail = {mode, units, totalMeters, segments, areaMeters2, perimeterMeters, poleWarning, cutMeters3, fillMeters3, netMeters3, totalMeters3, cutAdjustedMeters3, fillAdjustedMeters3, swell, shrink, cutMassKg, fillMassKg, cellSizeM, gsdM, cutErrorM3, fillErrorM3, nodataFraction, baseSurface, stale, profileSeries}` — everything from `cutMeters3` on is volume-mode-only, populating once a footprint closes). `volume` mode outlines a polygon footprint the same way `area` does — double-click (or Enter) closes it, and it turns solid teal to signal it's ready — then a double-headed arrow gizmo (fixed screen-pixel size, unbounded drag distance) appears at the centroid: drag it up to fill, down to cut, panel reads Cut/Fill/Net (signed, fill−cut)/Total (unsigned, cut+fill) volume + Area/Perimeter live — always RAW geometric figures, never altered by `swell`/`shrink`. REQUIRES `terrain` on `<om-map>` (validation warns a `volume` mode with none): the math is a REAL per-cell grid integration (issue #35) — closing a footprint bulk-loads its covering DEM tiles and integrates terrain-vs-base per cell on a metric tangent-plane grid (cell size = the DEM's GSD at the ring's latitude, scanline point-in-polygon, bilinear tile-seam-correct sampling, worker-offloaded with a synchronous fallback), reporting mixed cut AND fill within one footprint on undulating ground plus `cellSizeM`/`gsdM`/`cutErrorM3`/`fillErrorM3` (± = per-cell cellArea × 1.5 × GSD, summed per side) and `nodataFraction` on the readout. `base-surface` picks the reference surface: `custom` (default — the gizmo's draggable target plane, re-summed live from the cached grid during a drag) or boundary-derived stockpile strategies with NO gizmo (`triangulated` boundary TIN — the drone-survey default, `plane` least-squares, `lowest`/`highest`/`average`). No terrain (or a failed tile fetch) falls back to the flat single-elevation approximation with no error figures rather than erroring. Five more volume-only attributes, all no-ops without `volume` in `modes` (validation warns): `base-surface` (above); `profile` — closing a footprint also samples elevation around its own perimeter, dispatched on `profileSeries` for a paired `dynamic-chart` widget to plot, updating live from the first vertex (debounced on hover, immediate on each new vertex) while sketching, not just on close; `deadband` (m³, default 0) — zeroes a Cut/Fill figure below the threshold; `density` (t/m³ metric, lb/yd³ imperial) and `swell`/`shrink` (multipliers, default 1×) populate a SEPARATE Material section instead of touching Cut/Fill/Net/Total — standard Bank/Loose/Compacted convention: `cutAdjustedMeters3` = raw cut × swell (loose/haul volume, bigger — excavating adds air voids), `fillAdjustedMeters3` = raw fill ÷ shrink (loose/borrow volume needed, also bigger — raw fill is already a compacted target void), `cutMassKg`/`fillMassKg` from the RAW volume (mass-conserving — swell/shrink change volume via air voids, not the mass of material). The widget only renders the Material section once at least one of `density`/`swell`/`shrink` is configured — no separate toggle. `stale` flags the brief window between a footprint committing and its elevation sample resolving.
356
360
  - `vega-lite`
361
+ - `dynamic-chart` — same Vega-Lite rendering as `vega-lite`, but data-driven by a live DOM event instead of a layer/`ctx.data`: `on="<event-name>"` (required — the event to listen for on `<om-map>`), `series-field="<name>"` (default `series`) reads `event.detail[seriesField]` as the chart's `data.values` and re-embeds on every event where that field is a present array; `width` (fixed, default 280) and `title` work the same as `vega-lite`. The child `<script type="application/json">` spec is the same Vega-Lite mark/encoding shape, minus `data` (supplied live). A feature "freezes" the chart for free by simply not including the field on a later event (e.g. switching modes) — the widget has no separate pause API, it just does nothing when the field is absent. Built for a feature that computes its own series as the user interacts (a drawn line's elevation profile updating vertex-by-vertex) and has no layer of its own to bind to.
357
362
  - `player`
358
363
  - `basemap-switcher` — radio list of presets; `options="positron dark-matter osm"` (default: every keyless registered preset)
359
364
  - `lighting` — scene-lighting controller: preset radios (Off/daylight/studio/flat/custom) + ambient/sun/azimuth/elevation/camera sliders, all over the lighting* attributes via `set-lighting` (undoable; re-syncs when anything else writes them). A bare preset click is a clean RESET (stale lighting-* overrides removed); a slider edit flips to `custom` and sets only the touched key.
360
365
  - `widgets-toggle` — one button hiding/showing all OTHER widgets (`widgets-hidden` attribute / `set-widgets-visible {visible}` action; bare payload toggles). Hidden = visibility, never removal — widget state survives; attribution and the toggle itself never hide. Transient (not an undo step), story-scrub-capturable.
361
366
  - `undo-redo` — undo/redo buttons over the manifest history (layer toggles, filters, basemap switches, element edits, drawn sketches). Keyboard works without the widget: Cmd/Ctrl-Z, Shift-Cmd/Ctrl-Z, Ctrl-Y. Camera moves, hover effects, and story playback are not undo steps.
367
+ - `ifc-browser` — the model browser (registered as `ifc-legend` too, the deprecated original name): group the model by any property-table field, colour it, and isolate/hide/ghost per value. `layer` names the `pick-features` layer (defaults to the only one on the map), `fields="ifcClass material container"` are the group-by choices, `scale-fields="netVolume"` adds graduated numeric ramps, `rows="9"` caps the visible list before it scrolls. Each row has I/H/G buttons that write `isolate-features`/`hide-features`/`ghost-features` — so the panel is a UI over the attributes, and everything it does is undoable and story-steppable. **I is multi-select**: pressing it on several rows (or several tree nodes) isolates their union, and pressing it again on one removes just that one, so "ground floor AND roof" is a normal thing to ask for. In a tree, ancestors of an isolated node stay legible so the branch is still reachable from the root. It also keeps the companion outline layer's `filter-categories` in step, or hidden elements would leave their edges behind. Colour mode defaults to the model's own IFC surface colours; `no-color` removes the select. Non-physical classes (`IfcSpace`, `IfcOpeningElement`, …) are hidden from the list unless `show-non-physical` is set, because they otherwise dominate the counts with things nobody can see.
368
+
369
+ The select additionally offers whichever **trees** the file supports: **Spatial** (`spatialPath` — IfcSite → IfcBuilding → IfcStorey → space), **Type** (`typePath` — IfcDoorType → Single-Flush), **System** (`systemPath` — a distribution system and its parent systems) and **Classification** (`classificationPath` — CCS → [L]BB Fundamentskonstruktion, built by walking `ReferencedSource` rather than parsing the code string, which would only work for whichever scheme's punctuation you guessed). Each renders expandable, counts aggregated upward, with the same I/H/G on every node — isolating a storey, a system or a classification code reaches every element beneath it. Spatial is not privileged: on a real Danish project the classification tree covered 3,415 elements against spatial's 660.
370
+
371
+ A tree is **not** a separate mode — it groups by a hierarchy column exactly as the list groups by `ifcClass`, so it reuses the reset-on-field-change rule, the colour wiring and the isolate/hide/ghost attributes untouched, and a fifth tree would cost one column plus one line. `loadIfc` extracts every hierarchy a model has and emits a column **only when the file populates it** (all four measured at 26 ms of a 290 ms relationship pass on 2,626 elements, and lost in the noise on a 74 MB one), so a tree that would render empty is never offered and an authored hierarchy field in `fields` is dropped rather than shown dead. Trees appear automatically when available — you do not have to list them — while `field="spatialPath"` opens straight onto one. Tilesets converted before these columns existed carry none. Elements with no container get their own bucket rather than disappearing. **One browser per layer:** `feature-filter-field` and the isolate/hide/ghost attributes are single-valued, so two instances pointed at one layer would clobber each other's filtering.
372
+ - `ifc-clash` — clash overlay over two CO-REGISTERED model layers. With no `layers` attribute it offers two SELECTS over whatever models are loaded, which is how coordination tools work (append models, then choose a pair) and the only shape that survives a project with five disciplines; `layers="arch mep"` still pins a fixed pair. Controls follow Navisworks' Clash Detective, which is what a coordinator already knows: a **highlight-all** switch in the header, and an **isolation MODE** above the list (`none` / `dim others` / `hide others`) rather than independent toggles — two switches would offer four states, two of them meaningless. Isolation applies to the SELECTED clash, so with nothing selected the model is untouched. Hiding is `opacity: 0`, a shader discard, not partial transparency. Sides are red and BLUE rather than Navisworks' conventional red/green, which is the worst possible pair for a red-green deficiency. CLICKING A ROW focuses that clash: the chosen pair goes full strength, every other clashing element drops to a faint tint, and the camera flies to the centre of the overlap. Without focus, painting all 527 clashing elements the same red means flying to one shows you a red building. Results are GROUPED by the element on side A — one wall crossing four ducts is one row with a count, not four, which is how coordination tools report and what keeps a four-figure result readable. Starts COLLAPSED with the count in its header — a real pass returns four figures of rows and a permanently open list buries the model. Attributes: `tolerance` in metres of real interpenetration before a pair counts (0 reports any overlap), `rows`, `zoom`. Flags element pairs whose bounding boxes interpenetrate, colours both sides through `feature-styles` (which outranks `feature-color-by`, so clearing it hands the colouring back), and gives each pair a Z button that flies to the centre of the OVERLAP rather than of either element. v1 is an axis-aligned box test — it finds a duct through a roof in milliseconds with no server, and it over-reports anything diagonal, since a brace's box is far bigger than the brace. Zones, spaces, openings and proxies are excluded (a zone is a volume, so a box test says it intersects everything inside it), as are pairs sharing a class and name, which are reference markers repeated across disciplines. Persisting or sharing results is what BCF exists for and is not attempted. **Co-registration is checked, not assumed**: differing `site-origin` values are reported rather than silently returning zero, because two mis-registered models look exactly like two clean ones.
373
+ - `feature-inspector` (renamed from `ifc-inspector`, which still works as an alias — the widget's body is generic property-row rendering with no IFC dependency, so it works unchanged on any `pick-features` layer, IFC-derived or not) — properties of the currently picked element. `fields="ifcClass material container netVolume"` chooses the rows, `placeholder` is the nothing-selected text. Reads the same property table picking resolves, so it needs no data of its own.
374
+ - `ifc-loader` — a drop zone that parses an `.ifc` in the browser and builds the layers for it (see **In-browser IFC** below). Add `federate` and ONE drop zone accepts several models into a co-registered scene — one layer per model, each with its own visibility toggle and remove button — instead of dedicating a widget per discipline. Under `federate`, the FIRST model loaded decides the shared model-space origin and placement, and every later model inherits both (discipline exports of one building routinely disagree by kilometres, so honouring each file's own would scatter it); add `independent` to opt out when the models are unrelated buildings rather than disciplines of one. That sharing never applies without `federate` — a plain loader always resolves each new file's own georeference — and it resets once every model in a federated scene is removed, so the next one dropped in starts fresh rather than inheriting a dead scene's position. `layer="ifc"` is the id it creates (plus `<id>-edges`), `zoom` the flyTo zoom, `field` the filter field, `outline-color`/`no-outlines`/`ghost-opacity` tune what it builds, and `site-origin`/`site-heading`/`site-scale` override what the file declares. If a loaded model turns out to be georeferenced it is AUTO-PLACED: the widget writes the file's own coordinates, heading and scale onto the layers it CREATED and flies the camera there — but it NEVER touches `<om-map>`'s own scene attributes (`basemap`, `terrain`): those are author-owned, and a georeferenced model landing on a map with neither raises a structured warning ("no spatial context") instead of switching one on.
362
375
 
363
376
  Positions — 8 managed slots (logical, RTL-aware): `top-start`, `top-center`, `top-end`, `center-start`, `center-end`, `bottom-start`, `bottom-center`, `bottom-end`. Legacy corner names (`top-left`, `top-right`, `bottom-left`, `bottom-right`) are aliases. Same-slot widgets stack in one library-owned flex container: flush edges, shared gap — never overlapping. `order="1"` sets deterministic in-slot ordering (default: DOM order). Adjacent COMPACT button widgets (zoom-controls, undo-redo, widgets-toggle) in one slot auto-merge into a single control group (shared radius/shadow, 1px dividers); `cluster="false"` keeps one out — validation warns if set on a non-compact widget. At map widths ≤640px, managed widgets auto-fold into top/end/bottom/start disclosure drawers; `fold="never"` keeps an essential control outside, `widgets-fold="off"` opts the map out, and `--om-widget-fold-breakpoint` changes the map-width threshold. `position="manual"` opts out of management: the widget renders as a plain block you place with your own CSS (even outside the map, e.g. in an app header, driving the map through actions). Layout tokens: `--om-widget-inset-x/-y` (slot inset, default 12px), `--om-widget-gap-x/-y` (stack gap, default 8px), `--om-widget-opacity`, `--om-widget-opacity-dimmed` (default 0.35 — the collision-dim level), `--om-widget-radius`, `--om-widget-fold-breakpoint` — or the no-CSS sugar attribute `<om-map widget-style="gap:10 opacity:0.9 inset:16">` (keys: inset, gap, inset-x/-y, gap-x/-y, opacity, radius, size; numbers are px except opacity).
364
377
 
@@ -416,12 +429,17 @@ watched token fires:
416
429
  </om-widget>
417
430
  ```
418
431
 
419
- - `ctx.layers`, `ctx.data(id)`, `ctx.dataInViewport(id)`,
420
- `ctx.stats(id, field, { scope: "viewport" })`, `ctx.selection`,
421
- `ctx.viewport`, `ctx.history` (`{ canUndo, canRedo }`).
422
- - Watch tokens: `data:<layerId>`, `viewport`, `selection`, `layers` (add/remove,
423
- visibility, filter changes), `basemap`, `lighting`, `terrain`, `history`,
424
- `widgets` (`widgets-hidden` hide-all toggle).
432
+ - `ctx.layers`
433
+ - `ctx.data(id)`
434
+ - `ctx.dataInViewport(id)`
435
+ - `ctx.stats(id, field, { scope: "viewport" })`
436
+ - `ctx.selection`
437
+ - `ctx.viewport`
438
+ - `ctx.features(layerId)` — the decoded `EXT_structural_metadata` property table of a `pick-features` tileset, one row per element, or `undefined` before the first tile carrying one has landed. This is what a BIM panel groups and counts by; pair it with the `features` watch token, since the table cannot exist on the first render.
439
+ - `ctx.history` — `{ canUndo, canRedo }`; re-render on changes via the `history` watch token
440
+ - `ctx.emit(action, payload)`
441
+
442
+ Watch tokens: `data:<layerId>`, `viewport`, `selection`, `layers` (fires on layer add/remove, visibility, and filter changes), `features` (fires when a tileset's property table decodes), `basemap`, `lighting`, `terrain`, `history`, `widgets` (fires on a `widgets-hidden` hide-all toggle). An EMPTY `watch` array means the widget never re-renders — omit it or list tokens, never `watch: []`.
425
443
 
426
444
  **Drive the map — the emission contract.** This is the part authors get wrong.
427
445
  A widget NEVER mutates the map directly and NEVER dispatches its own
@@ -481,15 +499,25 @@ Anchors:
481
499
  - `anchor-from="selection"`
482
500
  - `anchor-layer="regions" anchor-feature-id="mission"`
483
501
 
502
+ Selection scoping (both only apply with `anchor-from="selection"`):
503
+
504
+ - `layer="quakes"` — only that layer's picks move/re-template the overlay.
505
+ - `selection-type="click"` (or `"hover"`) — only that pick type does. A click-opened popup NEEDS `selection-type="click"`: without it, merely hovering any pickable feature drags the popup there and re-interpolates its template against the hovered object (wrong-layer ghost popup). With it, hover is inert and a click on empty space still dismisses. `"hover"` is the mirror for hover-driven overlays.
506
+
507
+ Viewport clipping:
508
+
509
+ - `clip-to-map` — hide the overlay when its own BOX would spill past the map viewport, not just when its anchor leaves (the default). Opt-in, because an overhanging absolutely-positioned box inflates the page's scrollable overflow, and the resulting scrollbar → map resize → reprojection loop shows as view jitter. Use it for small transient tips that track the cursor; an authored popup near an edge normally wants to keep showing its visible half.
510
+
484
511
  Templates:
485
512
 
486
513
  - `{{field}}` HTML-escaped interpolation.
487
514
  - `{{{field}}}` raw HTML; avoid unless trusted.
515
+ - `{{z}}` — the pick's elevation in meters, present only when a `pickable="3d"` layer ran the depth pass for it (see `<om-layer>`'s `pickable` above). Absent, not `0`, otherwise.
488
516
 
489
517
  Example:
490
518
 
491
519
  ```html
492
- <om-overlay id="detail" anchor-from="selection" visible="false">
520
+ <om-overlay id="detail" anchor-from="selection" selection-type="click" visible="false">
493
521
  <div><b>{{place}}</b> M {{magnitude}}</div>
494
522
  </om-overlay>
495
523
  <om-behavior on="click" layer="quakes" action="show-overlay" target="detail"></om-behavior>
@@ -540,6 +568,9 @@ Common built-in actions:
540
568
  - story actions: `story-play`, `story-pause`, `story-seek`
541
569
  - effect actions: `fade`, `pulse`, `trace`, `populate`
542
570
  - draw actions: `draw-mode`, `draw-commit`, `draw-cancel`, `draw-delete`, `draw-clear`, `draw-config`, `draw-save`
571
+ - measure actions: `measure-mode` (`{mode: "distance"|"area"|"volume"|null}`), `measure-units` (`{units: "metric"|"imperial"}`), `measure-clear`, `measure-config` (`{profile?, baseSurface?, density?, swell?, shrink?, deadband?}`), `measure-flat-target-plane` (`{flat}` — volume mode's target-surface switch)
572
+ - clip box: `set-clip-box` (`{min, max, invert?, highlight?}`, or `{clear: true}` to remove it), `clip-box-edit` (`{editing}` — shows/hides the draggable face gizmos)
573
+ - region export: `export-region-3d` (`{target?: "sketch", format?: "glb"|"b3dm"}`) — clips the drawn footprint's 3D content and downloads it; this is what the `draw` widget's `export-3d` button emits
543
574
 
544
575
  Payload attributes are kebab-case and become camelCase payload keys.
545
576
 
@@ -635,3 +666,271 @@ For 3D Tiles LOD/refinement experiments, use:
635
666
  maximum-memory-usage="256"
636
667
  view-distance-scale="0.85"></om-layer>
637
668
  ```
669
+
670
+ ### Per-element (BIM) picking and styling
671
+
672
+ A plain `pickable` `Tile3DLayer` picks a whole TILE. To pick individual
673
+ elements — a wall, a window, one IFC product — add `pick-features`. The pick's
674
+ `selection` then carries `featureId`, `properties` and `class` resolved from the
675
+ tile's own `EXT_mesh_features` + `EXT_structural_metadata`:
676
+
677
+ ```html
678
+ <om-layer id="building" type="Tile3DLayer"
679
+ tileset="https://example.com/tileset.json"
680
+ pick-features
681
+ feature-id-property="_FEATURE_ID_0"
682
+ load-options='{"gltf":{"loadBuffers":true,"loadImages":true},"image":{"type":"data"}}'
683
+ pickable></om-layer>
684
+ ```
685
+
686
+ `feature-styles` recolours, fades or highlights elements by feature ID — an
687
+ array indexed BY id, each entry `{color: [r,g,b], strength: 0-1, opacity: 0-1}`.
688
+ Set it live (from a widget, a dropdown, a pick handler) and only a small lookup
689
+ texture is re-uploaded; no refetch, no re-tesselation:
690
+
691
+ ```html
692
+ <om-layer id="building" type="Tile3DLayer" tileset="…" pick-features pickable
693
+ feature-styles='[{"color":[90,200,255],"strength":0.5},{},{"color":[255,215,130],"strength":0.9}]'></om-layer>
694
+ ```
695
+
696
+ Use `strength` below 1 to TINT rather than replace — a full-strength colour hides
697
+ the model's own texture entirely.
698
+
699
+ **Isolate / hide / ghost** are declarative, and mirror the vector
700
+ `filter-field` + `filter-categories` pair: name the metadata field once, then
701
+ list values per state. Values are JSON arrays, matched against the tile's own
702
+ property table:
703
+
704
+ ```html
705
+ <om-layer id="building" type="Tile3DLayer" tileset="…" pick-features pickable
706
+ feature-filter-field="component"
707
+ hide-features='["Windows","Skylight"]'
708
+ ghost-features='["Wall"]'
709
+ ghost-opacity="0.18"></om-layer>
710
+ ```
711
+
712
+ - `isolate-features` is exclusive — anything NOT listed is hidden, so it is a
713
+ scope rather than another kind of hide.
714
+ - Hiding is a shader `discard`, so a hidden element also stops being pickable
715
+ and you can select whatever sits behind it.
716
+ - These compose ONTO `feature-styles` rather than replacing it: the style table
717
+ supplies colour, these supply visibility. Changing colour scheme never
718
+ un-hides anything, and isolating never drops your colouring.
719
+ - Being attributes, they are undoable and story-steppable — prefer them over
720
+ computing a `feature-styles` table in page JS.
721
+
722
+ **Colour by property.** `feature-styles` is indexed by feature ID, which means
723
+ computing a table by hand. These name a property-table FIELD instead and build
724
+ that table for you, once the table arrives with the first tile:
725
+
726
+ ```html
727
+ <om-layer id="clinic" type="Tile3DLayer" tileset="…" pick-features pickable
728
+ feature-color-by="material"></om-layer>
729
+ ```
730
+
731
+ - `feature-color-by` is CATEGORICAL — one palette entry per distinct value.
732
+ `feature-palette='["#4f7cff","#ff9a3c"]'` overrides the built-in
733
+ colour-blind-safe cycle.
734
+ - `feature-color-scale` is GRADUATED over a numeric field.
735
+ - `feature-color-strength` (default 0.85) is how hard the colour is mixed over
736
+ the model's own material; below 1 tints rather than replaces.
737
+ - Setting NEITHER is meaningful, and is the default: the model renders in its
738
+ own IFC surface colours, which is what someone opening a building expects.
739
+ Reach for these to answer a question, not to make it look coloured.
740
+ - An authored `feature-styles` always wins — these are sugar over the same
741
+ table, never an override of it.
742
+
743
+ A graduated ramp needs the field to actually be populated. Revit IFC2x3 exports
744
+ frequently carry no `IfcElementQuantity` at all, so every `netVolume` is 0 and
745
+ the ramp renders flat — check the property table before blaming the ramp.
746
+
747
+ **Georeferencing — `site-origin` / `site-heading` / `site-scale`** (Tile3DLayer
748
+ and PathLayer). Where a model sits is a viewing decision, not a conversion one,
749
+ so it lives on the layer:
750
+
751
+ ```html
752
+ <om-layer id="clinic" type="Tile3DLayer" tileset="…/tileset.json" pick-features pickable
753
+ site-origin="[-71.059776, 42.358429]" site-heading="32" site-scale="1"></om-layer>
754
+ ```
755
+
756
+ - `site-origin` is `[lng, lat]` or `[lng, lat, elevation]`, and it OVERRIDES the
757
+ position baked into the tileset's root transform rather than offsetting it.
758
+ - `site-heading` is a bearing — degrees CLOCKWISE from true north. On its own,
759
+ with no `site-origin`, it rotates the model where it stands.
760
+ - Rotation and scale pivot on the model's own anchor, not the tileset origin,
761
+ so a heading change spins the building about itself.
762
+ - An IFC model is a PAIR of layers — the mesh tileset and a `PathLayer` outline
763
+ overlay whose paths are local east/north/up metres — and both need the same
764
+ three values, or the building walks away from its own edges. The
765
+ `ifc-loader` widget sets them on both for you.
766
+ - Do not trust a model's declared position without looking at it. Authoring
767
+ tools ship a default project location, and a default is indistinguishable
768
+ from a survey: the buildingSMART Medical-Dental Clinic sample carries Revit's
769
+ Boston default and the Duplex a Chicago city-centre point, so both land on
770
+ occupied downtown blocks at an arbitrary rotation. `IfcMapConversion` (real
771
+ georeferencing) is absent from most IFC2x3 exports and `TrueNorth` is
772
+ routinely unset — which is exactly what these attributes are for.
773
+ - Editing `site-*` on a live Tile3DLayer reloads the tileset (deck.gl only
774
+ reloads on a URL change, so the runtime cache-busts the URL). The outline
775
+ PathLayer updates as a uniform, with no reload.
776
+
777
+ Three constraints worth knowing before promising this to a user:
778
+
779
+ - Multi-material models are fine: glTF allows one material per primitive, so a
780
+ five-material house is five primitives and a real IFC export is often dozens —
781
+ all of them get per-element picking. Only genuinely instanced (i3dm) tiles fall
782
+ back to tile-granularity picking, where `feature-styles` does nothing.
783
+ - Datasets that store IDs in a TEXTURE (photogrammetry classification) need
784
+ `load-options` with `gltf.loadImages`, `gltf.loadBuffers` AND
785
+ `image: {"type": "data"}`. Without the last one the tileset takes minutes to
786
+ appear — loaders.gl otherwise reads the whole ID texture back through a canvas
787
+ once per vertex.
788
+ - `opacity: 0` HIDES an element (a shader discard, so it also stops being
789
+ pickable and you can select what is behind it). Partial `opacity` (ghosting) is
790
+ still being validated.
791
+
792
+ Worked example: `examples/ferry-building-features.html`.
793
+
794
+ ### BIMLayer — declarative in-browser loading
795
+
796
+ `<om-layer type="BIMLayer" src="./model.ifc">` is the declarative counterpart
797
+ to `ifc-loader`/`loadIfc`: point it at a BIM source file (an `.ifc` today,
798
+ other formats plug into the same layer later) and it runs the file through
799
+ the loader itself the moment `src` resolves — no pre-baked tileset, no
800
+ `site-origin`/`site-heading`/`site-scale` (the file's own georeference is
801
+ read and applied automatically; the attributes exist to OVERRIDE a wrong or
802
+ missing reading, not to restate what the loader already computed — an
803
+ authored `site-origin` on a BIMLayer is not wired up yet, a documented gap),
804
+ and no separate `<om-layer type="PathLayer">` for the outline overlay (it's
805
+ added automatically). Everything else about a picked BIM layer — `pick-features`
806
+ (defaults ON, unlike a plain `Tile3DLayer`), `feature-filter-field`,
807
+ `feature-styles`, `isolate-features`/`hide-features`/`ghost-features`,
808
+ `feature-color-by`, `ghost-opacity` — works exactly as it does on
809
+ `Tile3DLayer`, because BIMLayer forwards them to a real `Tile3DLayer` it
810
+ builds internally:
811
+
812
+ ```html
813
+ <om-layer id="clinic" type="BIMLayer" src="./clinic.ifc"
814
+ label="Medical-Dental Clinic"
815
+ feature-filter-field="ifcClass"
816
+ ghost-opacity="0.15"
817
+ pickable></om-layer>
818
+ ```
819
+
820
+ Reach for `BIMLayer` when the model is fixed and known ahead of time
821
+ (`dev/examples/features/terrain-3d/inspect-a-bim-model.html`); reach for the `ifc-loader` widget (BIM
822
+ workbench) when a visitor picks the file, or when several models need to
823
+ federate into one coordinated scene. **Known gap:** the outline overlay does
824
+ not yet follow isolate/hide/ghost the way the mesh does — `ifc-browser`'s
825
+ visibility sync targets a separate `<layer-id>-edges` element by convention,
826
+ and BIMLayer's outline never reaches the DOM as one.
827
+
828
+ ### In-browser IFC
829
+
830
+ `loadIfc` parses an `.ifc` with web-ifc (WASM) and returns a 3D Tiles model held
831
+ entirely in memory — nothing is uploaded and nothing is written to disk. Because
832
+ the output IS a tileset, `pick-features`, `feature-styles`, `site-*` and the
833
+ declarative isolate/hide/ghost attributes work on it unchanged.
834
+
835
+ Most pages should not call it directly — `<om-widget type="ifc-loader">` owns
836
+ the drop zone, the call, the layer elements, the camera and the blob-URL
837
+ lifetime. `examples/features/terrain-3d/inspect-a-bim-model.html` is the whole workflow — a model on load, a drop zone for more, browse and coordinate — in five widget tags.
838
+
839
+ ```js
840
+ import { loadIfc, configureIfc } from "@nika-js/onlymap";
841
+
842
+ configureIfc({ wasmPath: "/vendor/web-ifc/" }); // optional: self-host instead of the CDN
843
+ const arch = await loadIfc(archBytes, { onProgress: (m) => console.log(m) });
844
+ // FEDERATION: pass the first model's origin so the two share a frame.
845
+ const mep = await loadIfc(mepBytes, { origin: arch.origin });
846
+ ```
847
+
848
+ Returns `tilesetUrl` and `edgesUrl` (blob URLs — assign to a `Tile3DLayer`'s
849
+ `tileset` and a companion `PathLayer`'s `data`), `loadOptions` to pass straight
850
+ through, `features` (the property table, for legends and category lists —
851
+ `ifcClass`, `name`, `material`, `container`, `netVolume`, plus the hierarchy
852
+ columns `spatialPath` / `typePath` / `systemPath` / `classificationPath`, each
853
+ joined by `SPATIAL_SEPARATOR` (U+001F, which cannot occur in an IFC label) and
854
+ emitted only when the file populates it),
855
+ `lonLat`/`georeferenced`/`heading`/`scale`/`originSource`/`headingSource` for
856
+ the `site-*` attributes, `stats`, `timings` (ms per phase), `bounds`, and
857
+ `revoke()`.
858
+
859
+ - **Call `revoke()` when you swap models.** Blob URLs are held by the document
860
+ and are not garbage collected.
861
+ - **`origin` is federation.** Each model is otherwise centred on its own
862
+ bounding box, so two discipline models of one building drift apart by the
863
+ difference between those boxes — and a clash pass then reports nothing, which
864
+ is indistinguishable from a clean model. Pass the first model's `origin` into
865
+ every later `loadIfc` for the same building. `ifc-loader` does this for you
866
+ per map, and shares the PLACEMENT too (`site-origin`/`site-heading`/`site-scale`):
867
+ discipline files routinely declare IfcSite coordinates kilometres apart for the
868
+ same building, so the first model loaded decides where it goes and the rest
869
+ follow. `independent` opts out.
870
+ - Every element also gets a bounding box in the property table
871
+ (`bboxMinE`/`bboxMinN`/`bboxMinU`/`bboxMaxE`/`bboxMaxN`/`bboxMaxU`, tile-local
872
+ metres), which is what `ifc-clash` reads.
873
+ - web-ifc is MPL-2.0 and is NOT a package dependency — it is dynamic-imported
874
+ from unpkg on first use, so it never reaches the bundle and pages that never
875
+ open an IFC pay nothing. Measured: the IFC loader chunk is 23.7 KB raw /
876
+ 8.5 KB gzipped and contains only the CDN URL; no web-ifc code and no `.wasm`
877
+ ship in `dist/`.
878
+ - **For OFFLINE, air-gapped or strict-CSP deployments, self-host it.**
879
+ `npm run vendor:web-ifc public/vendor/web-ifc` copies the four files you need (1.37 MB gzipped — the multithreaded pair is included because `IfcAPI.Init()` picks it whenever the page is cross-origin isolated, so vendoring only the single-threaded pair breaks under COOP/COEP),
880
+ then either `wasm-path="/vendor/web-ifc/"` on the `ifc-loader` widget (no
881
+ script needed) or `configureIfc({wasmPath})` once before the first model
882
+ loads. web-ifc is only ONE of the network dependencies though: a georeferenced
883
+ model makes `ifc-loader` switch a basemap on, which reaches a tile server, so
884
+ offline pages also want `basemap="none"` on the loader and `telemetry="off"`
885
+ on the map. They stay STATIC ASSETS fetched by the same lazy import — the JS
886
+ bundle does not grow. Cost: `web-ifc-api.js` 5.31 MB raw / 0.49 MB gzipped
887
+ plus `web-ifc.wasm` 1.20 MB / 0.44 MB, so ~0.93 MB gzipped, served once and
888
+ cached. (`web-ifc-mt.wasm` is a further 1.22 MB and is only needed for the
889
+ multithreaded path.) Note a dynamic import cannot carry subresource
890
+ integrity, so the pinned version in the URL is the only thing fixing what
891
+ runs — another reason to serve it yourself.
892
+ - Position is read from the file, preferring the trustworthy route.
893
+ `IfcMapConversion` — a surveyed placement into a named projected CRS — wins
894
+ over `IfcSite.RefLatitude`/`RefLongitude`, which is very often an authoring
895
+ default. `originSource` says which was used: `"map-conversion"`, `"ifc-site"`
896
+ or `"fallback"`.
897
+ - **Anything other than `"map-conversion"` raises a structured `"warning"`**
898
+ through the same validation channel other `om-layer` errors use (visible in
899
+ the on-page panel with `validate` set, and on `om-validation-error`'s
900
+ `detail.warnings`) — for both `BIMLayer` and the `ifc-loader` widget, once
901
+ per layer. It does not flip `valid` to `false`; it flags that the position
902
+ may be off by tens of metres with no rotation correction applied. Override
903
+ with `site-origin`/`site-heading` once the real location is known, or
904
+ re-export the model with a proper `IfcMapConversion`.
905
+ - Un-projecting a map conversion covers WGS84 UTM zones analytically, plus
906
+ every CRS in the bundled table `src/crs.ts` shares with the CityJSON
907
+ decoder (Dutch RD, Swiss LV95, ETRS89/UTM, Japan's plane systems, …).
908
+ Anything else is declined with a warning naming the bundled codes, rather
909
+ than approximated — a guessed projection lands the model in another
910
+ country while looking entirely plausible.
911
+ - A model aligned to a NATIONAL GRID is not aligned to true north, so the
912
+ grid convergence is measured and folded into the heading. Dutch RD at
913
+ Rotterdam leans -0.735 degrees, and it grows with distance from the
914
+ central meridian.
915
+ - The conversion is APPLIED, not just read. It anchors the model's ORIGIN,
916
+ while the tileset is recentred on its geometry, so the full affine
917
+ (offset, grid axis, scale) is applied to the anchor point. Skipping that
918
+ put one Revit export 32.7 m out, its survey point being that far from the
919
+ building.
920
+ - `IfcMapConversion` OUTRANKS `IfcSite` for position. Files carrying both
921
+ routinely disagree: one Dutch model's two statements are 108 m apart, and
922
+ the projected pair is the surveyed one. Note the eastings/northings are in the target CRS's own
923
+ unit, which is frequently MILLIMETRES.
924
+ - `headingSource` distinguishes `"map-conversion"` / `"true-north"` (read from
925
+ the file) from `"assumed"` (the file was silent and project north was taken as
926
+ true north). Report the assumption; do not let it read as a measurement.
927
+ - A file can be perfectly georeferenced and still land somewhere useless: all
928
+ three prepared samples in this repo declare placeholder positions (the clinic
929
+ on Revit's Boston default, which reverse-geocodes to a 1630 graveyard; the
930
+ duplex on a Chicago city-centre point; the bridge, which DOES carry a real
931
+ `IfcMapConversion`, into the mid-Pacific at 179.08E 8.46S). Reading the file
932
+ correctly and the model being in a sensible place are separate problems, and
933
+ `site-origin` is the fix for the second.
934
+ - `<om-map>` reads its camera attributes ONCE at init, so `setAttribute("center", …)`
935
+ after mount moves nothing and leaves the model outside the frustum. Use
936
+ `map.flyTo(lonLat, zoom)`.