From d8402c47b5fea855eaa178765b1498094d66d44a Mon Sep 17 00:00:00 2001
From: erseco
Date: Fri, 10 Jul 2026 14:26:06 +0100
Subject: [PATCH 01/18] docs(three-d-viewer): add SDD-0001 and ADR-0001 for
interaction layer
Specify hotspots, guided navigation and single-choice questions for the
3D Viewer iDevice (issue #2153): versioned state, renderer-adapter
architecture over the shared runtime, migration, accessibility, export
and test strategy. No code changes yet.
---
...R-0001-three-d-viewer-interaction-layer.md | 193 ++++++++
doc/architecture/adr/records.md | 3 +-
.../SDD-0001-three-d-viewer-interactions.md | 433 ++++++++++++++++++
doc/architecture/sdd/records.md | 3 +-
4 files changed, 630 insertions(+), 2 deletions(-)
create mode 100644 doc/architecture/adr/ADR-0001-three-d-viewer-interaction-layer.md
create mode 100644 doc/architecture/sdd/SDD-0001-three-d-viewer-interactions.md
diff --git a/doc/architecture/adr/ADR-0001-three-d-viewer-interaction-layer.md b/doc/architecture/adr/ADR-0001-three-d-viewer-interaction-layer.md
new file mode 100644
index 000000000..28f20c105
--- /dev/null
+++ b/doc/architecture/adr/ADR-0001-three-d-viewer-interaction-layer.md
@@ -0,0 +1,193 @@
+---
+id: ADR-0001
+title: "3D Viewer interaction layer: renderer adapters over a shared runtime controller"
+status: Proposed
+date: 2026-07-10
+deciders:
+ - "@erseco"
+reviewers:
+ - "@erseco"
+related:
+ issues: [2153]
+ prs: []
+ sdds: [1]
+ adrs: []
+supersedes: []
+superseded_by: []
+ai_assistance:
+ tool: "Claude Code"
+ model: "claude-opus-4-8"
+---
+
+# ADR-0001: 3D Viewer interaction layer: renderer adapters over a shared runtime controller
+
+## Status
+
+Proposed
+
+## Context
+
+The `three-d-viewer` iDevice supports two independent render paths: GLB/GLTF via the
+`` web component and STL via a bespoke Three.js scene in the shared
+`window.eXe3DViewer` runtime (`public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer-runtime.js`
+@ f3a32e774). SDD-0001 adds hotspots, guided navigation and single-choice questions to this
+iDevice (issue #2153). The two render paths expose completely different placement, projection and
+occlusion mechanics: `` offers a declarative hotspot API with native projection and
+occlusion (`slot="hotspot-*"`, `positionAndNormalFromPoint()`), while the STL path exposes raw
+Three.js objects (`scene/camera/renderer/canvas/mesh`) and normalizes the mesh (`geometry.center()`
++ `2/maxDim` scale, runtime lines 478-485), so markers only make sense in normalized model space
+and must be projected to a DOM overlay by hand each frame.
+
+The sibling `three-sixty-viewer` already implements a hotspot system, but because it has **no shared
+runtime**, it duplicates its entire state + projection + dialog stack across `edition/` and `export/`,
+and all its coordinate math is panorama-specific (yaw/pitch on an inverted sphere) — reusable in
+shape, not in substance.
+
+## Problem
+
+How should marker placement, projection, dialogs, questions and guided navigation be structured so
+that (a) GLB/GLTF and STL do not each grow their own copy of the marker/dialog/question logic,
+(b) the editor preview and the exported learner page behave identically, (c) no new export library
+or registration is introduced, and (d) the abstraction stays minimal rather than importing a
+framework or the 3Dmol game engine?
+
+## Decision drivers
+
+- **No duplicated marker logic** across the two render paths (AGENTS.md "single source of truth").
+- **Editor/export parity** — one behavioural implementation, not two.
+- **Minimal surface** — no framework, no new dependency, no new registered file (avoid the ~6-site
+ export-registration burden documented in repo memory `Export lib registration sites`).
+- **Preserve existing runtime semantics** — `window.eXe3DViewer` lifecycle, WebGL disposal,
+ AssetManager `asset://` handling, no persisted `blob:`.
+- **Accessibility + testability** — coordinate math extractable as pure functions; DOM behaviour
+ reusable from the 360 dialog pattern.
+- **Back-compat** — migrate interaction-less state transparently.
+
+## Options considered
+
+### Option 1: Two independent implementations (one per render path)
+
+Add marker/dialog/question code separately to the model-viewer path and the STL path.
+
+- Pros: each path optimally native; no adapter indirection.
+- Cons: marker state, dialogs, questions, guided nav and ARIA duplicated twice **and** across
+ edition/export (4 copies); guaranteed drift; violates single-source-of-truth; largest surface.
+
+### Option 2: One renderer abstraction that hides model-viewer and Three.js behind a common scene API
+
+Build a generic 3D scene facade so a single code path drives both.
+
+- Pros: one code path.
+- Cons: `` deliberately hides its internals; forcing a common low-level scene API means
+ re-implementing model-viewer's projection/occlusion/animation — large, fragile, over-engineered;
+ fights the web component instead of using its native hotspots.
+
+### Option 3 (chosen): Shared renderer-agnostic controller + two thin renderer adapters
+
+A single `InteractionController` in the shared runtime owns marker state, active-marker tracking,
+guided navigation, the accessible dialog, the single-choice question renderer, ARIA announcements and
+the fallback list. It talks to the model only through a small adapter contract. Two adapters
+implement it: `ModelViewerMarkerAdapter` (native declarative hotspots) and `StlMarkerAdapter`
+(raycast + per-frame DOM-overlay reprojection). Both the editor preview and the export runtime
+construct the controller via one factory, so behaviour is single-copy and identical.
+
+Adapter contract:
+
+```js
+{
+ enterPlacementMode(onPlaced), // onPlaced({ position, normal, surface, camera })
+ exitPlacementMode(),
+ renderMarkers(markers, { showLabels, activeId }),
+ focusMarker(marker), // apply marker.camera if present
+ captureCamera(), // -> opaque { orbit, target, fieldOfView }
+ updateOverlay(), // per-frame reprojection (STL); no-op for model-viewer
+ destroy(),
+}
+```
+
+Two supporting decisions ride with this ADR:
+
+- **Schema `normalize*`/migration are mirrored (byte-identical) in `edition/` and `export/`**, marked
+ `// mirror edition`, exactly as `three-sixty-viewer` does — rather than adding a new shared
+ classic-script file (which would need the ~6-site registration + bundle regen and introduce a
+ runtime load-order dependency). Only the *pure, small* schema layer is duplicated; the large
+ behavioural layer lives single-copy in the runtime.
+- **Interaction state is serialized as a JSON `` breakout.
+
+## Evidence
+
+- Render-path divergence and mesh normalization: `three-d-viewer/export/three-d-viewer-runtime.js:246,384,409,478-485,523,289` @ f3a32e774.
+- Model-viewer emitted without `src`; flat `data-*`; external asset rewrite: `export/three-d-viewer.js:600,635,1195,1304`; `src/shared/export/exporters/BaseExporter.ts:756`.
+- 360 duplication + JSON data script + accessible dialog: `three-sixty-viewer/export/three-sixty-viewer.js:78,348,946-1128`.
+- No-registration packaging by directory recursion: `src/shared/export/providers/FileSystemResourceProvider.ts:93`; `Html5Exporter.ts:327`. Registration burden if a new file were added: `src/shared/export/browser/idevice-config-browser.ts:137,179` + bundle regen (repo memory `Export lib registration sites`).
+- 3Dmol question concept (reuse) vs game engine (avoid): `3dmol/export/3dmol.js:333,2010-2054` and the timers/lives/scoring/SCORM stack.
+- `` hotspot + `positionAndNormalFromPoint` API: model-viewer documentation (`modelviewer.dev`, "Annotations").
+
+## Decision
+
+We will implement **Option 3**: a single renderer-agnostic `InteractionController` in the shared
+`three-d-viewer-runtime.js`, with two thin renderer adapters (`ModelViewerMarkerAdapter`,
+`StlMarkerAdapter`) implementing a small common contract, constructed by one factory shared between
+the editor preview and the export runtime. Schema `normalize*`/migration are mirrored byte-identical
+in `edition/` and `export/`; interaction state is serialized as an escaped JSON ``
+ breakout; it is parsed with `JSON.parse` inside `try/catch`.
+- Links: validate URL scheme; `target="_blank"` always paired with `rel="noopener noreferrer"`.
+- No `eval`, no dynamic code execution, no inline event handlers, no scripts from marker content.
+- `blob:`/`data:` URLs are stripped before persistence, matching `get3DViewerJSON`'s existing guard.
+
+## Accessibility
+
+- Viewer region has an accessible name (existing `aria-label` from `alt`).
+- Each marker is a real `` with a meaningful accessible label (`label`, falling back to
+ "Marker N"); decorative icons are `aria-hidden`.
+- Marker dialog: `role="dialog"`, `aria-modal="true"`, focus moved in on open, focus trap, `Escape`
+ closes, focus returns to the originating marker button (reuses the 360 dialog pattern).
+- Guided nav buttons expose `disabled` state at the ends; the active step is announced via an
+ `aria-live="polite"` region.
+- Questions use native `` + ` ` + ``; a check button;
+ feedback rendered into an `aria-live` region (improvement over 3Dmol, which had none).
+- Correctness never conveyed by colour alone (text feedback + icon).
+- Non-WebGL fallback: a structured `` of marker order/label/content and question prompt/options.
+
+## Internationalization
+
+- Editor chrome uses `_()`. Learner-facing default/baked strings (e.g. "Marker", "Previous",
+ "Next", "Check", fallback headings) use `c_()`. No hardcoded English.
+- **No changes under `translations/`**; no `make translations`. New keys are only wrapped in source.
+
+## Performance
+
+- One RAF loop remains (the existing `animate()`); the STL overlay reprojection is an added
+ `onFrame` callback, O(markers) per frame with markers typically < 30. `model-viewer` hotspots are
+ native and cost nothing extra.
+- No new network requests; libraries are unchanged and already local.
+- Marker overlays and anchors are disposed in `destroy()`; no duplicate RAF loops or leaked listeners.
+
+## Testing strategy
+
+- **Unit (Vitest/happy-dom, colocated `*.test.js`)**: normalization, migration (v1→v2, idempotent
+ round-trip), invalid/malformed data, anchor/camera/action/question normalization, id generation,
+ ordering/reorder, single-choice validation (correct/incorrect/attempts), JSON block
+ serialize/parse (incl. `` escaping and no `blob:`), accessible-label construction, and
+ the pure STL projection/occlusion math (injected minimal `THREE` stub, per the runtime test
+ pattern). `public/files/perm/**` is excluded from the v8 coverage *include*, so the ≥90% patch
+ gate is met by shipping colocated tests for every new function.
+- **Runtime**: marker overlay markup, active-marker state, dialog open/close + focus return, guided
+ prev/next, question feedback, keyboard activation, fallback rendering, cleanup.
+- **Export**: markers reach the exported JSON block; `asset://` rewritten; **no `blob:`**; required
+ scripts/styles present; legacy state exports without interactions; attributes escaped; fallback
+ present.
+- **Playwright** (`test/e2e/playwright/specs/idevices/three-d-viewer-interactions.spec.ts`): GLB flow
+ — add viewer, enable interactions, add informational marker, add question marker, enable guided
+ mode, save, reopen, assert persistence, open preview (direct `#head-bottom-preview` click, wait for
+ `article`), navigate markers, answer the question, assert accessible labels + feedback. STL:
+ cover the raycasting adapter with a deterministic unit/integration fixture; keep pointer-based
+ WebGL E2E minimal. Run `make test-e2e-static` (export/preview affected).
+
+## Rollout plan
+
+Single feature branch `2153-3d-viewer-edevice`, small logical commits: (1) schema + migration +
+tests; (2) runtime interaction layer + adapters + tests; (3) editor UI + tests; (4) export markup +
+fallback + tests; (5) CSS; (6) Playwright + docs. No feature flag needed — the feature is inert
+until an author enables it.
+
+## Risks and mitigations
+
+- **STL occlusion / reprojection correctness** under rotation, resize, fullscreen, auto-rotate →
+ drive reprojection from the shared RAF `onFrame`; hide markers with `NDC.z ≥ 1` or occluded by a
+ camera→point raycast; add resize handling. Covered by pure-math unit tests + E2E smoke.
+- **Animated GLB surface anchoring** may drift on skinned meshes → store position+normal anchors as
+ the reliable default; use `model-viewer` `surface` only when available; document the limitation;
+ never block the base feature.
+- **Duplication drift** between mirrored `normalize*` in edition/export → keep blocks byte-identical
+ with a `// mirror edition` marker and identical tests; the behavioural logic lives single-copy in
+ the runtime to minimize what is duplicated.
+- **Round-trip data loss** (the #1 iDevice bug) → mandatory `load(save(x))` test for every field.
+
+## Open questions
+
+- Icon set: start with a small fixed allowlist (`circle`, `pin`, `info`, `question`, `star`) rendered
+ as CSS/SVG glyphs; extensible later.
+- Whether `description` and `information` `payload.html` should merge — kept separate per the issue
+ prompt schema; `description` is an optional dialog subtitle.
+
+## ADRs required or referenced
+
+| Decision | ADR | Status |
+|---|---|---|
+| Renderer-adapter abstraction + shared-runtime interaction layer vs per-path duplication | ADR-0001 | Proposed |
+| Mirror `normalize*` in edition/export (follow 360 convention) rather than a new shared lib | ADR-0001 | Proposed |
+| Serialize interaction state as a JSON `l
');
+ expect(out).toContain('hi');
+ expect(out).not.toContain(' breakout', () => {
+ const html = $tdv.renderView(dataWith(enabled({ markers: [
+ { id: 'x', label: 'X', icon: 'circle',
+ anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } },
+ action: { type: 'information', payload: { html: ' ' } } },
+ ] })), null, '{content}');
+ // The data block must not contain a raw closing from the
+ // payload — every leading < is escaped to <.
+ const block = html.slice(html.indexOf('tdv-interaction-data'));
+ const jsonPart = block.slice(0, block.indexOf(''));
+ expect(jsonPart).not.toContain('');
+ expect(jsonPart).toContain('\\u003c/script>');
+ });
+
+ it('keeps asset:// media references in the JSON block for the export rewriter', () => {
+ const html = $tdv.renderView(dataWith(enabled({ markers: [
+ { id: 'i', label: 'Img', icon: 'circle',
+ anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } },
+ action: { type: 'image', payload: { src: 'asset://pic.png', alt: 'p' } } },
+ ] })), null, '{content}');
+ expect(html).toContain('asset://pic.png');
+ });
+
+ it('never emits a blob: URL from marker media', () => {
+ const html = $tdv.renderView(dataWith(enabled({ markers: [
+ { id: 'i', label: 'Img', icon: 'circle',
+ anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } },
+ action: { type: 'image', payload: { src: 'blob:http://x/leak' } } },
+ ] })), null, '{content}');
+ expect(html).not.toContain('blob:');
+ });
+
+ it('renders guided navigation controls only in guided mode', () => {
+ const off = $tdv.renderView(dataWith(enabled({ guidedMode: false })), null, '{content}');
+ expect(off).not.toContain('tdv-guided-nav');
+ const on = $tdv.renderView(dataWith(enabled({ guidedMode: true })), null, '{content}');
+ expect(on).toContain('tdv-guided-nav');
+ expect(on).toContain('tdv-nav-prev');
+ expect(on).toContain('tdv-nav-next');
+ });
+
+ it('bakes a runtime i18n map into the data block', () => {
+ const html = $tdv.renderView(dataWith(enabled()), null, '{content}');
+ expect(html).toContain('"i18n"');
+ expect(html).toContain('Check');
+ });
+ });
});
diff --git a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js b/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js
index 40aa0e30d..4f7b150a4 100644
--- a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js
+++ b/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js
@@ -141,6 +141,158 @@
return !!(chosen && chosen.correct);
}
+ // ─────────────────────────────────────────────────────────────────────
+ // Interaction export markup
+ //
+ // The interaction state ships as an escaped JSON l ');
- expect(out).toContain('hi');
- expect(out).not.toContain(' breakout', () => {
- const html = $tdv.renderView(dataWith(enabled({ markers: [
- { id: 'x', label: 'X', icon: 'circle',
- anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } },
- action: { type: 'information', payload: { html: ' ' } } },
- ] })), null, '{content}');
- // The data block must not contain a raw closing from the
- // payload — every leading < is escaped to <.
- const block = html.slice(html.indexOf('tdv-interaction-data'));
- const jsonPart = block.slice(0, block.indexOf(''));
- expect(jsonPart).not.toContain('');
- expect(jsonPart).toContain('\\u003c/script>');
- });
-
- it('keeps asset:// media references in the JSON block for the export rewriter', () => {
- const html = $tdv.renderView(dataWith(enabled({ markers: [
- { id: 'i', label: 'Img', icon: 'circle',
- anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } },
- action: { type: 'image', payload: { src: 'asset://pic.png', alt: 'p' } } },
- ] })), null, '{content}');
- expect(html).toContain('asset://pic.png');
- });
-
- it('never emits a blob: URL from marker media', () => {
- const html = $tdv.renderView(dataWith(enabled({ markers: [
- { id: 'i', label: 'Img', icon: 'circle',
- anchor: { position: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 1, z: 0 } },
- action: { type: 'image', payload: { src: 'blob:http://x/leak' } } },
- ] })), null, '{content}');
- expect(html).not.toContain('blob:');
- });
-
- it('renders guided navigation controls only in guided mode', () => {
- const off = $tdv.renderView(dataWith(enabled({ guidedMode: false })), null, '{content}');
- expect(off).not.toContain('tdv-guided-nav');
- const on = $tdv.renderView(dataWith(enabled({ guidedMode: true })), null, '{content}');
- expect(on).toContain('tdv-guided-nav');
- expect(on).toContain('tdv-nav-prev');
- expect(on).toContain('tdv-nav-next');
- });
-
- it('bakes a runtime i18n map into the data block', () => {
- const html = $tdv.renderView(dataWith(enabled()), null, '{content}');
- expect(html).toContain('"i18n"');
- expect(html).toContain('Check');
- });
-
- it('embeds the SCORM scoring config in the data block', () => {
- const data = Object.assign(dataWith(enabled()), { isScorm: 1, weighted: 80 });
- const html = $tdv.renderView(data, null, '{content}');
- expect(html).toContain('"scorm"');
- expect(html).toContain('"isScorm":1');
- expect(html).toContain('"weighted":80');
- });
- });
-
- describe('normalizeScorm + setupScormScoring', () => {
- it('clamps the SCORM config', () => {
- expect($tdv.__normalizeScorm({ isScorm: 9, weighted: 0 })).toEqual({ isScorm: 2, weighted: 1, textButtonScorm: '' });
- });
-
- it('registers the activity and reports the fraction correct in a SCORM export', () => {
- const events = [];
- globalThis.$exeDevices = { iDevice: { gamification: { scorm: {
- registerActivity: (g) => events.push(['register', g.main, g.isScorm, g.weighted]),
- sendScoreNew: (auto, g) => events.push(['send', g.scorerp, !!g.gameOver]),
- } } } };
- const savedDoc = globalThis.document;
- globalThis.document = { body: { classList: { contains: (c) => c === 'exe-scorm' } } };
- try {
- const interaction = $tdv.__normalizeInteraction({ enabled: true, markers: [
- { id: 'q1', action: { type: 'question', payload: { options: [{ text: 'a', correct: true }, { text: 'b' }] } } },
- { id: 'q2', action: { type: 'question', payload: { options: [{ text: 'a', correct: true }, { text: 'b' }] } } },
- { id: 'i1', action: { type: 'information', payload: { html: '' } } },
- ] });
- const hooks = {};
- $tdv.__setupScormScoring({ id: 'viewer-1' }, interaction, { isScorm: 1, weighted: 100 }, hooks);
- expect(events).toContainEqual(['register', 'viewer-1', 1, 100]);
- expect(typeof hooks.onQuestionAnswered).toBe('function');
- hooks.onQuestionAnswered('q1', true); // 1 of 2 questions → 5/10
- expect(events).toContainEqual(['send', 5, false]);
- hooks.onQuestionAnswered('q2', true); // 2 of 2 → 10/10, complete
- expect(events).toContainEqual(['send', 10, true]);
- } finally {
- globalThis.document = savedDoc;
- delete globalThis.$exeDevices;
- }
- });
-
- it('does not wire scoring outside a SCORM export', () => {
- const savedDoc = globalThis.document;
- globalThis.document = { body: { classList: { contains: () => false } } };
- try {
- const hooks = {};
- const interaction = $tdv.__normalizeInteraction({ enabled: true, markers: [
- { id: 'q', action: { type: 'question', payload: {} } },
- ] });
- $tdv.__setupScormScoring({ id: 'v' }, interaction, { isScorm: 1 }, hooks);
- expect(hooks.onQuestionAnswered).toBeUndefined();
- } finally {
- globalThis.document = savedDoc;
- }
- });
- });
-});
diff --git a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js b/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js
index 3def7c80d..64ea5a419 100644
--- a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js
+++ b/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js
@@ -1,1761 +1,2733 @@
-/* global eXe */
-
-/**
- * Three D Viewer iDevice (export runtime)
- *
- * - Loads the model-viewer web component (ES module) once per page.
- * - Renders a using the JSON stored by the edition view.
- * - Works on initial page load (refresh) without entering Edit mode.
- */
-
-(function () {
- const globalScope = typeof window !== 'undefined' ? window : globalThis;
-
- /** Default background color */
- const DEFAULT_BACKGROUND = '#f5f5f5';
-
- /** Fallback translations when i18n is not available */
- const FALLBACK_TRANSLATIONS = {
- 'viewer.empty_state': 'Select a 3D model to display',
- 'viewer.animation_paused': 'Animation paused',
- 'viewer.animation_enabled': 'Animation enabled',
- 'viewer.local_warning_title': '3D Viewer not available',
- 'viewer.local_warning_message': 'The 3D viewer requires a web server to work. Open this content from a web server or use eXeLearning preview.',
- 'viewer.fullscreen': 'Fullscreen',
- 'viewer.exit_fullscreen': 'Exit fullscreen',
- 'viewer.rotate_left': 'Rotate left',
- 'viewer.rotate_right': 'Rotate right',
- 'viewer.tilt_up': 'Tilt up',
- 'viewer.tilt_down': 'Tilt down'
- };
-
- /** Camera nudge step (radians) — matches threesixty viewer feel */
- const YAW_STEP = (15 * Math.PI) / 180;
- const PITCH_STEP = (10 * Math.PI) / 180;
-
- // ─────────────────────────────────────────────────────────────────────
- // Interaction schema (mirror edition/three-d-viewer.js). These pure
- // helpers must stay byte-identical with the edition copy — see
- // doc/architecture/sdd/SDD-0001. Used by renderView/renderBehaviour and
- // consumed by the shared runtime (three-d-viewer-runtime.js).
- // ─────────────────────────────────────────────────────────────────────
- var STATE_VERSION = 2;
- var MARKER_ICONS = ['circle', 'pin', 'info', 'question', 'star'];
- var INTERACTION_ACTION_TYPES = ['information', 'image', 'video', 'link', 'question'];
-
- function tdNum(v, fallback) { var n = typeof v === 'number' ? v : parseFloat(v); return Number.isFinite(n) ? n : fallback; }
- function tdClamp(v, min, max) { return Math.min(max, Math.max(min, v)); }
- function tdStr(v, fallback) { return typeof v === 'string' ? v : (fallback || ''); }
- function tdId(prefix, existing) {
- if (typeof existing === 'string' && existing) return existing;
- return prefix + '-' + Math.floor(Math.random() * 1e9).toString(36) + Math.floor(Math.random() * 1e6).toString(36);
- }
- function tdStripUnsafeUrl(v) { var s = tdStr(v, ''); return /^\s*(blob:|data:|javascript:|vbscript:)/i.test(s) ? '' : s.trim(); }
- function tdInt(v, fallback) { var n = parseInt(v, 10); return Number.isFinite(n) ? n : fallback; }
- /** Normalize the SCORM scoring config (mirror edition/export). */
- function normalizeScorm(data) {
- var o = data && typeof data === 'object' ? data : {};
- return {
- isScorm: tdClamp(tdInt(o.isScorm, 0), 0, 2),
- weighted: tdClamp(tdNum(o.weighted, 100), 1, 100),
- textButtonScorm: tdStr(o.textButtonScorm, ''),
- };
+(() => {
+ // public/files/perm/idevices/base/three-d-viewer/src/interactions/marker-renderer.ts
+ function createMarkerButton(marker, options) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `tdv-marker ${options.variantClass}`;
+ button.dataset.markerId = marker.id;
+ button.dataset.markerOrder = String(options.index);
+ button.setAttribute("aria-label", options.label);
+ const icon = document.createElement("span");
+ icon.className = `tdv-marker-icon tdv-icon-${marker.icon}`;
+ icon.setAttribute("aria-hidden", "true");
+ button.appendChild(icon);
+ if (options.showLabels && marker.label) {
+ const label = document.createElement("span");
+ label.className = "tdv-marker-label";
+ label.textContent = marker.label;
+ button.appendChild(label);
+ }
+ if (options.activeId === marker.id) {
+ button.classList.add("tdv-marker--active");
+ button.setAttribute("aria-current", "true");
+ }
+ button.addEventListener("click", () => options.onActivate(marker.id));
+ return button;
+ }
+ function applyActiveMarker(buttons, activeId) {
+ for (const button of buttons) {
+ const isActive = button.dataset.markerId === activeId;
+ button.classList.toggle("tdv-marker--active", isActive);
+ if (isActive) {
+ button.setAttribute("aria-current", "true");
+ } else {
+ button.removeAttribute("aria-current");
+ }
}
- function normalizeVec3(v, dflt) {
- var o = v && typeof v === 'object' ? v : {};
- return { x: tdNum(o.x, dflt.x), y: tdNum(o.y, dflt.y), z: tdNum(o.z, dflt.z) };
+ }
+
+ // public/files/perm/idevices/base/three-d-viewer/src/adapters/geometry.ts
+ var FACING_THRESHOLD = -0.15;
+ function ndcToScreen(ndc, width, height) {
+ return {
+ x: (ndc.x * 0.5 + 0.5) * width,
+ y: (-ndc.y * 0.5 + 0.5) * height
+ };
+ }
+ function isOnScreen(ndc) {
+ const inFrustum = ndc.z < 1 && ndc.z > -1;
+ return inFrustum && ndc.x >= -1 && ndc.x <= 1 && ndc.y >= -1 && ndc.y <= 1;
+ }
+ function isFacingCamera(normal, toCamera) {
+ return normal.x * toCamera.x + normal.y * toCamera.y + normal.z * toCamera.z > FACING_THRESHOLD;
+ }
+ function isMarkerVisible(ndc, normal, toCamera) {
+ return isFacingCamera(normal, toCamera) && isOnScreen(ndc);
+ }
+ function parseTriple(value) {
+ const parts = String(value ?? "").trim().split(/\s+/).map(Number.parseFloat);
+ return {
+ x: Number.isFinite(parts[0]) ? parts[0] : 0,
+ y: Number.isFinite(parts[1]) ? parts[1] : 0,
+ z: Number.isFinite(parts[2]) ? parts[2] : 0
+ };
+ }
+ function formatTriple(vector) {
+ return `${vector.x} ${vector.y} ${vector.z}`;
+ }
+ function pointerToNdc(rect, clientX, clientY) {
+ if (!rect.width || !rect.height) {
+ return null;
}
- function normalizeAnchor(a) {
- var o = a && typeof a === 'object' ? a : {};
+ return {
+ x: (clientX - rect.left) / rect.width * 2 - 1,
+ y: -((clientY - rect.top) / rect.height) * 2 + 1
+ };
+ }
+
+ // public/files/perm/idevices/base/three-d-viewer/src/adapters/model-viewer-adapter.ts
+ var EMPTY_CAMERA = { orbit: "", target: "", fieldOfView: "" };
+ function createModelViewerAdapter(modelViewer, deps) {
+ let placeHandler = null;
+ const clearMarkers = () => {
+ for (const element of Array.from(modelViewer.querySelectorAll('.tdv-marker[slot^="hotspot-"]'))) {
+ element.remove();
+ }
+ };
+ const captureCamera = () => {
+ try {
return {
- position: normalizeVec3(o.position, { x: 0, y: 0, z: 0 }),
- normal: normalizeVec3(o.normal, { x: 0, y: 1, z: 0 }),
- surface: tdStr(o.surface, ''),
+ orbit: modelViewer.getCameraOrbit?.().toString() ?? "",
+ target: modelViewer.getCameraTarget?.().toString() ?? "",
+ fieldOfView: modelViewer.getFieldOfView ? `${modelViewer.getFieldOfView()}deg` : ""
};
- }
- function normalizeCamera(c) {
- var o = c && typeof c === 'object' ? c : {};
- return { orbit: tdStr(o.orbit, ''), target: tdStr(o.target, ''), fieldOfView: tdStr(o.fieldOfView, '') };
- }
- function normalizeQuestion(p) {
- var o = p && typeof p === 'object' ? p : {};
- var rawOpts = Array.isArray(o.options) ? o.options : [];
- var seenCorrect = false;
- var options = rawOpts.slice(0, 10).map(function (opt) {
- var oo = opt && typeof opt === 'object' ? opt : {};
- var correct = !!oo.correct && !seenCorrect;
- if (correct) seenCorrect = true;
- return { id: tdId('option', oo.id), text: tdStr(oo.text, ''), correct: correct };
+ } catch {
+ return { ...EMPTY_CAMERA };
+ }
+ };
+ return {
+ renderMarkers(markers, options) {
+ clearMarkers();
+ markers.forEach((marker, index) => {
+ const button = createMarkerButton(marker, {
+ ...options,
+ index,
+ label: deps.markerLabel(marker, index),
+ variantClass: "tdv-marker--mv",
+ onActivate: deps.onActivate
+ });
+ button.setAttribute("slot", `hotspot-${marker.id}`);
+ button.dataset.position = formatTriple(marker.anchor.position);
+ button.dataset.normal = formatTriple(marker.anchor.normal);
+ if (marker.anchor.surface) {
+ button.dataset.surface = marker.anchor.surface;
+ }
+ modelViewer.appendChild(button);
});
- if (options.length === 0) {
- options = [{ id: tdId('option'), text: '', correct: true }, { id: tdId('option'), text: '', correct: false }];
- } else if (!seenCorrect) {
- options[0].correct = true;
+ },
+ setActive(activeId) {
+ applyActiveMarker(modelViewer.querySelectorAll(".tdv-marker"), activeId);
+ },
+ focusMarker(marker) {
+ const camera = marker.camera;
+ if (camera.orbit) {
+ modelViewer.cameraOrbit = camera.orbit;
}
- return {
- prompt: tdStr(o.prompt, ''),
- type: 'single-choice',
- options: options,
- feedbackCorrect: tdStr(o.feedbackCorrect, ''),
- feedbackIncorrect: tdStr(o.feedbackIncorrect, ''),
- attemptsAllowed: tdClamp(Math.round(tdNum(o.attemptsAllowed, 0)), 0, 20),
+ if (camera.target) {
+ modelViewer.cameraTarget = camera.target;
+ }
+ if (camera.fieldOfView) {
+ modelViewer.fieldOfView = camera.fieldOfView;
+ }
+ },
+ captureCamera,
+ updateOverlay() {},
+ enterPlacementMode(onPlaced) {
+ placeHandler = (event) => {
+ const hit = modelViewer.positionAndNormalFromPoint?.(event.clientX, event.clientY);
+ if (!hit) {
+ return;
+ }
+ onPlaced({
+ position: parseTriple(hit.position?.toString()),
+ normal: parseTriple(hit.normal?.toString()),
+ surface: "",
+ camera: captureCamera()
+ });
};
- }
- function normalizeAction(a) {
- var o = a && typeof a === 'object' ? a : {};
- var type = INTERACTION_ACTION_TYPES.indexOf(o.type) >= 0 ? o.type : 'information';
- var pin = o.payload && typeof o.payload === 'object' ? o.payload : {};
- var payload;
- switch (type) {
- case 'image': payload = { src: tdStripUnsafeUrl(pin.src), alt: tdStr(pin.alt, ''), caption: tdStr(pin.caption, '') }; break;
- case 'video': payload = { src: tdStripUnsafeUrl(pin.src), poster: tdStripUnsafeUrl(pin.poster) }; break;
- case 'link': payload = { url: tdStripUnsafeUrl(pin.url), newTab: pin.newTab !== false }; break;
- case 'question': payload = normalizeQuestion(pin); break;
- default: payload = { html: tdStr(pin.html, '') }; break;
+ modelViewer.addEventListener("click", placeHandler);
+ },
+ exitPlacementMode() {
+ if (placeHandler) {
+ modelViewer.removeEventListener("click", placeHandler);
+ placeHandler = null;
}
- return { type: type, payload: payload };
+ },
+ destroy() {
+ this.exitPlacementMode();
+ clearMarkers();
+ }
+ };
+ }
+
+ // public/files/perm/idevices/base/three-d-viewer/src/shared/urls.ts
+ var EXECUTABLE_SCHEME = /^\s*(javascript|vbscript):/i;
+ var EPHEMERAL_OR_EXECUTABLE_SCHEME = /^\s*(blob:|data:|javascript:|vbscript:)/i;
+ var ALLOWED_RENDER_SCHEME = /^(https?:|mailto:|tel:|asset:|blob:)/i;
+ var HAS_EXPLICIT_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
+ var ABSOLUTE_URL = /^(https?:)?\/\//i;
+ function stripUnsafeUrl(value) {
+ const raw = typeof value === "string" ? value : "";
+ return EPHEMERAL_OR_EXECUTABLE_SCHEME.test(raw) ? "" : raw.trim();
+ }
+ function safeUrl(value) {
+ const raw = typeof value === "string" ? value.trim() : "";
+ if (!raw) {
+ return "";
}
- function normalizeMarker(m, index) {
- var o = m && typeof m === 'object' ? m : {};
- var order = tdNum(o.order, NaN);
- return {
- id: tdId('marker', o.id),
- label: tdStr(o.label, ''),
- description: tdStr(o.description, ''),
- icon: MARKER_ICONS.indexOf(o.icon) >= 0 ? o.icon : 'circle',
- order: Number.isFinite(order) ? order : index,
- anchor: normalizeAnchor(o.anchor),
- camera: normalizeCamera(o.camera),
- action: normalizeAction(o.action),
- };
+ if (EXECUTABLE_SCHEME.test(raw)) {
+ return "";
}
- function normalizeInteraction(it) {
- var o = it && typeof it === 'object' ? it : {};
- var markers = (Array.isArray(o.markers) ? o.markers : []).map(normalizeMarker);
- markers.sort(function (a, b) { return a.order - b.order; });
- markers.forEach(function (mk, i) { mk.order = i; });
- var ids = markers.map(function (mk) { return mk.id; });
- return {
- enabled: !!o.enabled,
- guidedMode: !!o.guidedMode,
- wrapNavigation: !!o.wrapNavigation,
- showMarkerLabels: o.showMarkerLabels !== false,
- activeMarkerId: ids.indexOf(o.activeMarkerId) >= 0 ? o.activeMarkerId : '',
- markers: markers,
- };
+ if (ALLOWED_RENDER_SCHEME.test(raw)) {
+ return raw;
}
- /** Pure single-choice grading — chosen option id → correct? */
- function gradeSingleChoice(question, selectedOptionId) {
- var q = normalizeQuestion(question);
- var chosen = q.options.filter(function (op) { return op.id === selectedOptionId; })[0];
- return !!(chosen && chosen.correct);
+ return HAS_EXPLICIT_SCHEME.test(raw) ? "" : raw;
+ }
+ function isAbsoluteUrl(value) {
+ return ABSOLUTE_URL.test(value);
+ }
+ function normalizePath(value) {
+ const clean = String(value ?? "").trim().replace(/\\+/g, "/");
+ if (!clean) {
+ return "";
}
-
- // ─────────────────────────────────────────────────────────────────────
- // Interaction export markup
- //
- // The interaction state ships as an escaped JSON ';
+ expect(parseInteractionData(wrapper)).toEqual({ enabled: true });
+ });
+
+ it('returns null when the block is missing or malformed', () => {
+ expect(parseInteractionData(createWrapper('a'))).toBeNull();
+ const broken = createWrapper('b');
+ broken.innerHTML = '';
+ expect(parseInteractionData(broken)).toBeNull();
+ });
+});
+
+describe('findWrappers', () => {
+ it('finds every wrapper in the document', () => {
+ createWrapper('one');
+ createWrapper('two');
+ expect(findWrappers('')).toHaveLength(2);
+ });
+
+ it('scopes to the iDevice node when one matches', () => {
+ const node = document.createElement('div');
+ node.className = 'idevice_node three-d-viewer';
+ node.id = 'idev-1';
+ document.body.appendChild(node);
+ const scoped = createWrapper('scoped');
+ node.appendChild(scoped);
+ createWrapper('outside');
+ expect(findWrappers('idev-1')).toEqual([scoped]);
+ });
+
+ it('falls back to the whole document when the scope holds no wrapper', () => {
+ const node = document.createElement('div');
+ node.className = 'idevice_node three-d-viewer';
+ node.id = 'idev-1';
+ document.body.appendChild(node);
+ const outside = createWrapper('outside');
+ expect(findWrappers('idev-1')).toEqual([outside]);
+ });
+});
+
+describe('attachInteractionLayer', () => {
+ function withInteraction(wrapper: HTMLElement, payload: Record): void {
+ const script = document.createElement('script');
+ script.type = 'application/json';
+ script.className = 'tdv-interaction-data';
+ script.textContent = JSON.stringify(payload);
+ wrapper.appendChild(script);
+ }
+
+ it('creates the layer for a GLB wrapper', async () => {
+ const wrapper = createWrapper();
+ wrapper.dataset.modelType = 'glb';
+ createModelViewerStub(wrapper);
+ withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1', label: 'One' }] });
+
+ await attachInteractionLayer(wrapper);
+ expect(wrapper.querySelector('.tdv-marker')?.getAttribute('aria-label')).toBe('One');
+ expect(wrapper.dataset.tdvInteractionBooted).toBe('1');
+ });
+
+ it('uses the baked i18n map for learner strings', async () => {
+ const wrapper = createWrapper();
+ wrapper.dataset.modelType = 'glb';
+ createModelViewerStub(wrapper);
+ withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1' }], i18n: { Marker: 'Marcador' } });
+ await attachInteractionLayer(wrapper);
+ expect(wrapper.querySelector('.tdv-marker')?.getAttribute('aria-label')).toBe('Marcador 1');
+ });
+
+ it('is idempotent and skips a disabled or missing block', async () => {
+ const wrapper = createWrapper();
+ wrapper.dataset.modelType = 'glb';
+ createModelViewerStub(wrapper);
+ withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1' }] });
+ await attachInteractionLayer(wrapper);
+ await attachInteractionLayer(wrapper);
+ expect(wrapper.querySelectorAll('.tdv-marker')).toHaveLength(1);
+
+ const disabled = createWrapper('disabled');
+ withInteraction(disabled, { enabled: false });
+ await attachInteractionLayer(disabled);
+ expect(disabled.dataset.tdvInteractionBooted).toBeUndefined();
+ });
+
+ it('attaches to a booted STL instance', async () => {
+ const wrapper = createWrapper();
+ wrapper.dataset.modelType = 'stl';
+ withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1', label: 'STL' }] });
+ const runtime = publishViewerRuntime();
+ const instance = createStubInstance(wrapper);
+ runtime.registry.set(wrapper, instance);
+
+ await attachInteractionLayer(wrapper);
+ expect(wrapper.querySelector('.tdv-marker--stl')?.getAttribute('aria-label')).toBe('STL');
+ expect(instance.interaction).not.toBeNull();
+ });
+
+ it('reveals the text fallback when the STL scene never produced a mesh', async () => {
+ const wrapper = createWrapper();
+ wrapper.dataset.modelType = 'stl';
+ wrapper.innerHTML = '';
+ withInteraction(wrapper, { enabled: true, markers: [{ id: 'm1' }] });
+ const runtime = publishViewerRuntime();
+ const instance = createStubInstance(wrapper);
+ instance.mesh = null;
+ runtime.registry.set(wrapper, instance);
+
+ // A zero deadline keeps the test fast; production waits 20 seconds.
+ await attachInteractionLayer(wrapper, 0);
+ expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(false);
+ });
+});
+
+describe('bootWrappers', () => {
+ it('returns true and does nothing when there is no wrapper', () => {
+ expect(bootWrappers('')).toBe(true);
+ });
+
+ it('migrates, strips and boots every wrapper it finds', async () => {
+ const wrapper = createWrapper();
+ wrapper.dataset.modelSrc = 'content/resources/a.stl';
+ wrapper.dataset.modelType = 'stl';
+ const modelViewer = createModelViewerStub(wrapper);
+ modelViewer.setAttribute('src', 'content/resources/a.stl');
+
+ expect(bootWrappers('')).toBe(true);
+ await flush();
+
+ expect(modelViewer.hasAttribute('src')).toBe(false);
+ expect(wrapper.dataset.threedBooted).toBe('1');
+ });
+
+ it('does not boot the same wrapper twice', async () => {
+ const wrapper = createWrapper();
+ wrapper.dataset.modelSrc = 'a.glb';
+ createModelViewerStub(wrapper);
+ bootWrappers('');
+ await flush();
+ const first = wrapper.dataset.threedBooted;
+ bootWrappers('');
+ await flush();
+ expect(wrapper.dataset.threedBooted).toBe(first);
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/bootstrap.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/bootstrap.ts
new file mode 100644
index 000000000..da9246644
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/export/bootstrap.ts
@@ -0,0 +1,298 @@
+/**
+ * Booting the wrappers on an exported (or previewed) page: legacy upgrade,
+ * boot-config resolution, viewer construction and interaction attachment.
+ */
+
+import { revealFallback } from '../interactions/fallback';
+import type { InteractionHooks } from '../interactions/types';
+import { getAssetManager } from '../runtime/asset-resolver';
+import { getExportLibBaseUrl, getExportModelViewerUrl, getIdeviceResourcesBase } from '../runtime/paths';
+import { ensureModelViewerLoaded } from '../runtime/model-viewer-loader';
+import { ensureThreeJsLoaded } from '../runtime/three-loader';
+import type { ViewerInstance } from '../runtime/types';
+import { publishViewerRuntime } from '../runtime/viewer-runtime';
+import { DEFAULT_BACKGROUND_COLOR, DEFAULT_MODEL_COLOR, normalizeColor } from '../shared/colors';
+import { normalizeAnimation, normalizeInteraction, normalizeScorm } from '../shared/schema';
+import { detectModelType, isStlSource } from '../shared/model-source';
+import type { InteractionSettings, ScormSettings, ViewerDisplayConfig } from '../shared/types';
+import { resolveRuntimeSrc } from './source-resolver';
+import { setupScormScoring } from './scorm';
+import { ThreeDViewerController } from './viewer-controller';
+
+/** How long to wait for a booted STL mesh before showing the text fallback. */
+const STL_INTERACTION_TIMEOUT_MS = 20000;
+
+/**
+ * Upgrade persisted HTML that still carries the base64 `data-config` payload
+ * (written before the flat `data-*` attributes existed) into the current shape.
+ * Idempotent, silent, and a no-op for wrappers already in the new format.
+ */
+export function migrateLegacyConfig(wrapper: HTMLElement): void {
+ const encoded = wrapper.getAttribute('data-config');
+ if (!encoded) {
+ return;
+ }
+ let config: Record = {};
+ try {
+ config = JSON.parse(decodeURIComponent(escape(atob(encoded)))) as Record;
+ } catch {
+ try {
+ config = JSON.parse(encoded) as Record;
+ } catch {
+ config = {};
+ }
+ }
+ const data = wrapper.dataset;
+ const setIfMissing = (key: string, value: unknown): void => {
+ if (data[key] == null && value != null && value !== '') {
+ data[key] = String(value);
+ }
+ };
+ setIfMissing('modelSrc', config.src);
+ setIfMissing('alt', config.alt);
+ setIfMissing('backgroundColor', config.backgroundColor);
+ if (config.cameraControls != null) {
+ setIfMissing('cameraControls', Boolean(config.cameraControls));
+ }
+ if (config.autoRotate != null) {
+ setIfMissing('autoRotate', Boolean(config.autoRotate));
+ }
+ setIfMissing('autoRotateSpeed', config.autoRotateSpeed);
+ if (config.showNavControls != null) {
+ setIfMissing('showNavControls', Boolean(config.showNavControls));
+ }
+ const animation = config.animation as Record | undefined;
+ if (animation) {
+ if (animation.enabled != null) {
+ setIfMissing('animationEnabled', Boolean(animation.enabled));
+ }
+ setIfMissing('animationName', animation.name);
+ setIfMissing('animationSpeed', animation.speed);
+ }
+ if (!data.modelType && data.modelSrc) {
+ const type = detectModelType(data.modelSrc);
+ if (type !== 'unknown') {
+ data.modelType = type;
+ }
+ }
+ if (!data.modelColor) {
+ data.modelColor = DEFAULT_MODEL_COLOR;
+ }
+ wrapper.removeAttribute('data-config');
+}
+
+/**
+ * Read the boot config from a wrapper's flat `data-*` attributes.
+ *
+ * The attributes are the single source of truth: the exporter rewrites
+ * `asset://uuid.ext` → `content/resources/...` inside `data-model-src`, so the
+ * editor and a static export take the same code path.
+ */
+export function resolveBootConfig(wrapper: HTMLElement): ViewerDisplayConfig {
+ const data = wrapper.dataset;
+ const showNavControls = data.showNavControls === 'true';
+ const rawSrc = (data.modelSrc ?? '').trim();
+ const assetRef = (data.modelAssetRef ?? '').trim();
+ // Prefer the canonical `asset://` handle when AssetManager is live:
+ // `data-model-src` may have been rewritten to a path that only exists inside
+ // an export ZIP, or replaced with a blob URL by the workarea resolver.
+ let src = assetRef && getAssetManager() ? `asset://${assetRef}` : rawSrc;
+ // `data:` would never round-trip; `blob:` is left intact because the browser
+ // can still fetch a live one, which beats showing the empty state.
+ if (src.startsWith('data:')) {
+ src = '';
+ }
+ return {
+ src,
+ type: data.modelType ? (data.modelType as ViewerDisplayConfig['type']) : detectModelType(src),
+ alt: data.alt ?? '',
+ modelColor: normalizeColor(data.modelColor, DEFAULT_MODEL_COLOR),
+ backgroundColor: normalizeColor(data.backgroundColor, DEFAULT_BACKGROUND_COLOR),
+ cameraControls: data.cameraControls !== 'false',
+ autoRotate: !showNavControls && data.autoRotate !== 'false',
+ autoRotateSpeed: Number.parseFloat(data.autoRotateSpeed ?? '') || 30,
+ showNavControls,
+ animation: normalizeAnimation({
+ enabled: data.animationEnabled === 'true',
+ name: data.animationName ?? '',
+ speed: Number.parseFloat(data.animationSpeed ?? '') || 1,
+ }),
+ };
+}
+
+/** Read and parse the JSON interaction block a wrapper carries, if any. */
+export function parseInteractionData(wrapper: HTMLElement): Record | null {
+ const script = wrapper.querySelector('script.tdv-interaction-data');
+ if (!script) {
+ return null;
+ }
+ try {
+ const parsed: unknown = JSON.parse(script.textContent || '{}');
+ return parsed && typeof parsed === 'object' ? (parsed as Record) : null;
+ } catch {
+ return null;
+ }
+}
+
+function buildInteractionHooks(wrapper: HTMLElement, raw: Record): InteractionHooks {
+ const i18n = (raw.i18n && typeof raw.i18n === 'object' ? raw.i18n : {}) as Record;
+ return {
+ t: key => i18n[key] ?? key,
+ resolveMediaUrl: url => {
+ try {
+ return resolveRuntimeSrc(url) || url;
+ } catch {
+ return url;
+ }
+ },
+ };
+}
+
+/** Poll the runtime until the STL mesh exists, or the deadline passes. */
+function waitForStlMesh(wrapper: HTMLElement, timeoutMs: number): Promise {
+ const runtime = publishViewerRuntime();
+ const deadline = Date.now() + timeoutMs;
+ return new Promise(resolve => {
+ const poll = (): void => {
+ const instance = runtime.getInstance(wrapper);
+ if (instance?.mesh || Date.now() >= deadline) {
+ resolve(instance);
+ return;
+ }
+ const raf = globalThis.requestAnimationFrame;
+ if (typeof raf === 'function') {
+ raf(poll);
+ } else {
+ setTimeout(poll, 16);
+ }
+ };
+ poll();
+ });
+}
+
+/**
+ * Attach the shared interaction layer to a booted wrapper. Idempotent: the
+ * wrapper is flagged so a second `renderBehaviour` pass does nothing.
+ */
+export async function attachInteractionLayer(
+ wrapper: HTMLElement,
+ timeoutMs: number = STL_INTERACTION_TIMEOUT_MS,
+): Promise {
+ if (wrapper.dataset.tdvInteractionBooted === '1') {
+ return;
+ }
+ const raw = parseInteractionData(wrapper);
+ if (!raw?.enabled) {
+ return;
+ }
+ wrapper.dataset.tdvInteractionBooted = '1';
+
+ const interaction: InteractionSettings = normalizeInteraction(raw);
+ const scorm: ScormSettings = normalizeScorm(raw.scorm);
+ const hooks = buildInteractionHooks(wrapper, raw);
+ setupScormScoring(wrapper, interaction, scorm, hooks);
+
+ const runtime = publishViewerRuntime();
+ const type = wrapper.dataset.modelType || detectModelType(wrapper.dataset.modelSrc ?? '');
+
+ if (type === 'stl') {
+ const instance = await waitForStlMesh(wrapper, timeoutMs);
+ if (!instance?.mesh) {
+ // No mesh means no WebGL or a failed load: expose the text list.
+ revealFallback(wrapper, true);
+ return;
+ }
+ instance.interaction = runtime.createInteractionLayer(
+ { wrapper, type: 'stl', instance },
+ interaction,
+ 'view',
+ hooks,
+ );
+ return;
+ }
+
+ const modelViewer = wrapper.querySelector('model-viewer');
+ runtime.createInteractionLayer({ wrapper, type, modelViewer }, interaction, 'view', hooks);
+}
+
+/** Every 3D Viewer wrapper reachable from an iDevice scope. */
+export function findWrappers(ideviceId: string): HTMLElement[] {
+ const selector = '.three-d-viewer-wrapper[data-three-d]';
+ let scope: ParentNode = document;
+ if (ideviceId) {
+ scope =
+ document.querySelector(`.idevice_node.three-d-viewer[id="${ideviceId}"]`) ??
+ document.querySelector(`[idevice-id="${ideviceId}"]`) ??
+ document.getElementById(ideviceId) ??
+ document;
+ }
+ const scoped = Array.from(scope.querySelectorAll(selector));
+ if (scoped.length > 0 || scope === document) {
+ return scoped;
+ }
+ return Array.from(document.querySelectorAll(selector));
+}
+
+/**
+ * Strip a stale `src` from any `` inside an STL wrapper, BEFORE
+ * the custom element is defined. The moment model-viewer upgrades it would
+ * fetch that URL and route the ASCII STL bytes through its GLB/GLTF/USDZ
+ * loaders, throwing on the `COLOR=` header.
+ */
+export function stripStlModelViewerSrc(wrapper: HTMLElement): void {
+ const modelViewer = wrapper.querySelector('model-viewer');
+ if (!modelViewer) {
+ return;
+ }
+ const data = wrapper.dataset;
+ const isStl =
+ data.modelType === 'stl' ||
+ isStlSource(data.modelSrc ?? '') ||
+ isStlSource(modelViewer.getAttribute('src') ?? '');
+ if (isStl) {
+ modelViewer.removeAttribute('src');
+ }
+}
+
+/** Boot every wrapper of an iDevice: viewers first, interaction layers after. */
+export function bootWrappers(ideviceId: string): boolean {
+ const wrappers = findWrappers(ideviceId);
+ if (wrappers.length === 0) {
+ return true;
+ }
+
+ wrappers.forEach(migrateLegacyConfig);
+ wrappers.forEach(stripStlModelViewerSrc);
+
+ const modelViewerCandidates = [getExportModelViewerUrl()];
+ const resourcesBase = getIdeviceResourcesBase(ideviceId);
+ if (resourcesBase) {
+ modelViewerCandidates.push(`${resourcesBase}model-viewer.min.js`);
+ }
+
+ void ensureModelViewerLoaded(modelViewerCandidates, 'export').then(() => {
+ for (const wrapper of wrappers) {
+ if (wrapper.dataset.threedBooted === '1') {
+ continue;
+ }
+ wrapper.dataset.threedBooted = '1';
+ void new ThreeDViewerController(wrapper, resolveBootConfig(wrapper)).start();
+ }
+ });
+
+ const interactive = wrappers.filter(wrapper => parseInteractionData(wrapper)?.enabled);
+ if (interactive.length > 0) {
+ // The STL path needs Three.js before markers can project; the GLB path
+ // does not, and `ensureThreeJsLoaded` is a no-op once it is loaded.
+ const needsThree = interactive.some(wrapper => wrapper.dataset.modelType === 'stl');
+ const ready = needsThree ? ensureThreeJsLoaded(getExportLibBaseUrl()) : Promise.resolve();
+ void ready
+ .then(() => Promise.all(interactive.map(attachInteractionLayer)))
+ .catch(() => {
+ for (const wrapper of interactive) {
+ revealFallback(wrapper, true);
+ }
+ });
+ }
+ return true;
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/i18n.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/i18n.ts
new file mode 100644
index 000000000..2fb1afb43
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/export/i18n.ts
@@ -0,0 +1,72 @@
+/**
+ * Translation helpers for the exported page.
+ *
+ * Two vocabularies exist. Chrome strings (`viewer.*`) are translated through
+ * the workarea GUI translator with a built-in English fallback, because an
+ * exported package may run without any translator at all. Learner-facing
+ * micro-strings go through the CONTENT translator (`c_`) so they follow the
+ * language of the content, not the language of the authoring UI.
+ */
+
+/** English fallbacks for the chrome strings, used when no translator answers. */
+export const FALLBACK_TRANSLATIONS: Readonly> = {
+ 'viewer.empty_state': 'Select a 3D model to display',
+ 'viewer.animation_paused': 'Animation paused',
+ 'viewer.animation_enabled': 'Animation enabled',
+ 'viewer.local_warning_title': '3D Viewer not available',
+ 'viewer.local_warning_message':
+ 'The 3D viewer requires a web server to work. Open this content from a web server or use eXeLearning preview.',
+ 'viewer.fullscreen': 'Fullscreen',
+ 'viewer.exit_fullscreen': 'Exit fullscreen',
+ 'viewer.rotate_left': 'Rotate left',
+ 'viewer.rotate_right': 'Rotate right',
+ 'viewer.tilt_up': 'Tilt up',
+ 'viewer.tilt_down': 'Tilt down',
+};
+
+/** Translate a `viewer.*` chrome string. */
+export function translate(key: string): string {
+ try {
+ const translator = globalThis._;
+ if (typeof translator === 'function') {
+ const translated = translator(key);
+ if (translated && translated !== key) {
+ return translated;
+ }
+ }
+ } catch {
+ // A broken translator must never stop the viewer from rendering.
+ }
+ return FALLBACK_TRANSLATIONS[key] ?? key;
+}
+
+/** Translate a learner-facing string, preferring the content translator. */
+export function translateContent(text: string): string {
+ if (typeof globalThis.c_ === 'function') {
+ return globalThis.c_(text);
+ }
+ if (typeof globalThis._ === 'function') {
+ return globalThis._(text);
+ }
+ return text;
+}
+
+/** The micro-strings the interaction controller needs, baked into the export. */
+export function buildRuntimeI18n(): Record {
+ const keys = [
+ 'Marker',
+ 'Close',
+ 'Check',
+ 'Correct',
+ 'Incorrect',
+ 'Previous',
+ 'Next',
+ 'Please select an answer',
+ 'No attempts left',
+ ];
+ const map: Record = {};
+ for (const key of keys) {
+ map[key] = translateContent(key);
+ }
+ return map;
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/index.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/index.ts
new file mode 100644
index 000000000..b7a5466c5
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/export/index.ts
@@ -0,0 +1,31 @@
+/**
+ * Export entry point. Compiled by `scripts/build-idevices.ts` into
+ * `export/three-d-viewer.js` — a self-contained classic-script IIFE.
+ *
+ * It publishes three globals the engine and the existing tests rely on:
+ *
+ * window.$threedviewer the render/boot contract
+ * window.ThreeDViewerExportObject the serialization helper class
+ * window.eXe3DViewer the shared viewer runtime
+ *
+ * The globals are ASSIGNED explicitly rather than left to the bundler's
+ * `globalName`, so the contract is visible in the source and survives any
+ * change of bundling strategy.
+ */
+
+import { publishViewerRuntime } from '../runtime/viewer-runtime';
+import { createExportRuntime, ThreeDViewerExportObject } from './runtime';
+
+const runtime = createExportRuntime();
+
+// The shared viewer runtime is published idempotently: the first bundle on the
+// page owns the single instance registry, any later one reuses it.
+publishViewerRuntime();
+
+(globalThis as { $threedviewer?: unknown }).$threedviewer = runtime;
+(globalThis as { ThreeDViewerExportObject?: unknown }).ThreeDViewerExportObject = ThreeDViewerExportObject;
+
+if (typeof window !== 'undefined') {
+ window.$threedviewer = runtime;
+ window.ThreeDViewerExportObject = ThreeDViewerExportObject;
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.spec.ts
new file mode 100644
index 000000000..37c6d5122
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.spec.ts
@@ -0,0 +1,261 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { makeInteraction, sequentialIds } from '../test/helpers';
+import { normalizeScorm } from '../shared/schema';
+import type { ScormSettings, ViewerDisplayConfig } from '../shared/types';
+import {
+ buildControlsMarkup,
+ buildInteractionFallback,
+ buildInteractionMarkup,
+ buildModelMarkup,
+ buildViewerMarkup,
+ buildWrapperAttributes,
+ computeEmptyStateDisplay,
+} from './renderer';
+
+const NO_SCORM: ScormSettings = normalizeScorm(undefined);
+
+function config(overrides: Partial = {}): ViewerDisplayConfig {
+ return {
+ src: 'asset://a.glb',
+ type: 'glb',
+ alt: '',
+ modelColor: '#888888',
+ backgroundColor: '#f5f5f5',
+ cameraControls: true,
+ autoRotate: false,
+ autoRotateSpeed: 30,
+ showNavControls: false,
+ animation: { enabled: false, name: '', speed: 1 },
+ ...overrides,
+ };
+}
+
+afterEach(() => {
+ globalThis.eXeLearning = undefined;
+});
+
+describe('buildModelMarkup', () => {
+ it('emits a WITHOUT a src, which the runtime sets at boot', () => {
+ const markup = buildModelMarkup(config());
+ expect(markup).toContain(' {
+ const markup = buildModelMarkup(config({ alt: 'A cube' }));
+ expect(markup).toContain('alt="A cube"');
+ expect(markup).toContain('aria-label="A cube"');
+ });
+
+ it('adds the auto-rotation attributes only when enabled', () => {
+ expect(buildModelMarkup(config({ autoRotate: true, autoRotateSpeed: 45 }))).toContain(
+ 'rotation-per-second="45deg"',
+ );
+ expect(buildModelMarkup(config())).not.toContain('auto-rotate');
+ });
+
+ it('escapes the alt text so it cannot break out of the attribute', () => {
+ expect(buildModelMarkup(config({ alt: '">' }))).not.toContain(' ' } } }],
+ },
+ sequentialIds(),
+ );
+ const markup = buildInteractionMarkup(interaction, NO_SCORM);
+ const json = markup.substring(markup.indexOf('>') + 1, markup.indexOf(''));
+ expect(json).not.toContain('');
+ expect(JSON.parse(json)).toBeTruthy();
+ });
+
+ it('keeps asset:// media in the block for the export rewriter, and never blob:', () => {
+ const interaction = makeInteraction(
+ {
+ enabled: true,
+ markers: [
+ { id: 'm1', action: { type: 'image', payload: { src: 'asset://pic.png' } } },
+ { id: 'm2', action: { type: 'image', payload: { src: 'blob:http://x/1' } } },
+ ],
+ },
+ sequentialIds(),
+ );
+ const markup = buildInteractionMarkup(interaction, NO_SCORM);
+ expect(markup).toContain('asset://pic.png');
+ expect(markup).not.toContain('blob:');
+ });
+
+ it('bakes the runtime i18n map and the SCORM configuration into the block', () => {
+ const interaction = makeInteraction({ enabled: true, markers: [{ id: 'm1' }] }, sequentialIds());
+ const markup = buildInteractionMarkup(interaction, normalizeScorm({ mode: 2, weighted: 70 }));
+ const json = JSON.parse(markup.substring(markup.indexOf('>') + 1, markup.indexOf(''))) as {
+ i18n: Record;
+ scorm: ScormSettings;
+ };
+ expect(json.i18n.Check).toBe('Check');
+ expect(json.scorm).toEqual({ mode: 2, weighted: 70, saveButtonText: '' });
+ });
+
+ it('renders the guided controls only in guided mode', () => {
+ const guided = makeInteraction({ enabled: true, guidedMode: true, markers: [{ id: 'm1' }] }, sequentialIds());
+ expect(buildInteractionMarkup(guided, NO_SCORM)).toContain('tdv-guided-nav');
+ const plain = makeInteraction({ enabled: true, markers: [{ id: 'm1' }] }, sequentialIds());
+ expect(buildInteractionMarkup(plain, NO_SCORM)).not.toContain('tdv-guided-nav');
+ });
+});
+
+describe('buildInteractionFallback', () => {
+ it('lists every marker with escaped content', () => {
+ const interaction = makeInteraction(
+ {
+ enabled: true,
+ markers: [
+ {
+ id: 'm1',
+ label: 'Summit',
+ description: 'The top',
+ action: { type: 'information', payload: { html: 'Highest point
' } },
+ },
+ { id: 'm2', action: { type: 'image', payload: { alt: 'Alt', caption: 'Cap' } } },
+ { id: 'm3', action: { type: 'link', payload: { url: 'https://example.org' } } },
+ {
+ id: 'm4',
+ action: {
+ type: 'question',
+ payload: { prompt: 'Q?', options: [{ text: 'A' }, { text: 'B' }] },
+ },
+ },
+ ],
+ },
+ sequentialIds(),
+ );
+ const html = buildInteractionFallback(interaction);
+ expect(html).toContain('1. Summit');
+ expect(html).toContain('The top');
+ expect(html).toContain('Highest point');
+ expect(html).toContain('Alt');
+ expect(html).toContain('Cap');
+ expect(html).toContain('rel="noopener noreferrer"');
+ expect(html).toContain('Q?');
+ expect(html).toContain('A ');
+ expect(html).toContain('hidden');
+ });
+
+ it('never emits an executable link', () => {
+ const interaction = makeInteraction(
+ { enabled: true, markers: [{ id: 'm1', action: { type: 'link', payload: { url: 'javascript:x()' } } }] },
+ sequentialIds(),
+ );
+ expect(buildInteractionFallback(interaction)).not.toContain('javascript:');
+ });
+
+ it('escapes marker text rather than embedding it as markup', () => {
+ const interaction = makeInteraction(
+ { enabled: true, markers: [{ id: 'm1', label: ' ' }] },
+ sequentialIds(),
+ );
+ const html = buildInteractionFallback(interaction);
+ expect(html).not.toContain(' {
+ it('assembles the wrapper with the live region, the empty state and the model', () => {
+ const markup = buildViewerMarkup({
+ viewerId: 'idev-1',
+ config: config(),
+ interaction: makeInteraction({}, sequentialIds()),
+ scorm: NO_SCORM,
+ });
+ expect(markup).toContain('class="three-d-viewer-wrapper"');
+ expect(markup).toContain('id="idev-1"');
+ expect(markup).toContain('data-live aria-live="polite"');
+ expect(markup).toContain('data-empty');
+ expect(markup).toContain(' {
+ it('hides the overlay for every kind of configured source', () => {
+ for (const src of ['asset://a.glb', 'content/resources/a.glb', 'blob:http://x/1', 'https://x/a.glb']) {
+ expect(computeEmptyStateDisplay(src, '')).toBe('none');
+ }
+ });
+
+ it('hides the overlay once model-viewer resolved a source', () => {
+ expect(computeEmptyStateDisplay('', 'blob:http://x/1')).toBe('none');
+ });
+
+ it('shows the overlay when nothing is configured', () => {
+ expect(computeEmptyStateDisplay('', '')).toBe('grid');
+ expect(computeEmptyStateDisplay(' ', ' ')).toBe('grid');
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.ts b/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.ts
new file mode 100644
index 000000000..276e444a6
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/export/renderer.ts
@@ -0,0 +1,217 @@
+/**
+ * Static markup generation for the exported page.
+ *
+ * Everything here is a pure string builder: the same input always produces the
+ * same HTML, which is what makes `renderView` testable without a DOM.
+ *
+ * Two deliberate choices carry over from the interaction design:
+ *
+ * - The `` element ships WITHOUT a `src`. The runtime sets it at
+ * boot from `data-model-src`, so an STL never reaches model-viewer's GLB
+ * loader and no `blob:` URL is ever persisted into the saved HTML.
+ * - Interaction state travels as an escaped JSON `' } },
+ },
+ ]);
+ controller.focusMarker('m1');
+ const dialog = wrapper.querySelector('.tdv-dialog');
+ expect(dialog?.getAttribute('role')).toBe('dialog');
+ expect(dialog?.getAttribute('aria-modal')).toBe('true');
+ expect(dialog?.getAttribute('aria-label')).toBe('Summit');
+ expect(wrapper.querySelector('.tdv-dialog-html')?.innerHTML).toBe('Hi
');
+ });
+
+ it('shows the marker description above the action content', () => {
+ const { wrapper, controller } = build([{ id: 'm1', description: 'A short note' }]);
+ controller.focusMarker('m1');
+ expect(wrapper.querySelector('.tdv-dialog-description')?.textContent).toBe('A short note');
+ });
+
+ it('renders an image action with its alt text and caption', () => {
+ const { wrapper, controller } = build([
+ { id: 'm1', action: { type: 'image', payload: { src: 'a.png', alt: 'Alt', caption: 'Cap' } } },
+ ]);
+ controller.focusMarker('m1');
+ const image = wrapper.querySelector('.tdv-dialog-figure img');
+ expect(image?.getAttribute('src')).toBe('a.png');
+ expect(image?.alt).toBe('Alt');
+ expect(wrapper.querySelector('figcaption')?.textContent).toBe('Cap');
+ });
+
+ it('renders a video action with controls and a poster', () => {
+ const { wrapper, controller } = build([
+ { id: 'm1', action: { type: 'video', payload: { src: 'a.mp4', poster: 'p.png' } } },
+ ]);
+ controller.focusMarker('m1');
+ const video = wrapper.querySelector('.tdv-dialog-video');
+ expect(video?.controls).toBe(true);
+ expect(video?.getAttribute('src')).toBe('a.mp4');
+ expect(video?.poster).toBe('p.png');
+ });
+
+ it('resolves media URLs through the host hook', () => {
+ const resolveMediaUrl = vi.fn((url: string) => `blob:${url}`);
+ const { wrapper, controller } = build(
+ [{ id: 'm1', action: { type: 'image', payload: { src: 'asset://a.png' } } }],
+ { resolveMediaUrl },
+ );
+ controller.focusMarker('m1');
+ expect(resolveMediaUrl).toHaveBeenCalledWith('asset://a.png');
+ expect(wrapper.querySelector('img')?.getAttribute('src')).toBe('blob:asset://a.png');
+ });
+
+ it('opens a safe link in a new tab and never opens an executable one', () => {
+ const open = vi.spyOn(globalThis, 'open').mockImplementation(() => null);
+ const { wrapper, controller } = build([
+ { id: 'm1', action: { type: 'link', payload: { url: 'https://example.org' } } },
+ { id: 'm2', action: { type: 'link', payload: { url: 'javascript:alert(1)' } } },
+ ]);
+ controller.focusMarker('m1');
+ expect(open).toHaveBeenCalledWith('https://example.org', '_blank', 'noopener,noreferrer');
+ expect(wrapper.querySelector('.tdv-dialog')).toBeNull();
+
+ open.mockClear();
+ controller.focusMarker('m2');
+ expect(open).not.toHaveBeenCalled();
+ });
+
+ it('calls the activation hook', () => {
+ const onActivate = vi.fn();
+ const { controller } = build([{ id: 'm1' }], { onActivate });
+ controller.focusMarker('m1');
+ expect(onActivate).toHaveBeenCalledWith('m1');
+ });
+
+ it('ignores a request to focus a marker that does not exist', () => {
+ const { wrapper, controller } = build([{ id: 'm1' }]);
+ controller.focusMarker('ghost');
+ expect(wrapper.querySelector('.tdv-dialog')).toBeNull();
+ expect(controller.getActiveId()).toBe('');
+ });
+});
+
+describe('dialog accessibility', () => {
+ it('moves focus into the dialog and returns it on close', () => {
+ const { wrapper, controller } = build([{ id: 'm1' }]);
+ const trigger = wrapper.querySelector('.tdv-marker');
+ trigger?.focus();
+ controller.focusMarker('m1');
+ expect(document.activeElement?.classList.contains('tdv-dialog-close')).toBe(true);
+ wrapper.querySelector('.tdv-dialog-close')?.click();
+ expect(wrapper.querySelector('.tdv-dialog')).toBeNull();
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it('closes on Escape', () => {
+ const { wrapper, controller } = build([{ id: 'm1' }]);
+ controller.focusMarker('m1');
+ wrapper
+ .querySelector('.tdv-dialog')
+ ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+ expect(wrapper.querySelector('.tdv-dialog')).toBeNull();
+ });
+
+ it('closes when the backdrop is clicked but not when the dialog itself is', () => {
+ const { wrapper, controller } = build([{ id: 'm1' }]);
+ controller.focusMarker('m1');
+ wrapper.querySelector('.tdv-dialog')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(wrapper.querySelector('.tdv-dialog')).not.toBeNull();
+ wrapper.querySelector('.tdv-dialog-overlay')?.dispatchEvent(new MouseEvent('click'));
+ expect(wrapper.querySelector('.tdv-dialog')).toBeNull();
+ });
+
+ it('traps Tab inside the dialog', () => {
+ const { wrapper, controller } = build([
+ {
+ id: 'm1',
+ action: {
+ type: 'question',
+ payload: { prompt: 'Q', options: [{ text: 'a', correct: true }, { text: 'b' }] },
+ },
+ },
+ ]);
+ controller.focusMarker('m1');
+ const dialog = wrapper.querySelector('.tdv-dialog');
+ const focusable = dialog?.querySelectorAll(
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',
+ );
+ const first = focusable?.[0];
+ const last = focusable?.[(focusable?.length ?? 1) - 1];
+ expect(first).toBeDefined();
+ expect(last).toBeDefined();
+
+ last?.focus();
+ dialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
+ expect(document.activeElement).toBe(first);
+
+ first?.focus();
+ dialog?.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
+ );
+ expect(document.activeElement).toBe(last);
+ });
+
+ it('opening another marker replaces the open dialog', () => {
+ const { wrapper, controller } = build([
+ { id: 'm1', label: 'One' },
+ { id: 'm2', label: 'Two' },
+ ]);
+ controller.focusMarker('m1');
+ controller.focusMarker('m2');
+ expect(wrapper.querySelectorAll('.tdv-dialog')).toHaveLength(1);
+ expect(wrapper.querySelector('.tdv-dialog')?.getAttribute('aria-label')).toBe('Two');
+ });
+});
+
+describe('questions', () => {
+ const question = (attemptsAllowed = 0): Record => ({
+ id: 'q1',
+ label: 'Quiz',
+ action: {
+ type: 'question',
+ payload: {
+ prompt: 'Is it a volcano?',
+ options: [
+ { id: 'yes', text: 'Yes', correct: true },
+ { id: 'no', text: 'No', correct: false },
+ ],
+ feedbackCorrect: 'Right!',
+ feedbackIncorrect: 'Nope',
+ attemptsAllowed,
+ },
+ },
+ });
+
+ function answer(wrapper: HTMLElement, optionId: string): void {
+ const input = wrapper.querySelector(`.tdv-question input[value="${optionId}"]`);
+ if (input) {
+ input.checked = true;
+ }
+ wrapper.querySelector('.tdv-q-check')?.click();
+ }
+
+ it('renders an accessible single-choice question', () => {
+ const { wrapper, controller } = build([question()]);
+ controller.focusMarker('q1');
+ expect(wrapper.querySelector('.tdv-question legend')?.textContent).toBe('Is it a volcano?');
+ expect(wrapper.querySelectorAll('.tdv-question input[type="radio"]')).toHaveLength(2);
+ const feedback = wrapper.querySelector('.tdv-q-feedback');
+ expect(feedback?.getAttribute('role')).toBe('status');
+ expect(feedback?.getAttribute('aria-live')).toBe('polite');
+ });
+
+ it('asks for an answer when nothing is selected', () => {
+ const { wrapper, controller } = build([question()]);
+ controller.focusMarker('q1');
+ wrapper.querySelector('.tdv-q-check')?.click();
+ expect(wrapper.querySelector('.tdv-q-feedback')?.textContent).toBe('Please select an answer');
+ });
+
+ it('shows correct feedback and locks the question', () => {
+ const { wrapper, controller } = build([question()]);
+ controller.focusMarker('q1');
+ answer(wrapper, 'yes');
+ const feedback = wrapper.querySelector('.tdv-q-feedback');
+ expect(feedback?.className).toContain('tdv-q-feedback--correct');
+ expect(feedback?.textContent).toBe('Right!');
+ expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true);
+ });
+
+ it('shows incorrect feedback and keeps unlimited attempts open', () => {
+ const { wrapper, controller } = build([question()]);
+ controller.focusMarker('q1');
+ answer(wrapper, 'no');
+ expect(wrapper.querySelector('.tdv-q-feedback')?.className).toContain('tdv-q-feedback--incorrect');
+ expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(false);
+ });
+
+ it('locks the question once the attempt allowance runs out', () => {
+ const { wrapper, controller } = build([question(1)]);
+ controller.focusMarker('q1');
+ answer(wrapper, 'no');
+ expect(wrapper.querySelector('.tdv-q-feedback')?.textContent).toContain('No attempts left');
+ expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true);
+ });
+
+ it('keeps attempts exhausted after closing and reopening the marker', () => {
+ const { wrapper, controller } = build([question(1)]);
+ controller.focusMarker('q1');
+ answer(wrapper, 'no');
+ wrapper.querySelector('.tdv-dialog-close')?.click();
+
+ controller.focusMarker('q1');
+ // The allowance applies to the marker for the whole session, so the
+ // reopened dialog must not hand out a fresh attempt.
+ expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true);
+ expect(wrapper.querySelector('.tdv-q-feedback')?.textContent).toContain('No attempts left');
+ expect(wrapper.querySelectorAll('.tdv-question input')[0]?.disabled).toBe(true);
+ });
+
+ it('keeps a resolved question resolved after reopening, and restores the choice', () => {
+ const { wrapper, controller } = build([question(2)]);
+ controller.focusMarker('q1');
+ answer(wrapper, 'yes');
+ wrapper.querySelector('.tdv-dialog-close')?.click();
+
+ controller.focusMarker('q1');
+ expect(wrapper.querySelector('.tdv-q-feedback')?.className).toContain('tdv-q-feedback--correct');
+ expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true);
+ expect(wrapper.querySelector('.tdv-question input[value="yes"]')?.checked).toBe(true);
+ });
+
+ it('counts attempts across reopens rather than restarting them', () => {
+ const { wrapper, controller } = build([question(2)]);
+ controller.focusMarker('q1');
+ answer(wrapper, 'no');
+ wrapper.querySelector('.tdv-dialog-close')?.click();
+
+ controller.focusMarker('q1');
+ expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(false);
+ answer(wrapper, 'no');
+ expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(true);
+ });
+
+ it('reports every graded answer to the host', () => {
+ const onQuestionAnswered = vi.fn();
+ const { wrapper, controller } = build([question()], { onQuestionAnswered });
+ controller.focusMarker('q1');
+ answer(wrapper, 'no');
+ expect(onQuestionAnswered).toHaveBeenCalledWith('q1', false);
+ answer(wrapper, 'yes');
+ expect(onQuestionAnswered).toHaveBeenLastCalledWith('q1', true);
+ });
+
+ it('survives a host hook that throws', () => {
+ const { wrapper, controller } = build([question()], {
+ onQuestionAnswered: () => {
+ throw new Error('transport down');
+ },
+ });
+ controller.focusMarker('q1');
+ expect(() => answer(wrapper, 'yes')).not.toThrow();
+ expect(wrapper.querySelector('.tdv-q-feedback')?.className).toContain('tdv-q-feedback--correct');
+ });
+
+ it('forgets the answers of a marker the author deleted', () => {
+ const { wrapper, controller } = build([question(1)]);
+ controller.focusMarker('q1');
+ answer(wrapper, 'no');
+ wrapper.querySelector('.tdv-dialog-close')?.click();
+
+ controller.setState(interactionWith([]));
+ controller.setState(interactionWith([question(1)]));
+ controller.focusMarker('q1');
+ expect(wrapper.querySelector('.tdv-q-check')?.disabled).toBe(false);
+ });
+});
+
+describe('guided navigation', () => {
+ const three = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
+
+ it('steps forward and backward through the markers', () => {
+ const { controller } = build(three, {}, { guidedMode: true });
+ controller.next();
+ expect(controller.getActiveId()).toBe('a');
+ controller.next();
+ expect(controller.getActiveId()).toBe('b');
+ controller.previous();
+ expect(controller.getActiveId()).toBe('a');
+ });
+
+ it('stops at the ends without wrapping', () => {
+ const { controller } = build(three, {}, { guidedMode: true });
+ controller.previous();
+ expect(controller.getActiveId()).toBe('c');
+ controller.next();
+ expect(controller.getActiveId()).toBe('c');
+ });
+
+ it('wraps when wrapping is enabled', () => {
+ const { controller } = build(three, {}, { guidedMode: true, wrapNavigation: true });
+ controller.previous();
+ expect(controller.getActiveId()).toBe('c');
+ controller.next();
+ expect(controller.getActiveId()).toBe('a');
+ });
+
+ it('updates the live status as it moves', () => {
+ const { wrapper, controller } = build(three, {}, { guidedMode: true });
+ controller.next();
+ expect(wrapper.querySelector('.tdv-guided-status')?.textContent).toBe('Marker 1 / 3');
+ controller.next();
+ expect(wrapper.querySelector('.tdv-guided-status')?.textContent).toBe('Marker 2 / 3');
+ });
+
+ it('advances exactly one step per click even after repeated setState', () => {
+ const { wrapper, controller } = build(three, {}, { guidedMode: true });
+ controller.setState(interactionWith(three, { guidedMode: true }));
+ controller.setState(interactionWith(three, { guidedMode: true }));
+ wrapper.querySelector('.tdv-nav-next')?.click();
+ expect(controller.getActiveId()).toBe('a');
+ });
+
+ it('does nothing when there are no markers', () => {
+ const { controller } = build([], {}, { guidedMode: true });
+ controller.next();
+ expect(controller.getActiveId()).toBe('');
+ });
+});
+
+describe('state changes and the fallback', () => {
+ it('re-renders markers when the state changes', () => {
+ const { wrapper, controller } = build([{ id: 'a' }]);
+ expect(wrapper.querySelectorAll('.tdv-marker')).toHaveLength(1);
+ controller.setState(interactionWith([{ id: 'a' }, { id: 'b' }]));
+ expect(wrapper.querySelectorAll('.tdv-marker')).toHaveLength(2);
+ });
+
+ it('clears the active marker when the state drops it', () => {
+ const { controller } = build([{ id: 'a' }, { id: 'b' }]);
+ controller.focusMarker('b');
+ expect(controller.getActiveId()).toBe('b');
+ controller.setState(interactionWith([{ id: 'a' }]));
+ expect(controller.getActiveId()).toBe('');
+ });
+
+ it('keeps the text fallback hidden when WebGL is available', () => {
+ const wrapper = createWrapper();
+ wrapper.innerHTML = '';
+ const modelViewer = createModelViewerStub(wrapper);
+ createInteractionController({ wrapper, type: 'glb', modelViewer }, interactionWith([{ id: 'a' }]), 'view', {
+ t: key => key,
+ });
+ expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(true);
+ });
+
+ it('reveals the text fallback when WebGL is unavailable', () => {
+ globalThis.__tdvForceWebGL = false;
+ const wrapper = createWrapper();
+ wrapper.innerHTML = '';
+ const modelViewer = createModelViewerStub(wrapper);
+ createInteractionController({ wrapper, type: 'glb', modelViewer }, interactionWith([{ id: 'a' }]), 'view', {
+ t: key => key,
+ });
+ expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(false);
+ });
+
+ it('reveals the text fallback when no adapter could be built', () => {
+ const wrapper = createWrapper();
+ wrapper.innerHTML = '';
+ createInteractionController({ wrapper, type: 'unknown' }, interactionWith([{ id: 'a' }]), 'view', {
+ t: key => key,
+ });
+ expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(false);
+ });
+});
+
+describe('placement mode', () => {
+ it('enters placement mode in edit mode and reports the anchor', () => {
+ const { wrapper, modelViewer } = mountModelViewer();
+ const onPlaced = vi.fn();
+ const controller = createInteractionController(
+ { wrapper, type: 'glb', modelViewer },
+ interactionWith([]),
+ 'edit',
+ { t: key => key, onPlaced },
+ );
+ controller.enterPlacementMode();
+ expect(wrapper.classList.contains('tdv-placing')).toBe(true);
+ modelViewer.dispatchEvent(new MouseEvent('click'));
+ expect(onPlaced).toHaveBeenCalledTimes(1);
+ expect(wrapper.classList.contains('tdv-placing')).toBe(false);
+ });
+
+ it('never enters placement mode on a learner page', () => {
+ const { wrapper, modelViewer, controller } = build([]);
+ const onPlaced = vi.fn();
+ controller.enterPlacementMode();
+ expect(wrapper.classList.contains('tdv-placing')).toBe(false);
+ modelViewer.dispatchEvent(new MouseEvent('click'));
+ expect(onPlaced).not.toHaveBeenCalled();
+ });
+});
+
+describe('camera capture and teardown', () => {
+ it('delegates camera capture to the adapter', () => {
+ const { controller } = build([{ id: 'a' }]);
+ expect(controller.captureCamera()).toEqual({ orbit: '1rad 2rad 3m', target: '0m 0m 0m', fieldOfView: '40deg' });
+ });
+
+ it('returns an empty camera view when there is no adapter', () => {
+ const wrapper = createWrapper();
+ const controller = createInteractionController({ wrapper, type: 'unknown' }, interactionWith([]), 'view');
+ expect(controller.captureCamera()).toEqual({ orbit: '', target: '', fieldOfView: '' });
+ });
+
+ it('removes markers, the dialog and the nav controls on destroy', () => {
+ const { wrapper, controller } = build([{ id: 'a' }], {}, { guidedMode: true });
+ controller.focusMarker('a');
+ expect(wrapper.querySelector('.tdv-dialog')).not.toBeNull();
+ controller.destroy();
+ expect(wrapper.querySelector('.tdv-dialog')).toBeNull();
+ expect(wrapper.querySelector('.tdv-marker')).toBeNull();
+ expect(wrapper.querySelector('.tdv-guided-nav')).toBeNull();
+ // Destroying twice, and rendering afterwards, are both no-ops.
+ expect(() => controller.destroy()).not.toThrow();
+ controller.render();
+ expect(wrapper.querySelector('.tdv-marker')).toBeNull();
+ });
+
+ it('keeps two controllers on one page isolated', () => {
+ const first = build([{ id: 'a' }, { id: 'b' }], {}, { guidedMode: true });
+ const second = build([{ id: 'c' }], {}, { guidedMode: true });
+ first.controller.next();
+ expect(first.controller.getActiveId()).toBe('a');
+ expect(second.controller.getActiveId()).toBe('');
+ first.controller.destroy();
+ expect(second.wrapper.querySelectorAll('.tdv-marker')).toHaveLength(1);
+ });
+});
+
+describe('the STL render path', () => {
+ it('builds the STL adapter from a viewer instance', () => {
+ const wrapper = createWrapper();
+ const instance = createStubInstance(wrapper);
+ const controller = createInteractionController(
+ { wrapper, type: 'stl', instance },
+ interactionWith([{ id: 'a', label: 'STL marker' }]),
+ 'view',
+ { t: key => key },
+ );
+ expect(wrapper.querySelector('.tdv-marker--stl')?.getAttribute('aria-label')).toBe('STL marker');
+ controller.destroy();
+ expect(wrapper.querySelector('.tdv-marker-layer')).toBeNull();
+ });
+});
+
+describe('marker labels', () => {
+ it('falls back to a numbered label when the marker has none', () => {
+ const { wrapper } = build([{ id: 'a' }, { id: 'b', label: 'Named' }]);
+ const labels = [...wrapper.querySelectorAll('.tdv-marker')].map(node => node.getAttribute('aria-label'));
+ expect(labels).toEqual(['Marker 1', 'Named']);
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/controller.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/controller.ts
new file mode 100644
index 000000000..73dae520a
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/controller.ts
@@ -0,0 +1,285 @@
+/**
+ * The renderer-agnostic interaction controller.
+ *
+ * It owns everything that must behave identically on both render paths and in
+ * both hosts (editor preview and exported page): active-marker state, marker
+ * activation, the accessible dialog, question rendering, learner answer state,
+ * guided navigation and the text fallback. Renderers are reached only through
+ * the `MarkerAdapter` contract.
+ */
+
+import { createModelViewerAdapter } from '../adapters/model-viewer-adapter';
+import { createStlAdapter } from '../adapters/stl-adapter';
+import { resolveMediaUrlSync } from '../runtime/asset-resolver';
+import type { ViewerInstance } from '../runtime/types';
+import { sanitizeHtml as defaultSanitizeHtml } from '../shared/html';
+import type { InteractionSettings, Marker, MarkerCamera } from '../shared/types';
+import { safeUrl } from '../shared/urls';
+import { openDialog, type DialogHandle } from './dialog';
+import { hasWebGL, revealFallback } from './fallback';
+import { createGuidedNavigation, resolveStepIndex, type GuidedNavigationView } from './guided-navigation';
+import { renderQuestion } from './question';
+import { createAnswerStore } from './state';
+import type {
+ InteractionController,
+ InteractionHandle,
+ InteractionHooks,
+ InteractionMode,
+ MarkerAdapter,
+} from './types';
+
+const EMPTY_CAMERA: MarkerCamera = { orbit: '', target: '', fieldOfView: '' };
+
+function emptyState(): InteractionSettings {
+ return {
+ enabled: false,
+ guidedMode: false,
+ wrapNavigation: false,
+ showMarkerLabels: true,
+ activeMarkerId: '',
+ markers: [],
+ };
+}
+
+function buildActionBody(
+ body: HTMLElement,
+ marker: Marker,
+ deps: { sanitize: (html: string) => string; resolveMedia: (url: string) => string },
+): void {
+ if (marker.description) {
+ const description = document.createElement('p');
+ description.className = 'tdv-dialog-description';
+ description.textContent = marker.description;
+ body.appendChild(description);
+ }
+ const action = marker.action;
+ switch (action.type) {
+ case 'information': {
+ const container = document.createElement('div');
+ container.className = 'tdv-dialog-html';
+ container.innerHTML = deps.sanitize(action.payload.html);
+ body.appendChild(container);
+ return;
+ }
+ case 'image': {
+ const figure = document.createElement('figure');
+ figure.className = 'tdv-dialog-figure';
+ const image = document.createElement('img');
+ image.src = deps.resolveMedia(action.payload.src);
+ image.alt = action.payload.alt;
+ figure.appendChild(image);
+ if (action.payload.caption) {
+ const caption = document.createElement('figcaption');
+ caption.textContent = action.payload.caption;
+ figure.appendChild(caption);
+ }
+ body.appendChild(figure);
+ return;
+ }
+ case 'video': {
+ const video = document.createElement('video');
+ video.className = 'tdv-dialog-video';
+ video.controls = true;
+ video.src = deps.resolveMedia(action.payload.src);
+ if (action.payload.poster) {
+ video.poster = deps.resolveMedia(action.payload.poster);
+ }
+ body.appendChild(video);
+ return;
+ }
+ case 'link':
+ case 'question':
+ // `link` never opens a dialog (handled before this runs) and
+ // `question` is rendered by the caller, which owns answer state.
+ return;
+ }
+}
+
+/**
+ * Build a controller for one viewer.
+ *
+ * `handle.type` selects the adapter; when no adapter can be built (an unknown
+ * renderer, or an STL instance that never produced a mesh) the controller still
+ * works — it simply reveals the accessible text fallback instead of an overlay.
+ */
+export function createInteractionController(
+ handle: InteractionHandle,
+ interaction: InteractionSettings,
+ mode: InteractionMode,
+ hooks: InteractionHooks = {},
+): InteractionController {
+ const wrapper = handle.wrapper;
+ const translate = hooks.t ?? ((key: string) => key);
+ const resolveMedia = hooks.resolveMediaUrl ?? (url => resolveMediaUrlSync(url));
+ const sanitize = hooks.sanitizeHtml ?? defaultSanitizeHtml;
+ const answers = createAnswerStore();
+
+ let state: InteractionSettings = interaction ?? emptyState();
+ let markers: readonly Marker[] = state.markers;
+ let activeId = '';
+ let destroyed = false;
+ let dialog: DialogHandle | null = null;
+ let adapter: MarkerAdapter | null = null;
+ let guided: GuidedNavigationView | null = null;
+
+ const markerLabel = (marker: Marker, index: number): string =>
+ marker.label || `${translate('Marker')} ${index + 1}`;
+
+ const closeDialog = (): void => {
+ dialog?.close();
+ dialog = null;
+ };
+
+ const currentIndex = (): number => markers.findIndex(marker => marker.id === activeId);
+
+ const updateGuided = (): void => {
+ guided?.update({
+ enabled: Boolean(state.guidedMode),
+ index: currentIndex(),
+ total: markers.length,
+ wrap: Boolean(state.wrapNavigation),
+ });
+ };
+
+ const setActive = (markerId: string): void => {
+ activeId = markerId;
+ adapter?.setActive(activeId);
+ updateGuided();
+ };
+
+ const activateMarker = (marker: Marker, index: number): void => {
+ if (marker.action.type === 'link') {
+ const url = safeUrl(marker.action.payload.url);
+ if (!url) {
+ return;
+ }
+ if (marker.action.payload.newTab) {
+ globalThis.open(url, '_blank', 'noopener,noreferrer');
+ } else if (globalThis.location) {
+ globalThis.location.href = url;
+ }
+ return;
+ }
+ closeDialog();
+ dialog = openDialog(
+ {
+ title: markerLabel(marker, index),
+ closeLabel: translate('Close'),
+ host: wrapper ?? null,
+ onClose: () => {
+ dialog = null;
+ },
+ },
+ body => {
+ buildActionBody(body, marker, { sanitize, resolveMedia });
+ if (marker.action.type === 'question') {
+ renderQuestion(body, marker, {
+ answers,
+ t: translate,
+ onAnswered: hooks.onQuestionAnswered,
+ });
+ }
+ },
+ );
+ hooks.onActivate?.(marker.id);
+ };
+
+ const focusMarker = (markerId: string): void => {
+ const index = markers.findIndex(marker => marker.id === markerId);
+ const marker = markers[index];
+ if (!marker) {
+ return;
+ }
+ setActive(markerId);
+ adapter?.focusMarker(marker);
+ activateMarker(marker, index);
+ };
+
+ const go = (delta: number): void => {
+ const next = resolveStepIndex(currentIndex(), delta, markers.length, Boolean(state.wrapNavigation));
+ const marker = next === null ? undefined : markers[next];
+ if (marker) {
+ focusMarker(marker.id);
+ }
+ };
+
+ const render = (): void => {
+ if (destroyed) {
+ return;
+ }
+ markers = state.markers;
+ if (adapter) {
+ adapter.renderMarkers(markers, {
+ showLabels: state.showMarkerLabels !== false,
+ activeId,
+ });
+ // Keep the text fallback visible without WebGL so assistive-tech and
+ // no-WebGL users still reach the marker content.
+ revealFallback(wrapper, !hasWebGL());
+ } else {
+ revealFallback(wrapper, true);
+ }
+ updateGuided();
+ };
+
+ const controller: InteractionController = {
+ setState(next) {
+ state = next ?? emptyState();
+ const ids = state.markers.map(marker => marker.id);
+ if (activeId && !ids.includes(activeId)) {
+ activeId = '';
+ }
+ // Answers of deleted markers are dropped so a re-created marker with
+ // a fresh id starts clean.
+ answers.retain(ids);
+ render();
+ },
+ render,
+ enterPlacementMode() {
+ // Placement is an authoring affordance; a learner page never enters it.
+ if (!adapter || mode !== 'edit') {
+ return;
+ }
+ wrapper?.classList.add('tdv-placing');
+ adapter.enterPlacementMode(placement => {
+ controller.exitPlacementMode();
+ hooks.onPlaced?.(placement);
+ });
+ },
+ exitPlacementMode() {
+ wrapper?.classList.remove('tdv-placing');
+ adapter?.exitPlacementMode();
+ },
+ focusMarker,
+ captureCamera: () => adapter?.captureCamera() ?? { ...EMPTY_CAMERA },
+ next: () => go(1),
+ previous: () => go(-1),
+ getActiveId: () => activeId,
+ markerLabel,
+ destroy() {
+ if (destroyed) {
+ return;
+ }
+ destroyed = true;
+ controller.exitPlacementMode();
+ closeDialog();
+ guided?.destroy();
+ guided = null;
+ adapter?.destroy();
+ adapter = null;
+ answers.clear();
+ },
+ };
+
+ const adapterDeps = { markerLabel, onActivate: focusMarker };
+ if ((handle.type === 'glb' || handle.type === 'gltf') && handle.modelViewer) {
+ adapter = createModelViewerAdapter(handle.modelViewer, adapterDeps);
+ } else if (handle.type === 'stl' && handle.instance) {
+ adapter = createStlAdapter(handle.instance as ViewerInstance, wrapper, adapterDeps);
+ }
+
+ guided = createGuidedNavigation(wrapper ?? null, { t: translate, onGo: go });
+
+ render();
+ return controller;
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/dialog.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/dialog.ts
new file mode 100644
index 000000000..e62abcf96
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/dialog.ts
@@ -0,0 +1,129 @@
+/**
+ * The accessible marker dialog: a modal with a focus trap, Escape handling and
+ * focus return. One dialog is open at a time per controller.
+ */
+
+const FOCUSABLE_SELECTOR =
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
+
+/** Focusable descendants that are actually reachable right now. */
+export function getFocusable(container: HTMLElement): HTMLElement[] {
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
+ element => element.offsetParent !== null || element === document.activeElement,
+ );
+}
+
+export interface DialogHandle {
+ readonly overlay: HTMLElement;
+ readonly dialog: HTMLElement;
+ readonly body: HTMLElement;
+ close(): void;
+}
+
+export interface DialogOptions {
+ title: string;
+ closeLabel: string;
+ /** Where the overlay is appended; defaults to `document.body`. */
+ host?: HTMLElement | null;
+ onClose?: () => void;
+}
+
+/**
+ * Open a modal dialog and fill its body through `buildBody`.
+ *
+ * Returns a handle whose `close()` removes the dialog and restores focus to
+ * whatever had it before. The caller owns the handle and must close it on
+ * teardown; nothing here registers a global listener.
+ */
+export function openDialog(options: DialogOptions, buildBody: (body: HTMLElement) => void): DialogHandle {
+ const previouslyFocused = document.activeElement;
+
+ const overlay = document.createElement('div');
+ overlay.className = 'tdv-dialog-overlay';
+
+ const dialog = document.createElement('div');
+ dialog.className = 'tdv-dialog';
+ dialog.setAttribute('role', 'dialog');
+ dialog.setAttribute('aria-modal', 'true');
+ dialog.setAttribute('aria-label', options.title);
+
+ const header = document.createElement('div');
+ header.className = 'tdv-dialog-header';
+ const heading = document.createElement('h2');
+ heading.className = 'tdv-dialog-title';
+ heading.textContent = options.title;
+ const closeButton = document.createElement('button');
+ closeButton.type = 'button';
+ closeButton.className = 'tdv-dialog-close';
+ closeButton.setAttribute('aria-label', options.closeLabel);
+ closeButton.textContent = '✕';
+ header.append(heading, closeButton);
+
+ const body = document.createElement('div');
+ body.className = 'tdv-dialog-body';
+
+ dialog.append(header, body);
+ overlay.appendChild(dialog);
+ (options.host ?? document.body).appendChild(overlay);
+
+ buildBody(body);
+
+ let closed = false;
+ const close = (): void => {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ try {
+ overlay.remove();
+ } catch {
+ // Already detached, e.g. the wrapper was replaced.
+ }
+ if (previouslyFocused instanceof HTMLElement) {
+ try {
+ previouslyFocused.focus();
+ } catch {
+ // The previously focused node may have gone away.
+ }
+ }
+ options.onClose?.();
+ };
+
+ closeButton.addEventListener('click', close);
+ overlay.addEventListener('click', event => {
+ if (event.target === overlay) {
+ close();
+ }
+ });
+ dialog.addEventListener('keydown', event => {
+ if (event.key === 'Escape') {
+ event.stopPropagation();
+ close();
+ return;
+ }
+ if (event.key !== 'Tab') {
+ return;
+ }
+ const focusable = getFocusable(dialog);
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (!first || !last) {
+ return;
+ }
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ });
+
+ try {
+ closeButton.focus();
+ } catch {
+ // happy-dom and detached hosts can refuse focus; not fatal.
+ }
+
+ return { overlay, dialog, body, close };
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.spec.ts
new file mode 100644
index 000000000..338ea4f66
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.spec.ts
@@ -0,0 +1,67 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createWrapper, resetDom } from '../test/helpers';
+import { hasWebGL, resetWebGLProbe, revealFallback } from './fallback';
+
+beforeEach(resetWebGLProbe);
+
+afterEach(() => {
+ globalThis.__tdvForceWebGL = undefined;
+ resetWebGLProbe();
+ resetDom();
+ vi.restoreAllMocks();
+});
+
+describe('hasWebGL', () => {
+ it('honours the deterministic test override in both directions', () => {
+ globalThis.__tdvForceWebGL = false;
+ expect(hasWebGL()).toBe(false);
+ globalThis.__tdvForceWebGL = true;
+ expect(hasWebGL()).toBe(true);
+ });
+
+ it('probes a canvas context and memoizes the answer', () => {
+ const getContext = vi.fn(() => ({}) as unknown as RenderingContext);
+ vi.spyOn(document, 'createElement').mockImplementation(() => ({ getContext }) as unknown as HTMLCanvasElement);
+ expect(hasWebGL()).toBe(true);
+ expect(hasWebGL()).toBe(true);
+ // Memoized: the probe runs once even across repeated calls.
+ expect(getContext).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to experimental-webgl before giving up', () => {
+ const getContext = vi.fn((name: string) => (name === 'experimental-webgl' ? ({} as RenderingContext) : null));
+ vi.spyOn(document, 'createElement').mockImplementation(() => ({ getContext }) as unknown as HTMLCanvasElement);
+ expect(hasWebGL()).toBe(true);
+ expect(getContext).toHaveBeenCalledTimes(2);
+ });
+
+ it('reports false when no context can be created', () => {
+ vi.spyOn(document, 'createElement').mockImplementation(
+ () => ({ getContext: () => null }) as unknown as HTMLCanvasElement,
+ );
+ expect(hasWebGL()).toBe(false);
+ });
+
+ it('reports false when probing throws', () => {
+ vi.spyOn(document, 'createElement').mockImplementation(() => {
+ throw new Error('no canvas');
+ });
+ expect(hasWebGL()).toBe(false);
+ });
+});
+
+describe('revealFallback', () => {
+ it('shows and hides the static marker list', () => {
+ const wrapper = createWrapper();
+ wrapper.innerHTML = '';
+ revealFallback(wrapper, true);
+ expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(false);
+ revealFallback(wrapper, false);
+ expect(wrapper.querySelector('.tdv-fallback')?.hidden).toBe(true);
+ });
+
+ it('is a no-op without a wrapper or without a list', () => {
+ expect(() => revealFallback(null, true)).not.toThrow();
+ expect(() => revealFallback(createWrapper(), true)).not.toThrow();
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.ts
new file mode 100644
index 000000000..fdde1ba04
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.ts
@@ -0,0 +1,52 @@
+/**
+ * The accessible text fallback.
+ *
+ * Exported pages ship a static, escaped ``
+ * listing every marker. It is revealed whenever the interactive overlay cannot
+ * render — no WebGL, a failed STL boot, or no usable adapter — so marker content
+ * is never lost to a rendering problem.
+ */
+
+/** Memoized WebGL probe result; `null` until first asked. */
+let webglAvailable: boolean | null = null;
+
+/** Reset the memoized probe (tests only). */
+export function resetWebGLProbe(): void {
+ webglAvailable = null;
+}
+
+/**
+ * Whether this page can create a WebGL context.
+ *
+ * `window.__tdvForceWebGL` overrides the probe, which is what makes the
+ * fallback behaviour deterministic under happy-dom (no WebGL) and Playwright.
+ */
+export function hasWebGL(): boolean {
+ if (typeof globalThis.__tdvForceWebGL === 'boolean') {
+ return globalThis.__tdvForceWebGL;
+ }
+ if (webglAvailable !== null) {
+ return webglAvailable;
+ }
+ try {
+ if (typeof document === 'undefined' || typeof document.createElement !== 'function') {
+ webglAvailable = true;
+ return webglAvailable;
+ }
+ const canvas = document.createElement('canvas');
+ webglAvailable = Boolean(
+ canvas.getContext && (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')),
+ );
+ } catch {
+ webglAvailable = false;
+ }
+ return webglAvailable;
+}
+
+/** Show or hide the static marker list inside a wrapper. */
+export function revealFallback(wrapper: HTMLElement | null, show: boolean): void {
+ const list = wrapper?.querySelector('.tdv-fallback');
+ if (list) {
+ list.hidden = !show;
+ }
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/guided-navigation.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/guided-navigation.spec.ts
new file mode 100644
index 000000000..5392e7dd4
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/guided-navigation.spec.ts
@@ -0,0 +1,145 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { createWrapper, resetDom } from '../test/helpers';
+import { createGuidedNavigation, resolveStepIndex } from './guided-navigation';
+
+afterEach(resetDom);
+
+describe('resolveStepIndex', () => {
+ it('returns null when there is nothing to navigate', () => {
+ expect(resolveStepIndex(-1, 1, 0, false)).toBeNull();
+ });
+
+ it('starts at the first marker for next and the last for previous', () => {
+ expect(resolveStepIndex(-1, 1, 3, false)).toBe(0);
+ expect(resolveStepIndex(-1, -1, 3, false)).toBe(2);
+ });
+
+ it('steps forwards and backwards', () => {
+ expect(resolveStepIndex(0, 1, 3, false)).toBe(1);
+ expect(resolveStepIndex(2, -1, 3, false)).toBe(1);
+ });
+
+ it('stops at the ends without wrapping', () => {
+ expect(resolveStepIndex(2, 1, 3, false)).toBeNull();
+ expect(resolveStepIndex(0, -1, 3, false)).toBeNull();
+ });
+
+ it('wraps around both ends when wrapping is on', () => {
+ expect(resolveStepIndex(2, 1, 3, true)).toBe(0);
+ expect(resolveStepIndex(0, -1, 3, true)).toBe(2);
+ });
+});
+
+describe('createGuidedNavigation', () => {
+ const t = (key: string): string => key;
+
+ it('creates the controls when the markup did not bake them in', () => {
+ const wrapper = createWrapper();
+ const view = createGuidedNavigation(wrapper, { t, onGo: vi.fn() });
+ view.update({ enabled: true, index: 0, total: 2, wrap: false });
+ const nav = wrapper.querySelector('.tdv-guided-nav');
+ expect(nav).not.toBeNull();
+ expect(nav?.hidden).toBe(false);
+ expect(nav?.querySelector('.tdv-nav-prev')?.textContent).toBe('Previous');
+ expect(nav?.querySelector('.tdv-guided-status')?.getAttribute('aria-live')).toBe('polite');
+ });
+
+ it('reuses controls the export markup already shipped', () => {
+ const wrapper = createWrapper();
+ wrapper.innerHTML =
+ '' +
+ 'Anterior ' +
+ ' ' +
+ 'Siguiente
';
+ const view = createGuidedNavigation(wrapper, { t, onGo: vi.fn() });
+ view.update({ enabled: true, index: 0, total: 2, wrap: false });
+ expect(wrapper.querySelectorAll('.tdv-guided-nav')).toHaveLength(1);
+ // The baked translations are kept.
+ expect(wrapper.querySelector('.tdv-nav-prev')?.textContent).toBe('Anterior');
+ });
+
+ it('hides the controls when guided mode is off', () => {
+ const wrapper = createWrapper();
+ const view = createGuidedNavigation(wrapper, { t, onGo: vi.fn() });
+ view.update({ enabled: true, index: 0, total: 2, wrap: false });
+ view.update({ enabled: false, index: 0, total: 2, wrap: false });
+ expect(wrapper.querySelector('.tdv-guided-nav')?.hidden).toBe(true);
+ });
+
+ it('disables the ends when wrapping is off', () => {
+ const wrapper = createWrapper();
+ const view = createGuidedNavigation(wrapper, { t, onGo: vi.fn() });
+ view.update({ enabled: true, index: 0, total: 3, wrap: false });
+ expect(wrapper.querySelector('.tdv-nav-prev')?.disabled).toBe(true);
+ expect(wrapper.querySelector('.tdv-nav-next')?.disabled).toBe(false);
+
+ view.update({ enabled: true, index: 2, total: 3, wrap: false });
+ expect(wrapper.querySelector('.tdv-nav-next')?.disabled).toBe(true);
+ });
+
+ it('keeps both directions available when wrapping is on', () => {
+ const wrapper = createWrapper();
+ const view = createGuidedNavigation(wrapper, { t, onGo: vi.fn() });
+ view.update({ enabled: true, index: 0, total: 3, wrap: true });
+ expect(wrapper.querySelector('.tdv-nav-prev')?.disabled).toBe(false);
+ expect(wrapper.querySelector('.tdv-nav-next')?.disabled).toBe(false);
+ });
+
+ it('disables both buttons when there are no markers', () => {
+ const wrapper = createWrapper();
+ const view = createGuidedNavigation(wrapper, { t, onGo: vi.fn() });
+ view.update({ enabled: true, index: -1, total: 0, wrap: true });
+ expect(wrapper.querySelector('.tdv-nav-prev')?.disabled).toBe(true);
+ expect(wrapper.querySelector('.tdv-nav-next')?.disabled).toBe(true);
+ });
+
+ it('announces the position, showing 0 before anything is selected', () => {
+ const wrapper = createWrapper();
+ const view = createGuidedNavigation(wrapper, { t, onGo: vi.fn() });
+ view.update({ enabled: true, index: -1, total: 3, wrap: false });
+ expect(wrapper.querySelector('.tdv-guided-status')?.textContent).toBe('Marker 0 / 3');
+ view.update({ enabled: true, index: 1, total: 3, wrap: false });
+ expect(wrapper.querySelector('.tdv-guided-status')?.textContent).toBe('Marker 2 / 3');
+ });
+
+ it('binds the click handlers exactly once across repeated updates', () => {
+ const wrapper = createWrapper();
+ const onGo = vi.fn();
+ const view = createGuidedNavigation(wrapper, { t, onGo });
+ for (let i = 0; i < 3; i += 1) {
+ view.update({ enabled: true, index: 0, total: 3, wrap: true });
+ }
+ wrapper.querySelector('.tdv-nav-next')?.click();
+ expect(onGo).toHaveBeenCalledTimes(1);
+ expect(onGo).toHaveBeenCalledWith(1);
+ wrapper.querySelector('.tdv-nav-prev')?.click();
+ expect(onGo).toHaveBeenLastCalledWith(-1);
+ });
+
+ it('removes the controls it created and stops listening on destroy', () => {
+ const wrapper = createWrapper();
+ const onGo = vi.fn();
+ const view = createGuidedNavigation(wrapper, { t, onGo });
+ view.update({ enabled: true, index: 0, total: 3, wrap: true });
+ const next = wrapper.querySelector('.tdv-nav-next');
+ view.destroy();
+ expect(wrapper.querySelector('.tdv-guided-nav')).toBeNull();
+ next?.click();
+ expect(onGo).not.toHaveBeenCalled();
+ });
+
+ it('leaves controls it did not create in place on destroy', () => {
+ const wrapper = createWrapper();
+ wrapper.innerHTML = '
';
+ const view = createGuidedNavigation(wrapper, { t, onGo: vi.fn() });
+ view.update({ enabled: true, index: 0, total: 1, wrap: false });
+ view.destroy();
+ expect(wrapper.querySelector('.tdv-guided-nav')).not.toBeNull();
+ });
+
+ it('does nothing without a wrapper', () => {
+ const view = createGuidedNavigation(null, { t, onGo: vi.fn() });
+ expect(() => view.update({ enabled: true, index: 0, total: 1, wrap: false })).not.toThrow();
+ expect(() => view.destroy()).not.toThrow();
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/guided-navigation.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/guided-navigation.ts
new file mode 100644
index 000000000..73b61067a
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/guided-navigation.ts
@@ -0,0 +1,147 @@
+/**
+ * Guided navigation: previous/next controls plus the live position status.
+ *
+ * The index arithmetic is a pure function so wrapping, clamping and the
+ * "nothing selected yet" case are testable without any DOM.
+ */
+
+export interface GuidedNavigationDeps {
+ t: (key: string) => string;
+ onGo: (delta: number) => void;
+}
+
+export interface GuidedNavigationView {
+ /** Show/hide the controls and refresh their disabled state and status text. */
+ update(options: { enabled: boolean; index: number; total: number; wrap: boolean }): void;
+ destroy(): void;
+}
+
+/**
+ * Resolve the marker index a previous/next step lands on.
+ *
+ * Returns `null` when the step is not possible (empty list, or an edge without
+ * wrapping). With nothing selected, "next" starts at the first marker and
+ * "previous" starts at the last.
+ */
+export function resolveStepIndex(current: number, delta: number, total: number, wrap: boolean): number | null {
+ if (total <= 0) {
+ return null;
+ }
+ const start = current < 0 ? (delta > 0 ? -1 : total) : current;
+ const next = start + delta;
+ if (wrap) {
+ return ((next % total) + total) % total;
+ }
+ return next < 0 || next >= total ? null : next;
+}
+
+function buildControls(t: (key: string) => string): HTMLElement {
+ const nav = document.createElement('div');
+ nav.className = 'tdv-guided-nav';
+ nav.setAttribute('data-guided', '');
+ const previous = document.createElement('button');
+ previous.type = 'button';
+ previous.className = 'tdv-nav-prev';
+ previous.textContent = t('Previous');
+ const status = document.createElement('span');
+ status.className = 'tdv-guided-status';
+ status.setAttribute('aria-live', 'polite');
+ const next = document.createElement('button');
+ next.type = 'button';
+ next.className = 'tdv-nav-next';
+ next.textContent = t('Next');
+ nav.append(previous, status, next);
+ return nav;
+}
+
+/**
+ * Attach to the guided-nav controls of a wrapper, creating them when the export
+ * markup did not bake them in (the editor preview).
+ *
+ * Click handlers are bound exactly once, even though `update()` runs on every
+ * render — re-binding would make one click advance several markers.
+ */
+export function createGuidedNavigation(wrapper: HTMLElement | null, deps: GuidedNavigationDeps): GuidedNavigationView {
+ let nav: HTMLElement | null = wrapper?.querySelector('.tdv-guided-nav') ?? null;
+ let created = false;
+ const listeners: Array<() => void> = [];
+
+ const ensureNav = (): HTMLElement | null => {
+ if (nav || !wrapper) {
+ return nav;
+ }
+ nav = buildControls(deps.t);
+ created = true;
+ wrapper.appendChild(nav);
+ return nav;
+ };
+
+ const bindOnce = (element: HTMLElement): void => {
+ if (element.dataset.tdvBound === '1') {
+ return;
+ }
+ element.dataset.tdvBound = '1';
+ const previousButton = element.querySelector('.tdv-nav-prev');
+ const nextButton = element.querySelector('.tdv-nav-next');
+ if (previousButton) {
+ const handler = (): void => deps.onGo(-1);
+ previousButton.addEventListener('click', handler);
+ listeners.push(() => previousButton.removeEventListener('click', handler));
+ }
+ if (nextButton) {
+ const handler = (): void => deps.onGo(1);
+ nextButton.addEventListener('click', handler);
+ listeners.push(() => nextButton.removeEventListener('click', handler));
+ }
+ };
+
+ return {
+ update({ enabled, index, total, wrap }) {
+ if (!enabled) {
+ if (nav) {
+ nav.hidden = true;
+ }
+ return;
+ }
+ const element = ensureNav();
+ if (!element) {
+ return;
+ }
+ element.hidden = false;
+ const previousButton = element.querySelector('.tdv-nav-prev');
+ const nextButton = element.querySelector('.tdv-nav-next');
+ if (previousButton && !previousButton.textContent) {
+ previousButton.textContent = deps.t('Previous');
+ }
+ if (nextButton && !nextButton.textContent) {
+ nextButton.textContent = deps.t('Next');
+ }
+ bindOnce(element);
+ const empty = total === 0;
+ if (previousButton) {
+ previousButton.disabled = empty || (!wrap && index <= 0);
+ }
+ if (nextButton) {
+ nextButton.disabled = empty || (!wrap && index >= total - 1);
+ }
+ const status = element.querySelector('.tdv-guided-status');
+ if (status) {
+ status.textContent = `${deps.t('Marker')} ${index < 0 ? 0 : index + 1} / ${total}`;
+ }
+ },
+ destroy() {
+ for (const off of listeners) {
+ off();
+ }
+ listeners.length = 0;
+ if (created && nav) {
+ try {
+ nav.remove();
+ } catch {
+ // Wrapper already gone.
+ }
+ }
+ nav = null;
+ },
+ };
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/marker-renderer.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/marker-renderer.ts
new file mode 100644
index 000000000..e9f73e2d0
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/marker-renderer.ts
@@ -0,0 +1,61 @@
+/**
+ * The marker button. Both adapters build the same accessible element so a
+ * marker looks and reads identically on the GLB/GLTF and STL paths; only the
+ * positioning mechanism differs.
+ */
+
+import type { Marker } from '../shared/types';
+import type { MarkerRenderOptions } from './types';
+
+export interface MarkerButtonOptions extends MarkerRenderOptions {
+ index: number;
+ label: string;
+ /** Extra class marking the render path, e.g. `tdv-marker--mv`. */
+ variantClass: string;
+ onActivate: (markerId: string) => void;
+}
+
+/** Build one marker button, fully wired and labelled. */
+export function createMarkerButton(marker: Marker, options: MarkerButtonOptions): HTMLButtonElement {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = `tdv-marker ${options.variantClass}`;
+ button.dataset.markerId = marker.id;
+ button.dataset.markerOrder = String(options.index);
+ button.setAttribute('aria-label', options.label);
+
+ const icon = document.createElement('span');
+ icon.className = `tdv-marker-icon tdv-icon-${marker.icon}`;
+ icon.setAttribute('aria-hidden', 'true');
+ button.appendChild(icon);
+
+ if (options.showLabels && marker.label) {
+ const label = document.createElement('span');
+ label.className = 'tdv-marker-label';
+ label.textContent = marker.label;
+ button.appendChild(label);
+ }
+
+ if (options.activeId === marker.id) {
+ button.classList.add('tdv-marker--active');
+ button.setAttribute('aria-current', 'true');
+ }
+
+ // Bound directly: marker buttons are rebuilt on every render and removed on
+ // destroy, so their listeners die with them and never need tracking.
+ button.addEventListener('click', () => options.onActivate(marker.id));
+ return button;
+}
+
+/** Reflect the active marker across an already-rendered set of buttons. */
+export function applyActiveMarker(buttons: Iterable, activeId: string): void {
+ for (const button of buttons) {
+ const isActive = button.dataset.markerId === activeId;
+ button.classList.toggle('tdv-marker--active', isActive);
+ if (isActive) {
+ button.setAttribute('aria-current', 'true');
+ } else {
+ button.removeAttribute('aria-current');
+ }
+ }
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/question.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/question.ts
new file mode 100644
index 000000000..9c54d1588
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/question.ts
@@ -0,0 +1,117 @@
+/**
+ * The single-choice question renderer.
+ *
+ * Attempt and answer state come from the controller's `AnswerStore`, so
+ * reopening a marker restores what the learner already did instead of handing
+ * them a fresh attempt allowance.
+ */
+
+import { gradeSingleChoice } from '../shared/scoring';
+import type { Marker, SingleChoiceQuestion } from '../shared/types';
+import type { AnswerStore } from './state';
+
+export interface QuestionRenderDeps {
+ answers: AnswerStore;
+ t: (key: string) => string;
+ onAnswered?: (markerId: string, correct: boolean) => void;
+}
+
+function lockQuestion(inputs: readonly HTMLInputElement[], checkButton: HTMLButtonElement): void {
+ checkButton.disabled = true;
+ for (const input of inputs) {
+ input.disabled = true;
+ }
+}
+
+/**
+ * Render a question into `body` and wire its Check button.
+ *
+ * The marker is passed whole (not just its payload) because the answer store is
+ * keyed by marker id — that key is what survives the dialog.
+ */
+export function renderQuestion(body: HTMLElement, marker: Marker, deps: QuestionRenderDeps): void {
+ if (marker.action.type !== 'question') {
+ return;
+ }
+ const question: SingleChoiceQuestion = marker.action.payload;
+ const { answers, t } = deps;
+ const state = answers.get(marker.id);
+
+ const fieldset = document.createElement('fieldset');
+ fieldset.className = 'tdv-question';
+ const legend = document.createElement('legend');
+ legend.className = 'tdv-question-prompt';
+ legend.textContent = question.prompt;
+ fieldset.appendChild(legend);
+
+ const groupName = `tdv-q-${marker.id}`;
+ const inputs: HTMLInputElement[] = [];
+ for (const option of question.options) {
+ const label = document.createElement('label');
+ label.className = 'tdv-question-option';
+ const input = document.createElement('input');
+ input.type = 'radio';
+ input.name = groupName;
+ input.value = option.id;
+ if (option.id === state.selectedOptionId) {
+ input.checked = true;
+ }
+ const text = document.createElement('span');
+ text.textContent = option.text;
+ label.append(input, text);
+ fieldset.appendChild(label);
+ inputs.push(input);
+ }
+
+ const checkButton = document.createElement('button');
+ checkButton.type = 'button';
+ checkButton.className = 'tdv-q-check';
+ checkButton.textContent = t('Check');
+
+ const feedback = document.createElement('div');
+ feedback.className = 'tdv-q-feedback';
+ feedback.setAttribute('role', 'status');
+ feedback.setAttribute('aria-live', 'polite');
+
+ body.append(fieldset, checkButton, feedback);
+
+ // Restore the state the learner left this marker in.
+ if (state.resolved) {
+ feedback.className = 'tdv-q-feedback tdv-q-feedback--correct';
+ feedback.textContent = question.feedbackCorrect || t('Correct');
+ lockQuestion(inputs, checkButton);
+ } else if (answers.isExhausted(marker.id, question.attemptsAllowed)) {
+ feedback.className = 'tdv-q-feedback tdv-q-feedback--incorrect';
+ feedback.textContent = `${question.feedbackIncorrect || t('Incorrect')} ${t('No attempts left')}`;
+ lockQuestion(inputs, checkButton);
+ }
+
+ checkButton.addEventListener('click', () => {
+ const chosen = inputs.find(input => input.checked);
+ if (!chosen) {
+ feedback.className = 'tdv-q-feedback';
+ feedback.textContent = t('Please select an answer');
+ return;
+ }
+ const correct = gradeSingleChoice(question, chosen.value);
+ const next = answers.recordAttempt(marker.id, chosen.value, correct);
+ try {
+ deps.onAnswered?.(marker.id, correct);
+ } catch {
+ // A failing host hook (SCORM transport) must not break feedback.
+ }
+ if (correct) {
+ feedback.className = 'tdv-q-feedback tdv-q-feedback--correct';
+ feedback.textContent = question.feedbackCorrect || t('Correct');
+ lockQuestion(inputs, checkButton);
+ return;
+ }
+ feedback.className = 'tdv-q-feedback tdv-q-feedback--incorrect';
+ let message = question.feedbackIncorrect || t('Incorrect');
+ if (question.attemptsAllowed > 0 && next.attempts >= question.attemptsAllowed) {
+ lockQuestion(inputs, checkButton);
+ message += ` ${t('No attempts left')}`;
+ }
+ feedback.textContent = message;
+ });
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/state.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/state.spec.ts
new file mode 100644
index 000000000..3bba3a430
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/state.spec.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest';
+import { createAnswerStore } from './state';
+
+describe('createAnswerStore', () => {
+ it('reports an untouched marker as unanswered', () => {
+ const store = createAnswerStore();
+ expect(store.get('m1')).toEqual({ attempts: 0, resolved: false, selectedOptionId: '' });
+ });
+
+ it('counts attempts and remembers the last choice', () => {
+ const store = createAnswerStore();
+ store.recordAttempt('m1', 'a', false);
+ const state = store.recordAttempt('m1', 'b', false);
+ expect(state).toEqual({ attempts: 2, resolved: false, selectedOptionId: 'b' });
+ });
+
+ it('keeps a marker resolved once it has been answered correctly', () => {
+ const store = createAnswerStore();
+ store.recordAttempt('m1', 'a', true);
+ const state = store.recordAttempt('m1', 'b', false);
+ expect(state.resolved).toBe(true);
+ });
+
+ it('treats an allowance of 0 as unlimited', () => {
+ const store = createAnswerStore();
+ store.recordAttempt('m1', 'a', false);
+ store.recordAttempt('m1', 'a', false);
+ expect(store.isExhausted('m1', 0)).toBe(false);
+ });
+
+ it('reports exhaustion once the allowance is used up', () => {
+ const store = createAnswerStore();
+ expect(store.isExhausted('m1', 1)).toBe(false);
+ store.recordAttempt('m1', 'a', false);
+ expect(store.isExhausted('m1', 1)).toBe(true);
+ expect(store.isExhausted('m1', 2)).toBe(false);
+ });
+
+ it('keeps markers independent', () => {
+ const store = createAnswerStore();
+ store.recordAttempt('m1', 'a', false);
+ expect(store.get('m2').attempts).toBe(0);
+ expect(store.isExhausted('m2', 1)).toBe(false);
+ });
+
+ it('collects the ids answered correctly', () => {
+ const store = createAnswerStore();
+ store.recordAttempt('m1', 'a', true);
+ store.recordAttempt('m2', 'a', false);
+ expect([...store.correctMarkerIds()]).toEqual(['m1']);
+ });
+
+ it('forgets markers that no longer exist', () => {
+ const store = createAnswerStore();
+ store.recordAttempt('m1', 'a', true);
+ store.recordAttempt('m2', 'a', true);
+ store.retain(['m2']);
+ expect([...store.correctMarkerIds()]).toEqual(['m2']);
+ expect(store.get('m1').attempts).toBe(0);
+ });
+
+ it('clears everything', () => {
+ const store = createAnswerStore();
+ store.recordAttempt('m1', 'a', true);
+ store.clear();
+ expect([...store.correctMarkerIds()]).toEqual([]);
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/state.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/state.ts
new file mode 100644
index 000000000..1d6943c11
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/state.ts
@@ -0,0 +1,80 @@
+/**
+ * Learner answer state, keyed by marker id.
+ *
+ * This lives on the controller, not on the dialog, because a dialog is created
+ * and destroyed every time a marker is opened. Keeping attempts here is what
+ * makes the configured attempt limit apply to the marker for the whole activity
+ * session instead of resetting each time the learner reopens it.
+ */
+
+export interface QuestionAttemptState {
+ /** How many times the learner has pressed Check for this marker. */
+ attempts: number;
+ /** True once answered correctly; the question stays resolved afterwards. */
+ resolved: boolean;
+ /** The option the learner last chose, restored on reopen. */
+ selectedOptionId: string;
+}
+
+export interface AnswerStore {
+ get(markerId: string): QuestionAttemptState;
+ recordAttempt(markerId: string, selectedOptionId: string, correct: boolean): QuestionAttemptState;
+ /** True when the marker has used up a non-zero attempt allowance. */
+ isExhausted(markerId: string, attemptsAllowed: number): boolean;
+ /** Marker ids answered correctly, for scoring. */
+ correctMarkerIds(): Set;
+ /** Forget markers that no longer exist (the author deleted them). */
+ retain(markerIds: readonly string[]): void;
+ clear(): void;
+}
+
+function emptyState(): QuestionAttemptState {
+ return { attempts: 0, resolved: false, selectedOptionId: '' };
+}
+
+export function createAnswerStore(): AnswerStore {
+ const states = new Map();
+
+ const get = (markerId: string): QuestionAttemptState => states.get(markerId) ?? emptyState();
+
+ return {
+ get,
+ recordAttempt(markerId, selectedOptionId, correct) {
+ const previous = get(markerId);
+ const next: QuestionAttemptState = {
+ attempts: previous.attempts + 1,
+ // Once correct, always correct — a later reopen cannot undo it.
+ resolved: previous.resolved || correct,
+ selectedOptionId,
+ };
+ states.set(markerId, next);
+ return next;
+ },
+ isExhausted(markerId, attemptsAllowed) {
+ if (attemptsAllowed <= 0) {
+ return false;
+ }
+ return get(markerId).attempts >= attemptsAllowed;
+ },
+ correctMarkerIds() {
+ const ids = new Set();
+ for (const [markerId, state] of states) {
+ if (state.resolved) {
+ ids.add(markerId);
+ }
+ }
+ return ids;
+ },
+ retain(markerIds) {
+ const keep = new Set(markerIds);
+ for (const markerId of [...states.keys()]) {
+ if (!keep.has(markerId)) {
+ states.delete(markerId);
+ }
+ }
+ },
+ clear() {
+ states.clear();
+ },
+ };
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/interactions/types.ts b/public/files/perm/idevices/base/three-d-viewer/src/interactions/types.ts
new file mode 100644
index 000000000..deb7c40bd
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/interactions/types.ts
@@ -0,0 +1,77 @@
+/** Contracts between the interaction controller, its adapters and its hosts. */
+
+import type { InteractionSettings, Marker, MarkerCamera, Vector3 } from '../shared/types';
+
+/** Where an author dropped a marker, as reported by a renderer adapter. */
+export interface MarkerPlacement {
+ position: Vector3;
+ normal: Vector3;
+ surface: string;
+ camera: MarkerCamera;
+}
+
+export interface MarkerRenderOptions {
+ showLabels: boolean;
+ activeId: string;
+}
+
+/**
+ * The only thing the controller knows about a renderer.
+ *
+ * `` implements it with native declarative hotspots; the STL path
+ * implements it with a raycast plus a per-frame DOM overlay. Neither adapter
+ * contains dialog, question or navigation logic.
+ */
+export interface MarkerAdapter {
+ enterPlacementMode(onPlaced: (placement: MarkerPlacement) => void): void;
+ exitPlacementMode(): void;
+ renderMarkers(markers: readonly Marker[], options: MarkerRenderOptions): void;
+ setActive(activeId: string): void;
+ focusMarker(marker: Marker): void;
+ captureCamera(): MarkerCamera;
+ /** Re-position the overlay; a no-op where the renderer projects natively. */
+ updateOverlay(): void;
+ destroy(): void;
+}
+
+/** Host callbacks. Every one is optional; the controller degrades without them. */
+export interface InteractionHooks {
+ /** Translate a learner-facing micro-string. */
+ t?: (key: string) => string;
+ /** Author placed a marker (edit mode only). */
+ onPlaced?: (placement: MarkerPlacement) => void;
+ /** A marker was activated by the learner. */
+ onActivate?: (markerId: string) => void;
+ /** A question was graded — used by SCORM scoring. */
+ onQuestionAnswered?: (markerId: string, correct: boolean) => void;
+ /** Turn an `asset://` media reference into something the browser can load. */
+ resolveMediaUrl?: (url: string) => string;
+ /** Sanitize author HTML; defaults to the shared DOM sanitizer. */
+ sanitizeHtml?: (html: string) => string;
+}
+
+export type InteractionMode = 'view' | 'edit';
+
+/** What the host handles the controller through. */
+export interface InteractionController {
+ setState(next: InteractionSettings): void;
+ render(): void;
+ enterPlacementMode(): void;
+ exitPlacementMode(): void;
+ focusMarker(markerId: string): void;
+ captureCamera(): MarkerCamera;
+ next(): void;
+ previous(): void;
+ getActiveId(): string;
+ /** Accessible label of a marker, used by adapters when building buttons. */
+ markerLabel(marker: Marker, index: number): string;
+ destroy(): void;
+}
+
+/** How the controller reaches its renderer. */
+export interface InteractionHandle {
+ wrapper: HTMLElement;
+ type: string;
+ modelViewer?: ModelViewerElement | null;
+ instance?: unknown;
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/colors.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/colors.spec.ts
new file mode 100644
index 000000000..8b36bb98c
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/colors.spec.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_BACKGROUND_COLOR, DEFAULT_MODEL_COLOR, normalizeColor } from './colors';
+
+describe('normalizeColor', () => {
+ it('accepts #RRGGBB and lowercases it', () => {
+ expect(normalizeColor('#AABBCC')).toBe('#aabbcc');
+ });
+
+ it('expands #RGB to #RRGGBB', () => {
+ expect(normalizeColor('#ABC')).toBe('#aabbcc');
+ expect(normalizeColor('#000')).toBe('#000000');
+ });
+
+ it('trims surrounding whitespace', () => {
+ expect(normalizeColor(' #123456 ')).toBe('#123456');
+ });
+
+ it('falls back for anything that is not a hex colour', () => {
+ for (const value of ['red', 'rgb(1,2,3)', '#12345', '', null, undefined, 42]) {
+ expect(normalizeColor(value)).toBe(DEFAULT_MODEL_COLOR);
+ }
+ });
+
+ it('uses the caller-provided fallback', () => {
+ expect(normalizeColor('nope', DEFAULT_BACKGROUND_COLOR)).toBe(DEFAULT_BACKGROUND_COLOR);
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/colors.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/colors.ts
new file mode 100644
index 000000000..c610a017d
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/colors.ts
@@ -0,0 +1,28 @@
+/** CSS colour coercion shared by the schema, the editor form and the renderers. */
+
+export const DEFAULT_MODEL_COLOR = '#888888';
+export const DEFAULT_BACKGROUND_COLOR = '#f5f5f5';
+
+const HEX6 = /^#[0-9a-f]{6}$/;
+const HEX3 = /^#[0-9a-f]{3}$/;
+
+/**
+ * Coerce a colour to lowercase `#rrggbb`. Accepts `#RGB` and `#RRGGBB`;
+ * anything else (including non-strings) falls back.
+ */
+export function normalizeColor(value: unknown, fallback: string = DEFAULT_MODEL_COLOR): string {
+ if (typeof value !== 'string') {
+ return fallback;
+ }
+ const trimmed = value.trim().toLowerCase();
+ if (HEX6.test(trimmed)) {
+ return trimmed;
+ }
+ if (HEX3.test(trimmed)) {
+ const r = trimmed[1] ?? '0';
+ const g = trimmed[2] ?? '0';
+ const b = trimmed[3] ?? '0';
+ return `#${r}${r}${g}${g}${b}${b}`;
+ }
+ return fallback;
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/html.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/html.spec.ts
new file mode 100644
index 000000000..d1a6dde28
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/html.spec.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { escapeHtml, escapeJsonForScript, sanitizeHtml, stripHtmlToText } from './html';
+
+describe('escapeHtml', () => {
+ it('escapes every markup metacharacter', () => {
+ expect(escapeHtml(`&' `)).toBe('<a href="x">&'</a>');
+ });
+
+ it('renders null and undefined as an empty string', () => {
+ expect(escapeHtml(null)).toBe('');
+ expect(escapeHtml(undefined)).toBe('');
+ });
+});
+
+describe('stripHtmlToText', () => {
+ it('flattens markup to collapsed plain text', () => {
+ expect(stripHtmlToText('Hello world
')).toBe('Hello world');
+ });
+
+ it('returns an empty string for nullish input', () => {
+ expect(stripHtmlToText(undefined)).toBe('');
+ });
+});
+
+describe('escapeJsonForScript', () => {
+ it('escapes `<` so a payload cannot terminate the script element', () => {
+ const json = escapeJsonForScript({ html: ' ' });
+ expect(json).not.toContain('');
+ expect(json).toContain('\\u003c/script');
+ expect(JSON.parse(json)).toEqual({ html: ' ' });
+ });
+});
+
+describe('sanitizeHtml', () => {
+ it('keeps benign markup untouched', () => {
+ expect(sanitizeHtml('Hello world
')).toBe('Hello world
');
+ });
+
+ it('returns an empty string for empty or non-string input', () => {
+ expect(sanitizeHtml('')).toBe('');
+ expect(sanitizeHtml(undefined)).toBe('');
+ expect(sanitizeHtml(42)).toBe('');
+ });
+
+ it('removes scripts, styles, iframes, objects, embeds and forms', () => {
+ const dirty =
+ 'ok
' +
+ '';
+ const clean = sanitizeHtml(dirty);
+ for (const tag of ['x ');
+ expect(clean.toLowerCase()).not.toContain('`).
+ */
+export function escapeJsonForScript(value: unknown): string {
+ return JSON.stringify(value).replace(/` or ``.
+ */
+const BANNED_TAGS = new Set([
+ 'SCRIPT',
+ 'STYLE',
+ 'IFRAME',
+ 'OBJECT',
+ 'EMBED',
+ 'LINK',
+ 'META',
+ 'BASE',
+ 'FORM',
+ 'FRAME',
+ 'FRAMESET',
+ 'FOREIGNOBJECT',
+ 'ANNOTATION-XML',
+]);
+
+/** Attributes whose value is a URL and therefore has to pass `safeUrl`. */
+const URL_ATTRIBUTES = new Set([
+ 'href',
+ 'src',
+ 'srcset',
+ 'srcdoc',
+ 'xlink:href',
+ 'action',
+ 'formaction',
+ 'poster',
+ 'ping',
+ 'data',
+ 'background',
+]);
+
+function sanitizeElement(element: Element): boolean {
+ // `tagName` casing differs between HTML (upper) and foreign content
+ // (author-provided); normalize before every comparison.
+ if (BANNED_TAGS.has(element.tagName.toUpperCase())) {
+ element.remove();
+ return false;
+ }
+ for (const attribute of Array.from(element.attributes)) {
+ const name = attribute.name.toLowerCase();
+ if (name.startsWith('on')) {
+ element.removeAttribute(attribute.name);
+ continue;
+ }
+ if (URL_ATTRIBUTES.has(name) && !safeUrl(attribute.value)) {
+ element.removeAttribute(attribute.name);
+ }
+ }
+ return true;
+}
+
+function sanitizeChildren(node: Node): void {
+ for (const child of Array.from(node.childNodes)) {
+ if (child.nodeType !== 1) {
+ continue;
+ }
+ if (sanitizeElement(child as Element)) {
+ sanitizeChildren(child);
+ }
+ }
+}
+
+/**
+ * Conservative DOM sanitizer for `information` marker HTML. Removes banned
+ * elements, inline `on*` handlers and unsafe URL attributes. Falls back to full
+ * escaping when there is no DOM (server-side or non-browser test runners).
+ */
+export function sanitizeHtml(html: unknown): string {
+ const source = typeof html === 'string' ? html : '';
+ if (!source) {
+ return '';
+ }
+ if (typeof document === 'undefined' || typeof document.createElement !== 'function') {
+ return escapeHtml(source);
+ }
+ const template = document.createElement('template');
+ template.innerHTML = source;
+ sanitizeChildren(template.content);
+ return template.innerHTML;
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/migration.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/migration.spec.ts
new file mode 100644
index 000000000..979e92352
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/migration.spec.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from 'vitest';
+import { readFixture, sequentialIds } from '../test/helpers';
+import { hydrateDocument, hydrateFromJson, serializeDocument } from './migration';
+import { createDefaultDocument } from './schema';
+import { SCHEMA_VERSION } from './types';
+
+describe('hydrateDocument — original unversioned content', () => {
+ it('migrates the pre-interaction shape straight to schema v2', () => {
+ const result = hydrateDocument(readFixture('legacy/unversioned.json'), sequentialIds());
+ expect(result.status).toBe('ok');
+ if (result.status !== 'ok') {
+ return;
+ }
+ expect(result.document.schemaVersion).toBe(SCHEMA_VERSION);
+ expect(result.document.src).toBe('asset://8f3c2a10-0b41-4f0f-9a1c-2b7d5e6f7a90.glb');
+ expect(result.document.modelColor).toBe('#aabbcc');
+ expect(result.document.animation).toEqual({ enabled: true, name: 'Spin', speed: 1.5 });
+ });
+
+ it('gives legacy content a disabled, empty interaction layer so it looks unchanged', () => {
+ const result = hydrateDocument(readFixture('legacy/unversioned.json'), sequentialIds());
+ expect(result.status === 'ok' && result.document.interaction).toEqual({
+ enabled: false,
+ guidedMode: false,
+ wrapNavigation: false,
+ showMarkerLabels: true,
+ activeMarkerId: '',
+ markers: [],
+ });
+ expect(result.status === 'ok' && result.document.scorm).toEqual({
+ mode: 0,
+ weighted: 100,
+ saveButtonText: '',
+ });
+ });
+
+ it('never loses a valid model source', () => {
+ for (const src of [
+ 'asset://a.glb',
+ 'content/resources/a.stl',
+ 'https://example.org/a.gltf',
+ 'file_manager/a.glb',
+ ]) {
+ const result = hydrateDocument({ src }, sequentialIds());
+ expect(result.status === 'ok' && result.document.src).toBe(src);
+ }
+ });
+
+ it('never persists a blob: or data: model source', () => {
+ for (const src of ['blob:http://localhost/abc', 'data:model/gltf+json,{}']) {
+ const result = hydrateDocument({ src }, sequentialIds());
+ expect(result.status === 'ok' && result.document.src).toBe('');
+ }
+ });
+});
+
+describe('hydrateDocument — schema v2', () => {
+ it('normalizes a stored v2 document without changing its meaning', () => {
+ const result = hydrateDocument(readFixture('schema-v2/with-markers.json'), sequentialIds());
+ expect(result.status).toBe('ok');
+ if (result.status !== 'ok') {
+ return;
+ }
+ expect(result.document.interaction.markers.map(marker => marker.id)).toEqual(['marker-summit', 'marker-quiz']);
+ expect(result.document.scorm).toEqual({ mode: 1, weighted: 80, saveButtonText: '' });
+ // Nav controls win over auto-rotation, as the schema guarantees.
+ expect(result.document.autoRotate).toBe(false);
+ });
+
+ it('round-trips through serializeDocument without loss', () => {
+ const first = hydrateDocument(readFixture('schema-v2/with-markers.json'), sequentialIds());
+ expect(first.status).toBe('ok');
+ if (first.status !== 'ok') {
+ return;
+ }
+ const serialized = serializeDocument(first.document, sequentialIds());
+ const second = hydrateDocument(serialized, sequentialIds());
+ expect(second.status === 'ok' && second.document).toEqual(first.document);
+ });
+
+ it('is idempotent under repeated normalization', () => {
+ const hydrated = hydrateDocument(readFixture('schema-v2/with-markers.json'), sequentialIds());
+ expect(hydrated.status).toBe('ok');
+ if (hydrated.status !== 'ok') {
+ return;
+ }
+ const once = serializeDocument(hydrated.document, sequentialIds());
+ expect(serializeDocument(once, sequentialIds())).toEqual(once);
+ });
+
+ it('strips a blob: marker media URL on the way out', () => {
+ const result = hydrateDocument(
+ {
+ schemaVersion: 2,
+ src: 'asset://a.glb',
+ interaction: {
+ enabled: true,
+ markers: [{ id: 'm1', action: { type: 'image', payload: { src: 'blob:http://x/1' } } }],
+ },
+ },
+ sequentialIds(),
+ );
+ expect(result.status).toBe('ok');
+ if (result.status !== 'ok') {
+ return;
+ }
+ const serialized = serializeDocument(result.document, sequentialIds());
+ const action = serialized.interaction.markers[0]?.action;
+ expect(action?.type === 'image' && action.payload.src).toBe('');
+ });
+});
+
+describe('hydrateDocument — version gating', () => {
+ it('rejects a future schema version and preserves the original', () => {
+ const original = readFixture('schema-v2/future.json');
+ const result = hydrateDocument(original, sequentialIds());
+ expect(result.status).toBe('unsupported-version');
+ if (result.status !== 'unsupported-version') {
+ return;
+ }
+ expect(result.version).toBe(99);
+ expect(result.original).toBe(original);
+ });
+
+ it('accepts schemaVersion written as a numeric string', () => {
+ expect(hydrateDocument({ schemaVersion: '2' }, sequentialIds()).status).toBe('ok');
+ expect(hydrateDocument({ schemaVersion: '3' }, sequentialIds()).status).toBe('unsupported-version');
+ });
+
+ it('treats an unrecognised version marker as original unversioned content', () => {
+ // The unpublished development branch used `version: 2`; there is no
+ // migration for it — the field is simply ignored and the shape, which is
+ // compatible, is normalized as legacy content.
+ const result = hydrateDocument({ version: 2, src: 'asset://a.glb' }, sequentialIds());
+ expect(result.status === 'ok' && result.document.schemaVersion).toBe(2);
+ expect(
+ result.status === 'ok' && (result.document as unknown as Record).version,
+ ).toBeUndefined();
+ });
+
+ it('returns the defaults for null, undefined and an empty string', () => {
+ for (const input of [null, undefined, '']) {
+ const result = hydrateDocument(input, sequentialIds());
+ expect(result.status === 'ok' && result.document).toEqual(createDefaultDocument());
+ }
+ });
+
+ it('reports non-object input as invalid instead of guessing', () => {
+ for (const input of ['a string', 42, [1, 2, 3], true]) {
+ const result = hydrateDocument(input, sequentialIds());
+ expect(result.status).toBe('invalid');
+ expect(result.status === 'invalid' && result.original).toBe(input);
+ }
+ });
+});
+
+describe('hydrateFromJson', () => {
+ it('parses a JSON string', () => {
+ const result = hydrateFromJson('{"schemaVersion":2,"src":"asset://a.glb"}', sequentialIds());
+ expect(result.status === 'ok' && result.document.src).toBe('asset://a.glb');
+ });
+
+ it('reports malformed JSON as invalid rather than throwing', () => {
+ const result = hydrateFromJson('{not json', sequentialIds());
+ expect(result.status).toBe('invalid');
+ expect(result.status === 'invalid' && result.reason).toBe('malformed JSON');
+ });
+
+ it('returns the defaults for an empty string and delegates non-strings', () => {
+ expect(hydrateFromJson(' ', sequentialIds()).status).toBe('ok');
+ expect(hydrateFromJson({ schemaVersion: 99 }, sequentialIds()).status).toBe('unsupported-version');
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/migration.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/migration.ts
new file mode 100644
index 000000000..43799f684
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/migration.ts
@@ -0,0 +1,92 @@
+/**
+ * Turning untrusted persisted data into a canonical schema-v2 document.
+ *
+ * Only three transitions exist:
+ *
+ * original unversioned 3D Viewer state → schema v2
+ * schema v2 → normalized schema v2
+ * schema version > 2 → rejected, original preserved
+ *
+ * The unversioned shape is everything the iDevice wrote before interactions
+ * existed: model source, alt text, colours, camera/auto-rotate flags and the
+ * animation block. Those documents have no `interaction` and no `scorm`, so
+ * hydration gives them a disabled, empty interaction layer and they reopen and
+ * re-export exactly as before.
+ *
+ * There is deliberately NO migration for intermediate development shapes: the
+ * interaction feature has never been released, so schema v2 is the only version
+ * that has ever been published.
+ */
+
+import { createDefaultDocument, defaultIdFactory, normalizeDocument } from './schema';
+import type { HydrationResult, IdFactory, ThreeDViewerDocumentV2 } from './types';
+import { SCHEMA_VERSION } from './types';
+
+function readSchemaVersion(raw: Record): number {
+ const value = raw.schemaVersion;
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === 'string' && value.trim() !== '') {
+ const parsed = Number.parseInt(value, 10);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+ // No usable version marker: original, pre-interaction content.
+ return 0;
+}
+
+/**
+ * Parse, version-gate and normalize persisted data.
+ *
+ * Never cast persisted JSON to the document type — everything arrives as
+ * `unknown` and leaves as a typed result the caller has to branch on.
+ */
+export function hydrateDocument(value: unknown, createId: IdFactory = defaultIdFactory): HydrationResult {
+ if (value === null || value === undefined || value === '') {
+ return { status: 'ok', document: createDefaultDocument() };
+ }
+ if (typeof value !== 'object' || Array.isArray(value)) {
+ return { status: 'invalid', reason: 'expected an object', original: value };
+ }
+ const raw = value as Record;
+ const version = readSchemaVersion(raw);
+ if (version > SCHEMA_VERSION) {
+ return { status: 'unsupported-version', version, original: value };
+ }
+ return { status: 'ok', document: normalizeDocument(raw, createId) };
+}
+
+/**
+ * Hydrate a JSON string (or an already-parsed value). Malformed JSON is an
+ * `invalid` result, never a thrown error, because it reaches us from storage.
+ */
+export function hydrateFromJson(value: unknown, createId: IdFactory = defaultIdFactory): HydrationResult {
+ if (typeof value !== 'string') {
+ return hydrateDocument(value, createId);
+ }
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return { status: 'ok', document: createDefaultDocument() };
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch {
+ return { status: 'invalid', reason: 'malformed JSON', original: value };
+ }
+ return hydrateDocument(parsed, createId);
+}
+
+/**
+ * Produce the object to persist. Re-normalizing on the way out re-strips any
+ * ephemeral URL that slipped in during preview and guarantees a stable,
+ * idempotent serialized shape.
+ */
+export function serializeDocument(
+ document: ThreeDViewerDocumentV2,
+ createId: IdFactory = defaultIdFactory,
+): ThreeDViewerDocumentV2 {
+ return normalizeDocument(document, createId);
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/model-source.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/model-source.spec.ts
new file mode 100644
index 000000000..f5396db75
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/model-source.spec.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+import { detectModelType, isStlSource, isSupportedModelFile, normalizeModelSource } from './model-source';
+
+describe('detectModelType', () => {
+ it('recognises every known extension', () => {
+ expect(detectModelType('asset://uuid.stl')).toBe('stl');
+ expect(detectModelType('a.glb')).toBe('glb');
+ expect(detectModelType('a.gltf')).toBe('gltf');
+ expect(detectModelType('a.obj')).toBe('obj');
+ expect(detectModelType('a.fbx')).toBe('fbx');
+ });
+
+ it('is case-insensitive and tolerates surrounding whitespace', () => {
+ expect(detectModelType(' A.STL ')).toBe('stl');
+ });
+
+ it('strips the query string and the fragment before looking at the extension', () => {
+ expect(detectModelType('a.glb?v=2')).toBe('glb');
+ expect(detectModelType('a.stl#frag')).toBe('stl');
+ expect(detectModelType('a.gltf?v=2#frag')).toBe('gltf');
+ });
+
+ it('returns "unknown" for missing, unsupported and non-string input', () => {
+ for (const input of ['', 'a.txt', 'no-extension', undefined, null, 42]) {
+ expect(detectModelType(input)).toBe('unknown');
+ }
+ });
+});
+
+describe('isStlSource', () => {
+ it('is true only for STL', () => {
+ expect(isStlSource('a.stl')).toBe(true);
+ expect(isStlSource('a.STL')).toBe(true);
+ expect(isStlSource('a.glb')).toBe(false);
+ expect(isStlSource('')).toBe(false);
+ });
+});
+
+describe('normalizeModelSource', () => {
+ it('passes durable references through unchanged', () => {
+ expect(normalizeModelSource('asset://uuid.glb')).toBe('asset://uuid.glb');
+ expect(normalizeModelSource('https://example.org/a.glb')).toBe('https://example.org/a.glb');
+ expect(normalizeModelSource('content/resources/a.stl')).toBe('content/resources/a.stl');
+ });
+
+ it('strips ephemeral URLs', () => {
+ expect(normalizeModelSource('blob:http://localhost/x')).toBe('');
+ expect(normalizeModelSource('data:model/gltf+json,{}')).toBe('');
+ });
+
+ it('trims and returns an empty string for invalid input', () => {
+ expect(normalizeModelSource(' a.glb ')).toBe('a.glb');
+ expect(normalizeModelSource(' ')).toBe('');
+ expect(normalizeModelSource(null)).toBe('');
+ });
+});
+
+describe('isSupportedModelFile', () => {
+ it('accepts the three supported formats through every reference style', () => {
+ for (const path of [
+ 'model.glb',
+ 'model.gltf',
+ 'model.stl',
+ 'asset://uuid.glb',
+ 'asset://uuid.stl',
+ 'file_manager/dir/model.GLTF',
+ 'https://example.org/a/model.glb?v=1',
+ ]) {
+ expect(isSupportedModelFile(path)).toBe(true);
+ }
+ });
+
+ it('accepts blob: URLs, which carry no extension', () => {
+ expect(isSupportedModelFile('blob:http://localhost/abc')).toBe(true);
+ });
+
+ it('rejects unsupported extensions and empty input', () => {
+ for (const path of ['model.obj', 'model.txt', '', null, undefined, 'asset://uuid']) {
+ expect(isSupportedModelFile(path)).toBe(false);
+ }
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/model-source.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/model-source.ts
new file mode 100644
index 000000000..d5c97f681
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/model-source.ts
@@ -0,0 +1,73 @@
+/** Model-source classification: what kind of file is this, and may we keep it? */
+
+import type { ModelType } from './types';
+import { stripQueryAndHash } from './urls';
+
+/** Extensions the file picker accepts and the two render paths cover. */
+export const SUPPORTED_MODEL_EXTENSIONS = ['glb', 'gltf', 'stl'] as const;
+
+/** Extensions `detectModelType` recognises (a superset of the supported ones). */
+const KNOWN_EXTENSIONS: readonly ModelType[] = ['stl', 'glb', 'gltf', 'obj', 'fbx'];
+
+/**
+ * Detect the model type from a path or `asset://` URL by file extension.
+ * Tolerates query strings, hash fragments, mixed case and surrounding space.
+ */
+export function detectModelType(src: unknown): ModelType {
+ if (typeof src !== 'string') {
+ return 'unknown';
+ }
+ const clean = stripQueryAndHash(src.trim());
+ const dot = clean.lastIndexOf('.');
+ if (dot === -1) {
+ return 'unknown';
+ }
+ const ext = clean.substring(dot + 1).toLowerCase();
+ return (KNOWN_EXTENSIONS as readonly string[]).includes(ext) ? (ext as ModelType) : 'unknown';
+}
+
+/** True when the source resolves to an STL file (the Three.js render path). */
+export function isStlSource(src: unknown): boolean {
+ return detectModelType(src) === 'stl';
+}
+
+/**
+ * Normalize an inbound model source for persistence.
+ *
+ * `asset://`, `http(s)://` and relative paths pass through unchanged;
+ * `blob:` and `data:` are dropped because they are ephemeral runtime URLs
+ * that must never reach the saved document.
+ */
+export function normalizeModelSource(src: unknown): string {
+ if (typeof src !== 'string') {
+ return '';
+ }
+ const clean = src.trim();
+ if (!clean || clean.startsWith('blob:') || clean.startsWith('data:')) {
+ return '';
+ }
+ return clean;
+}
+
+/**
+ * Whether a picked file is one the viewer can render. `blob:` URLs are accepted
+ * because they carry no extension — the file was validated at upload time.
+ */
+export function isSupportedModelFile(path: unknown): boolean {
+ if (!path) {
+ return false;
+ }
+ let filename = String(path).toLowerCase();
+ if (filename.startsWith('asset://')) {
+ filename = filename.substring('asset://'.length);
+ } else if (filename.startsWith('blob:')) {
+ return true;
+ } else {
+ filename = filename.split('/').pop() ?? '';
+ }
+ filename = stripQueryAndHash(filename);
+ if (!filename) {
+ return false;
+ }
+ return SUPPORTED_MODEL_EXTENSIONS.some(ext => filename.endsWith(`.${ext}`));
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/schema.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/schema.spec.ts
new file mode 100644
index 000000000..e36c7c1e8
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/schema.spec.ts
@@ -0,0 +1,274 @@
+import { describe, expect, it } from 'vitest';
+import { sequentialIds } from '../test/helpers';
+import {
+ createDefaultDocument,
+ defaultIdFactory,
+ normalizeAction,
+ normalizeAnchor,
+ normalizeAnimation,
+ normalizeCamera,
+ normalizeDocument,
+ normalizeInteraction,
+ normalizeMarker,
+ normalizeQuestion,
+ normalizeScorm,
+ normalizeVector3,
+} from './schema';
+
+describe('normalizeVector3 / normalizeAnchor / normalizeCamera', () => {
+ it('coerces components with a numeric fallback', () => {
+ expect(normalizeVector3({ x: '1.5', y: null, z: 3 }, { x: 0, y: 9, z: 0 })).toEqual({ x: 1.5, y: 9, z: 3 });
+ });
+
+ it('defaults an anchor to the origin with an up-facing normal', () => {
+ expect(normalizeAnchor(undefined)).toEqual({
+ position: { x: 0, y: 0, z: 0 },
+ normal: { x: 0, y: 1, z: 0 },
+ surface: '',
+ });
+ });
+
+ it('keeps a surface hint and coerces camera fields to strings', () => {
+ expect(normalizeAnchor({ surface: 'front' }).surface).toBe('front');
+ expect(normalizeCamera({ orbit: 1, target: 'a', fieldOfView: null })).toEqual({
+ orbit: '',
+ target: 'a',
+ fieldOfView: '',
+ });
+ });
+});
+
+describe('normalizeQuestion', () => {
+ it('provides two placeholder options when none are supplied', () => {
+ const question = normalizeQuestion({}, sequentialIds());
+ expect(question.options).toHaveLength(2);
+ expect(question.options[0]?.correct).toBe(true);
+ expect(question.options[1]?.correct).toBe(false);
+ });
+
+ it('forces exactly one correct option — the first flagged wins', () => {
+ const question = normalizeQuestion(
+ { options: [{ text: 'a', correct: true }, { text: 'b', correct: true }, { text: 'c' }] },
+ sequentialIds(),
+ );
+ expect(question.options.map(option => option.correct)).toEqual([true, false, false]);
+ });
+
+ it('marks the first option correct when none is', () => {
+ const question = normalizeQuestion({ options: [{ text: 'a' }, { text: 'b' }] }, sequentialIds());
+ expect(question.options[0]?.correct).toBe(true);
+ });
+
+ it('clamps attemptsAllowed to whole numbers in [0, 20]', () => {
+ expect(normalizeQuestion({ attemptsAllowed: -3 }, sequentialIds()).attemptsAllowed).toBe(0);
+ expect(normalizeQuestion({ attemptsAllowed: 999 }, sequentialIds()).attemptsAllowed).toBe(20);
+ expect(normalizeQuestion({ attemptsAllowed: 2.6 }, sequentialIds()).attemptsAllowed).toBe(3);
+ });
+
+ it('caps stored options at ten', () => {
+ const options = Array.from({ length: 15 }, (_, index) => ({ text: `option ${index}` }));
+ expect(normalizeQuestion({ options }, sequentialIds()).options).toHaveLength(10);
+ });
+
+ it('preserves existing option ids and creates ids for new options', () => {
+ const question = normalizeQuestion({ options: [{ id: 'keep-me', text: 'a' }, { text: 'b' }] }, sequentialIds());
+ expect(question.options[0]?.id).toBe('keep-me');
+ expect(question.options[1]?.id).toBe('option-1');
+ });
+});
+
+describe('normalizeAction', () => {
+ it('defaults an unknown action type to information, keeping its payload', () => {
+ const action = normalizeAction({ type: 'explode', payload: { html: 'x ' } }, sequentialIds());
+ expect(action.type).toBe('information');
+ expect(action.payload).toEqual({ html: 'x ' });
+ });
+
+ it('drops payload fields that do not belong to the resolved action type', () => {
+ const action = normalizeAction({ type: 'information', payload: { html: 'a', url: 'b' } }, sequentialIds());
+ expect(action.payload).toEqual({ html: 'a' });
+ });
+
+ it('strips blob: and data: URLs from image and video payloads', () => {
+ const image = normalizeAction(
+ { type: 'image', payload: { src: 'blob:http://x/1', alt: 'a' } },
+ sequentialIds(),
+ );
+ expect(image.type === 'image' && image.payload.src).toBe('');
+ const video = normalizeAction(
+ { type: 'video', payload: { src: 'data:video/mp4;base64,AA', poster: 'poster.png' } },
+ sequentialIds(),
+ );
+ expect(video.type === 'video' && video.payload).toEqual({ src: '', poster: 'poster.png' });
+ });
+
+ it('keeps asset:// media and defaults link newTab to true', () => {
+ const image = normalizeAction({ type: 'image', payload: { src: 'asset://a.png' } }, sequentialIds());
+ expect(image.type === 'image' && image.payload.src).toBe('asset://a.png');
+ const link = normalizeAction({ type: 'link', payload: { url: 'https://example.org' } }, sequentialIds());
+ expect(link.type === 'link' && link.payload).toEqual({ url: 'https://example.org', newTab: true });
+ });
+
+ it('strips executable schemes from link URLs at normalize time', () => {
+ for (const url of ['javascript:alert(1)', ' vbscript:msgbox', 'JavaScript:alert(1)']) {
+ const link = normalizeAction({ type: 'link', payload: { url } }, sequentialIds());
+ expect(link.type === 'link' && link.payload.url).toBe('');
+ }
+ });
+
+ it('normalizes a question payload through normalizeQuestion', () => {
+ const action = normalizeAction({ type: 'question', payload: { prompt: 'Q?' } }, sequentialIds());
+ expect(action.type).toBe('question');
+ expect(action.type === 'question' && action.payload.type).toBe('single-choice');
+ });
+});
+
+describe('normalizeMarker', () => {
+ it('creates an id, defaults the icon and falls back to the array index', () => {
+ const marker = normalizeMarker({}, 4, sequentialIds());
+ expect(marker.id).toBe('marker-1');
+ expect(marker.icon).toBe('circle');
+ expect(marker.order).toBe(4);
+ });
+
+ it('preserves an existing id and clamps an invalid icon', () => {
+ const marker = normalizeMarker({ id: 'marker-x', icon: 'rocket' }, 0, sequentialIds());
+ expect(marker.id).toBe('marker-x');
+ expect(marker.icon).toBe('circle');
+ });
+});
+
+describe('normalizeInteraction', () => {
+ it('returns a disabled, empty interaction for undefined and for garbage', () => {
+ for (const input of [undefined, null, 'nope', 42, []]) {
+ const interaction = normalizeInteraction(input, sequentialIds());
+ expect(interaction.enabled).toBe(false);
+ expect(interaction.markers).toEqual([]);
+ expect(interaction.showMarkerLabels).toBe(true);
+ }
+ });
+
+ it('coerces the boolean flags', () => {
+ const interaction = normalizeInteraction(
+ { enabled: 1, guidedMode: 'yes', wrapNavigation: 0, showMarkerLabels: false },
+ sequentialIds(),
+ );
+ expect(interaction).toMatchObject({
+ enabled: true,
+ guidedMode: true,
+ wrapNavigation: false,
+ showMarkerLabels: false,
+ });
+ });
+
+ it('sorts markers by order and re-indexes them contiguously', () => {
+ const interaction = normalizeInteraction(
+ {
+ markers: [
+ { id: 'b', order: 5 },
+ { id: 'a', order: 1 },
+ { id: 'c', order: 3 },
+ ],
+ },
+ sequentialIds(),
+ );
+ expect(interaction.markers.map(marker => marker.id)).toEqual(['a', 'c', 'b']);
+ expect(interaction.markers.map(marker => marker.order)).toEqual([0, 1, 2]);
+ });
+
+ it('keeps activeMarkerId only when it points at an existing marker', () => {
+ expect(
+ normalizeInteraction({ markers: [{ id: 'a' }], activeMarkerId: 'a' }, sequentialIds()).activeMarkerId,
+ ).toBe('a');
+ expect(
+ normalizeInteraction({ markers: [{ id: 'a' }], activeMarkerId: 'zzz' }, sequentialIds()).activeMarkerId,
+ ).toBe('');
+ });
+
+ it('is idempotent', () => {
+ const once = normalizeInteraction(
+ { enabled: true, markers: [{ id: 'a', action: { type: 'question', payload: {} } }] },
+ sequentialIds(),
+ );
+ expect(normalizeInteraction(once, sequentialIds())).toEqual(once);
+ });
+});
+
+describe('normalizeAnimation', () => {
+ it('clamps the speed into [0.1, 3] and defaults to 1', () => {
+ expect(normalizeAnimation({ speed: 99 }).speed).toBe(3);
+ expect(normalizeAnimation({ speed: 0 }).speed).toBe(0.1);
+ expect(normalizeAnimation({}).speed).toBe(1);
+ });
+});
+
+describe('normalizeScorm', () => {
+ it('defaults to disabled scoring', () => {
+ expect(normalizeScorm(undefined)).toEqual({ mode: 0, weighted: 100, saveButtonText: '' });
+ });
+
+ it('clamps the mode to 0..2 and the weight to 1..100', () => {
+ expect(normalizeScorm({ mode: 7, weighted: 500 })).toMatchObject({ mode: 2, weighted: 100 });
+ expect(normalizeScorm({ mode: -4, weighted: 0 })).toMatchObject({ mode: 0, weighted: 1 });
+ });
+
+ it('accepts the gamification framework vocabulary', () => {
+ expect(normalizeScorm({ isScorm: 2, weighted: 60, textButtonScorm: 'Send' })).toEqual({
+ mode: 2,
+ weighted: 60,
+ saveButtonText: 'Send',
+ });
+ });
+});
+
+describe('normalizeDocument', () => {
+ it('produces the defaults for an empty input', () => {
+ expect(normalizeDocument({}, sequentialIds())).toEqual(createDefaultDocument());
+ });
+
+ it('drops blob: and data: model sources', () => {
+ expect(normalizeDocument({ src: 'blob:http://x/1' }, sequentialIds()).src).toBe('');
+ expect(normalizeDocument({ src: 'data:model/gltf+json,{}' }, sequentialIds()).src).toBe('');
+ });
+
+ it('normalizes colours to lowercase six-digit hex', () => {
+ const document = normalizeDocument({ modelColor: '#ABC', backgroundColor: 'rebeccapurple' }, sequentialIds());
+ expect(document.modelColor).toBe('#aabbcc');
+ expect(document.backgroundColor).toBe('#f5f5f5');
+ });
+
+ it('lets nav controls win over auto-rotation', () => {
+ const document = normalizeDocument({ showNavControls: true, autoRotate: true }, sequentialIds());
+ expect(document.autoRotate).toBe(false);
+ });
+
+ it('clamps the auto-rotate speed to the control range', () => {
+ expect(normalizeDocument({ autoRotateSpeed: 900 }, sequentialIds()).autoRotateSpeed).toBe(90);
+ expect(normalizeDocument({ autoRotateSpeed: 0 }, sequentialIds()).autoRotateSpeed).toBe(1);
+ });
+
+ it('reads SCORM from the nested block or from the legacy flat fields', () => {
+ expect(normalizeDocument({ scorm: { mode: 2 } }, sequentialIds()).scorm.mode).toBe(2);
+ expect(normalizeDocument({ isScorm: 1, weighted: 50 }, sequentialIds()).scorm).toMatchObject({
+ mode: 1,
+ weighted: 50,
+ });
+ });
+
+ it('is idempotent', () => {
+ const once = normalizeDocument(
+ { src: 'asset://a.stl', interaction: { enabled: true, markers: [{ id: 'a' }] } },
+ sequentialIds(),
+ );
+ expect(normalizeDocument(once, sequentialIds())).toEqual(once);
+ });
+});
+
+describe('defaultIdFactory', () => {
+ it('prefixes the generated id and does not repeat itself', () => {
+ const first = defaultIdFactory('marker');
+ const second = defaultIdFactory('marker');
+ expect(first.startsWith('marker-')).toBe(true);
+ expect(first).not.toBe(second);
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/schema.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/schema.ts
new file mode 100644
index 000000000..78a6f47cf
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/schema.ts
@@ -0,0 +1,295 @@
+/**
+ * Schema v2 normalization.
+ *
+ * Every function here takes `unknown` and returns a fully-formed, canonical
+ * value: parsing persisted JSON, reading a DOM dataset and building a document
+ * in the editor all funnel through the same code, so there is exactly one
+ * definition of "what a valid 3D Viewer document looks like".
+ *
+ * All normalizers are idempotent — `normalize(normalize(x))` equals
+ * `normalize(x)` — which is what makes save/reopen round trips stable.
+ */
+
+import { DEFAULT_BACKGROUND_COLOR, DEFAULT_MODEL_COLOR, normalizeColor } from './colors';
+import { normalizeModelSource } from './model-source';
+import type {
+ AnimationSettings,
+ IdFactory,
+ ImagePayload,
+ InformationPayload,
+ InteractionSettings,
+ LinkPayload,
+ Marker,
+ MarkerAction,
+ MarkerActionType,
+ MarkerAnchor,
+ MarkerCamera,
+ MarkerIcon,
+ QuestionOption,
+ ScormSettings,
+ SingleChoiceQuestion,
+ ThreeDViewerDocumentV2,
+ Vector3,
+ VideoPayload,
+} from './types';
+import { MARKER_ACTION_TYPES, MARKER_ICONS } from './types';
+import { stripUnsafeUrl } from './urls';
+
+/** Upper bound on authored answers; the editor stops at 8, storage tolerates 10. */
+const MAX_QUESTION_OPTIONS = 10;
+const MAX_ATTEMPTS_ALLOWED = 20;
+
+/** The default, non-deterministic id factory used outside tests. */
+export const defaultIdFactory: IdFactory = prefix =>
+ `${prefix}-${Math.floor(Math.random() * 1e9).toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {};
+}
+
+function toNumber(value: unknown, fallback: number): number {
+ const parsed = typeof value === 'number' ? value : Number.parseFloat(String(value));
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function toInteger(value: unknown, fallback: number): number {
+ const parsed = Number.parseInt(String(value), 10);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function toText(value: unknown, fallback = ''): string {
+ return typeof value === 'string' ? value : fallback;
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+function keepOrCreateId(value: unknown, prefix: string, createId: IdFactory): string {
+ return typeof value === 'string' && value ? value : createId(prefix);
+}
+
+export function normalizeVector3(value: unknown, fallback: Vector3): Vector3 {
+ const raw = asRecord(value);
+ return {
+ x: toNumber(raw.x, fallback.x),
+ y: toNumber(raw.y, fallback.y),
+ z: toNumber(raw.z, fallback.z),
+ };
+}
+
+export function normalizeAnchor(value: unknown): MarkerAnchor {
+ const raw = asRecord(value);
+ return {
+ position: normalizeVector3(raw.position, { x: 0, y: 0, z: 0 }),
+ normal: normalizeVector3(raw.normal, { x: 0, y: 1, z: 0 }),
+ surface: toText(raw.surface),
+ };
+}
+
+export function normalizeCamera(value: unknown): MarkerCamera {
+ const raw = asRecord(value);
+ return {
+ orbit: toText(raw.orbit),
+ target: toText(raw.target),
+ fieldOfView: toText(raw.fieldOfView),
+ };
+}
+
+export function normalizeQuestion(value: unknown, createId: IdFactory = defaultIdFactory): SingleChoiceQuestion {
+ const raw = asRecord(value);
+ const rawOptions = Array.isArray(raw.options) ? raw.options : [];
+ let seenCorrect = false;
+ const options: QuestionOption[] = rawOptions.slice(0, MAX_QUESTION_OPTIONS).map(option => {
+ const item = asRecord(option);
+ // Exactly one correct answer: the first one flagged wins.
+ const correct = Boolean(item.correct) && !seenCorrect;
+ if (correct) {
+ seenCorrect = true;
+ }
+ return { id: keepOrCreateId(item.id, 'option', createId), text: toText(item.text), correct };
+ });
+ if (options.length === 0) {
+ options.push(
+ { id: createId('option'), text: '', correct: true },
+ { id: createId('option'), text: '', correct: false },
+ );
+ } else if (!seenCorrect) {
+ const first = options[0];
+ if (first) {
+ first.correct = true;
+ }
+ }
+ return {
+ prompt: toText(raw.prompt),
+ type: 'single-choice',
+ options,
+ feedbackCorrect: toText(raw.feedbackCorrect),
+ feedbackIncorrect: toText(raw.feedbackIncorrect),
+ attemptsAllowed: clamp(Math.round(toNumber(raw.attemptsAllowed, 0)), 0, MAX_ATTEMPTS_ALLOWED),
+ };
+}
+
+function normalizeInformationPayload(raw: Record): InformationPayload {
+ return { html: toText(raw.html) };
+}
+
+function normalizeImagePayload(raw: Record): ImagePayload {
+ return { src: stripUnsafeUrl(raw.src), alt: toText(raw.alt), caption: toText(raw.caption) };
+}
+
+function normalizeVideoPayload(raw: Record): VideoPayload {
+ return { src: stripUnsafeUrl(raw.src), poster: stripUnsafeUrl(raw.poster) };
+}
+
+function normalizeLinkPayload(raw: Record): LinkPayload {
+ return { url: stripUnsafeUrl(raw.url), newTab: raw.newTab !== false };
+}
+
+function toActionType(value: unknown): MarkerActionType {
+ return (MARKER_ACTION_TYPES as readonly string[]).includes(String(value))
+ ? (value as MarkerActionType)
+ : 'information';
+}
+
+export function normalizeAction(value: unknown, createId: IdFactory = defaultIdFactory): MarkerAction {
+ const raw = asRecord(value);
+ const type = toActionType(raw.type);
+ const payload = asRecord(raw.payload);
+ switch (type) {
+ case 'image':
+ return { type, payload: normalizeImagePayload(payload) };
+ case 'video':
+ return { type, payload: normalizeVideoPayload(payload) };
+ case 'link':
+ return { type, payload: normalizeLinkPayload(payload) };
+ case 'question':
+ return { type, payload: normalizeQuestion(payload, createId) };
+ case 'information':
+ return { type, payload: normalizeInformationPayload(payload) };
+ }
+ // Exhaustiveness guard: adding a MarkerActionType without a branch above is
+ // a compile error here, not a silent fallthrough.
+ const unreachable: never = type;
+ void unreachable;
+ return { type: 'information', payload: { html: '' } };
+}
+
+function toIcon(value: unknown): MarkerIcon {
+ return (MARKER_ICONS as readonly string[]).includes(String(value)) ? (value as MarkerIcon) : 'circle';
+}
+
+export function normalizeMarker(value: unknown, index: number, createId: IdFactory = defaultIdFactory): Marker {
+ const raw = asRecord(value);
+ const order = toNumber(raw.order, Number.NaN);
+ return {
+ id: keepOrCreateId(raw.id, 'marker', createId),
+ label: toText(raw.label),
+ description: toText(raw.description),
+ icon: toIcon(raw.icon),
+ order: Number.isFinite(order) ? order : index,
+ anchor: normalizeAnchor(raw.anchor),
+ camera: normalizeCamera(raw.camera),
+ action: normalizeAction(raw.action, createId),
+ };
+}
+
+export function normalizeInteraction(value: unknown, createId: IdFactory = defaultIdFactory): InteractionSettings {
+ const raw = asRecord(value);
+ const markers = (Array.isArray(raw.markers) ? raw.markers : []).map((marker, index) =>
+ normalizeMarker(marker, index, createId),
+ );
+ markers.sort((a, b) => a.order - b.order);
+ markers.forEach((marker, index) => {
+ marker.order = index;
+ });
+ const ids = markers.map(marker => marker.id);
+ const activeMarkerId = toText(raw.activeMarkerId);
+ return {
+ enabled: Boolean(raw.enabled),
+ guidedMode: Boolean(raw.guidedMode),
+ wrapNavigation: Boolean(raw.wrapNavigation),
+ showMarkerLabels: raw.showMarkerLabels !== false,
+ activeMarkerId: ids.includes(activeMarkerId) ? activeMarkerId : '',
+ markers,
+ };
+}
+
+export function normalizeAnimation(value: unknown): AnimationSettings {
+ const raw = asRecord(value);
+ return {
+ enabled: Boolean(raw.enabled),
+ name: toText(raw.name),
+ speed: clamp(toNumber(raw.speed, 1), 0.1, 3),
+ };
+}
+
+/**
+ * Normalize the SCORM block.
+ *
+ * Accepts both the canonical nested shape (`{ mode, weighted, saveButtonText }`)
+ * and the shared gamification framework's flat naming (`isScorm`, `weighted`,
+ * `textButtonScorm`), because that framework is the only other producer of
+ * these values. This is the single place the two vocabularies meet.
+ */
+export function normalizeScorm(value: unknown): ScormSettings {
+ const raw = asRecord(value);
+ const mode = clamp(toInteger(raw.mode ?? raw.isScorm, 0), 0, 2) as ScormSettings['mode'];
+ return {
+ mode,
+ weighted: clamp(toNumber(raw.weighted, 100), 1, 100),
+ saveButtonText: toText(raw.saveButtonText ?? raw.textButtonScorm),
+ };
+}
+
+/** The document a brand-new 3D Viewer iDevice starts from. */
+export function createDefaultDocument(): ThreeDViewerDocumentV2 {
+ return {
+ schemaVersion: 2,
+ src: '',
+ alt: '',
+ modelColor: DEFAULT_MODEL_COLOR,
+ backgroundColor: DEFAULT_BACKGROUND_COLOR,
+ cameraControls: true,
+ autoRotate: true,
+ autoRotateSpeed: 30,
+ showNavControls: false,
+ animation: { enabled: false, name: '', speed: 1 },
+ interaction: {
+ enabled: false,
+ guidedMode: false,
+ wrapNavigation: false,
+ showMarkerLabels: true,
+ activeMarkerId: '',
+ markers: [],
+ },
+ scorm: { mode: 0, weighted: 100, saveButtonText: '' },
+ };
+}
+
+/**
+ * Normalize an already-parsed record into a canonical schema-v2 document.
+ * Callers that start from untrusted input should use `hydrateDocument` instead,
+ * which handles version gating first.
+ */
+export function normalizeDocument(value: unknown, createId: IdFactory = defaultIdFactory): ThreeDViewerDocumentV2 {
+ const raw = asRecord(value);
+ const defaults = createDefaultDocument();
+ const showNavControls = typeof raw.showNavControls === 'boolean' ? raw.showNavControls : defaults.showNavControls;
+ const autoRotate = typeof raw.autoRotate === 'boolean' ? raw.autoRotate : defaults.autoRotate;
+ return {
+ schemaVersion: 2,
+ src: normalizeModelSource(raw.src),
+ alt: toText(raw.alt),
+ modelColor: normalizeColor(raw.modelColor, DEFAULT_MODEL_COLOR),
+ backgroundColor: normalizeColor(raw.backgroundColor, DEFAULT_BACKGROUND_COLOR),
+ cameraControls: typeof raw.cameraControls === 'boolean' ? raw.cameraControls : defaults.cameraControls,
+ // Mutually exclusive: manual nav controls win over auto-rotation.
+ autoRotate: showNavControls ? false : autoRotate,
+ autoRotateSpeed: clamp(toNumber(raw.autoRotateSpeed, 30), 1, 90),
+ showNavControls,
+ animation: normalizeAnimation(raw.animation),
+ interaction: normalizeInteraction(raw.interaction, createId),
+ scorm: normalizeScorm(raw.scorm ?? raw),
+ };
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/scoring.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/scoring.spec.ts
new file mode 100644
index 000000000..e9af8df25
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/scoring.spec.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+import { makeMarker, sequentialIds } from '../test/helpers';
+import { computeScore, gradeSingleChoice, isActivityComplete, questionMarkers, SCORE_SCALE } from './scoring';
+import { normalizeQuestion } from './schema';
+
+function questionMarker(id: string): ReturnType {
+ return makeMarker({ id, action: { type: 'question', payload: { prompt: id } } }, 0, sequentialIds());
+}
+
+describe('gradeSingleChoice', () => {
+ it('grades the chosen option by id', () => {
+ const question = normalizeQuestion(
+ {
+ options: [
+ { id: 'a', text: 'A', correct: false },
+ { id: 'b', text: 'B', correct: true },
+ ],
+ },
+ sequentialIds(),
+ );
+ expect(gradeSingleChoice(question, 'b')).toBe(true);
+ expect(gradeSingleChoice(question, 'a')).toBe(false);
+ expect(gradeSingleChoice(question, 'missing')).toBe(false);
+ });
+});
+
+describe('questionMarkers', () => {
+ it('selects only the question markers, in order', () => {
+ const markers = [makeMarker({ id: 'info' }, 0, sequentialIds()), questionMarker('q1'), questionMarker('q2')];
+ expect(questionMarkers(markers).map(marker => marker.id)).toEqual(['q1', 'q2']);
+ });
+});
+
+describe('computeScore', () => {
+ it('is the fraction of question markers answered correctly, on the 0..10 scale', () => {
+ const markers = [questionMarker('q1'), questionMarker('q2'), questionMarker('q3'), questionMarker('q4')];
+ expect(computeScore(markers, new Set(['q1']))).toBe(SCORE_SCALE / 4);
+ expect(computeScore(markers, new Set(['q1', 'q2']))).toBe(SCORE_SCALE / 2);
+ expect(computeScore(markers, new Set(['q1', 'q2', 'q3', 'q4']))).toBe(SCORE_SCALE);
+ });
+
+ it('ignores non-question markers and unknown ids', () => {
+ const markers = [makeMarker({ id: 'info' }, 0, sequentialIds()), questionMarker('q1')];
+ expect(computeScore(markers, new Set(['info', 'ghost']))).toBe(0);
+ expect(computeScore(markers, new Set(['q1']))).toBe(SCORE_SCALE);
+ });
+
+ it('is 0 when there is nothing to score', () => {
+ expect(computeScore([], new Set())).toBe(0);
+ expect(computeScore([makeMarker({ id: 'info' }, 0, sequentialIds())], new Set(['info']))).toBe(0);
+ });
+});
+
+describe('isActivityComplete', () => {
+ it('is true only once every question marker is correct', () => {
+ const markers = [questionMarker('q1'), questionMarker('q2')];
+ expect(isActivityComplete(markers, new Set(['q1']))).toBe(false);
+ expect(isActivityComplete(markers, new Set(['q1', 'q2']))).toBe(true);
+ });
+
+ it('is false when there are no question markers at all', () => {
+ expect(isActivityComplete([], new Set())).toBe(false);
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/scoring.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/scoring.ts
new file mode 100644
index 000000000..db3ec3224
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/scoring.ts
@@ -0,0 +1,43 @@
+/**
+ * Pure grading and score arithmetic. No browser globals, no SCORM transport —
+ * the transport lives in `edition/scorm.ts` and `export/scorm.ts` and calls in
+ * here for every number it reports.
+ */
+
+import type { Marker, SingleChoiceQuestion } from './types';
+
+/** The eXe gamification framework reports scores on a 0..10 scale. */
+export const SCORE_SCALE = 10;
+
+/** Grade a single-choice answer by option id. */
+export function gradeSingleChoice(question: SingleChoiceQuestion, selectedOptionId: string): boolean {
+ const chosen = question.options.find(option => option.id === selectedOptionId);
+ return Boolean(chosen?.correct);
+}
+
+/** Every marker whose action is a question, in authoring order. */
+export function questionMarkers(markers: readonly Marker[]): Marker[] {
+ return markers.filter(marker => marker.action.type === 'question');
+}
+
+/**
+ * The activity score: correctly answered question markers over the total
+ * number of question markers, on the 0..10 convention.
+ *
+ * `correctMarkerIds` is a set so a marker answered correctly twice still counts
+ * once; markers that are not questions (or no longer exist) are ignored.
+ */
+export function computeScore(markers: readonly Marker[], correctMarkerIds: ReadonlySet): number {
+ const questions = questionMarkers(markers);
+ if (questions.length === 0) {
+ return 0;
+ }
+ const correct = questions.filter(marker => correctMarkerIds.has(marker.id)).length;
+ return (correct * SCORE_SCALE) / questions.length;
+}
+
+/** Whether every question marker has been answered correctly. */
+export function isActivityComplete(markers: readonly Marker[], correctMarkerIds: ReadonlySet): boolean {
+ const questions = questionMarkers(markers);
+ return questions.length > 0 && questions.every(marker => correctMarkerIds.has(marker.id));
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/types.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/types.ts
new file mode 100644
index 000000000..02e36261f
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/types.ts
@@ -0,0 +1,172 @@
+/**
+ * The canonical 3D Viewer document model (schema v2) and every type derived
+ * from it. This module is the single source of truth for both the edition and
+ * the export bundle — neither one re-declares the shape.
+ */
+
+/** The only schema version this iDevice writes. */
+export const SCHEMA_VERSION = 2 as const;
+
+/** Model formats the viewer can detect from a file extension. */
+export type ModelType = 'stl' | 'glb' | 'gltf' | 'obj' | 'fbx' | 'unknown';
+
+/** Icons an author can pick for a marker. */
+export const MARKER_ICONS = ['circle', 'pin', 'info', 'question', 'star'] as const;
+export type MarkerIcon = (typeof MARKER_ICONS)[number];
+
+/** Marker action discriminators, in authoring order. */
+export const MARKER_ACTION_TYPES = ['information', 'image', 'video', 'link', 'question'] as const;
+export type MarkerActionType = (typeof MARKER_ACTION_TYPES)[number];
+
+export interface Vector3 {
+ x: number;
+ y: number;
+ z: number;
+}
+
+/**
+ * Where a marker sits on the model, in renderer-independent terms: a point and
+ * a surface normal in the model's own (normalized, centered) space. Adapters
+ * translate this into `` hotspot attributes or Three.js world
+ * coordinates; the controller never sees renderer specifics.
+ */
+export interface MarkerAnchor {
+ position: Vector3;
+ normal: Vector3;
+ /** Best-effort surface hint for ``; empty when unknown. */
+ surface: string;
+}
+
+/** An opaque, adapter-defined camera view captured for a marker. */
+export interface MarkerCamera {
+ orbit: string;
+ target: string;
+ fieldOfView: string;
+}
+
+export interface QuestionOption {
+ id: string;
+ text: string;
+ correct: boolean;
+}
+
+export interface SingleChoiceQuestion {
+ prompt: string;
+ type: 'single-choice';
+ options: QuestionOption[];
+ feedbackCorrect: string;
+ feedbackIncorrect: string;
+ /** 0 means unlimited. */
+ attemptsAllowed: number;
+}
+
+export interface InformationPayload {
+ html: string;
+}
+
+export interface ImagePayload {
+ src: string;
+ alt: string;
+ caption: string;
+}
+
+export interface VideoPayload {
+ src: string;
+ poster: string;
+}
+
+export interface LinkPayload {
+ url: string;
+ newTab: boolean;
+}
+
+interface BaseMarkerAction {
+ type: TType;
+ payload: TPayload;
+}
+
+export type MarkerAction =
+ | BaseMarkerAction<'information', InformationPayload>
+ | BaseMarkerAction<'image', ImagePayload>
+ | BaseMarkerAction<'video', VideoPayload>
+ | BaseMarkerAction<'link', LinkPayload>
+ | BaseMarkerAction<'question', SingleChoiceQuestion>;
+
+export interface Marker {
+ id: string;
+ label: string;
+ description: string;
+ icon: MarkerIcon;
+ /** Contiguous 0-based position; normalization re-indexes it. */
+ order: number;
+ anchor: MarkerAnchor;
+ camera: MarkerCamera;
+ action: MarkerAction;
+}
+
+export interface InteractionSettings {
+ enabled: boolean;
+ guidedMode: boolean;
+ wrapNavigation: boolean;
+ showMarkerLabels: boolean;
+ /** Empty, or the id of a marker that exists in `markers`. */
+ activeMarkerId: string;
+ markers: Marker[];
+}
+
+export interface AnimationSettings {
+ enabled: boolean;
+ name: string;
+ speed: number;
+}
+
+/**
+ * SCORM scoring configuration for question markers.
+ *
+ * `mode` mirrors the shared gamification framework's `isScorm` convention:
+ * 0 = off, 1 = save the score automatically, 2 = save through a button.
+ */
+export interface ScormSettings {
+ mode: 0 | 1 | 2;
+ weighted: number;
+ saveButtonText: string;
+}
+
+/** The canonical persisted document. */
+export interface ThreeDViewerDocumentV2 {
+ schemaVersion: typeof SCHEMA_VERSION;
+ src: string;
+ alt: string;
+ modelColor: string;
+ backgroundColor: string;
+ cameraControls: boolean;
+ autoRotate: boolean;
+ autoRotateSpeed: number;
+ showNavControls: boolean;
+ animation: AnimationSettings;
+ interaction: InteractionSettings;
+ scorm: ScormSettings;
+}
+
+/** The result of turning unknown persisted data into a canonical document. */
+export type HydrationResult =
+ | { status: 'ok'; document: ThreeDViewerDocumentV2 }
+ | { status: 'unsupported-version'; version: number; original: unknown }
+ | { status: 'invalid'; reason: string; original: unknown };
+
+/** Everything the viewer needs to render a model, independent of markers. */
+export interface ViewerDisplayConfig {
+ src: string;
+ type: ModelType | '';
+ alt: string;
+ modelColor: string;
+ backgroundColor: string;
+ cameraControls: boolean;
+ autoRotate: boolean;
+ autoRotateSpeed: number;
+ showNavControls: boolean;
+ animation: AnimationSettings;
+}
+
+/** Creates stable marker/option identifiers; injected so tests stay deterministic. */
+export type IdFactory = (prefix: string) => string;
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/urls.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/urls.spec.ts
new file mode 100644
index 000000000..a55b8036b
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/urls.spec.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest';
+import { isAbsoluteUrl, joinAppUrl, normalizePath, safeUrl, stripQueryAndHash, stripUnsafeUrl } from './urls';
+
+describe('stripUnsafeUrl', () => {
+ it('rejects the ephemeral and executable schemes', () => {
+ for (const url of ['blob:http://x/1', 'data:image/png;base64,AA', 'javascript:alert(1)', ' vbscript:x']) {
+ expect(stripUnsafeUrl(url)).toBe('');
+ }
+ });
+
+ it('keeps and trims everything else', () => {
+ expect(stripUnsafeUrl(' asset://a.png ')).toBe('asset://a.png');
+ expect(stripUnsafeUrl('https://example.org/a')).toBe('https://example.org/a');
+ expect(stripUnsafeUrl(undefined)).toBe('');
+ });
+});
+
+describe('safeUrl', () => {
+ it('allows the safe schemes plus blob:, which only ever appears at render time', () => {
+ for (const url of ['https://a', 'http://a', 'mailto:a@b', 'tel:+1', 'asset://a.png', 'blob:http://x/1']) {
+ expect(safeUrl(url)).toBe(url);
+ }
+ });
+
+ it('allows relative and fragment URLs', () => {
+ expect(safeUrl('page.html')).toBe('page.html');
+ expect(safeUrl('#anchor')).toBe('#anchor');
+ expect(safeUrl('../content/resources/a.png')).toBe('../content/resources/a.png');
+ });
+
+ it('rejects executable and unknown schemes', () => {
+ for (const url of ['javascript:alert(1)', ' JavaScript:alert(1)', 'vbscript:x', 'gopher://a']) {
+ expect(safeUrl(url)).toBe('');
+ }
+ });
+
+ it('returns an empty string for empty or non-string input', () => {
+ expect(safeUrl('')).toBe('');
+ expect(safeUrl(null)).toBe('');
+ });
+});
+
+describe('normalizePath', () => {
+ it('unifies slashes and strips the leading one', () => {
+ expect(normalizePath('\\a\\b/c')).toBe('a/b/c');
+ expect(normalizePath('/a/b')).toBe('a/b');
+ });
+
+ it('passes absolute URLs through untouched', () => {
+ expect(normalizePath('https://example.org/a')).toBe('https://example.org/a');
+ expect(normalizePath('//cdn/a.glb')).toBe('//cdn/a.glb');
+ });
+
+ it('returns an empty string for nullish input', () => {
+ expect(normalizePath(undefined)).toBe('');
+ expect(normalizePath(' ')).toBe('');
+ });
+});
+
+describe('stripQueryAndHash', () => {
+ it('drops the query string and the fragment', () => {
+ expect(stripQueryAndHash('a/b.glb?v=2#frag')).toBe('a/b.glb');
+ expect(stripQueryAndHash('a/b.glb')).toBe('a/b.glb');
+ });
+});
+
+describe('isAbsoluteUrl', () => {
+ it('recognises protocol and protocol-relative URLs', () => {
+ expect(isAbsoluteUrl('https://a')).toBe(true);
+ expect(isAbsoluteUrl('//a')).toBe(true);
+ expect(isAbsoluteUrl('/a')).toBe(false);
+ expect(isAbsoluteUrl('a')).toBe(false);
+ });
+});
+
+describe('joinAppUrl', () => {
+ it('joins the base URL, the base path and the path', () => {
+ expect(joinAppUrl('https://host', 'app', 'files/a.js')).toBe('https://host/app/files/a.js');
+ });
+
+ it('tolerates stray slashes on every part', () => {
+ expect(joinAppUrl('https://host///', '/app/', '///files/a.js')).toBe('https://host/app/files/a.js');
+ });
+
+ it('returns a rooted path when there is no base', () => {
+ expect(joinAppUrl('', '', 'files/a.js')).toBe('/files/a.js');
+ expect(joinAppUrl(undefined, undefined, 'files/a.js')).toBe('/files/a.js');
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/urls.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/urls.ts
new file mode 100644
index 000000000..04dc6d54e
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/urls.ts
@@ -0,0 +1,83 @@
+/**
+ * URL safety and path helpers.
+ *
+ * Two distinct questions live here and must not be confused:
+ *
+ * - `stripUnsafeUrl` decides what may be PERSISTED. It rejects the ephemeral
+ * schemes (`blob:`, `data:`) alongside the executable ones, because a stored
+ * blob URL 404s on reload and a stored data: URL bloats the document.
+ * - `safeUrl` decides what may be RENDERED. `blob:` is fine at render time
+ * (the editor preview resolves `asset://` to one), executable schemes are not.
+ */
+
+const EXECUTABLE_SCHEME = /^\s*(javascript|vbscript):/i;
+const EPHEMERAL_OR_EXECUTABLE_SCHEME = /^\s*(blob:|data:|javascript:|vbscript:)/i;
+const ALLOWED_RENDER_SCHEME = /^(https?:|mailto:|tel:|asset:|blob:)/i;
+const HAS_EXPLICIT_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
+const ABSOLUTE_URL = /^(https?:)?\/\//i;
+
+/** Strip a URL that must never be persisted; returns '' when rejected. */
+export function stripUnsafeUrl(value: unknown): string {
+ const raw = typeof value === 'string' ? value : '';
+ return EPHEMERAL_OR_EXECUTABLE_SCHEME.test(raw) ? '' : raw.trim();
+}
+
+/**
+ * Allow only schemes that are safe to put in an `href`/`src` at render time.
+ * Scheme-less values are treated as relative URLs and pass through.
+ */
+export function safeUrl(value: unknown): string {
+ const raw = typeof value === 'string' ? value.trim() : '';
+ if (!raw) {
+ return '';
+ }
+ if (EXECUTABLE_SCHEME.test(raw)) {
+ return '';
+ }
+ if (ALLOWED_RENDER_SCHEME.test(raw)) {
+ return raw;
+ }
+ return HAS_EXPLICIT_SCHEME.test(raw) ? '' : raw;
+}
+
+/** True for `//host/...` and `http(s)://host/...`. */
+export function isAbsoluteUrl(value: string): boolean {
+ return ABSOLUTE_URL.test(value);
+}
+
+/** Trim, unify slashes and drop the leading slash; absolute URLs pass through. */
+export function normalizePath(value: unknown): string {
+ const clean = String(value ?? '')
+ .trim()
+ .replace(/\\+/g, '/');
+ if (!clean) {
+ return '';
+ }
+ return isAbsoluteUrl(clean) ? clean : clean.replace(/^\/+/, '');
+}
+
+/** Drop the query string and hash fragment from a path or URL. */
+export function stripQueryAndHash(value: string): string {
+ let out = value;
+ const query = out.indexOf('?');
+ if (query !== -1) {
+ out = out.substring(0, query);
+ }
+ const hash = out.indexOf('#');
+ if (hash !== -1) {
+ out = out.substring(0, hash);
+ }
+ return out;
+}
+
+/**
+ * Join an app-relative path onto the eXeLearning base URL/path.
+ * Always returns a rooted URL so callers never build `foo//bar`.
+ */
+export function joinAppUrl(baseURL: unknown, basePath: unknown, path: unknown): string {
+ const base = String(baseURL ?? '').replace(/\/+$/g, '');
+ const prefixPath = basePath ? `/${String(basePath).replace(/^\/+|\/+$/g, '')}` : '';
+ const prefix = `${base}${prefixPath}`.replace(/\/+$/g, '');
+ const normalized = String(path ?? '').replace(/^\/+/, '');
+ return prefix ? `${prefix}/${normalized}` : `/${normalized}`;
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/test/bundle-contract.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/test/bundle-contract.spec.ts
new file mode 100644
index 000000000..bcfa5cd06
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/test/bundle-contract.spec.ts
@@ -0,0 +1,210 @@
+/**
+ * Generated-bundle contract tests.
+ *
+ * These evaluate the ACTUAL compiled IIFEs (built by
+ * `scripts/build-idevices.ts`) inside the happy-dom window and assert the
+ * classic-script contracts eXeLearning depends on. They catch bundling problems
+ * — a broken entry point, a tree-shaken global, a chunked output — that
+ * source-level imports would never detect.
+ */
+
+import { execSync } from 'node:child_process';
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
+
+const ideviceRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+const repoRoot = join(ideviceRoot, '..', '..', '..', '..', '..');
+const editionBundle = join(ideviceRoot, 'edition', 'three-d-viewer.js');
+const exportBundle = join(ideviceRoot, 'export', 'three-d-viewer.js');
+
+interface ContractWindow {
+ $exeDevice?: { i18n?: { name?: string }; init?: unknown; save?: unknown; handleMarkerPlaced?: unknown };
+ $threedviewer?: { renderView?: unknown; renderBehaviour?: unknown; init?: unknown; resolveBootConfig?: unknown };
+ ThreeDViewerExportObject?: new () => { init(node: unknown): boolean; toJSON(): unknown };
+ eXe3DViewer?: { init?: unknown; destroy?: unknown; createInteractionLayer?: unknown; getInstance?: unknown };
+}
+
+function runBundle(path: string): void {
+ // Evaluate as a classic script: an IIFE with no imports and no exports.
+ new Function(readFileSync(path, 'utf-8'))();
+}
+
+function contractWindow(): ContractWindow {
+ return window as unknown as ContractWindow;
+}
+
+beforeAll(() => {
+ // happy-dom never upgrades the real (it needs WebGL), so
+ // register a stand-in and let the bundles resolve their loader immediately.
+ if (!customElements.get('model-viewer')) {
+ customElements.define('model-viewer', class extends HTMLElement {});
+ }
+ if (!existsSync(editionBundle) || !existsSync(exportBundle)) {
+ execSync('bun scripts/build-idevices.ts --only three-d-viewer', { cwd: repoRoot, stdio: 'pipe' });
+ }
+});
+
+afterEach(() => {
+ const contract = contractWindow();
+ delete contract.$exeDevice;
+ delete contract.$threedviewer;
+ delete contract.ThreeDViewerExportObject;
+ delete contract.eXe3DViewer;
+});
+
+describe.each([
+ ['edition', editionBundle],
+ ['export', exportBundle],
+])('generated bundle shape — %s', (_name, bundle) => {
+ it('is a self-contained classic script (no module syntax, no chunk imports)', () => {
+ const code = readFileSync(bundle, 'utf-8');
+ expect(code).not.toMatch(/^\s*import[\s{]/m);
+ expect(code).not.toMatch(/^\s*export[\s{]/m);
+ expect(code).not.toContain('require(');
+ expect(code.trimStart().startsWith('(() => {')).toBe(true);
+ });
+
+ it('links a source map rather than inlining one', () => {
+ const code = readFileSync(bundle, 'utf-8');
+ expect(code).toContain('sourceMappingURL=three-d-viewer.js.map');
+ expect(code).not.toContain('sourceMappingURL=data:');
+ expect(existsSync(`${bundle}.map`)).toBe(true);
+ });
+});
+
+describe('generated bundle contract — edition', () => {
+ it('exposes window.$exeDevice with the JSON-iDevice editor contract', () => {
+ runBundle(editionBundle);
+ const device = contractWindow().$exeDevice;
+ expect(device).toBeTruthy();
+ expect(typeof device?.init).toBe('function');
+ expect(typeof device?.save).toBe('function');
+ expect(typeof device?.handleMarkerPlaced).toBe('function');
+ expect(typeof device?.i18n?.name).toBe('string');
+ });
+
+ it('publishes the shared viewer runtime its live preview drives', () => {
+ runBundle(editionBundle);
+ const runtime = contractWindow().eXe3DViewer;
+ expect(typeof runtime?.init).toBe('function');
+ expect(typeof runtime?.destroy).toBe('function');
+ expect(typeof runtime?.getInstance).toBe('function');
+ expect(typeof runtime?.createInteractionLayer).toBe('function');
+ });
+
+ it('renders its editor into a host element', async () => {
+ runBundle(editionBundle);
+ const device = contractWindow().$exeDevice as {
+ init: (element: HTMLElement, data?: unknown) => Promise;
+ };
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ try {
+ await device.init(host, { schemaVersion: 2, src: 'content/resources/a.glb' });
+ expect(host.querySelector('#threeDViewerEditor')).not.toBeNull();
+ expect(host.querySelector('#threeD3DModelFile')?.value).toBe('content/resources/a.glb');
+ } finally {
+ document.body.removeChild(host);
+ }
+ });
+});
+
+describe('generated bundle contract — export', () => {
+ it('exposes window.$threedviewer with the learner-runtime contract', () => {
+ runBundle(exportBundle);
+ const runtime = contractWindow().$threedviewer;
+ expect(runtime).toBeTruthy();
+ expect(typeof runtime?.renderView).toBe('function');
+ expect(typeof runtime?.renderBehaviour).toBe('function');
+ expect(typeof runtime?.resolveBootConfig).toBe('function');
+ expect(typeof runtime?.init).toBe('function');
+ });
+
+ it('exposes window.ThreeDViewerExportObject and window.eXe3DViewer', () => {
+ runBundle(exportBundle);
+ const contract = contractWindow();
+ expect(typeof contract.ThreeDViewerExportObject).toBe('function');
+ const helper = new (
+ contract.ThreeDViewerExportObject as new () => {
+ init(node: unknown): boolean;
+ toJSON(): unknown;
+ }
+ )();
+ expect(helper.init(null)).toBe(true);
+ expect(helper.toJSON()).toEqual({});
+ expect(typeof contract.eXe3DViewer?.createInteractionLayer).toBe('function');
+ });
+
+ it('renders a schema-v2 document end to end through the compiled bundle', () => {
+ runBundle(exportBundle);
+ const runtime = contractWindow().$threedviewer as {
+ renderView: (data: unknown, accessibility?: unknown, template?: string) => string;
+ renderBehaviour: (data: unknown, accessibility?: unknown, ideviceId?: string) => boolean;
+ };
+ const document_ = {
+ schemaVersion: 2,
+ src: 'content/resources/cube.glb',
+ alt: 'Cube',
+ interaction: {
+ enabled: true,
+ guidedMode: true,
+ markers: [
+ {
+ id: 'marker-1',
+ label: 'Summit',
+ anchor: { position: { x: 0, y: 1, z: 0 }, normal: { x: 0, y: 1, z: 0 } },
+ action: { type: 'information', payload: { html: 'Top
' } },
+ },
+ ],
+ },
+ scorm: { mode: 1, weighted: 100, saveButtonText: '' },
+ };
+
+ const html = runtime.renderView({ ...document_, ideviceId: 'bundle-smoke' }, undefined, '{content}');
+ expect(html).toContain('three-d-viewer-wrapper');
+ expect(html).toContain('data-model-src="content/resources/cube.glb"');
+ expect(html).toContain('tdv-interaction-data');
+ expect(html).toContain('tdv-fallback');
+ expect(html).toContain('Summit');
+
+ const host = document.createElement('div');
+ host.innerHTML = html;
+ document.body.appendChild(host);
+ try {
+ expect(runtime.renderBehaviour(document_, undefined, 'bundle-smoke')).toBe(true);
+ } finally {
+ document.body.removeChild(host);
+ }
+ });
+
+ it('never leaks a blob: URL from marker media into the rendered markup', () => {
+ runBundle(exportBundle);
+ const runtime = contractWindow().$threedviewer as {
+ renderView: (data: unknown, accessibility?: unknown, template?: string) => string;
+ };
+ const html = runtime.renderView(
+ {
+ schemaVersion: 2,
+ src: 'content/resources/cube.glb',
+ interaction: {
+ enabled: true,
+ markers: [{ id: 'm1', action: { type: 'image', payload: { src: 'blob:http://x/1' } } }],
+ },
+ },
+ undefined,
+ '{content}',
+ );
+ expect(html).not.toContain('blob:');
+ });
+});
+
+describe('generated bundles side by side', () => {
+ it('share one viewer runtime instead of replacing each other', () => {
+ runBundle(exportBundle);
+ const first = contractWindow().eXe3DViewer;
+ runBundle(editionBundle);
+ expect(contractWindow().eXe3DViewer).toBe(first);
+ });
+});
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/test/fixtures/legacy/unversioned.json b/public/files/perm/idevices/base/three-d-viewer/src/test/fixtures/legacy/unversioned.json
new file mode 100644
index 000000000..f38a89216
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/test/fixtures/legacy/unversioned.json
@@ -0,0 +1,11 @@
+{
+ "src": "asset://8f3c2a10-0b41-4f0f-9a1c-2b7d5e6f7a90.glb",
+ "alt": "A cube",
+ "modelColor": "#AABBCC",
+ "backgroundColor": "#ffffff",
+ "cameraControls": true,
+ "autoRotate": true,
+ "autoRotateSpeed": 45,
+ "showNavControls": false,
+ "animation": { "enabled": true, "name": "Spin", "speed": 1.5 }
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/test/fixtures/schema-v2/future.json b/public/files/perm/idevices/base/three-d-viewer/src/test/fixtures/schema-v2/future.json
new file mode 100644
index 000000000..a65b21a4f
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/test/fixtures/schema-v2/future.json
@@ -0,0 +1,5 @@
+{
+ "schemaVersion": 99,
+ "src": "asset://future.glb",
+ "somethingNew": { "kind": "unknown-to-this-build" }
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/test/fixtures/schema-v2/with-markers.json b/public/files/perm/idevices/base/three-d-viewer/src/test/fixtures/schema-v2/with-markers.json
new file mode 100644
index 000000000..b488c8f0a
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/test/fixtures/schema-v2/with-markers.json
@@ -0,0 +1,55 @@
+{
+ "schemaVersion": 2,
+ "src": "asset://8f3c2a10-0b41-4f0f-9a1c-2b7d5e6f7a90.glb",
+ "alt": "A cube",
+ "modelColor": "#aabbcc",
+ "backgroundColor": "#ffffff",
+ "cameraControls": true,
+ "autoRotate": false,
+ "autoRotateSpeed": 30,
+ "showNavControls": true,
+ "animation": { "enabled": false, "name": "", "speed": 1 },
+ "interaction": {
+ "enabled": true,
+ "guidedMode": true,
+ "wrapNavigation": false,
+ "showMarkerLabels": true,
+ "activeMarkerId": "",
+ "markers": [
+ {
+ "id": "marker-summit",
+ "label": "Summit",
+ "description": "The top",
+ "icon": "info",
+ "order": 0,
+ "anchor": { "position": { "x": 0, "y": 1, "z": 0 }, "normal": { "x": 0, "y": 1, "z": 0 }, "surface": "" },
+ "camera": { "orbit": "1rad 1rad 3m", "target": "0m 0m 0m", "fieldOfView": "45deg" },
+ "action": { "type": "information", "payload": { "html": "The highest point.
" } }
+ },
+ {
+ "id": "marker-quiz",
+ "label": "Quiz",
+ "description": "",
+ "icon": "question",
+ "order": 1,
+ "anchor": { "position": { "x": 1, "y": 0, "z": 0 }, "normal": { "x": 1, "y": 0, "z": 0 }, "surface": "" },
+ "camera": { "orbit": "", "target": "", "fieldOfView": "" },
+ "action": {
+ "type": "question",
+ "payload": {
+ "prompt": "Is Teide a volcano?",
+ "type": "single-choice",
+ "options": [
+ { "id": "option-yes", "text": "Yes", "correct": true },
+ { "id": "option-no", "text": "No", "correct": false }
+ ],
+ "feedbackCorrect": "Right!",
+ "feedbackIncorrect": "Try again",
+ "attemptsAllowed": 1
+ }
+ }
+ }
+ ]
+ },
+ "scorm": { "mode": 1, "weighted": 80, "saveButtonText": "" }
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/test/helpers.ts b/public/files/perm/idevices/base/three-d-viewer/src/test/helpers.ts
new file mode 100644
index 000000000..f73d5c635
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/test/helpers.ts
@@ -0,0 +1,105 @@
+/**
+ * Shared test helpers: deterministic ids, DOM scaffolding, viewer instances and
+ * fixture loading.
+ */
+
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createInstance } from '../runtime/lifecycle';
+import type { ViewerInstance, ViewerOptions } from '../runtime/types';
+import type { IdFactory, InteractionSettings, Marker, ThreeDViewerDocumentV2 } from '../shared/types';
+import { normalizeInteraction, normalizeMarker } from '../shared/schema';
+import { hydrateDocument } from '../shared/migration';
+import { StubCamera, StubObject3D } from './three-stub';
+
+const testDir = dirname(fileURLToPath(import.meta.url));
+
+/** A counter-based id factory, so every test run produces the same ids. */
+export function sequentialIds(): IdFactory {
+ let counter = 0;
+ return prefix => `${prefix}-${++counter}`;
+}
+
+/** Read a JSON fixture from `src/test/fixtures/`. */
+export function readFixture(relativePath: string): unknown {
+ return JSON.parse(readFileSync(join(testDir, 'fixtures', relativePath), 'utf-8')) as unknown;
+}
+
+/** A wrapper element attached to the document, cleaned up by the caller. */
+export function createWrapper(id = 'tdv-test'): HTMLElement {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'three-d-viewer-wrapper';
+ wrapper.id = id;
+ wrapper.setAttribute('data-three-d', '');
+ document.body.appendChild(wrapper);
+ return wrapper;
+}
+
+/** Remove every element this suite appended to the document body. */
+export function resetDom(): void {
+ document.body.innerHTML = '';
+}
+
+/** A viewer instance with a stub mesh, camera and canvas, ready for the STL adapter. */
+export function createStubInstance(wrapper: HTMLElement, overrides: Partial = {}): ViewerInstance {
+ const options: ViewerOptions = {
+ src: 'asset://model.stl',
+ type: 'stl',
+ modelColor: '#888888',
+ backgroundColor: '#f5f5f5',
+ cameraControls: true,
+ autoRotate: false,
+ autoRotateSpeed: 30,
+ ...overrides,
+ };
+ const instance = createInstance(wrapper, options);
+ const canvas = document.createElement('canvas');
+ // happy-dom reports zero-size elements; fake a viewport for the projection.
+ Object.defineProperty(canvas, 'clientWidth', { value: 200, configurable: true });
+ Object.defineProperty(canvas, 'clientHeight', { value: 100, configurable: true });
+ canvas.getBoundingClientRect = () =>
+ ({
+ left: 0,
+ top: 0,
+ width: 200,
+ height: 100,
+ right: 200,
+ bottom: 100,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ }) as DOMRect;
+ wrapper.appendChild(canvas);
+ instance.canvas = canvas;
+ instance.mesh = new StubObject3D() as unknown as ThreeObject3D;
+ instance.camera = new StubCamera() as unknown as ThreeCamera;
+ return instance;
+}
+
+/** Build a normalized marker with deterministic ids. */
+export function makeMarker(partial: Record, index = 0, createId: IdFactory = sequentialIds()): Marker {
+ return normalizeMarker(partial, index, createId);
+}
+
+/** Build normalized interaction settings with deterministic ids. */
+export function makeInteraction(
+ partial: Record,
+ createId: IdFactory = sequentialIds(),
+): InteractionSettings {
+ return normalizeInteraction(partial, createId);
+}
+
+/** Hydrate a document, failing the test loudly if the input is not valid. */
+export function makeDocument(partial: unknown, createId: IdFactory = sequentialIds()): ThreeDViewerDocumentV2 {
+ const result = hydrateDocument(partial, createId);
+ if (result.status !== 'ok') {
+ throw new Error(`Expected a valid document, got: ${result.status}`);
+ }
+ return result.document;
+}
+
+/** Flush pending microtasks (and any zero-delay timers) inside a test. */
+export function flush(): Promise {
+ return new Promise(resolve => setTimeout(resolve, 0));
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/test/model-viewer-stub.ts b/public/files/perm/idevices/base/three-d-viewer/src/test/model-viewer-stub.ts
new file mode 100644
index 000000000..b0980b5f8
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/test/model-viewer-stub.ts
@@ -0,0 +1,55 @@
+/**
+ * A `` stand-in for unit tests.
+ *
+ * happy-dom will not register the real custom element (it needs WebGL), so the
+ * stub is an ordinary element carrying the handful of members the adapter and
+ * the export controller call.
+ */
+
+export interface ModelViewerStub extends ModelViewerElement {
+ /** The camera view `captureCamera()` should report. */
+ __camera: { orbit: string; target: string; fieldOfView: number };
+ /** The hit `positionAndNormalFromPoint()` should return, or null. */
+ __hit: { position: string; normal: string } | null;
+ /** Whether `jumpCameraToGoal()` was called. */
+ __jumped: boolean;
+ __played: boolean;
+ __paused: boolean;
+}
+
+/** Create a stub ``, optionally appended to a parent. */
+export function createModelViewerStub(parent?: HTMLElement): ModelViewerStub {
+ const element = document.createElement('model-viewer') as ModelViewerStub;
+ element.__camera = { orbit: '1rad 2rad 3m', target: '0m 0m 0m', fieldOfView: 40 };
+ element.__hit = { position: '1 2 3', normal: '0 1 0' };
+ element.__jumped = false;
+ element.__played = false;
+ element.__paused = false;
+ element.availableAnimations = [];
+ element.getCameraOrbit = () => ({
+ theta: 1,
+ phi: 2,
+ radius: 3,
+ toString: () => element.__camera.orbit,
+ });
+ element.getCameraTarget = () => ({ toString: () => element.__camera.target });
+ element.getFieldOfView = () => element.__camera.fieldOfView;
+ element.jumpCameraToGoal = () => {
+ element.__jumped = true;
+ };
+ element.play = () => {
+ element.__played = true;
+ };
+ element.pause = () => {
+ element.__paused = true;
+ };
+ element.positionAndNormalFromPoint = () =>
+ element.__hit
+ ? {
+ position: { toString: () => element.__hit?.position ?? '' },
+ normal: { toString: () => element.__hit?.normal ?? '' },
+ }
+ : null;
+ parent?.appendChild(element);
+ return element;
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/src/test/three-stub.ts b/public/files/perm/idevices/base/three-d-viewer/src/test/three-stub.ts
new file mode 100644
index 000000000..bd6cfbeb5
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/src/test/three-stub.ts
@@ -0,0 +1,182 @@
+/**
+ * A minimal, deterministic Three.js stub.
+ *
+ * Unit tests must exercise the STL adapter's projection, occlusion and raycast
+ * logic without a WebGL context, so this stub implements only the members the
+ * adapters touch. `project()` is the identity, which makes local coordinates
+ * double as normalized device coordinates and keeps the assertions readable.
+ */
+
+export class StubVector3 {
+ constructor(
+ public x = 0,
+ public y = 0,
+ public z = 0,
+ ) {}
+
+ set(x: number, y: number, z: number): this {
+ this.x = x;
+ this.y = y;
+ this.z = z;
+ return this;
+ }
+
+ clone(): StubVector3 {
+ return new StubVector3(this.x, this.y, this.z);
+ }
+
+ length(): number {
+ return Math.hypot(this.x, this.y, this.z);
+ }
+
+ normalize(): this {
+ const length = this.length() || 1;
+ this.x /= length;
+ this.y /= length;
+ this.z /= length;
+ return this;
+ }
+
+ dot(other: StubVector3): number {
+ return this.x * other.x + this.y * other.y + this.z * other.z;
+ }
+
+ subVectors(a: StubVector3, b: StubVector3): this {
+ return this.set(a.x - b.x, a.y - b.y, a.z - b.z);
+ }
+
+ /** Identity projection: local coordinates are the NDC in these tests. */
+ project(): this {
+ return this;
+ }
+
+ /** Identity transform: the stub mesh is never rotated or scaled. */
+ transformDirection(): this {
+ return this;
+ }
+}
+
+export class StubObject3D {
+ matrixWorld = {};
+ rotation = { y: 0 };
+ children: StubObject3D[] = [];
+ geometry: { dispose: () => void; disposed?: boolean } | null = null;
+ material: unknown = null;
+ /** Uniform world offset applied by `localToWorld` / `worldToLocal`. */
+ offset = new StubVector3(0, 0, 0);
+
+ updateMatrixWorld(): void {}
+
+ localToWorld(target: StubVector3): StubVector3 {
+ return target.set(target.x + this.offset.x, target.y + this.offset.y, target.z + this.offset.z);
+ }
+
+ worldToLocal(target: StubVector3): StubVector3 {
+ return target.set(target.x - this.offset.x, target.y - this.offset.y, target.z - this.offset.z);
+ }
+
+ traverse(callback: (node: StubObject3D) => void): void {
+ callback(this);
+ for (const child of this.children) {
+ child.traverse(callback);
+ }
+ }
+
+ add(child: unknown): void {
+ this.children.push(child as StubObject3D);
+ }
+}
+
+export class StubCamera {
+ position = new StubVector3(0, 0, 3);
+ fov = 45;
+ lookedAt: [number, number, number] | null = null;
+
+ lookAt(x: number, y: number, z: number): void {
+ this.lookedAt = [x, y, z];
+ }
+
+ updateMatrixWorld(): void {}
+}
+
+export interface StubRaycastHit {
+ point: StubVector3;
+ face: { normal: StubVector3 } | null;
+}
+
+/** Hits the next raycast returns; tests push and pop entries. */
+export const raycastHits: StubRaycastHit[] = [];
+
+export class StubRaycaster {
+ lastNdc: unknown = null;
+ lastCamera: unknown = null;
+
+ setFromCamera(ndc: unknown, camera: unknown): void {
+ this.lastNdc = ndc;
+ this.lastCamera = camera;
+ }
+
+ intersectObject(): StubRaycastHit[] {
+ return raycastHits;
+ }
+}
+
+export class StubScene extends StubObject3D {
+ background: unknown = null;
+}
+
+/** Build a `window.THREE`-shaped stub namespace. */
+export function createThreeStub(): ThreeNamespace {
+ class StubRenderer {
+ outputColorSpace: unknown = undefined;
+ toneMapping: unknown = undefined;
+ disposed = false;
+ size: [number, number] = [0, 0];
+ setSize(width: number, height: number): void {
+ this.size = [width, height];
+ }
+ setPixelRatio(): void {}
+ render(): void {}
+ dispose(): void {
+ this.disposed = true;
+ }
+ }
+
+ class StubMaterial {
+ disposed = false;
+ constructor(public params: Record) {}
+ dispose(): void {
+ this.disposed = true;
+ }
+ }
+
+ return {
+ Scene: StubScene as unknown as ThreeNamespace['Scene'],
+ Color: class {
+ constructor(public value: string | number) {}
+ } as unknown as ThreeNamespace['Color'],
+ PerspectiveCamera: StubCamera as unknown as ThreeNamespace['PerspectiveCamera'],
+ WebGLRenderer: StubRenderer as unknown as ThreeNamespace['WebGLRenderer'],
+ AmbientLight: class {} as unknown as ThreeNamespace['AmbientLight'],
+ DirectionalLight: class {
+ position = new StubVector3();
+ } as unknown as ThreeNamespace['DirectionalLight'],
+ MeshStandardMaterial: StubMaterial as unknown as ThreeNamespace['MeshStandardMaterial'],
+ Mesh: StubObject3D as unknown as ThreeNamespace['Mesh'],
+ Vector2: StubVector3 as unknown as ThreeNamespace['Vector2'],
+ Vector3: StubVector3 as unknown as ThreeNamespace['Vector3'],
+ Raycaster: StubRaycaster as unknown as ThreeNamespace['Raycaster'],
+ ColorManagement: { enabled: false },
+ SRGBColorSpace: 'srgb',
+ NoToneMapping: 0,
+ };
+}
+
+/** Install the stub on `globalThis.THREE` and return a restore function. */
+export function installThreeStub(three: ThreeNamespace = createThreeStub()): () => void {
+ const previous = globalThis.THREE;
+ globalThis.THREE = three;
+ return () => {
+ globalThis.THREE = previous;
+ };
+}
diff --git a/public/files/perm/idevices/base/three-d-viewer/tsconfig.json b/public/files/perm/idevices/base/three-d-viewer/tsconfig.json
new file mode 100644
index 000000000..bb6fc1d0a
--- /dev/null
+++ b/public/files/perm/idevices/base/three-d-viewer/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2021",
+ "module": "Preserve",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["node"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "useUnknownInCatchVariables": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/vitest.config.mts b/vitest.config.mts
index 09930038b..9a4466e6a 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -124,6 +124,12 @@ export default defineConfig({
// TypeScript sources, not on their compiled output.
'public/files/perm/idevices/**/edition/*.js',
'public/files/perm/idevices/**/export/*.js',
+ // Test doubles and fixtures live beside the sources they serve.
+ 'public/files/perm/idevices/base/*/src/test/**',
+ // Bundle entry points: a few lines of global assignment,
+ // exercised through the compiled IIFEs by the bundle-contract
+ // tests — which v8 cannot attribute back to the source.
+ 'public/files/perm/idevices/base/*/src/*/index.ts',
],
},
},
From 1865cda2b5842c6b6c2dbea3ef649acffd1b87b7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 30 Jul 2026 07:47:48 +0000
Subject: [PATCH 12/18] test(three-d-viewer): port the suite to TypeScript and
extend E2E coverage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The JavaScript tests that lived beside the old `edition/` and `export/`
sources are replaced by colocated `*.spec.ts` files next to the modules
they cover: 36 files, 567 tests, 95% line coverage of `src/`.
New ground the old suite did not cover:
- generated-bundle contract tests that evaluate the ACTUAL compiled IIFEs
and assert `$exeDevice`, `$threedviewer`, `ThreeDViewerExportObject` and
`eXe3DViewer`, that neither bundle carries module syntax or chunk
imports, that source maps are linked rather than inlined, and that a
schema-v2 document renders end to end through the export bundle
- schema-v2 migration: legacy → v2, future-version rejection, round-trip
and idempotency, and that no blob:/data: URL is ever persisted
- lifecycle and registry teardown, including multiple-instance isolation
- the STL adapter's projection, occlusion, raycast and camera maths
against a small deterministic Three.js stub
- HTML sanitization of lowercase SVG/MathML foreign content
Small Three.js and `` stubs replace WebGL, so the suite is
deterministic and needs no browser.
E2E (`three-d-viewer-interactions.spec.ts`) gains a third marker with a
one-attempt question that proves the allowance survives closing and
reopening the dialog, an assertion that a resolved question stays
resolved, and a new spec asserting interaction-free content still exports
with no interaction payload, markers, guided controls or fallback list.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01TDMjPiVVapwAo6DtpZZGfb
---
.../three-d-viewer-interactions.spec.ts | 104 ++++++++++++++++--
1 file changed, 96 insertions(+), 8 deletions(-)
diff --git a/test/e2e/playwright/specs/idevices/three-d-viewer-interactions.spec.ts b/test/e2e/playwright/specs/idevices/three-d-viewer-interactions.spec.ts
index 4319075c6..a8577ae6c 100644
--- a/test/e2e/playwright/specs/idevices/three-d-viewer-interactions.spec.ts
+++ b/test/e2e/playwright/specs/idevices/three-d-viewer-interactions.spec.ts
@@ -62,14 +62,18 @@ async function save3DViewerIdevice(page: Page): Promise {
}
/** Inject a marker placement through the live editor device (deterministic). */
+interface ThreeDViewerDeviceProbe {
+ handleMarkerPlaced?: (placement: unknown) => void;
+}
+
async function placeMarker(page: Page, x: number, y: number, z: number): Promise {
await page.evaluate(
([mx, my, mz]) => {
- const dev = (window as any).$exeDevice;
- if (!dev || typeof dev.handleMarkerPlaced !== 'function') {
+ const device = (window as { $exeDevice?: ThreeDViewerDeviceProbe }).$exeDevice;
+ if (typeof device?.handleMarkerPlaced !== 'function') {
throw new Error('3D viewer device not available on window');
}
- dev.handleMarkerPlaced({
+ device.handleMarkerPlaced({
position: { x: mx, y: my, z: mz },
normal: { x: 0, y: 0, z: 1 },
surface: '',
@@ -126,8 +130,21 @@ test.describe('3D Viewer interactions', () => {
await page.locator('#tdvActionFields .tdv-q-options input[type="radio"]').nth(0).check();
await page.locator('#threeDMarkerEditorHost [data-save]').click();
- // Two rows in the marker list.
- await expect(page.locator('#threeDMarkerList .tdv-marker-row')).toHaveCount(2);
+ // Marker 3 — a question with a single attempt, used below to prove the
+ // attempt allowance survives closing and reopening the dialog.
+ await placeMarker(page, 0, 0.3, 1);
+ await page.locator('#tdvMkLabel').fill('OneShot');
+ await page.locator('#tdvMkType').selectOption('question');
+ await page.locator('#tdvActionFields textarea').first().fill('Only one try?');
+ const oneShotOptions = page.locator('#tdvActionFields .tdv-q-options .input-group input[type="text"]');
+ await oneShotOptions.nth(0).fill('Right');
+ await oneShotOptions.nth(1).fill('Wrong');
+ await page.locator('#tdvActionFields .tdv-q-options input[type="radio"]').nth(0).check();
+ await page.locator('#tdvMkAttempts').fill('1');
+ await page.locator('#threeDMarkerEditorHost [data-save]').click();
+
+ // Three rows in the marker list.
+ await expect(page.locator('#threeDMarkerList .tdv-marker-row')).toHaveCount(3);
// Persist.
await save3DViewerIdevice(page);
@@ -151,7 +168,7 @@ test.describe('3D Viewer interactions', () => {
// Markers render as accessible buttons with our labels.
const markers = iframe.locator('.three-d-viewer-wrapper .tdv-marker');
- await expect(markers).toHaveCount(2, { timeout: 15000 });
+ await expect(markers).toHaveCount(3, { timeout: 15000 });
await expect(iframe.locator('.tdv-marker[aria-label="Summit"]')).toBeAttached();
// Guided navigation controls are present.
@@ -181,6 +198,77 @@ test.describe('3D Viewer interactions', () => {
const feedback = iframe.locator('.tdv-q-feedback');
await expect(feedback).toHaveClass(/tdv-q-feedback--correct/, { timeout: 10000 });
await expect(feedback).toHaveAttribute('aria-live', 'polite');
+ await iframe.locator('.tdv-dialog-close').click();
+ await expect(iframe.locator('.tdv-dialog')).toHaveCount(0);
+
+ // A resolved question stays resolved after reopening its marker.
+ await iframe.locator('.tdv-marker[aria-label="Quiz"]').dispatchEvent('click');
+ await expect(iframe.locator('.tdv-q-feedback')).toHaveClass(/tdv-q-feedback--correct/, { timeout: 10000 });
+ await expect(iframe.locator('.tdv-q-check')).toBeDisabled();
+ await iframe.locator('.tdv-dialog-close').click();
+ await expect(iframe.locator('.tdv-dialog')).toHaveCount(0);
+
+ // A spent attempt allowance also survives the close/reopen: the learner
+ // gets one try per marker per activity session, not one per dialog.
+ await iframe.locator('.tdv-marker[aria-label="OneShot"]').dispatchEvent('click');
+ await expect(iframe.locator('.tdv-question legend')).toHaveText('Only one try?', { timeout: 10000 });
+ await iframe.locator('.tdv-question input[type="radio"]').nth(1).check();
+ await iframe.locator('.tdv-q-check').click();
+ await expect(iframe.locator('.tdv-q-feedback')).toHaveClass(/tdv-q-feedback--incorrect/, { timeout: 10000 });
+ await expect(iframe.locator('.tdv-q-check')).toBeDisabled();
+ await iframe.locator('.tdv-dialog-close').click();
+ await expect(iframe.locator('.tdv-dialog')).toHaveCount(0);
+
+ await iframe.locator('.tdv-marker[aria-label="OneShot"]').dispatchEvent('click');
+ await expect(iframe.locator('.tdv-question legend')).toHaveText('Only one try?', { timeout: 10000 });
+ await expect(iframe.locator('.tdv-q-check')).toBeDisabled();
+ await expect(iframe.locator('.tdv-q-feedback')).toContainText('No attempts left');
+ });
+
+ test('keeps interaction-free content rendering exactly as before', async ({ authenticatedPage, createProject }) => {
+ const page = authenticatedPage;
+ test.setTimeout(120000);
+
+ const projectUuid = await createProject(page, '3D Viewer Legacy Test');
+ await gotoWorkarea(page, projectUuid);
+ await waitForAppReady(page);
+ await selectFirstPage(page);
+
+ await add3DViewerIdevice(page);
+ await uploadModelViaFilePicker(page, 'test/fixtures/sample-model.glb');
+ await page.waitForFunction(
+ () => {
+ const viewer = document.querySelector('#threeDViewerPreview model-viewer') as {
+ loaded?: boolean;
+ src?: string;
+ } | null;
+ return !!(viewer?.loaded || viewer?.src);
+ },
+ { timeout: 30000 },
+ );
+
+ // Interactions stay off — this is what pre-interaction content looks
+ // like once it has been migrated to schema v2.
+ await expect(page.locator('#threeDInteractionsEnable')).not.toBeChecked();
+ await expect(page.locator('#threeDInteractionsBody')).toBeHidden();
+
+ await save3DViewerIdevice(page);
+ await saveProject(page);
+
+ await page.click('#head-bottom-preview');
+ await expect(page.locator('#previewsidenav')).toBeVisible({ timeout: 15000 });
+ const iframe = page.frameLocator('#preview-iframe');
+ await iframe.locator('article').waitFor({ state: 'attached', timeout: 30000 });
+ await iframe
+ .locator('.three-d-viewer-wrapper model-viewer[src]')
+ .first()
+ .waitFor({ state: 'attached', timeout: 20000 });
+
+ // No interaction payload, no markers, no guided controls, no fallback.
+ await expect(iframe.locator('.tdv-interaction-data')).toHaveCount(0);
+ await expect(iframe.locator('.tdv-marker')).toHaveCount(0);
+ await expect(iframe.locator('.tdv-guided-nav')).toHaveCount(0);
+ await expect(iframe.locator('.tdv-fallback')).toHaveCount(0);
});
test('authors an STL marker that persists and reaches the preview', async ({
@@ -202,8 +290,8 @@ test.describe('3D Viewer interactions', () => {
// "load" event.
await page.waitForFunction(
() => {
- const inp = document.querySelector('#threeD3DModelFile');
- return !!(inp && inp.value && inp.value.toLowerCase().includes('.stl'));
+ const input = document.querySelector('#threeD3DModelFile');
+ return !!input?.value?.toLowerCase().includes('.stl');
},
{ timeout: 20000 },
);
From dbf323adb81e6416c46c126d895e24a71aefe82c Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 30 Jul 2026 07:51:42 +0000
Subject: [PATCH 13/18] docs(three-d-viewer): describe the TypeScript
architecture
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Amend SDD-0002 and ADR-0007 — both still unpublished — so they describe
what was built rather than the classic-script plan.
The main correction: the SDD and the ADR both said the pure schema layer
would be MIRRORED byte-for-byte between `edition/` and `export/`, with a
`// mirror edition` marker and identical tests to police the drift. That
is no longer true and no longer needed. The schema, the migration and the
whole behavioural layer have a single TypeScript source under `src/`; the
compiler puts a copy into each generated bundle. Duplicated bytes are
accepted, duplicated maintained source is not — so the "duplication drift"
risk is replaced by a "stale generated bundle" one, and the mitigation
moves accordingly.
Also documented: the `src/` tree and what each directory owns; one edition
IIFE and one export IIFE, gitignored; no separate runtime JavaScript file;
schema v2 with an explicit `schemaVersion`, the direct legacy → v2
migration and the safe rejection of future versions; the single canonical
home for SCORM settings; the build, watch and typecheck commands; source
maps and how they are kept out of resource ZIPs; the test layout including
the generated-bundle contract; and the four browser globals the bundles
publish.
`doc/development/idevices-typescript.md` points at this iDevice as the
reference for the convention now that it follows it.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01TDMjPiVVapwAo6DtpZZGfb
---
...R-0007-three-d-viewer-interaction-layer.md | 53 ++--
.../SDD-0002-three-d-viewer-interactions.md | 264 ++++++++++++------
doc/development/idevices-typescript.md | 12 +-
3 files changed, 223 insertions(+), 106 deletions(-)
diff --git a/doc/architecture/adr/ADR-0007-three-d-viewer-interaction-layer.md b/doc/architecture/adr/ADR-0007-three-d-viewer-interaction-layer.md
index 5e865b512..a6db45484 100644
--- a/doc/architecture/adr/ADR-0007-three-d-viewer-interaction-layer.md
+++ b/doc/architecture/adr/ADR-0007-three-d-viewer-interaction-layer.md
@@ -17,6 +17,7 @@ superseded_by: []
ai_assistance:
tool: "Claude Code"
model: "claude-opus-4-8"
+ notes: "Decision revised for the TypeScript implementation with claude-opus-5"
---
# ADR-0007: 3D Viewer interaction layer: renderer adapters over a shared runtime controller
@@ -38,6 +39,11 @@ Three.js objects (`scene/camera/renderer/canvas/mesh`) and normalizes the mesh (
+ `2/maxDim` scale, runtime lines 478-485), so markers only make sense in normalized model space
and must be projected to a DOM overlay by hand each frame.
+(That runtime was a hand-written classic script at the time of this decision. It is now
+`src/runtime/` inside the iDevice's TypeScript source tree, compiled into both generated bundles —
+see [ADR-0006](ADR-0006-typescript-idevices-build-convention.md). The render-path analysis above is
+unchanged by that move.)
+
The sibling `three-sixty-viewer` already implements a hotspot system, but because it has **no shared
runtime**, it duplicates its entire state + projection + dialog stack across `edition/` and `export/`,
and all its coordinate math is panorama-specific (yaw/pitch on an inverted sphere) — reusable in
@@ -107,11 +113,14 @@ Adapter contract:
Two supporting decisions ride with this ADR:
-- **Schema `normalize*`/migration are mirrored (byte-identical) in `edition/` and `export/`**, marked
- `// mirror edition`, exactly as `three-sixty-viewer` does — rather than adding a new shared
- classic-script file (which would need the ~6-site registration + bundle regen and introduce a
- runtime load-order dependency). Only the *pure, small* schema layer is duplicated; the large
- behavioural layer lives single-copy in the runtime.
+- **Schema `normalize*`/migration have exactly one maintained source.** The iDevice follows the
+ TypeScript iDevice convention ([ADR-0006](ADR-0006-typescript-idevices-build-convention.md)): all
+ source lives under `src/`, and the edition and export bundles are generated IIFEs that each
+ compile in a copy of `src/shared/schema.ts`. This replaces the alternative that a classic-script
+ implementation would have forced — mirroring the pure schema layer byte-for-byte in `edition/`
+ and `export/`, as `three-sixty-viewer` does — because a compile step removes the drift risk
+ without adding a new registered runtime file or a load-order dependency. Duplicated bytes in the
+ two generated bundles are accepted; duplicated maintained source is not.
- **Interaction state is serialized as a JSON `` escaping and no `blob:`), accessible-label construction, and
- the pure STL projection/occlusion math (injected minimal `THREE` stub, per the runtime test
- pattern). `public/files/perm/**` is excluded from the v8 coverage *include*, so the ≥90% patch
- gate is met by shipping colocated tests for every new function.
-- **Runtime**: marker overlay markup, active-marker state, dialog open/close + focus return, guided
- prev/next, question feedback, keyboard activation, fallback rendering, cleanup.
-- **Export**: markers reach the exported JSON block; `asset://` rewritten; **no `blob:`**; required
- scripts/styles present; legacy state exports without interactions; attributes escaped; fallback
- present.
+- **Unit (Vitest/happy-dom, colocated `src/**/*.spec.ts`)**: normalization, migration (legacy → v2,
+ future-version rejection, idempotent round-trip), invalid/malformed data,
+ anchor/camera/action/question normalization, deterministic id generation through an injected id
+ factory, ordering/reorder, single-choice grading, JSON block serialize/parse (incl. ``
+ escaping and no `blob:`), accessible-label construction, and the pure STL projection/occlusion
+ maths against a small deterministic `THREE` stub. Because the sources are real ES modules, v8 can
+ instrument them: `vitest.config.mts` includes
+ `public/files/perm/idevices/base/*/src/**/*.ts` in coverage and excludes the generated bundles,
+ the test doubles and the two entry points.
+- **Generated-bundle contract** (`src/test/bundle-contract.spec.ts`): evaluates the ACTUAL compiled
+ IIFEs as classic scripts and asserts the four window globals, that neither bundle carries
+ top-level `import`/`export`/`require(`, that source maps are linked rather than inlined, that a
+ schema-v2 document renders end to end through the export bundle, and that the two bundles share
+ one `eXe3DViewer` rather than replacing each other.
+- **Runtime and interactions**: registry lifecycle and teardown, multiple-instance isolation,
+ marker overlay markup, active-marker state, dialog open/close + focus trap + focus return,
+ Escape, guided prev/next with and without wrapping, question feedback, **attempts surviving a
+ dialog reopen**, fallback rendering, listener cleanup.
+- **Export**: markers reach the exported JSON block; `asset://` survives for the export rewriter;
+ **no `blob:`**; legacy state exports without interactions; attributes escaped; fallback present;
+ SCORM registration and score reporting.
- **Playwright** (`test/e2e/playwright/specs/idevices/three-d-viewer-interactions.spec.ts`): GLB flow
- — add viewer, enable interactions, add informational marker, add question marker, enable guided
- mode, save, reopen, assert persistence, open preview (direct `#head-bottom-preview` click, wait for
- `article`), navigate markers, answer the question, assert accessible labels + feedback. STL:
- cover the raycasting adapter with a deterministic unit/integration fixture; keep pointer-based
- WebGL E2E minimal. Run `make test-e2e-static` (export/preview affected).
+ — add viewer, enable interactions, add informational marker, add two question markers, enable
+ guided mode, save, reopen, assert persistence, open preview (direct `#head-bottom-preview` click,
+ wait for `article`), assert accessible labels and guided controls, answer the question, and
+ confirm both a resolved question and a spent attempt allowance survive closing and reopening the
+ dialog. A second spec asserts interaction-free content exports unchanged. STL: the raycasting
+ adapter is unit-tested with a stub; the E2E spec asserts the WebGL-independent guarantees. Run
+ `make test-e2e-static` (export/preview affected).
## Rollout plan
@@ -368,9 +453,12 @@ until an author enables it.
- **Animated GLB surface anchoring** may drift on skinned meshes → store position+normal anchors as
the reliable default; use `model-viewer` `surface` only when available; document the limitation;
never block the base feature.
-- **Duplication drift** between mirrored `normalize*` in edition/export → keep blocks byte-identical
- with a `// mirror edition` marker and identical tests; the behavioural logic lives single-copy in
- the runtime to minimize what is duplicated.
+- **Duplication drift** between edition and export → eliminated by construction: the schema and the
+ behavioural layer have one TypeScript source that both bundles compile in. Duplicated *bytes* in
+ the two generated IIFEs are accepted; duplicated maintained source is not.
+- **Stale generated bundles** during development or E2E → `build:all` (and therefore `make bundle`)
+ runs `typecheck:idevices` + `bundle:idevices` before `bundle:resources`, and the E2E workflow
+ uploads both generated bundles as artifacts because the runners get a fresh checkout.
- **Round-trip data loss** (the #1 iDevice bug) → mandatory `load(save(x))` test for every field.
## Open questions
@@ -417,17 +505,23 @@ until an author enables it.
## Implementation checklist
-- [ ] Schema + `normalize*`/migration (mirrored edition/export) + unit tests.
-- [ ] Runtime `InteractionController` + adapters + dialog + question + guided nav + hooks + tests.
-- [ ] Editor UI (enable, add/edit/reorder, placement, marker editor, live preview) + tests.
-- [ ] Export markup (JSON block, fallback list, nav controls) + tests.
-- [ ] CSS (edition + export).
-- [ ] Playwright spec + `make test-e2e` / `make test-e2e-static`.
-- [ ] `config.xml` version bump; ADR-0007; records index updates.
+- [x] Schema v2 + migration in `src/shared/` (one source) + unit tests.
+- [x] `InteractionController` + adapters + dialog + question + guided nav + hooks + tests.
+- [x] Editor UI (enable, add/edit/reorder, placement, marker editor, live preview) + tests.
+- [x] Export markup (JSON block, fallback list, nav controls) + tests.
+- [x] CSS (edition + export).
+- [x] TypeScript build convention ([ADR-0006](../adr/ADR-0006-typescript-idevices-build-convention.md)):
+ `src/` sources, generated bundles gitignored, bundle-contract tests.
+- [x] Playwright spec + `make test-e2e` / `make test-e2e-static`.
+- [x] ADR-0007; records index updates. `config.xml` unchanged — the generated bundles keep the
+ existing filenames.
## References
- Issue: https://github.com/exelearning/exelearning/issues/2153
-- ADR-0007 (this feature's durable decisions).
+- [ADR-0007](../adr/ADR-0007-three-d-viewer-interaction-layer.md) (this feature's durable decisions).
+- [ADR-0006](../adr/ADR-0006-typescript-idevices-build-convention.md) and
+ [doc/development/idevices-typescript.md](../../development/idevices-typescript.md) (the build
+ convention this iDevice follows).
- `doc/elpx-format/idevices/patterns.md` (Pattern 1: JSON iDevice), `config-xml.md`.
- Repo memory: `E2E preview-open gotcha`, `sanitizeHtml DOM fallback`, `Export lib registration sites`.
diff --git a/doc/development/idevices-typescript.md b/doc/development/idevices-typescript.md
index a958a5f2c..199d35778 100644
--- a/doc/development/idevices-typescript.md
+++ b/doc/development/idevices-typescript.md
@@ -4,7 +4,7 @@ Most iDevices are classic-script vanilla JavaScript committed directly under
`edition/` and `export/`. An iDevice whose maintained source lives in a
**`src/` directory is a TypeScript iDevice**: its shipped `edition/*.js` /
`export/*.js` files are **generated bundles** (gitignored — never edit or
-commit them) compiled by the centralized build. Slide and Interactive Video
+commit them) compiled by the centralized build. Slide and the 3D Viewer
follow this model today. The decision record is
[ADR-0006](../architecture/adr/ADR-0006-typescript-idevices-build-convention.md).
@@ -81,13 +81,15 @@ exposes it as the module's default export.
## Testing
- Unit tests are **colocated `*.spec.ts`** files next to each module, run by
- **Vitest** (`bun test` deliberately ignores `public/**`). Add the iDevice's
- `src/**/*.spec.ts` glob to `vitest.config.mts` `include` when creating a new
- TypeScript iDevice.
+ **Vitest** (`bun test` deliberately ignores `public/**`). Discovery is by
+ convention — `vitest.config.mts` already includes
+ `public/files/perm/idevices/**/src/**/*.spec.ts` and measures coverage on
+ `public/files/perm/idevices/base/*/src/**/*.ts` — so a new TypeScript iDevice
+ needs no config edit.
- Add **bundle-contract smoke tests** that evaluate the ACTUAL compiled IIFEs
and assert the window globals and their public methods — they catch bundling
problems source-level imports cannot (see
- `interactive-video/src/test/bundle-contract.spec.ts`).
+ `three-d-viewer/src/test/bundle-contract.spec.ts`).
- Playwright coverage works on the built bundles like for any other iDevice.
## Debugging
From 9754378c9301e03a96cf73022bf354ccc9d24a76 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 30 Jul 2026 07:56:59 +0000
Subject: [PATCH 14/18] fix(export): keep generated source maps out of
server-side export packages
The browser export path builds from `public/bundles/idevices.zip`, which
`scripts/build-resource-bundles.js` now assembles without `.map` files, so
a learner package produced in the browser never carries a source map.
The server path (CLI commands and the external API) walks
`public/files/perm/idevices/base//export/` directly and only filtered
`.test.js` / `.spec.js`, so it would have shipped the ~230 KB
`three-d-viewer.js.map` inside every HTML5, SCORM, EPUB and IMS package it
generated. Filter `.map` alongside the tests so both paths agree.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01TDMjPiVVapwAo6DtpZZGfb
---
.../providers/FileSystemResourceProvider.spec.ts | 16 ++++++++++++++++
.../providers/FileSystemResourceProvider.ts | 8 ++++++--
2 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/src/shared/export/providers/FileSystemResourceProvider.spec.ts b/src/shared/export/providers/FileSystemResourceProvider.spec.ts
index e09aa3425..7ee3e78e8 100644
--- a/src/shared/export/providers/FileSystemResourceProvider.spec.ts
+++ b/src/shared/export/providers/FileSystemResourceProvider.spec.ts
@@ -212,6 +212,22 @@ describe('FileSystemResourceProvider', () => {
expect(files.size).toBe(0);
});
+
+ it('should exclude tests and source maps from the exported files', async () => {
+ const exportDir = path.join(testDir, 'files', 'perm', 'idevices', 'base', 'text', 'export');
+ await fs.writeFile(path.join(exportDir, 'text.test.js'), 'test();');
+ await fs.writeFile(path.join(exportDir, 'text.spec.js'), 'spec();');
+ // A TypeScript iDevice ships its generated bundle with a linked
+ // source map (ADR-0006); that map is a development aid only.
+ await fs.writeFile(path.join(exportDir, 'text.js.map'), '{"version":3}');
+
+ const files = await provider.fetchIdeviceResources('text');
+
+ expect(files.has('text.js')).toBe(true);
+ expect(files.has('text.test.js')).toBe(false);
+ expect(files.has('text.spec.js')).toBe(false);
+ expect(files.has('text.js.map')).toBe(false);
+ });
});
describe('normalizeIdeviceType', () => {
diff --git a/src/shared/export/providers/FileSystemResourceProvider.ts b/src/shared/export/providers/FileSystemResourceProvider.ts
index 99950d46a..f67d9aaed 100644
--- a/src/shared/export/providers/FileSystemResourceProvider.ts
+++ b/src/shared/export/providers/FileSystemResourceProvider.ts
@@ -101,9 +101,13 @@ export class FileSystemResourceProvider implements ResourceProvider {
if (await fs.pathExists(idevicePath)) {
// No prefix - files go to idevices/{type}/ folder (prefix added by caller)
const files = await this.readDirectoryRecursive(idevicePath, '');
- // Filter out test files (should not be included in exports)
+ // Filter out development-only files. Tests never belong in an
+ // export, and neither do the source maps that sit next to a
+ // TypeScript iDevice's generated bundle (see ADR-0006) — the
+ // browser export path already drops them when building the
+ // resource ZIP, so this keeps the server path consistent.
for (const filePath of files.keys()) {
- if (filePath.endsWith('.test.js') || filePath.endsWith('.spec.js')) {
+ if (filePath.endsWith('.test.js') || filePath.endsWith('.spec.js') || filePath.endsWith('.map')) {
files.delete(filePath);
}
}
From 0db489a49d7c26cd1129c60d01ddac5f90ff656e Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 30 Jul 2026 08:15:31 +0000
Subject: [PATCH 15/18] fix(three-d-viewer): restore editor animation playback
and canvas teardown
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two things the classic-script version did that the first pass of the
migration dropped:
- **Animation playback in the editor preview.** The old `$exeDevice`
called `applyAnimationState()` from `updatePreview()` and from the
model-viewer `load` handler, so ticking "Play animation" started the
clip immediately. The preview now owns an `applyAnimation()` that does
the same — pick the stored clip when the model still offers it, fall
back to the first one, set the speed, play on repeat and announce it —
and the device calls it after refreshing the animation picker.
- **Canvas teardown.** `hideThreeJSCanvas()` used to hide the Three.js
canvas when the author swapped an STL model for a GLB one. The runtime
created that canvas, so `disposeInstance()` now removes it and un-hides
the sibling ``; otherwise a dead canvas stayed on top of
the GLB preview.
Also untrack `edition/three-d-viewer.js` and `export/three-d-viewer.js`.
They were correctly `git rm --cached`-ed during the migration, but an
intervening `git reset` put them back in the index, and .gitignore does
not apply to already-tracked files. `git status` is clean after a build
again.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01TDMjPiVVapwAo6DtpZZGfb
---
.../three-d-viewer/edition/three-d-viewer.js | 3309 -----------------
.../three-d-viewer/export/three-d-viewer.js | 2733 --------------
.../three-d-viewer/src/edition/device.spec.ts | 52 +-
.../base/three-d-viewer/src/edition/device.ts | 5 +
.../three-d-viewer/src/edition/preview.ts | 32 +-
5 files changed, 73 insertions(+), 6058 deletions(-)
delete mode 100644 public/files/perm/idevices/base/three-d-viewer/edition/three-d-viewer.js
delete mode 100644 public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js
diff --git a/public/files/perm/idevices/base/three-d-viewer/edition/three-d-viewer.js b/public/files/perm/idevices/base/three-d-viewer/edition/three-d-viewer.js
deleted file mode 100644
index 6d06b5006..000000000
--- a/public/files/perm/idevices/base/three-d-viewer/edition/three-d-viewer.js
+++ /dev/null
@@ -1,3309 +0,0 @@
-(() => {
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/marker-renderer.ts
- function createMarkerButton(marker, options) {
- const button = document.createElement("button");
- button.type = "button";
- button.className = `tdv-marker ${options.variantClass}`;
- button.dataset.markerId = marker.id;
- button.dataset.markerOrder = String(options.index);
- button.setAttribute("aria-label", options.label);
- const icon = document.createElement("span");
- icon.className = `tdv-marker-icon tdv-icon-${marker.icon}`;
- icon.setAttribute("aria-hidden", "true");
- button.appendChild(icon);
- if (options.showLabels && marker.label) {
- const label = document.createElement("span");
- label.className = "tdv-marker-label";
- label.textContent = marker.label;
- button.appendChild(label);
- }
- if (options.activeId === marker.id) {
- button.classList.add("tdv-marker--active");
- button.setAttribute("aria-current", "true");
- }
- button.addEventListener("click", () => options.onActivate(marker.id));
- return button;
- }
- function applyActiveMarker(buttons, activeId) {
- for (const button of buttons) {
- const isActive = button.dataset.markerId === activeId;
- button.classList.toggle("tdv-marker--active", isActive);
- if (isActive) {
- button.setAttribute("aria-current", "true");
- } else {
- button.removeAttribute("aria-current");
- }
- }
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/adapters/geometry.ts
- var FACING_THRESHOLD = -0.15;
- function ndcToScreen(ndc, width, height) {
- return {
- x: (ndc.x * 0.5 + 0.5) * width,
- y: (-ndc.y * 0.5 + 0.5) * height
- };
- }
- function isOnScreen(ndc) {
- const inFrustum = ndc.z < 1 && ndc.z > -1;
- return inFrustum && ndc.x >= -1 && ndc.x <= 1 && ndc.y >= -1 && ndc.y <= 1;
- }
- function isFacingCamera(normal, toCamera) {
- return normal.x * toCamera.x + normal.y * toCamera.y + normal.z * toCamera.z > FACING_THRESHOLD;
- }
- function isMarkerVisible(ndc, normal, toCamera) {
- return isFacingCamera(normal, toCamera) && isOnScreen(ndc);
- }
- function parseTriple(value) {
- const parts = String(value ?? "").trim().split(/\s+/).map(Number.parseFloat);
- return {
- x: Number.isFinite(parts[0]) ? parts[0] : 0,
- y: Number.isFinite(parts[1]) ? parts[1] : 0,
- z: Number.isFinite(parts[2]) ? parts[2] : 0
- };
- }
- function formatTriple(vector) {
- return `${vector.x} ${vector.y} ${vector.z}`;
- }
- function pointerToNdc(rect, clientX, clientY) {
- if (!rect.width || !rect.height) {
- return null;
- }
- return {
- x: (clientX - rect.left) / rect.width * 2 - 1,
- y: -((clientY - rect.top) / rect.height) * 2 + 1
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/adapters/model-viewer-adapter.ts
- var EMPTY_CAMERA = { orbit: "", target: "", fieldOfView: "" };
- function createModelViewerAdapter(modelViewer, deps) {
- let placeHandler = null;
- const clearMarkers = () => {
- for (const element of Array.from(modelViewer.querySelectorAll('.tdv-marker[slot^="hotspot-"]'))) {
- element.remove();
- }
- };
- const captureCamera = () => {
- try {
- return {
- orbit: modelViewer.getCameraOrbit?.().toString() ?? "",
- target: modelViewer.getCameraTarget?.().toString() ?? "",
- fieldOfView: modelViewer.getFieldOfView ? `${modelViewer.getFieldOfView()}deg` : ""
- };
- } catch {
- return { ...EMPTY_CAMERA };
- }
- };
- return {
- renderMarkers(markers, options) {
- clearMarkers();
- markers.forEach((marker, index) => {
- const button = createMarkerButton(marker, {
- ...options,
- index,
- label: deps.markerLabel(marker, index),
- variantClass: "tdv-marker--mv",
- onActivate: deps.onActivate
- });
- button.setAttribute("slot", `hotspot-${marker.id}`);
- button.dataset.position = formatTriple(marker.anchor.position);
- button.dataset.normal = formatTriple(marker.anchor.normal);
- if (marker.anchor.surface) {
- button.dataset.surface = marker.anchor.surface;
- }
- modelViewer.appendChild(button);
- });
- },
- setActive(activeId) {
- applyActiveMarker(modelViewer.querySelectorAll(".tdv-marker"), activeId);
- },
- focusMarker(marker) {
- const camera = marker.camera;
- if (camera.orbit) {
- modelViewer.cameraOrbit = camera.orbit;
- }
- if (camera.target) {
- modelViewer.cameraTarget = camera.target;
- }
- if (camera.fieldOfView) {
- modelViewer.fieldOfView = camera.fieldOfView;
- }
- },
- captureCamera,
- updateOverlay() {},
- enterPlacementMode(onPlaced) {
- placeHandler = (event) => {
- const hit = modelViewer.positionAndNormalFromPoint?.(event.clientX, event.clientY);
- if (!hit) {
- return;
- }
- onPlaced({
- position: parseTriple(hit.position?.toString()),
- normal: parseTriple(hit.normal?.toString()),
- surface: "",
- camera: captureCamera()
- });
- };
- modelViewer.addEventListener("click", placeHandler);
- },
- exitPlacementMode() {
- if (placeHandler) {
- modelViewer.removeEventListener("click", placeHandler);
- placeHandler = null;
- }
- },
- destroy() {
- this.exitPlacementMode();
- clearMarkers();
- }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/urls.ts
- var EXECUTABLE_SCHEME = /^\s*(javascript|vbscript):/i;
- var EPHEMERAL_OR_EXECUTABLE_SCHEME = /^\s*(blob:|data:|javascript:|vbscript:)/i;
- var ALLOWED_RENDER_SCHEME = /^(https?:|mailto:|tel:|asset:|blob:)/i;
- var HAS_EXPLICIT_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
- function stripUnsafeUrl(value) {
- const raw = typeof value === "string" ? value : "";
- return EPHEMERAL_OR_EXECUTABLE_SCHEME.test(raw) ? "" : raw.trim();
- }
- function safeUrl(value) {
- const raw = typeof value === "string" ? value.trim() : "";
- if (!raw) {
- return "";
- }
- if (EXECUTABLE_SCHEME.test(raw)) {
- return "";
- }
- if (ALLOWED_RENDER_SCHEME.test(raw)) {
- return raw;
- }
- return HAS_EXPLICIT_SCHEME.test(raw) ? "" : raw;
- }
- function stripQueryAndHash(value) {
- let out = value;
- const query = out.indexOf("?");
- if (query !== -1) {
- out = out.substring(0, query);
- }
- const hash = out.indexOf("#");
- if (hash !== -1) {
- out = out.substring(0, hash);
- }
- return out;
- }
- function joinAppUrl(baseURL, basePath, path) {
- const base = String(baseURL ?? "").replace(/\/+$/g, "");
- const prefixPath = basePath ? `/${String(basePath).replace(/^\/+|\/+$/g, "")}` : "";
- const prefix = `${base}${prefixPath}`.replace(/\/+$/g, "");
- const normalized = String(path ?? "").replace(/^\/+/, "");
- return prefix ? `${prefix}/${normalized}` : `/${normalized}`;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/model-source.ts
- var SUPPORTED_MODEL_EXTENSIONS = ["glb", "gltf", "stl"];
- var KNOWN_EXTENSIONS = ["stl", "glb", "gltf", "obj", "fbx"];
- function detectModelType(src) {
- if (typeof src !== "string") {
- return "unknown";
- }
- const clean = stripQueryAndHash(src.trim());
- const dot = clean.lastIndexOf(".");
- if (dot === -1) {
- return "unknown";
- }
- const ext = clean.substring(dot + 1).toLowerCase();
- return KNOWN_EXTENSIONS.includes(ext) ? ext : "unknown";
- }
- function isStlSource(src) {
- return detectModelType(src) === "stl";
- }
- function normalizeModelSource(src) {
- if (typeof src !== "string") {
- return "";
- }
- const clean = src.trim();
- if (!clean || clean.startsWith("blob:") || clean.startsWith("data:")) {
- return "";
- }
- return clean;
- }
- function isSupportedModelFile(path) {
- if (!path) {
- return false;
- }
- let filename = String(path).toLowerCase();
- if (filename.startsWith("asset://")) {
- filename = filename.substring("asset://".length);
- } else if (filename.startsWith("blob:")) {
- return true;
- } else {
- filename = filename.split("/").pop() ?? "";
- }
- filename = stripQueryAndHash(filename);
- if (!filename) {
- return false;
- }
- return SUPPORTED_MODEL_EXTENSIONS.some((ext) => filename.endsWith(`.${ext}`));
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/lifecycle.ts
- function createInstance(wrapper, options) {
- return {
- wrapper,
- options,
- type: options.type || detectModelType(options.src),
- modelViewer: null,
- canvas: null,
- scene: null,
- camera: null,
- renderer: null,
- controls: null,
- mesh: null,
- geometry: null,
- material: null,
- rafId: null,
- stopped: false,
- listeners: [],
- objectURLs: [],
- onFrame: [],
- interaction: null
- };
- }
- function addFrameCallback(instance, callback) {
- if (!instance.onFrame.includes(callback)) {
- instance.onFrame.push(callback);
- }
- }
- function removeFrameCallback(instance, callback) {
- const index = instance.onFrame.indexOf(callback);
- if (index !== -1) {
- instance.onFrame.splice(index, 1);
- }
- }
- function isTexture(value) {
- return Boolean(value && typeof value === "object" && value.isTexture && typeof value.dispose === "function");
- }
- function disposeMaterial(material) {
- if (!material) {
- return;
- }
- const list = Array.isArray(material) ? material : [material];
- for (const entry of list) {
- if (!entry || typeof entry !== "object") {
- continue;
- }
- const record = entry;
- for (const key of Object.keys(record)) {
- const value = record[key];
- if (isTexture(value)) {
- value.dispose();
- }
- }
- const dispose = entry.dispose;
- if (typeof dispose === "function") {
- dispose.call(entry);
- }
- }
- }
- function disposeObject3D(object) {
- const traverse = object?.traverse;
- if (typeof traverse !== "function") {
- return;
- }
- object.traverse((node) => {
- if (node?.geometry && typeof node.geometry.dispose === "function") {
- node.geometry.dispose();
- }
- if (node?.material) {
- disposeMaterial(node.material);
- }
- });
- }
- function cancelFrame(rafId) {
- if (typeof globalThis.cancelAnimationFrame === "function") {
- globalThis.cancelAnimationFrame(rafId);
- } else {
- clearTimeout(rafId);
- }
- }
- function disposeInstance(instance) {
- instance.stopped = true;
- if (instance.interaction) {
- try {
- instance.interaction.destroy();
- } catch {}
- instance.interaction = null;
- }
- instance.onFrame.length = 0;
- if (instance.rafId !== null) {
- cancelFrame(instance.rafId);
- instance.rafId = null;
- }
- for (const { target, type, handler, options } of instance.listeners) {
- try {
- target.removeEventListener(type, handler, options);
- } catch {}
- }
- instance.listeners.length = 0;
- try {
- disposeObject3D(instance.scene);
- } catch {}
- try {
- disposeMaterial(instance.material);
- } catch {}
- try {
- instance.geometry?.dispose?.();
- } catch {}
- try {
- instance.controls?.dispose?.();
- } catch {}
- try {
- instance.renderer?.dispose?.();
- } catch {}
- for (const url of instance.objectURLs) {
- try {
- URL.revokeObjectURL(url);
- } catch {}
- }
- instance.objectURLs.length = 0;
- instance.scene = null;
- instance.camera = null;
- instance.renderer = null;
- instance.controls = null;
- instance.mesh = null;
- instance.geometry = null;
- instance.material = null;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/adapters/raycast.ts
- function raycastFromPointer(target, clientX, clientY) {
- const three = globalThis.THREE;
- if (!three || !target.mesh || !target.camera || !target.canvas) {
- return null;
- }
- const ndc = pointerToNdc(target.canvas.getBoundingClientRect(), clientX, clientY);
- if (!ndc) {
- return null;
- }
- const raycaster = new three.Raycaster;
- raycaster.setFromCamera(new three.Vector2(ndc.x, ndc.y), target.camera);
- const hit = raycaster.intersectObject(target.mesh, true)[0];
- if (!hit) {
- return null;
- }
- const local = target.mesh.worldToLocal(hit.point.clone());
- const faceNormal = hit.face?.normal;
- return {
- position: { x: local.x, y: local.y, z: local.z },
- normal: faceNormal ? { x: faceNormal.x, y: faceNormal.y, z: faceNormal.z } : { x: 0, y: 1, z: 0 }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/adapters/stl-adapter.ts
- var EMPTY_CAMERA2 = { orbit: "", target: "", fieldOfView: "" };
- function ensureLayer(wrapper) {
- const existing = wrapper.querySelector(".tdv-marker-layer");
- if (existing) {
- return existing;
- }
- const layer = document.createElement("div");
- layer.className = "tdv-marker-layer";
- wrapper.appendChild(layer);
- return layer;
- }
- function createStlAdapter(instance, wrapper, deps) {
- const layer = ensureLayer(wrapper);
- let entries = [];
- let placeHandler = null;
- const updateOverlay = () => {
- const three = globalThis.THREE;
- const { mesh, camera, canvas } = instance;
- if (!three || !mesh || !camera || !canvas || entries.length === 0) {
- return;
- }
- mesh.updateMatrixWorld();
- camera.updateMatrixWorld();
- const width = canvas.clientWidth || canvas.width || 1;
- const height = canvas.clientHeight || canvas.height || 1;
- for (const entry of entries) {
- const world = mesh.localToWorld(entry.local.clone());
- const ndc = world.clone().project(camera);
- const worldNormal = entry.normal.clone().transformDirection(mesh.matrixWorld);
- const toCamera = new three.Vector3().subVectors(camera.position, world).normalize();
- const visible = isMarkerVisible(ndc, worldNormal, toCamera);
- const screen = ndcToScreen(ndc, width, height);
- entry.element.style.left = `${screen.x}px`;
- entry.element.style.top = `${screen.y}px`;
- entry.element.classList.toggle("tdv-marker--hidden", !visible);
- if (visible) {
- entry.element.removeAttribute("tabindex");
- entry.element.removeAttribute("aria-hidden");
- } else {
- entry.element.setAttribute("tabindex", "-1");
- entry.element.setAttribute("aria-hidden", "true");
- }
- }
- };
- addFrameCallback(instance, updateOverlay);
- const captureCamera = () => {
- const camera = instance.camera;
- if (!camera) {
- return { ...EMPTY_CAMERA2 };
- }
- const position = camera.position;
- const target = instance.controls?.target ?? { x: 0, y: 0, z: 0 };
- return {
- orbit: `${position.x} ${position.y} ${position.z}`,
- target: `${target.x} ${target.y} ${target.z}`,
- fieldOfView: `${camera.fov ?? 45}deg`
- };
- };
- return {
- renderMarkers(markers, options) {
- const three = globalThis.THREE;
- layer.innerHTML = "";
- entries = markers.map((marker, index) => {
- const element = createMarkerButton(marker, {
- ...options,
- index,
- label: deps.markerLabel(marker, index),
- variantClass: "tdv-marker--stl",
- onActivate: deps.onActivate
- });
- layer.appendChild(element);
- const { position, normal } = marker.anchor;
- return {
- element,
- local: new three.Vector3(position.x, position.y, position.z),
- normal: new three.Vector3(normal.x, normal.y, normal.z)
- };
- });
- updateOverlay();
- },
- setActive(activeId) {
- applyActiveMarker(entries.map((entry) => entry.element), activeId);
- },
- focusMarker(marker) {
- const camera = instance.camera;
- if (!globalThis.THREE || !camera) {
- return;
- }
- const position = parseTriple(marker.camera.orbit);
- const target = parseTriple(marker.camera.target);
- if (marker.camera.orbit) {
- camera.position.set(position.x, position.y, position.z);
- }
- if (!marker.camera.target) {
- return;
- }
- if (instance.controls) {
- instance.controls.target.set(target.x, target.y, target.z);
- instance.controls.update?.();
- } else {
- camera.lookAt(target.x, target.y, target.z);
- }
- },
- captureCamera,
- updateOverlay,
- enterPlacementMode(onPlaced) {
- const canvas = instance.canvas;
- if (!canvas) {
- return;
- }
- placeHandler = (event) => {
- const hit = raycastFromPointer(instance, event.clientX, event.clientY);
- if (!hit) {
- return;
- }
- onPlaced({ position: hit.position, normal: hit.normal, surface: "", camera: captureCamera() });
- };
- canvas.addEventListener("click", placeHandler);
- },
- exitPlacementMode() {
- if (placeHandler && instance.canvas) {
- instance.canvas.removeEventListener("click", placeHandler);
- }
- placeHandler = null;
- },
- destroy() {
- this.exitPlacementMode();
- removeFrameCallback(instance, updateOverlay);
- try {
- layer.remove();
- } catch {}
- entries = [];
- }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/asset-resolver.ts
- function getAssetManager() {
- const project = globalThis.eXeLearning?.app?.project;
- const local = project?.assetManager ?? project?._yjsBridge?.assetManager;
- if (local) {
- return local;
- }
- try {
- const parentWindow = globalThis.parent;
- const parentProject = parentWindow?.eXeLearning?.app?.project;
- return parentProject?.assetManager ?? parentProject?._yjsBridge?.assetManager ?? null;
- } catch {
- return null;
- }
- }
- async function resolveModelSource(src, assetManager) {
- if (typeof src !== "string") {
- return "";
- }
- const trimmed = src.trim();
- if (!trimmed) {
- return "";
- }
- if (!trimmed.startsWith("asset://")) {
- return trimmed;
- }
- const manager = assetManager ?? getAssetManager();
- if (!manager) {
- return "";
- }
- try {
- const sync = manager.resolveAssetURLSync?.(trimmed);
- if (sync) {
- return sync;
- }
- const resolved = await manager.resolveAssetURL?.(trimmed);
- return resolved ?? "";
- } catch {
- return "";
- }
- }
- function resolveMediaUrlSync(url, assetManager) {
- const raw = typeof url === "string" ? url.trim() : "";
- if (!raw || !raw.startsWith("asset://")) {
- return raw;
- }
- const manager = assetManager ?? getAssetManager();
- if (!manager?.resolveAssetURLSync) {
- return raw;
- }
- try {
- return manager.resolveAssetURLSync(raw) || raw;
- } catch {
- return raw;
- }
- }
- function recoverAssetRefFromBlob(blobUrl, assetManager) {
- if (typeof blobUrl !== "string" || !blobUrl.startsWith("blob:")) {
- return "";
- }
- const manager = assetManager ?? getAssetManager();
- const assetId = manager?.reverseBlobCache?.get?.(blobUrl);
- if (!assetId) {
- return "";
- }
- const filename = manager?.getAssetMetadata?.(assetId)?.filename ?? "";
- const dot = filename.lastIndexOf(".");
- const extension = dot !== -1 ? filename.substring(dot + 1).toLowerCase() : "";
- return extension ? `${assetId}.${extension}` : String(assetId);
- }
- async function waitForAssetManager(timeoutMs = 5000, pollIntervalMs = 100) {
- const deadline = Date.now() + timeoutMs;
- while (Date.now() < deadline) {
- const manager = getAssetManager();
- if (manager) {
- return manager;
- }
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
- }
- return null;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/html.ts
- var ESCAPES = {
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'"
- };
- function escapeHtml(value) {
- return String(value ?? "").replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
- }
- var BANNED_TAGS = new Set([
- "SCRIPT",
- "STYLE",
- "IFRAME",
- "OBJECT",
- "EMBED",
- "LINK",
- "META",
- "BASE",
- "FORM",
- "FRAME",
- "FRAMESET",
- "FOREIGNOBJECT",
- "ANNOTATION-XML"
- ]);
- var URL_ATTRIBUTES = new Set([
- "href",
- "src",
- "srcset",
- "srcdoc",
- "xlink:href",
- "action",
- "formaction",
- "poster",
- "ping",
- "data",
- "background"
- ]);
- function sanitizeElement(element) {
- if (BANNED_TAGS.has(element.tagName.toUpperCase())) {
- element.remove();
- return false;
- }
- for (const attribute of Array.from(element.attributes)) {
- const name = attribute.name.toLowerCase();
- if (name.startsWith("on")) {
- element.removeAttribute(attribute.name);
- continue;
- }
- if (URL_ATTRIBUTES.has(name) && !safeUrl(attribute.value)) {
- element.removeAttribute(attribute.name);
- }
- }
- return true;
- }
- function sanitizeChildren(node) {
- for (const child of Array.from(node.childNodes)) {
- if (child.nodeType !== 1) {
- continue;
- }
- if (sanitizeElement(child)) {
- sanitizeChildren(child);
- }
- }
- }
- function sanitizeHtml(html) {
- const source = typeof html === "string" ? html : "";
- if (!source) {
- return "";
- }
- if (typeof document === "undefined" || typeof document.createElement !== "function") {
- return escapeHtml(source);
- }
- const template = document.createElement("template");
- template.innerHTML = source;
- sanitizeChildren(template.content);
- return template.innerHTML;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/dialog.ts
- var FOCUSABLE_SELECTOR = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
- function getFocusable(container) {
- return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((element) => element.offsetParent !== null || element === document.activeElement);
- }
- function openDialog(options, buildBody) {
- const previouslyFocused = document.activeElement;
- const overlay = document.createElement("div");
- overlay.className = "tdv-dialog-overlay";
- const dialog = document.createElement("div");
- dialog.className = "tdv-dialog";
- dialog.setAttribute("role", "dialog");
- dialog.setAttribute("aria-modal", "true");
- dialog.setAttribute("aria-label", options.title);
- const header = document.createElement("div");
- header.className = "tdv-dialog-header";
- const heading = document.createElement("h2");
- heading.className = "tdv-dialog-title";
- heading.textContent = options.title;
- const closeButton = document.createElement("button");
- closeButton.type = "button";
- closeButton.className = "tdv-dialog-close";
- closeButton.setAttribute("aria-label", options.closeLabel);
- closeButton.textContent = "✕";
- header.append(heading, closeButton);
- const body = document.createElement("div");
- body.className = "tdv-dialog-body";
- dialog.append(header, body);
- overlay.appendChild(dialog);
- (options.host ?? document.body).appendChild(overlay);
- buildBody(body);
- let closed = false;
- const close = () => {
- if (closed) {
- return;
- }
- closed = true;
- try {
- overlay.remove();
- } catch {}
- if (previouslyFocused instanceof HTMLElement) {
- try {
- previouslyFocused.focus();
- } catch {}
- }
- options.onClose?.();
- };
- closeButton.addEventListener("click", close);
- overlay.addEventListener("click", (event) => {
- if (event.target === overlay) {
- close();
- }
- });
- dialog.addEventListener("keydown", (event) => {
- if (event.key === "Escape") {
- event.stopPropagation();
- close();
- return;
- }
- if (event.key !== "Tab") {
- return;
- }
- const focusable = getFocusable(dialog);
- const first = focusable[0];
- const last = focusable[focusable.length - 1];
- if (!first || !last) {
- return;
- }
- if (event.shiftKey && document.activeElement === first) {
- event.preventDefault();
- last.focus();
- } else if (!event.shiftKey && document.activeElement === last) {
- event.preventDefault();
- first.focus();
- }
- });
- try {
- closeButton.focus();
- } catch {}
- return { overlay, dialog, body, close };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.ts
- var webglAvailable = null;
- function hasWebGL() {
- if (typeof globalThis.__tdvForceWebGL === "boolean") {
- return globalThis.__tdvForceWebGL;
- }
- if (webglAvailable !== null) {
- return webglAvailable;
- }
- try {
- if (typeof document === "undefined" || typeof document.createElement !== "function") {
- webglAvailable = true;
- return webglAvailable;
- }
- const canvas = document.createElement("canvas");
- webglAvailable = Boolean(canvas.getContext && (canvas.getContext("webgl") || canvas.getContext("experimental-webgl")));
- } catch {
- webglAvailable = false;
- }
- return webglAvailable;
- }
- function revealFallback(wrapper, show) {
- const list = wrapper?.querySelector(".tdv-fallback");
- if (list) {
- list.hidden = !show;
- }
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/guided-navigation.ts
- function resolveStepIndex(current, delta, total, wrap) {
- if (total <= 0) {
- return null;
- }
- const start = current < 0 ? delta > 0 ? -1 : total : current;
- const next = start + delta;
- if (wrap) {
- return (next % total + total) % total;
- }
- return next < 0 || next >= total ? null : next;
- }
- function buildControls(t) {
- const nav = document.createElement("div");
- nav.className = "tdv-guided-nav";
- nav.setAttribute("data-guided", "");
- const previous = document.createElement("button");
- previous.type = "button";
- previous.className = "tdv-nav-prev";
- previous.textContent = t("Previous");
- const status = document.createElement("span");
- status.className = "tdv-guided-status";
- status.setAttribute("aria-live", "polite");
- const next = document.createElement("button");
- next.type = "button";
- next.className = "tdv-nav-next";
- next.textContent = t("Next");
- nav.append(previous, status, next);
- return nav;
- }
- function createGuidedNavigation(wrapper, deps) {
- let nav = wrapper?.querySelector(".tdv-guided-nav") ?? null;
- let created = false;
- const listeners = [];
- const ensureNav = () => {
- if (nav || !wrapper) {
- return nav;
- }
- nav = buildControls(deps.t);
- created = true;
- wrapper.appendChild(nav);
- return nav;
- };
- const bindOnce = (element) => {
- if (element.dataset.tdvBound === "1") {
- return;
- }
- element.dataset.tdvBound = "1";
- const previousButton = element.querySelector(".tdv-nav-prev");
- const nextButton = element.querySelector(".tdv-nav-next");
- if (previousButton) {
- const handler = () => deps.onGo(-1);
- previousButton.addEventListener("click", handler);
- listeners.push(() => previousButton.removeEventListener("click", handler));
- }
- if (nextButton) {
- const handler = () => deps.onGo(1);
- nextButton.addEventListener("click", handler);
- listeners.push(() => nextButton.removeEventListener("click", handler));
- }
- };
- return {
- update({ enabled, index, total, wrap }) {
- if (!enabled) {
- if (nav) {
- nav.hidden = true;
- }
- return;
- }
- const element = ensureNav();
- if (!element) {
- return;
- }
- element.hidden = false;
- const previousButton = element.querySelector(".tdv-nav-prev");
- const nextButton = element.querySelector(".tdv-nav-next");
- if (previousButton && !previousButton.textContent) {
- previousButton.textContent = deps.t("Previous");
- }
- if (nextButton && !nextButton.textContent) {
- nextButton.textContent = deps.t("Next");
- }
- bindOnce(element);
- const empty = total === 0;
- if (previousButton) {
- previousButton.disabled = empty || !wrap && index <= 0;
- }
- if (nextButton) {
- nextButton.disabled = empty || !wrap && index >= total - 1;
- }
- const status = element.querySelector(".tdv-guided-status");
- if (status) {
- status.textContent = `${deps.t("Marker")} ${index < 0 ? 0 : index + 1} / ${total}`;
- }
- },
- destroy() {
- for (const off of listeners) {
- off();
- }
- listeners.length = 0;
- if (created && nav) {
- try {
- nav.remove();
- } catch {}
- }
- nav = null;
- }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/scoring.ts
- function gradeSingleChoice(question, selectedOptionId) {
- const chosen = question.options.find((option) => option.id === selectedOptionId);
- return Boolean(chosen?.correct);
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/question.ts
- function lockQuestion(inputs, checkButton) {
- checkButton.disabled = true;
- for (const input of inputs) {
- input.disabled = true;
- }
- }
- function renderQuestion(body, marker, deps) {
- if (marker.action.type !== "question") {
- return;
- }
- const question = marker.action.payload;
- const { answers, t } = deps;
- const state = answers.get(marker.id);
- const fieldset = document.createElement("fieldset");
- fieldset.className = "tdv-question";
- const legend = document.createElement("legend");
- legend.className = "tdv-question-prompt";
- legend.textContent = question.prompt;
- fieldset.appendChild(legend);
- const groupName = `tdv-q-${marker.id}`;
- const inputs = [];
- for (const option of question.options) {
- const label = document.createElement("label");
- label.className = "tdv-question-option";
- const input = document.createElement("input");
- input.type = "radio";
- input.name = groupName;
- input.value = option.id;
- if (option.id === state.selectedOptionId) {
- input.checked = true;
- }
- const text = document.createElement("span");
- text.textContent = option.text;
- label.append(input, text);
- fieldset.appendChild(label);
- inputs.push(input);
- }
- const checkButton = document.createElement("button");
- checkButton.type = "button";
- checkButton.className = "tdv-q-check";
- checkButton.textContent = t("Check");
- const feedback = document.createElement("div");
- feedback.className = "tdv-q-feedback";
- feedback.setAttribute("role", "status");
- feedback.setAttribute("aria-live", "polite");
- body.append(fieldset, checkButton, feedback);
- if (state.resolved) {
- feedback.className = "tdv-q-feedback tdv-q-feedback--correct";
- feedback.textContent = question.feedbackCorrect || t("Correct");
- lockQuestion(inputs, checkButton);
- } else if (answers.isExhausted(marker.id, question.attemptsAllowed)) {
- feedback.className = "tdv-q-feedback tdv-q-feedback--incorrect";
- feedback.textContent = `${question.feedbackIncorrect || t("Incorrect")} ${t("No attempts left")}`;
- lockQuestion(inputs, checkButton);
- }
- checkButton.addEventListener("click", () => {
- const chosen = inputs.find((input) => input.checked);
- if (!chosen) {
- feedback.className = "tdv-q-feedback";
- feedback.textContent = t("Please select an answer");
- return;
- }
- const correct = gradeSingleChoice(question, chosen.value);
- const next = answers.recordAttempt(marker.id, chosen.value, correct);
- try {
- deps.onAnswered?.(marker.id, correct);
- } catch {}
- if (correct) {
- feedback.className = "tdv-q-feedback tdv-q-feedback--correct";
- feedback.textContent = question.feedbackCorrect || t("Correct");
- lockQuestion(inputs, checkButton);
- return;
- }
- feedback.className = "tdv-q-feedback tdv-q-feedback--incorrect";
- let message = question.feedbackIncorrect || t("Incorrect");
- if (question.attemptsAllowed > 0 && next.attempts >= question.attemptsAllowed) {
- lockQuestion(inputs, checkButton);
- message += ` ${t("No attempts left")}`;
- }
- feedback.textContent = message;
- });
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/state.ts
- function emptyState() {
- return { attempts: 0, resolved: false, selectedOptionId: "" };
- }
- function createAnswerStore() {
- const states = new Map;
- const get = (markerId) => states.get(markerId) ?? emptyState();
- return {
- get,
- recordAttempt(markerId, selectedOptionId, correct) {
- const previous = get(markerId);
- const next = {
- attempts: previous.attempts + 1,
- resolved: previous.resolved || correct,
- selectedOptionId
- };
- states.set(markerId, next);
- return next;
- },
- isExhausted(markerId, attemptsAllowed) {
- if (attemptsAllowed <= 0) {
- return false;
- }
- return get(markerId).attempts >= attemptsAllowed;
- },
- correctMarkerIds() {
- const ids = new Set;
- for (const [markerId, state] of states) {
- if (state.resolved) {
- ids.add(markerId);
- }
- }
- return ids;
- },
- retain(markerIds) {
- const keep = new Set(markerIds);
- for (const markerId of [...states.keys()]) {
- if (!keep.has(markerId)) {
- states.delete(markerId);
- }
- }
- },
- clear() {
- states.clear();
- }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/controller.ts
- var EMPTY_CAMERA3 = { orbit: "", target: "", fieldOfView: "" };
- function emptyState2() {
- return {
- enabled: false,
- guidedMode: false,
- wrapNavigation: false,
- showMarkerLabels: true,
- activeMarkerId: "",
- markers: []
- };
- }
- function buildActionBody(body, marker, deps) {
- if (marker.description) {
- const description = document.createElement("p");
- description.className = "tdv-dialog-description";
- description.textContent = marker.description;
- body.appendChild(description);
- }
- const action = marker.action;
- switch (action.type) {
- case "information": {
- const container = document.createElement("div");
- container.className = "tdv-dialog-html";
- container.innerHTML = deps.sanitize(action.payload.html);
- body.appendChild(container);
- return;
- }
- case "image": {
- const figure = document.createElement("figure");
- figure.className = "tdv-dialog-figure";
- const image = document.createElement("img");
- image.src = deps.resolveMedia(action.payload.src);
- image.alt = action.payload.alt;
- figure.appendChild(image);
- if (action.payload.caption) {
- const caption = document.createElement("figcaption");
- caption.textContent = action.payload.caption;
- figure.appendChild(caption);
- }
- body.appendChild(figure);
- return;
- }
- case "video": {
- const video = document.createElement("video");
- video.className = "tdv-dialog-video";
- video.controls = true;
- video.src = deps.resolveMedia(action.payload.src);
- if (action.payload.poster) {
- video.poster = deps.resolveMedia(action.payload.poster);
- }
- body.appendChild(video);
- return;
- }
- case "link":
- case "question":
- return;
- }
- }
- function createInteractionController(handle, interaction, mode, hooks = {}) {
- const wrapper = handle.wrapper;
- const translate = hooks.t ?? ((key) => key);
- const resolveMedia = hooks.resolveMediaUrl ?? ((url) => resolveMediaUrlSync(url));
- const sanitize = hooks.sanitizeHtml ?? sanitizeHtml;
- const answers = createAnswerStore();
- let state = interaction ?? emptyState2();
- let markers = state.markers;
- let activeId = "";
- let destroyed = false;
- let dialog = null;
- let adapter = null;
- let guided = null;
- const markerLabel = (marker, index) => marker.label || `${translate("Marker")} ${index + 1}`;
- const closeDialog = () => {
- dialog?.close();
- dialog = null;
- };
- const currentIndex = () => markers.findIndex((marker) => marker.id === activeId);
- const updateGuided = () => {
- guided?.update({
- enabled: Boolean(state.guidedMode),
- index: currentIndex(),
- total: markers.length,
- wrap: Boolean(state.wrapNavigation)
- });
- };
- const setActive = (markerId) => {
- activeId = markerId;
- adapter?.setActive(activeId);
- updateGuided();
- };
- const activateMarker = (marker, index) => {
- if (marker.action.type === "link") {
- const url = safeUrl(marker.action.payload.url);
- if (!url) {
- return;
- }
- if (marker.action.payload.newTab) {
- globalThis.open(url, "_blank", "noopener,noreferrer");
- } else if (globalThis.location) {
- globalThis.location.href = url;
- }
- return;
- }
- closeDialog();
- dialog = openDialog({
- title: markerLabel(marker, index),
- closeLabel: translate("Close"),
- host: wrapper ?? null,
- onClose: () => {
- dialog = null;
- }
- }, (body) => {
- buildActionBody(body, marker, { sanitize, resolveMedia });
- if (marker.action.type === "question") {
- renderQuestion(body, marker, {
- answers,
- t: translate,
- onAnswered: hooks.onQuestionAnswered
- });
- }
- });
- hooks.onActivate?.(marker.id);
- };
- const focusMarker = (markerId) => {
- const index = markers.findIndex((marker2) => marker2.id === markerId);
- const marker = markers[index];
- if (!marker) {
- return;
- }
- setActive(markerId);
- adapter?.focusMarker(marker);
- activateMarker(marker, index);
- };
- const go = (delta) => {
- const next = resolveStepIndex(currentIndex(), delta, markers.length, Boolean(state.wrapNavigation));
- const marker = next === null ? undefined : markers[next];
- if (marker) {
- focusMarker(marker.id);
- }
- };
- const render = () => {
- if (destroyed) {
- return;
- }
- markers = state.markers;
- if (adapter) {
- adapter.renderMarkers(markers, {
- showLabels: state.showMarkerLabels !== false,
- activeId
- });
- revealFallback(wrapper, !hasWebGL());
- } else {
- revealFallback(wrapper, true);
- }
- updateGuided();
- };
- const controller = {
- setState(next) {
- state = next ?? emptyState2();
- const ids = state.markers.map((marker) => marker.id);
- if (activeId && !ids.includes(activeId)) {
- activeId = "";
- }
- answers.retain(ids);
- render();
- },
- render,
- enterPlacementMode() {
- if (!adapter || mode !== "edit") {
- return;
- }
- wrapper?.classList.add("tdv-placing");
- adapter.enterPlacementMode((placement) => {
- controller.exitPlacementMode();
- hooks.onPlaced?.(placement);
- });
- },
- exitPlacementMode() {
- wrapper?.classList.remove("tdv-placing");
- adapter?.exitPlacementMode();
- },
- focusMarker,
- captureCamera: () => adapter?.captureCamera() ?? { ...EMPTY_CAMERA3 },
- next: () => go(1),
- previous: () => go(-1),
- getActiveId: () => activeId,
- markerLabel,
- destroy() {
- if (destroyed) {
- return;
- }
- destroyed = true;
- controller.exitPlacementMode();
- closeDialog();
- guided?.destroy();
- guided = null;
- adapter?.destroy();
- adapter = null;
- answers.clear();
- }
- };
- const adapterDeps = { markerLabel, onActivate: focusMarker };
- if ((handle.type === "glb" || handle.type === "gltf") && handle.modelViewer) {
- adapter = createModelViewerAdapter(handle.modelViewer, adapterDeps);
- } else if (handle.type === "stl" && handle.instance) {
- adapter = createStlAdapter(handle.instance, wrapper, adapterDeps);
- }
- guided = createGuidedNavigation(wrapper ?? null, { t: translate, onGo: go });
- render();
- return controller;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/colors.ts
- var DEFAULT_MODEL_COLOR = "#888888";
- var DEFAULT_BACKGROUND_COLOR = "#f5f5f5";
- var HEX6 = /^#[0-9a-f]{6}$/;
- var HEX3 = /^#[0-9a-f]{3}$/;
- function normalizeColor(value, fallback = DEFAULT_MODEL_COLOR) {
- if (typeof value !== "string") {
- return fallback;
- }
- const trimmed = value.trim().toLowerCase();
- if (HEX6.test(trimmed)) {
- return trimmed;
- }
- if (HEX3.test(trimmed)) {
- const r = trimmed[1] ?? "0";
- const g = trimmed[2] ?? "0";
- const b = trimmed[3] ?? "0";
- return `#${r}${r}${g}${g}${b}${b}`;
- }
- return fallback;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/instance-registry.ts
- function createRegistry() {
- const instances = new Map;
- const destroy = (wrapper) => {
- const instance = instances.get(wrapper);
- if (!instance) {
- return;
- }
- instances.delete(wrapper);
- disposeInstance(instance);
- };
- return {
- get: (wrapper) => instances.get(wrapper),
- set: (wrapper, instance) => {
- instances.set(wrapper, instance);
- },
- has: (wrapper) => instances.has(wrapper),
- destroy,
- destroyAll: () => {
- for (const wrapper of [...instances.keys()].reverse()) {
- destroy(wrapper);
- }
- },
- wrappers: () => [...instances.keys()]
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/stl-renderer.ts
- var NORMALIZED_SIZE = 2;
- function configureRendererColorManagement(renderer) {
- const three = globalThis.THREE;
- if (!three || !renderer) {
- return;
- }
- if (three.ColorManagement && "enabled" in three.ColorManagement) {
- three.ColorManagement.enabled = true;
- }
- if ("outputColorSpace" in renderer && three.SRGBColorSpace !== undefined) {
- renderer.outputColorSpace = three.SRGBColorSpace;
- } else if ("outputEncoding" in renderer && three.sRGBEncoding !== undefined) {
- renderer.outputEncoding = three.sRGBEncoding;
- }
- if ("toneMapping" in renderer && three.NoToneMapping !== undefined) {
- renderer.toneMapping = three.NoToneMapping;
- }
- }
- function ensureCanvas(wrapper) {
- const existing = wrapper.querySelector("canvas.three-js-canvas");
- if (existing) {
- return existing;
- }
- const canvas = document.createElement("canvas");
- canvas.className = "three-js-canvas";
- canvas.style.cssText = "width: 100%; height: 100%; display: block;";
- wrapper.appendChild(canvas);
- return canvas;
- }
- function requestFrame(callback) {
- const raf = globalThis.requestAnimationFrame;
- return typeof raf === "function" ? raf(callback) : setTimeout(callback, 16);
- }
- async function bootStl(instance) {
- const three = globalThis.THREE;
- if (!three?.STLLoader || instance.stopped) {
- return;
- }
- const { options, wrapper } = instance;
- const url = await resolveModelSource(options.src);
- if (instance.stopped || !url) {
- return;
- }
- const canvas = ensureCanvas(wrapper);
- instance.canvas = canvas;
- const modelViewer = wrapper.querySelector("model-viewer");
- if (modelViewer) {
- modelViewer.style.display = "none";
- instance.modelViewer = modelViewer;
- }
- const width = wrapper.clientWidth || 400;
- const height = wrapper.clientHeight || 300;
- const scene = new three.Scene;
- scene.background = new three.Color(normalizeColor(options.backgroundColor, DEFAULT_BACKGROUND_COLOR));
- const camera = new three.PerspectiveCamera(45, width / height, 0.1, 1000);
- const renderer = new three.WebGLRenderer({ canvas, antialias: true });
- renderer.setSize(width, height);
- renderer.setPixelRatio?.(Math.min(globalThis.devicePixelRatio || 1, 2));
- configureRendererColorManagement(renderer);
- instance.scene = scene;
- instance.camera = camera;
- instance.renderer = renderer;
- scene.add(new three.AmbientLight(16777215, 0.6));
- const keyLight = new three.DirectionalLight(16777215, 0.8);
- keyLight.position.set(1, 1, 1);
- scene.add(keyLight);
- const fillLight = new three.DirectionalLight(16777215, 0.4);
- fillLight.position.set(-1, -1, -1);
- scene.add(fillLight);
- try {
- const response = await fetch(url);
- if (instance.stopped) {
- return;
- }
- const buffer = await response.arrayBuffer();
- if (instance.stopped) {
- return;
- }
- const geometry = new three.STLLoader().parse(buffer);
- geometry.computeBoundingBox();
- geometry.center();
- const size = geometry.boundingBox?.getSize(new three.Vector3);
- const maxDimension = size ? Math.max(size.x, size.y, size.z) || 1 : 1;
- const scale = NORMALIZED_SIZE / maxDimension;
- geometry.scale(scale, scale, scale);
- if (!geometry.hasAttribute("normal")) {
- geometry.computeVertexNormals();
- }
- const material = new three.MeshStandardMaterial({
- color: new three.Color(normalizeColor(options.modelColor, DEFAULT_MODEL_COLOR)),
- metalness: 0,
- roughness: 0.55
- });
- const mesh = new three.Mesh(geometry, material);
- scene.add(mesh);
- camera.position.set(3, 3, 3);
- camera.lookAt(0, 0, 0);
- let controls = null;
- if (options.cameraControls && three.OrbitControls) {
- const orbitControls = new three.OrbitControls(camera, canvas);
- orbitControls.enableDamping = true;
- orbitControls.dampingFactor = 0.05;
- controls = orbitControls;
- }
- instance.mesh = mesh;
- instance.geometry = geometry;
- instance.material = material;
- instance.controls = controls;
- const autoRotate = options.autoRotate;
- const radiansPerSecond = (options.autoRotateSpeed || 30) * Math.PI / 180;
- const animate = () => {
- if (instance.stopped || !instance.renderer || !instance.scene || !instance.camera) {
- return;
- }
- if (autoRotate && instance.mesh) {
- instance.mesh.rotation.y += radiansPerSecond / 60;
- }
- instance.controls?.update?.();
- for (const callback of instance.onFrame) {
- try {
- callback();
- } catch {}
- }
- instance.renderer.render(instance.scene, instance.camera);
- instance.rafId = requestFrame(animate);
- };
- animate();
- const empty = wrapper.querySelector("[data-empty], [data-empty-state]");
- if (empty) {
- empty.style.display = "none";
- }
- } catch (error) {
- console.error("[3D Viewer] Failed to render STL:", error);
- }
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/viewer-runtime.ts
- function readWrapperOptions(wrapper) {
- const data = wrapper.dataset;
- const showNavControls = data.showNavControls === "true";
- const src = normalizeModelSource(data.modelSrc ?? "");
- return {
- src,
- type: data.modelType || detectModelType(src),
- modelColor: normalizeColor(data.modelColor, DEFAULT_MODEL_COLOR),
- backgroundColor: normalizeColor(data.backgroundColor, DEFAULT_BACKGROUND_COLOR),
- cameraControls: data.cameraControls !== "false",
- autoRotate: !showNavControls && data.autoRotate !== "false",
- autoRotateSpeed: Number.parseFloat(data.autoRotateSpeed ?? "") || 30
- };
- }
- function createViewerRuntime() {
- const registry = createRegistry();
- let unloadBound = false;
- const bindUnloadOnce = () => {
- if (unloadBound || typeof globalThis.addEventListener !== "function") {
- return;
- }
- unloadBound = true;
- globalThis.addEventListener("beforeunload", () => registry.destroyAll());
- };
- return {
- init(wrapper, options) {
- if (!wrapper) {
- return null;
- }
- const existing = registry.get(wrapper);
- if (existing) {
- return existing;
- }
- const instance = createInstance(wrapper, options ?? readWrapperOptions(wrapper));
- registry.set(wrapper, instance);
- bindUnloadOnce();
- if (instance.type === "stl" && instance.options.src) {
- bootStl(instance).catch((error) => {
- console.error("[3D Viewer] STL boot failed:", error);
- });
- }
- return instance;
- },
- destroy: (wrapper) => registry.destroy(wrapper),
- destroyAll: () => registry.destroyAll(),
- getInstance: (wrapper) => registry.get(wrapper) ?? null,
- createInteractionLayer: (handle, interaction, mode, hooks) => createInteractionController(handle, interaction, mode, hooks),
- detectModelType,
- normalizeColor,
- normalizeModelSource,
- resolveModelSource,
- configureRendererColorManagement,
- disposeObject3D,
- disposeMaterial,
- readWrapperOptions,
- registry
- };
- }
- function publishViewerRuntime() {
- const existing = globalThis.eXe3DViewer;
- if (existing) {
- return existing;
- }
- const runtime = createViewerRuntime();
- globalThis.eXe3DViewer = runtime;
- return runtime;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/types.ts
- var SCHEMA_VERSION = 2;
- var MARKER_ICONS = ["circle", "pin", "info", "question", "star"];
- var MARKER_ACTION_TYPES = ["information", "image", "video", "link", "question"];
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/schema.ts
- var MAX_QUESTION_OPTIONS = 10;
- var MAX_ATTEMPTS_ALLOWED = 20;
- var defaultIdFactory = (prefix) => `${prefix}-${Math.floor(Math.random() * 1e9).toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
- function asRecord(value) {
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
- }
- function toNumber(value, fallback) {
- const parsed = typeof value === "number" ? value : Number.parseFloat(String(value));
- return Number.isFinite(parsed) ? parsed : fallback;
- }
- function toInteger(value, fallback) {
- const parsed = Number.parseInt(String(value), 10);
- return Number.isFinite(parsed) ? parsed : fallback;
- }
- function toText(value, fallback = "") {
- return typeof value === "string" ? value : fallback;
- }
- function clamp(value, min, max) {
- return Math.min(max, Math.max(min, value));
- }
- function keepOrCreateId(value, prefix, createId) {
- return typeof value === "string" && value ? value : createId(prefix);
- }
- function normalizeVector3(value, fallback) {
- const raw = asRecord(value);
- return {
- x: toNumber(raw.x, fallback.x),
- y: toNumber(raw.y, fallback.y),
- z: toNumber(raw.z, fallback.z)
- };
- }
- function normalizeAnchor(value) {
- const raw = asRecord(value);
- return {
- position: normalizeVector3(raw.position, { x: 0, y: 0, z: 0 }),
- normal: normalizeVector3(raw.normal, { x: 0, y: 1, z: 0 }),
- surface: toText(raw.surface)
- };
- }
- function normalizeCamera(value) {
- const raw = asRecord(value);
- return {
- orbit: toText(raw.orbit),
- target: toText(raw.target),
- fieldOfView: toText(raw.fieldOfView)
- };
- }
- function normalizeQuestion(value, createId = defaultIdFactory) {
- const raw = asRecord(value);
- const rawOptions = Array.isArray(raw.options) ? raw.options : [];
- let seenCorrect = false;
- const options = rawOptions.slice(0, MAX_QUESTION_OPTIONS).map((option) => {
- const item = asRecord(option);
- const correct = Boolean(item.correct) && !seenCorrect;
- if (correct) {
- seenCorrect = true;
- }
- return { id: keepOrCreateId(item.id, "option", createId), text: toText(item.text), correct };
- });
- if (options.length === 0) {
- options.push({ id: createId("option"), text: "", correct: true }, { id: createId("option"), text: "", correct: false });
- } else if (!seenCorrect) {
- const first = options[0];
- if (first) {
- first.correct = true;
- }
- }
- return {
- prompt: toText(raw.prompt),
- type: "single-choice",
- options,
- feedbackCorrect: toText(raw.feedbackCorrect),
- feedbackIncorrect: toText(raw.feedbackIncorrect),
- attemptsAllowed: clamp(Math.round(toNumber(raw.attemptsAllowed, 0)), 0, MAX_ATTEMPTS_ALLOWED)
- };
- }
- function normalizeInformationPayload(raw) {
- return { html: toText(raw.html) };
- }
- function normalizeImagePayload(raw) {
- return { src: stripUnsafeUrl(raw.src), alt: toText(raw.alt), caption: toText(raw.caption) };
- }
- function normalizeVideoPayload(raw) {
- return { src: stripUnsafeUrl(raw.src), poster: stripUnsafeUrl(raw.poster) };
- }
- function normalizeLinkPayload(raw) {
- return { url: stripUnsafeUrl(raw.url), newTab: raw.newTab !== false };
- }
- function toActionType(value) {
- return MARKER_ACTION_TYPES.includes(String(value)) ? value : "information";
- }
- function normalizeAction(value, createId = defaultIdFactory) {
- const raw = asRecord(value);
- const type = toActionType(raw.type);
- const payload = asRecord(raw.payload);
- switch (type) {
- case "image":
- return { type, payload: normalizeImagePayload(payload) };
- case "video":
- return { type, payload: normalizeVideoPayload(payload) };
- case "link":
- return { type, payload: normalizeLinkPayload(payload) };
- case "question":
- return { type, payload: normalizeQuestion(payload, createId) };
- case "information":
- return { type, payload: normalizeInformationPayload(payload) };
- }
- const unreachable = type;
- return { type: "information", payload: { html: "" } };
- }
- function toIcon(value) {
- return MARKER_ICONS.includes(String(value)) ? value : "circle";
- }
- function normalizeMarker(value, index, createId = defaultIdFactory) {
- const raw = asRecord(value);
- const order = toNumber(raw.order, Number.NaN);
- return {
- id: keepOrCreateId(raw.id, "marker", createId),
- label: toText(raw.label),
- description: toText(raw.description),
- icon: toIcon(raw.icon),
- order: Number.isFinite(order) ? order : index,
- anchor: normalizeAnchor(raw.anchor),
- camera: normalizeCamera(raw.camera),
- action: normalizeAction(raw.action, createId)
- };
- }
- function normalizeInteraction(value, createId = defaultIdFactory) {
- const raw = asRecord(value);
- const markers = (Array.isArray(raw.markers) ? raw.markers : []).map((marker, index) => normalizeMarker(marker, index, createId));
- markers.sort((a, b) => a.order - b.order);
- markers.forEach((marker, index) => {
- marker.order = index;
- });
- const ids = markers.map((marker) => marker.id);
- const activeMarkerId = toText(raw.activeMarkerId);
- return {
- enabled: Boolean(raw.enabled),
- guidedMode: Boolean(raw.guidedMode),
- wrapNavigation: Boolean(raw.wrapNavigation),
- showMarkerLabels: raw.showMarkerLabels !== false,
- activeMarkerId: ids.includes(activeMarkerId) ? activeMarkerId : "",
- markers
- };
- }
- function normalizeAnimation(value) {
- const raw = asRecord(value);
- return {
- enabled: Boolean(raw.enabled),
- name: toText(raw.name),
- speed: clamp(toNumber(raw.speed, 1), 0.1, 3)
- };
- }
- function normalizeScorm(value) {
- const raw = asRecord(value);
- const mode = clamp(toInteger(raw.mode ?? raw.isScorm, 0), 0, 2);
- return {
- mode,
- weighted: clamp(toNumber(raw.weighted, 100), 1, 100),
- saveButtonText: toText(raw.saveButtonText ?? raw.textButtonScorm)
- };
- }
- function createDefaultDocument() {
- return {
- schemaVersion: 2,
- src: "",
- alt: "",
- modelColor: DEFAULT_MODEL_COLOR,
- backgroundColor: DEFAULT_BACKGROUND_COLOR,
- cameraControls: true,
- autoRotate: true,
- autoRotateSpeed: 30,
- showNavControls: false,
- animation: { enabled: false, name: "", speed: 1 },
- interaction: {
- enabled: false,
- guidedMode: false,
- wrapNavigation: false,
- showMarkerLabels: true,
- activeMarkerId: "",
- markers: []
- },
- scorm: { mode: 0, weighted: 100, saveButtonText: "" }
- };
- }
- function normalizeDocument(value, createId = defaultIdFactory) {
- const raw = asRecord(value);
- const defaults = createDefaultDocument();
- const showNavControls = typeof raw.showNavControls === "boolean" ? raw.showNavControls : defaults.showNavControls;
- const autoRotate = typeof raw.autoRotate === "boolean" ? raw.autoRotate : defaults.autoRotate;
- return {
- schemaVersion: 2,
- src: normalizeModelSource(raw.src),
- alt: toText(raw.alt),
- modelColor: normalizeColor(raw.modelColor, DEFAULT_MODEL_COLOR),
- backgroundColor: normalizeColor(raw.backgroundColor, DEFAULT_BACKGROUND_COLOR),
- cameraControls: typeof raw.cameraControls === "boolean" ? raw.cameraControls : defaults.cameraControls,
- autoRotate: showNavControls ? false : autoRotate,
- autoRotateSpeed: clamp(toNumber(raw.autoRotateSpeed, 30), 1, 90),
- showNavControls,
- animation: normalizeAnimation(raw.animation),
- interaction: normalizeInteraction(raw.interaction, createId),
- scorm: normalizeScorm(raw.scorm ?? raw)
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/migration.ts
- function readSchemaVersion(raw) {
- const value = raw.schemaVersion;
- if (typeof value === "number" && Number.isFinite(value)) {
- return value;
- }
- if (typeof value === "string" && value.trim() !== "") {
- const parsed = Number.parseInt(value, 10);
- if (Number.isFinite(parsed)) {
- return parsed;
- }
- }
- return 0;
- }
- function hydrateDocument(value, createId = defaultIdFactory) {
- if (value === null || value === undefined || value === "") {
- return { status: "ok", document: createDefaultDocument() };
- }
- if (typeof value !== "object" || Array.isArray(value)) {
- return { status: "invalid", reason: "expected an object", original: value };
- }
- const raw = value;
- const version = readSchemaVersion(raw);
- if (version > SCHEMA_VERSION) {
- return { status: "unsupported-version", version, original: value };
- }
- return { status: "ok", document: normalizeDocument(raw, createId) };
- }
- function serializeDocument(document2, createId = defaultIdFactory) {
- return normalizeDocument(document2, createId);
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/edition/editor.ts
- function renderEditorMarkup(t) {
- const e = (text) => escapeHtml(t(text));
- return `
-
-
-
-
-
-
-
-
-
-
-
-
-
${e("Select a 3D model to preview")}
-
-
-
⛶
-
- ←
- ↑
- ↓
- →
-
-
-
-
-
-
- ${e("3D Model")}:
-
-
-
${e("Supported formats")}: GLB, GLTF, STL
-
-
-
-
${e("Alternative text")}:
-
-
${e("Describe the 3D model for screen readers and accessibility")}
-
-
-
-
- ${e("Display Options")}
-
-
-
${e("Background color")}:
-
-
-
-
-
-
-
${e("STL model color")}:
-
-
-
-
${e("Only used for STL files; ignored for GLB/GLTF (materials come from the model).")}
-
-
-
-
-
-
-
-
- ${e("Enable camera controls")}
-
-
-
-
-
-
-
-
-
- ${e("Auto-rotate model")}
-
-
-
${e("Speed")}:
-
-
- °/s
-
-
-
-
-
-
-
-
-
-
- ${e("Show navigation controls (fullscreen + arrows)")}
-
-
- ${e("Mutually exclusive with auto-rotate.")}
-
-
-
-
- ${e("Animation")}
-
-
-
-
-
-
-
- ${e("Play animation")}
-
-
-
-
-
- ${e("Animation")}:
-
-
-
-
${e("Speed")}:
-
-
- x
-
-
-
-
-
-
-
- ${e("Interactions")}
-
-
-
-
-
- ${e("Enable interactions")}
-
-
-
-
-
-
-
- ${e("Guided navigation (previous / next)")}
-
-
-
-
-
-
- ${e("Wrap around at the ends")}
-
-
-
-
-
-
- ${e("Show marker labels")}
-
-
${e("Add marker")}
-
${e("Click on the model to place the marker.")}
-
-
-
${e("Assessment (SCORM)")}
-
-
-
-
-
-
-
-
- `;
- }
- function renderUnsupportedVersionMarkup(t, version) {
- return `
-
-
-
-
${escapeHtml(t("This 3D Viewer was created with a newer version of eXeLearning and cannot be edited here."))}
-
${escapeHtml(t("Update eXeLearning to edit it. Its content is preserved."))}
-
-
-
- `;
- }
- function require2(root, selector) {
- const element = root.querySelector(selector);
- if (!element) {
- throw new Error(`[3D Viewer] Editor element not found: ${selector}`);
- }
- return element;
- }
- function collectElements(root) {
- return {
- root,
- preview: require2(root, "#threeDViewerPreview"),
- ariaLive: require2(root, "#threeDAnimationLive"),
- animationRow: require2(root, "[data-animation-row]"),
- autoRotateSpeedRow: require2(root, "#threeDAutoRotateSpeedRow"),
- modelColorHint: require2(root, "#threeDModelColorHint"),
- src: require2(root, "#threeD3DModelFile"),
- alt: require2(root, "#threeDAlt"),
- modelColor: require2(root, "#threeDModelColor"),
- backgroundColor: require2(root, "#threeDBackground"),
- cameraControls: require2(root, "#threeDCameraControls"),
- autoRotate: require2(root, "#threeDAutoRotate"),
- autoRotateSpeed: require2(root, "#threeDAutoRotateSpeed"),
- showNavControls: require2(root, "#threeDShowNavControls"),
- animationToggle: require2(root, "#threeDAnimationToggle"),
- animationName: require2(root, "#threeDAnimationName"),
- animationSpeed: require2(root, "#threeDAnimationSpeed"),
- interactionsEnable: require2(root, "#threeDInteractionsEnable"),
- interactionsBody: require2(root, "#threeDInteractionsBody"),
- guidedMode: require2(root, "#threeDGuidedMode"),
- wrapNavigation: require2(root, "#threeDWrapNavigation"),
- showMarkerLabels: require2(root, "#threeDShowMarkerLabels"),
- addMarker: require2(root, "#threeDAddMarker"),
- placementHint: require2(root, "#threeDPlacementHint"),
- markerList: require2(root, "#threeDMarkerList"),
- markerEditorHost: require2(root, "#threeDMarkerEditorHost"),
- scormSection: require2(root, "#threeDScormSection"),
- scormHost: require2(root, "#threeDScormTab")
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/edition/form.ts
- function applyDocumentToForm(elements, document2) {
- elements.src.value = document2.src;
- elements.alt.value = document2.alt;
- elements.modelColor.value = document2.modelColor || DEFAULT_MODEL_COLOR;
- elements.backgroundColor.value = document2.backgroundColor || DEFAULT_BACKGROUND_COLOR;
- elements.cameraControls.checked = document2.cameraControls;
- elements.autoRotate.checked = document2.autoRotate;
- elements.autoRotateSpeed.value = String(document2.autoRotateSpeed || 30);
- elements.showNavControls.checked = document2.showNavControls;
- elements.animationToggle.checked = document2.animation.enabled;
- elements.animationSpeed.value = String(document2.animation.speed || 1);
- elements.animationName.value = document2.animation.name;
- elements.interactionsEnable.checked = document2.interaction.enabled;
- elements.guidedMode.checked = document2.interaction.guidedMode;
- elements.wrapNavigation.checked = document2.interaction.wrapNavigation;
- elements.showMarkerLabels.checked = document2.interaction.showMarkerLabels;
- }
- function readDisplaySettings(elements, currentSrc) {
- const showNavControls = elements.showNavControls.checked;
- const speed = Number.parseFloat(elements.animationSpeed.value);
- return {
- src: elements.src.value.trim() || currentSrc,
- alt: elements.alt.value.trim(),
- modelColor: normalizeColor(elements.modelColor.value, DEFAULT_MODEL_COLOR),
- backgroundColor: normalizeColor(elements.backgroundColor.value, DEFAULT_BACKGROUND_COLOR),
- cameraControls: elements.cameraControls.checked,
- autoRotate: !showNavControls && elements.autoRotate.checked,
- autoRotateSpeed: Number.parseFloat(elements.autoRotateSpeed.value) || 30,
- showNavControls,
- animation: {
- enabled: elements.animationToggle.checked,
- name: elements.animationName.value,
- speed: Number.isFinite(speed) ? Math.min(Math.max(speed, 0.1), 3) : 1
- }
- };
- }
- function updateAutoRotateSpeedState(elements) {
- const enabled = elements.autoRotate.checked;
- elements.autoRotateSpeed.disabled = !enabled;
- elements.autoRotateSpeedRow.style.display = enabled ? "" : "none";
- }
- function updateModelColorFieldState(elements, src, t) {
- const isStl = isStlSource(src);
- elements.modelColor.disabled = !isStl;
- elements.modelColor.title = isStl ? t("Choose STL model color") : t("Only STL files use this color; the current file is not STL");
- elements.modelColorHint.classList.toggle("text-muted", !isStl);
- }
- function updateNavControlsVisibility(elements, visible) {
- const fullscreen = elements.preview.querySelector("[data-fullscreen]");
- const nav = elements.preview.querySelector(".three-d-viewer-nav");
- if (fullscreen) {
- fullscreen.style.display = visible ? "" : "none";
- }
- if (nav) {
- nav.style.display = visible ? "" : "none";
- }
- }
- function updateEmptyState(elements, src) {
- const empty = elements.preview.querySelector("[data-empty-state]");
- if (empty) {
- empty.style.display = src ? "none" : "grid";
- }
- }
- function updateAnimationOptions(elements, available, animation) {
- elements.animationName.innerHTML = "";
- for (const name of available) {
- const option = document.createElement("option");
- option.value = name;
- option.textContent = name;
- elements.animationName.appendChild(option);
- }
- if (available.length === 0) {
- elements.animationToggle.checked = false;
- elements.animationToggle.disabled = true;
- elements.animationName.disabled = true;
- elements.animationSpeed.disabled = true;
- elements.animationRow.hidden = true;
- return { ...animation, enabled: false, name: "" };
- }
- const selected = available.includes(animation.name) ? animation.name : available[0] ?? "";
- elements.animationName.value = selected;
- elements.animationRow.hidden = false;
- elements.animationToggle.disabled = false;
- elements.animationName.disabled = false;
- elements.animationSpeed.disabled = false;
- return { ...animation, name: selected };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/edition/marker-editor.ts
- var MAX_AUTHORED_OPTIONS = 8;
- function validateMarker(marker, t) {
- if (marker.action.type !== "question") {
- return { valid: true };
- }
- const question = marker.action.payload;
- if (!question.prompt.trim()) {
- return { valid: false, message: t("Enter the question prompt.") };
- }
- const answered = question.options.filter((option) => option.text.trim().length > 0);
- if (answered.length < 2) {
- return { valid: false, message: t("Enter at least two answer options.") };
- }
- if (question.options.filter((option) => option.correct).length !== 1) {
- return { valid: false, message: t("Mark exactly one option as correct.") };
- }
- return { valid: true };
- }
- function labelledInput(container, id, labelText, element) {
- const wrapper = document.createElement("div");
- wrapper.className = "mb-2";
- const label = document.createElement("label");
- label.className = "form-label";
- label.setAttribute("for", id);
- label.textContent = labelText;
- element.id = id;
- wrapper.append(label, element);
- container.appendChild(wrapper);
- }
- function textInput(value) {
- const input = document.createElement("input");
- input.type = "text";
- input.className = "form-control";
- input.value = value;
- return input;
- }
- function textArea(value, rows = 3) {
- const area = document.createElement("textarea");
- area.className = "form-control";
- area.rows = rows;
- area.value = value;
- return area;
- }
- function renderQuestionFields(container, question, t, createId) {
- const prompt = textArea(question.prompt, 2);
- prompt.classList.add("mb-2");
- prompt.setAttribute("aria-label", t("Question prompt"));
- prompt.placeholder = t("Question prompt");
- prompt.addEventListener("input", () => {
- question.prompt = prompt.value;
- });
- container.appendChild(prompt);
- const optionsHost = document.createElement("div");
- optionsHost.className = "tdv-q-options";
- container.appendChild(optionsHost);
- const renderOptions = () => {
- optionsHost.innerHTML = "";
- question.options.forEach((option, index) => {
- const row = document.createElement("div");
- row.className = "input-group input-group-sm mb-1";
- const radio = document.createElement("input");
- radio.type = "radio";
- radio.name = "tdvMkCorrect";
- radio.className = "form-check-input mt-2 me-2";
- radio.checked = option.correct;
- radio.setAttribute("aria-label", `${t("Correct answer")} ${index + 1}`);
- radio.addEventListener("change", () => {
- for (const other of question.options) {
- other.correct = other === option;
- }
- });
- const text = textInput(option.text);
- text.placeholder = `${t("Option")} ${index + 1}`;
- text.addEventListener("input", () => {
- option.text = text.value;
- });
- const remove = document.createElement("button");
- remove.type = "button";
- remove.className = "btn btn-outline-secondary";
- remove.textContent = "✕";
- remove.setAttribute("aria-label", `${t("Remove option")} ${index + 1}`);
- remove.disabled = question.options.length <= 2;
- remove.addEventListener("click", () => {
- question.options = question.options.filter((other) => other !== option);
- if (!question.options.some((other) => other.correct) && question.options[0]) {
- question.options[0].correct = true;
- }
- renderOptions();
- });
- row.append(radio, text, remove);
- optionsHost.appendChild(row);
- });
- };
- renderOptions();
- const addOption = document.createElement("button");
- addOption.type = "button";
- addOption.className = "btn btn-outline-secondary btn-sm mb-2";
- addOption.textContent = t("Add option");
- addOption.addEventListener("click", () => {
- if (question.options.length >= MAX_AUTHORED_OPTIONS) {
- return;
- }
- question.options.push({ id: createId("option"), text: "", correct: false });
- renderOptions();
- });
- container.appendChild(addOption);
- const feedbackCorrect = textInput(question.feedbackCorrect);
- feedbackCorrect.classList.add("mb-2");
- feedbackCorrect.placeholder = t("Feedback when correct");
- feedbackCorrect.addEventListener("input", () => {
- question.feedbackCorrect = feedbackCorrect.value;
- });
- container.appendChild(feedbackCorrect);
- const feedbackIncorrect = textInput(question.feedbackIncorrect);
- feedbackIncorrect.classList.add("mb-2");
- feedbackIncorrect.placeholder = t("Feedback when incorrect");
- feedbackIncorrect.addEventListener("input", () => {
- question.feedbackIncorrect = feedbackIncorrect.value;
- });
- container.appendChild(feedbackIncorrect);
- const attemptsWrapper = document.createElement("div");
- attemptsWrapper.className = "mb-1";
- const attempts = document.createElement("input");
- attempts.type = "number";
- attempts.className = "form-control";
- attempts.min = "0";
- attempts.max = "20";
- attempts.value = String(question.attemptsAllowed);
- attempts.addEventListener("input", () => {
- question.attemptsAllowed = Number.parseInt(attempts.value, 10) || 0;
- });
- labelledInput(attemptsWrapper, "tdvMkAttempts", t("Attempts allowed (0 = unlimited)"), attempts);
- container.appendChild(attemptsWrapper);
- }
- function renderActionFields(container, draft, t, createId) {
- container.innerHTML = "";
- const action = draft.action;
- switch (action.type) {
- case "information": {
- const html = textArea(action.payload.html);
- html.addEventListener("input", () => {
- action.payload.html = html.value;
- });
- labelledInput(container, "tdvMkHtml", t("Content (HTML allowed)"), html);
- return;
- }
- case "image": {
- const src = textInput(action.payload.src);
- src.addEventListener("input", () => {
- action.payload.src = src.value;
- });
- labelledInput(container, "tdvMkImgSrc", t("Image URL"), src);
- const alt = textInput(action.payload.alt);
- alt.addEventListener("input", () => {
- action.payload.alt = alt.value;
- });
- labelledInput(container, "tdvMkImgAlt", t("Alternative text"), alt);
- const caption = textInput(action.payload.caption);
- caption.addEventListener("input", () => {
- action.payload.caption = caption.value;
- });
- labelledInput(container, "tdvMkImgCap", t("Caption"), caption);
- return;
- }
- case "video": {
- const src = textInput(action.payload.src);
- src.addEventListener("input", () => {
- action.payload.src = src.value;
- });
- labelledInput(container, "tdvMkVidSrc", t("Video URL"), src);
- const poster = textInput(action.payload.poster);
- poster.addEventListener("input", () => {
- action.payload.poster = poster.value;
- });
- labelledInput(container, "tdvMkVidPoster", t("Poster URL"), poster);
- return;
- }
- case "link": {
- const url = textInput(action.payload.url);
- url.addEventListener("input", () => {
- action.payload.url = url.value;
- });
- labelledInput(container, "tdvMkLinkUrl", t("Link URL"), url);
- const check = document.createElement("div");
- check.className = "form-check";
- const box = document.createElement("input");
- box.type = "checkbox";
- box.className = "form-check-input";
- box.id = "tdvMkNewTab";
- box.checked = action.payload.newTab;
- box.addEventListener("change", () => {
- action.payload.newTab = box.checked;
- });
- const label = document.createElement("label");
- label.className = "form-check-label";
- label.setAttribute("for", "tdvMkNewTab");
- label.textContent = t("Open in a new tab");
- check.append(box, label);
- container.appendChild(check);
- return;
- }
- case "question":
- renderQuestionFields(container, action.payload, t, createId);
- return;
- }
- }
- function buildPanelMarkup(t) {
- return `
-
-
-
${t("Edit marker")}
-
-
-
- ${t("Label")}
-
-
-
-
- ${t("Icon")}
-
-
-
- ${t("Action type")}
-
-
-
-
- ${t("Short description")}
-
-
-
-
- ${t("Capture current camera")}
-
-
-
-
-
${t("Delete marker")}
-
- ${t("Cancel")}
- ${t("Save marker")}
-
-
-
`;
- }
- function openMarkerEditor(host, marker, t, createId, callbacks) {
- const draft = normalizeMarker(JSON.parse(JSON.stringify(marker)), marker.order, createId);
- host.innerHTML = buildPanelMarkup(t);
- const panel = host.querySelector(".tdv-marker-editor");
- if (!panel) {
- throw new Error("[3D Viewer] Marker editor panel failed to render");
- }
- const query = (selector) => {
- const element = panel.querySelector(selector);
- if (!element) {
- throw new Error(`[3D Viewer] Marker editor element not found: ${selector}`);
- }
- return element;
- };
- const iconSelect = query("#tdvMkIcon");
- for (const icon of MARKER_ICONS) {
- const option = document.createElement("option");
- option.value = icon;
- option.textContent = icon;
- iconSelect.appendChild(option);
- }
- const typeLabels = {
- information: t("Information"),
- image: t("Image"),
- video: t("Video"),
- link: t("Link"),
- question: t("Question")
- };
- const typeSelect = query("#tdvMkType");
- for (const type of MARKER_ACTION_TYPES) {
- const option = document.createElement("option");
- option.value = type;
- option.textContent = typeLabels[type];
- typeSelect.appendChild(option);
- }
- const labelInput = query("#tdvMkLabel");
- const descriptionInput = query("#tdvMkDesc");
- labelInput.value = draft.label;
- descriptionInput.value = draft.description;
- iconSelect.value = draft.icon;
- typeSelect.value = draft.action.type;
- const cameraNote = query("[data-camera-note]");
- if (draft.camera.orbit || draft.camera.target) {
- cameraNote.textContent = t("Camera captured");
- }
- const errorNote = query("[data-error]");
- const actionFields = query("#tdvActionFields");
- const renderFields = () => renderActionFields(actionFields, draft, t, createId);
- renderFields();
- typeSelect.addEventListener("change", () => {
- draft.action = normalizeAction({ type: typeSelect.value, payload: {} }, createId);
- if (typeSelect.value === "question") {
- iconSelect.value = "question";
- }
- renderFields();
- });
- query("[data-capture-camera]").addEventListener("click", () => {
- const camera = callbacks.captureCamera();
- if (camera) {
- draft.camera = camera;
- cameraNote.textContent = t("Camera captured");
- }
- });
- let closed = false;
- const close = () => {
- if (closed) {
- return;
- }
- closed = true;
- host.innerHTML = "";
- };
- const cancel = () => {
- close();
- callbacks.onCancel();
- };
- query("[data-close]").addEventListener("click", cancel);
- query("[data-cancel]").addEventListener("click", cancel);
- query("[data-delete]").addEventListener("click", () => {
- close();
- callbacks.onDelete(marker.id);
- });
- query("[data-save]").addEventListener("click", () => {
- draft.label = labelInput.value;
- draft.description = descriptionInput.value;
- draft.icon = iconSelect.value || "circle";
- const normalized = normalizeMarker(draft, draft.order, createId);
- const validation = validateMarker(normalized, t);
- if (!validation.valid) {
- errorNote.hidden = false;
- errorNote.textContent = validation.message;
- return;
- }
- errorNote.hidden = true;
- errorNote.textContent = "";
- close();
- callbacks.onSave(normalized);
- });
- try {
- labelInput.focus();
- } catch {}
- return { markerId: marker.id, draft, close };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/edition/marker-list.ts
- function actionTypeLabel(type, t) {
- const labels = {
- information: t("Information"),
- image: t("Image"),
- video: t("Video"),
- link: t("Link"),
- question: t("Question")
- };
- return labels[type];
- }
- function createRowButton(options) {
- const button = document.createElement("button");
- button.type = "button";
- button.className = `btn btn-sm btn-outline-secondary ${options.className}`;
- button.textContent = options.glyph;
- button.title = options.title;
- button.setAttribute("aria-label", `${options.title}: ${options.markerName}`);
- button.disabled = options.disabled;
- button.addEventListener("click", options.onClick);
- return button;
- }
- function renderMarkerList(host, markers, t, callbacks) {
- host.innerHTML = "";
- markers.forEach((marker, index) => {
- const name = marker.label || `${t("Marker")} ${index + 1}`;
- const row = document.createElement("li");
- row.className = "tdv-marker-row d-flex align-items-center gap-1 mb-1";
- row.dataset.markerId = marker.id;
- const label = document.createElement("span");
- label.className = "tdv-marker-row-label flex-grow-1";
- label.textContent = `${index + 1}. ${name} — ${actionTypeLabel(marker.action.type, t)}`;
- row.appendChild(label);
- row.append(createRowButton({
- className: "tdv-move-up",
- glyph: "↑",
- title: t("Move up"),
- markerName: name,
- disabled: index === 0,
- onClick: () => callbacks.onMove(marker.id, -1)
- }), createRowButton({
- className: "tdv-move-down",
- glyph: "↓",
- title: t("Move down"),
- markerName: name,
- disabled: index === markers.length - 1,
- onClick: () => callbacks.onMove(marker.id, 1)
- }), createRowButton({
- className: "tdv-edit-marker",
- glyph: "✎",
- title: t("Edit"),
- markerName: name,
- disabled: false,
- onClick: () => callbacks.onEdit(marker.id)
- }), createRowButton({
- className: "tdv-delete-marker",
- glyph: "✕",
- title: t("Delete"),
- markerName: name,
- disabled: false,
- onClick: () => callbacks.onDelete(marker.id)
- }));
- host.appendChild(row);
- });
- }
- function moveMarker(markers, markerId, delta) {
- const from = markers.findIndex((marker) => marker.id === markerId);
- const to = from + delta;
- const next = [...markers];
- const moved = next[from];
- const displaced = next[to];
- if (from < 0 || !moved || !displaced) {
- return next;
- }
- next[from] = displaced;
- next[to] = moved;
- return next.map((marker, index) => ({ ...marker, order: index }));
- }
- function removeMarker(markers, markerId) {
- return markers.filter((marker) => marker.id !== markerId).map((marker, index) => ({ ...marker, order: index }));
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/model-viewer-loader.ts
- var SCRIPT_MARKER = "data-threedviewer-lib";
- var DEFINITION_TIMEOUT_MS = 15000;
- function libs() {
- globalThis.$exeLibs = globalThis.$exeLibs ?? {};
- return globalThis.$exeLibs;
- }
- function isModelViewerDefined() {
- return Boolean(globalThis.customElements?.get?.("model-viewer"));
- }
- function injectScript(url, origin) {
- return new Promise((resolve) => {
- const script = document.createElement("script");
- script.src = url;
- script.setAttribute(SCRIPT_MARKER, origin);
- script.addEventListener("load", () => resolve());
- script.addEventListener("error", () => {
- console.error("[3D Viewer] Unable to load the model-viewer library from", url);
- resolve();
- });
- document.head.appendChild(script);
- });
- }
- async function ensureModelViewerLoaded(candidates, origin) {
- if (isModelViewerDefined()) {
- return;
- }
- const shared = libs();
- const pending = shared.modelViewerPromise;
- if (pending instanceof Promise) {
- await pending;
- return;
- }
- const existing = typeof document !== "undefined" ? document.querySelector(`script[${SCRIPT_MARKER}]`) : null;
- const loading = (async () => {
- if (!existing) {
- for (const url of candidates.filter(Boolean)) {
- if (isModelViewerDefined()) {
- return;
- }
- await injectScript(url, origin);
- if (isModelViewerDefined()) {
- return;
- }
- }
- }
- const whenDefined = globalThis.customElements?.whenDefined;
- if (whenDefined) {
- try {
- await Promise.race([
- whenDefined.call(globalThis.customElements, "model-viewer"),
- new Promise((resolve) => setTimeout(resolve, DEFINITION_TIMEOUT_MS))
- ]);
- } catch {}
- }
- })();
- shared.modelViewerPromise = loading;
- await loading;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/paths.ts
- var LIB_RELATIVE_PATH = "files/perm/idevices/base/three-d-viewer/export/";
- function parseRuntimeConfig() {
- const config = globalThis.eXeLearning?.config;
- if (typeof config !== "string") {
- return config ?? null;
- }
- try {
- return JSON.parse(config);
- } catch {
- return null;
- }
- }
- function isStaticMode() {
- const config = parseRuntimeConfig();
- return Boolean(config?.isStaticMode || config?.isOfflineInstallation);
- }
- function resolveAppUrl(path) {
- const symfony = globalThis.eXeLearning?.symfony ?? {};
- return joinAppUrl(symfony.baseURL, symfony.basePath, path);
- }
- function withOrigin(url) {
- if (/^https?:\/\//i.test(url)) {
- return url;
- }
- const origin = globalThis.location?.origin ?? "";
- return origin + (url.startsWith("/") ? "" : "/") + url;
- }
- function getEditionLibBaseUrl() {
- if (isStaticMode()) {
- return `${globalThis.location?.origin ?? ""}/${LIB_RELATIVE_PATH}`;
- }
- const symfony = globalThis.eXeLearning?.symfony ?? {};
- const baseURL = String(symfony.baseURL ?? "").replace(/\/+$/g, "");
- const basePath = symfony.basePath ? `/${String(symfony.basePath).replace(/^\/+|\/+$/g, "")}` : "";
- return withOrigin(`${baseURL}${basePath}/${LIB_RELATIVE_PATH}`);
- }
- function getEditionModelViewerUrl() {
- const path = `${LIB_RELATIVE_PATH}model-viewer.min.js`;
- return isStaticMode() ? `./${path}` : resolveAppUrl(path);
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/three-loader.ts
- function libs2() {
- globalThis.$exeLibs = globalThis.$exeLibs ?? {};
- return globalThis.$exeLibs;
- }
- function isThreeJsReady() {
- const three = globalThis.THREE;
- return Boolean(three?.STLLoader && three?.OrbitControls);
- }
- async function ensureThreeJsLoaded(baseUrl) {
- if (isThreeJsReady()) {
- return;
- }
- const shared = libs2();
- const pending = shared.threeJsPromise;
- if (pending instanceof Promise) {
- await pending;
- return;
- }
- const loading = (async () => {
- const core = await import(`${baseUrl}three.module.min.js`);
- const { STLLoader } = await import(`${baseUrl}STLLoader.js`);
- const { OrbitControls } = await import(`${baseUrl}OrbitControls.js`);
- const three = globalThis.THREE ?? {};
- Object.assign(three, core);
- three.STLLoader = STLLoader;
- three.OrbitControls = OrbitControls;
- globalThis.THREE = three;
- })();
- shared.threeJsPromise = loading;
- await loading;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/edition/preview.ts
- var STL_READY_TIMEOUT_MS = 20000;
- var MIN_POLAR = 0.05;
- var MAX_POLAR = Math.PI - 0.05;
- function clamp2(value, min, max) {
- return Math.min(max, Math.max(min, value));
- }
- function createEditorPreview(container, callbacks) {
- const runtime = publishViewerRuntime();
- let modelViewer = null;
- let interactions = null;
- let previewBlobUrl = "";
- let lastPreviewKey = "";
- const destroyInteractions = () => {
- if (interactions) {
- try {
- interactions.destroy();
- } catch {}
- interactions = null;
- }
- };
- const resolveMediaUrl = (url) => {
- if (previewBlobUrl && url === "") {
- return previewBlobUrl;
- }
- if (url.startsWith("asset://")) {
- return getAssetManager()?.resolveAssetURLSync?.(url) || url;
- }
- return url;
- };
- const resolvePreviewUrl = async (src) => {
- if (!src) {
- return "";
- }
- if (src.startsWith("blob:")) {
- return src;
- }
- if (!src.startsWith("asset://")) {
- return src;
- }
- const cached = getAssetManager()?.resolveAssetURLSync?.(src);
- if (cached) {
- previewBlobUrl = cached;
- return cached;
- }
- const manager = await waitForAssetManager(5000);
- if (!manager) {
- console.warn("[3D Viewer] AssetManager not available; cannot preview", src);
- return "";
- }
- const resolved = await resolveModelSource(src, manager);
- if (resolved) {
- previewBlobUrl = resolved;
- }
- return resolved;
- };
- const waitForStlInstance = (timeoutMs) => {
- const deadline = Date.now() + timeoutMs;
- return new Promise((resolve) => {
- const poll = () => {
- const instance = runtime.getInstance(container);
- if (instance?.mesh || Date.now() >= deadline) {
- resolve(instance);
- return;
- }
- const raf = globalThis.requestAnimationFrame;
- if (typeof raf === "function") {
- raf(poll);
- } else {
- setTimeout(poll, 16);
- }
- };
- poll();
- });
- };
- const preview = {
- async mount() {
- await ensureModelViewerLoaded([getEditionModelViewerUrl()], "edition");
- const element = document.createElement("model-viewer");
- element.setAttribute("shadow-intensity", "1");
- element.setAttribute("tone-mapping", "pbr-neutral");
- element.setAttribute("reveal", "auto");
- element.style.width = "100%";
- element.style.height = "100%";
- element.addEventListener("load", () => {
- callbacks.onModelLoaded(Array.from(element.availableAnimations ?? []));
- });
- element.addEventListener("error", () => callbacks.onModelError());
- container.prepend(element);
- modelViewer = element;
- },
- async update(documentState, force = false) {
- const background = documentState.backgroundColor || DEFAULT_BACKGROUND_COLOR;
- container.style.setProperty("--viewer-preview-bg", background);
- if (documentState.src && isStlSource(documentState.src)) {
- await renderStl(documentState, force);
- return;
- }
- if (!modelViewer) {
- return;
- }
- modelViewer.style.display = "";
- runtime.destroy(container);
- const url = await resolvePreviewUrl(documentState.src);
- if (url && (force || url !== lastPreviewKey || !modelViewer.src)) {
- lastPreviewKey = url;
- modelViewer.src = url;
- modelViewer.setAttribute("src", url);
- }
- modelViewer.alt = documentState.alt;
- if (documentState.alt) {
- modelViewer.setAttribute("aria-label", documentState.alt);
- } else {
- modelViewer.removeAttribute("aria-label");
- }
- modelViewer.style.backgroundColor = background;
- if (documentState.cameraControls) {
- modelViewer.setAttribute("camera-controls", "");
- } else {
- modelViewer.removeAttribute("camera-controls");
- }
- if (documentState.autoRotate) {
- modelViewer.setAttribute("auto-rotate", "");
- modelViewer.setAttribute("rotation-per-second", `${documentState.autoRotateSpeed || 30}deg`);
- } else {
- modelViewer.removeAttribute("auto-rotate");
- modelViewer.removeAttribute("rotation-per-second");
- }
- },
- async attachInteractions(documentState, hooks) {
- destroyInteractions();
- const interaction = documentState.interaction;
- if (!interaction.enabled || !documentState.src) {
- return null;
- }
- const type = detectModelType(documentState.src);
- if (type === "stl") {
- const instance = await waitForStlInstance(STL_READY_TIMEOUT_MS);
- if (!instance) {
- return null;
- }
- interactions = runtime.createInteractionLayer({ wrapper: container, type: "stl", instance }, interaction, "edit", hooks);
- instance.interaction = interactions;
- return interactions;
- }
- interactions = runtime.createInteractionLayer({ wrapper: container, type, modelViewer }, interaction, "edit", hooks);
- return interactions;
- },
- getInteractions: () => interactions,
- syncInteractions(interaction) {
- interactions?.setState(interaction);
- },
- nudgeCamera(dAzimuth, dPolar) {
- const instance = runtime.getInstance(container);
- const camera = instance?.camera;
- if (camera) {
- const controls = instance?.controls;
- const radius = Math.hypot(camera.position.x, camera.position.y, camera.position.z) || 1;
- const azimuth = (controls?.getAzimuthalAngle?.() ?? Math.atan2(camera.position.x, camera.position.z)) + dAzimuth;
- const polar = clamp2((controls?.getPolarAngle?.() ?? Math.acos(clamp2(camera.position.y / radius, -1, 1))) + dPolar, MIN_POLAR, MAX_POLAR);
- const sinPolar = Math.sin(polar);
- camera.position.set(radius * sinPolar * Math.sin(azimuth), radius * Math.cos(polar), radius * sinPolar * Math.cos(azimuth));
- camera.lookAt(0, 0, 0);
- controls?.update?.();
- return;
- }
- const orbit = modelViewer?.getCameraOrbit?.();
- if (!modelViewer || !orbit) {
- return;
- }
- const theta = (orbit.theta ?? 0) + dAzimuth;
- const phi = clamp2((orbit.phi ?? Math.PI / 2) + dPolar, MIN_POLAR, MAX_POLAR);
- modelViewer.cameraOrbit = `${theta}rad ${phi}rad ${orbit.radius ?? "auto"}m`;
- modelViewer.jumpCameraToGoal?.();
- },
- getModelViewer: () => modelViewer,
- resolveMediaUrl,
- destroy() {
- destroyInteractions();
- runtime.destroy(container);
- previewBlobUrl = "";
- lastPreviewKey = "";
- }
- };
- async function renderStl(documentState, force) {
- const url = await resolvePreviewUrl(documentState.src);
- if (!url) {
- console.warn("[3D Viewer] STL: no URL available for", documentState.src);
- return;
- }
- const key = JSON.stringify({
- url,
- modelColor: documentState.modelColor,
- backgroundColor: documentState.backgroundColor,
- cameraControls: documentState.cameraControls,
- autoRotate: documentState.autoRotate,
- autoRotateSpeed: documentState.autoRotateSpeed
- });
- const existing = runtime.getInstance(container);
- if (!force && key === lastPreviewKey && existing?.renderer) {
- return;
- }
- lastPreviewKey = key;
- if (modelViewer) {
- modelViewer.style.display = "none";
- }
- await ensureThreeJsLoaded(getEditionLibBaseUrl());
- runtime.destroy(container);
- runtime.init(container, {
- src: url,
- type: "stl",
- modelColor: documentState.modelColor || DEFAULT_MODEL_COLOR,
- backgroundColor: documentState.backgroundColor || DEFAULT_BACKGROUND_COLOR,
- cameraControls: documentState.cameraControls,
- autoRotate: documentState.autoRotate,
- autoRotateSpeed: documentState.autoRotateSpeed || 30
- });
- }
- return preview;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/edition/scorm.ts
- function getScormEdition() {
- return globalThis.$exeDevicesEdition?.iDevice?.gamification?.scorm ?? null;
- }
- function shouldShowScormSection(interactionEnabled, markers) {
- return interactionEnabled && markers.some((marker) => marker.action.type === "question");
- }
- function createScormSection(host) {
- let rendered = false;
- return {
- render(scorm, defaultButtonText) {
- const framework = getScormEdition();
- if (!framework?.getTab) {
- return;
- }
- try {
- host.innerHTML = framework.getTab(false, false);
- framework.init?.();
- framework.setValues?.(scorm.mode, scorm.saveButtonText || defaultButtonText, true, scorm.weighted);
- rendered = true;
- } catch (error) {
- console.warn("[3D Viewer] SCORM tab unavailable:", error);
- }
- },
- read(current) {
- const framework = getScormEdition();
- if (rendered && framework?.getValues) {
- try {
- const values = framework.getValues();
- if (values) {
- return normalizeScorm(values);
- }
- } catch {}
- }
- return normalizeScorm(current);
- },
- isRendered: () => rendered,
- reset() {
- rendered = false;
- host.innerHTML = "";
- }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/edition/device.ts
- var YAW_STEP = 15 * Math.PI / 180;
- var PITCH_STEP = 10 * Math.PI / 180;
- var MAX_PREVIEW_RETRIES = 3;
- function defaultTranslate(text) {
- return typeof globalThis._ === "function" ? globalThis._(text) : text;
- }
- function defaultAlert(message) {
- const app = globalThis.eXe?.app;
- if (typeof app?.alert === "function") {
- app.alert(message);
- return;
- }
- console.warn("[3D Viewer]", message);
- }
- var defaultDependencies = {
- translate: defaultTranslate,
- createId: defaultIdFactory,
- createPreview: createEditorPreview,
- alert: defaultAlert
- };
- function createThreeDViewerDevice(overrides = {}) {
- const deps = { ...defaultDependencies, ...overrides };
- const t = deps.translate;
- const emptyDocument = () => {
- const fresh = hydrateDocument(null, deps.createId);
- if (fresh.status !== "ok") {
- throw new Error("[3D Viewer] Default document failed to hydrate");
- }
- return fresh.document;
- };
- let hydration = { status: "ok", document: emptyDocument() };
- let documentState = hydration.status === "ok" ? hydration.document : emptyDocument();
- let elements = null;
- let preview = null;
- let scormSection = null;
- let markerEditor = null;
- let previewRetries = 0;
- const announce = (message) => {
- if (elements) {
- elements.ariaLive.textContent = message;
- }
- };
- const interactionHooks = () => ({
- t,
- onPlaced: (placement) => device.handleMarkerPlaced(placement),
- resolveMediaUrl: (url) => preview?.resolveMediaUrl(url) ?? url
- });
- const syncPreviewInteractions = () => {
- preview?.syncInteractions(documentState.interaction);
- };
- const refreshScormVisibility = () => {
- if (!elements || !scormSection) {
- return;
- }
- const show = shouldShowScormSection(documentState.interaction.enabled, documentState.interaction.markers);
- elements.scormSection.hidden = !show;
- if (show && !scormSection.isRendered()) {
- scormSection.render(documentState.scorm, t("Save score"));
- }
- };
- const refreshMarkerList = () => {
- if (!elements) {
- return;
- }
- renderMarkerList(elements.markerList, documentState.interaction.markers, t, {
- onMove: (markerId, delta) => {
- documentState.interaction.markers = moveMarker(documentState.interaction.markers, markerId, delta);
- refreshMarkerList();
- syncPreviewInteractions();
- },
- onEdit: (markerId) => openEditorFor(markerId),
- onDelete: (markerId) => deleteMarker(markerId)
- });
- refreshScormVisibility();
- };
- const deleteMarker = (markerId) => {
- documentState.interaction.markers = removeMarker(documentState.interaction.markers, markerId);
- if (markerEditor?.markerId === markerId) {
- markerEditor.close();
- markerEditor = null;
- }
- refreshMarkerList();
- syncPreviewInteractions();
- };
- const openEditorFor = (markerId) => {
- if (!elements) {
- return;
- }
- const marker = documentState.interaction.markers.find((candidate) => candidate.id === markerId);
- if (!marker) {
- return;
- }
- markerEditor?.close();
- markerEditor = openMarkerEditor(elements.markerEditorHost, marker, t, deps.createId, {
- onSave: (saved) => {
- const index = documentState.interaction.markers.findIndex((candidate) => candidate.id === markerId);
- if (index >= 0) {
- documentState.interaction.markers[index] = { ...saved, order: index };
- }
- markerEditor = null;
- refreshMarkerList();
- syncPreviewInteractions();
- },
- onCancel: () => {
- markerEditor = null;
- },
- onDelete: (id) => {
- markerEditor = null;
- deleteMarker(id);
- },
- captureCamera: () => preview?.getInteractions()?.captureCamera() ?? null
- });
- };
- const refreshInteractionVisibility = () => {
- if (!elements) {
- return;
- }
- elements.interactionsBody.hidden = !documentState.interaction.enabled;
- elements.addMarker.disabled = !documentState.src;
- refreshScormVisibility();
- };
- const applyDisplayFormState = () => {
- if (!elements) {
- return;
- }
- const settings = readDisplaySettings(elements, documentState.src);
- documentState = { ...documentState, ...settings };
- updateAutoRotateSpeedState(elements);
- };
- const refreshPreview = (force = false) => {
- if (!elements || !preview) {
- return;
- }
- updateEmptyState(elements, documentState.src);
- preview.update(documentState, force).then(() => {
- preview?.attachInteractions(documentState, interactionHooks());
- });
- };
- const registerBehaviours = () => {
- if (!elements) {
- return;
- }
- const el = elements;
- const onDisplayChange = () => {
- applyDisplayFormState();
- refreshPreview();
- };
- for (const control of [
- el.alt,
- el.modelColor,
- el.backgroundColor,
- el.cameraControls,
- el.autoRotateSpeed,
- el.animationToggle,
- el.animationName,
- el.animationSpeed
- ]) {
- control.addEventListener("change", onDisplayChange);
- if (control instanceof HTMLInputElement && control.type === "text") {
- control.addEventListener("input", onDisplayChange);
- }
- }
- const onExclusiveToggle = (winner) => {
- if (winner === "autoRotate" && el.autoRotate.checked) {
- el.showNavControls.checked = false;
- } else if (winner === "showNavControls" && el.showNavControls.checked) {
- el.autoRotate.checked = false;
- }
- applyDisplayFormState();
- updateNavControlsVisibility(el, documentState.showNavControls);
- refreshPreview();
- };
- el.autoRotate.addEventListener("change", () => onExclusiveToggle("autoRotate"));
- el.showNavControls.addEventListener("change", () => onExclusiveToggle("showNavControls"));
- el.src.addEventListener("change", () => {
- handleModelSelection();
- });
- el.interactionsEnable.addEventListener("change", () => {
- documentState.interaction.enabled = el.interactionsEnable.checked;
- refreshInteractionVisibility();
- preview?.attachInteractions(documentState, interactionHooks());
- });
- const syncFlag = (control, key) => {
- control.addEventListener("change", () => {
- documentState.interaction[key] = control.checked;
- syncPreviewInteractions();
- });
- };
- syncFlag(el.guidedMode, "guidedMode");
- syncFlag(el.wrapNavigation, "wrapNavigation");
- syncFlag(el.showMarkerLabels, "showMarkerLabels");
- el.addMarker.addEventListener("click", () => {
- startMarkerPlacement();
- });
- const fullscreen = el.preview.querySelector("[data-fullscreen]");
- if (fullscreen) {
- const target = el.preview.parentElement ?? el.preview;
- const isFullscreen = () => document.fullscreenElement === target;
- fullscreen.addEventListener("click", () => {
- if (isFullscreen()) {
- document.exitFullscreen?.();
- } else {
- target.requestFullscreen?.();
- }
- });
- document.addEventListener("fullscreenchange", () => {
- const label = t(isFullscreen() ? "Exit fullscreen" : "Fullscreen");
- fullscreen.setAttribute("aria-label", label);
- fullscreen.setAttribute("title", label);
- });
- }
- for (const button of Array.from(el.preview.querySelectorAll("[data-nav]"))) {
- const direction = button.getAttribute("data-nav");
- const dAzimuth = direction === "right" ? -YAW_STEP : direction === "left" ? YAW_STEP : 0;
- const dPolar = direction === "up" ? PITCH_STEP : direction === "down" ? -PITCH_STEP : 0;
- button.addEventListener("click", () => preview?.nudgeCamera(dAzimuth, dPolar));
- }
- };
- const handleModelSelection = async () => {
- if (!elements) {
- return;
- }
- const picked = elements.src.value;
- if (!picked) {
- return;
- }
- if (picked.startsWith("blob:")) {
- console.warn("[3D Viewer] Refusing to store a blob: URL as the model source");
- elements.src.value = documentState.src;
- return;
- }
- documentState.src = picked;
- applyDisplayFormState();
- updateModelColorFieldState(elements, documentState.src, t);
- refreshInteractionVisibility();
- refreshPreview(true);
- };
- const startMarkerPlacement = async () => {
- if (!elements || !documentState.src) {
- return;
- }
- if (!documentState.interaction.enabled) {
- documentState.interaction.enabled = true;
- elements.interactionsEnable.checked = true;
- refreshInteractionVisibility();
- }
- const layer = await preview?.attachInteractions(documentState, interactionHooks());
- if (!layer) {
- return;
- }
- layer.enterPlacementMode();
- elements.placementHint.hidden = false;
- announce(t("Click on the model to place the marker."));
- };
- const device = {
- name: t("3D Viewer"),
- i18n: { name: t("3D Viewer") },
- async init(element, previousData) {
- preview?.destroy();
- preview = null;
- markerEditor = null;
- previewRetries = 0;
- device.set3DViewerJSON(previousData ?? {});
- if (hydration.status !== "ok") {
- const version = hydration.status === "unsupported-version" ? hydration.version : 0;
- element.innerHTML = renderUnsupportedVersionMarkup(t, version);
- elements = null;
- return;
- }
- element.innerHTML = renderEditorMarkup(t);
- elements = collectElements(element);
- scormSection = createScormSection(elements.scormHost);
- applyDocumentToForm(elements, documentState);
- updateAutoRotateSpeedState(elements);
- updateNavControlsVisibility(elements, documentState.showNavControls);
- updateModelColorFieldState(elements, documentState.src, t);
- updateEmptyState(elements, documentState.src);
- elements.animationRow.hidden = true;
- elements.animationToggle.disabled = true;
- elements.animationName.disabled = true;
- elements.animationSpeed.disabled = true;
- refreshInteractionVisibility();
- refreshMarkerList();
- preview = deps.createPreview(elements.preview, {
- onModelLoaded: (available) => {
- if (!elements) {
- return;
- }
- previewRetries = 0;
- documentState.animation = updateAnimationOptions(elements, available, documentState.animation);
- updateEmptyState(elements, documentState.src);
- preview?.attachInteractions(documentState, interactionHooks());
- },
- onModelError: () => {
- if (!documentState.src || previewRetries >= MAX_PREVIEW_RETRIES) {
- return;
- }
- previewRetries += 1;
- setTimeout(() => refreshPreview(true), 150 * previewRetries);
- }
- });
- await preview.mount();
- registerBehaviours();
- refreshPreview(true);
- },
- save() {
- if (hydration.status !== "ok") {
- deps.alert(t("This 3D Viewer was created with a newer version of eXeLearning and cannot be edited here."));
- return hydration.original;
- }
- if (elements) {
- applyDisplayFormState();
- documentState.scorm = scormSection?.read(documentState.scorm) ?? documentState.scorm;
- }
- if (!documentState.src) {
- deps.alert(t("Please select a 3D model file"));
- return false;
- }
- if (!isSupportedModelFile(documentState.src)) {
- deps.alert(t("Please select a valid 3D model file (GLB, GLTF, or STL)"));
- return false;
- }
- return device.get3DViewerJSON();
- },
- set3DViewerJSON(data) {
- hydration = hydrateDocument(data, deps.createId);
- if (hydration.status !== "ok") {
- return;
- }
- documentState = hydration.document;
- const rawSrc = data?.src;
- if (typeof rawSrc === "string" && rawSrc.startsWith("blob:")) {
- const assetRef = recoverAssetRefFromBlob(rawSrc);
- if (assetRef) {
- documentState.src = `asset://${assetRef}`;
- } else {
- console.warn("[3D Viewer] Discarding a stale blob: URL from stored data");
- }
- }
- },
- get3DViewerJSON() {
- if (hydration.status !== "ok") {
- return hydration.original;
- }
- return serializeDocument(documentState, deps.createId);
- },
- handleMarkerPlaced(placement) {
- if (elements) {
- elements.placementHint.hidden = true;
- }
- const index = documentState.interaction.markers.length;
- const marker = normalizeMarker({
- label: "",
- icon: "circle",
- order: index,
- anchor: {
- position: placement.position,
- normal: placement.normal,
- surface: placement.surface
- },
- camera: placement.camera,
- action: { type: "information", payload: { html: "" } }
- }, index, deps.createId);
- documentState.interaction.markers.push(marker);
- refreshMarkerList();
- syncPreviewInteractions();
- openEditorFor(marker.id);
- },
- getDocument: () => documentState,
- getHydration: () => hydration
- };
- return device;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/edition/index.ts
- var device = createThreeDViewerDevice();
- publishViewerRuntime();
- globalThis.$exeDevice = device;
- if (typeof window !== "undefined") {
- window.$exeDevice = device;
- }
-})();
-
-//# debugId=1D7312B920602D2464756E2164756E21
-//# sourceMappingURL=three-d-viewer.js.map
diff --git a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js b/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js
deleted file mode 100644
index 64ea5a419..000000000
--- a/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js
+++ /dev/null
@@ -1,2733 +0,0 @@
-(() => {
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/marker-renderer.ts
- function createMarkerButton(marker, options) {
- const button = document.createElement("button");
- button.type = "button";
- button.className = `tdv-marker ${options.variantClass}`;
- button.dataset.markerId = marker.id;
- button.dataset.markerOrder = String(options.index);
- button.setAttribute("aria-label", options.label);
- const icon = document.createElement("span");
- icon.className = `tdv-marker-icon tdv-icon-${marker.icon}`;
- icon.setAttribute("aria-hidden", "true");
- button.appendChild(icon);
- if (options.showLabels && marker.label) {
- const label = document.createElement("span");
- label.className = "tdv-marker-label";
- label.textContent = marker.label;
- button.appendChild(label);
- }
- if (options.activeId === marker.id) {
- button.classList.add("tdv-marker--active");
- button.setAttribute("aria-current", "true");
- }
- button.addEventListener("click", () => options.onActivate(marker.id));
- return button;
- }
- function applyActiveMarker(buttons, activeId) {
- for (const button of buttons) {
- const isActive = button.dataset.markerId === activeId;
- button.classList.toggle("tdv-marker--active", isActive);
- if (isActive) {
- button.setAttribute("aria-current", "true");
- } else {
- button.removeAttribute("aria-current");
- }
- }
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/adapters/geometry.ts
- var FACING_THRESHOLD = -0.15;
- function ndcToScreen(ndc, width, height) {
- return {
- x: (ndc.x * 0.5 + 0.5) * width,
- y: (-ndc.y * 0.5 + 0.5) * height
- };
- }
- function isOnScreen(ndc) {
- const inFrustum = ndc.z < 1 && ndc.z > -1;
- return inFrustum && ndc.x >= -1 && ndc.x <= 1 && ndc.y >= -1 && ndc.y <= 1;
- }
- function isFacingCamera(normal, toCamera) {
- return normal.x * toCamera.x + normal.y * toCamera.y + normal.z * toCamera.z > FACING_THRESHOLD;
- }
- function isMarkerVisible(ndc, normal, toCamera) {
- return isFacingCamera(normal, toCamera) && isOnScreen(ndc);
- }
- function parseTriple(value) {
- const parts = String(value ?? "").trim().split(/\s+/).map(Number.parseFloat);
- return {
- x: Number.isFinite(parts[0]) ? parts[0] : 0,
- y: Number.isFinite(parts[1]) ? parts[1] : 0,
- z: Number.isFinite(parts[2]) ? parts[2] : 0
- };
- }
- function formatTriple(vector) {
- return `${vector.x} ${vector.y} ${vector.z}`;
- }
- function pointerToNdc(rect, clientX, clientY) {
- if (!rect.width || !rect.height) {
- return null;
- }
- return {
- x: (clientX - rect.left) / rect.width * 2 - 1,
- y: -((clientY - rect.top) / rect.height) * 2 + 1
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/adapters/model-viewer-adapter.ts
- var EMPTY_CAMERA = { orbit: "", target: "", fieldOfView: "" };
- function createModelViewerAdapter(modelViewer, deps) {
- let placeHandler = null;
- const clearMarkers = () => {
- for (const element of Array.from(modelViewer.querySelectorAll('.tdv-marker[slot^="hotspot-"]'))) {
- element.remove();
- }
- };
- const captureCamera = () => {
- try {
- return {
- orbit: modelViewer.getCameraOrbit?.().toString() ?? "",
- target: modelViewer.getCameraTarget?.().toString() ?? "",
- fieldOfView: modelViewer.getFieldOfView ? `${modelViewer.getFieldOfView()}deg` : ""
- };
- } catch {
- return { ...EMPTY_CAMERA };
- }
- };
- return {
- renderMarkers(markers, options) {
- clearMarkers();
- markers.forEach((marker, index) => {
- const button = createMarkerButton(marker, {
- ...options,
- index,
- label: deps.markerLabel(marker, index),
- variantClass: "tdv-marker--mv",
- onActivate: deps.onActivate
- });
- button.setAttribute("slot", `hotspot-${marker.id}`);
- button.dataset.position = formatTriple(marker.anchor.position);
- button.dataset.normal = formatTriple(marker.anchor.normal);
- if (marker.anchor.surface) {
- button.dataset.surface = marker.anchor.surface;
- }
- modelViewer.appendChild(button);
- });
- },
- setActive(activeId) {
- applyActiveMarker(modelViewer.querySelectorAll(".tdv-marker"), activeId);
- },
- focusMarker(marker) {
- const camera = marker.camera;
- if (camera.orbit) {
- modelViewer.cameraOrbit = camera.orbit;
- }
- if (camera.target) {
- modelViewer.cameraTarget = camera.target;
- }
- if (camera.fieldOfView) {
- modelViewer.fieldOfView = camera.fieldOfView;
- }
- },
- captureCamera,
- updateOverlay() {},
- enterPlacementMode(onPlaced) {
- placeHandler = (event) => {
- const hit = modelViewer.positionAndNormalFromPoint?.(event.clientX, event.clientY);
- if (!hit) {
- return;
- }
- onPlaced({
- position: parseTriple(hit.position?.toString()),
- normal: parseTriple(hit.normal?.toString()),
- surface: "",
- camera: captureCamera()
- });
- };
- modelViewer.addEventListener("click", placeHandler);
- },
- exitPlacementMode() {
- if (placeHandler) {
- modelViewer.removeEventListener("click", placeHandler);
- placeHandler = null;
- }
- },
- destroy() {
- this.exitPlacementMode();
- clearMarkers();
- }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/urls.ts
- var EXECUTABLE_SCHEME = /^\s*(javascript|vbscript):/i;
- var EPHEMERAL_OR_EXECUTABLE_SCHEME = /^\s*(blob:|data:|javascript:|vbscript:)/i;
- var ALLOWED_RENDER_SCHEME = /^(https?:|mailto:|tel:|asset:|blob:)/i;
- var HAS_EXPLICIT_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
- var ABSOLUTE_URL = /^(https?:)?\/\//i;
- function stripUnsafeUrl(value) {
- const raw = typeof value === "string" ? value : "";
- return EPHEMERAL_OR_EXECUTABLE_SCHEME.test(raw) ? "" : raw.trim();
- }
- function safeUrl(value) {
- const raw = typeof value === "string" ? value.trim() : "";
- if (!raw) {
- return "";
- }
- if (EXECUTABLE_SCHEME.test(raw)) {
- return "";
- }
- if (ALLOWED_RENDER_SCHEME.test(raw)) {
- return raw;
- }
- return HAS_EXPLICIT_SCHEME.test(raw) ? "" : raw;
- }
- function isAbsoluteUrl(value) {
- return ABSOLUTE_URL.test(value);
- }
- function normalizePath(value) {
- const clean = String(value ?? "").trim().replace(/\\+/g, "/");
- if (!clean) {
- return "";
- }
- return isAbsoluteUrl(clean) ? clean : clean.replace(/^\/+/, "");
- }
- function stripQueryAndHash(value) {
- let out = value;
- const query = out.indexOf("?");
- if (query !== -1) {
- out = out.substring(0, query);
- }
- const hash = out.indexOf("#");
- if (hash !== -1) {
- out = out.substring(0, hash);
- }
- return out;
- }
- function joinAppUrl(baseURL, basePath, path) {
- const base = String(baseURL ?? "").replace(/\/+$/g, "");
- const prefixPath = basePath ? `/${String(basePath).replace(/^\/+|\/+$/g, "")}` : "";
- const prefix = `${base}${prefixPath}`.replace(/\/+$/g, "");
- const normalized = String(path ?? "").replace(/^\/+/, "");
- return prefix ? `${prefix}/${normalized}` : `/${normalized}`;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/model-source.ts
- var KNOWN_EXTENSIONS = ["stl", "glb", "gltf", "obj", "fbx"];
- function detectModelType(src) {
- if (typeof src !== "string") {
- return "unknown";
- }
- const clean = stripQueryAndHash(src.trim());
- const dot = clean.lastIndexOf(".");
- if (dot === -1) {
- return "unknown";
- }
- const ext = clean.substring(dot + 1).toLowerCase();
- return KNOWN_EXTENSIONS.includes(ext) ? ext : "unknown";
- }
- function isStlSource(src) {
- return detectModelType(src) === "stl";
- }
- function normalizeModelSource(src) {
- if (typeof src !== "string") {
- return "";
- }
- const clean = src.trim();
- if (!clean || clean.startsWith("blob:") || clean.startsWith("data:")) {
- return "";
- }
- return clean;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/lifecycle.ts
- function createInstance(wrapper, options) {
- return {
- wrapper,
- options,
- type: options.type || detectModelType(options.src),
- modelViewer: null,
- canvas: null,
- scene: null,
- camera: null,
- renderer: null,
- controls: null,
- mesh: null,
- geometry: null,
- material: null,
- rafId: null,
- stopped: false,
- listeners: [],
- objectURLs: [],
- onFrame: [],
- interaction: null
- };
- }
- function addFrameCallback(instance, callback) {
- if (!instance.onFrame.includes(callback)) {
- instance.onFrame.push(callback);
- }
- }
- function removeFrameCallback(instance, callback) {
- const index = instance.onFrame.indexOf(callback);
- if (index !== -1) {
- instance.onFrame.splice(index, 1);
- }
- }
- function isTexture(value) {
- return Boolean(value && typeof value === "object" && value.isTexture && typeof value.dispose === "function");
- }
- function disposeMaterial(material) {
- if (!material) {
- return;
- }
- const list = Array.isArray(material) ? material : [material];
- for (const entry of list) {
- if (!entry || typeof entry !== "object") {
- continue;
- }
- const record = entry;
- for (const key of Object.keys(record)) {
- const value = record[key];
- if (isTexture(value)) {
- value.dispose();
- }
- }
- const dispose = entry.dispose;
- if (typeof dispose === "function") {
- dispose.call(entry);
- }
- }
- }
- function disposeObject3D(object) {
- const traverse = object?.traverse;
- if (typeof traverse !== "function") {
- return;
- }
- object.traverse((node) => {
- if (node?.geometry && typeof node.geometry.dispose === "function") {
- node.geometry.dispose();
- }
- if (node?.material) {
- disposeMaterial(node.material);
- }
- });
- }
- function cancelFrame(rafId) {
- if (typeof globalThis.cancelAnimationFrame === "function") {
- globalThis.cancelAnimationFrame(rafId);
- } else {
- clearTimeout(rafId);
- }
- }
- function disposeInstance(instance) {
- instance.stopped = true;
- if (instance.interaction) {
- try {
- instance.interaction.destroy();
- } catch {}
- instance.interaction = null;
- }
- instance.onFrame.length = 0;
- if (instance.rafId !== null) {
- cancelFrame(instance.rafId);
- instance.rafId = null;
- }
- for (const { target, type, handler, options } of instance.listeners) {
- try {
- target.removeEventListener(type, handler, options);
- } catch {}
- }
- instance.listeners.length = 0;
- try {
- disposeObject3D(instance.scene);
- } catch {}
- try {
- disposeMaterial(instance.material);
- } catch {}
- try {
- instance.geometry?.dispose?.();
- } catch {}
- try {
- instance.controls?.dispose?.();
- } catch {}
- try {
- instance.renderer?.dispose?.();
- } catch {}
- for (const url of instance.objectURLs) {
- try {
- URL.revokeObjectURL(url);
- } catch {}
- }
- instance.objectURLs.length = 0;
- instance.scene = null;
- instance.camera = null;
- instance.renderer = null;
- instance.controls = null;
- instance.mesh = null;
- instance.geometry = null;
- instance.material = null;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/adapters/raycast.ts
- function raycastFromPointer(target, clientX, clientY) {
- const three = globalThis.THREE;
- if (!three || !target.mesh || !target.camera || !target.canvas) {
- return null;
- }
- const ndc = pointerToNdc(target.canvas.getBoundingClientRect(), clientX, clientY);
- if (!ndc) {
- return null;
- }
- const raycaster = new three.Raycaster;
- raycaster.setFromCamera(new three.Vector2(ndc.x, ndc.y), target.camera);
- const hit = raycaster.intersectObject(target.mesh, true)[0];
- if (!hit) {
- return null;
- }
- const local = target.mesh.worldToLocal(hit.point.clone());
- const faceNormal = hit.face?.normal;
- return {
- position: { x: local.x, y: local.y, z: local.z },
- normal: faceNormal ? { x: faceNormal.x, y: faceNormal.y, z: faceNormal.z } : { x: 0, y: 1, z: 0 }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/adapters/stl-adapter.ts
- var EMPTY_CAMERA2 = { orbit: "", target: "", fieldOfView: "" };
- function ensureLayer(wrapper) {
- const existing = wrapper.querySelector(".tdv-marker-layer");
- if (existing) {
- return existing;
- }
- const layer = document.createElement("div");
- layer.className = "tdv-marker-layer";
- wrapper.appendChild(layer);
- return layer;
- }
- function createStlAdapter(instance, wrapper, deps) {
- const layer = ensureLayer(wrapper);
- let entries = [];
- let placeHandler = null;
- const updateOverlay = () => {
- const three = globalThis.THREE;
- const { mesh, camera, canvas } = instance;
- if (!three || !mesh || !camera || !canvas || entries.length === 0) {
- return;
- }
- mesh.updateMatrixWorld();
- camera.updateMatrixWorld();
- const width = canvas.clientWidth || canvas.width || 1;
- const height = canvas.clientHeight || canvas.height || 1;
- for (const entry of entries) {
- const world = mesh.localToWorld(entry.local.clone());
- const ndc = world.clone().project(camera);
- const worldNormal = entry.normal.clone().transformDirection(mesh.matrixWorld);
- const toCamera = new three.Vector3().subVectors(camera.position, world).normalize();
- const visible = isMarkerVisible(ndc, worldNormal, toCamera);
- const screen = ndcToScreen(ndc, width, height);
- entry.element.style.left = `${screen.x}px`;
- entry.element.style.top = `${screen.y}px`;
- entry.element.classList.toggle("tdv-marker--hidden", !visible);
- if (visible) {
- entry.element.removeAttribute("tabindex");
- entry.element.removeAttribute("aria-hidden");
- } else {
- entry.element.setAttribute("tabindex", "-1");
- entry.element.setAttribute("aria-hidden", "true");
- }
- }
- };
- addFrameCallback(instance, updateOverlay);
- const captureCamera = () => {
- const camera = instance.camera;
- if (!camera) {
- return { ...EMPTY_CAMERA2 };
- }
- const position = camera.position;
- const target = instance.controls?.target ?? { x: 0, y: 0, z: 0 };
- return {
- orbit: `${position.x} ${position.y} ${position.z}`,
- target: `${target.x} ${target.y} ${target.z}`,
- fieldOfView: `${camera.fov ?? 45}deg`
- };
- };
- return {
- renderMarkers(markers, options) {
- const three = globalThis.THREE;
- layer.innerHTML = "";
- entries = markers.map((marker, index) => {
- const element = createMarkerButton(marker, {
- ...options,
- index,
- label: deps.markerLabel(marker, index),
- variantClass: "tdv-marker--stl",
- onActivate: deps.onActivate
- });
- layer.appendChild(element);
- const { position, normal } = marker.anchor;
- return {
- element,
- local: new three.Vector3(position.x, position.y, position.z),
- normal: new three.Vector3(normal.x, normal.y, normal.z)
- };
- });
- updateOverlay();
- },
- setActive(activeId) {
- applyActiveMarker(entries.map((entry) => entry.element), activeId);
- },
- focusMarker(marker) {
- const camera = instance.camera;
- if (!globalThis.THREE || !camera) {
- return;
- }
- const position = parseTriple(marker.camera.orbit);
- const target = parseTriple(marker.camera.target);
- if (marker.camera.orbit) {
- camera.position.set(position.x, position.y, position.z);
- }
- if (!marker.camera.target) {
- return;
- }
- if (instance.controls) {
- instance.controls.target.set(target.x, target.y, target.z);
- instance.controls.update?.();
- } else {
- camera.lookAt(target.x, target.y, target.z);
- }
- },
- captureCamera,
- updateOverlay,
- enterPlacementMode(onPlaced) {
- const canvas = instance.canvas;
- if (!canvas) {
- return;
- }
- placeHandler = (event) => {
- const hit = raycastFromPointer(instance, event.clientX, event.clientY);
- if (!hit) {
- return;
- }
- onPlaced({ position: hit.position, normal: hit.normal, surface: "", camera: captureCamera() });
- };
- canvas.addEventListener("click", placeHandler);
- },
- exitPlacementMode() {
- if (placeHandler && instance.canvas) {
- instance.canvas.removeEventListener("click", placeHandler);
- }
- placeHandler = null;
- },
- destroy() {
- this.exitPlacementMode();
- removeFrameCallback(instance, updateOverlay);
- try {
- layer.remove();
- } catch {}
- entries = [];
- }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/asset-resolver.ts
- function getAssetManager() {
- const project = globalThis.eXeLearning?.app?.project;
- const local = project?.assetManager ?? project?._yjsBridge?.assetManager;
- if (local) {
- return local;
- }
- try {
- const parentWindow = globalThis.parent;
- const parentProject = parentWindow?.eXeLearning?.app?.project;
- return parentProject?.assetManager ?? parentProject?._yjsBridge?.assetManager ?? null;
- } catch {
- return null;
- }
- }
- async function resolveModelSource(src, assetManager) {
- if (typeof src !== "string") {
- return "";
- }
- const trimmed = src.trim();
- if (!trimmed) {
- return "";
- }
- if (!trimmed.startsWith("asset://")) {
- return trimmed;
- }
- const manager = assetManager ?? getAssetManager();
- if (!manager) {
- return "";
- }
- try {
- const sync = manager.resolveAssetURLSync?.(trimmed);
- if (sync) {
- return sync;
- }
- const resolved = await manager.resolveAssetURL?.(trimmed);
- return resolved ?? "";
- } catch {
- return "";
- }
- }
- function resolveMediaUrlSync(url, assetManager) {
- const raw = typeof url === "string" ? url.trim() : "";
- if (!raw || !raw.startsWith("asset://")) {
- return raw;
- }
- const manager = assetManager ?? getAssetManager();
- if (!manager?.resolveAssetURLSync) {
- return raw;
- }
- try {
- return manager.resolveAssetURLSync(raw) || raw;
- } catch {
- return raw;
- }
- }
- async function resolveAssetUrlAsync(assetUrl, timeoutMs = 1e4, pollIntervalMs = 100) {
- if (!assetUrl.startsWith("asset://")) {
- return null;
- }
- const deadline = Date.now() + timeoutMs;
- while (Date.now() < deadline) {
- const resolved = await resolveModelSource(assetUrl);
- if (resolved) {
- return resolved;
- }
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
- }
- return null;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/html.ts
- var ESCAPES = {
- "&": "&",
- "<": "<",
- ">": ">",
- '"': """,
- "'": "'"
- };
- function escapeHtml(value) {
- return String(value ?? "").replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
- }
- function stripHtmlToText(html) {
- return String(html ?? "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
- }
- function escapeJsonForScript(value) {
- return JSON.stringify(value).replace(/ element.offsetParent !== null || element === document.activeElement);
- }
- function openDialog(options, buildBody) {
- const previouslyFocused = document.activeElement;
- const overlay = document.createElement("div");
- overlay.className = "tdv-dialog-overlay";
- const dialog = document.createElement("div");
- dialog.className = "tdv-dialog";
- dialog.setAttribute("role", "dialog");
- dialog.setAttribute("aria-modal", "true");
- dialog.setAttribute("aria-label", options.title);
- const header = document.createElement("div");
- header.className = "tdv-dialog-header";
- const heading = document.createElement("h2");
- heading.className = "tdv-dialog-title";
- heading.textContent = options.title;
- const closeButton = document.createElement("button");
- closeButton.type = "button";
- closeButton.className = "tdv-dialog-close";
- closeButton.setAttribute("aria-label", options.closeLabel);
- closeButton.textContent = "✕";
- header.append(heading, closeButton);
- const body = document.createElement("div");
- body.className = "tdv-dialog-body";
- dialog.append(header, body);
- overlay.appendChild(dialog);
- (options.host ?? document.body).appendChild(overlay);
- buildBody(body);
- let closed = false;
- const close = () => {
- if (closed) {
- return;
- }
- closed = true;
- try {
- overlay.remove();
- } catch {}
- if (previouslyFocused instanceof HTMLElement) {
- try {
- previouslyFocused.focus();
- } catch {}
- }
- options.onClose?.();
- };
- closeButton.addEventListener("click", close);
- overlay.addEventListener("click", (event) => {
- if (event.target === overlay) {
- close();
- }
- });
- dialog.addEventListener("keydown", (event) => {
- if (event.key === "Escape") {
- event.stopPropagation();
- close();
- return;
- }
- if (event.key !== "Tab") {
- return;
- }
- const focusable = getFocusable(dialog);
- const first = focusable[0];
- const last = focusable[focusable.length - 1];
- if (!first || !last) {
- return;
- }
- if (event.shiftKey && document.activeElement === first) {
- event.preventDefault();
- last.focus();
- } else if (!event.shiftKey && document.activeElement === last) {
- event.preventDefault();
- first.focus();
- }
- });
- try {
- closeButton.focus();
- } catch {}
- return { overlay, dialog, body, close };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/fallback.ts
- var webglAvailable = null;
- function hasWebGL() {
- if (typeof globalThis.__tdvForceWebGL === "boolean") {
- return globalThis.__tdvForceWebGL;
- }
- if (webglAvailable !== null) {
- return webglAvailable;
- }
- try {
- if (typeof document === "undefined" || typeof document.createElement !== "function") {
- webglAvailable = true;
- return webglAvailable;
- }
- const canvas = document.createElement("canvas");
- webglAvailable = Boolean(canvas.getContext && (canvas.getContext("webgl") || canvas.getContext("experimental-webgl")));
- } catch {
- webglAvailable = false;
- }
- return webglAvailable;
- }
- function revealFallback(wrapper, show) {
- const list = wrapper?.querySelector(".tdv-fallback");
- if (list) {
- list.hidden = !show;
- }
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/guided-navigation.ts
- function resolveStepIndex(current, delta, total, wrap) {
- if (total <= 0) {
- return null;
- }
- const start = current < 0 ? delta > 0 ? -1 : total : current;
- const next = start + delta;
- if (wrap) {
- return (next % total + total) % total;
- }
- return next < 0 || next >= total ? null : next;
- }
- function buildControls(t) {
- const nav = document.createElement("div");
- nav.className = "tdv-guided-nav";
- nav.setAttribute("data-guided", "");
- const previous = document.createElement("button");
- previous.type = "button";
- previous.className = "tdv-nav-prev";
- previous.textContent = t("Previous");
- const status = document.createElement("span");
- status.className = "tdv-guided-status";
- status.setAttribute("aria-live", "polite");
- const next = document.createElement("button");
- next.type = "button";
- next.className = "tdv-nav-next";
- next.textContent = t("Next");
- nav.append(previous, status, next);
- return nav;
- }
- function createGuidedNavigation(wrapper, deps) {
- let nav = wrapper?.querySelector(".tdv-guided-nav") ?? null;
- let created = false;
- const listeners = [];
- const ensureNav = () => {
- if (nav || !wrapper) {
- return nav;
- }
- nav = buildControls(deps.t);
- created = true;
- wrapper.appendChild(nav);
- return nav;
- };
- const bindOnce = (element) => {
- if (element.dataset.tdvBound === "1") {
- return;
- }
- element.dataset.tdvBound = "1";
- const previousButton = element.querySelector(".tdv-nav-prev");
- const nextButton = element.querySelector(".tdv-nav-next");
- if (previousButton) {
- const handler = () => deps.onGo(-1);
- previousButton.addEventListener("click", handler);
- listeners.push(() => previousButton.removeEventListener("click", handler));
- }
- if (nextButton) {
- const handler = () => deps.onGo(1);
- nextButton.addEventListener("click", handler);
- listeners.push(() => nextButton.removeEventListener("click", handler));
- }
- };
- return {
- update({ enabled, index, total, wrap }) {
- if (!enabled) {
- if (nav) {
- nav.hidden = true;
- }
- return;
- }
- const element = ensureNav();
- if (!element) {
- return;
- }
- element.hidden = false;
- const previousButton = element.querySelector(".tdv-nav-prev");
- const nextButton = element.querySelector(".tdv-nav-next");
- if (previousButton && !previousButton.textContent) {
- previousButton.textContent = deps.t("Previous");
- }
- if (nextButton && !nextButton.textContent) {
- nextButton.textContent = deps.t("Next");
- }
- bindOnce(element);
- const empty = total === 0;
- if (previousButton) {
- previousButton.disabled = empty || !wrap && index <= 0;
- }
- if (nextButton) {
- nextButton.disabled = empty || !wrap && index >= total - 1;
- }
- const status = element.querySelector(".tdv-guided-status");
- if (status) {
- status.textContent = `${deps.t("Marker")} ${index < 0 ? 0 : index + 1} / ${total}`;
- }
- },
- destroy() {
- for (const off of listeners) {
- off();
- }
- listeners.length = 0;
- if (created && nav) {
- try {
- nav.remove();
- } catch {}
- }
- nav = null;
- }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/scoring.ts
- var SCORE_SCALE = 10;
- function gradeSingleChoice(question, selectedOptionId) {
- const chosen = question.options.find((option) => option.id === selectedOptionId);
- return Boolean(chosen?.correct);
- }
- function questionMarkers(markers) {
- return markers.filter((marker) => marker.action.type === "question");
- }
- function computeScore(markers, correctMarkerIds) {
- const questions = questionMarkers(markers);
- if (questions.length === 0) {
- return 0;
- }
- const correct = questions.filter((marker) => correctMarkerIds.has(marker.id)).length;
- return correct * SCORE_SCALE / questions.length;
- }
- function isActivityComplete(markers, correctMarkerIds) {
- const questions = questionMarkers(markers);
- return questions.length > 0 && questions.every((marker) => correctMarkerIds.has(marker.id));
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/question.ts
- function lockQuestion(inputs, checkButton) {
- checkButton.disabled = true;
- for (const input of inputs) {
- input.disabled = true;
- }
- }
- function renderQuestion(body, marker, deps) {
- if (marker.action.type !== "question") {
- return;
- }
- const question = marker.action.payload;
- const { answers, t } = deps;
- const state = answers.get(marker.id);
- const fieldset = document.createElement("fieldset");
- fieldset.className = "tdv-question";
- const legend = document.createElement("legend");
- legend.className = "tdv-question-prompt";
- legend.textContent = question.prompt;
- fieldset.appendChild(legend);
- const groupName = `tdv-q-${marker.id}`;
- const inputs = [];
- for (const option of question.options) {
- const label = document.createElement("label");
- label.className = "tdv-question-option";
- const input = document.createElement("input");
- input.type = "radio";
- input.name = groupName;
- input.value = option.id;
- if (option.id === state.selectedOptionId) {
- input.checked = true;
- }
- const text = document.createElement("span");
- text.textContent = option.text;
- label.append(input, text);
- fieldset.appendChild(label);
- inputs.push(input);
- }
- const checkButton = document.createElement("button");
- checkButton.type = "button";
- checkButton.className = "tdv-q-check";
- checkButton.textContent = t("Check");
- const feedback = document.createElement("div");
- feedback.className = "tdv-q-feedback";
- feedback.setAttribute("role", "status");
- feedback.setAttribute("aria-live", "polite");
- body.append(fieldset, checkButton, feedback);
- if (state.resolved) {
- feedback.className = "tdv-q-feedback tdv-q-feedback--correct";
- feedback.textContent = question.feedbackCorrect || t("Correct");
- lockQuestion(inputs, checkButton);
- } else if (answers.isExhausted(marker.id, question.attemptsAllowed)) {
- feedback.className = "tdv-q-feedback tdv-q-feedback--incorrect";
- feedback.textContent = `${question.feedbackIncorrect || t("Incorrect")} ${t("No attempts left")}`;
- lockQuestion(inputs, checkButton);
- }
- checkButton.addEventListener("click", () => {
- const chosen = inputs.find((input) => input.checked);
- if (!chosen) {
- feedback.className = "tdv-q-feedback";
- feedback.textContent = t("Please select an answer");
- return;
- }
- const correct = gradeSingleChoice(question, chosen.value);
- const next = answers.recordAttempt(marker.id, chosen.value, correct);
- try {
- deps.onAnswered?.(marker.id, correct);
- } catch {}
- if (correct) {
- feedback.className = "tdv-q-feedback tdv-q-feedback--correct";
- feedback.textContent = question.feedbackCorrect || t("Correct");
- lockQuestion(inputs, checkButton);
- return;
- }
- feedback.className = "tdv-q-feedback tdv-q-feedback--incorrect";
- let message = question.feedbackIncorrect || t("Incorrect");
- if (question.attemptsAllowed > 0 && next.attempts >= question.attemptsAllowed) {
- lockQuestion(inputs, checkButton);
- message += ` ${t("No attempts left")}`;
- }
- feedback.textContent = message;
- });
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/state.ts
- function emptyState() {
- return { attempts: 0, resolved: false, selectedOptionId: "" };
- }
- function createAnswerStore() {
- const states = new Map;
- const get = (markerId) => states.get(markerId) ?? emptyState();
- return {
- get,
- recordAttempt(markerId, selectedOptionId, correct) {
- const previous = get(markerId);
- const next = {
- attempts: previous.attempts + 1,
- resolved: previous.resolved || correct,
- selectedOptionId
- };
- states.set(markerId, next);
- return next;
- },
- isExhausted(markerId, attemptsAllowed) {
- if (attemptsAllowed <= 0) {
- return false;
- }
- return get(markerId).attempts >= attemptsAllowed;
- },
- correctMarkerIds() {
- const ids = new Set;
- for (const [markerId, state] of states) {
- if (state.resolved) {
- ids.add(markerId);
- }
- }
- return ids;
- },
- retain(markerIds) {
- const keep = new Set(markerIds);
- for (const markerId of [...states.keys()]) {
- if (!keep.has(markerId)) {
- states.delete(markerId);
- }
- }
- },
- clear() {
- states.clear();
- }
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/interactions/controller.ts
- var EMPTY_CAMERA3 = { orbit: "", target: "", fieldOfView: "" };
- function emptyState2() {
- return {
- enabled: false,
- guidedMode: false,
- wrapNavigation: false,
- showMarkerLabels: true,
- activeMarkerId: "",
- markers: []
- };
- }
- function buildActionBody(body, marker, deps) {
- if (marker.description) {
- const description = document.createElement("p");
- description.className = "tdv-dialog-description";
- description.textContent = marker.description;
- body.appendChild(description);
- }
- const action = marker.action;
- switch (action.type) {
- case "information": {
- const container = document.createElement("div");
- container.className = "tdv-dialog-html";
- container.innerHTML = deps.sanitize(action.payload.html);
- body.appendChild(container);
- return;
- }
- case "image": {
- const figure = document.createElement("figure");
- figure.className = "tdv-dialog-figure";
- const image = document.createElement("img");
- image.src = deps.resolveMedia(action.payload.src);
- image.alt = action.payload.alt;
- figure.appendChild(image);
- if (action.payload.caption) {
- const caption = document.createElement("figcaption");
- caption.textContent = action.payload.caption;
- figure.appendChild(caption);
- }
- body.appendChild(figure);
- return;
- }
- case "video": {
- const video = document.createElement("video");
- video.className = "tdv-dialog-video";
- video.controls = true;
- video.src = deps.resolveMedia(action.payload.src);
- if (action.payload.poster) {
- video.poster = deps.resolveMedia(action.payload.poster);
- }
- body.appendChild(video);
- return;
- }
- case "link":
- case "question":
- return;
- }
- }
- function createInteractionController(handle, interaction, mode, hooks = {}) {
- const wrapper = handle.wrapper;
- const translate = hooks.t ?? ((key) => key);
- const resolveMedia = hooks.resolveMediaUrl ?? ((url) => resolveMediaUrlSync(url));
- const sanitize = hooks.sanitizeHtml ?? sanitizeHtml;
- const answers = createAnswerStore();
- let state = interaction ?? emptyState2();
- let markers = state.markers;
- let activeId = "";
- let destroyed = false;
- let dialog = null;
- let adapter = null;
- let guided = null;
- const markerLabel = (marker, index) => marker.label || `${translate("Marker")} ${index + 1}`;
- const closeDialog = () => {
- dialog?.close();
- dialog = null;
- };
- const currentIndex = () => markers.findIndex((marker) => marker.id === activeId);
- const updateGuided = () => {
- guided?.update({
- enabled: Boolean(state.guidedMode),
- index: currentIndex(),
- total: markers.length,
- wrap: Boolean(state.wrapNavigation)
- });
- };
- const setActive = (markerId) => {
- activeId = markerId;
- adapter?.setActive(activeId);
- updateGuided();
- };
- const activateMarker = (marker, index) => {
- if (marker.action.type === "link") {
- const url = safeUrl(marker.action.payload.url);
- if (!url) {
- return;
- }
- if (marker.action.payload.newTab) {
- globalThis.open(url, "_blank", "noopener,noreferrer");
- } else if (globalThis.location) {
- globalThis.location.href = url;
- }
- return;
- }
- closeDialog();
- dialog = openDialog({
- title: markerLabel(marker, index),
- closeLabel: translate("Close"),
- host: wrapper ?? null,
- onClose: () => {
- dialog = null;
- }
- }, (body) => {
- buildActionBody(body, marker, { sanitize, resolveMedia });
- if (marker.action.type === "question") {
- renderQuestion(body, marker, {
- answers,
- t: translate,
- onAnswered: hooks.onQuestionAnswered
- });
- }
- });
- hooks.onActivate?.(marker.id);
- };
- const focusMarker = (markerId) => {
- const index = markers.findIndex((marker2) => marker2.id === markerId);
- const marker = markers[index];
- if (!marker) {
- return;
- }
- setActive(markerId);
- adapter?.focusMarker(marker);
- activateMarker(marker, index);
- };
- const go = (delta) => {
- const next = resolveStepIndex(currentIndex(), delta, markers.length, Boolean(state.wrapNavigation));
- const marker = next === null ? undefined : markers[next];
- if (marker) {
- focusMarker(marker.id);
- }
- };
- const render = () => {
- if (destroyed) {
- return;
- }
- markers = state.markers;
- if (adapter) {
- adapter.renderMarkers(markers, {
- showLabels: state.showMarkerLabels !== false,
- activeId
- });
- revealFallback(wrapper, !hasWebGL());
- } else {
- revealFallback(wrapper, true);
- }
- updateGuided();
- };
- const controller = {
- setState(next) {
- state = next ?? emptyState2();
- const ids = state.markers.map((marker) => marker.id);
- if (activeId && !ids.includes(activeId)) {
- activeId = "";
- }
- answers.retain(ids);
- render();
- },
- render,
- enterPlacementMode() {
- if (!adapter || mode !== "edit") {
- return;
- }
- wrapper?.classList.add("tdv-placing");
- adapter.enterPlacementMode((placement) => {
- controller.exitPlacementMode();
- hooks.onPlaced?.(placement);
- });
- },
- exitPlacementMode() {
- wrapper?.classList.remove("tdv-placing");
- adapter?.exitPlacementMode();
- },
- focusMarker,
- captureCamera: () => adapter?.captureCamera() ?? { ...EMPTY_CAMERA3 },
- next: () => go(1),
- previous: () => go(-1),
- getActiveId: () => activeId,
- markerLabel,
- destroy() {
- if (destroyed) {
- return;
- }
- destroyed = true;
- controller.exitPlacementMode();
- closeDialog();
- guided?.destroy();
- guided = null;
- adapter?.destroy();
- adapter = null;
- answers.clear();
- }
- };
- const adapterDeps = { markerLabel, onActivate: focusMarker };
- if ((handle.type === "glb" || handle.type === "gltf") && handle.modelViewer) {
- adapter = createModelViewerAdapter(handle.modelViewer, adapterDeps);
- } else if (handle.type === "stl" && handle.instance) {
- adapter = createStlAdapter(handle.instance, wrapper, adapterDeps);
- }
- guided = createGuidedNavigation(wrapper ?? null, { t: translate, onGo: go });
- render();
- return controller;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/colors.ts
- var DEFAULT_MODEL_COLOR = "#888888";
- var DEFAULT_BACKGROUND_COLOR = "#f5f5f5";
- var HEX6 = /^#[0-9a-f]{6}$/;
- var HEX3 = /^#[0-9a-f]{3}$/;
- function normalizeColor(value, fallback = DEFAULT_MODEL_COLOR) {
- if (typeof value !== "string") {
- return fallback;
- }
- const trimmed = value.trim().toLowerCase();
- if (HEX6.test(trimmed)) {
- return trimmed;
- }
- if (HEX3.test(trimmed)) {
- const r = trimmed[1] ?? "0";
- const g = trimmed[2] ?? "0";
- const b = trimmed[3] ?? "0";
- return `#${r}${r}${g}${g}${b}${b}`;
- }
- return fallback;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/instance-registry.ts
- function createRegistry() {
- const instances = new Map;
- const destroy = (wrapper) => {
- const instance = instances.get(wrapper);
- if (!instance) {
- return;
- }
- instances.delete(wrapper);
- disposeInstance(instance);
- };
- return {
- get: (wrapper) => instances.get(wrapper),
- set: (wrapper, instance) => {
- instances.set(wrapper, instance);
- },
- has: (wrapper) => instances.has(wrapper),
- destroy,
- destroyAll: () => {
- for (const wrapper of [...instances.keys()].reverse()) {
- destroy(wrapper);
- }
- },
- wrappers: () => [...instances.keys()]
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/stl-renderer.ts
- var NORMALIZED_SIZE = 2;
- function configureRendererColorManagement(renderer) {
- const three = globalThis.THREE;
- if (!three || !renderer) {
- return;
- }
- if (three.ColorManagement && "enabled" in three.ColorManagement) {
- three.ColorManagement.enabled = true;
- }
- if ("outputColorSpace" in renderer && three.SRGBColorSpace !== undefined) {
- renderer.outputColorSpace = three.SRGBColorSpace;
- } else if ("outputEncoding" in renderer && three.sRGBEncoding !== undefined) {
- renderer.outputEncoding = three.sRGBEncoding;
- }
- if ("toneMapping" in renderer && three.NoToneMapping !== undefined) {
- renderer.toneMapping = three.NoToneMapping;
- }
- }
- function ensureCanvas(wrapper) {
- const existing = wrapper.querySelector("canvas.three-js-canvas");
- if (existing) {
- return existing;
- }
- const canvas = document.createElement("canvas");
- canvas.className = "three-js-canvas";
- canvas.style.cssText = "width: 100%; height: 100%; display: block;";
- wrapper.appendChild(canvas);
- return canvas;
- }
- function requestFrame(callback) {
- const raf = globalThis.requestAnimationFrame;
- return typeof raf === "function" ? raf(callback) : setTimeout(callback, 16);
- }
- async function bootStl(instance) {
- const three = globalThis.THREE;
- if (!three?.STLLoader || instance.stopped) {
- return;
- }
- const { options, wrapper } = instance;
- const url = await resolveModelSource(options.src);
- if (instance.stopped || !url) {
- return;
- }
- const canvas = ensureCanvas(wrapper);
- instance.canvas = canvas;
- const modelViewer = wrapper.querySelector("model-viewer");
- if (modelViewer) {
- modelViewer.style.display = "none";
- instance.modelViewer = modelViewer;
- }
- const width = wrapper.clientWidth || 400;
- const height = wrapper.clientHeight || 300;
- const scene = new three.Scene;
- scene.background = new three.Color(normalizeColor(options.backgroundColor, DEFAULT_BACKGROUND_COLOR));
- const camera = new three.PerspectiveCamera(45, width / height, 0.1, 1000);
- const renderer = new three.WebGLRenderer({ canvas, antialias: true });
- renderer.setSize(width, height);
- renderer.setPixelRatio?.(Math.min(globalThis.devicePixelRatio || 1, 2));
- configureRendererColorManagement(renderer);
- instance.scene = scene;
- instance.camera = camera;
- instance.renderer = renderer;
- scene.add(new three.AmbientLight(16777215, 0.6));
- const keyLight = new three.DirectionalLight(16777215, 0.8);
- keyLight.position.set(1, 1, 1);
- scene.add(keyLight);
- const fillLight = new three.DirectionalLight(16777215, 0.4);
- fillLight.position.set(-1, -1, -1);
- scene.add(fillLight);
- try {
- const response = await fetch(url);
- if (instance.stopped) {
- return;
- }
- const buffer = await response.arrayBuffer();
- if (instance.stopped) {
- return;
- }
- const geometry = new three.STLLoader().parse(buffer);
- geometry.computeBoundingBox();
- geometry.center();
- const size = geometry.boundingBox?.getSize(new three.Vector3);
- const maxDimension = size ? Math.max(size.x, size.y, size.z) || 1 : 1;
- const scale = NORMALIZED_SIZE / maxDimension;
- geometry.scale(scale, scale, scale);
- if (!geometry.hasAttribute("normal")) {
- geometry.computeVertexNormals();
- }
- const material = new three.MeshStandardMaterial({
- color: new three.Color(normalizeColor(options.modelColor, DEFAULT_MODEL_COLOR)),
- metalness: 0,
- roughness: 0.55
- });
- const mesh = new three.Mesh(geometry, material);
- scene.add(mesh);
- camera.position.set(3, 3, 3);
- camera.lookAt(0, 0, 0);
- let controls = null;
- if (options.cameraControls && three.OrbitControls) {
- const orbitControls = new three.OrbitControls(camera, canvas);
- orbitControls.enableDamping = true;
- orbitControls.dampingFactor = 0.05;
- controls = orbitControls;
- }
- instance.mesh = mesh;
- instance.geometry = geometry;
- instance.material = material;
- instance.controls = controls;
- const autoRotate = options.autoRotate;
- const radiansPerSecond = (options.autoRotateSpeed || 30) * Math.PI / 180;
- const animate = () => {
- if (instance.stopped || !instance.renderer || !instance.scene || !instance.camera) {
- return;
- }
- if (autoRotate && instance.mesh) {
- instance.mesh.rotation.y += radiansPerSecond / 60;
- }
- instance.controls?.update?.();
- for (const callback of instance.onFrame) {
- try {
- callback();
- } catch {}
- }
- instance.renderer.render(instance.scene, instance.camera);
- instance.rafId = requestFrame(animate);
- };
- animate();
- const empty = wrapper.querySelector("[data-empty], [data-empty-state]");
- if (empty) {
- empty.style.display = "none";
- }
- } catch (error) {
- console.error("[3D Viewer] Failed to render STL:", error);
- }
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/viewer-runtime.ts
- function readWrapperOptions(wrapper) {
- const data = wrapper.dataset;
- const showNavControls = data.showNavControls === "true";
- const src = normalizeModelSource(data.modelSrc ?? "");
- return {
- src,
- type: data.modelType || detectModelType(src),
- modelColor: normalizeColor(data.modelColor, DEFAULT_MODEL_COLOR),
- backgroundColor: normalizeColor(data.backgroundColor, DEFAULT_BACKGROUND_COLOR),
- cameraControls: data.cameraControls !== "false",
- autoRotate: !showNavControls && data.autoRotate !== "false",
- autoRotateSpeed: Number.parseFloat(data.autoRotateSpeed ?? "") || 30
- };
- }
- function createViewerRuntime() {
- const registry = createRegistry();
- let unloadBound = false;
- const bindUnloadOnce = () => {
- if (unloadBound || typeof globalThis.addEventListener !== "function") {
- return;
- }
- unloadBound = true;
- globalThis.addEventListener("beforeunload", () => registry.destroyAll());
- };
- return {
- init(wrapper, options) {
- if (!wrapper) {
- return null;
- }
- const existing = registry.get(wrapper);
- if (existing) {
- return existing;
- }
- const instance = createInstance(wrapper, options ?? readWrapperOptions(wrapper));
- registry.set(wrapper, instance);
- bindUnloadOnce();
- if (instance.type === "stl" && instance.options.src) {
- bootStl(instance).catch((error) => {
- console.error("[3D Viewer] STL boot failed:", error);
- });
- }
- return instance;
- },
- destroy: (wrapper) => registry.destroy(wrapper),
- destroyAll: () => registry.destroyAll(),
- getInstance: (wrapper) => registry.get(wrapper) ?? null,
- createInteractionLayer: (handle, interaction, mode, hooks) => createInteractionController(handle, interaction, mode, hooks),
- detectModelType,
- normalizeColor,
- normalizeModelSource,
- resolveModelSource,
- configureRendererColorManagement,
- disposeObject3D,
- disposeMaterial,
- readWrapperOptions,
- registry
- };
- }
- function publishViewerRuntime() {
- const existing = globalThis.eXe3DViewer;
- if (existing) {
- return existing;
- }
- const runtime = createViewerRuntime();
- globalThis.eXe3DViewer = runtime;
- return runtime;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/paths.ts
- var LIB_RELATIVE_PATH = "files/perm/idevices/base/three-d-viewer/export/";
- var EXPORT_LIB_PATH = "idevices/three-d-viewer/";
- function parseRuntimeConfig() {
- const config = globalThis.eXeLearning?.config;
- if (typeof config !== "string") {
- return config ?? null;
- }
- try {
- return JSON.parse(config);
- } catch {
- return null;
- }
- }
- function detectMode() {
- const config = parseRuntimeConfig();
- const documentId = typeof document !== "undefined" ? document.documentElement.id : "";
- const isOnIndexPage = documentId === "exe-index";
- return {
- isStaticMode: Boolean(config?.isStaticMode || config?.isOfflineInstallation),
- isServerMode: config?.baseURL !== undefined,
- isExportMode: isOnIndexPage || typeof document !== "undefined" && document.querySelector('html[id^="exe-"]') !== null,
- isOnIndexPage
- };
- }
- function resolveAppUrl(path) {
- const symfony = globalThis.eXeLearning?.symfony ?? {};
- return joinAppUrl(symfony.baseURL, symfony.basePath, path);
- }
- function getIdeviceResourcesBase(ideviceId) {
- if (!ideviceId) {
- return "";
- }
- const onIndex = typeof document !== "undefined" && document.documentElement.id === "exe-index";
- return onIndex ? `content/resources/${ideviceId}/` : `../content/resources/${ideviceId}/`;
- }
- function getExportLibBaseUrl() {
- const mode = detectMode();
- if (mode.isStaticMode) {
- return `${globalThis.location?.origin ?? ""}/${LIB_RELATIVE_PATH}`;
- }
- if (mode.isServerMode) {
- const config = parseRuntimeConfig();
- const baseURL2 = String(config?.baseURL || globalThis.location?.origin || "").replace(/\/+$/g, "");
- const basePath2 = config?.basePath ? `/${config.basePath.replace(/^\/+|\/+$/g, "")}` : "";
- return `${baseURL2}${basePath2}/${LIB_RELATIVE_PATH}`;
- }
- if (mode.isExportMode) {
- const href = globalThis.location?.href ?? "";
- const pageBase = href.substring(0, href.lastIndexOf("/") + 1);
- return `${pageBase}${mode.isOnIndexPage ? "" : "../"}${EXPORT_LIB_PATH}`;
- }
- const symfony = globalThis.eXeLearning?.symfony ?? {};
- const baseURL = String(symfony.baseURL || globalThis.location?.origin || "").replace(/\/+$/g, "");
- const basePath = symfony.basePath ? `/${String(symfony.basePath).replace(/^\/+|\/+$/g, "")}` : "";
- return `${baseURL}${basePath}/${LIB_RELATIVE_PATH}`;
- }
- function getExportModelViewerUrl() {
- const mode = detectMode();
- const path = `${LIB_RELATIVE_PATH}model-viewer.min.js`;
- if (mode.isStaticMode) {
- return `./${path}`;
- }
- if (mode.isServerMode) {
- return resolveAppUrl(path);
- }
- if (mode.isExportMode) {
- return `${mode.isOnIndexPage ? "./" : "../"}${EXPORT_LIB_PATH}model-viewer.min.js`;
- }
- return resolveAppUrl(path);
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/types.ts
- var MARKER_ICONS = ["circle", "pin", "info", "question", "star"];
- var MARKER_ACTION_TYPES = ["information", "image", "video", "link", "question"];
-
- // public/files/perm/idevices/base/three-d-viewer/src/shared/schema.ts
- var MAX_QUESTION_OPTIONS = 10;
- var MAX_ATTEMPTS_ALLOWED = 20;
- var defaultIdFactory = (prefix) => `${prefix}-${Math.floor(Math.random() * 1e9).toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
- function asRecord(value) {
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
- }
- function toNumber(value, fallback) {
- const parsed = typeof value === "number" ? value : Number.parseFloat(String(value));
- return Number.isFinite(parsed) ? parsed : fallback;
- }
- function toInteger(value, fallback) {
- const parsed = Number.parseInt(String(value), 10);
- return Number.isFinite(parsed) ? parsed : fallback;
- }
- function toText(value, fallback = "") {
- return typeof value === "string" ? value : fallback;
- }
- function clamp(value, min, max) {
- return Math.min(max, Math.max(min, value));
- }
- function keepOrCreateId(value, prefix, createId) {
- return typeof value === "string" && value ? value : createId(prefix);
- }
- function normalizeVector3(value, fallback) {
- const raw = asRecord(value);
- return {
- x: toNumber(raw.x, fallback.x),
- y: toNumber(raw.y, fallback.y),
- z: toNumber(raw.z, fallback.z)
- };
- }
- function normalizeAnchor(value) {
- const raw = asRecord(value);
- return {
- position: normalizeVector3(raw.position, { x: 0, y: 0, z: 0 }),
- normal: normalizeVector3(raw.normal, { x: 0, y: 1, z: 0 }),
- surface: toText(raw.surface)
- };
- }
- function normalizeCamera(value) {
- const raw = asRecord(value);
- return {
- orbit: toText(raw.orbit),
- target: toText(raw.target),
- fieldOfView: toText(raw.fieldOfView)
- };
- }
- function normalizeQuestion(value, createId = defaultIdFactory) {
- const raw = asRecord(value);
- const rawOptions = Array.isArray(raw.options) ? raw.options : [];
- let seenCorrect = false;
- const options = rawOptions.slice(0, MAX_QUESTION_OPTIONS).map((option) => {
- const item = asRecord(option);
- const correct = Boolean(item.correct) && !seenCorrect;
- if (correct) {
- seenCorrect = true;
- }
- return { id: keepOrCreateId(item.id, "option", createId), text: toText(item.text), correct };
- });
- if (options.length === 0) {
- options.push({ id: createId("option"), text: "", correct: true }, { id: createId("option"), text: "", correct: false });
- } else if (!seenCorrect) {
- const first = options[0];
- if (first) {
- first.correct = true;
- }
- }
- return {
- prompt: toText(raw.prompt),
- type: "single-choice",
- options,
- feedbackCorrect: toText(raw.feedbackCorrect),
- feedbackIncorrect: toText(raw.feedbackIncorrect),
- attemptsAllowed: clamp(Math.round(toNumber(raw.attemptsAllowed, 0)), 0, MAX_ATTEMPTS_ALLOWED)
- };
- }
- function normalizeInformationPayload(raw) {
- return { html: toText(raw.html) };
- }
- function normalizeImagePayload(raw) {
- return { src: stripUnsafeUrl(raw.src), alt: toText(raw.alt), caption: toText(raw.caption) };
- }
- function normalizeVideoPayload(raw) {
- return { src: stripUnsafeUrl(raw.src), poster: stripUnsafeUrl(raw.poster) };
- }
- function normalizeLinkPayload(raw) {
- return { url: stripUnsafeUrl(raw.url), newTab: raw.newTab !== false };
- }
- function toActionType(value) {
- return MARKER_ACTION_TYPES.includes(String(value)) ? value : "information";
- }
- function normalizeAction(value, createId = defaultIdFactory) {
- const raw = asRecord(value);
- const type = toActionType(raw.type);
- const payload = asRecord(raw.payload);
- switch (type) {
- case "image":
- return { type, payload: normalizeImagePayload(payload) };
- case "video":
- return { type, payload: normalizeVideoPayload(payload) };
- case "link":
- return { type, payload: normalizeLinkPayload(payload) };
- case "question":
- return { type, payload: normalizeQuestion(payload, createId) };
- case "information":
- return { type, payload: normalizeInformationPayload(payload) };
- }
- const unreachable = type;
- return { type: "information", payload: { html: "" } };
- }
- function toIcon(value) {
- return MARKER_ICONS.includes(String(value)) ? value : "circle";
- }
- function normalizeMarker(value, index, createId = defaultIdFactory) {
- const raw = asRecord(value);
- const order = toNumber(raw.order, Number.NaN);
- return {
- id: keepOrCreateId(raw.id, "marker", createId),
- label: toText(raw.label),
- description: toText(raw.description),
- icon: toIcon(raw.icon),
- order: Number.isFinite(order) ? order : index,
- anchor: normalizeAnchor(raw.anchor),
- camera: normalizeCamera(raw.camera),
- action: normalizeAction(raw.action, createId)
- };
- }
- function normalizeInteraction(value, createId = defaultIdFactory) {
- const raw = asRecord(value);
- const markers = (Array.isArray(raw.markers) ? raw.markers : []).map((marker, index) => normalizeMarker(marker, index, createId));
- markers.sort((a, b) => a.order - b.order);
- markers.forEach((marker, index) => {
- marker.order = index;
- });
- const ids = markers.map((marker) => marker.id);
- const activeMarkerId = toText(raw.activeMarkerId);
- return {
- enabled: Boolean(raw.enabled),
- guidedMode: Boolean(raw.guidedMode),
- wrapNavigation: Boolean(raw.wrapNavigation),
- showMarkerLabels: raw.showMarkerLabels !== false,
- activeMarkerId: ids.includes(activeMarkerId) ? activeMarkerId : "",
- markers
- };
- }
- function normalizeAnimation(value) {
- const raw = asRecord(value);
- return {
- enabled: Boolean(raw.enabled),
- name: toText(raw.name),
- speed: clamp(toNumber(raw.speed, 1), 0.1, 3)
- };
- }
- function normalizeScorm(value) {
- const raw = asRecord(value);
- const mode = clamp(toInteger(raw.mode ?? raw.isScorm, 0), 0, 2);
- return {
- mode,
- weighted: clamp(toNumber(raw.weighted, 100), 1, 100),
- saveButtonText: toText(raw.saveButtonText ?? raw.textButtonScorm)
- };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/model-viewer-loader.ts
- var SCRIPT_MARKER = "data-threedviewer-lib";
- var DEFINITION_TIMEOUT_MS = 15000;
- function libs() {
- globalThis.$exeLibs = globalThis.$exeLibs ?? {};
- return globalThis.$exeLibs;
- }
- function isModelViewerDefined() {
- return Boolean(globalThis.customElements?.get?.("model-viewer"));
- }
- function injectScript(url, origin) {
- return new Promise((resolve) => {
- const script = document.createElement("script");
- script.src = url;
- script.setAttribute(SCRIPT_MARKER, origin);
- script.addEventListener("load", () => resolve());
- script.addEventListener("error", () => {
- console.error("[3D Viewer] Unable to load the model-viewer library from", url);
- resolve();
- });
- document.head.appendChild(script);
- });
- }
- async function ensureModelViewerLoaded(candidates, origin) {
- if (isModelViewerDefined()) {
- return;
- }
- const shared = libs();
- const pending = shared.modelViewerPromise;
- if (pending instanceof Promise) {
- await pending;
- return;
- }
- const existing = typeof document !== "undefined" ? document.querySelector(`script[${SCRIPT_MARKER}]`) : null;
- const loading = (async () => {
- if (!existing) {
- for (const url of candidates.filter(Boolean)) {
- if (isModelViewerDefined()) {
- return;
- }
- await injectScript(url, origin);
- if (isModelViewerDefined()) {
- return;
- }
- }
- }
- const whenDefined = globalThis.customElements?.whenDefined;
- if (whenDefined) {
- try {
- await Promise.race([
- whenDefined.call(globalThis.customElements, "model-viewer"),
- new Promise((resolve) => setTimeout(resolve, DEFINITION_TIMEOUT_MS))
- ]);
- } catch {}
- }
- })();
- shared.modelViewerPromise = loading;
- await loading;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/runtime/three-loader.ts
- function libs2() {
- globalThis.$exeLibs = globalThis.$exeLibs ?? {};
- return globalThis.$exeLibs;
- }
- function isThreeJsReady() {
- const three = globalThis.THREE;
- return Boolean(three?.STLLoader && three?.OrbitControls);
- }
- async function ensureThreeJsLoaded(baseUrl) {
- if (isThreeJsReady()) {
- return;
- }
- const shared = libs2();
- const pending = shared.threeJsPromise;
- if (pending instanceof Promise) {
- await pending;
- return;
- }
- const loading = (async () => {
- const core = await import(`${baseUrl}three.module.min.js`);
- const { STLLoader } = await import(`${baseUrl}STLLoader.js`);
- const { OrbitControls } = await import(`${baseUrl}OrbitControls.js`);
- const three = globalThis.THREE ?? {};
- Object.assign(three, core);
- three.STLLoader = STLLoader;
- three.OrbitControls = OrbitControls;
- globalThis.THREE = three;
- })();
- shared.threeJsPromise = loading;
- await loading;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/export/source-resolver.ts
- function getOdeSessionId() {
- const session = globalThis.eXeLearning?.app?.project?.odeSession;
- return typeof session === "string" && session.trim().length >= 8 ? session.trim() : "";
- }
- function sessionPrefix(sessionId) {
- return `files/tmp/${sessionId.substring(0, 4)}/${sessionId.substring(4, 6)}/${sessionId.substring(6, 8)}/${sessionId}/`;
- }
- function resolveRuntimeSrc(path) {
- const clean = normalizePath(path);
- if (!clean) {
- return "";
- }
- if (isAbsoluteUrl(clean) || clean.startsWith("blob:")) {
- return clean;
- }
- if (clean.startsWith("files/tmp/")) {
- return resolveAppUrl(clean);
- }
- if (clean.startsWith("asset://")) {
- const assetManager = getAssetManager();
- if (assetManager) {
- return assetManager.resolveAssetURLSync?.(clean) || "";
- }
- const assetPath = clean.substring("asset://".length);
- if (!assetPath) {
- return "";
- }
- const onIndex = typeof document !== "undefined" && document.documentElement.id === "exe-index";
- return `${onIndex ? "content/resources/" : "../content/resources/"}${assetPath}`;
- }
- if (clean.startsWith("content/resources/") || clean.startsWith("../content/resources/")) {
- return clean;
- }
- const sessionId = getOdeSessionId();
- return resolveAppUrl(sessionId ? `${sessionPrefix(sessionId)}${clean}` : clean);
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/export/scorm.ts
- function getScormRuntime() {
- return globalThis.$exeDevices?.iDevice?.gamification?.scorm ?? null;
- }
- function isScormExport() {
- return Boolean(typeof document !== "undefined" && document.body?.classList?.contains("exe-scorm"));
- }
- function setupScormScoring(wrapper, interaction, scorm, hooks) {
- if (scorm.mode <= 0 || !isScormExport()) {
- return null;
- }
- const runtime = getScormRuntime();
- if (!runtime || questionMarkers(interaction.markers).length === 0) {
- return null;
- }
- const correctMarkerIds = new Set;
- const game = {
- main: wrapper.id,
- idevice: "three-d-viewer",
- isScorm: scorm.mode,
- weighted: scorm.weighted,
- scorerp: 0,
- gameStarted: true,
- msgs: {}
- };
- try {
- runtime.registerActivity?.(game);
- } catch {}
- hooks.onQuestionAnswered = (markerId, correct) => {
- if (correct) {
- correctMarkerIds.add(markerId);
- }
- game.scorerp = computeScore(interaction.markers, correctMarkerIds);
- game.gameOver = isActivityComplete(interaction.markers, correctMarkerIds);
- try {
- runtime.sendScoreNew?.(true, game);
- } catch {}
- };
- return { game, correctMarkerIds };
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/export/i18n.ts
- var FALLBACK_TRANSLATIONS = {
- "viewer.empty_state": "Select a 3D model to display",
- "viewer.animation_paused": "Animation paused",
- "viewer.animation_enabled": "Animation enabled",
- "viewer.local_warning_title": "3D Viewer not available",
- "viewer.local_warning_message": "The 3D viewer requires a web server to work. Open this content from a web server or use eXeLearning preview.",
- "viewer.fullscreen": "Fullscreen",
- "viewer.exit_fullscreen": "Exit fullscreen",
- "viewer.rotate_left": "Rotate left",
- "viewer.rotate_right": "Rotate right",
- "viewer.tilt_up": "Tilt up",
- "viewer.tilt_down": "Tilt down"
- };
- function translate(key) {
- try {
- const translator = globalThis._;
- if (typeof translator === "function") {
- const translated = translator(key);
- if (translated && translated !== key) {
- return translated;
- }
- }
- } catch {}
- return FALLBACK_TRANSLATIONS[key] ?? key;
- }
- function translateContent(text) {
- if (typeof globalThis.c_ === "function") {
- return globalThis.c_(text);
- }
- if (typeof globalThis._ === "function") {
- return globalThis._(text);
- }
- return text;
- }
- function buildRuntimeI18n() {
- const keys = [
- "Marker",
- "Close",
- "Check",
- "Correct",
- "Incorrect",
- "Previous",
- "Next",
- "Please select an answer",
- "No attempts left"
- ];
- const map = {};
- for (const key of keys) {
- map[key] = translateContent(key);
- }
- return map;
- }
-
- // public/files/perm/idevices/base/three-d-viewer/src/export/renderer.ts
- function buildModelMarkup(config) {
- const attributes = [
- ["shadow-intensity", "1"],
- ["tone-mapping", "pbr-neutral"],
- ["reveal", "auto"],
- ["style", `background-color: ${config.backgroundColor || DEFAULT_BACKGROUND_COLOR};`]
- ];
- if (config.alt) {
- attributes.push(["alt", config.alt], ["aria-label", config.alt]);
- }
- if (config.cameraControls) {
- attributes.push(["camera-controls", ""]);
- }
- if (config.autoRotate) {
- attributes.push(["auto-rotate", ""], ["rotation-per-second", `${config.autoRotateSpeed || 30}deg`]);
- }
- const rendered = attributes.map(([name, value]) => value === "" ? name : `${name}="${escapeHtml(value)}"`).join(" ");
- return ` `;
- }
- function buildWrapperAttributes(config, assetRef = "") {
- const parts = [];
- const push = (name, value) => {
- parts.push(`${name}="${escapeHtml(String(value))}"`);
- };
- const src = config.src;
- const type = config.type || (src ? detectModelType(src) : "");
- if (src) {
- push("data-model-src", src);
- }
- if (assetRef) {
- push("data-model-asset-ref", assetRef);
- }
- if (type && type !== "unknown") {
- push("data-model-type", type);
- }
- push("data-model-color", config.modelColor || DEFAULT_MODEL_COLOR);
- push("data-background-color", config.backgroundColor || DEFAULT_BACKGROUND_COLOR);
- push("data-camera-controls", config.cameraControls ? "true" : "false");
- push("data-auto-rotate", config.autoRotate ? "true" : "false");
- push("data-auto-rotate-speed", config.autoRotateSpeed || 30);
- push("data-show-nav-controls", config.showNavControls ? "true" : "false");
- push("data-animation-enabled", config.animation.enabled ? "true" : "false");
- if (config.animation.name) {
- push("data-animation-name", config.animation.name);
- }
- push("data-animation-speed", config.animation.speed);
- if (config.alt) {
- push("data-alt", config.alt);
- }
- return parts.join(" ");
- }
- function buildControlsMarkup(config) {
- if (!config.showNavControls) {
- return "";
- }
- const fullscreenLabel = escapeHtml(translate("viewer.fullscreen"));
- const directions = [
- ["left", "←", translate("viewer.rotate_left")],
- ["up", "↑", translate("viewer.tilt_up")],
- ["down", "↓", translate("viewer.tilt_down")],
- ["right", "→", translate("viewer.rotate_right")]
- ];
- const buttons = directions.map(([key, glyph, label]) => {
- const safeLabel = escapeHtml(label);
- return `${glyph} `;
- }).join("");
- return `
- ⛶
- ${buttons}
- `;
- }
- function buildMarkerFallbackItem(marker, index) {
- const label = marker.label || `${translateContent("Marker")} ${index + 1}`;
- const parts = [`${index + 1}. ${escapeHtml(label)} `];
- if (marker.description) {
- parts.push(`${escapeHtml(marker.description)}
`);
- }
- const action = marker.action;
- switch (action.type) {
- case "information": {
- const text = stripHtmlToText(action.payload.html);
- if (text) {
- parts.push(`${escapeHtml(text)}
`);
- }
- break;
- }
- case "image": {
- if (action.payload.alt) {
- parts.push(`${escapeHtml(action.payload.alt)}
`);
- }
- if (action.payload.caption) {
- parts.push(`${escapeHtml(action.payload.caption)}
`);
- }
- break;
- }
- case "link": {
- const url = safeUrl(action.payload.url);
- if (url) {
- parts.push(`${escapeHtml(url)} `);
- }
- break;
- }
- case "question": {
- if (action.payload.prompt) {
- parts.push(`${escapeHtml(action.payload.prompt)}
`);
- }
- const options = action.payload.options.map((option) => `${escapeHtml(option.text)} `).join("");
- if (options) {
- parts.push(``);
- }
- break;
- }
- case "video":
- break;
- }
- return `${parts.join("")} `;
- }
- function buildInteractionFallback(interaction) {
- const items = interaction.markers.map(buildMarkerFallbackItem).join("");
- return ``;
- }
- function buildInteractionMarkup(interaction, scorm) {
- if (!interaction.enabled) {
- return "";
- }
- const payload = { ...interaction, i18n: buildRuntimeI18n(), scorm };
- const dataBlock = `