@nika-js/onlymap 0.6.0 → 0.6.1

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 (54) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/README.md +18 -6
  3. package/THIRD-PARTY-LICENSES.md +27 -0
  4. package/dist/{LercDecode.es-fbo6h5rt.js → LercDecode.es-CJnypw8j.js} +1 -1
  5. package/dist/{basemap-CprSan9q.js → basemap-DrQ0-eyR.js} +1 -1
  6. package/dist/cityjson-D_V5GY8b.js +332 -0
  7. package/dist/crs-Ciu7Xs7a.js +108 -0
  8. package/dist/crs.d.ts +50 -0
  9. package/dist/ctx.d.ts +8 -1
  10. package/dist/elements/om-map.d.ts +26 -2
  11. package/dist/elements/om-overlay.d.ts +1 -1
  12. package/dist/elements/om-widget.d.ts +4 -0
  13. package/dist/feature-colors.d.ts +41 -0
  14. package/dist/{geoparquet-Bm8pfxyo.js → geoparquet-Dix4lTNy.js} +1 -1
  15. package/dist/html-data.d.ts +1 -1
  16. package/dist/ifc-Dc-TdSJr.js +877 -0
  17. package/dist/ifc.d.ts +406 -0
  18. package/dist/{index-Bz-rbmhj.js → index-1UgNlfGR.js} +20892 -18886
  19. package/dist/{index-GOwMjqm6.js → index-BQMjW5w0.js} +1 -1
  20. package/dist/{index-DSRIZR81.js → index-B_1PPJgC.js} +1 -1
  21. package/dist/{index-CCQttJzO.js → index-CAuT5j9Y.js} +2 -2
  22. package/dist/{index-UzmNhuS0.js → index-CXPaeisL.js} +1 -1
  23. package/dist/index.d.ts +8 -0
  24. package/dist/layer-registry.d.ts +14 -0
  25. package/dist/layers/bim-layer.d.ts +104 -0
  26. package/dist/layers/feature-mesh-layer.d.ts +288 -0
  27. package/dist/layers/gltf-scene-walk.d.ts +55 -0
  28. package/dist/{lerc-CuifOkoA.js → lerc-CbTjQ7uI.js} +2 -2
  29. package/dist/meshopt_simplifier-Co6uRDDA.js +370 -0
  30. package/dist/onlymap.standalone.js +57996 -54706
  31. package/dist/onlymapjs.js +69 -67
  32. package/dist/programmatic.d.ts +22 -2
  33. package/dist/{raster-dysYIQ_k.js → raster-0b0nSHUh.js} +2 -2
  34. package/dist/{raster-pipeline-ZdCOGfqV.js → raster-pipeline-D8siq7-4.js} +1 -1
  35. package/dist/react/om-overlay.d.ts +9 -0
  36. package/dist/react.js +144 -144
  37. package/dist/runtime-core.d.ts +110 -3
  38. package/dist/selection.d.ts +33 -0
  39. package/dist/site-placement.d.ts +85 -0
  40. package/dist/testing.d.ts +6 -2
  41. package/dist/tile3d-metadata.d.ts +185 -0
  42. package/dist/version.d.ts +1 -1
  43. package/dist/widget-registry.d.ts +11 -0
  44. package/dist/widgets/ifc-clash.d.ts +18 -0
  45. package/dist/widgets/ifc.d.ts +41 -0
  46. package/dist/{zarr-OewK7k2K.js → zarr-DCYro_Vs.js} +79 -66
  47. package/docs/testing.md +2 -2
  48. package/llms.txt +3 -3
  49. package/onlymapjs.html-data.json +152 -0
  50. package/package.json +3 -1
  51. package/skills/onlymapjs/SKILL.md +4 -2
  52. package/skills/onlymapjs/references/react.md +1 -1
  53. package/skills/onlymapjs/references/syntax.md +298 -11
  54. package/dist/cityjson-urQeujQv.js +0 -407
