@thatopen/services 0.16.1 → 0.18.0

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.
@@ -0,0 +1,189 @@
1
+ /* MD
2
+ ## map-panel
3
+ ---
4
+ In a large site, the 3D camera alone is a poor way to get oriented: users
5
+ want a plan view to see where they are, where the GIS features are, and to
6
+ jump somewhere else with a click.
7
+
8
+ `map-panel` is a ready-made web component that delivers exactly that: a
9
+ top-down, north-up map of the GIS tiles with the project's vector layers drawn
10
+ over it, a marker for the 3D camera, and your own markers. It connects to
11
+ `GISManager` automatically, and everything on it stays synchronized with
12
+ the 3D view.
13
+
14
+ This tutorial covers the prerequisites before mounting the panel; dropping it
15
+ into a `top-app` layout beside the 3D viewer; a breakdown of everything the
16
+ panel handles automatically; and how an application adds its own markers.
17
+
18
+ By the end, you'll have an interactive minimap in your application with a
19
+ single line of markup.
20
+ */
21
+
22
+ import { html } from "lit";
23
+ import * as THREE from "three";
24
+ import * as OBC from "@thatopen/components";
25
+ import * as OBF from "@thatopen/components-front";
26
+ import * as FRAGS from "@thatopen/fragments";
27
+ import * as BUI from "@thatopen/ui";
28
+ import { PlatformClient, UIManager, GISManager, ProjectManager } from "@thatopen/services";
29
+ import type { App } from "../app/index";
30
+
31
+
32
+ /* MD
33
+ ### ✅ Prerequisites
34
+
35
+ Three conditions must be met before the panel mounts:
36
+
37
+ 1. **`UIManager` must be in the setup call.** It registers all platform web
38
+ components, including `<top-map-panel>` itself. Without it the element is
39
+ unknown to the browser and renders as an empty box.
40
+
41
+ 2. **`ProjectManager.init(client)` must have resolved.** `GISManager` takes
42
+ the project origin from it: without it the 3D tiles and the vector layers
43
+ are placed around a default origin instead of the project's.
44
+
45
+ 3. **`GISManager.init(client)` must have resolved**, after `ProjectManager`.
46
+ The panel needs the 3D tiles to capture the map, and
47
+ `init` is what reads the layer manifest: the captured area is the box
48
+ of all the manifest's layers, so without `init` the area falls back to a
49
+ square around the origin and no layers are drawn over the map.
50
+
51
+ Both are satisfied by awaiting the two `init`s, in that order, before
52
+ appending `top-app` to the document, which is the pattern shown below.
53
+ */
54
+
55
+ const client = PlatformClient.fromPlatformContext();
56
+
57
+ const { components } = (await client.setup(
58
+ { OBC, OBF, BUI, THREE, FRAGS },
59
+ { uuid: UIManager.uuid },
60
+ { uuid: ProjectManager.uuid },
61
+ { uuid: GISManager.uuid },
62
+ )) as { components: OBC.Components };
63
+
64
+ components.get(UIManager).init();
65
+
66
+ // Order matters: GISManager reads the origin that ProjectManager has loaded.
67
+ await components.get(ProjectManager).init(client);
68
+ await components.get(GISManager).init(client);
69
+
70
+ /* MD
71
+ ### 🖥️ Wiring the panel
72
+
73
+ Add `map-panel` as a named area next to the viewer. The map can only be
74
+ captured once the 3D tiles are loaded, so this layout also mounts
75
+ `gis-panel`, which lets the user enter the token and load the map. Your
76
+ application can do the same by code with `GISManager.enableTiles()`. The
77
+ viewer also carries `top-viewer-gis-ghost-button` in a toolbar, which makes the
78
+ map see-through so its vector features can be picked.
79
+ */
80
+
81
+ const app = document.createElement("top-app") as unknown as App;
82
+
83
+ app.setup = (waitUntil) => {
84
+ waitUntil(
85
+ (async () => {
86
+ const fragments = components.get(OBC.FragmentsManager);
87
+ const workerUrl = await FRAGS.FragmentsModels.getWorker();
88
+ fragments.init(await FRAGS.toClassicWorker(workerUrl), { classicWorker: true });
89
+ })(),
90
+ "Fragments Core",
91
+ );
92
+
93
+ return { components, client };
94
+ };
95
+
96
+ app.elements = {
97
+ gis: () => html`<top-gis-panel></top-gis-panel>`,
98
+ viewer: () => html`
99
+ <top-viewer>
100
+ <top-viewer-tools></top-viewer-tools>
101
+ <top-viewer-toolbar headless>
102
+ <top-viewer-gis-ghost-button></top-viewer-gis-ghost-button>
103
+ </top-viewer-toolbar>
104
+ </top-viewer>
105
+ `,
106
+ map: () => html`<top-map-panel></top-map-panel>`,
107
+ };
108
+
109
+ app.layouts = {
110
+ main: {
111
+ label: "Main",
112
+ icon: "mdi:map-search-outline",
113
+ template: `"gis viewer map" 1fr / 20rem 1fr 26rem`,
114
+ },
115
+ };
116
+
117
+ app.layout = "main";
118
+
119
+ const container = document.getElementById("that-open-app") ?? document.body;
120
+ container.appendChild(app);
121
+ document.body.style.margin = "0";
122
+
123
+ /* MD
124
+ ### 🤖 What the panel manages automatically
125
+
126
+ Once mounted, `map-panel` takes ownership of the following without any
127
+ further code from you:
128
+
129
+ **Capturing the map** — a "Capture map" button calls `minimap.captureMap()`.
130
+ It is disabled until the 3D tiles are loaded, with a hint saying so, and a
131
+ failed capture is reported as a toast. A map that has already been captured
132
+ stays on screen if the tiles are unloaded afterwards. The area is the box of
133
+ all the manifest's layers (a square around the origin when there are none),
134
+ and the image has a fixed resolution.
135
+
136
+ **Pan and zoom** — the wheel zooms around the cursor and dragging pans. The
137
+ zoom is limited by the image's resolution: the map never magnifies the image
138
+ beyond the point where it is just blur.
139
+
140
+ **Live vector layers** — the loaded layers are drawn as 2D vectors under the
141
+ captured image (so they stay crisp at any zoom), with the style of the
142
+ manifest. Where the terrain is opaque it hides them, just like in 3D.
143
+
144
+ **Hover and selection, in sync with 3D** — moving over a feature highlights it
145
+ on the map and in the 3D view, and the selected feature is highlighted in both.
146
+ Hovering and clicking features on the map only work while the terrain is
147
+ see-through, since you cannot pick what you cannot see. The
148
+ `<top-viewer-gis-ghost-button>` placed in the viewer toolbar above toggles it
149
+ (it is disabled until the tiles are loaded). An application without a toolbar
150
+ can call `GISManager.setTilesOpacity` with a value below 1 instead.
151
+
152
+ **Click to select and focus** — a click on a feature selects it, exactly like a
153
+ 3D click (`onFeatureSelected` fires), and the 3D camera flies to it while the
154
+ map zooms to the same neighbourhood. A click on empty ground moves the 3D
155
+ camera there, keeping its height and viewing direction, and leaves the
156
+ selection alone.
157
+
158
+ **Camera indicator** — an arrow and a view cone show where the 3D camera is and
159
+ where it looks, live.
160
+
161
+ **Markers** — the markers added to `GISManager.minimap` are drawn over the map
162
+ and follow its zoom and pan (see the next section). Markers without their own
163
+ element are drawn as a default badge showing the label.
164
+
165
+ **Disconnection cleanup** — when the panel is removed from the DOM it
166
+ unsubscribes from the manager and clears the hover it had set. The capture and
167
+ the markers live in `GISManager`, so a recreated panel (for example after the
168
+ user switches layout) shows them again straight away.
169
+ */
170
+
171
+ /* MD
172
+ ### 📍 Adding your own markers
173
+
174
+ The markers belong to `GISManager.minimap`, not to the panel. Add them with a
175
+ scene position (`vectorLayers.geoToWorld` converts a latitude and a longitude)
176
+ and, optionally, a `createElement` factory to draw them your way. Without the
177
+ factory the panel draws a badge with the label and reports the clicks through
178
+ `onMarkerClicked`.
179
+ */
180
+
181
+ const gis = components.get(GISManager);
182
+
183
+ gis.minimap.addMarker({
184
+ id: "site-office",
185
+ label: "Site office",
186
+ position: gis.vectorLayers.geoToWorld(gis.latitude, gis.longitude, gis.height),
187
+ });
188
+
189
+ gis.minimap.onMarkerClicked.add((id) => console.log("marker clicked:", id));
@@ -7,7 +7,7 @@
7
7
 
