Cardona Creative Technology Lab · Technical Documentation

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.

VersionSprint 9b · v1.0
RendererThree.js r136 · WebGL 2
OutputSCORM 1.2 · SCORM 2004 · Web Bundle
ComplianceWCAG 2.1 AA · Section 508
Asset formatGLB (glTF 2.0 binary)
Target deliveryEdge (Chromium) · Chrome · Firefox
01Overview

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).

GLB native
Accepts any glTF 2.0 binary, exported from 3ds Max, Blender, Maya, or UE5. Textures embedded in GLB.
Annotation pins
Raycaster-placed pins with label and description. Dot always visible, label on hover, popover on click.
Keyboard nav
Full keyboard orbit, zoom, reset. Tab cycles annotation pins. Enter opens popover. Section 508 compliant.
Cross-section
Slice the model along X, Y, or Z to reveal the interior, a real GPU clip via Three.js clipping planes.
Part highlighting
Hover or click meshes to highlight and label them. Multi-mesh parts group by name; labels carry author text.
📦
GLB bundled
The GLB, Three.js, the loaders, and the Draco decoder are packaged inside the SCORM ZIP and loaded local-first, no runtime CDN dependency.
02Interaction Model

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

InputAction
Left dragOrbit, horizontal and vertical
Scroll wheelZoom in / out
Click annotation dotOpen annotation popover

Touch

InputAction
One-finger dragOrbit
Two-finger pinchZoom
Tap annotation dotOpen annotation popover

Keyboard (Section 508)

KeyAction
← →Orbit horizontally
↑ ↓Orbit vertically
+ / Zoom in / out
RReset camera to default position
TabCycle focus through annotation pins
Enter / SpaceOpen focused annotation popover
EscapeClose open popover
Design rationale

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).

03Architecture

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
04Annotation System

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

  1. Enable Preview in the Model3D block editor
  2. Click ✦ Place pin, cursor changes to crosshair
  3. Click any point on the model surface, raycaster fires, 3D world coordinates captured
  4. Fill label and description in the pending pin form
  5. Click ✓ Add annotation, pin appears on model immediately
  6. Orbit the model, pins track the model surface correctly
  7. 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.

StateVisualBehavior
Default14px amber dot, white border, glowSlow pulse animation (2.5s)
Hover / focusDot scales 1.3×, label appearsLabel: component name only
ActiveDot scales 1.35×, popover opensAmber title + full description
Hiddendisplay:noneWhen ndc.z ≥ 1.0 (behind camera plane)
Technical note · z-occlusion

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.

05Viewer Capabilities

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
}
ControlEffect
Cut axisAligns the clip plane normal to X, Y, or Z
Cut positionSlides the plane from 0% to 100% across the model's bounding box on that axis
FlipNegates the plane normal, keeps the other half
Twin discipline · clipping math

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

SettingValuesEffect
environmentstudio · day · nightImage-based lighting for PBR reflections. studio is procedural (no file); day / night are bundled HDRIs.
env_intensity0 – 2Scales the environment's contribution to material reflections (default 1).
auto_rotatebooleanIdle turntable spin; honors prefers-reduced-motion and pauses during interaction.
decorativebooleanMarks 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.
06Authoring Workflow

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.tgafoo.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

  1. Model and texture the asset using PBR materials (Physical Material)
  2. Apply Reset XForm to all objects before export
  3. Collapse modifier stacks
  4. File → Export → glTF 2.0 (.glb)
  5. Options: embed textures in GLB, Y-up axis, apply transforms
  6. Target under 20 MB for SCORM delivery

Asset preparation: Blender

  1. File → Export → glTF 2.0 → Format: glTF Binary (.glb)
  2. Include: Apply Modifiers, Normals, UVs, Materials, Textures
  3. Principled BSDF maps correctly to glTF metallic-roughness

Asset preparation: Unreal Engine 5

  1. Select meshes → File → Export Selected → .glb
  2. Bake materials to PBR before export
  3. Embed textures, separate texture files will not resolve inside SCORM ZIP
File size guidance

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.

07SCORM Integration

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.

Strict-CSP / offline delivery

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.

08Section 508 / WCAG 2.1 AA

Section 508 compliance is a default requirement, not a review step. The Forge3D block was designed for accessibility from the first sprint.

RequirementImplementation
Keyboard accessCanvas tabindex="0". Arrow keys orbit. +/- zoom. R resets. Tab cycles pins. Enter/Space opens popover. Escape closes.
Screen readerCanvas role="img" with aria-label including keyboard instructions. Pins have role="button" and descriptive aria-label.
Focus managementPopovers receive focus on open. Escape returns focus to triggering pin. focus-visible ring uses 2px amber outline.
Reduced motionprefers-reduced-motion disables loading spinner and pin pulse animation.
Color contrastAmber on navy exceeds 4.5:1 for normal text. Pin white border provides contrast on both light and dark model surfaces.
Touch targetsPins are 14px with effective 44px tap area via transform scale on hover/focus.
Seizure riskNo flashing animations. Pin pulse is slow (2.5s), low-amplitude, well below the 3Hz threshold.
09API Reference

Server routes

RouteMethodDescription
/api/media/modelPOSTUpload .glb / .gltf. Returns id, serve_url, file_size_mb.
/api/media/model/{id}.glbGETServe the GLB file for editor preview and SCORM output.
/api/frames/{id}/preview-htmlGETRender 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.