diff --git a/.agents/skills/idevice/SKILL.md b/.agents/skills/idevice/SKILL.md index 4f52c634d8..50c176d948 100644 --- a/.agents/skills/idevice/SKILL.md +++ b/.agents/skills/idevice/SKILL.md @@ -23,6 +23,25 @@ Creating or modifying interactive devices (iDevices) in `public/files/perm/idevi **Reference iDevices** (well-tested, good to study): `checklist`, `rubric`, `geogebra-activity` +## TypeScript iDevices (`src/`) + +An iDevice with a `src/` directory is a **TypeScript iDevice**: its +`edition/.js` and `export/.js` are GENERATED bundles (gitignored) +— never edit them; edit `src/` and rebuild. Convention and commands: + +- `src/edition/index.ts` → `edition/.js` (assigns `window.$exeDevice`); + `src/export/index.ts` → `export/.js` (assigns the runtime global). +- Build/typecheck: `bun run bundle:idevices` / `bun run typecheck:idevices` + (central runner `scripts/build-idevices.ts`; `--only `, `--watch`). + Run `make bundle` after src/ edits and BEFORE E2E, or the preview serves the + stale bundle from `public/bundles/idevices.zip`. +- Tests are colocated `*.spec.ts` (Vitest — `bun test` ignores `public/**`), + plus bundle-contract smoke tests over the compiled IIFEs. +- Deviations (custom bundle name, externals, minify) go in an optional + `build.config.json` — see `doc/development/idevices-typescript.md` and + ADR-2147-01. Reference implementations: `three-d-viewer` (full convention), + `slide` (manifest). + ## Structure ``` diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9f01c7a7a2..71aab36877 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,9 @@ jobs: - name: Build all assets once run: bun run build:static + # The path list must include every GENERATED (gitignored) file the + # workarea serves — the test runners get a fresh checkout, so anything + # missing here 404s at runtime (e.g. TypeScript-iDevice bundles, ADR-2147-01). - name: Upload dynamic bundles (chromium/firefox) uses: actions/upload-artifact@v7 with: @@ -64,6 +67,8 @@ jobs: public/bundles/** public/style/workarea/main.css public/files/perm/idevices/base/slide/edition/slide-editor.bundle.js + public/files/perm/idevices/base/three-d-viewer/edition/three-d-viewer.js + public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js - name: Upload static distribution (static project) uses: actions/upload-artifact@v7 diff --git a/.gitignore b/.gitignore index 60cc7e67fc..2197acb836 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,10 @@ symfony.lock .phpunit.cache .idea/ -runtime/ +# Anchored to the repository root: an unanchored `runtime/` also matches source +# directories named `runtime` at any depth (e.g. a TypeScript iDevice's +# `src/runtime/`) and silently drops them from commits. +/runtime/ symfony_legacy nestjs_legacy test-results/ @@ -128,6 +131,10 @@ public/app/dist/ /app/dist/ /app/node_modules/ -# Slide iDevice — pre-built editor bundle (regenerated by package.json postinstall) +# TypeScript iDevice bundles — generated from each iDevice's src/ by scripts/build-idevices.ts /public/files/perm/idevices/base/slide/edition/slide-editor.bundle.js +/public/files/perm/idevices/base/three-d-viewer/edition/three-d-viewer.js +/public/files/perm/idevices/base/three-d-viewer/edition/three-d-viewer.js.map +/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js +/public/files/perm/idevices/base/three-d-viewer/export/three-d-viewer.js.map .omc/ diff --git a/Makefile b/Makefile index fda466e2c5..fb65998e2a 100644 --- a/Makefile +++ b/Makefile @@ -531,7 +531,10 @@ test-frontend: check-bun check-env bundle ## Run frontend tests (with Vitest + h bun test:frontend .PHONY: test-unit-ci -test-unit-ci: check-bun check-tests check-env ## Run unit tests with lcov coverage for CI/Codecov +# Depends on `bundle` for the same reason test-unit/test-integration/test-frontend +# do: backend tests assert against generated assets (e.g. the TypeScript iDevice +# bundles), which are not committed. +test-unit-ci: check-bun check-tests check-env bundle ## Run unit tests with lcov coverage for CI/Codecov @echo "Running unit tests with lcov coverage..." @mkdir -p coverage/bun $(TEST_ENV) bun test:unit:ci diff --git a/doc/architecture/adr/ADR-2147-01-typescript-idevices-build-convention.md b/doc/architecture/adr/ADR-2147-01-typescript-idevices-build-convention.md new file mode 100644 index 0000000000..890d071017 --- /dev/null +++ b/doc/architecture/adr/ADR-2147-01-typescript-idevices-build-convention.md @@ -0,0 +1,121 @@ +--- +id: ADR-2147-01 +title: "TypeScript iDevices: src/ sources compiled by one convention-based build" +status: Proposed +date: 2026-07-30 +tracking_issue: 2147 +deciders: + - "@erseco" +reviewers: + - "@mnunezcedec" + - "@cristinavaldera" +related: + prs: [2147] + changes: + - "2153-three-d-viewer-interactions" + adrs: [] +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "Claude Code" + model: "claude-fable-5" +--- + +# ADR-2147-01: TypeScript iDevices: src/ sources compiled by one convention-based build + +## Context + +iDevices are classic-script objects loaded by the workarea and the exporters. +Historically each one is hand-written vanilla JavaScript committed directly +under `edition/` and `export/`. Two iDevices now keep their maintained source +in TypeScript instead — Slide (`src/` + a bespoke `scripts/build-slide-editor.ts`) +and Interactive Video (`src/` + a bespoke `scripts/build-interactive-video.ts`). +Two per-iDevice build scripts with duplicated Bun plumbing were already +diverging in flags and behaviour, and every future TypeScript iDevice would +have added another copy plus more package.json entries. + +## Problem + +How does the repository recognise, build, type-check and test an iDevice whose +maintained source is TypeScript, without a new build pipeline per iDevice? + +## Decision drivers + +- One obvious convention for the next TypeScript iDevice (zero new scripts). +- The shipped output must remain plain classic-script IIFEs; the language and + compile step are not a framework. +- Generated artifacts must never be committed; a clean checkout must + regenerate them through the existing pipeline (`build:all` / `make bundle`). +- Existing iDevices with special needs (Slide) must fit without renaming their + shipped bundles. + +## Decision + +**An iDevice that keeps a `src/` directory is a TypeScript iDevice**, built by +the centralized `scripts/build-idevices.ts`: + +- **Convention:** `src/edition/index.ts` → `edition/.js` and + `src/export/index.ts` → `export/.js` — self-contained IIFEs + (`target: browser`, linked source maps, unminified), whose entry points + explicitly assign their window globals (`$exeDevice`, `$`). +- **Escape hatch:** an optional `build.config.json` next to `config.xml` + replaces the convention for that iDevice (custom entries/naming/globalName/ + minify/sourcemap, plus `externals` mapping bare imports to page-provided + globals so vendored libraries are never inlined). Slide uses it. +- **Type checking:** each TypeScript iDevice ships its own `tsconfig.json` + (strict for new code); the runner executes `tsc -p` for every one it finds. +- **Tests:** colocated `*.spec.ts` next to each module, run by **Vitest** + (`bun test` ignores `public/**`), plus bundle-contract smoke tests that + evaluate the compiled IIFEs. +- **Artifacts:** generated bundles and source maps are gitignored; + `build:all` runs `typecheck:idevices` + `bundle:idevices` before + `bundle:resources` (export bundles ship inside `idevices.zip`). + +Package scripts: `typecheck:idevices`, `bundle:idevices`, +`bundle:idevices:watch`; the runner accepts `--only ` and `--watch`. + +## Options considered + +### Option 1: One bespoke build script per TypeScript iDevice (status quo) + +Pros: each script is trivially readable. Cons: duplicated plumbing, per-iDevice +package.json entries, drift between scripts (they already differed in +sourcemaps, watch support and failure reporting). + +### Option 2: Convention-based central runner + per-iDevice manifest (chosen) + +Pros: the next TypeScript iDevice needs no build changes at all; one place to +fix bundler behaviour; deviations are declared, not programmed. Cons: one more +convention to know; the manifest is a small new format (documented in the +runner header and `doc/development/idevices-typescript.md`). + +## Consequences + +### Positive + +- Adding a TypeScript iDevice = create `src/edition|export/index.ts` (+ a + strict `tsconfig.json`); building, type-checking and watching come for free. +- Slide and Interactive Video share one build path; Slide's output stayed + byte-identical apart from the generic externals shim's message strings. + +### Negative + +- A hidden convention: `src/` now has meaning. Mitigated by this ADR, + `doc/development/idevices-typescript.md` and the idevice skill. + +### Neutral + +- Classic-script iDevices are untouched; nothing forces a migration. + +## Validation + +- `scripts/build-idevices.spec.ts` covers discovery, the convention, the + manifest and its validation against the real repository state. +- `bun run build:all` exercises typecheck + build for every TypeScript + iDevice on every bundle/test target. + +## References + +- `scripts/build-idevices.ts` (runner; manifest schema in its header). +- `doc/development/idevices-typescript.md` (developer guide). +- PR [#2147](https://github.com/exelearning/exelearning/pull/2147). diff --git a/doc/architecture/adr/ADR-2153-01-three-d-viewer-interaction-layer.md b/doc/architecture/adr/ADR-2153-01-three-d-viewer-interaction-layer.md new file mode 100644 index 0000000000..db3b5698b0 --- /dev/null +++ b/doc/architecture/adr/ADR-2153-01-three-d-viewer-interaction-layer.md @@ -0,0 +1,212 @@ +--- +id: ADR-2153-01 +title: "3D Viewer interaction layer: renderer adapters over a shared runtime controller" +status: Proposed +date: 2026-07-10 +tracking_issue: 2153 +deciders: + - "@erseco" +reviewers: + - "@erseco" +related: + prs: [2157] + changes: + - "2153-three-d-viewer-interactions" + adrs: + - ADR-2147-01 +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "Claude Code" + model: "claude-opus-4-8" + notes: "Decision revised for the TypeScript implementation with claude-opus-5" +--- + +# ADR-2153-01: 3D Viewer interaction layer: renderer adapters over a shared runtime controller + +## 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). the 3D Viewer change design 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. + +(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-2147-01](ADR-2147-01-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 +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 have exactly one maintained source.** The iDevice follows the + TypeScript iDevice convention ([ADR-2147-01](ADR-2147-01-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 `` 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`, with two thin +renderer adapters (`src/adapters/model-viewer-adapter.ts`, `src/adapters/stl-adapter.ts`) +implementing a small common contract, constructed by one factory +(`eXe3DViewer.createInteractionLayer`) shared between the editor preview and the export runtime. +Schema and migration have one TypeScript source under `src/shared/`; 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 `' + + '' + + ''; + 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 0000000000..73b61067a8 --- /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 0000000000..e9f73e2d08 --- /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 0000000000..9c54d15886 --- /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 0000000000..3bba3a4300 --- /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 0000000000..1d6943c111 --- /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 0000000000..deb7c40bd0 --- /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/runtime/asset-resolver.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/asset-resolver.spec.ts new file mode 100644 index 0000000000..c21033f1f1 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/asset-resolver.spec.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getAssetManager, + isPreviewContext, + recoverAssetRefFromBlob, + resolveAssetUrlAsync, + resolveMediaUrlSync, + resolveModelSource, + waitForAssetManager, +} from './asset-resolver'; + +function installAssetManager(manager: ExeAssetManager | null): void { + globalThis.eXeLearning = manager ? { app: { project: { assetManager: manager } } } : undefined; +} + +afterEach(() => { + globalThis.eXeLearning = undefined; + vi.restoreAllMocks(); +}); + +describe('getAssetManager / isPreviewContext', () => { + it('reads the manager from the project', () => { + const manager: ExeAssetManager = {}; + installAssetManager(manager); + expect(getAssetManager()).toBe(manager); + expect(isPreviewContext()).toBe(true); + }); + + it('falls back to the Yjs bridge', () => { + const manager: ExeAssetManager = {}; + globalThis.eXeLearning = { app: { project: { _yjsBridge: { assetManager: manager } } } }; + expect(getAssetManager()).toBe(manager); + }); + + it('returns null in an export context', () => { + installAssetManager(null); + expect(getAssetManager()).toBeNull(); + expect(isPreviewContext()).toBe(false); + }); +}); + +describe('resolveModelSource', () => { + it('returns an empty string for empty or non-string input', async () => { + await expect(resolveModelSource('')).resolves.toBe(''); + await expect(resolveModelSource(null)).resolves.toBe(''); + await expect(resolveModelSource(' ')).resolves.toBe(''); + }); + + it('passes absolute and relative sources straight through', async () => { + await expect(resolveModelSource('https://example.org/a.glb')).resolves.toBe('https://example.org/a.glb'); + await expect(resolveModelSource(' content/resources/a.stl ')).resolves.toBe('content/resources/a.stl'); + }); + + it('resolves asset:// synchronously when the manager has it cached', async () => { + const manager: ExeAssetManager = { resolveAssetURLSync: () => 'blob:cached' }; + await expect(resolveModelSource('asset://a.glb', manager)).resolves.toBe('blob:cached'); + }); + + it('falls back to the async resolver', async () => { + const manager: ExeAssetManager = { + resolveAssetURLSync: () => null, + resolveAssetURL: async () => 'blob:async', + }; + await expect(resolveModelSource('asset://a.glb', manager)).resolves.toBe('blob:async'); + }); + + it('returns an empty string when there is no manager or the manager throws', async () => { + installAssetManager(null); + await expect(resolveModelSource('asset://a.glb')).resolves.toBe(''); + const manager: ExeAssetManager = { + resolveAssetURLSync: () => { + throw new Error('nope'); + }, + }; + await expect(resolveModelSource('asset://a.glb', manager)).resolves.toBe(''); + }); +}); + +describe('resolveMediaUrlSync', () => { + it('leaves non-asset URLs alone', () => { + expect(resolveMediaUrlSync('https://example.org/a.png')).toBe('https://example.org/a.png'); + expect(resolveMediaUrlSync('')).toBe(''); + expect(resolveMediaUrlSync(null)).toBe(''); + }); + + it('resolves an asset:// URL through the manager', () => { + expect(resolveMediaUrlSync('asset://a.png', { resolveAssetURLSync: () => 'blob:x' })).toBe('blob:x'); + }); + + it('returns the original when the manager is missing, empty or throwing', () => { + installAssetManager(null); + expect(resolveMediaUrlSync('asset://a.png')).toBe('asset://a.png'); + expect(resolveMediaUrlSync('asset://a.png', { resolveAssetURLSync: () => null })).toBe('asset://a.png'); + expect( + resolveMediaUrlSync('asset://a.png', { + resolveAssetURLSync: () => { + throw new Error('nope'); + }, + }), + ).toBe('asset://a.png'); + }); +}); + +describe('resolveAssetUrlAsync', () => { + it('returns null for a non-asset URL', async () => { + await expect(resolveAssetUrlAsync('https://example.org/a.glb')).resolves.toBeNull(); + }); + + it('resolves once the asset becomes available', async () => { + let calls = 0; + installAssetManager({ + resolveAssetURLSync: () => (++calls >= 2 ? 'blob:ready' : null), + }); + await expect(resolveAssetUrlAsync('asset://a.glb', 1000, 1)).resolves.toBe('blob:ready'); + }); + + it('gives up at the deadline', async () => { + installAssetManager({ resolveAssetURLSync: () => null }); + await expect(resolveAssetUrlAsync('asset://a.glb', 5, 1)).resolves.toBeNull(); + }); +}); + +describe('recoverAssetRefFromBlob', () => { + it('rebuilds `.` from the reverse blob cache and the metadata', () => { + const manager: ExeAssetManager = { + reverseBlobCache: { get: () => 'uuid-1' }, + getAssetMetadata: () => ({ filename: 'Model.GLB' }), + }; + expect(recoverAssetRefFromBlob('blob:http://x/1', manager)).toBe('uuid-1.glb'); + }); + + it('falls back to the bare id when there is no filename extension', () => { + const manager: ExeAssetManager = { + reverseBlobCache: { get: () => 'uuid-1' }, + getAssetMetadata: () => ({ filename: 'model' }), + }; + expect(recoverAssetRefFromBlob('blob:http://x/1', manager)).toBe('uuid-1'); + }); + + it('returns an empty string when recovery is impossible', () => { + expect(recoverAssetRefFromBlob('asset://a.glb', {})).toBe(''); + expect(recoverAssetRefFromBlob(null, {})).toBe(''); + expect(recoverAssetRefFromBlob('blob:http://x/1', { reverseBlobCache: { get: () => null } })).toBe(''); + }); +}); + +describe('waitForAssetManager', () => { + it('returns the manager as soon as it appears', async () => { + const manager: ExeAssetManager = {}; + setTimeout(() => installAssetManager(manager), 2); + await expect(waitForAssetManager(500, 1)).resolves.toBe(manager); + }); + + it('returns null at the deadline', async () => { + installAssetManager(null); + await expect(waitForAssetManager(5, 1)).resolves.toBeNull(); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/asset-resolver.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/asset-resolver.ts new file mode 100644 index 0000000000..888c425008 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/asset-resolver.ts @@ -0,0 +1,142 @@ +/** + * Locating the live AssetManager and turning `asset://` handles into URLs the + * browser can fetch. + * + * `asset://` is the only durable model reference: in the workarea and the + * preview it resolves to a blob URL through AssetManager, and in an exported + * package the export pipeline has already rewritten it to a + * `content/resources/...` path. Blob URLs are never persisted. + */ + +/** Read the AssetManager from this window, or from the parent for a preview iframe. */ +export function getAssetManager(): ExeAssetManager | null { + const project = globalThis.eXeLearning?.app?.project; + const local = project?.assetManager ?? project?._yjsBridge?.assetManager; + if (local) { + return local; + } + try { + const parentWindow = (globalThis as { parent?: { eXeLearning?: ExeLearningGlobal } }).parent; + const parentProject = parentWindow?.eXeLearning?.app?.project; + return parentProject?.assetManager ?? parentProject?._yjsBridge?.assetManager ?? null; + } catch { + // Cross-origin parent: this is a genuine export context, not an error. + return null; + } +} + +/** True when an AssetManager is reachable (workarea or preview, not a static export). */ +export function isPreviewContext(): boolean { + return getAssetManager() !== null; +} + +/** + * Resolve a model source to a fetchable URL. + * + * `asset://` needs an AssetManager; without one the caller falls back to the + * wrapper's already-rewritten path. Everything else passes through. + */ +export async function resolveModelSource(src: unknown, assetManager?: ExeAssetManager | null): Promise { + 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 { + // A rejected resolution means "not available"; the caller shows the + // empty state rather than a broken model. + return ''; + } +} + +/** Synchronous best effort: returns the cached blob URL, or the input unchanged. */ +export function resolveMediaUrlSync(url: unknown, assetManager?: ExeAssetManager | null): string { + 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; + } +} + +/** + * Poll AssetManager until an `asset://` handle resolves or the deadline passes. + * Used on the boot path, where the asset may still be downloading. + */ +export async function resolveAssetUrlAsync( + assetUrl: string, + timeoutMs = 10000, + pollIntervalMs = 100, +): Promise { + 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; +} + +/** + * Recover the canonical `asset://.` reference behind a blob URL. + * + * The workarea resolves `asset://` → `blob:` when it reads the iDevice JSON, so + * on re-open the stored source can arrive as an ephemeral blob URL. The reverse + * blob cache plus the asset metadata rebuild the durable handle; without them + * the source has to be dropped rather than persisted as a dead blob URL. + */ +export function recoverAssetRefFromBlob(blobUrl: unknown, assetManager?: ExeAssetManager | null): string { + 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); +} + +/** Wait for an AssetManager to appear, e.g. while the workarea is still booting. */ +export async function waitForAssetManager(timeoutMs = 5000, pollIntervalMs = 100): Promise { + 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; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/instance-registry.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/instance-registry.spec.ts new file mode 100644 index 0000000000..31e5ef6d02 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/instance-registry.spec.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createStubInstance, createWrapper, resetDom } from '../test/helpers'; +import { createRegistry } from './instance-registry'; + +afterEach(resetDom); + +describe('createRegistry', () => { + it('stores, reports and returns instances by wrapper', () => { + const registry = createRegistry(); + const wrapper = createWrapper('one'); + const instance = createStubInstance(wrapper); + expect(registry.has(wrapper)).toBe(false); + registry.set(wrapper, instance); + expect(registry.get(wrapper)).toBe(instance); + expect(registry.has(wrapper)).toBe(true); + expect(registry.wrappers()).toEqual([wrapper]); + }); + + it('destroy tears the instance down and drops it', () => { + const registry = createRegistry(); + const wrapper = createWrapper('one'); + const instance = createStubInstance(wrapper); + registry.set(wrapper, instance); + registry.destroy(wrapper); + expect(registry.get(wrapper)).toBeUndefined(); + expect(instance.stopped).toBe(true); + }); + + it('destroy on an unregistered wrapper is a safe no-op', () => { + const registry = createRegistry(); + expect(() => registry.destroy(createWrapper('ghost'))).not.toThrow(); + }); + + it('destroying one instance leaves the others intact', () => { + const registry = createRegistry(); + const first = createWrapper('one'); + const second = createWrapper('two'); + const firstInstance = createStubInstance(first); + const secondInstance = createStubInstance(second); + registry.set(first, firstInstance); + registry.set(second, secondInstance); + registry.destroy(first); + expect(registry.get(second)).toBe(secondInstance); + expect(secondInstance.stopped).toBe(false); + }); + + it('destroyAll tears every instance down, most recent first', () => { + const registry = createRegistry(); + const order: string[] = []; + for (const id of ['one', 'two', 'three']) { + const wrapper = createWrapper(id); + const instance = createStubInstance(wrapper); + instance.interaction = { + destroy: () => order.push(id), + } as unknown as typeof instance.interaction; + registry.set(wrapper, instance); + } + registry.destroyAll(); + expect(order).toEqual(['three', 'two', 'one']); + expect(registry.wrappers()).toEqual([]); + }); + + it('does not recurse when a disposer re-enters destroy for the same wrapper', () => { + const registry = createRegistry(); + const wrapper = createWrapper('one'); + const instance = createStubInstance(wrapper); + let calls = 0; + instance.interaction = { + destroy: () => { + calls += 1; + registry.destroy(wrapper); + }, + } as unknown as typeof instance.interaction; + registry.set(wrapper, instance); + registry.destroy(wrapper); + expect(calls).toBe(1); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/instance-registry.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/instance-registry.ts new file mode 100644 index 0000000000..b8f39a88df --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/instance-registry.ts @@ -0,0 +1,42 @@ +/** + * The wrapper → instance registry. + * + * Created through a factory rather than exported as a module-level singleton so + * tests get a fresh registry per case and so the runtime facade owns exactly + * one instance of it. + */ + +import { disposeInstance } from './lifecycle'; +import type { ViewerInstance, ViewerRegistry } from './types'; + +export function createRegistry(): ViewerRegistry { + const instances = new Map(); + + const destroy = (wrapper: HTMLElement): void => { + const instance = instances.get(wrapper); + if (!instance) { + return; + } + // Drop the entry first so a disposer that re-enters (an interaction + // controller closing a dialog, say) cannot recurse into this instance. + 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: () => { + // Reverse insertion order: the most recently booted viewer is the + // most likely to still hold a live WebGL context. + for (const wrapper of [...instances.keys()].reverse()) { + destroy(wrapper); + } + }, + wrappers: () => [...instances.keys()], + }; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/lifecycle.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/lifecycle.spec.ts new file mode 100644 index 0000000000..6947155f23 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/lifecycle.spec.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStubInstance, createWrapper, resetDom } from '../test/helpers'; +import { installThreeStub, StubObject3D } from '../test/three-stub'; +import { + addFrameCallback, + createInstance, + disposeInstance, + disposeMaterial, + disposeObject3D, + removeFrameCallback, + trackListener, +} from './lifecycle'; +import type { ViewerOptions } from './types'; + +const OPTIONS: ViewerOptions = { + src: 'asset://a.stl', + type: 'stl', + modelColor: '#888888', + backgroundColor: '#f5f5f5', + cameraControls: true, + autoRotate: false, + autoRotateSpeed: 30, +}; + +let restoreThree: () => void; + +beforeEach(() => { + restoreThree = installThreeStub(); +}); + +afterEach(() => { + restoreThree(); + resetDom(); + vi.restoreAllMocks(); +}); + +describe('createInstance', () => { + it('derives the type from the source when none is given', () => { + const wrapper = createWrapper(); + const instance = createInstance(wrapper, { ...OPTIONS, type: '', src: 'a.glb' }); + expect(instance.type).toBe('glb'); + }); + + it('starts with empty resource collections', () => { + const instance = createInstance(createWrapper(), OPTIONS); + expect(instance.listeners).toEqual([]); + expect(instance.onFrame).toEqual([]); + expect(instance.objectURLs).toEqual([]); + expect(instance.interaction).toBeNull(); + }); +}); + +describe('frame callbacks', () => { + it('registers a callback once and removes it again', () => { + const instance = createInstance(createWrapper(), OPTIONS); + const callback = (): void => {}; + addFrameCallback(instance, callback); + addFrameCallback(instance, callback); + expect(instance.onFrame).toHaveLength(1); + removeFrameCallback(instance, callback); + expect(instance.onFrame).toHaveLength(0); + // Removing an unregistered callback is a no-op. + removeFrameCallback(instance, callback); + expect(instance.onFrame).toHaveLength(0); + }); +}); + +describe('disposeMaterial', () => { + it('tolerates nullish input', () => { + expect(() => disposeMaterial(null)).not.toThrow(); + expect(() => disposeMaterial(undefined)).not.toThrow(); + }); + + it('disposes the material and its texture-shaped fields', () => { + const texture = { isTexture: true, dispose: vi.fn() }; + const material = { map: texture, notATexture: { dispose: vi.fn() }, dispose: vi.fn() }; + disposeMaterial(material); + expect(texture.dispose).toHaveBeenCalledTimes(1); + expect(material.dispose).toHaveBeenCalledTimes(1); + expect(material.notATexture.dispose).not.toHaveBeenCalled(); + }); + + it('handles arrays of materials', () => { + const first = { dispose: vi.fn() }; + const second = { dispose: vi.fn() }; + disposeMaterial([first, null, second]); + expect(first.dispose).toHaveBeenCalledTimes(1); + expect(second.dispose).toHaveBeenCalledTimes(1); + }); +}); + +describe('disposeObject3D', () => { + it('is a no-op for values without traverse', () => { + expect(() => disposeObject3D(null)).not.toThrow(); + expect(() => disposeObject3D({})).not.toThrow(); + }); + + it('disposes geometries and materials across the subtree', () => { + const root = new StubObject3D(); + const child = new StubObject3D(); + root.children.push(child); + const geometry = { dispose: vi.fn() }; + const material = { dispose: vi.fn() }; + child.geometry = geometry; + child.material = material; + disposeObject3D(root); + expect(geometry.dispose).toHaveBeenCalledTimes(1); + expect(material.dispose).toHaveBeenCalledTimes(1); + }); +}); + +describe('disposeInstance', () => { + it('cancels the animation frame, removes listeners and drops the interaction layer', () => { + const wrapper = createWrapper(); + const instance = createStubInstance(wrapper); + const cancel = vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {}); + const handler = vi.fn(); + const target = document.createElement('button'); + trackListener(instance, target, 'click', handler); + instance.rafId = 7; + const destroy = vi.fn(); + instance.interaction = { destroy } as unknown as typeof instance.interaction; + + disposeInstance(instance); + + expect(destroy).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith(7); + expect(instance.rafId).toBeNull(); + expect(instance.listeners).toEqual([]); + target.dispatchEvent(new Event('click')); + expect(handler).not.toHaveBeenCalled(); + expect(instance.stopped).toBe(true); + }); + + it('disposes GPU resources and revokes the object URLs it tracked', () => { + const instance = createStubInstance(createWrapper()); + const geometry = { dispose: vi.fn() }; + const controls = { dispose: vi.fn(), target: { x: 0, y: 0, z: 0 } }; + const renderer = { dispose: vi.fn() }; + instance.geometry = geometry as unknown as ThreeGeometry; + instance.material = { dispose: vi.fn() }; + instance.controls = controls as unknown as ThreeOrbitControls; + instance.renderer = renderer as unknown as ThreeRenderer; + instance.objectURLs.push('blob:one', 'blob:two'); + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + disposeInstance(instance); + + expect(geometry.dispose).toHaveBeenCalledTimes(1); + expect(controls.dispose).toHaveBeenCalledTimes(1); + expect(renderer.dispose).toHaveBeenCalledTimes(1); + expect(revoke).toHaveBeenCalledTimes(2); + expect(instance.objectURLs).toEqual([]); + expect(instance.renderer).toBeNull(); + }); + + it('survives a disposer that throws', () => { + const instance = createStubInstance(createWrapper()); + instance.interaction = { + destroy: () => { + throw new Error('boom'); + }, + } as unknown as typeof instance.interaction; + instance.renderer = { + dispose: () => { + throw new Error('context lost'); + }, + } as unknown as ThreeRenderer; + expect(() => disposeInstance(instance)).not.toThrow(); + expect(instance.interaction).toBeNull(); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/lifecycle.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/lifecycle.ts new file mode 100644 index 0000000000..19db56cbd0 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/lifecycle.ts @@ -0,0 +1,213 @@ +/** + * Instance construction and teardown. + * + * Resource ownership is explicit: whatever `createInstance` and the boot path + * allocate, `disposeInstance` releases — listeners, animation frames, Three.js + * geometries/materials/textures, controls, the WebGL renderer, generated object + * URLs and the interaction layer. + */ + +import { detectModelType } from '../shared/model-source'; +import type { FrameCallback, ViewerInstance, ViewerOptions } from './types'; + +export function createInstance(wrapper: HTMLElement, options: ViewerOptions): ViewerInstance { + 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, + }; +} + +/** Add a listener and remember it so `disposeInstance` can remove it. */ +export function trackListener( + instance: ViewerInstance, + target: EventTarget, + type: string, + handler: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, +): void { + target.addEventListener(type, handler, options); + instance.listeners.push({ target, type, handler, options }); +} + +/** Register a per-frame callback exactly once. */ +export function addFrameCallback(instance: ViewerInstance, callback: FrameCallback): void { + if (!instance.onFrame.includes(callback)) { + instance.onFrame.push(callback); + } +} + +/** Remove a previously registered per-frame callback. */ +export function removeFrameCallback(instance: ViewerInstance, callback: FrameCallback): void { + const index = instance.onFrame.indexOf(callback); + if (index !== -1) { + instance.onFrame.splice(index, 1); + } +} + +function isTexture(value: unknown): value is { dispose: () => void } { + return Boolean( + value && + typeof value === 'object' && + (value as { isTexture?: unknown }).isTexture && + typeof (value as { dispose?: unknown }).dispose === 'function', + ); +} + +/** Dispose every texture-shaped field of a material, then the material itself. */ +export function disposeMaterial(material: unknown): void { + if (!material) { + return; + } + const list = Array.isArray(material) ? material : [material]; + for (const entry of list) { + if (!entry || typeof entry !== 'object') { + continue; + } + const record = entry as Record; + for (const key of Object.keys(record)) { + const value = record[key]; + if (isTexture(value)) { + value.dispose(); + } + } + const dispose = (entry as { dispose?: unknown }).dispose; + if (typeof dispose === 'function') { + dispose.call(entry); + } + } +} + +/** Traverse an Object3D subtree, disposing each node's geometry and material. */ +export function disposeObject3D(object: unknown): void { + const traverse = (object as { traverse?: unknown } | null)?.traverse; + if (typeof traverse !== 'function') { + return; + } + (object as ThreeObject3D).traverse(node => { + if (node?.geometry && typeof node.geometry.dispose === 'function') { + node.geometry.dispose(); + } + if (node?.material) { + disposeMaterial(node.material); + } + }); +} + +function cancelFrame(rafId: number): void { + if (typeof globalThis.cancelAnimationFrame === 'function') { + globalThis.cancelAnimationFrame(rafId); + } else { + clearTimeout(rafId); + } +} + +/** + * Release everything an instance owns. Safe to call more than once and safe on + * a half-booted instance: each step is guarded, because a viewer can be torn + * down while its STL fetch is still in flight. + */ +export function disposeInstance(instance: ViewerInstance): void { + instance.stopped = true; + + // The interaction layer goes first: it removes marker overlays, the dialog, + // its listeners and its per-frame callback before the scene disappears. + if (instance.interaction) { + try { + instance.interaction.destroy(); + } catch { + // A broken controller must not block the rest of the teardown. + } + 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 { + // Detached nodes can throw; nothing left to remove either way. + } + } + instance.listeners.length = 0; + + try { + disposeObject3D(instance.scene); + } catch { + // Partially built scenes may hold nodes Three.js cannot traverse. + } + try { + disposeMaterial(instance.material); + } catch { + // Already-disposed materials throw on a second dispose. + } + try { + instance.geometry?.dispose?.(); + } catch { + // Same as above. + } + try { + instance.controls?.dispose?.(); + } catch { + // OrbitControls throws when its DOM element is already gone. + } + try { + instance.renderer?.dispose?.(); + } catch { + // Losing the WebGL context first makes dispose throw. + } + + for (const url of instance.objectURLs) { + try { + URL.revokeObjectURL(url); + } catch { + // Revoking twice is harmless but throws in some engines. + } + } + instance.objectURLs.length = 0; + + // The runtime created this canvas, so the runtime removes it. Leaving it + // behind would cover the sibling when the author switches an + // STL model for a GLB one. + try { + instance.canvas?.remove(); + } catch { + // Already detached. + } + instance.canvas = null; + + // The is NOT ours — the editor and the export markup own it. + // Un-hide it so the GLB path can take over the wrapper again. + if (instance.modelViewer) { + instance.modelViewer.style.display = ''; + instance.modelViewer = null; + } + + instance.scene = null; + instance.camera = null; + instance.renderer = null; + instance.controls = null; + instance.mesh = null; + instance.geometry = null; + instance.material = null; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/model-viewer-loader.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/model-viewer-loader.spec.ts new file mode 100644 index 0000000000..2ecd9ef279 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/model-viewer-loader.spec.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetDom } from '../test/helpers'; +import { ensureModelViewerLoaded, isModelViewerDefined } from './model-viewer-loader'; + +/** + * `customElements.define` cannot be undone, so the tests drive the loader + * through a stubbed registry rather than the real one. + */ +function stubRegistry(defined: boolean, whenDefined?: () => Promise): void { + vi.stubGlobal('customElements', { + get: () => (defined ? class {} : undefined), + define: () => {}, + whenDefined: whenDefined ?? (() => Promise.resolve()), + }); +} + +beforeEach(() => { + globalThis.$exeLibs = undefined; + document.head.innerHTML = ''; +}); + +afterEach(() => { + globalThis.$exeLibs = undefined; + document.head.innerHTML = ''; + vi.unstubAllGlobals(); + resetDom(); + vi.restoreAllMocks(); +}); + +describe('isModelViewerDefined', () => { + it('reflects the custom-element registry', () => { + stubRegistry(true); + expect(isModelViewerDefined()).toBe(true); + stubRegistry(false); + expect(isModelViewerDefined()).toBe(false); + }); +}); + +describe('ensureModelViewerLoaded', () => { + it('returns immediately when the element is already defined', async () => { + stubRegistry(true); + await ensureModelViewerLoaded(['a.js'], 'export'); + expect(document.head.querySelectorAll('script')).toHaveLength(0); + }); + + it('injects the script and marks its origin', async () => { + stubRegistry(false); + const promise = ensureModelViewerLoaded(['lib.js'], 'edition'); + const script = document.head.querySelector('script'); + expect(script?.getAttribute('src')).toBe('lib.js'); + expect(script?.getAttribute('data-threedviewer-lib')).toBe('edition'); + script?.dispatchEvent(new Event('load')); + await promise; + }); + + it('tries the next candidate when one fails, and skips falsy entries', async () => { + stubRegistry(false); + const promise = ensureModelViewerLoaded(['first.js', '', 'second.js'], 'export'); + const first = document.head.querySelector('script'); + first?.dispatchEvent(new Event('error')); + await Promise.resolve(); + await Promise.resolve(); + const scripts = document.head.querySelectorAll('script'); + expect(scripts).toHaveLength(2); + scripts[1]?.dispatchEvent(new Event('load')); + await promise; + }); + + it('stops injecting once the element registers', async () => { + let defined = false; + vi.stubGlobal('customElements', { + get: () => (defined ? class {} : undefined), + whenDefined: () => Promise.resolve(), + }); + const promise = ensureModelViewerLoaded(['first.js', 'second.js'], 'export'); + defined = true; + document.head.querySelector('script')?.dispatchEvent(new Event('load')); + await promise; + expect(document.head.querySelectorAll('script')).toHaveLength(1); + }); + + it('waits for an in-flight load started by the other bundle', async () => { + stubRegistry(false); + let resolveShared: () => void = () => {}; + globalThis.$exeLibs = { + modelViewerPromise: new Promise(resolve => { + resolveShared = resolve; + }), + }; + let settled = false; + const promise = ensureModelViewerLoaded(['lib.js'], 'export').then(() => { + settled = true; + }); + expect(document.head.querySelectorAll('script')).toHaveLength(0); + resolveShared(); + await promise; + expect(settled).toBe(true); + }); + + it('does not inject a second script when one is already on the page', async () => { + stubRegistry(false); + const existing = document.createElement('script'); + existing.setAttribute('data-threedviewer-lib', 'edition'); + document.head.appendChild(existing); + await ensureModelViewerLoaded(['lib.js'], 'export'); + expect(document.head.querySelectorAll('script')).toHaveLength(1); + }); + + it('gives up after the definition timeout instead of hanging forever', async () => { + vi.useFakeTimers(); + try { + // A library that never registers would leave `whenDefined` pending, + // which must not block the STL path that needs no model-viewer. + stubRegistry(false, () => new Promise(() => {})); + let settled = false; + const promise = ensureModelViewerLoaded([], 'export').then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(15000); + await promise; + expect(settled).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it('survives a rejecting whenDefined', async () => { + stubRegistry(false, () => Promise.reject(new Error('nope'))); + await expect(ensureModelViewerLoaded([], 'export')).resolves.toBeUndefined(); + }); + + it('logs when a candidate fails to load', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + stubRegistry(false); + const promise = ensureModelViewerLoaded(['broken.js'], 'export'); + document.head.querySelector('script')?.dispatchEvent(new Event('error')); + await promise; + expect(error).toHaveBeenCalled(); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/model-viewer-loader.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/model-viewer-loader.ts new file mode 100644 index 0000000000..82d127058e --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/model-viewer-loader.ts @@ -0,0 +1,87 @@ +/** + * Lazy loading of the `` custom element (the GLB/GLTF path). + * + * The element is registered once per page. Both bundles coordinate through + * `window.$exeLibs.modelViewerPromise`, so an editor preview and an exported + * viewer on the same document never inject the script twice. + */ + +const SCRIPT_MARKER = 'data-threedviewer-lib'; + +/** + * How long to wait for the custom element to register before giving up. + * `customElements.whenDefined()` never settles for an element that fails to + * load, and an unresolved promise here would also block the STL render path, + * which does not need model-viewer at all. + */ +const DEFINITION_TIMEOUT_MS = 15000; + +function libs(): Record { + globalThis.$exeLibs = globalThis.$exeLibs ?? {}; + return globalThis.$exeLibs; +} + +/** True once the custom element is defined. */ +export function isModelViewerDefined(): boolean { + return Boolean(globalThis.customElements?.get?.('model-viewer')); +} + +function injectScript(url: string, origin: string): Promise { + 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 rather than reject: callers boot anyway and degrade to the + // empty state instead of leaving a pending promise behind. + resolve(); + }); + document.head.appendChild(script); + }); +} + +/** + * Ensure `` is defined, trying each candidate URL in order. + * Always resolves — a missing library shows the empty state, never an unhandled + * rejection in the middle of the workarea. + */ +export async function ensureModelViewerLoaded(candidates: readonly string[], origin: 'edition' | 'export'): Promise { + 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 { + // Never registered: the caller shows the empty state. + } + } + })(); + shared.modelViewerPromise = loading; + await loading; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/paths.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/paths.spec.ts new file mode 100644 index 0000000000..8e45b30059 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/paths.spec.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + detectMode, + getEditionLibBaseUrl, + getEditionModelViewerUrl, + getExportLibBaseUrl, + getExportModelViewerUrl, + getIdeviceResourcesBase, + isStaticMode, + LIB_RELATIVE_PATH, + parseRuntimeConfig, + resolveAppUrl, +} from './paths'; + +const ORIGIN = globalThis.location?.origin ?? 'http://localhost:3000'; + +beforeEach(() => { + globalThis.eXeLearning = undefined; + document.documentElement.id = ''; +}); + +afterEach(() => { + globalThis.eXeLearning = undefined; + document.documentElement.id = ''; +}); + +describe('parseRuntimeConfig / isStaticMode', () => { + it('reads a plain object config', () => { + globalThis.eXeLearning = { config: { isStaticMode: true } }; + expect(parseRuntimeConfig()).toEqual({ isStaticMode: true }); + expect(isStaticMode()).toBe(true); + }); + + it('parses a JSON string config', () => { + globalThis.eXeLearning = { config: '{"isOfflineInstallation":true}' }; + expect(isStaticMode()).toBe(true); + }); + + it('degrades to null on invalid JSON', () => { + globalThis.eXeLearning = { config: '{not json' }; + expect(parseRuntimeConfig()).toBeNull(); + expect(isStaticMode()).toBe(false); + }); + + it('is false when no config is present', () => { + expect(isStaticMode()).toBe(false); + }); +}); + +describe('detectMode', () => { + it('detects server mode from a defined baseURL', () => { + globalThis.eXeLearning = { config: { baseURL: 'https://host' } }; + expect(detectMode().isServerMode).toBe(true); + }); + + it('detects export mode and the index page from the root element id', () => { + document.documentElement.id = 'exe-index'; + expect(detectMode()).toMatchObject({ isExportMode: true, isOnIndexPage: true }); + document.documentElement.id = 'exe-page-3'; + expect(detectMode()).toMatchObject({ isExportMode: true, isOnIndexPage: false }); + }); + + it('reports neither export nor static for a plain page', () => { + expect(detectMode()).toMatchObject({ isExportMode: false, isStaticMode: false, isOnIndexPage: false }); + }); +}); + +describe('resolveAppUrl', () => { + it('joins the symfony base URL and base path', () => { + globalThis.eXeLearning = { symfony: { baseURL: 'https://host/', basePath: '/app/' } }; + expect(resolveAppUrl('files/a.js')).toBe('https://host/app/files/a.js'); + }); + + it('returns a rooted path when there is no symfony config', () => { + expect(resolveAppUrl('files/a.js')).toBe('/files/a.js'); + }); +}); + +describe('getIdeviceResourcesBase', () => { + it('is relative to index.html on the index page and one level up elsewhere', () => { + document.documentElement.id = 'exe-index'; + expect(getIdeviceResourcesBase('id1')).toBe('content/resources/id1/'); + document.documentElement.id = 'exe-page'; + expect(getIdeviceResourcesBase('id1')).toBe('../content/resources/id1/'); + }); + + it('is empty without an iDevice id', () => { + expect(getIdeviceResourcesBase('')).toBe(''); + }); +}); + +describe('getEditionLibBaseUrl', () => { + it('is always absolute, because dynamic import() resolves against the module', () => { + expect(getEditionLibBaseUrl().startsWith('http')).toBe(true); + }); + + it('includes the base URL and base path', () => { + globalThis.eXeLearning = { symfony: { baseURL: 'https://host', basePath: 'app' } }; + expect(getEditionLibBaseUrl()).toBe(`https://host/app/${LIB_RELATIVE_PATH}`); + }); + + it('prepends the origin for a relative base URL', () => { + globalThis.eXeLearning = { symfony: { baseURL: '/sub' } }; + expect(getEditionLibBaseUrl()).toBe(`${ORIGIN}/sub/${LIB_RELATIVE_PATH}`); + }); + + it('drops the base path in static mode to avoid duplicating the deploy prefix', () => { + globalThis.eXeLearning = { config: { isStaticMode: true }, symfony: { basePath: 'pr-preview/pr-1' } }; + expect(getEditionLibBaseUrl()).toBe(`${ORIGIN}/${LIB_RELATIVE_PATH}`); + }); +}); + +describe('getExportLibBaseUrl', () => { + it('uses the origin in static mode', () => { + globalThis.eXeLearning = { config: { isStaticMode: true } }; + expect(getExportLibBaseUrl()).toBe(`${ORIGIN}/${LIB_RELATIVE_PATH}`); + }); + + it('uses the runtime config in server mode', () => { + globalThis.eXeLearning = { config: { baseURL: 'https://host', basePath: 'app' } }; + expect(getExportLibBaseUrl()).toBe(`https://host/app/${LIB_RELATIVE_PATH}`); + }); + + it('resolves relative to the page in export mode', () => { + document.documentElement.id = 'exe-index'; + expect(getExportLibBaseUrl().endsWith('idevices/three-d-viewer/')).toBe(true); + expect(getExportLibBaseUrl()).not.toContain('../'); + document.documentElement.id = 'exe-page'; + expect(getExportLibBaseUrl()).toContain('../idevices/three-d-viewer/'); + }); + + it('falls back to the symfony config', () => { + globalThis.eXeLearning = { symfony: { baseURL: 'https://host' } }; + expect(getExportLibBaseUrl()).toBe(`https://host/${LIB_RELATIVE_PATH}`); + }); +}); + +describe('model-viewer URLs', () => { + it('uses a document-relative path in static mode', () => { + globalThis.eXeLearning = { config: { isStaticMode: true } }; + expect(getEditionModelViewerUrl()).toBe(`./${LIB_RELATIVE_PATH}model-viewer.min.js`); + expect(getExportModelViewerUrl()).toBe(`./${LIB_RELATIVE_PATH}model-viewer.min.js`); + }); + + it('uses the app URL in server mode and as the fallback', () => { + globalThis.eXeLearning = { config: { baseURL: 'https://host' }, symfony: { baseURL: 'https://host' } }; + expect(getExportModelViewerUrl()).toBe(`https://host/${LIB_RELATIVE_PATH}model-viewer.min.js`); + globalThis.eXeLearning = { symfony: { baseURL: 'https://host' } }; + expect(getEditionModelViewerUrl()).toBe(`https://host/${LIB_RELATIVE_PATH}model-viewer.min.js`); + expect(getExportModelViewerUrl()).toBe(`https://host/${LIB_RELATIVE_PATH}model-viewer.min.js`); + }); + + it('uses the packaged path in export mode', () => { + document.documentElement.id = 'exe-index'; + expect(getExportModelViewerUrl()).toBe('./idevices/three-d-viewer/model-viewer.min.js'); + document.documentElement.id = 'exe-page'; + expect(getExportModelViewerUrl()).toBe('../idevices/three-d-viewer/model-viewer.min.js'); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/paths.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/paths.ts new file mode 100644 index 0000000000..9d89330707 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/paths.ts @@ -0,0 +1,160 @@ +/** + * Where the vendored libraries live, per execution context. + * + * Three.js, STLLoader, OrbitControls and model-viewer are shipped once under + * `export/` and reused by the editor and by every exported package, so the only + * thing that varies is the prefix. Four contexts need different prefixes: + * + * static PWA/offline build served from the deploy root + * server running against an eXeLearning server (possibly in a subdirectory) + * export a standalone HTML package (index.html, or html/.html) + * preview the workarea preview, which still has the symfony config + * + * Dynamic `import()` resolves relative specifiers against the *importing + * module*, so the Three.js base URL is always absolute — a relative one would + * be re-prefixed with the bundle's own directory. + */ + +import { joinAppUrl } from '../shared/urls'; + +/** Path of the shared library directory relative to the application root. */ +export const LIB_RELATIVE_PATH = 'files/perm/idevices/base/three-d-viewer/export/'; + +/** Path of the library directory inside an exported package, from index.html. */ +const EXPORT_LIB_PATH = 'idevices/three-d-viewer/'; + +interface RuntimeConfig { + isStaticMode?: boolean; + isOfflineInstallation?: boolean; + baseURL?: string; + basePath?: string; +} + +/** `eXeLearning.config`, parsed when it arrives as a JSON string. */ +export function parseRuntimeConfig(): RuntimeConfig | null { + const config = globalThis.eXeLearning?.config; + if (typeof config !== 'string') { + return config ?? null; + } + try { + return JSON.parse(config) as RuntimeConfig; + } catch { + return null; + } +} + +/** True for the PWA/offline build, where paths must not repeat the base path. */ +export function isStaticMode(): boolean { + const config = parseRuntimeConfig(); + return Boolean(config?.isStaticMode || config?.isOfflineInstallation); +} + +export interface ExecutionMode { + isStaticMode: boolean; + isServerMode: boolean; + isExportMode: boolean; + isOnIndexPage: boolean; +} + +/** + * Classify the current page. Exported HTML sets the root element id to + * `exe-index` (or `exe-`), which is what distinguishes a static package + * from a server-rendered page. + */ +export function detectMode(): ExecutionMode { + 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, + }; +} + +/** Build an application URL from the symfony base URL/path. */ +export function resolveAppUrl(path: string): string { + const symfony = globalThis.eXeLearning?.symfony ?? {}; + return joinAppUrl(symfony.baseURL, symfony.basePath, path); +} + +/** The `content/resources//` prefix used by offline packages. */ +export function getIdeviceResourcesBase(ideviceId: string): string { + if (!ideviceId) { + return ''; + } + const onIndex = typeof document !== 'undefined' && document.documentElement.id === 'exe-index'; + return onIndex ? `content/resources/${ideviceId}/` : `../content/resources/${ideviceId}/`; +} + +function withOrigin(url: string): string { + if (/^https?:\/\//i.test(url)) { + return url; + } + const origin = globalThis.location?.origin ?? ''; + return origin + (url.startsWith('/') ? '' : '/') + url; +} + +/** + * Absolute base URL of the shared library directory, as seen from the workarea + * editor. Static mode drops the base path, which the deploy URL already carries. + */ +export function getEditionLibBaseUrl(): string { + 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}`); +} + +/** Absolute base URL of the shared library directory, as seen from an export. */ +export function getExportLibBaseUrl(): string { + const mode = detectMode(); + if (mode.isStaticMode) { + return `${globalThis.location?.origin ?? ''}/${LIB_RELATIVE_PATH}`; + } + if (mode.isServerMode) { + const config = parseRuntimeConfig(); + const baseURL = String(config?.baseURL || globalThis.location?.origin || '').replace(/\/+$/g, ''); + const basePath = config?.basePath ? `/${config.basePath.replace(/^\/+|\/+$/g, '')}` : ''; + return `${baseURL}${basePath}/${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}`; +} + +/** URL of the model-viewer bundle, as seen from the workarea editor. */ +export function getEditionModelViewerUrl(): string { + const path = `${LIB_RELATIVE_PATH}model-viewer.min.js`; + // Static mode resolves `./files/...` against the document, which already + // carries the deploy prefix; prepending the base path would duplicate it. + return isStaticMode() ? `./${path}` : resolveAppUrl(path); +} + +/** URL of the model-viewer bundle, as seen from an exported package. */ +export function getExportModelViewerUrl(): string { + 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); +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/stl-renderer.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/stl-renderer.spec.ts new file mode 100644 index 0000000000..c4fdb0c56e --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/stl-renderer.spec.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createWrapper, resetDom } from '../test/helpers'; +import { createModelViewerStub } from '../test/model-viewer-stub'; +import { createThreeStub, installThreeStub, StubVector3 } from '../test/three-stub'; +import { createInstance } from './lifecycle'; +import { bootStl, configureRendererColorManagement } from './stl-renderer'; +import type { ViewerInstance, ViewerOptions } from './types'; + +const OPTIONS: ViewerOptions = { + src: 'content/resources/a.stl', + type: 'stl', + modelColor: '#3325f4', + backgroundColor: '#ffffff', + cameraControls: true, + autoRotate: false, + autoRotateSpeed: 30, +}; + +let restoreThree: () => void; +let three: ThreeNamespace; + +function stubGeometry(): ThreeGeometry { + return { + boundingBox: { getSize: (target: ThreeVector3) => target.set(4, 2, 2) }, + computeBoundingBox: vi.fn(), + center: vi.fn(), + scale: vi.fn(), + hasAttribute: () => false, + computeVertexNormals: vi.fn(), + dispose: vi.fn(), + }; +} + +function stubFetch(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })), + ); +} + +function makeInstance(wrapper: HTMLElement, overrides: Partial = {}): ViewerInstance { + return createInstance(wrapper, { ...OPTIONS, ...overrides }); +} + +beforeEach(() => { + three = createThreeStub(); + three.STLLoader = class { + parse(): ThreeGeometry { + return stubGeometry(); + } + } as unknown as ThreeNamespace['STLLoader']; + three.OrbitControls = class { + target = new StubVector3(); + enableDamping = false; + dampingFactor = 0; + update = vi.fn(); + dispose = vi.fn(); + } as unknown as ThreeNamespace['OrbitControls']; + restoreThree = installThreeStub(three); + // Run the animation loop exactly once per boot so tests stay deterministic. + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); + stubFetch(); +}); + +afterEach(() => { + restoreThree(); + vi.unstubAllGlobals(); + resetDom(); + vi.restoreAllMocks(); +}); + +describe('configureRendererColorManagement', () => { + it('enables sRGB output on a modern renderer', () => { + const renderer = { outputColorSpace: undefined, toneMapping: undefined } as unknown as ThreeRenderer; + configureRendererColorManagement(renderer); + expect(renderer.outputColorSpace).toBe('srgb'); + expect(renderer.toneMapping).toBe(0); + expect(three.ColorManagement?.enabled).toBe(true); + }); + + it('falls back to outputEncoding on a pre-r150 renderer', () => { + three.SRGBColorSpace = undefined; + three.sRGBEncoding = 'srgb-encoding'; + const renderer = { outputEncoding: undefined } as unknown as ThreeRenderer; + configureRendererColorManagement(renderer); + expect(renderer.outputEncoding).toBe('srgb-encoding'); + }); + + it('is a no-op without a renderer or without THREE', () => { + expect(() => configureRendererColorManagement(null)).not.toThrow(); + restoreThree(); + expect(() => configureRendererColorManagement({} as ThreeRenderer)).not.toThrow(); + restoreThree = installThreeStub(three); + }); +}); + +describe('bootStl', () => { + it('builds the scene, the mesh and the controls, and starts the loop', async () => { + const wrapper = createWrapper(); + const instance = makeInstance(wrapper); + await bootStl(instance); + + expect(wrapper.querySelector('canvas.three-js-canvas')).not.toBeNull(); + expect(instance.scene).not.toBeNull(); + expect(instance.camera).not.toBeNull(); + expect(instance.renderer).not.toBeNull(); + expect(instance.mesh).not.toBeNull(); + expect(instance.material).not.toBeNull(); + expect(instance.controls).not.toBeNull(); + expect(instance.rafId).toBe(1); + }); + + it('normalizes the mesh to a two-unit box', async () => { + const geometry = stubGeometry(); + three.STLLoader = class { + parse(): ThreeGeometry { + return geometry; + } + } as unknown as ThreeNamespace['STLLoader']; + const instance = makeInstance(createWrapper()); + await bootStl(instance); + expect(geometry.center).toHaveBeenCalled(); + // The longest dimension is 4, so the scale factor is 2/4. + expect(geometry.scale).toHaveBeenCalledWith(0.5, 0.5, 0.5); + expect(geometry.computeVertexNormals).toHaveBeenCalled(); + }); + + it('uses a purely diffuse material so the author colour survives', async () => { + const created: Array> = []; + three.MeshStandardMaterial = class { + constructor(params: Record) { + created.push(params); + } + } as unknown as ThreeNamespace['MeshStandardMaterial']; + await bootStl(makeInstance(createWrapper())); + expect(created[0]).toMatchObject({ metalness: 0, roughness: 0.55 }); + }); + + it('skips the OrbitControls when camera controls are off', async () => { + const instance = makeInstance(createWrapper(), { cameraControls: false }); + await bootStl(instance); + expect(instance.controls).toBeNull(); + }); + + it('hides a sibling and the empty-state overlay', async () => { + const wrapper = createWrapper(); + wrapper.innerHTML = '
'; + const modelViewer = createModelViewerStub(wrapper); + const instance = makeInstance(wrapper); + await bootStl(instance); + expect(modelViewer.style.display).toBe('none'); + expect(wrapper.querySelector('[data-empty]')?.style.display).toBe('none'); + expect(instance.modelViewer).toBe(modelViewer); + }); + + it('reuses an existing canvas instead of stacking them', async () => { + const wrapper = createWrapper(); + await bootStl(makeInstance(wrapper)); + await bootStl(makeInstance(wrapper)); + expect(wrapper.querySelectorAll('canvas.three-js-canvas')).toHaveLength(1); + }); + + it('runs the per-frame callbacks before rendering, and survives one throwing', async () => { + const wrapper = createWrapper(); + const instance = makeInstance(wrapper); + const good = vi.fn(); + instance.onFrame.push(() => { + throw new Error('overlay broken'); + }, good); + await bootStl(instance); + expect(good).toHaveBeenCalled(); + }); + + it('rotates the mesh when auto-rotation is on', async () => { + const instance = makeInstance(createWrapper(), { autoRotate: true, autoRotateSpeed: 60 }); + await bootStl(instance); + expect(instance.mesh?.rotation.y).toBeGreaterThan(0); + }); + + it('does nothing without THREE, without STLLoader or on a stopped instance', async () => { + const stopped = makeInstance(createWrapper('a')); + stopped.stopped = true; + await bootStl(stopped); + expect(stopped.canvas).toBeNull(); + + three.STLLoader = undefined; + const noLoader = makeInstance(createWrapper('b')); + await bootStl(noLoader); + expect(noLoader.canvas).toBeNull(); + }); + + it('does nothing when the source cannot be resolved', async () => { + const instance = makeInstance(createWrapper(), { src: 'asset://missing.stl' }); + await bootStl(instance); + expect(instance.canvas).toBeNull(); + }); + + it('abandons the boot when the instance is destroyed mid-fetch', async () => { + const instance = makeInstance(createWrapper()); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + instance.stopped = true; + return { arrayBuffer: async () => new ArrayBuffer(8) }; + }), + ); + await bootStl(instance); + expect(instance.mesh).toBeNull(); + }); + + it('logs and degrades when the fetch or the parse fails', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('network down'); + }), + ); + const instance = makeInstance(createWrapper()); + await bootStl(instance); + expect(error).toHaveBeenCalled(); + expect(instance.mesh).toBeNull(); + // The scene still exists, so teardown has something to dispose. + expect(instance.renderer).not.toBeNull(); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/stl-renderer.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/stl-renderer.ts new file mode 100644 index 0000000000..cac53b367d --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/stl-renderer.ts @@ -0,0 +1,188 @@ +/** + * The STL render path: a Three.js scene owned by one viewer instance. + * + * The mesh is centred and scaled to fit a 2-unit box, so marker anchors are + * stored in that normalized model space and stay valid across camera moves and + * auto-rotation. + */ + +import { DEFAULT_BACKGROUND_COLOR, DEFAULT_MODEL_COLOR, normalizeColor } from '../shared/colors'; +import { resolveModelSource } from './asset-resolver'; +import type { ViewerInstance } from './types'; + +/** Target size of the longest model dimension after normalization. */ +const NORMALIZED_SIZE = 2; + +/** + * Enable sRGB output and linear colour management, across Three.js r150+ + * (`outputColorSpace`) and earlier (`outputEncoding`). + */ +export function configureRendererColorManagement(renderer: ThreeRenderer | null): void { + 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: HTMLElement): HTMLCanvasElement { + 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: () => void): number { + const raf = globalThis.requestAnimationFrame; + return typeof raf === 'function' ? raf(callback) : (setTimeout(callback, 16) as unknown as number); +} + +/** + * Build the Three.js scene for an instance and start its animation loop. + * + * Idempotent per instance and safe to abandon: every await re-checks + * `instance.stopped`, so tearing a viewer down mid-fetch leaves nothing behind. + */ +export async function bootStl(instance: ViewerInstance): Promise { + 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; + + // A sibling would still claim layout and might try to fetch + // the STL through its GLB loader; hide it while the Three.js scene renders. + 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 as unknown as ThreeObject3D; + instance.camera = camera; + instance.renderer = renderer; + + scene.add(new three.AmbientLight(0xffffff, 0.6)); + const keyLight = new three.DirectionalLight(0xffffff, 0.8); + keyLight.position.set(1, 1, 1); + scene.add(keyLight); + const fillLight = new three.DirectionalLight(0xffffff, 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(); + } + + // Pure diffuse: with any metallic component and no environment map the + // material reflects an empty scene (≈ black) and swallows the author's + // colour entirely. + 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: ThreeOrbitControls | null = 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 = (): void => { + if (instance.stopped || !instance.renderer || !instance.scene || !instance.camera) { + return; + } + if (autoRotate && instance.mesh) { + instance.mesh.rotation.y += radiansPerSecond / 60; + } + instance.controls?.update?.(); + // Marker reprojection and any other per-frame work run here, before + // the render, so there is only ever one animation loop per viewer. + for (const callback of instance.onFrame) { + try { + callback(); + } catch { + // One broken overlay must not stop the render loop. + } + } + 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) { + // A failed fetch/parse leaves the empty state (or the accessible text + // fallback) in place instead of a blank canvas. + console.error('[3D Viewer] Failed to render STL:', error); + } +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/three-loader.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/three-loader.spec.ts new file mode 100644 index 0000000000..881e693d77 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/three-loader.spec.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createThreeStub } from '../test/three-stub'; +import { ensureThreeJsLoaded, isThreeJsReady } from './three-loader'; + +beforeEach(() => { + globalThis.THREE = undefined; + globalThis.$exeLibs = undefined; +}); + +afterEach(() => { + globalThis.THREE = undefined; + globalThis.$exeLibs = undefined; + vi.restoreAllMocks(); +}); + +describe('isThreeJsReady', () => { + it('needs the core plus both add-ons', () => { + expect(isThreeJsReady()).toBe(false); + const three = createThreeStub(); + globalThis.THREE = three; + // The stub namespace ships neither add-on by default. + expect(isThreeJsReady()).toBe(false); + three.STLLoader = class {} as unknown as ThreeNamespace['STLLoader']; + three.OrbitControls = class {} as unknown as ThreeNamespace['OrbitControls']; + expect(isThreeJsReady()).toBe(true); + }); +}); + +describe('ensureThreeJsLoaded', () => { + it('returns immediately when Three.js is already published', async () => { + const three = createThreeStub(); + three.STLLoader = class {} as unknown as ThreeNamespace['STLLoader']; + three.OrbitControls = class {} as unknown as ThreeNamespace['OrbitControls']; + globalThis.THREE = three; + await expect(ensureThreeJsLoaded('http://host/libs/')).resolves.toBeUndefined(); + }); + + it('waits for an in-flight load started by the other bundle', async () => { + let resolveShared: () => void = () => {}; + globalThis.$exeLibs = { + threeJsPromise: new Promise(resolve => { + resolveShared = resolve; + }), + }; + let settled = false; + const promise = ensureThreeJsLoaded('http://host/libs/').then(() => { + settled = true; + }); + expect(settled).toBe(false); + resolveShared(); + await promise; + expect(settled).toBe(true); + }); + + it('surfaces an import failure rather than resolving silently', async () => { + // The vendored modules are not importable from the test environment, so + // a real call must reject; callers wrap it in their own error handling. + await expect(ensureThreeJsLoaded('http://127.0.0.1:0/missing/')).rejects.toThrow(); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/three-loader.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/three-loader.ts new file mode 100644 index 0000000000..0ecce587d3 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/three-loader.ts @@ -0,0 +1,53 @@ +/** + * Lazy loading of the vendored Three.js ES modules (core + STLLoader + + * OrbitControls) used by the STL render path. + * + * The modules are only fetched when an STL model is actually shown, and the + * in-flight promise is parked on `window.$exeLibs` so the editor and the export + * runtime on the same page share one download. + */ + +function libs(): Record { + globalThis.$exeLibs = globalThis.$exeLibs ?? {}; + return globalThis.$exeLibs; +} + +/** True once THREE, STLLoader and OrbitControls are all on `window.THREE`. */ +export function isThreeJsReady(): boolean { + const three = globalThis.THREE; + return Boolean(three?.STLLoader && three?.OrbitControls); +} + +/** + * Import the Three.js modules from `baseUrl` and publish them on `window.THREE`. + * + * `baseUrl` must be absolute: a dynamic `import()` resolves relative specifiers + * against the importing module, which would duplicate the bundle's own path. + */ +export async function ensureThreeJsLoaded(baseUrl: string): Promise { + if (isThreeJsReady()) { + return; + } + const shared = libs(); + const pending = shared.threeJsPromise; + if (pending instanceof Promise) { + await pending; + return; + } + const loading = (async () => { + const core = (await import(/* @vite-ignore */ `${baseUrl}three.module.min.js`)) as Record; + const { STLLoader } = (await import(/* @vite-ignore */ `${baseUrl}STLLoader.js`)) as { + STLLoader: ThreeNamespace['STLLoader']; + }; + const { OrbitControls } = (await import(/* @vite-ignore */ `${baseUrl}OrbitControls.js`)) as { + OrbitControls: ThreeNamespace['OrbitControls']; + }; + const three = (globalThis.THREE ?? {}) as ThreeNamespace; + Object.assign(three, core); + three.STLLoader = STLLoader; + three.OrbitControls = OrbitControls; + globalThis.THREE = three; + })(); + shared.threeJsPromise = loading; + await loading; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/types.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/types.ts new file mode 100644 index 0000000000..5f0dc0820d --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/types.ts @@ -0,0 +1,71 @@ +/** Types for the shared viewer runtime: instances, options and the registry. */ + +import type { InteractionController } from '../interactions/types'; +import type { ModelType } from '../shared/types'; + +/** Boot options for one viewer instance. */ +export interface ViewerOptions { + src: string; + type: ModelType | ''; + modelColor: string; + backgroundColor: string; + cameraControls: boolean; + autoRotate: boolean; + autoRotateSpeed: number; +} + +/** A callback invoked once per animation frame, before the render. */ +export type FrameCallback = () => void; + +interface TrackedListener { + target: EventTarget; + type: string; + handler: EventListenerOrEventListenerObject; + options?: boolean | AddEventListenerOptions; +} + +/** + * One live viewer bound to a wrapper element. + * + * Every field that owns a resource — listeners, animation frames, GPU objects, + * object URLs, the interaction layer — is tracked here so `destroy()` can + * release all of it. Nothing about a viewer lives in module scope, which is + * what keeps several 3D Viewers on one page isolated. + */ +export interface ViewerInstance { + readonly wrapper: HTMLElement; + options: ViewerOptions; + type: ModelType | ''; + modelViewer: ModelViewerElement | null; + canvas: HTMLCanvasElement | null; + scene: ThreeObject3D | null; + camera: ThreeCamera | null; + renderer: ThreeRenderer | null; + controls: ThreeOrbitControls | null; + mesh: ThreeObject3D | null; + geometry: ThreeGeometry | null; + material: unknown; + rafId: number | null; + stopped: boolean; + listeners: TrackedListener[]; + objectURLs: string[]; + /** + * Per-frame callbacks run inside the animation loop. The STL marker adapter + * registers its reprojection here so markers stay in sync during rotation + * without opening a second `requestAnimationFrame` loop. + */ + onFrame: FrameCallback[]; + /** The attached interaction layer, torn down before the scene. */ + interaction: InteractionController | null; +} + +/** Narrow lifecycle surface over the wrapper → instance map. */ +export interface ViewerRegistry { + get(wrapper: HTMLElement): ViewerInstance | undefined; + set(wrapper: HTMLElement, instance: ViewerInstance): void; + has(wrapper: HTMLElement): boolean; + destroy(wrapper: HTMLElement): void; + destroyAll(): void; + /** Wrappers currently registered, in insertion order. */ + wrappers(): HTMLElement[]; +} diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/viewer-runtime.spec.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/viewer-runtime.spec.ts new file mode 100644 index 0000000000..bf5bb8b12b --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/viewer-runtime.spec.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createWrapper, makeInteraction, resetDom, sequentialIds } from '../test/helpers'; +import { createModelViewerStub } from '../test/model-viewer-stub'; +import { installThreeStub } from '../test/three-stub'; +import { createViewerRuntime, getViewerRuntime, publishViewerRuntime, readWrapperOptions } from './viewer-runtime'; + +let restoreThree: () => void; + +beforeEach(() => { + restoreThree = installThreeStub(); + globalThis.eXe3DViewer = undefined; + globalThis.__tdvForceWebGL = true; +}); + +afterEach(() => { + restoreThree(); + globalThis.eXe3DViewer = undefined; + globalThis.__tdvForceWebGL = undefined; + resetDom(); + vi.restoreAllMocks(); +}); + +describe('readWrapperOptions', () => { + it('reads and normalizes the flat attributes', () => { + const wrapper = createWrapper(); + Object.assign(wrapper.dataset, { + modelSrc: 'content/resources/a.stl', + modelColor: '#ABC', + backgroundColor: '#DEF', + cameraControls: 'false', + autoRotate: 'true', + autoRotateSpeed: '45', + }); + expect(readWrapperOptions(wrapper)).toEqual({ + src: 'content/resources/a.stl', + type: 'stl', + modelColor: '#aabbcc', + backgroundColor: '#ddeeff', + cameraControls: false, + autoRotate: true, + autoRotateSpeed: 45, + }); + }); + + it('applies the defaults and strips an ephemeral source', () => { + const wrapper = createWrapper(); + wrapper.dataset.modelSrc = 'blob:http://x/1'; + expect(readWrapperOptions(wrapper)).toMatchObject({ + src: '', + cameraControls: true, + autoRotate: true, + autoRotateSpeed: 30, + }); + }); + + it('lets nav controls win over auto-rotation', () => { + const wrapper = createWrapper(); + wrapper.dataset.showNavControls = 'true'; + wrapper.dataset.autoRotate = 'true'; + expect(readWrapperOptions(wrapper).autoRotate).toBe(false); + }); +}); + +describe('createViewerRuntime', () => { + it('registers an instance and returns the same one on a repeated init', () => { + const runtime = createViewerRuntime(); + const wrapper = createWrapper(); + const first = runtime.init(wrapper, readWrapperOptions(wrapper)); + expect(first).not.toBeNull(); + expect(runtime.init(wrapper)).toBe(first); + expect(runtime.getInstance(wrapper)).toBe(first); + }); + + it('reads the wrapper attributes when no options are given', () => { + const runtime = createViewerRuntime(); + const wrapper = createWrapper(); + wrapper.dataset.modelSrc = 'a.glb'; + expect(runtime.init(wrapper)?.type).toBe('glb'); + }); + + it('returns null without a wrapper', () => { + expect(createViewerRuntime().init(null as unknown as HTMLElement)).toBeNull(); + }); + + it('registers the instance BEFORE the async STL boot, so destroy always finds it', () => { + const runtime = createViewerRuntime(); + const wrapper = createWrapper(); + wrapper.dataset.modelSrc = 'content/resources/a.stl'; + const instance = runtime.init(wrapper); + expect(runtime.getInstance(wrapper)).toBe(instance); + runtime.destroy(wrapper); + expect(runtime.getInstance(wrapper)).toBeNull(); + }); + + it('reports an STL boot failure without rejecting', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const three = globalThis.THREE as ThreeNamespace; + three.STLLoader = class { + parse(): never { + throw new Error('bad geometry'); + } + } as unknown as ThreeNamespace['STLLoader']; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('network down'); + }), + ); + const runtime = createViewerRuntime(); + const wrapper = createWrapper(); + wrapper.dataset.modelSrc = 'content/resources/a.stl'; + runtime.init(wrapper); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(error).toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it('tears every instance down', () => { + const runtime = createViewerRuntime(); + const first = runtime.init(createWrapper('one'), readWrapperOptions(createWrapper('one-opts'))); + const second = runtime.init(createWrapper('two'), readWrapperOptions(createWrapper('two-opts'))); + runtime.destroyAll(); + expect(first?.stopped).toBe(true); + expect(second?.stopped).toBe(true); + expect(runtime.registry.wrappers()).toEqual([]); + }); + + it('binds a single beforeunload teardown', () => { + const addEventListener = vi.spyOn(globalThis, 'addEventListener'); + const runtime = createViewerRuntime(); + runtime.init(createWrapper('one'), readWrapperOptions(createWrapper('one-opts'))); + runtime.init(createWrapper('two'), readWrapperOptions(createWrapper('two-opts'))); + const unloadBindings = addEventListener.mock.calls.filter(call => call[0] === 'beforeunload'); + expect(unloadBindings).toHaveLength(1); + }); + + it('creates an interaction layer through the shared controller', () => { + const runtime = createViewerRuntime(); + const wrapper = createWrapper(); + const modelViewer = createModelViewerStub(wrapper); + const controller = runtime.createInteractionLayer( + { wrapper, type: 'glb', modelViewer }, + makeInteraction({ enabled: true, markers: [{ id: 'm1', label: 'One' }] }, sequentialIds()), + 'view', + { t: key => key }, + ); + expect(wrapper.querySelector('.tdv-marker')?.getAttribute('aria-label')).toBe('One'); + controller.destroy(); + }); + + it('re-exports the pure helpers both surfaces share', () => { + const runtime = createViewerRuntime(); + expect(runtime.detectModelType('a.stl')).toBe('stl'); + expect(runtime.normalizeColor('#ABC')).toBe('#aabbcc'); + expect(runtime.normalizeModelSource('blob:x')).toBe(''); + expect(typeof runtime.resolveModelSource).toBe('function'); + expect(typeof runtime.configureRendererColorManagement).toBe('function'); + expect(typeof runtime.disposeObject3D).toBe('function'); + expect(typeof runtime.disposeMaterial).toBe('function'); + expect(typeof runtime.readWrapperOptions).toBe('function'); + }); + + it('keeps two runtimes independent', () => { + const first = createViewerRuntime(); + const second = createViewerRuntime(); + const wrapper = createWrapper(); + first.init(wrapper, readWrapperOptions(wrapper)); + expect(second.getInstance(wrapper)).toBeNull(); + }); +}); + +describe('publishViewerRuntime / getViewerRuntime', () => { + it('publishes once and reuses whatever is already there', () => { + expect(getViewerRuntime()).toBeNull(); + const first = publishViewerRuntime(); + expect(globalThis.eXe3DViewer).toBe(first); + expect(publishViewerRuntime()).toBe(first); + expect(getViewerRuntime()).toBe(first); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/runtime/viewer-runtime.ts b/public/files/perm/idevices/base/three-d-viewer/src/runtime/viewer-runtime.ts new file mode 100644 index 0000000000..bfef1b2e0b --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/runtime/viewer-runtime.ts @@ -0,0 +1,129 @@ +/** + * `window.eXe3DViewer` — the shared viewer runtime. + * + * Both generated bundles carry a compiled copy of this module and publish it + * idempotently, so the first bundle on a page owns the single registry and any + * later bundle reuses it. That keeps one instance map per document even when + * the editor preview and an exported viewer coexist. + */ + +import { createInteractionController } from '../interactions/controller'; +import type { InteractionController, InteractionHandle, InteractionHooks, InteractionMode } from '../interactions/types'; +import { DEFAULT_BACKGROUND_COLOR, DEFAULT_MODEL_COLOR, normalizeColor } from '../shared/colors'; +import { detectModelType, normalizeModelSource } from '../shared/model-source'; +import type { InteractionSettings } from '../shared/types'; +import { resolveModelSource } from './asset-resolver'; +import { createRegistry } from './instance-registry'; +import { createInstance, disposeMaterial, disposeObject3D } from './lifecycle'; +import { bootStl, configureRendererColorManagement } from './stl-renderer'; +import type { ViewerInstance, ViewerOptions, ViewerRegistry } from './types'; + +/** Read boot options from a wrapper's flat `data-*` attributes. */ +export function readWrapperOptions(wrapper: HTMLElement): ViewerOptions { + const data = wrapper.dataset; + const showNavControls = data.showNavControls === 'true'; + const src = normalizeModelSource(data.modelSrc ?? ''); + return { + src, + type: (data.modelType as ViewerOptions['type']) || 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, + }; +} + +export interface ViewerRuntime { + init(wrapper: HTMLElement, options?: ViewerOptions): ViewerInstance | null; + destroy(wrapper: HTMLElement): void; + destroyAll(): void; + getInstance(wrapper: HTMLElement): ViewerInstance | null; + createInteractionLayer( + handle: InteractionHandle, + interaction: InteractionSettings, + mode: InteractionMode, + hooks?: InteractionHooks, + ): InteractionController; + /** Pure helpers, reused by both surfaces and by the tests. */ + detectModelType: typeof detectModelType; + normalizeColor: typeof normalizeColor; + normalizeModelSource: typeof normalizeModelSource; + resolveModelSource: typeof resolveModelSource; + configureRendererColorManagement: typeof configureRendererColorManagement; + disposeObject3D: typeof disposeObject3D; + disposeMaterial: typeof disposeMaterial; + readWrapperOptions: typeof readWrapperOptions; + /** The live registry, exposed for tests and cross-instance assertions. */ + registry: ViewerRegistry; +} + +/** Build a runtime with its own registry. */ +export function createViewerRuntime(): ViewerRuntime { + const registry = createRegistry(); + let unloadBound = false; + + const bindUnloadOnce = (): void => { + 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)); + // Register before any async boot work so `destroy()` always finds + // the instance, even mid-fetch. + registry.set(wrapper, instance); + bindUnloadOnce(); + if (instance.type === 'stl' && instance.options.src) { + void bootStl(instance).catch((error: unknown) => { + 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, + }; +} + +/** + * Publish the runtime on `window.eXe3DViewer`, reusing an already-published one. + * Returns whichever runtime is now live so callers never hold a shadow copy. + */ +export function publishViewerRuntime(): ViewerRuntime { + const existing = globalThis.eXe3DViewer as ViewerRuntime | undefined; + if (existing) { + return existing; + } + const runtime = createViewerRuntime(); + globalThis.eXe3DViewer = runtime; + return runtime; +} + +/** The live runtime, or `null` when no bundle has published one yet. */ +export function getViewerRuntime(): ViewerRuntime | null { + return (globalThis.eXe3DViewer as ViewerRuntime | undefined) ?? null; +} 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 0000000000..8b36bb98c1 --- /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 0000000000..c610a017dc --- /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 0000000000..d1a6dde280 --- /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 ['ok

'); + }); + + it('removes inline event handlers regardless of case', () => { + const clean = sanitizeHtml(''); + expect(clean).not.toContain('onerror'); + expect(clean.toLowerCase()).not.toContain('onload'); + expect(clean).toContain('a.png'); + }); + + it('removes unsafe URL schemes from href and src', () => { + const clean = sanitizeHtml('x'); + expect(clean).not.toContain('javascript:'); + expect(clean).not.toContain('vbscript:'); + }); + + it('keeps safe and relative URLs', () => { + const clean = sanitizeHtml('xy'); + expect(clean).toContain('https://example.org'); + expect(clean).toContain('page.html'); + }); + + it('removes lowercase SVG foreign-content elements that a tagName check would miss', () => { + // Inside , `tagName` preserves the author's casing, so a naive + // uppercase lookup would let `script` and `foreignObject` through. + const clean = sanitizeHtml('x'); + expect(clean.toLowerCase()).not.toContain(' { + const clean = sanitizeHtml('x'); + expect(clean.toLowerCase()).not.toContain('annotation-xml'); + }); + + it('strips an unsafe xlink:href on an SVG ', () => { + const clean = sanitizeHtml(''); + expect(clean).not.toContain('javascript:'); + }); + + it('strips formaction, ping, poster and srcdoc when they carry an unsafe scheme', () => { + const clean = sanitizeHtml( + 'x' + + '
', + ); + expect(clean).not.toContain('javascript:'); + }); + + it('sanitizes nested content, not just the top level', () => { + const clean = sanitizeHtml(''); + expect(clean).not.toContain('javascript:'); + expect(clean).not.toContain('onclick'); + expect(clean).toContain('deep'); + }); +}); diff --git a/public/files/perm/idevices/base/three-d-viewer/src/shared/html.ts b/public/files/perm/idevices/base/three-d-viewer/src/shared/html.ts new file mode 100644 index 0000000000..e93b32d2c1 --- /dev/null +++ b/public/files/perm/idevices/base/three-d-viewer/src/shared/html.ts @@ -0,0 +1,122 @@ +/** + * HTML escaping and the conservative DOM sanitizer used for author-supplied + * marker content. Sanitization is always DOM traversal — never a regex scrub of + * markup — so nesting and entity tricks cannot slip past it. + */ + +import { safeUrl } from './urls'; + +const ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +/** Escape a value for interpolation into text or an attribute. */ +export function escapeHtml(value: unknown): string { + return String(value ?? '').replace(/[&<>"']/g, char => ESCAPES[char] ?? char); +} + +/** Flatten HTML to plain text for the escaped, no-WebGL fallback list. */ +export function stripHtmlToText(html: unknown): string { + return String(html ?? '') + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Serialize a value for a ``). + */ +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 0000000000..979e923522 --- /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 0000000000..43799f6849 --- /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 0000000000..f5396db75d --- /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 0000000000..d5c97f681a --- /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 0000000000..e36c7c1e87 --- /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 0000000000..78a6f47cf6 --- /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 0000000000..e9af8df25b --- /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 0000000000..db3ec32247 --- /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 0000000000..02e36261f5 --- /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 0000000000..a55b8036b2 --- /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 0000000000..04dc6d54e2 --- /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 0000000000..bcfa5cd063 --- /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 0000000000..f38a892164 --- /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 0000000000..a65b21a4f3 --- /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 0000000000..b488c8f0a8 --- /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 0000000000..f73d5c6353 --- /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 0000000000..b0980b5f83 --- /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 0000000000..bd6cfbeb55 --- /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 0000000000..bb6fc1d0a2 --- /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/scripts/build-idevices.spec.ts b/scripts/build-idevices.spec.ts new file mode 100644 index 0000000000..5f2f90c477 --- /dev/null +++ b/scripts/build-idevices.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; +import { discoverTsIdevices, IDEVICES_BASE, resolveEntries } from './build-idevices'; + +function makeIdevice(base: string, name: string, files: Record): string { + const dir = join(base, name); + for (const [path, content] of Object.entries(files)) { + const full = join(dir, path); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, content); + } + return dir; +} + +describe('discoverTsIdevices', () => { + it('finds the real TypeScript iDevices of the repo', () => { + const names = discoverTsIdevices().map(i => i.name); + expect(names).toContain('three-d-viewer'); + expect(names).toContain('slide'); + // Classic-script iDevices without src/ are not build candidates. + expect(names).not.toContain('text'); + expect(names).not.toContain('trueorfalse'); + }); + + it('honours the --only filter and skips src-less directories', () => { + const only = discoverTsIdevices(IDEVICES_BASE, ['slide']); + expect(only.map(i => i.name)).toEqual(['slide']); + expect(discoverTsIdevices(IDEVICES_BASE, ['no-such-idevice'])).toEqual([]); + }); + + it('records the per-iDevice tsconfig when one exists', () => { + const byName = new Map(discoverTsIdevices().map(i => [i.name, i])); + expect(byName.get('three-d-viewer')?.tsconfig).toContain('tsconfig.json'); + expect(byName.get('slide')?.tsconfig).toBeNull(); + }); +}); + +describe('resolveEntries', () => { + it('builds edition and export by convention from src//index.ts', () => { + const base = mkdtempSync(join(tmpdir(), 'idevice-build-')); + try { + const dir = makeIdevice(base, 'demo', { + 'src/edition/index.ts': '', + 'src/export/index.ts': '', + }); + const entries = resolveEntries('demo', dir); + expect(entries.map(e => e.label)).toEqual(['demo/edition', 'demo/export']); + expect(entries[0]).toMatchObject({ + naming: '[dir]/demo.[ext]', + minify: false, + sourcemap: 'linked', + externals: {}, + }); + expect(entries[0]?.outdir.endsWith('/edition')).toBe(true); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it('only emits the surfaces that exist', () => { + const base = mkdtempSync(join(tmpdir(), 'idevice-build-')); + try { + const dir = makeIdevice(base, 'demo', { 'src/edition/index.ts': '' }); + expect(resolveEntries('demo', dir).map(e => e.label)).toEqual(['demo/edition']); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it('lets a build.config.json replace the convention (the slide shape)', () => { + const base = mkdtempSync(join(tmpdir(), 'idevice-build-')); + try { + const dir = makeIdevice(base, 'demo', { + 'src/index.ts': '', + 'build.config.json': JSON.stringify({ + entries: [ + { + entry: 'src/index.ts', + outdir: 'edition', + naming: '[dir]/demo.bundle.[ext]', + globalName: '__demoInit', + minify: true, + sourcemap: 'none', + externals: { + fabric: 'fabric', + dompurify: { global: 'DOMPurify', default: true }, + }, + }, + ], + }), + }); + const [entry] = resolveEntries('demo', dir); + expect(entry).toMatchObject({ + naming: '[dir]/demo.bundle.[ext]', + globalName: '__demoInit', + minify: true, + sourcemap: 'none', + externals: { + fabric: { global: 'fabric', default: false }, + dompurify: { global: 'DOMPurify', default: true }, + }, + }); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it('rejects manifest entries without entry/outdir', () => { + const base = mkdtempSync(join(tmpdir(), 'idevice-build-')); + try { + const dir = makeIdevice(base, 'demo', { + 'src/index.ts': '', + 'build.config.json': JSON.stringify({ entries: [{ outdir: 'edition' }] }), + }); + expect(() => resolveEntries('demo', dir)).toThrow(/entry/); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it('matches the repo state: slide via manifest, three-d-viewer via convention', () => { + const slide = resolveEntries('slide', join(IDEVICES_BASE, 'slide')); + expect(slide).toHaveLength(1); + expect(slide[0]).toMatchObject({ + naming: '[dir]/slide-editor.bundle.[ext]', + globalName: '__slideEditorInit', + minify: true, + }); + const viewer = resolveEntries('three-d-viewer', join(IDEVICES_BASE, 'three-d-viewer')); + expect(viewer.map(e => e.label)).toEqual(['three-d-viewer/edition', 'three-d-viewer/export']); + }); +}); + +describe('maintained iDevice sources', () => { + // A `.gitignore` rule meant for a generated directory can silently match a + // source directory of the same name at any depth (an unanchored `runtime/` + // swallowed `three-d-viewer/src/runtime/`). Nothing else catches that: the + // working tree still builds, only the commit is incomplete. + it('are never matched by a gitignore rule', () => { + const sources = discoverTsIdevices().flatMap(idevice => + Array.from(new Bun.Glob('src/**/*.ts').scanSync({ cwd: idevice.dir }), file => join(idevice.dir, file)), + ); + expect(sources.length).toBeGreaterThan(0); + + // --no-index tests the ignore rules themselves. Without it git skips + // paths already in the index, so a rule that would drop a *new* source + // file goes unnoticed once someone has force-added the existing ones. + const check = Bun.spawnSync(['git', 'check-ignore', '--no-index', '--stdin'], { + cwd: resolve(import.meta.dir, '..'), + stdin: Buffer.from(`${sources.join('\n')}\n`), + }); + + expect(check.stdout.toString().trim()).toBe(''); + }); +}); diff --git a/scripts/build-idevices.ts b/scripts/build-idevices.ts new file mode 100644 index 0000000000..412bbc379d --- /dev/null +++ b/scripts/build-idevices.ts @@ -0,0 +1,326 @@ +/** + * Centralized build for TypeScript-based iDevices. + * + * Any iDevice under `public/files/perm/idevices/base//` that keeps its + * maintained source in a `src/` directory is built by CONVENTION: + * + * src/edition/index.ts -> edition/.js + * src/export/index.ts -> export/.js + * + * Each bundle is a self-contained classic-script IIFE (browser target, no + * chunks, linked source maps, unminified) whose entry point explicitly + * assigns its window global(s). Generated bundles and maps are gitignored. + * + * An iDevice that needs to deviate ships a `build.config.json` next to its + * `config.xml`, which REPLACES the convention for that iDevice: + * + * { + * "entries": [ + * { + * "entry": "src/index.ts", // relative to the iDevice dir + * "outdir": "edition", // relative to the iDevice dir + * "naming": "[dir]/slide-editor.bundle.[ext]", + * "globalName": "__slideEditorInit", // optional IIFE global + * "minify": true, // default false + * "sourcemap": "none", // default "linked" + * "externals": { // import name -> window global + * "fabric": "fabric", + * "dompurify": { "global": "DOMPurify", "default": true } + * } + * } + * ] + * } + * + * `externals` maps a bare import specifier to a global the page already + * provides (vendored under public/libs/), so the library is never inlined. + * With `"default": true` the shim also exposes the global as the module's + * default export (what `import X from '...'` consumers need). + * + * Type checking: every discovered iDevice that ships a `tsconfig.json` is + * checked with `tsc -p` (see --typecheck-only / --typecheck). + * + * Usage: + * bun scripts/build-idevices.ts # build every TS iDevice + * bun scripts/build-idevices.ts --typecheck-only # tsc -p only, no build + * bun scripts/build-idevices.ts --typecheck # tsc -p, then build + * bun scripts/build-idevices.ts --watch # rebuild on src changes + * bun scripts/build-idevices.ts --only slide # filter (comma-separated) + * + * Released under Attribution-ShareAlike 4.0 International License. + * Author: eXeLearning - https://exelearning.net + */ + +import { existsSync, readdirSync, readFileSync, watch } from 'fs'; +import { join, resolve } from 'path'; + +export const IDEVICES_BASE = resolve(import.meta.dir, '..', 'public/files/perm/idevices/base'); + +type SourcemapMode = 'linked' | 'none' | 'inline' | 'external'; + +export interface ExternalSpec { + global: string; + default: boolean; +} + +export interface BundleEntry { + /** iDevice folder name (also the default bundle basename). */ + idevice: string; + /** Short label for logs, e.g. 'interactive-video/edition'. */ + label: string; + entrypoint: string; + outdir: string; + naming: string; + globalName?: string; + minify: boolean; + sourcemap: SourcemapMode; + externals: Record; +} + +export interface TsIdevice { + name: string; + dir: string; + srcDir: string; + tsconfig: string | null; + entries: BundleEntry[]; +} + +function normalizeExternals(value: unknown): Record { + const out: Record = {}; + if (!value || typeof value !== 'object') { + return out; + } + for (const [name, spec] of Object.entries(value as Record)) { + if (typeof spec === 'string') { + out[name] = { global: spec, default: false }; + } else if (spec && typeof spec === 'object' && typeof (spec as { global?: unknown }).global === 'string') { + out[name] = { + global: (spec as { global: string }).global, + default: (spec as { default?: unknown }).default === true, + }; + } + } + return out; +} + +/** The build entries of one iDevice: its manifest, or the src/ convention. */ +export function resolveEntries(name: string, dir: string): BundleEntry[] { + const manifestPath = join(dir, 'build.config.json'); + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + entries?: Array>; + }; + return (manifest.entries || []).map((raw, index) => { + const entry = String(raw.entry || ''); + const outdir = String(raw.outdir || ''); + if (!entry || !outdir) { + throw new Error(`${name}/build.config.json: entries[${index}] needs "entry" and "outdir"`); + } + return { + idevice: name, + label: `${name}/${outdir}`, + entrypoint: join(dir, entry), + outdir: join(dir, outdir), + naming: typeof raw.naming === 'string' ? raw.naming : `[dir]/${name}.[ext]`, + globalName: typeof raw.globalName === 'string' ? raw.globalName : undefined, + minify: raw.minify === true, + sourcemap: (typeof raw.sourcemap === 'string' ? raw.sourcemap : 'linked') as SourcemapMode, + externals: normalizeExternals(raw.externals), + }; + }); + } + const entries: BundleEntry[] = []; + for (const surface of ['edition', 'export'] as const) { + const entrypoint = join(dir, 'src', surface, 'index.ts'); + if (existsSync(entrypoint)) { + entries.push({ + idevice: name, + label: `${name}/${surface}`, + entrypoint, + outdir: join(dir, surface), + naming: `[dir]/${name}.[ext]`, + minify: false, + sourcemap: 'linked', + externals: {}, + }); + } + } + return entries; +} + +/** Every iDevice that keeps TypeScript sources under src/. */ +export function discoverTsIdevices(baseDir: string = IDEVICES_BASE, only?: string[]): TsIdevice[] { + const idevices: TsIdevice[] = []; + for (const entry of readdirSync(baseDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.')) { + continue; + } + if (only && only.length > 0 && !only.includes(entry.name)) { + continue; + } + const dir = join(baseDir, entry.name); + const srcDir = join(dir, 'src'); + if (!existsSync(srcDir)) { + continue; + } + const entries = resolveEntries(entry.name, dir); + if (entries.length === 0) { + continue; + } + const tsconfig = existsSync(join(dir, 'tsconfig.json')) ? join(dir, 'tsconfig.json') : null; + idevices.push({ name: entry.name, dir, srcDir, tsconfig, entries }); + } + return idevices; +} + +/** Bun plugin resolving the declared externals to page-provided globals. */ +function externalsPlugin(externals: Record): import('bun').BunPlugin { + const names = Object.keys(externals); + return { + name: 'idevice-externals', + setup(build) { + if (names.length === 0) { + return; + } + const filter = new RegExp(`^(${names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`); + build.onResolve({ filter }, args => ({ path: args.path, namespace: 'idevice-externals' })); + build.onLoad({ filter: /.*/, namespace: 'idevice-externals' }, args => { + const spec = externals[args.path]; + if (!spec) { + throw new Error(`No external mapping for '${args.path}'`); + } + const message = `iDevice bundle: window.${spec.global} is not loaded. Load its vendored script first.`; + return { + contents: ` + const __global = globalThis[${JSON.stringify(spec.global)}]; + if (!__global) { + throw new Error(${JSON.stringify(message)}); + } + module.exports = __global; + ${spec.default ? 'module.exports.default = __global;' : ''} + `, + loader: 'js', + }; + }); + }, + }; +} + +/** Build one entry; returns false (after printing every diagnostic) on failure. */ +async function buildEntry(entry: BundleEntry): Promise { + try { + const result = await Bun.build({ + entrypoints: [entry.entrypoint], + outdir: entry.outdir, + naming: entry.naming, + format: 'iife', + target: 'browser', + sourcemap: entry.sourcemap, + minify: entry.minify, + ...(entry.globalName ? { globalName: entry.globalName } : {}), + plugins: [externalsPlugin(entry.externals)], + }); + if (!result.success) { + console.error(`iDevice bundle FAILED: ${entry.label}`); + for (const log of result.logs) { + console.error(log); + } + return false; + } + for (const out of result.outputs) { + console.log(` ${out.path}`); + } + return true; + } catch (error) { + console.error(`iDevice bundle FAILED: ${entry.label}`); + console.error(error); + return false; + } +} + +/** Build every entry of one iDevice independently. */ +async function buildIdevice(idevice: TsIdevice): Promise { + const results = await Promise.all(idevice.entries.map(buildEntry)); + return results.every(ok => ok); +} + +/** + * tsc -p for every discovered iDevice that ships a tsconfig. The processes run + * concurrently (each cold tsc takes seconds and they are independent); output + * is buffered per iDevice so failures stay readable. + */ +async function typecheck(idevices: TsIdevice[]): Promise { + const checked = idevices.filter(idevice => idevice.tsconfig); + const results = await Promise.all( + checked.map(async idevice => { + const run = Bun.spawn(['bun', 'x', 'tsc', '-p', idevice.tsconfig as string], { + stdout: 'pipe', + stderr: 'pipe', + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(run.stdout).text(), + new Response(run.stderr).text(), + run.exited, + ]); + console.log(`Type-checking ${idevice.name}…`); + if (stdout.trim()) { + console.log(stdout.trimEnd()); + } + if (stderr.trim()) { + console.error(stderr.trimEnd()); + } + return exitCode === 0; + }), + ); + return results.every(ok => ok); +} + +async function main(): Promise { + const args = process.argv.slice(2); + const isWatch = args.includes('--watch'); + const typecheckOnly = args.includes('--typecheck-only'); + const withTypecheck = typecheckOnly || args.includes('--typecheck'); + const onlyIndex = args.indexOf('--only'); + const only = onlyIndex > -1 ? (args[onlyIndex + 1] || '').split(',').filter(Boolean) : undefined; + + const idevices = discoverTsIdevices(IDEVICES_BASE, only); + if (idevices.length === 0) { + console.error('No TypeScript iDevices found' + (only ? ` matching --only ${only.join(',')}` : '')); + process.exit(1); + } + + if (withTypecheck && !(await typecheck(idevices))) { + process.exit(1); + } + if (typecheckOnly) { + console.log('Type checks passed.'); + return; + } + + console.log(`Building ${idevices.length} TypeScript iDevice(s): ${idevices.map(i => i.name).join(', ')}`); + const results = await Promise.all(idevices.map(buildIdevice)); + const ok = results.every(Boolean); + if (!isWatch) { + process.exit(ok ? 0 : 1); + } + + console.log('Watching src/ directories… (Ctrl+C to stop)'); + for (const idevice of idevices) { + let pending: ReturnType | null = null; + watch(idevice.srcDir, { recursive: true }, (_event, filename) => { + if (filename && /\.(spec|test)\.[tj]s$/.test(filename)) { + return; + } + if (pending) { + clearTimeout(pending); + } + pending = setTimeout(() => { + pending = null; + void buildIdevice(idevice); + }, 100); + }); + } +} + +if (import.meta.main) { + await main(); +} diff --git a/scripts/build-resource-bundles.js b/scripts/build-resource-bundles.js index 8c1301c31c..94052c3e63 100644 --- a/scripts/build-resource-bundles.js +++ b/scripts/build-resource-bundles.js @@ -77,6 +77,9 @@ function scanDirectory(dirPath, basePath = '') { for (const entry of entries) { if (entry.name.startsWith('.')) continue; + // Source maps are a development aid next to generated bundles (e.g. the + // 3D Viewer iDevice); they must not ship inside resource ZIPs. + if (entry.isFile() && entry.name.endsWith('.map')) continue; const fullPath = path.join(dirPath, entry.name); const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name; diff --git a/scripts/build-slide-editor.ts b/scripts/build-slide-editor.ts deleted file mode 100644 index b4db8ecae2..0000000000 --- a/scripts/build-slide-editor.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Build script for the Slide iDevice editor bundle. - * - * Produces a single self-contained IIFE so the iDevice can be loaded - * by the eXeLearning workarea via a plain