8
8
  A behaviour worth knowing: registering a loader does NOT control whether a file shows up in the tree — the tree always mirrors the project's real storage folders, so an unrecognized file is still listed as long as it's a source (IFC/LAS/PLY) or derivative (`.frag`/`.potree`/`.splat`) extension; the loaders registry only decides whether a file's row gets a load action or none.
9
9
 
10
- Known gaps (intentionally deferred, not forgotten): file upload and the base-model/auto-coordinate settings aren't ported, so `allow-upload` is currently a no-op. Everything else per-file is here, including editable attach/detach via a picker — MINUS delete, dropped on purpose. See `models-list/legacy-example.ts` + `models-list/impl.ts` for the retired implementation those gaps would be ported from.
10
+ Known gaps (intentionally deferred, not forgotten): the base-model/auto-coordinate settings aren't ported, and the panel has no default upload of its own: `allow-upload` is opt-in (off by default) and adds an "Upload file" item to the row menu of the root and folder rows, which hands each picked file to the uploader you registered for its extension (see the uploader section below) — files with no uploader aren't uploaded. Everything else per-file is here, including editable attach/detach via a picker — MINUS delete, dropped on purpose. See `models-list/legacy-example.ts` + `models-list/impl.ts` for the retired implementation those gaps would be ported from.
11
11
 
