Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ The 3D tab catalogs 7,345 models, 8,404 animation files, and 2,254 collision
meshes from the US retail disc. The SDK executes the model's PS2 VIF packets,
reconstructs geometry, materials, bone hierarchies, skin weights, inverse bind
matrices, and I3M quaternion tracks, and resolves TIM2 materials within the
model's own package. Three.js renders the result with orbit controls, model
rotation toggled by R, framing, wireframe, grid, skeleton, playback, and
timeline controls.
model's own package. Three.js renders the result with camera orbit controls and
a DCC-style rotation gizmo: drag its center for free rotation or a colored X,
Y, or Z ring for constrained rotation. The gizmo rotates only the preview
wrapper, preserving the authored model transform for glTF export. Framing,
wireframe, grid, skeleton, playback, and timeline controls remain available.

Selecting a model lists every compatible animation in its package. Selecting an
animation finds a compatible skeleton by exact track and bone names, including
Expand Down
11 changes: 9 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -384,14 +384,21 @@ <h2>MODELS &amp; ANIMATION</h2>
</div>
</header>
<div id="model-workspace" hidden>
<section id="model-viewport" aria-label="Realtime 3D model preview">
<section id="model-viewport" aria-label="Realtime 3D model preview. Drag the background to orbit. Drag the center rotation handle for free rotation, or a colored ring to rotate on one axis.">
<canvas id="model-canvas"></canvas>
<div id="model-toolbar" aria-label="3D view controls">
<button id="model-reset-view" type="button">FRAME</button>
<button id="model-wireframe" type="button">WIREFRAME</button>
<button id="model-grid" class="on" type="button">GRID</button>
<button id="model-skeleton" type="button">SKELETON</button>
<button id="model-rotate" type="button">ROTATE</button>
</div>
<div id="model-rotation-help" hidden
aria-label="Rotation controls: drag the center for free rotation; drag the X, Y, or Z ring for constrained rotation.">
<span class="model-rotation-label">ROTATE</span>
<span data-axis="XYZE"><b></b>FREE</span>
<span data-axis="X"><b>X</b></span>
<span data-axis="Y"><b>Y</b></span>
<span data-axis="Z"><b>Z</b></span>
</div>
<div id="model-animation-bar">
<button id="model-anim-play" type="button" disabled>PLAY</button>
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ const KEYS_FMV = "SPACE play · ↑↓ movie · ENTER play · ←→ seek 5 s ·
+ "· F fullscreen · E exports · ESC exit/cancel · click bar to seek";
const KEYS_IMAGES = "↑↓ image · ENTER preview · E PNG · X original TM2 · A all PNG";
const KEYS_MODELS = "↑↓ asset · ENTER preview · SPACE play/pause animation · "
+ "R model rotation toggle · drag model to rotate · drag to orbit camera · wheel zoom";
+ "drag background to orbit · drag center for free rotation · drag X/Y/Z ring to constrain";