@@ -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.1/dist/onlymapjs.css">
20
+ <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.6.1"></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,7 +42,7 @@ 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
47
  - `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
48
  - `headless width="800" height="600"` for test harness use
@@ -137,7 +137,7 @@ For an epoch-millisecond filter, format the built-in widget's numeric labels dec
137
137
 
138
138
  Use the `type` value exactly:
139
139
 
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`.
140
+ `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
141
 
142
142
  Common choices:
143
143
 
@@ -147,6 +147,7 @@ Common choices:
147
147
  - Aggregation: `HeatmapLayer`, `HexagonLayer`, `GridLayer`, `ScreenGridLayer`.
148
148
  - Tiles: `TileLayer`, `MVTLayer`, `Tile3DLayer`.
149
149
  - 3D models: `ScenegraphLayer`, `SimpleMeshLayer`, `PointCloudLayer`, `Tile3DLayer`.
150
+ - BIM source files (`.ifc`, loaded in-browser, no pre-conversion step): `BIMLayer`.
150
151
  - GeoTIFF/COG rasters: `COGLayer`.
151
152
  - Zarr / GeoZarr rasters (chunked N-D arrays): `ZarrLayer`.
152
153
  - Geotagged drone JPEGs: `ImageOverlay`.
@@ -359,6 +360,14 @@ Built-ins:
359
360
  - `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
361
  - `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
362
  - `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.
363
+ - `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.
364
+
365
+ 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.
366
+
367
+ 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.
368
+ - `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.
369
+ - `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.
370
+ - `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
371
 
363
372
  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
373
 
@@ -416,12 +425,17 @@ watched token fires:
416
425
  </om-widget>
417
426
  ```
418
427
 
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).
428
+ - `ctx.layers`
429
+ - `ctx.data(id)`
430
+ - `ctx.dataInViewport(id)`
431
+ - `ctx.stats(id, field, { scope: "viewport" })`
432
+ - `ctx.selection`
433
+ - `ctx.viewport`
434
+ - `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.
435
+ - `ctx.history` — `{ canUndo, canRedo }`; re-render on changes via the `history` watch token
436
+ - `ctx.emit(action, payload)`
437
+
438
+ 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
439
 
426
440
  **Drive the map — the emission contract.** This is the part authors get wrong.
427
441
  A widget NEVER mutates the map directly and NEVER dispatches its own
@@ -481,6 +495,11 @@ Anchors:
481
495
  - `anchor-from="selection"`
482
496
  - `anchor-layer="regions" anchor-feature-id="mission"`
483
497
 
498
+ Selection scoping (both only apply with `anchor-from="selection"`):
499
+
500
+ - `layer="quakes"` — only that layer's picks move/re-template the overlay.
501
+ - `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.
502
+
484
503
  Templates:
485
504
 
486
505
  - `{{field}}` HTML-escaped interpolation.
@@ -489,7 +508,7 @@ Templates:
489
508
  Example:
490
509
 
491
510
  ```html
492
- <om-overlay id="detail" anchor-from="selection" visible="false">
511
+ <om-overlay id="detail" anchor-from="selection" selection-type="click" visible="false">
493
512
  <div><b>{{place}}</b> M {{magnitude}}</div>
494
513
  </om-overlay>
495
514
  <om-behavior on="click" layer="quakes" action="show-overlay" target="detail"></om-behavior>
@@ -635,3 +654,271 @@ For 3D Tiles LOD/refinement experiments, use:
635
654
  maximum-memory-usage="256"
636
655
  view-distance-scale="0.85"></om-layer>
