-
-
-
-
+
+
diff --git a/src/model-browser.ts b/src/model-browser.ts
index 8adc2af..79edc0e 100644
--- a/src/model-browser.ts
+++ b/src/model-browser.ts
@@ -278,6 +278,7 @@ export class ModelBrowser {
private resizeObserver: ResizeObserver | null = null;
private lastFrame = performance.now();
private sceneRadius = 0;
+ private rotationEnabled = false;
private playing = true;
private readonly status: Status;
@@ -355,6 +356,8 @@ export class ModelBrowser {
element("model-export-source").addEventListener("click", () => this.exportSource());
element("model-export-glb").addEventListener("click", () => void this.exportGlb());
element("model-reset-view").addEventListener("click", () => this.frameLoaded());
+ element("model-rotation-toggle").addEventListener("click", event =>
+ this.toggleRotation(event.currentTarget as HTMLButtonElement));
element("model-wireframe").addEventListener("click", event =>
this.toggleWireframe(event.currentTarget as HTMLButtonElement));
element("model-grid").addEventListener("click", event =>
@@ -731,13 +734,23 @@ export class ModelBrowser {
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) {
+ element("model-reset-view").disabled = !hasGeometry;
+ element("model-wireframe").disabled = !hasGeometry;
+ const rotationButton = element("model-rotation-toggle");
+ rotationButton.disabled = !hasGeometry;
+ if (hasGeometry && this.rotationEnabled) this.rotationControls?.attach(this.viewRoot);
+ element("model-rotation-help").hidden =
+ !hasGeometry || !this.rotationEnabled;
+ const hasSkeleton = Boolean(loaded.model?.bones.length);
+ element("model-skeleton").disabled = !hasSkeleton;
+ if (hasSkeleton) {
this.skeleton = new THREE.SkeletonHelper(loaded.root);
this.skeleton.visible = element("model-skeleton").classList.contains("on");
this.scene!.add(this.skeleton);
}
+ this.applyWireframe(
+ element("model-wireframe").classList.contains("on"),
+ );
this.mixer = new THREE.AnimationMixer(loaded.root);
this.populateAnimations(loaded);
this.frameLoaded();
@@ -758,6 +771,10 @@ export class ModelBrowser {
if (this.controls) this.controls.enabled = true;
element("model-viewport").classList.remove("rotating-model");
element("model-rotation-help").hidden = true;
+ element("model-reset-view").disabled = true;
+ element("model-wireframe").disabled = true;
+ element("model-rotation-toggle").disabled = true;
+ element("model-skeleton").disabled = true;
this.updateRotationHint(null);
if (this.skeleton && this.scene) this.scene.remove(this.skeleton);
this.skeleton = null;
@@ -841,6 +858,27 @@ export class ModelBrowser {
else delete hint.dataset.activeAxis;
}
+ private toggleRotation(button: HTMLButtonElement): void {
+ if (!this.viewRoot) return;
+ this.rotationEnabled = !this.rotationEnabled;
+ button.classList.toggle("on", this.rotationEnabled);
+ button.setAttribute("aria-pressed", String(this.rotationEnabled));
+ button.setAttribute("aria-label",
+ this.rotationEnabled ? "Stop rotating model" : "Rotate model");
+ button.title = this.rotationEnabled
+ ? "Turn off model rotation"
+ : "Rotate model freely or around one axis";
+ if (this.rotationEnabled) {
+ this.rotationControls?.attach(this.viewRoot);
+ } else {
+ this.rotationControls?.detach();
+ if (this.controls) this.controls.enabled = true;
+ element("model-viewport").classList.remove("rotating-model");
+ this.updateRotationHint(null);
+ }
+ element("model-rotation-help").hidden = !this.rotationEnabled;
+ }
+
private frameLoaded(): void {
if (!this.loaded || !this.camera || !this.controls) return;
const box = new THREE.Box3().setFromObject(this.viewRoot ?? this.loaded.root);
@@ -864,8 +902,15 @@ export class ModelBrowser {
}
private toggleWireframe(button: HTMLButtonElement): void {
- button.classList.toggle("on");
- const enabled = button.classList.contains("on");
+ const enabled = button.classList.toggle("on");
+ button.setAttribute("aria-pressed", String(enabled));
+ const label = enabled ? "Hide wireframe" : "Show wireframe";
+ button.setAttribute("aria-label", label);
+ button.title = label;
+ this.applyWireframe(enabled);
+ }
+
+ private applyWireframe(enabled: boolean): void {
this.loaded?.root.traverse(object => {
if (!(object instanceof THREE.Mesh)) return;
const materials = Array.isArray(object.material) ? object.material : [object.material];
@@ -874,14 +919,22 @@ export class ModelBrowser {
}
private toggleGrid(button: HTMLButtonElement): void {
- button.classList.toggle("on");
+ const enabled = button.classList.toggle("on");
+ button.setAttribute("aria-pressed", String(enabled));
+ const label = enabled ? "Hide floor grid" : "Show floor grid";
+ button.setAttribute("aria-label", label);
+ button.title = label;
const grid = this.scene?.getObjectByName("MODEL_GRID");
- if (grid) grid.visible = button.classList.contains("on");
+ if (grid) grid.visible = enabled;
}
private toggleSkeleton(button: HTMLButtonElement): void {
- button.classList.toggle("on");
- if (this.skeleton) this.skeleton.visible = button.classList.contains("on");
+ const enabled = button.classList.toggle("on");
+ button.setAttribute("aria-pressed", String(enabled));
+ const label = enabled ? "Hide skeleton" : "Show skeleton";
+ button.setAttribute("aria-label", label);
+ button.title = label;
+ if (this.skeleton) this.skeleton.visible = enabled;
}
diff --git a/src/style.css b/src/style.css
index 32e7001..464cf07 100644
--- a/src/style.css
+++ b/src/style.css
@@ -2048,27 +2048,69 @@ button.ex-row:disabled {
flex-wrap: wrap;
gap: 5px;
padding: 5px;
- border-radius: 7px;
+ border-radius: 8px;
background: oklch(0.17 0.018 240 / 0.86);
box-shadow:
0 10px 28px oklch(0 0 0 / 0.35),
inset 0 0 0 1px oklch(1 0 0 / 0.1);
backdrop-filter: blur(14px);
}
-#model-toolbar button {
- min-height: 34px;
- padding: 6px 8px;
+#model-toolbar button,
+#model-transform-toolbar button {
+ display: inline-flex;
+ min-height: 40px;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 7px 9px;
border-color: transparent;
color: var(--dim);
font-size: 8px;
- transition-property: color, background-color, border-color;
+ transition-property: color, background-color, border-color, opacity;
transition-duration: 120ms;
}
-#model-toolbar button:hover, #model-toolbar button.on {
+.model-tool-icon {
+ width: 16px;
+ height: 16px;
+ flex: none;
+ fill: none;
+ stroke: currentcolor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.6;
+}
+.model-tool-label { pointer-events: none; }
+#model-toolbar button:not(:disabled):hover,
+#model-toolbar button.on,
+#model-transform-toolbar button:not(:disabled):hover,
+#model-transform-toolbar button.on {
color: var(--model-accent);
border-color: color-mix(in srgb, var(--model-accent) 40%, transparent);
background: color-mix(in srgb, var(--model-accent) 10%, transparent);
}
+#model-toolbar button:focus-visible,
+#model-transform-toolbar button:focus-visible {
+ outline: 2px solid var(--model-accent);
+ outline-offset: 2px;
+}
+#model-transform-toolbar {
+ position: absolute;
+ z-index: 2;
+ top: 12px;
+ right: 12px;
+ padding: 5px;
+ border-radius: 8px;
+ background: oklch(0.17 0.018 240 / 0.86);
+ box-shadow:
+ 0 10px 28px oklch(0 0 0 / 0.35),
+ inset 0 0 0 1px oklch(1 0 0 / 0.1);
+ backdrop-filter: blur(14px);
+}
+#model-toolbar button:disabled,
+#model-transform-toolbar button:disabled {
+ opacity: 0.38;
+ cursor: default;
+}
#model-rotation-help {
position: absolute;
z-index: 2;
@@ -2290,6 +2332,12 @@ button.ex-row:disabled {
}
#model-anim-time { grid-column: 1 / -1; }
#model-anim-readout { display: none; }
- #model-toolbar { right: 12px; }
+ #model-toolbar { right: 64px; }
+ #model-toolbar button, #model-transform-toolbar button {
+ width: 40px;
+ padding-inline: 0;
+ }
+ #model-toolbar .model-tool-label,
+ #model-transform-toolbar .model-tool-label { display: none; }
#model-rotation-help { bottom: 104px; }
}