FRAME
WIREFRAME
GRID
SKELETON
- ROTATE
+
+
+ ROTATE
+ FREE
+ X
+ Y
+ Z
PLAY
diff --git a/src/main.ts b/src/main.ts
index adffab9..42c685c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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;
diff --git a/src/model-browser.ts b/src/model-browser.ts
index 01ef920..8adc2af 100644
--- a/src/model-browser.ts
+++ b/src/model-browser.ts
@@ -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,
@@ -265,11 +266,12 @@ 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;
@@ -277,10 +279,6 @@ export class ModelBrowser {
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) {
@@ -312,7 +310,6 @@ export class ModelBrowser {
}
close(): void {
- this.stopModelDrag();
if (this.action) this.action.paused = true;
}
@@ -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;
}
@@ -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 {
@@ -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);
@@ -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("model-rotation-help").hidden = !hasGeometry;
if (loaded.model && loaded.model.bones.length > 0) {
this.skeleton = new THREE.SkeletonHelper(loaded.root);
this.skeleton.visible = element("model-skeleton").classList.contains("on");
@@ -732,8 +742,8 @@ export class ModelBrowser {
this.populateAnimations(loaded);
this.frameLoaded();
this.renderInspector(asset, loaded, null);
- element("model-empty").hidden = loaded.root.children.length > 0;
- if (loaded.root.children.length === 0) {
+ element("model-empty").hidden = hasGeometry;
+ if (!hasGeometry) {
element("model-empty").textContent = "Animation decoded. No compatible model was available in the same package.";
}
this.updateActions();
@@ -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("model-viewport").classList.remove("rotating-model");
+ element("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);
}
@@ -821,10 +835,15 @@ export class ModelBrowser {
this.camera.far = far;
this.camera.updateProjectionMatrix();
}
+ private updateRotationHint(axis: string | null): void {
+ const hint = element("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);
@@ -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("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("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 {
diff --git a/src/style.css b/src/style.css
index 7edb02d..32e7001 100644
--- a/src/style.css
+++ b/src/style.css
@@ -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;
@@ -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;
@@ -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; }
}