Forge3D
Interactive 3D Model Viewer
A self-contained GLB model viewer with annotation authoring, keyboard navigation, and SCORM-safe output, built as a native block type in the CourseForge LCMS: one of the modular, reusable content components (text, media, quiz, hotspot, interactive video, 3D model, …) that authors stack to compose a frame.
The Forge3D block renders interactive GLB assets directly inside a CourseForge training frame. Learners orbit and zoom the model using mouse, touch, or keyboard controls. Authors place annotation pins on the model surface inside the CourseForge editor, no external tooling required.
At publish time, the SCORM packager bundles the GLB file into the SCO frame HTML and inlines a self-contained vanilla JS viewer. The GLB ships inside the ZIP and is referenced by relative path; the Three.js runtime, loaders, and Draco decoder are bundled in the ZIP as well, so it runs with no CDN dependency at runtime (see §06).
The viewer enforces orbit and zoom only, no pan. This design decision keeps the subject centered at all times, which is essential for training content where the learner must maintain a consistent frame of reference relative to the model.
Mouse + trackpad
| Input | Action |
|---|---|
| Left drag | Orbit, horizontal and vertical |
| Scroll wheel | Zoom in / out |
| Click annotation dot | Open annotation popover |
Touch
| Input | Action |
|---|---|
| One-finger drag | Orbit |
| Two-finger pinch | Zoom |
| Tap annotation dot | Open annotation popover |
Keyboard (Section 508)
| Key | Action |
|---|---|
| ← → | Orbit horizontally |
| ↑ ↓ | Orbit vertically |
| + / − | Zoom in / out |
| R | Reset camera to default position |
| Tab | Cycle focus through annotation pins |
| Enter / Space | Open focused annotation popover |
| Escape | Close open popover |
Static on load by default, the learner initiates interaction, so a frame opening mid-lesson doesn't disorient and the learner keeps full agency over the exploration. Auto-rotation is an opt-in, per-model setting (and honors the visitor's reduced-motion preference).
The Forge3D block is implemented across two layers: a React authoring component inside CourseForge, and a vanilla JS runtime embedded in SCORM output frames.
Authoring layer (React)
Model3DBlock.jsx handles GLB upload via the /api/media/model route, provides a live Three.js preview inside the block editor, and manages the annotation authoring workflow including pin placement via raycaster click on the canvas.
// Annotation data stored in frame JSONB — no sidecar file block.data = { "model_asset_id": "uuid", "model_serve_url": "/api/media/model/uuid.glb", "viewer_height": 400, "bg_color": "#0d1017", "annotations": [{ "id": "uuid", "label": "Component name", "description": "What this component does...", "position": { "x": 0.42, "y": 1.2, "z": -0.18 }, "color": "#533AFD" }] }
SCORM runtime (vanilla JS)
At publish time, scorm12.py inlines the orbit controller, annotation overlay system, and GLB loader directly into the SCO frame HTML. The GLB is bundled at media/models/{id}.glb inside the SCORM ZIP and referenced by relative path. The Three.js + GLTFLoader + Draco scripts are bundled in the ZIP and loaded local-first (with a CDN fallback), see §06.
# SCORM package structure CourseTitle.zip imsmanifest.xml sco_shell.html frame_0001.html ← SCO with embedded viewer JS media/ models/ {uuid}.glb ← bundled in the ZIP (no asset fetch) # Three.js + GLTFLoader: CDN-loaded at runtime today; # local js/ bundling is planned for strict-CSP delivery
Annotations are authored directly in the CourseForge frame editor, no external tool or sidecar file. They are stored in the frame's block JSONB alongside all other block configuration.
Authoring workflow
- Enable Preview in the Model3D block editor
- Click ✦ Place pin, cursor changes to crosshair
- Click any point on the model surface, raycaster fires, 3D world coordinates captured
- Fill label and description in the pending pin form
- Click ✓ Add annotation, pin appears on model immediately
- Orbit the model, pins track the model surface correctly
- Pins behind the camera plane are automatically hidden
Visual pattern: Option D
Four annotation visual patterns were prototyped and evaluated. Option D was selected: dot always visible, label on hover, popover on click. This pattern is touch-compatible (tap = click), keyboard accessible via Tab/Enter/Escape, and works with any number of annotations without visual clutter.
| State | Visual | Behavior |
|---|---|---|
| Default | 14px amber dot, white border, glow | Slow pulse animation (2.5s) |
| Hover / focus | Dot scales 1.3×, label appears | Label: component name only |
| Active | Dot scales 1.35×, popover opens | Amber title + full description |
| Hidden | display:none | When ndc.z ≥ 1.0 (behind camera plane) |
Current implementation hides pins when they pass behind the camera plane (NDC z ≥ 1.0). True per-surface depth occlusion requires a depth buffer read via render target, complex at Three.js r136. Learners orbit to find all annotations, which is intentional pedagogically.
Beyond orbit and annotations, the Model3D block exposes three author-controlled capabilities: a cross-section clip, part highlighting, and environment / motion options. Each is configured in the block editor and stored in block.data. All three render identically in the live React viewer (Model3DViewer.jsx) and the published SCORM runtime (scorm12.py): two twins kept in lock-step, with the standalone Forge3D desktop viewer as the third.
Cross-section (X-ray)
Slice the model along an axis to reveal its interior, for showing what sits inside a housing, an assembly, or a fuel system. It is a real GPU clip via Three.js clipping planes (renderer.localClippingEnabled = true), not a faked cutaway: each material gets a THREE.Plane and fragments on the negative side are discarded.
// block.data.section — cross-section clip "section": { "enabled": true, "axis": "x", // "x" | "y" | "z" "position": 0.5, // 0..1 along the model's bounding box "flip": false // keep the opposite half }
| Control | Effect |
|---|---|
| Cut axis | Aligns the clip plane normal to X, Y, or Z |
| Cut position | Slides the plane from 0% to 100% across the model's bounding box on that axis |
| Flip | Negates the plane normal, keeps the other half |
Plane normal = +axis keeps the high side; flip negates it; position maps 0–1 onto the model's post-transform bounding box. The same math lives in three renderers that must stay in sync, Model3DViewer.buildSectionPlane (live), the scorm12 SCORM runtime, and the Forge3D desktop viewer, so a change to one is a change to all three.
Part highlighting
With part_highlight enabled, learners hover or click individual meshes to highlight them and surface a label at the part's centroid. Meshes are grouped by name, so a multi-mesh component reads as a single part. Authors give each discovered part a human label (and optional description) in the editor; these are stored in block.data.parts, keyed by mesh name. The centroid label is projected to screen space through the shared project3d.js helper (projectToScreen), the same projection the annotation pins use, with on-screen culling so a label never overflows the frame.
// block.data — part highlighting "part_highlight": true, "parts": { "Fuel_injector": { "label": "Fuel injector", "description": "Meters fuel into the cylinder" } }
Environment & motion
| Setting | Values | Effect |
|---|---|---|
environment | studio · day · night | Image-based lighting for PBR reflections. studio is procedural (no file); day / night are bundled HDRIs. |
env_intensity | 0 – 2 | Scales the environment's contribution to material reflections (default 1). |
auto_rotate | boolean | Idle turntable spin; honors prefers-reduced-motion and pauses during interaction. |
decorative | boolean | Marks the model decorative, the canvas is aria-hidden with no role/label/tab stop, so screen readers skip it when it carries no instructional meaning. |
Asset conversion: the Forge3D app
Rather than hand-tuning a self-contained GLB in a DCC tool, run the source through the Forge3D desktop app (Electron + headless Blender). Drop an FBX, glTF, or GLB, or a whole model folder, or the model plus its texture files dropped together, and Forge3D stages the model with every texture into one working set, resolves external maps (including the common source/ + textures/ download layout, and renamed maps like foo.tga → foo.tga.png), preserves skeletal animation, bakes deprecated spec-gloss materials to modern metallic-roughness, and exports a clean, self-contained GLB with the textures embedded. The live preview plays the animation (with Recenter / Follow to keep a moving model in frame), and a one-click Capture exports a PNG of the current view. Drop several models at once, or a folder of them, and Forge3D queues them for batch conversion, writing each result next to its source. A Draco mesh-compression level control (0 to 10) trades file size against decode cost per job, and an output-format choice writes either one self-contained GLB or a separate .gltf plus .bin and texture files when a downstream pipeline needs the unpacked form. The app self-updates from a dedicated GitHub Releases feed: it checks on launch and on demand from Help > Check for Updates, downloads a newer build in the background, reports what it found, and prompts to restart to install, so a shipped fix reaches every author without a manual reinstall. This is the recommended path. It removes the "remember to embed the textures" footgun the manual exports below are prone to.
Asset preparation: 3ds Max
- Model and texture the asset using PBR materials (Physical Material)
- Apply Reset XForm to all objects before export
- Collapse modifier stacks
- File → Export → glTF 2.0 (.glb)
- Options: embed textures in GLB, Y-up axis, apply transforms
- Target under 20 MB for SCORM delivery
Asset preparation: Blender
- File → Export → glTF 2.0 → Format: glTF Binary (.glb)
- Include: Apply Modifiers, Normals, UVs, Materials, Textures
- Principled BSDF maps correctly to glTF metallic-roughness
Asset preparation: Unreal Engine 5
- Select meshes → File → Export Selected → .glb
- Bake materials to PBR before export
- Embed textures, separate texture files will not resolve inside SCORM ZIP
Most government LMS platforms cap SCORM uploads at 100–500 MB. Keep individual GLB files under 20 MB for reliable delivery. Draco compression reduces GLB size by 60 to 80%; Forge3D exposes a Draco level control at export today, and deeper batch processing is planned for a future ForgePack module.
The SCORM packager renders each frame's blocks through the _render_blocks pipeline. Model3D blocks generate a canvas element, an annotation overlay container, and a self-contained JS block that initializes the Three.js scene, loads the GLB, and handles all user input without any React dependency.
Asset bundling & CDN status
The GLB is bundled inside the SCORM ZIP at media/models/{id}.glb and referenced by relative path. No asset fetch is made for the model at runtime. The Three.js runtime, GLTF + DRACO loaders, and the Draco WASM decoder are bundled too (under assets/three/), so 3D blocks run with no runtime CDN dependency.
SCORM 1.2 and 2004 packages bundle three.min.js, the loaders, and the Draco decoder into the ZIP (when a 3D block exists), loaded local-first with a CDN fallback, so 3D works on air-gapped / strict-CSP LMS environments (e.g., isolated DoD networks). Draco-compressed models additionally require the host CSP to permit WebAssembly ('wasm-unsafe-eval'); non-Draco GLBs need no such allowance.
Annotation data inlining
// Inlined at publish time — no runtime network request for data var ANNOTATIONS = JSON.parse('[{"id":"...","label":"..."}]'); var MODEL_SRC = 'media/models/uuid.glb';
SCORM API interaction
The Model3D block does not directly call SCORM API methods. Completion and score reporting are handled by the CourseForge SCO shell. If a quiz block appears in the same frame as a Model3D block, quiz completion triggers SCORM reporting as normal.
Section 508 compliance is a default requirement, not a review step. The Forge3D block was designed for accessibility from the first sprint.
| Requirement | Implementation |
|---|---|
| Keyboard access | Canvas tabindex="0". Arrow keys orbit. +/- zoom. R resets. Tab cycles pins. Enter/Space opens popover. Escape closes. |
| Screen reader | Canvas role="img" with aria-label including keyboard instructions. Pins have role="button" and descriptive aria-label. |
| Focus management | Popovers receive focus on open. Escape returns focus to triggering pin. focus-visible ring uses 2px amber outline. |
| Reduced motion | prefers-reduced-motion disables loading spinner and pin pulse animation. |
| Color contrast | Amber on navy exceeds 4.5:1 for normal text. Pin white border provides contrast on both light and dark model surfaces. |
| Touch targets | Pins are 14px with effective 44px tap area via transform scale on hover/focus. |
| Seizure risk | No flashing animations. Pin pulse is slow (2.5s), low-amplitude, well below the 3Hz threshold. |
Server routes
| Route | Method | Description |
|---|---|---|
| /api/media/model | POST | Upload .glb / .gltf. Returns id, serve_url, file_size_mb. |
| /api/media/model/{id}.glb | GET | Serve the GLB file for editor preview and SCORM output. |
| /api/frames/{id}/preview-html | GET | Render frame as real SCORM HTML in new tab. SCORM API stubbed. |
Block data schema
{
// Asset reference
"model_asset_id": "string | null",
"model_filename": "string | null",
"model_serve_url": "string | null",
"file_size_mb": "number | null",
// Viewer config
"viewer_height": 400,
"bg_color": "#0d1017",
"caption": "",
"attribution": "",
"environment": "studio", // studio | day | night
"env_intensity": 1, // 0..2
"auto_rotate": false,
"decorative": false,
// Cross-section (X-ray)
"section": {
"enabled": false,
"axis": "x", // x | y | z
"position": 0.5, // 0..1 along bounding box
"flip": false
},
// Part highlighting (keyed by mesh name)
"part_highlight": false,
"parts": {
"<mesh_name>": { "label": "string", "description": "string" }
},
// Annotations
"annotations": [{
"id": "uuid",
"label": "string",
"description": "string",
"position": { "x": 0, "y": 0, "z": 0 },
"color": "#533AFD"
}]
}Annotation position coordinates
Positions are stored in Three.js world space after the GLB has been auto-scaled and centered. The auto-scale maps the model's bounding box to a 2-unit cube centered at origin. Raycaster hit points are recorded after this transform, so coordinates are consistent regardless of original GLB scale or pivot.