150 lines
3.8 KiB
Svelte
150 lines
3.8 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import Icon from '@iconify/svelte';
|
|
import type { ModelScene } from '#lib/models/model-scene';
|
|
|
|
let { modelUrl, modelName }: { modelUrl: string; modelName: string } = $props();
|
|
|
|
let container = $state<HTMLDivElement>();
|
|
let modelScene = $state<ModelScene | null>(null);
|
|
let status = $state<'loading' | 'ready' | 'error' | 'unsupported'>('loading');
|
|
let autoRotate = $state(true);
|
|
let wireframe = $state(false);
|
|
let brightness = $state(0.6);
|
|
|
|
onMount(() => {
|
|
let disposed = false;
|
|
let resizeObserver: ResizeObserver | undefined;
|
|
|
|
import('#lib/models/model-scene')
|
|
.then(({ ModelScene }) => {
|
|
if (disposed || !container) return;
|
|
modelScene = new ModelScene(container);
|
|
resizeObserver = new ResizeObserver(() => modelScene?.resize());
|
|
resizeObserver.observe(container);
|
|
})
|
|
.catch(() => (status = 'unsupported'));
|
|
|
|
return () => {
|
|
disposed = true;
|
|
resizeObserver?.disconnect();
|
|
modelScene?.dispose();
|
|
};
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!modelScene) return;
|
|
status = 'loading';
|
|
wireframe = false;
|
|
modelScene
|
|
.load(modelUrl)
|
|
.then(() => (status = 'ready'))
|
|
.catch(() => (status = 'error'));
|
|
});
|
|
|
|
$effect(() => modelScene?.setAutoRotate(autoRotate));
|
|
$effect(() => modelScene?.setWireframe(wireframe));
|
|
$effect(() => modelScene?.setBrightness(brightness));
|
|
</script>
|
|
|
|
<div class="viewer">
|
|
<div class="toolbar">
|
|
<span class="model-name">{modelName}</span>
|
|
<div class="controls">
|
|
<button class:active={autoRotate} title="Auto rotate" onclick={() => (autoRotate = !autoRotate)}>
|
|
<Icon icon="mdi:rotate-3d-variant" width="16" />
|
|
</button>
|
|
<button class:active={wireframe} title="Wireframe" onclick={() => (wireframe = !wireframe)}>
|
|
<Icon icon="mdi:cube-outline" width="16" />
|
|
</button>
|
|
<button title="Darker" onclick={() => (brightness = Math.max(0, brightness - 0.2))}>
|
|
<Icon icon="mdi:brightness-5" width="16" />
|
|
</button>
|
|
<button title="Brighter" onclick={() => (brightness = Math.min(3, brightness + 0.2))}>
|
|
<Icon icon="mdi:brightness-7" width="16" />
|
|
</button>
|
|
<button title="Reset camera" onclick={() => modelScene?.resetCamera()}>
|
|
<Icon icon="mdi:camera-retake" width="16" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="canvas-area" bind:this={container} tabindex="0" role="application" aria-label="3D view of {modelName}">
|
|
{#if status !== 'ready'}
|
|
<div class="status muted">
|
|
{#if status === 'loading'}
|
|
loading model...
|
|
{:else if status === 'error'}
|
|
failed to load model
|
|
{:else}
|
|
WebGL is not available in this browser
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="hints muted">drag to rotate · scroll to zoom · arrow keys after clicking</div>
|
|
</div>
|
|
|
|
<style>
|
|
.viewer {
|
|
display: flex;
|
|
flex-direction: column;
|
|
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.3rem 0.6rem;
|
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
|
}
|
|
|
|
.model-name {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.controls button {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
background: none;
|
|
border: 1px solid transparent;
|
|
}
|
|
|
|
.controls button:hover {
|
|
border-color: color-mix(in srgb, var(--foreground) 30%, transparent);
|
|
}
|
|
|
|
.controls button.active {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.canvas-area {
|
|
position: relative;
|
|
height: 380px;
|
|
background: radial-gradient(circle, var(--lighter-background), var(--dark-background));
|
|
}
|
|
|
|
.canvas-area :global(canvas) {
|
|
display: block;
|
|
}
|
|
|
|
.status {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
|
|
.hints {
|
|
padding: 0.3rem 0.6rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
</style>
|