FRAME
@@ -392,6 +392,14 @@
MODELS & ANIMATION
GRID
SKELETON
+
+ ROTATE
+ FREE
+ X
+ Y
+ Z
+
PLAY
diff --git a/src/model-browser.ts b/src/model-browser.ts
index 6661e70..aa2a46e 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,
@@ -269,6 +270,8 @@ export class ModelBrowser {
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;
@@ -390,6 +393,21 @@ export class ModelBrowser {
this.controls.dampingFactor = 0.07;
this.controls.zoomToCursor = true;
this.controls.screenSpacePanning = true;
+ 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);
@@ -702,7 +720,19 @@ export class ModelBrowser {
private present(asset: ModelAsset, loaded: LoadedScene): void {
this.clearScene();
this.loaded = loaded;
- this.scene!.add(loaded.root);
+ 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");
@@ -712,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();
@@ -724,12 +754,16 @@ 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.loaded && this.scene) {
- this.scene.remove(this.loaded.root);
- disposeObject(this.loaded.root);
- }
+ 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.populateAnimations(null);
@@ -801,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);
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; }
}