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
50 changes: 44 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -384,13 +384,51 @@ <h2>MODELS &amp; ANIMATION</h2>
</div>
</header>
<div id="model-workspace" hidden>
<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.">
<section id="model-viewport" aria-label="Realtime 3D model preview. Drag the background to orbit. Use the Rotate tool to turn the model freely or around 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>
<div id="model-toolbar" aria-label="3D view options">
<button id="model-reset-view" type="button" aria-label="Frame model"
title="Frame model" disabled>
<svg class="model-tool-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</svg>
<span class="model-tool-label">FRAME</span>
</button>
<button id="model-wireframe" type="button" aria-label="Show wireframe"
aria-pressed="false" title="Show wireframe" disabled>
<svg class="model-tool-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Z" />
<path d="m4 7.5 8 4.5 8-4.5M12 12v9" />
</svg>
<span class="model-tool-label">WIREFRAME</span>
</button>
<button id="model-grid" class="on" type="button" aria-label="Hide floor grid"
aria-pressed="true" title="Hide floor grid">
<svg class="model-tool-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m4 7 8-4 8 4-8 4-8-4Zm0 5 8 4 8-4M4 17l8 4 8-4" />
</svg>
<span class="model-tool-label">GRID</span>
</button>
<button id="model-skeleton" type="button" aria-label="Show skeleton"
aria-pressed="false" title="Show skeleton" disabled>
<svg class="model-tool-icon" aria-hidden="true" viewBox="0 0 24 24">
<circle cx="12" cy="5" r="2" />
<path d="M12 7v6m-4-3h8m-4 3-4 7m4-7 4 7" />
</svg>
<span class="model-tool-label">SKELETON</span>
</button>
</div>
<div id="model-transform-toolbar" aria-label="Model transform tools">
<button id="model-rotation-toggle" type="button" aria-label="Rotate model"
aria-pressed="false" title="Rotate model freely or around one axis" disabled>
<svg class="model-tool-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M20 11a8 8 0 1 1-2.34-5.66L20 7.68" />
<path d="M20 3v4.68h-4.68" />
<circle cx="12" cy="12" r="2.5" />
</svg>
<span class="model-tool-label">ROTATE</span>
</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.">
Expand Down
71 changes: 62 additions & 9 deletions src/model-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -355,6 +356,8 @@ export class ModelBrowser {
element<HTMLButtonElement>("model-export-source").addEventListener("click", () => this.exportSource());
element<HTMLButtonElement>("model-export-glb").addEventListener("click", () => void this.exportGlb());
element<HTMLButtonElement>("model-reset-view").addEventListener("click", () => this.frameLoaded());
element<HTMLButtonElement>("model-rotation-toggle").addEventListener("click", event =>
this.toggleRotation(event.currentTarget as HTMLButtonElement));
element<HTMLButtonElement>("model-wireframe").addEventListener("click", event =>
this.toggleWireframe(event.currentTarget as HTMLButtonElement));
element<HTMLButtonElement>("model-grid").addEventListener("click", event =>
Expand Down Expand Up @@ -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<HTMLElement>("model-rotation-help").hidden = !hasGeometry;
if (loaded.model && loaded.model.bones.length > 0) {
element<HTMLButtonElement>("model-reset-view").disabled = !hasGeometry;
element<HTMLButtonElement>("model-wireframe").disabled = !hasGeometry;
const rotationButton = element<HTMLButtonElement>("model-rotation-toggle");
rotationButton.disabled = !hasGeometry;
if (hasGeometry && this.rotationEnabled) this.rotationControls?.attach(this.viewRoot);
element<HTMLElement>("model-rotation-help").hidden =
!hasGeometry || !this.rotationEnabled;
const hasSkeleton = Boolean(loaded.model?.bones.length);
element<HTMLButtonElement>("model-skeleton").disabled = !hasSkeleton;
if (hasSkeleton) {
this.skeleton = new THREE.SkeletonHelper(loaded.root);
this.skeleton.visible = element<HTMLButtonElement>("model-skeleton").classList.contains("on");
this.scene!.add(this.skeleton);
}
this.applyWireframe(
element<HTMLButtonElement>("model-wireframe").classList.contains("on"),
);
this.mixer = new THREE.AnimationMixer(loaded.root);
this.populateAnimations(loaded);
this.frameLoaded();
Expand All @@ -758,6 +771,10 @@ export class ModelBrowser {
if (this.controls) this.controls.enabled = true;
element<HTMLElement>("model-viewport").classList.remove("rotating-model");
element<HTMLElement>("model-rotation-help").hidden = true;
element<HTMLButtonElement>("model-reset-view").disabled = true;
element<HTMLButtonElement>("model-wireframe").disabled = true;
element<HTMLButtonElement>("model-rotation-toggle").disabled = true;
element<HTMLButtonElement>("model-skeleton").disabled = true;
this.updateRotationHint(null);
if (this.skeleton && this.scene) this.scene.remove(this.skeleton);
this.skeleton = null;
Expand Down Expand Up @@ -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<HTMLElement>("model-viewport").classList.remove("rotating-model");
this.updateRotationHint(null);
}
element<HTMLElement>("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);
Expand All @@ -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];
Expand All @@ -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;
}


Expand Down
62 changes: 55 additions & 7 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
}
Loading