@ikaros-arch/react-3dhop-iiif 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -1,381 +1,381 @@
1
- # @ikaros-arch/react-3dhop-iiif
2
-
3
- Renders [IIIF Presentation 4.0 / IIIF 3D](https://github.com/IIIF/3d/blob/main/temp-draft-4.md)
4
- manifests with [3DHOP](https://3dhop.net/), on top of
5
- [`@ikaros-arch/react-3dhop`](https://github.com/ikaros-arch/react-3dhop/tree/main/packages/react-3dhop#readme).
6
-
7
- The IIIF 3D specification is still a draft. Keeping it in its own package means the core viewer
8
- does not have to move every time the draft does.
9
-
10
- ```bash
11
- npm install @ikaros-arch/react-3dhop-iiif @ikaros-arch/react-3dhop @ikaros-arch/3dhop
12
- ```
13
-
14
- `react`, `react-dom` and `@ikaros-arch/react-3dhop` are peer dependencies (and `@ikaros-arch/3dhop`
15
- is a peer of that, holding the 3DHOP runtime assets). The package itself has **no runtime
16
- dependencies**.
17
-
18
- This README is the reference for manifest authoring and specification coverage.
19
- [docs/iiif.md](https://github.com/ikaros-arch/react-3dhop/blob/main/docs/iiif.md) explains how the
20
- package is put together and where to extend it.
21
-
22
- ---
23
-
24
- ## Quick start
25
-
26
- ```tsx
27
- import { IIIFViewer, IIIFMetadataPanel, IIIFSavedViewsPanel } from '@ikaros-arch/react-3dhop-iiif';
28
-
29
- export function Viewer() {
30
- return (
31
- <IIIFViewer
32
- manifest="https://example.org/iiif/object/manifest.json"
33
- assetBaseUrl="/3dhop"
34
- width={800}
35
- height={600}
36
- >
37
- <IIIFMetadataPanel />
38
- <IIIFSavedViewsPanel />
39
- </IIIFViewer>
40
- );
41
- }
42
- ```
43
-
44
- `<IIIFViewer>` accepts everything `<ThreeDHopViewer>` does, except `models` and `modelUrl`, which
45
- it derives from the manifest. `manifest` takes either a URL to fetch or an already-parsed manifest
46
- object.
47
-
48
- Panels must be rendered **inside** `<IIIFViewer>` to reach its context. The viewer renders its
49
- children into a fixed-size, clipped box intended for canvas overlays, so to place panels elsewhere
50
- on the page, portal them out — React context passes through portals. See
51
- [`examples/react-3dhop-demo/src/IIIFDemo.tsx`](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/src/IIIFDemo.tsx).
52
-
53
- ### Reading the manifest yourself
54
-
55
- ```tsx
56
- const { metadata, models, cameras, language, setLanguage, goToCamera, saveCurrentView } =
57
- useIIIFManifest();
58
- ```
59
-
60
- Every parsing and geometry function is also exported standalone, with no React and no dependency on
61
- 3DHOP's globals — usable in Node, and unit-tested there:
62
-
63
- ```ts
64
- import { loadManifest, sceneFromManifest, buildModelMatrix, view2track } from '@ikaros-arch/react-3dhop-iiif';
65
-
66
- const parsed = await loadManifest(url);
67
- const scene = sceneFromManifest(parsed, { displayUnit: 'cm' });
68
- ```
69
-
70
- ---
71
-
72
- ## Manifest authoring
73
-
74
- ### Model formats
75
-
76
- 3DHOP streams [Nexus](https://vcg.isti.cnr.it/nexus/) (`.nxz`/`.nxs`) and loads `.ply`. When one
77
- annotation offers several sources, the best supported one wins:
78
-
79
- | Rank | `format` | Support |
80
- |---|---|---|
81
- | 1 | `nexus`, `nxz`, `nxs`, `application/octet-stream+nexus` | Streamed progressively — the format to publish |
82
- | 2 | `ply`, `application/ply` | Loaded whole |
83
- | 3 | `obj`, `model/obj` | **Cannot be rendered** — ranked here only so it is preferred over glTF |
84
- | 4 | `glb`, `gltf`, `model/gltf-binary`, `model/gltf+json` | **Cannot be rendered** |
85
-
86
- Comparison is case-insensitive, so `"format": "Nexus"` works. An unrecognised format ranks below
87
- every known one rather than being discarded.
88
-
89
- 3DHOP has loaders for Nexus and PLY only. A model in any other format is still parsed, so the
90
- manifest continues to describe the object, and a diagnostic says why nothing appeared. Adding an
91
- OBJ or glTF loader is out of scope; convert to Nexus with
92
- [`nxsbuild`](https://vcg.isti.cnr.it/nexus/) instead.
93
-
94
- ### Units
95
-
96
- Two non-standard metadata fields control scale, recognised by their English or Norwegian labels:
97
-
98
- | Label | Meaning | Default |
99
- |---|---|---|
100
- | `Measure Unit` / `Måleenhet` | The unit the manifest's own coordinates are authored in | `mm` |
101
- | `Display Unit` / `Visningsenhet` | The unit the scene is rendered and measured in | the measure unit |
102
-
103
- ```json
104
- { "label": { "en": ["Measure Unit"] }, "value": { "en": ["m"] } }
105
- ```
106
-
107
- A single model whose mesh is authored in a different unit from the rest of the manifest can say so
108
- on its source, without disturbing anything else:
109
-
110
- ```json
111
- { "id": "…/alvim.nxz", "type": "Model", "format": "Nexus", "measureUnit": "mm" }
112
- ```
113
-
114
- Recognised units are `km`, `m`, `cm`, `mm`, `um` (or `µm`) and `nm`. An unrecognised unit is
115
- treated as a factor of 1, so a typo renders the object unscaled rather than failing.
116
-
117
- The `Display Unit` also sets the label on measurements taken with the measuring tool, and picks the
118
- clipping-border width, which otherwise looks invisible on a millimetre-scale object and enormous on
119
- a metre-scale one.
120
-
121
- ### Placement and transforms
122
-
123
- A model's position comes from the `PointSelector` on the annotation's target:
124
-
125
- ```json
126
- "target": {
127
- "type": "SpecificResource",
128
- "source": [{ "id": "…/scene/1", "type": "Scene" }],
129
- "selector": [{ "type": "PointSelector", "x": 2.0, "y": 0.0, "z": 0.0 }]
130
- }
131
- ```
132
-
133
- `transform` is an **ordered list**, applied first entry first, and this package composes it in
134
- order rather than flattening it. Order is significant: scaling then translating moves the model by
135
- the unscaled offset, while translating then scaling moves it by the scaled one. The same holds for
136
- rotation.
137
-
138
- ```json
139
- "transform": [
140
- { "type": "ScaleTransform", "x": 1.5, "y": 1.5, "z": 1.5 },
141
- { "type": "RotateTransform", "x": -90.0, "y": -90.0, "z": 0.0 },
142
- { "type": "TranslateTransform", "x": 0.0, "y": 0.5, "z": 0.0 }
143
- ]
144
- ```
145
-
146
- `RotateTransform` accepts either per-axis Euler angles in degrees (`x`/`y`/`z`, composed Z then Y
147
- then X, matching 3DHOP's own rotation triple) or a single axis-angle (`{ "axis": "y", "angle": 45 }`).
148
-
149
- The complete model matrix is:
150
-
151
- ```text
152
- M = S(displayUnit ← measureUnit) · T(pointSelector) · L(transform list) · S(geometry unit override)
153
- ```
154
-
155
- Read right to left: bring the mesh's vertices into the manifest's unit, apply the transform list,
156
- place the result at the point selector, then convert the whole scene into the display unit. Keeping
157
- the unit conversions outermost and innermost is what makes positions and translations scale
158
- alongside the geometry.
159
-
160
- Models sharing a source URL are collapsed onto one mesh, so placing the same object several times
161
- downloads it once.
162
-
163
- ### Cameras
164
-
165
- Camera annotations become buttons in `<IIIFSavedViewsPanel>`, and the first one is applied as the
166
- opening view unless `applyInitialCamera={false}`:
167
-
168
- ```json
169
- {
170
- "type": "Annotation",
171
- "body": {
172
- "type": "PerspectiveCamera",
173
- "label": { "en": ["Front"] },
174
- "fieldOfView": 50.0,
175
- "lookAt": { "type": "PointSelector", "x": 0, "y": 0.5, "z": 0 }
176
- },
177
- "target": {
178
- "type": "SpecificResource",
179
- "source": [{ "id": "…/scene/1", "type": "Scene" }],
180
- "selector": [{ "type": "PointSelector", "x": 0.0, "y": 3.0, "z": -8.0 }]
181
- }
182
- }
183
- ```
184
-
185
- The target's `PointSelector` is the camera position; `lookAt` is what it points at. `lookAt` may
186
- instead reference another annotation by `id`, in which case the camera aims at that model's
187
- position. Both `fieldOfView` and the shorthand `fov` are read. With no `lookAt`, the camera aims at
188
- the scene centre.
189
-
190
- `saveCurrentView()` returns an annotation in exactly this shape, ready to paste back into a
191
- manifest.
192
-
193
- ### Language
194
-
195
- Any IIIF language map is resolved against the active language, falling back through `en`, `no`,
196
- `nb`, `nn` and then any remaining language, so a partially translated manifest still renders. The
197
- language defaults to the browser's and can be overridden with the `language` prop or switched at
198
- runtime through `<IIIFLanguageSwitcher>`, which hides itself when there is only one language to
199
- choose from.
200
-
201
- Metadata fields are recognised by their **English** label even when another language is displayed,
202
- so switching language does not lose the unit or inventory fields.
203
-
204
- ---
205
-
206
- ## Panels
207
-
208
- All read from `useIIIFManifest()` unless noted otherwise, use semantic markup with no CSS
209
- framework, and accept `className` props on every element. There is no bundled stylesheet; the
210
- demo's [`IIIFDemo.css`](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/src/IIIFDemo.css) is a starting point.
211
-
212
- | Component | Shows |
213
- |---|---|
214
- | `<IIIFSummary>` | `summary` and the `requiredStatement` attribution |
215
- | `<IIIFMetadataPanel>` | Recognised fields first, then the rest in manifest order |
216
- | `<IIIFModelsPanel>` | Per-model visibility and transparency toggles |
217
- | `<IIIFSavedViewsPanel>` | The manifest's cameras, plus "save current view" |
218
- | `<IIIFLanguageSwitcher>` | Language selector; hidden when the manifest has one language |
219
- | `<IIIFCollectionPicker>` | A `<select>` of every manifest in a collection |
220
- | `<IIIFCollectionCarousel>` | A thumbnail strip of every manifest in a collection, with prev/next |
221
- | `<IIIFMultiManifestModelsPanel>` | Visibility/transparency toggles grouped by manifest; reads `useIIIFMultiManifest()`, for use inside `<IIIFMultiManifestViewer>` instead of `<IIIFViewer>` |
222
-
223
- ---
224
-
225
- ## Collections
226
-
227
- A IIIF Collection lists several manifests — e.g. every object in a museum sub-catalogue — so a
228
- picker or carousel can switch between them. `<IIIFCollectionProvider>` fetches and parses one, and
229
- tracks which manifest is selected; it renders nothing itself, so it composes with `<IIIFViewer>`
230
- rather than replacing it:
231
-
232
- ```tsx
233
- import {
234
- IIIFCollectionProvider,
235
- IIIFCollectionPicker,
236
- IIIFCollectionCarousel,
237
- IIIFViewer,
238
- useIIIFCollection
239
- } from '@ikaros-arch/react-3dhop-iiif';
240
-
241
- function CollectionBrowser() {
242
- return (
243
- <IIIFCollectionProvider collection="/manifests/bitfrost/collection.json">
244
- <IIIFCollectionPicker />
245
- <IIIFCollectionCarousel />
246
- <SelectedManifestViewer />
247
- </IIIFCollectionProvider>
248
- );
249
- }
250
-
251
- function SelectedManifestViewer() {
252
- const { selectedId } = useIIIFCollection();
253
- if (!selectedId) return null;
254
- // Remounting on selection change avoids carrying one object's camera into the next.
255
- return <IIIFViewer key={selectedId} manifest={selectedId} assetBaseUrl="/3dhop" width={760} height={620} />;
256
- }
257
- ```
258
-
259
- `collection` accepts a URL or an already-fetched object, mirroring `<IIIFViewer manifest>`.
260
- `useIIIFCollection()` also exposes `items` (id, label, thumbnail), `status`/`error`, and
261
- `next()`/`previous()` for building a custom control. See
262
- [`examples/react-3dhop-demo/src/IIIFDemo.tsx`](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/src/IIIFDemo.tsx)
263
- for a full example, including the [generator
264
- script](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/scripts/generate-bitfrost-collection.mjs) that builds its
265
- sample collection from a museum catalogue export.
266
-
267
- ---
268
-
269
- ## Multiple manifests in one viewer
270
-
271
- `<IIIFMultiManifestViewer>` renders several manifests together in a single `<ThreeDHopViewer>`
272
- instance, laid out side by side rather than switched between one at a time:
273
-
274
- ```tsx
275
- import { IIIFMultiManifestViewer, IIIFMultiManifestModelsPanel } from '@ikaros-arch/react-3dhop-iiif';
276
-
277
- function Comparison() {
278
- return (
279
- <IIIFMultiManifestViewer
280
- manifests={['/manifests/a.json', '/manifests/b.json']}
281
- assetBaseUrl="/3dhop"
282
- width={760}
283
- height={620}
284
- >
285
- <IIIFMultiManifestModelsPanel />
286
- </IIIFMultiManifestViewer>
287
- );
288
- }
289
- ```
290
-
291
- It is a parallel component, not an extension of `<IIIFViewer>`: it fetches and parses each manifest
292
- independently, and fails fast if any one of them can't be loaded, since a partially-failed shared
293
- scene isn't a state worth trying to render. Its context, `useIIIFMultiManifest()`, only covers
294
- per-model visibility and transparency, scoped by manifest — cameras, saved views, metadata and
295
- language are per-manifest concepts without a defined multi-manifest behaviour yet, so `<IIIFViewer>`
296
- remains the way to reach those.
297
-
298
- There is no mesh geometry available at layout time — meshes stream in later, and the manifest only
299
- says where each annotation is *placed*, not how large it is — so the side-by-side layout is a
300
- heuristic: each manifest's own placement spread (the spread between its own annotations), floored by
301
- `minRadius` and separated by `gap`. A manifest with a single model at the origin (the common case)
302
- has a spread of zero and falls back entirely to `minRadius`. Tune both options for your data:
303
-
304
- ```tsx
305
- <IIIFMultiManifestViewer manifests={ids} minRadius={150} gap={80} columns={3} />
306
- ```
307
-
308
- `useIIIFMultiManifest()` addresses models with a `(manifestKey, modelId)` pair rather than the bare
309
- IIIF annotation id `useIIIFManifest()` uses, because two manifests can otherwise declare colliding
310
- ids:
311
-
312
- ```tsx
313
- const { manifests, isModelVisible, setModelVisible } = useIIIFMultiManifest();
314
- // manifests: [{ key, sourceId, label, models }, …]
315
- ```
316
-
317
- See [`examples/react-3dhop-demo/src/IIIFDemo.tsx`](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/src/IIIFDemo.tsx)
318
- (`"Multi-manifest viewer"` mode) for a full example that lets the user check off any number of
319
- objects from a collection.
320
-
321
- ---
322
-
323
- ## Error handling
324
-
325
- A manifest that cannot be fetched or parsed puts the viewer into an error state and renders
326
- `errorFallback` — it does not throw or leave a blank canvas.
327
-
328
- Problems affecting only part of a manifest are reported as **diagnostics** and the rest still
329
- renders: an annotation with no usable source, a glTF-only model, an unrecognised body type, a
330
- manifest with no `Scene`, or one with several. Diagnostics reach you through the `onDiagnostic`
331
- prop or the `diagnostics` array on the context.
332
-
333
- ---
334
-
335
- ## Supported IIIF 3D subset
336
-
337
- Against the [draft specification](https://github.com/IIIF/3d/blob/main/temp-draft-4.md). ✅ full,
338
- ⚠️ partial, ❌ not implemented.
339
-
340
- | Feature | Parsed | Rendered | Notes |
341
- |---|---|---|---|
342
- | **Containers** |
343
- | `Scene` | ✅ | ✅ | |
344
- | Multiple scenes | ✅ | ⚠️ | Merged into one view, with a diagnostic |
345
- | Canvas in Scene | ❌ | ❌ | |
346
- | Nested scenes | ❌ | ❌ | |
347
- | **Resources** |
348
- | `Model` as body | ✅ | ✅ | |
349
- | `Model` via `SpecificResource` | ✅ | ✅ | Required for transforms |
350
- | Several sources per annotation | ✅ | ✅ | Best supported format wins |
351
- | `PerspectiveCamera` | ✅ | ✅ | Position, `lookAt`, `fieldOfView` |
352
- | `OrthographicCamera` | ✅ | ✅ | Position, `lookAt` |
353
- | **Lights** (`Ambient`/`Directional`/`Point`/`Spot`) | ❌ | ❌ | 3DHOP has one directional light, controlled by the viewer |
354
- | **Transforms** |
355
- | `ScaleTransform` | ✅ | ✅ | |
356
- | `TranslateTransform` | ✅ | ✅ | |
357
- | `RotateTransform` | ✅ | ✅ | Euler and axis-angle forms |
358
- | Ordered composition | ✅ | ✅ | Composed as a matrix, not flattened |
359
- | **Selectors** |
360
- | `PointSelector` | ✅ | ✅ | Positions models and cameras |
361
- | `PolygonZSelector` | ❌ | ❌ | For Canvas placement |
362
- | **Properties** |
363
- | `label`, `summary`, `metadata` | ✅ | ✅ | Multilingual |
364
- | `requiredStatement` | ✅ | ✅ | Shown as attribution |
365
- | `fieldOfView` / `fov` | ✅ | ✅ | |
366
- | `lookAt` | ✅ | ✅ | Point or annotation reference |
367
- | `backgroundColor` | ❌ | ❌ | Use the viewer's `backgroundUrl` |
368
- | `duration` | ❌ | ❌ | Temporal scenes |
369
- | `near` / `far` | ❌ | ❌ | Derived from scene extents instead |
370
- | `exclude` | ❌ | ❌ | |
371
- | **Formats** |
372
- | Nexus (`.nxz`/`.nxs`) | ✅ | ✅ | Streamed progressively |
373
- | PLY | ✅ | ✅ | |
374
- | OBJ | ✅ | ❌ | Reported as a diagnostic; convert to Nexus |
375
- | glTF / GLB | ✅ | ❌ | Reported as a diagnostic; convert to Nexus |
376
-
377
- ---
378
-
379
- ## Licence
380
-
381
- GPL-3.0-or-later, matching 3DHOP.
1
+ # @ikaros-arch/react-3dhop-iiif
2
+
3
+ Renders [IIIF Presentation 4.0 / IIIF 3D](https://github.com/IIIF/3d/blob/main/temp-draft-4.md)
4
+ manifests with [3DHOP](https://3dhop.net/), on top of
5
+ [`@ikaros-arch/react-3dhop`](https://github.com/ikaros-arch/react-3dhop/tree/main/packages/react-3dhop#readme).
6
+
7
+ The IIIF 3D specification is still a draft. Keeping it in its own package means the core viewer
8
+ does not have to move every time the draft does.
9
+
10
+ ```bash
11
+ npm install @ikaros-arch/react-3dhop-iiif @ikaros-arch/react-3dhop @ikaros-arch/3dhop
12
+ ```
13
+
14
+ `react`, `react-dom` and `@ikaros-arch/react-3dhop` are peer dependencies (and `@ikaros-arch/3dhop`
15
+ is a peer of that, holding the 3DHOP runtime assets). The package itself has **no runtime
16
+ dependencies**.
17
+
18
+ This README is the reference for manifest authoring and specification coverage.
19
+ [docs/iiif.md](https://github.com/ikaros-arch/react-3dhop/blob/main/docs/iiif.md) explains how the
20
+ package is put together and where to extend it.
21
+
22
+ ---
23
+
24
+ ## Quick start
25
+
26
+ ```tsx
27
+ import { IIIFViewer, IIIFMetadataPanel, IIIFSavedViewsPanel } from '@ikaros-arch/react-3dhop-iiif';
28
+
29
+ export function Viewer() {
30
+ return (
31
+ <IIIFViewer
32
+ manifest="https://example.org/iiif/object/manifest.json"
33
+ assetBaseUrl="/3dhop"
34
+ width={800}
35
+ height={600}
36
+ >
37
+ <IIIFMetadataPanel />
38
+ <IIIFSavedViewsPanel />
39
+ </IIIFViewer>
40
+ );
41
+ }
42
+ ```
43
+
44
+ `<IIIFViewer>` accepts everything `<ThreeDHopViewer>` does, except `models` and `modelUrl`, which
45
+ it derives from the manifest. `manifest` takes either a URL to fetch or an already-parsed manifest
46
+ object.
47
+
48
+ Panels must be rendered **inside** `<IIIFViewer>` to reach its context. The viewer renders its
49
+ children into a fixed-size, clipped box intended for canvas overlays, so to place panels elsewhere
50
+ on the page, portal them out — React context passes through portals. See
51
+ [`examples/react-3dhop-demo/src/IIIFDemo.tsx`](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/src/IIIFDemo.tsx).
52
+
53
+ ### Reading the manifest yourself
54
+
55
+ ```tsx
56
+ const { metadata, models, cameras, language, setLanguage, goToCamera, saveCurrentView } =
57
+ useIIIFManifest();
58
+ ```
59
+
60
+ Every parsing and geometry function is also exported standalone, with no React and no dependency on
61
+ 3DHOP's globals — usable in Node, and unit-tested there:
62
+
63
+ ```ts
64
+ import { loadManifest, sceneFromManifest, buildModelMatrix, view2track } from '@ikaros-arch/react-3dhop-iiif';
65
+
66
+ const parsed = await loadManifest(url);
67
+ const scene = sceneFromManifest(parsed, { displayUnit: 'cm' });
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Manifest authoring
73
+
74
+ ### Model formats
75
+
76
+ 3DHOP streams [Nexus](https://vcg.isti.cnr.it/nexus/) (`.nxz`/`.nxs`) and loads `.ply`. When one
77
+ annotation offers several sources, the best supported one wins:
78
+
79
+ | Rank | `format` | Support |
80
+ |---|---|---|
81
+ | 1 | `nexus`, `nxz`, `nxs`, `application/octet-stream+nexus` | Streamed progressively — the format to publish |
82
+ | 2 | `ply`, `application/ply` | Loaded whole |
83
+ | 3 | `obj`, `model/obj` | **Cannot be rendered** — ranked here only so it is preferred over glTF |
84
+ | 4 | `glb`, `gltf`, `model/gltf-binary`, `model/gltf+json` | **Cannot be rendered** |
85
+
86
+ Comparison is case-insensitive, so `"format": "Nexus"` works. An unrecognised format ranks below
87
+ every known one rather than being discarded.
88
+
89
+ 3DHOP has loaders for Nexus and PLY only. A model in any other format is still parsed, so the
90
+ manifest continues to describe the object, and a diagnostic says why nothing appeared. Adding an
91
+ OBJ or glTF loader is out of scope; convert to Nexus with
92
+ [`nxsbuild`](https://vcg.isti.cnr.it/nexus/) instead.
93
+
94
+ ### Units
95
+
96
+ Two non-standard metadata fields control scale, recognised by their English or Norwegian labels:
97
+
98
+ | Label | Meaning | Default |
99
+ |---|---|---|
100
+ | `Measure Unit` / `Måleenhet` | The unit the manifest's own coordinates are authored in | `mm` |
101
+ | `Display Unit` / `Visningsenhet` | The unit the scene is rendered and measured in | the measure unit |
102
+
103
+ ```json
104
+ { "label": { "en": ["Measure Unit"] }, "value": { "en": ["m"] } }
105
+ ```
106
+
107
+ A single model whose mesh is authored in a different unit from the rest of the manifest can say so
108
+ on its source, without disturbing anything else:
109
+
110
+ ```json
111
+ { "id": "…/alvim.nxz", "type": "Model", "format": "Nexus", "measureUnit": "mm" }
112
+ ```
113
+
114
+ Recognised units are `km`, `m`, `cm`, `mm`, `um` (or `µm`) and `nm`. An unrecognised unit is
115
+ treated as a factor of 1, so a typo renders the object unscaled rather than failing.
116
+
117
+ The `Display Unit` also sets the label on measurements taken with the measuring tool, and picks the
118
+ clipping-border width, which otherwise looks invisible on a millimetre-scale object and enormous on
119
+ a metre-scale one.
120
+
121
+ ### Placement and transforms
122
+
123
+ A model's position comes from the `PointSelector` on the annotation's target:
124
+
125
+ ```json
126
+ "target": {
127
+ "type": "SpecificResource",
128
+ "source": [{ "id": "…/scene/1", "type": "Scene" }],
129
+ "selector": [{ "type": "PointSelector", "x": 2.0, "y": 0.0, "z": 0.0 }]
130
+ }
131
+ ```
132
+
133
+ `transform` is an **ordered list**, applied first entry first, and this package composes it in
134
+ order rather than flattening it. Order is significant: scaling then translating moves the model by
135
+ the unscaled offset, while translating then scaling moves it by the scaled one. The same holds for
136
+ rotation.
137
+
138
+ ```json
139
+ "transform": [
140
+ { "type": "ScaleTransform", "x": 1.5, "y": 1.5, "z": 1.5 },
141
+ { "type": "RotateTransform", "x": -90.0, "y": -90.0, "z": 0.0 },
142
+ { "type": "TranslateTransform", "x": 0.0, "y": 0.5, "z": 0.0 }
143
+ ]
144
+ ```
145
+
146
+ `RotateTransform` accepts either per-axis Euler angles in degrees (`x`/`y`/`z`, composed Z then Y
147
+ then X, matching 3DHOP's own rotation triple) or a single axis-angle (`{ "axis": "y", "angle": 45 }`).
148
+
149
+ The complete model matrix is:
150
+
151
+ ```text
152
+ M = S(displayUnit ← measureUnit) · T(pointSelector) · L(transform list) · S(geometry unit override)
153
+ ```
154
+
155
+ Read right to left: bring the mesh's vertices into the manifest's unit, apply the transform list,
156
+ place the result at the point selector, then convert the whole scene into the display unit. Keeping
157
+ the unit conversions outermost and innermost is what makes positions and translations scale
158
+ alongside the geometry.
159
+
160
+ Models sharing a source URL are collapsed onto one mesh, so placing the same object several times
161
+ downloads it once.
162
+
163
+ ### Cameras
164
+
165
+ Camera annotations become buttons in `<IIIFSavedViewsPanel>`, and the first one is applied as the
166
+ opening view unless `applyInitialCamera={false}`:
167
+
168
+ ```json
169
+ {
170
+ "type": "Annotation",
171
+ "body": {
172
+ "type": "PerspectiveCamera",
173
+ "label": { "en": ["Front"] },
174
+ "fieldOfView": 50.0,
175
+ "lookAt": { "type": "PointSelector", "x": 0, "y": 0.5, "z": 0 }
176
+ },
177
+ "target": {
178
+ "type": "SpecificResource",
179
+ "source": [{ "id": "…/scene/1", "type": "Scene" }],
180
+ "selector": [{ "type": "PointSelector", "x": 0.0, "y": 3.0, "z": -8.0 }]
181
+ }
182
+ }
183
+ ```
184
+
185
+ The target's `PointSelector` is the camera position; `lookAt` is what it points at. `lookAt` may
186
+ instead reference another annotation by `id`, in which case the camera aims at that model's
187
+ position. Both `fieldOfView` and the shorthand `fov` are read. With no `lookAt`, the camera aims at
188
+ the scene centre.
189
+
190
+ `saveCurrentView()` returns an annotation in exactly this shape, ready to paste back into a
191
+ manifest.
192
+
193
+ ### Language
194
+
195
+ Any IIIF language map is resolved against the active language, falling back through `en`, `no`,
196
+ `nb`, `nn` and then any remaining language, so a partially translated manifest still renders. The
197
+ language defaults to the browser's and can be overridden with the `language` prop or switched at
198
+ runtime through `<IIIFLanguageSwitcher>`, which hides itself when there is only one language to
199
+ choose from.
200
+
201
+ Metadata fields are recognised by their **English** label even when another language is displayed,
202
+ so switching language does not lose the unit or inventory fields.
203
+
204
+ ---
205
+
206
+ ## Panels
207
+
208
+ All read from `useIIIFManifest()` unless noted otherwise, use semantic markup with no CSS
209
+ framework, and accept `className` props on every element. There is no bundled stylesheet; the
210
+ demo's [`IIIFDemo.css`](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/src/IIIFDemo.css) is a starting point.
211
+
212
+ | Component | Shows |
213
+ |---|---|
214
+ | `<IIIFSummary>` | `summary` and the `requiredStatement` attribution |
215
+ | `<IIIFMetadataPanel>` | Recognised fields first, then the rest in manifest order |
216
+ | `<IIIFModelsPanel>` | Per-model visibility and transparency toggles |
217
+ | `<IIIFSavedViewsPanel>` | The manifest's cameras, plus "save current view" |
218
+ | `<IIIFLanguageSwitcher>` | Language selector; hidden when the manifest has one language |
219
+ | `<IIIFCollectionPicker>` | A `<select>` of every manifest in a collection |
220
+ | `<IIIFCollectionCarousel>` | A thumbnail strip of every manifest in a collection, with prev/next |
221
+ | `<IIIFMultiManifestModelsPanel>` | Visibility/transparency toggles grouped by manifest; reads `useIIIFMultiManifest()`, for use inside `<IIIFMultiManifestViewer>` instead of `<IIIFViewer>` |
222
+
223
+ ---
224
+
225
+ ## Collections
226
+
227
+ A IIIF Collection lists several manifests — e.g. every object in a museum sub-catalogue — so a
228
+ picker or carousel can switch between them. `<IIIFCollectionProvider>` fetches and parses one, and
229
+ tracks which manifest is selected; it renders nothing itself, so it composes with `<IIIFViewer>`
230
+ rather than replacing it:
231
+
232
+ ```tsx
233
+ import {
234
+ IIIFCollectionProvider,
235
+ IIIFCollectionPicker,
236
+ IIIFCollectionCarousel,
237
+ IIIFViewer,
238
+ useIIIFCollection
239
+ } from '@ikaros-arch/react-3dhop-iiif';
240
+
241
+ function CollectionBrowser() {
242
+ return (
243
+ <IIIFCollectionProvider collection="/manifests/bitfrost/collection.json">
244
+ <IIIFCollectionPicker />
245
+ <IIIFCollectionCarousel />
246
+ <SelectedManifestViewer />
247
+ </IIIFCollectionProvider>
248
+ );
249
+ }
250
+
251
+ function SelectedManifestViewer() {
252
+ const { selectedId } = useIIIFCollection();
253
+ if (!selectedId) return null;
254
+ // Remounting on selection change avoids carrying one object's camera into the next.
255
+ return <IIIFViewer key={selectedId} manifest={selectedId} assetBaseUrl="/3dhop" width={760} height={620} />;
256
+ }
257
+ ```
258
+
259
+ `collection` accepts a URL or an already-fetched object, mirroring `<IIIFViewer manifest>`.
260
+ `useIIIFCollection()` also exposes `items` (id, label, thumbnail), `status`/`error`, and
261
+ `next()`/`previous()` for building a custom control. See
262
+ [`examples/react-3dhop-demo/src/IIIFDemo.tsx`](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/src/IIIFDemo.tsx)
263
+ for a full example, including the [generator
264
+ script](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/scripts/generate-bitfrost-collection.mjs) that builds its
265
+ sample collection from a museum catalogue export.
266
+
267
+ ---
268
+
269
+ ## Multiple manifests in one viewer
270
+
271
+ `<IIIFMultiManifestViewer>` renders several manifests together in a single `<ThreeDHopViewer>`
272
+ instance, laid out side by side rather than switched between one at a time:
273
+
274
+ ```tsx
275
+ import { IIIFMultiManifestViewer, IIIFMultiManifestModelsPanel } from '@ikaros-arch/react-3dhop-iiif';
276
+
277
+ function Comparison() {
278
+ return (
279
+ <IIIFMultiManifestViewer
280
+ manifests={['/manifests/a.json', '/manifests/b.json']}
281
+ assetBaseUrl="/3dhop"
282
+ width={760}
283
+ height={620}
284
+ >
285
+ <IIIFMultiManifestModelsPanel />
286
+ </IIIFMultiManifestViewer>
287
+ );
288
+ }
289
+ ```
290
+
291
+ It is a parallel component, not an extension of `<IIIFViewer>`: it fetches and parses each manifest
292
+ independently, and fails fast if any one of them can't be loaded, since a partially-failed shared
293
+ scene isn't a state worth trying to render. Its context, `useIIIFMultiManifest()`, only covers
294
+ per-model visibility and transparency, scoped by manifest — cameras, saved views, metadata and
295
+ language are per-manifest concepts without a defined multi-manifest behaviour yet, so `<IIIFViewer>`
296
+ remains the way to reach those.
297
+
298
+ There is no mesh geometry available at layout time — meshes stream in later, and the manifest only
299
+ says where each annotation is *placed*, not how large it is — so the side-by-side layout is a
300
+ heuristic: each manifest's own placement spread (the spread between its own annotations), floored by
301
+ `minRadius` and separated by `gap`. A manifest with a single model at the origin (the common case)
302
+ has a spread of zero and falls back entirely to `minRadius`. Tune both options for your data:
303
+
304
+ ```tsx
305
+ <IIIFMultiManifestViewer manifests={ids} minRadius={150} gap={80} columns={3} />
306
+ ```
307
+
308
+ `useIIIFMultiManifest()` addresses models with a `(manifestKey, modelId)` pair rather than the bare
309
+ IIIF annotation id `useIIIFManifest()` uses, because two manifests can otherwise declare colliding
310
+ ids:
311
+
312
+ ```tsx
313
+ const { manifests, isModelVisible, setModelVisible } = useIIIFMultiManifest();
314
+ // manifests: [{ key, sourceId, label, models }, …]
315
+ ```
316
+
317
+ See [`examples/react-3dhop-demo/src/IIIFDemo.tsx`](https://github.com/ikaros-arch/react-3dhop/blob/main/examples/react-3dhop-demo/src/IIIFDemo.tsx)
318
+ (`"Multi-manifest viewer"` mode) for a full example that lets the user check off any number of
319
+ objects from a collection.
320
+
321
+ ---
322
+
323
+ ## Error handling
324
+
325
+ A manifest that cannot be fetched or parsed puts the viewer into an error state and renders
326
+ `errorFallback` — it does not throw or leave a blank canvas.
327
+
328
+ Problems affecting only part of a manifest are reported as **diagnostics** and the rest still
329
+ renders: an annotation with no usable source, a glTF-only model, an unrecognised body type, a
330
+ manifest with no `Scene`, or one with several. Diagnostics reach you through the `onDiagnostic`
331
+ prop or the `diagnostics` array on the context.
332
+
333
+ ---
334
+
335
+ ## Supported IIIF 3D subset
336
+
337
+ Against the [draft specification](https://github.com/IIIF/3d/blob/main/temp-draft-4.md). ✅ full,
338
+ ⚠️ partial, ❌ not implemented.
339
+
340
+ | Feature | Parsed | Rendered | Notes |
341
+ |---|---|---|---|
342
+ | **Containers** |
343
+ | `Scene` | ✅ | ✅ | |
344
+ | Multiple scenes | ✅ | ⚠️ | Merged into one view, with a diagnostic |
345
+ | Canvas in Scene | ❌ | ❌ | |
346
+ | Nested scenes | ❌ | ❌ | |
347
+ | **Resources** |
348
+ | `Model` as body | ✅ | ✅ | |
349
+ | `Model` via `SpecificResource` | ✅ | ✅ | Required for transforms |
350
+ | Several sources per annotation | ✅ | ✅ | Best supported format wins |
351
+ | `PerspectiveCamera` | ✅ | ✅ | Position, `lookAt`, `fieldOfView` |
352
+ | `OrthographicCamera` | ✅ | ✅ | Position, `lookAt` |
353
+ | **Lights** (`Ambient`/`Directional`/`Point`/`Spot`) | ❌ | ❌ | 3DHOP has one directional light, controlled by the viewer |
354
+ | **Transforms** |
355
+ | `ScaleTransform` | ✅ | ✅ | |
356
+ | `TranslateTransform` | ✅ | ✅ | |
357
+ | `RotateTransform` | ✅ | ✅ | Euler and axis-angle forms |
358
+ | Ordered composition | ✅ | ✅ | Composed as a matrix, not flattened |
359
+ | **Selectors** |
360
+ | `PointSelector` | ✅ | ✅ | Positions models and cameras |
361
+ | `PolygonZSelector` | ❌ | ❌ | For Canvas placement |
362
+ | **Properties** |
363
+ | `label`, `summary`, `metadata` | ✅ | ✅ | Multilingual |
364
+ | `requiredStatement` | ✅ | ✅ | Shown as attribution |
365
+ | `fieldOfView` / `fov` | ✅ | ✅ | |
366
+ | `lookAt` | ✅ | ✅ | Point or annotation reference |
367
+ | `backgroundColor` | ❌ | ❌ | Use the viewer's `backgroundUrl` |
368
+ | `duration` | ❌ | ❌ | Temporal scenes |
369
+ | `near` / `far` | ❌ | ❌ | Derived from scene extents instead |
370
+ | `exclude` | ❌ | ❌ | |
371
+ | **Formats** |
372
+ | Nexus (`.nxz`/`.nxs`) | ✅ | ✅ | Streamed progressively |
373
+ | PLY | ✅ | ✅ | |
374
+ | OBJ | ✅ | ❌ | Reported as a diagnostic; convert to Nexus |
375
+ | glTF / GLB | ✅ | ❌ | Reported as a diagnostic; convert to Nexus |
376
+
377
+ ---
378
+
379
+ ## Licence
380
+
381
+ GPL-3.0-or-later, matching 3DHOP.