const sDur = (e: StreamEntry): number =>
e.sectors * (2048 / e.channels / 16 * 28) / e.rate;
Expand Down
134 changes: 46 additions & 88 deletions src/model-browser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { TransformControls } from "three/addons/controls/TransformControls.js";
import { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";
import {
decodeI3dAnimation,
Expand Down Expand Up @@ -265,22 +266,19 @@ export class ModelBrowser {
private loadToken = 0;
private busy = false;
private loaded: LoadedScene | null = null;
private modelPivot: THREE.Group | null = null;
private renderer: THREE.WebGLRenderer | null = null;
private camera: THREE.PerspectiveCamera | null = null;
private scene: THREE.Scene | null = null;
private controls: OrbitControls | null = null;
private rotationControls: TransformControls | null = null;
private viewRoot: THREE.Group | null = null;
private mixer: THREE.AnimationMixer | null = null;
private action: THREE.AnimationAction | null = null;
private skeleton: THREE.SkeletonHelper | null = null;
private resizeObserver: ResizeObserver | null = null;
private lastFrame = performance.now();
private sceneRadius = 0;
private playing = true;
private rotateModel = false;
private modelPointerId: number | null = null;
private lastPointerX = 0;
private lastPointerY = 0;
private readonly status: Status;

constructor(status: Status) {
Expand Down Expand Up @@ -312,7 +310,6 @@ export class ModelBrowser {
}

close(): void {
this.stopModelDrag();
if (this.action) this.action.paused = true;
}

Expand All @@ -336,11 +333,6 @@ export class ModelBrowser {
void this.select(this.filtered[next]!);
return true;
}
if ((event.key === "r" || event.key === "R")
&& !event.metaKey && !event.ctrlKey && !event.altKey && this.loaded) {
this.toggleModelRotation();
return true;
}
return false;
}

Expand Down Expand Up @@ -381,8 +373,6 @@ export class ModelBrowser {
this.mixer.setTime(time);
this.updateAnimationUi();
});
const rotateBtn = document.getElementById("model-rotate") as HTMLButtonElement | null;
if (rotateBtn) rotateBtn.addEventListener("click", () => this.toggleModelRotation());
}

private ensureRenderer(): void {
Expand All @@ -403,11 +393,21 @@ export class ModelBrowser {
this.controls.dampingFactor = 0.07;
this.controls.zoomToCursor = true;
this.controls.screenSpacePanning = true;
canvas.style.touchAction = "none";
canvas.addEventListener("pointerdown", this.onModelRotatePointerDown);
canvas.addEventListener("pointermove", this.onModelRotatePointerMove);
canvas.addEventListener("pointerup", this.onModelRotatePointerUp);
canvas.addEventListener("pointercancel", this.onModelRotatePointerUp);
this.rotationControls = new TransformControls(this.camera, canvas);
this.rotationControls.setMode("rotate");
this.rotationControls.setSpace("world");
this.rotationControls.setSize(1.15);
this.rotationControls.showE = false;
this.rotationControls.setColors(0xff5d67, 0x63d18b, 0x57a6ff, 0xffd166);
this.rotationControls.addEventListener("dragging-changed", event => {
const dragging = event.value === true;
if (this.controls) this.controls.enabled = !dragging;
viewport.classList.toggle("rotating-model", dragging);
});
this.rotationControls.addEventListener("axis-changed", event => {
this.updateRotationHint(typeof event.value === "string" ? event.value : null);
});
this.scene.add(this.rotationControls.getHelper());
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
ambient.name = "AE3_AMBIENT";
this.scene.add(ambient);
Expand Down Expand Up @@ -720,9 +720,19 @@ export class ModelBrowser {
private present(asset: ModelAsset, loaded: LoadedScene): void {
this.clearScene();
this.loaded = loaded;
this.modelPivot = new THREE.Group();
this.modelPivot.add(loaded.root);
this.scene!.add(this.modelPivot);
const pivot = new THREE.Box3().setFromObject(loaded.root).getCenter(new THREE.Vector3());
this.viewRoot = new THREE.Group();
this.viewRoot.name = "MODEL_VIEW_ROTATION";
this.viewRoot.position.copy(pivot);
const viewOffset = new THREE.Group();
viewOffset.name = "MODEL_VIEW_OFFSET";
viewOffset.position.copy(pivot).multiplyScalar(-1);
viewOffset.add(loaded.root);
this.viewRoot.add(viewOffset);
this.scene!.add(this.viewRoot);
const hasGeometry = loaded.root.children.length > 0;
if (hasGeometry) this.rotationControls?.attach(this.viewRoot);
element<HTMLElement>("model-rotation-help").hidden = !hasGeometry;
if (loaded.model && loaded.model.bones.length > 0) {
this.skeleton = new THREE.SkeletonHelper(loaded.root);
this.skeleton.visible = element<HTMLButtonElement>("model-skeleton").classList.contains("on");
Expand All @@ -732,8 +742,8 @@ export class ModelBrowser {
this.populateAnimations(loaded);
this.frameLoaded();
this.renderInspector(asset, loaded, null);
element<HTMLElement>("model-empty").hidden = loaded.root.children.length > 0;
if (loaded.root.children.length === 0) {
element<HTMLElement>("model-empty").hidden = hasGeometry;
if (!hasGeometry) {
element<HTMLElement>("model-empty").textContent = "Animation decoded. No compatible model was available in the same package.";
}
this.updateActions();
Expand All @@ -744,14 +754,18 @@ export class ModelBrowser {
this.action = null;
this.mixer?.stopAllAction();
this.mixer = null;
this.rotationControls?.detach();
if (this.controls) this.controls.enabled = true;
element<HTMLElement>("model-viewport").classList.remove("rotating-model");
element<HTMLElement>("model-rotation-help").hidden = true;
this.updateRotationHint(null);
if (this.skeleton && this.scene) this.scene.remove(this.skeleton);
this.skeleton = null;
if (this.modelPivot && this.scene) this.scene.remove(this.modelPivot);
this.modelPivot = null;
if (this.viewRoot && this.scene) this.scene.remove(this.viewRoot);
if (this.loaded) disposeObject(this.loaded.root);
this.viewRoot = null;
this.loaded = null;
this.sceneRadius = 0;
this.stopModelDrag();
this.populateAnimations(null);
}

Expand Down Expand Up @@ -821,10 +835,15 @@ export class ModelBrowser {
this.camera.far = far;
this.camera.updateProjectionMatrix();
}
private updateRotationHint(axis: string | null): void {
const hint = element<HTMLElement>("model-rotation-help");
if (axis) hint.dataset.activeAxis = axis;
else delete hint.dataset.activeAxis;
}

private frameLoaded(): void {
if (!this.loaded || !this.camera || !this.controls) return;
const box = new THREE.Box3().setFromObject(this.loaded.root);
const box = new THREE.Box3().setFromObject(this.viewRoot ?? this.loaded.root);
if (box.isEmpty()) return;
const sphere = box.getBoundingSphere(new THREE.Sphere());
const radius = Math.max(sphere.radius, 0.01);
Expand Down Expand Up @@ -865,67 +884,6 @@ export class ModelBrowser {
if (this.skeleton) this.skeleton.visible = button.classList.contains("on");
}

private toggleModelRotation(): void {
this.rotateModel = !this.rotateModel;
const canvas = element<HTMLCanvasElement>("model-canvas");
const btn = document.getElementById("model-rotate") as HTMLButtonElement | null;
if (this.rotateModel) {
canvas.style.cursor = "grab";
if (this.controls) this.controls.enableRotate = false;
if (btn) {
btn.classList.add("on");
btn.textContent = "ROTATE ON";
}
this.status("MODEL ROTATION: ON — drag to rotate, right-click to pan, scroll to zoom");
} else {
if (this.controls) this.controls.enableRotate = true;
if (btn) {
btn.classList.remove("on");
btn.textContent = "ROTATE";
}
this.stopModelDrag();
this.status("MODEL ROTATION: OFF — camera orbit enabled");
}
}

private stopModelDrag(): void {
const canvas = element<HTMLCanvasElement>("model-canvas");
if (this.modelPointerId !== null && canvas.hasPointerCapture(this.modelPointerId)) {
canvas.releasePointerCapture(this.modelPointerId);
}
this.modelPointerId = null;
canvas.style.cursor = this.rotateModel ? "grab" : "";
}

private readonly onModelRotatePointerDown = (event: PointerEvent): void => {
if (!this.rotateModel || !event.isPrimary || event.button !== 0
|| this.modelPointerId !== null || !this.loaded || !this.modelPivot) return;
const canvas = event.currentTarget as HTMLCanvasElement;
this.modelPointerId = event.pointerId;
this.lastPointerX = event.clientX;
this.lastPointerY = event.clientY;
canvas.setPointerCapture(event.pointerId);
canvas.style.cursor = "grabbing";
};

private readonly onModelRotatePointerMove = (event: PointerEvent): void => {
if (this.modelPointerId !== event.pointerId || !event.isPrimary) return;
if (!this.rotateModel || !this.loaded || !this.modelPivot) {
this.onModelRotatePointerUp(event);
return;
}
const deltaX = event.clientX - this.lastPointerX;
const deltaY = event.clientY - this.lastPointerY;
this.lastPointerX = event.clientX;
this.lastPointerY = event.clientY;
this.modelPivot.rotation.y += deltaX * 0.01;
this.modelPivot.rotation.x += deltaY * 0.01;
};

private readonly onModelRotatePointerUp = (event: PointerEvent): void => {
if (event.pointerId !== this.modelPointerId) return;
this.stopModelDrag();
};

private renderInspector(asset: ModelAsset, loaded: LoadedScene | null,
error: string | null): void {
Expand Down
72 changes: 72 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -2037,6 +2037,8 @@ button.ex-row:disabled {
height: 100%;
outline: 1px solid oklch(1 0 0 / 0.08);
}
#model-canvas { cursor: grab; }
#model-viewport.rotating-model #model-canvas { cursor: grabbing; }
#model-toolbar {
position: absolute;
z-index: 2;
Expand Down Expand Up @@ -2067,6 +2069,75 @@ button.ex-row:disabled {
border-color: color-mix(in srgb, var(--model-accent) 40%, transparent);
background: color-mix(in srgb, var(--model-accent) 10%, transparent);
}
#model-rotation-help {
position: absolute;
z-index: 2;
bottom: 68px;
left: 12px;
display: flex;
align-items: center;
gap: 4px;
padding: 5px;
border-radius: 7px;
color: var(--dim);
background: oklch(0.17 0.018 240 / 0.82);
box-shadow:
0 10px 28px oklch(0 0 0 / 0.3),
inset 0 0 0 1px oklch(1 0 0 / 0.09);
backdrop-filter: blur(14px);
pointer-events: none;
}
#model-rotation-help[hidden] { display: none; }
.model-rotation-label {
padding: 0 5px;
font-size: 7px;
letter-spacing: 0.12em;
}
#model-rotation-help [data-axis] {
display: inline-flex;
min-height: 28px;
align-items: center;
gap: 4px;
padding: 3px 5px;
border-radius: 4px;
font-size: 7px;
letter-spacing: 0.07em;
opacity: 0.72;
transition-property: color, background-color, opacity;
transition-duration: 100ms;
}
#model-rotation-help b {
display: grid;
width: 18px;
height: 18px;
place-items: center;
font-size: 8px;
}
#model-rotation-help [data-axis="XYZE"] b {
position: relative;
width: 15px;
height: 15px;
border: 1px solid #d7e1e8;
border-radius: 50%;
}
#model-rotation-help [data-axis="XYZE"] b::after {
position: absolute;
inset: 4px;
border-radius: 50%;
background: #d7e1e8;
content: "";
}
#model-rotation-help [data-axis="X"] b { color: #ff5d67; }
#model-rotation-help [data-axis="Y"] b { color: #63d18b; }
#model-rotation-help [data-axis="Z"] b { color: #57a6ff; }
#model-rotation-help[data-active-axis="XYZE"] [data-axis="XYZE"],
#model-rotation-help[data-active-axis="X"] [data-axis="X"],
#model-rotation-help[data-active-axis="Y"] [data-axis="Y"],
#model-rotation-help[data-active-axis="Z"] [data-axis="Z"] {
color: #ffd166;
background: oklch(0.82 0.16 86 / 0.12);
opacity: 1;
}
#model-animation-bar {
position: absolute;
z-index: 2;
Expand Down Expand Up @@ -2220,4 +2291,5 @@ button.ex-row:disabled {
#model-anim-time { grid-column: 1 / -1; }
#model-anim-readout { display: none; }
#model-toolbar { right: 12px; }
#model-rotation-help { bottom: 104px; }
}
Loading