637
656
  ```
657
+
658
+ ### Per-element (BIM) picking and styling
659
+
660
+ A plain `pickable` `Tile3DLayer` picks a whole TILE. To pick individual
661
+ elements — a wall, a window, one IFC product — add `pick-features`. The pick's
662
+ `selection` then carries `featureId`, `properties` and `class` resolved from the
663
+ tile's own `EXT_mesh_features` + `EXT_structural_metadata`:
664
+
665
+ ```html
666
+ <om-layer id="building" type="Tile3DLayer"
667
+ tileset="https://example.com/tileset.json"
668
+ pick-features
669
+ feature-id-property="_FEATURE_ID_0"
670
+ load-options='{"gltf":{"loadBuffers":true,"loadImages":true},"image":{"type":"data"}}'
671
+ pickable></om-layer>
672
+ ```
673
+
674
+ `feature-styles` recolours, fades or highlights elements by feature ID — an
675
+ array indexed BY id, each entry `{color: [r,g,b], strength: 0-1, opacity: 0-1}`.
676
+ Set it live (from a widget, a dropdown, a pick handler) and only a small lookup
677
+ texture is re-uploaded; no refetch, no re-tesselation:
678
+
679
+ ```html
680
+ <om-layer id="building" type="Tile3DLayer" tileset="…" pick-features pickable
681
+ feature-styles='[{"color":[90,200,255],"strength":0.5},{},{"color":[255,215,130],"strength":0.9}]'></om-layer>
682
+ ```
683
+
684
+ Use `strength` below 1 to TINT rather than replace — a full-strength colour hides
685
+ the model's own texture entirely.
686
+
687
+ **Isolate / hide / ghost** are declarative, and mirror the vector
688
+ `filter-field` + `filter-categories` pair: name the metadata field once, then
689
+ list values per state. Values are JSON arrays, matched against the tile's own
690
+ property table:
691
+
692
+ ```html
693
+ <om-layer id="building" type="Tile3DLayer" tileset="…" pick-features pickable
694
+ feature-filter-field="component"
695
+ hide-features='["Windows","Skylight"]'
696
+ ghost-features='["Wall"]'
697
+ ghost-opacity="0.18"></om-layer>
698
+ ```
699
+
700
+ - `isolate-features` is exclusive — anything NOT listed is hidden, so it is a
701
+ scope rather than another kind of hide.
702
+ - Hiding is a shader `discard`, so a hidden element also stops being pickable
703
+ and you can select whatever sits behind it.
704
+ - These compose ONTO `feature-styles` rather than replacing it: the style table
705
+ supplies colour, these supply visibility. Changing colour scheme never
706
+ un-hides anything, and isolating never drops your colouring.
707
+ - Being attributes, they are undoable and story-steppable — prefer them over
708
+ computing a `feature-styles` table in page JS.
709
+
710
+ **Colour by property.** `feature-styles` is indexed by feature ID, which means
711
+ computing a table by hand. These name a property-table FIELD instead and build
712
+ that table for you, once the table arrives with the first tile:
713
+
714
+ ```html
715
+ <om-layer id="clinic" type="Tile3DLayer" tileset="…" pick-features pickable
716
+ feature-color-by="material"></om-layer>
717
+ ```
718
+
719
+ - `feature-color-by` is CATEGORICAL — one palette entry per distinct value.
720
+ `feature-palette='["#4f7cff","#ff9a3c"]'` overrides the built-in
721
+ colour-blind-safe cycle.
722
+ - `feature-color-scale` is GRADUATED over a numeric field.
723
+ - `feature-color-strength` (default 0.85) is how hard the colour is mixed over
724
+ the model's own material; below 1 tints rather than replaces.
725
+ - Setting NEITHER is meaningful, and is the default: the model renders in its
726
+ own IFC surface colours, which is what someone opening a building expects.
727
+ Reach for these to answer a question, not to make it look coloured.
728
+ - An authored `feature-styles` always wins — these are sugar over the same
729
+ table, never an override of it.
730
+
731
+ A graduated ramp needs the field to actually be populated. Revit IFC2x3 exports
732
+ frequently carry no `IfcElementQuantity` at all, so every `netVolume` is 0 and
733
+ the ramp renders flat — check the property table before blaming the ramp.
734
+
735
+ **Georeferencing — `site-origin` / `site-heading` / `site-scale`** (Tile3DLayer
736
+ and PathLayer). Where a model sits is a viewing decision, not a conversion one,
737
+ so it lives on the layer:
738
+
739
+ ```html
740
+ <om-layer id="clinic" type="Tile3DLayer" tileset="…/tileset.json" pick-features pickable
741
+ site-origin="[-71.059776, 42.358429]" site-heading="32" site-scale="1"></om-layer>
742
+ ```
743
+
744
+ - `site-origin` is `[lng, lat]` or `[lng, lat, elevation]`, and it OVERRIDES the
745
+ position baked into the tileset's root transform rather than offsetting it.
746
+ - `site-heading` is a bearing — degrees CLOCKWISE from true north. On its own,
747
+ with no `site-origin`, it rotates the model where it stands.
748
+ - Rotation and scale pivot on the model's own anchor, not the tileset origin,
749
+ so a heading change spins the building about itself.
750
+ - An IFC model is a PAIR of layers — the mesh tileset and a `PathLayer` outline
751
+ overlay whose paths are local east/north/up metres — and both need the same
752
+ three values, or the building walks away from its own edges. The
753
+ `ifc-loader` widget sets them on both for you.
754
+ - Do not trust a model's declared position without looking at it. Authoring
755
+ tools ship a default project location, and a default is indistinguishable
756
+ from a survey: the buildingSMART Medical-Dental Clinic sample carries Revit's
757
+ Boston default and the Duplex a Chicago city-centre point, so both land on
758
+ occupied downtown blocks at an arbitrary rotation. `IfcMapConversion` (real
759
+ georeferencing) is absent from most IFC2x3 exports and `TrueNorth` is
760
+ routinely unset — which is exactly what these attributes are for.
761
+ - Editing `site-*` on a live Tile3DLayer reloads the tileset (deck.gl only
762
+ reloads on a URL change, so the runtime cache-busts the URL). The outline
763
+ PathLayer updates as a uniform, with no reload.
764
+
765
+ Three constraints worth knowing before promising this to a user:
766
+
767
+ - Multi-material models are fine: glTF allows one material per primitive, so a
768
+ five-material house is five primitives and a real IFC export is often dozens —
769
+ all of them get per-element picking. Only genuinely instanced (i3dm) tiles fall
770
+ back to tile-granularity picking, where `feature-styles` does nothing.
771
+ - Datasets that store IDs in a TEXTURE (photogrammetry classification) need
772
+ `load-options` with `gltf.loadImages`, `gltf.loadBuffers` AND
773
+ `image: {"type": "data"}`. Without the last one the tileset takes minutes to
774
+ appear — loaders.gl otherwise reads the whole ID texture back through a canvas
775
+ once per vertex.
776
+ - `opacity: 0` HIDES an element (a shader discard, so it also stops being
777
+ pickable and you can select what is behind it). Partial `opacity` (ghosting) is
778
+ still being validated.
779
+
780
+ Worked example: `examples/ferry-building-features.html`.
781
+
782
+ ### BIMLayer — declarative in-browser loading
783
+
784
+ `<om-layer type="BIMLayer" src="./model.ifc">` is the declarative counterpart
785
+ to `ifc-loader`/`loadIfc`: point it at a BIM source file (an `.ifc` today,
786
+ other formats plug into the same layer later) and it runs the file through
787
+ the loader itself the moment `src` resolves — no pre-baked tileset, no
788
+ `site-origin`/`site-heading`/`site-scale` (the file's own georeference is
789
+ read and applied automatically; the attributes exist to OVERRIDE a wrong or
790
+ missing reading, not to restate what the loader already computed — an
791
+ authored `site-origin` on a BIMLayer is not wired up yet, a documented gap),
792
+ and no separate `<om-layer type="PathLayer">` for the outline overlay (it's
793
+ added automatically). Everything else about a picked BIM layer — `pick-features`
794
+ (defaults ON, unlike a plain `Tile3DLayer`), `feature-filter-field`,
795
+ `feature-styles`, `isolate-features`/`hide-features`/`ghost-features`,
796
+ `feature-color-by`, `ghost-opacity` — works exactly as it does on
797
+ `Tile3DLayer`, because BIMLayer forwards them to a real `Tile3DLayer` it
798
+ builds internally:
799
+
800
+ ```html
801
+ <om-layer id="clinic" type="BIMLayer" src="./clinic.ifc"
802
+ label="Medical-Dental Clinic"
803
+ feature-filter-field="ifcClass"
804
+ ghost-opacity="0.15"
805
+ pickable></om-layer>
806
+ ```
807
+
808
+ Reach for `BIMLayer` when the model is fixed and known ahead of time
809
+ (`dev/examples/bim-sample.html`); reach for the `ifc-loader` widget (BIM
810
+ workbench) when a visitor picks the file, or when several models need to
811
+ federate into one coordinated scene. **Known gap:** the outline overlay does
812
+ not yet follow isolate/hide/ghost the way the mesh does — `ifc-browser`'s
813
+ visibility sync targets a separate `<layer-id>-edges` element by convention,
814
+ and BIMLayer's outline never reaches the DOM as one.
815
+
816
+ ### In-browser IFC
817
+
818
+ `loadIfc` parses an `.ifc` with web-ifc (WASM) and returns a 3D Tiles model held
819
+ entirely in memory — nothing is uploaded and nothing is written to disk. Because
820
+ the output IS a tileset, `pick-features`, `feature-styles`, `site-*` and the
821
+ declarative isolate/hide/ghost attributes work on it unchanged.
822
+
823
+ Most pages should not call it directly — `<om-widget type="ifc-loader">` owns
824
+ the drop zone, the call, the layer elements, the camera and the blob-URL
825
+ lifetime. `examples/bim-workbench.html` is the whole workflow — load, browse, coordinate — in five widget tags.
826
+
827
+ ```js
828
+ import { loadIfc, configureIfc } from "@nika-js/onlymap";
829
+
830
+ configureIfc({ wasmPath: "/vendor/web-ifc/" }); // optional: self-host instead of the CDN
831
+ const arch = await loadIfc(archBytes, { onProgress: (m) => console.log(m) });
832
+ // FEDERATION: pass the first model's origin so the two share a frame.
833
+ const mep = await loadIfc(mepBytes, { origin: arch.origin });
834
+ ```
835
+
836
+ Returns `tilesetUrl` and `edgesUrl` (blob URLs — assign to a `Tile3DLayer`'s
837
+ `tileset` and a companion `PathLayer`'s `data`), `loadOptions` to pass straight
838
+ through, `features` (the property table, for legends and category lists —
839
+ `ifcClass`, `name`, `material`, `container`, `netVolume`, plus the hierarchy
840
+ columns `spatialPath` / `typePath` / `systemPath` / `classificationPath`, each
841
+ joined by `SPATIAL_SEPARATOR` (U+001F, which cannot occur in an IFC label) and
842
+ emitted only when the file populates it),
843
+ `lonLat`/`georeferenced`/`heading`/`scale`/`originSource`/`headingSource` for
844
+ the `site-*` attributes, `stats`, `timings` (ms per phase), `bounds`, and
845
+ `revoke()`.
846
+
847
+ - **Call `revoke()` when you swap models.** Blob URLs are held by the document
848
+ and are not garbage collected.
849
+ - **`origin` is federation.** Each model is otherwise centred on its own
850
+ bounding box, so two discipline models of one building drift apart by the
851
+ difference between those boxes — and a clash pass then reports nothing, which
852
+ is indistinguishable from a clean model. Pass the first model's `origin` into
853
+ every later `loadIfc` for the same building. `ifc-loader` does this for you
854
+ per map, and shares the PLACEMENT too (`site-origin`/`site-heading`/`site-scale`):
855
+ discipline files routinely declare IfcSite coordinates kilometres apart for the
856
+ same building, so the first model loaded decides where it goes and the rest
857
+ follow. `independent` opts out.
858
+ - Every element also gets a bounding box in the property table
859
+ (`bboxMinE`/`bboxMinN`/`bboxMinU`/`bboxMaxE`/`bboxMaxN`/`bboxMaxU`, tile-local
860
+ metres), which is what `ifc-clash` reads.
861
+ - web-ifc is MPL-2.0 and is NOT a package dependency — it is dynamic-imported
862
+ from unpkg on first use, so it never reaches the bundle and pages that never
863
+ open an IFC pay nothing. Measured: the IFC loader chunk is 23.7 KB raw /
864
+ 8.5 KB gzipped and contains only the CDN URL; no web-ifc code and no `.wasm`
865
+ ship in `dist/`.
866
+ - **For OFFLINE, air-gapped or strict-CSP deployments, self-host it.**
867
+ `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),
868
+ then either `wasm-path="/vendor/web-ifc/"` on the `ifc-loader` widget (no
869
+ script needed) or `configureIfc({wasmPath})` once before the first model
870
+ loads. web-ifc is only ONE of the network dependencies though: a georeferenced
871
+ model makes `ifc-loader` switch a basemap on, which reaches a tile server, so
872
+ offline pages also want `basemap="none"` on the loader and `telemetry="off"`
873
+ on the map. They stay STATIC ASSETS fetched by the same lazy import — the JS
874
+ bundle does not grow. Cost: `web-ifc-api.js` 5.31 MB raw / 0.49 MB gzipped
875
+ plus `web-ifc.wasm` 1.20 MB / 0.44 MB, so ~0.93 MB gzipped, served once and
876
+ cached. (`web-ifc-mt.wasm` is a further 1.22 MB and is only needed for the
877
+ multithreaded path.) Note a dynamic import cannot carry subresource
878
+ integrity, so the pinned version in the URL is the only thing fixing what
879
+ runs — another reason to serve it yourself.
880
+ - Position is read from the file, preferring the trustworthy route.
881
+ `IfcMapConversion` — a surveyed placement into a named projected CRS — wins
882
+ over `IfcSite.RefLatitude`/`RefLongitude`, which is very often an authoring
883
+ default. `originSource` says which was used: `"map-conversion"`, `"ifc-site"`
884
+ or `"fallback"`.
885
+ - **Anything other than `"map-conversion"` raises a structured `"warning"`**
886
+ through the same validation channel other `om-layer` errors use (visible in
887
+ the on-page panel with `validate` set, and on `om-validation-error`'s
888
+ `detail.warnings`) — for both `BIMLayer` and the `ifc-loader` widget, once
889
+ per layer. It does not flip `valid` to `false`; it flags that the position
890
+ may be off by tens of metres with no rotation correction applied. Override
891
+ with `site-origin`/`site-heading` once the real location is known, or
892
+ re-export the model with a proper `IfcMapConversion`.
893
+ - Un-projecting a map conversion covers WGS84 UTM zones analytically, plus
894
+ every CRS in the bundled table `src/crs.ts` shares with the CityJSON
895
+ decoder (Dutch RD, Swiss LV95, ETRS89/UTM, Japan's plane systems, …).
896
+ Anything else is declined with a warning naming the bundled codes, rather
897
+ than approximated — a guessed projection lands the model in another
898
+ country while looking entirely plausible.
899
+ - A model aligned to a NATIONAL GRID is not aligned to true north, so the
900
+ grid convergence is measured and folded into the heading. Dutch RD at
901
+ Rotterdam leans -0.735 degrees, and it grows with distance from the
902
+ central meridian.
903
+ - The conversion is APPLIED, not just read. It anchors the model's ORIGIN,
904
+ while the tileset is recentred on its geometry, so the full affine
905
+ (offset, grid axis, scale) is applied to the anchor point. Skipping that
906
+ put one Revit export 32.7 m out, its survey point being that far from the
907
+ building.
908
+ - `IfcMapConversion` OUTRANKS `IfcSite` for position. Files carrying both
909
+ routinely disagree: one Dutch model's two statements are 108 m apart, and
910
+ the projected pair is the surveyed one. Note the eastings/northings are in the target CRS's own
911
+ unit, which is frequently MILLIMETRES.
912
+ - `headingSource` distinguishes `"map-conversion"` / `"true-north"` (read from
913
+ the file) from `"assumed"` (the file was silent and project north was taken as
914
+ true north). Report the assumption; do not let it read as a measurement.
915
+ - A file can be perfectly georeferenced and still land somewhere useless: all
916
+ three prepared samples in this repo declare placeholder positions (the clinic
917
+ on Revit's Boston default, which reverse-geocodes to a 1630 graveyard; the
918
+ duplex on a Chicago city-centre point; the bridge, which DOES carry a real
919
+ `IfcMapConversion`, into the mid-Pacific at 179.08E 8.46S). Reading the file
920
+ correctly and the model being in a sensible place are separate problems, and
921
+ `site-origin` is the fix for the second.
922
+ - `<om-map>` reads its camera attributes ONCE at init, so `setAttribute("center", …)`
923
+ after mount moves nothing and leaves the model outside the frustum. Use
924
+ `map.flyTo(lonLat, zoom)`.