12
12
  By the end you'll have a files tree that knows how to load a custom format and how to run a custom IFC conversion, with the panel's UI untouched.
13
13
  */
@@ -57,7 +57,9 @@ const modelsList = document.createElement("top-models-list") as ModelsListV2;
57
57
  // ── Extension point 1: a custom loader, keyed by file extension ──────────────
58
58
  // A ModelLoader is `(fileId, ctx) => void | Promise<void>`. The panel calls it
59
59
  // when the user loads a file whose extension matches. `ctx` gives you everything
60
- // you need: { name, ext, components, client, getAlignment, setAlignment }. Do
60
+ // you need: { name, ext, components, client, getAlignment, setAlignment, progress }.
61
+ // For a long-running load call `ctx.progress(percent, label?)` to show it on the
62
+ // row (omit `percent` while unknown). Do
61
63
  // whatever it takes to put the file in the scene — fetch it via the client,
62
64
  // parse it, and add geometry to a world from `components`.
63
65
  modelsList.registerLoader("xyz", async (fileId, ctx) => {
@@ -87,6 +89,63 @@ modelsList.registerLoader("xyz", async (fileId, ctx) => {
87
89
  // },
88
90
  // });
89
91
 
92
+ // ── Upload: opt in, then register an uploader per file extension ─────────────
93
+ // `allow-upload` adds an "Upload file" item to the row menu of the root and
94
+ // folder rows. It opens a file picker and hands each picked file to the
95
+ // uploader registered for its extension, which fully owns that upload. Files
96
+ // whose extension has no uploader are not uploaded (the user gets a warning).
97
+ // `ctx` carries { name, ext, folderId, projectId, components, client };
98
+ // `folderId` is undefined when the upload was started from the root row. While
99
+ // the uploader runs, the panel shows a temporary row for the file; call
100
+ // `ctx.progress(percent, label?)` to drive its progress bar (omit `percent`
101
+ // while unknown for a spinner). The list refreshes once an uploader has run.
102
+ modelsList.setAttribute("allow-upload", "");
103
+ modelsList.registerUploader("ifc", async (file, ctx) => {
104
+ // Create the file in `ctx.folderId` (or the project root) with `ctx.client`,
105
+ // upload `file`'s bytes, and report success/failure however your app does.
106
+ ctx.progress(undefined, "Preparing…");
107
+ ctx.progress(50);
108
+ console.log(`[ifc uploader] would upload "${file.name}" to`, ctx.folderId ?? "the project root");
109
+ });
110
+
111
+ // Declarative form: modelsList.uploaders = { ifc: async (file, ctx) => { ... } };
112
+
113
+ // ── File actions: extra entries in a file row's three-dots menu ──────────────
114
+ // The menu already holds the panel's own items (fit camera, attach/detach); the
115
+ // actions you register for an extension are added after them. Pass a fixed list,
116
+ // or a resolver that runs every time the menu opens (it may be async — the menu
117
+ // shows a loading state meanwhile). While an action runs the file's row shows
118
+ // its status instead of its buttons: call `ctx.progress(percent, label?)` to
119
+ // drive it, and `ctx.reload()` if the action added/removed/renamed files.
120
+ modelsList.registerActions("ifc", [
121
+ {
122
+ label: "Re-index",
123
+ icon: "mdi:database-refresh-outline",
124
+ run: async (fileId, ctx) => {
125
+ ctx.progress(undefined, "Indexing…");
126
+ // await ctx.client.…
127
+ ctx.progress(100);
128
+ console.log(`[ifc action] re-indexed "${ctx.name}" (${fileId})`);
129
+ },
130
+ },
131
+ ]);
132
+ modelsList.registerActions("ply", async (fileId, ctx) => {
133
+ // Build the menu from state fetched when it opens, e.g. whether a derived
134
+ // file already exists for `fileId` (using `ctx.client`).
135
+ const alreadyConverted = false;
136
+ return [
137
+ {
138
+ label: "Regenerate 3D tiles",
139
+ icon: "mdi:cube-scan",
140
+ disabled: !alreadyConverted,
141
+ run: async (_id, actionCtx) => {
142
+ actionCtx.progress(25, "Converting…");
143
+ await actionCtx.reload();
144
+ },
145
+ },
146
+ ];
147
+ });
148
+
90
149
  // ── Extension point 2: override the IFC → fragments converter ────────────────
91
150
  // OMIT this to keep the built-in default (drives the project's IfcFragmenter
92
151
  // cloud component via the platform client). Provide it to run conversion
@@ -94,10 +94,12 @@ components.get(UIManager).init();
94
94
  ghost, show all, clear selection) that also registers the actual keyboard
95
95
  shortcuts while mounted.
96
96
 
97
- **Search box** — filters every section's own rows by label, stock or
98
- custom, as long as a section opts in (see below).
97
+ **Search box** — sits at the top of the panel, above the sections, and
98
+ filters every section's own rows by label, stock or custom, as long as a
99
+ section opts in (see below).
99
100
 
100
- **Restore defaults** — a two-click-confirm button that resets every
101
+ **Restore defaults** — a two-click-confirm button in the panel header that
102
+ resets every
101
103
  section with persisted state (currently: Graphics) back to its defaults.
102
104
 
103
105
  ### 🧩 Building your own section
@@ -149,28 +149,33 @@ document.body.style.margin = "0";
149
149
  gotcha below), then appended as the toolbar's light-DOM content:
150
150
 
151
151
  ```ts
152
+ import { html, render } from "lit";
153
+
152
154
  const headlessBar = document.createElement("top-viewer-toolbar");
153
155
  headlessBar.toggleAttribute("headless", true);
154
156
 
155
- const customButtons = document.createElement("bim-toolbar") as unknown as HTMLElement;
156
- customButtons.setAttribute("style", "pointer-events: auto;");
157
+ // A plain container — the `<bim-toolbar>` itself is rendered inside it.
158
+ const toolbarHost = document.createElement("div");
159
+ toolbarHost.style.pointerEvents = "auto";
157
160
  render(
158
161
  html`
159
- <bim-toolbar-section label-hidden style="background: transparent;">
160
- <top-viewer-mode-toggle-button></top-viewer-mode-toggle-button>
161
- <top-viewer-hide-button></top-viewer-hide-button>
162
- <top-viewer-select-button></top-viewer-select-button>
163
- </bim-toolbar-section>
164
- <bim-toolbar-section label-hidden style="background: transparent;">
165
- <bim-button
166
- icon="mdi:fit-to-page-outline"
167
- @click=${() => myOwnFitToViewLogic()}
168
- ><bim-tooltip placement="top">Zoom to fit</bim-tooltip></bim-button>
169
- </bim-toolbar-section>
162
+ <bim-toolbar>
163
+ <bim-toolbar-section label-hidden style="background: transparent;">
164
+ <top-viewer-mode-toggle-button></top-viewer-mode-toggle-button>
165
+ <top-viewer-hide-button></top-viewer-hide-button>
166
+ <top-viewer-select-button></top-viewer-select-button>
167
+ </bim-toolbar-section>
168
+ <bim-toolbar-section label-hidden style="background: transparent;">
169
+ <bim-button
170
+ icon="mdi:fit-to-page-outline"
171
+ @click=${() => myOwnFitToViewLogic()}
172
+ ><bim-tooltip placement="top">Zoom to fit</bim-tooltip></bim-button>
173
+ </bim-toolbar-section>
174
+ </bim-toolbar>
170
175
  `,
171
- customButtons,
176
+ toolbarHost,
172
177
  );
173
- headlessBar.appendChild(customButtons);
178
+ headlessBar.appendChild(toolbarHost);
174
179
  // headlessBar can now be appended anywhere inside <top-viewer>.
175
180
  ```
176
181
 
@@ -208,7 +213,7 @@ document.body.style.margin = "0";
208
213
  Custom Elements v1 forbids for elements created imperatively —
209
214
  `document.createElement("bim-button")` throws `NotSupportedError`.
210
215
  Render them with lit's `render(html\`...\`, container)` instead, exactly
211
- like the `customButtons` bar above — never build them by hand.
216
+ like the `toolbarHost` container above — never build them by hand.
212
217
  - **`bim-button` sizes itself** — square when it's icon-only, a consistent
213
218
  height when it has a label. Don't set `width`/`height`/`min-width`
214
219
  inline on it; that only fights its own layout and produces buttons that
@@ -225,7 +230,13 @@ document.body.style.margin = "0";
225
230
  ### 🧩 Standalone buttons reference
226
231
 
227
232
  Every button the default bar uses is independently importable/usable as
228
- its own custom element:
233
+ its own custom element. They render icon-only with a tooltip; set the
234
+ optional `label` attribute on any of them to show text next to the icon
235
+ (the tooltip stays as it is):
236
+
237
+ ```html
238
+ <top-viewer-hide-button label="Hide"></top-viewer-hide-button>
239
+ ```
229
240
 
230
241
  | Element | Does |
231
242
  |---|---|
@@ -234,19 +245,132 @@ document.body.style.margin = "0";
234
245
  | `<top-viewer-show-button>` | Un-hides the target set |
235
246
  | `<top-viewer-isolate-button>` | Shows only the target set |
236
247
  | `<top-viewer-ghost-button>` | Renders the target set semi-transparent |
248
+ | `<top-viewer-gis-ghost-button>` | Makes the GIS map (the 3D tiles of `GISManager`) see-through ⇄ opaque; disabled until the tiles are loaded; the optional `opacity` attribute (0 to 1, default `0.3`) sets the see-through level |
237
249
  | `<top-viewer-focus-button>` | Frames the camera on the target set |
238
- | `<top-viewer-reset-button>` | Restores full visibility + opacity (global) |
250
+ | `<top-viewer-reset-button>` | Shows everything and clears ghosting (global); the optional `reset` attribute can also remove sections and measurements (see below) |
239
251
  | `<top-viewer-select-button>` | Activates the default Select tool |
240
- | `<top-viewer-clip-button>` | Activates the Clip tool |
252
+ | `<top-viewer-clip-button>` | Clipping planes: face-placing mode by default, or axis planes / a mode menu via `modes` (see below) |
241
253
  | `<top-viewer-measure-length-button>` | Activates length measurement |
242
254
  | `<top-viewer-measure-area-button>` | Activates area measurement |
243
255
  | `<top-viewer-measure-angle-button>` | Activates angle measurement |
244
256
  | `<top-viewer-measure-edge-button>` | Activates edge measurement |
245
257
  | `<top-viewer-measure-face-button>` | Activates face measurement |
246
258
  | `<top-viewer-measure-volume-button>` | Activates volume measurement |
259
+ | `<top-viewer-measure-button>` | All measurement modes in one button (a menu by default), or a chosen subset via `modes` (see below) |
247
260
  | `<top-viewer-projection-toggle-button>` | Toggles Perspective ⇄ Orthographic |
248
261
  | `<top-viewer-walkthrough-button>` | Toggles first-person walkthrough |
262
+ | `<top-viewer-navigation-button>` | Switches between orbit and first-person navigation (a menu by default), or a single chosen mode via `modes` (see below) |
249
263
 
250
264
  Each one self-wires from context — drop it anywhere under `<top-viewer>`
251
265
  and it works, with no props to pass in.
266
+
267
+ ### ✂️ Clip button modes
268
+
269
+ Without any attribute, `<top-viewer-clip-button>` enters face-placing mode:
270
+ double-click a face in the model and a clip plane is dropped on it. The
271
+ optional `modes` attribute changes what the button offers — a whitespace-
272
+ (or comma-) separated list of `x`, `y`, `z` and `face` (case-insensitive;
273
+ unknown tokens are ignored, duplicates dropped, the order you write is the
274
+ order shown):
275
+
276
+ ```html
277
+ <!-- a menu with four entries: X axis, Y axis, Z axis, Face -->
278
+ <top-viewer-clip-button modes="x y z face"></top-viewer-clip-button>
279
+
280
+ <!-- one mode = no menu; a click creates a horizontal cut right away -->
281
+ <top-viewer-clip-button modes="y"></top-viewer-clip-button>
282
+
283
+ <!-- default: face-placing mode -->
284
+ <top-viewer-clip-button></top-viewer-clip-button>
285
+ ```
286
+
287
+ - **One mode** — the button runs it directly on click. `face` is the
288
+ default face-placing behavior.
289
+ - **Two or more** — clicking opens a menu with one entry per mode ("X
290
+ axis", "Y axis", "Z axis", "Face"); picking one runs it and closes the
291
+ menu.
292
+ - **Axis modes** create a single plane through the center of the bounding
293
+ box of all loaded models (nothing happens if no model is loaded). Axes
294
+ follow the three.js convention — **Y is vertical**, so `y` is a
295
+ horizontal cut. The plane's normal is the *negative* axis — `(-1, 0, 0)`,
296
+ `(0, -1, 0)`, `(0, 0, -1)` — so the plane clips what lies on the
297
+ positive side of the axis (`y` removes everything above the middle of
298
+ the model). Axis planes behave exactly like face-placed ones: same
299
+ section styling, listed in the objects outliner, draggable by their
300
+ gizmo, removable with Delete.
301
+ - The button shows as active only while face-placing mode is on.
302
+
303
+ ### 📏 Measure button modes
304
+
305
+ `<top-viewer-measure-button>` puts the measurement tools behind a single
306
+ button. Without any attribute it offers all six modes — `length`, `area`,
307
+ `angle`, `edge`, `face`, `volume` — in a menu. The optional `modes`
308
+ attribute picks a subset (whitespace- or comma-separated, case-insensitive;
309
+ unknown tokens are ignored, duplicates dropped, your order is kept):
310
+
311
+ ```html
312
+ <!-- default: a menu with all six modes -->
313
+ <top-viewer-measure-button></top-viewer-measure-button>
314
+
315
+ <!-- a menu with just these three, in this order -->
316
+ <top-viewer-measure-button modes="length area volume"></top-viewer-measure-button>
317
+
318
+ <!-- one mode = no menu; a click starts that measurement right away -->
319
+ <top-viewer-measure-button modes="length"></top-viewer-measure-button>
320
+ ```
321
+
322
+ - **One mode** — a click activates it directly, exactly like the matching
323
+ standalone button (`length` and `area` measure freely; `edge` and `face`
324
+ snap to edges and faces).
325
+ - **Two or more** — a click opens a menu with one text-labelled entry per
326
+ mode ("Length", "Area", "Angle", "Edge", "Face", "Volume"); picking one
327
+ activates it and closes the menu.
328
+ - The button shows as active while any of its modes is the active
329
+ measurement mode, and its icon switches to that mode's icon (a ruler
330
+ otherwise). In the menu, the active mode's entry is marked.
331
+
332
+ ### 🧭 Navigation button modes
333
+
334
+ `<top-viewer-navigation-button>` switches how the camera moves. Without any
335
+ attribute it offers both modes — `orbit` and `first-person` — in a menu.
336
+ The optional `modes` attribute narrows or reorders them (whitespace- or
337
+ comma-separated, case-insensitive; unknown tokens are ignored, duplicates
338
+ dropped, your order is kept):
339
+
340
+ ```html
341
+ <!-- default: a menu with "Orbit" and "First person" -->
342
+ <top-viewer-navigation-button></top-viewer-navigation-button>
343
+
344
+ <!-- one mode = no menu; a click switches straight to it -->
345
+ <top-viewer-navigation-button modes="orbit"></top-viewer-navigation-button>
346
+ ```
347
+
348
+ - `orbit` turns first-person navigation off (nothing happens if it already
349
+ is); `first-person` turns it on (nothing happens if it already is) — the
350
+ same first-person navigation `<top-viewer-walkthrough-button>` toggles.
351
+ - The button's icon always shows the current mode, and the button is active
352
+ while first-person is on. In the menu, the current mode's entry is marked.
353
+ ### ♻️ Reset button options
354
+
355
+ By default `<top-viewer-reset-button>` restores full visibility and clears
356
+ ghosting. The optional `reset` attribute lists what it resets — any of
357
+ `visibility`, `sections` (every clipping plane and its section styling)
358
+ and `measurements` (every measurement), whitespace- or comma-separated,
359
+ case-insensitive, unknown tokens ignored:
360
+
361
+ ```html
362
+ <!-- default: visibility only -->
363
+ <top-viewer-reset-button></top-viewer-reset-button>
364
+
365
+ <!-- reset everything -->
366
+ <top-viewer-reset-button reset="visibility sections measurements"></top-viewer-reset-button>
367
+ ```
368
+
369
+ - Parts always run in the order visibility, sections, measurements, and one
370
+ failing part doesn't stop the others.
371
+ - When `sections` or `measurements` is listed, the viewer returns to the
372
+ default Select tool afterwards, so nobody is left inside a half-finished
373
+ measure or clip mode.
374
+ - The tooltip describes what the button resets, and first-person navigation
375
+ is left as it is.
252
376
  */