Remove models page

This commit is contained in:
SirBlob committed 2026-10-26 16:11:08 -04:00
1 parent a0fd2fa2a1
commit fbc314b535
12 files changed
+3 -413

No files matched your search

@@ -1,156 +0,0 @@
<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;
}
@container (max-width: 600px) {
.canvas-area {
height: 280px;
touch-action: none;
}
}
</style>
-3
View File
@@ -2,7 +2,6 @@ import profileData from '#content/profile.json';
import experienceData from '#content/experience.json';
import leadershipData from '#content/leadership.json';
import aboutData from '#content/about.json';
import modelsData from '#content/models.json';
import desktopData from '#content/desktop.json';
import blogData from '#content/blog.json';
import openSourceData from '#content/projects/opensource.json';
@@ -15,7 +14,6 @@ import type {
DesktopContent,
Experience,
Leadership,
Model3D,
Profile,
Project,
ProjectCategory,
@@ -28,7 +26,6 @@ export const profile: Profile = profileData;
export const experience: Experience[] = experienceData;
export const leadership: Leadership[] = leadershipData;
export const about: AboutContent = aboutData;
export const models: Model3D[] = modelsData;
export const desktop: DesktopContent = desktopData;
export const blogSettings: BlogSettings = blogData;
-8
View File
@@ -119,14 +119,6 @@ export interface AboutContent {
movies: RatedTitle[];
}
export interface Model3D {
name: string;
description: string;
file: string;
software: string[];
polyCount?: string;
}
export type DesktopAction = { page: string } | { terminal: string } | { url: string };
export interface DesktopIconGroup {
-132
View File
@@ -1,132 +0,0 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const modelSize = 1.5;
const startingCameraPosition = new THREE.Vector3(0, 0.6, 2.5);
export class ModelScene {
private scene = new THREE.Scene();
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private controls: OrbitControls;
private ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
private model: THREE.Object3D | null = null;
private animationFrame = 0;
constructor(private container: HTMLElement) {
const { clientWidth, clientHeight } = container;
this.camera = new THREE.PerspectiveCamera(45, clientWidth / clientHeight, 0.1, 1000);
this.camera.position.copy(startingCameraPosition);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setSize(clientWidth, clientHeight);
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
container.appendChild(this.renderer.domElement);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.enablePan = false;
this.controls.minDistance = 1;
this.controls.maxDistance = 10;
this.controls.autoRotateSpeed = 2;
this.controls.listenToKeyEvents(container);
this.addLights();
this.animate();
}
private addLights() {
const keyLight = new THREE.DirectionalLight(0xffffff, 1);
keyLight.position.set(5, 5, 5);
const fillLight = new THREE.DirectionalLight(0xffffff, 0.5);
fillLight.position.set(-5, 3, -5);
const rimLight = new THREE.DirectionalLight(0x88ccff, 0.3);
rimLight.position.set(0, -3, -5);
this.scene.add(this.ambientLight, keyLight, fillLight, rimLight);
}
private animate = () => {
this.animationFrame = requestAnimationFrame(this.animate);
this.controls.update();
this.renderer.render(this.scene, this.camera);
};
load(url: string): Promise<void> {
return new Promise((resolve, reject) => {
new GLTFLoader().load(
url,
(gltf) => {
this.removeModel();
const model = gltf.scene;
const bounds = new THREE.Box3().setFromObject(model);
const center = bounds.getCenter(new THREE.Vector3());
const largestSide = Math.max(...bounds.getSize(new THREE.Vector3()).toArray());
const scale = modelSize / largestSide;
model.scale.setScalar(scale);
model.position.sub(center.multiplyScalar(scale));
this.scene.add(model);
this.model = model;
this.resetCamera();
resolve();
},
undefined,
reject
);
});
}
private removeModel() {
if (!this.model) return;
this.scene.remove(this.model);
this.model.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
child.geometry.dispose();
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((material) => material.dispose());
});
this.model = null;
}
setAutoRotate(enabled: boolean) {
this.controls.autoRotate = enabled;
}
setWireframe(enabled: boolean) {
this.model?.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((material) => {
if ('wireframe' in material) material.wireframe = enabled;
});
});
}
setBrightness(intensity: number) {
this.ambientLight.intensity = intensity;
}
resetCamera() {
this.camera.position.copy(startingCameraPosition);
this.controls.target.set(0, 0, 0);
this.controls.update();
}
resize() {
const { clientWidth, clientHeight } = this.container;
if (!clientWidth || !clientHeight) return;
this.camera.aspect = clientWidth / clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(clientWidth, clientHeight);
}
dispose() {
cancelAnimationFrame(this.animationFrame);
this.removeModel();
this.controls.dispose();
this.renderer.dispose();
this.renderer.domElement.remove();
}
}
-1
View File
@@ -27,7 +27,6 @@ export const sitePages: SitePage[] = [
}))
},
{ name: 'blog', path: '/blog', title: 'Blog', description: 'Notes and write-ups from my projects.', icon: 'ph:notebook-duotone' },
{ name: 'models', path: '/models', title: '3D Models', description: 'A collection of 3D models with an interactive viewer.', icon: 'ph:cube-duotone' },
{ name: 'links', path: '/links', title: 'Links', description: 'All my links with scannable QR codes.', icon: 'ph:qr-code-duotone' }
];
-89
View File
@@ -1,89 +0,0 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { models } from '#lib/content';
import PageHeader from '#lib/components/ui/PageHeader.svelte';
import ModelViewer from '#lib/components/models/ModelViewer.svelte';
import Tag from '#lib/components/ui/Tag.svelte';
let selectedModel = $state(models[0]);
</script>
<div class="page">
<PageHeader command="ls ~/models/" title="3D Models" subtitle="A collection of 3D models created for games, visualization, and fun." />
<div class="model-tabs">
{#each models as model (model.file)}
<button class:active={model === selectedModel} onclick={() => (selectedModel = model)}>
<Icon icon="mdi:cube-scan" width="15" />
{model.name}
</button>
{/each}
</div>
{#if selectedModel}
<ModelViewer modelUrl={selectedModel.file} modelName={selectedModel.name} />
<div class="model-info">
<p>{selectedModel.description}</p>
<div class="tags">
{#each selectedModel.software as software, index (software)}
<Tag label={software} colorIndex={index} />
{/each}
{#if selectedModel.polyCount}
<span class="muted">{selectedModel.polyCount}</span>
{/if}
<a class="download" href={selectedModel.file} download>download .glb</a>
</div>
</div>
{/if}
<p class="count">{models.length} models available</p>
</div>
<style>
.model-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.model-tabs button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.7rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
}
.model-tabs button:hover,
.model-tabs button.active {
border-color: var(--accent);
color: var(--accent);
}
.model-info {
margin-top: 0.75rem;
}
.model-info p {
margin: 0 0 0.5rem;
}
.tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
.download {
color: var(--blue);
text-decoration: underline;
}
.count {
color: var(--green);
}
</style>