Remove models page
This commit is contained in:
1 parent
a0fd2fa2a1
commit
fbc314b535
12 files changed
+3
-413
No files matched your search
@@ -7,7 +7,6 @@
|
||||
<img src="https://img.shields.io/badge/SvelteKit-FF3E00?style=for-the-badge&logo=svelte&logoColor=white" alt="SvelteKit" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Tailwind_CSS-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white" alt="Tailwind CSS" />
|
||||
<img src="https://img.shields.io/badge/Three.js-000000?style=for-the-badge&logo=threedotjs&logoColor=white" alt="Three.js" />
|
||||
<img src="https://img.shields.io/badge/Bun-000000?style=for-the-badge&logo=bun&logoColor=white" alt="Bun" />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +21,7 @@ The site recreates my [blomarchy](https://git.sirblob.co/SirBlob/blomarchy) setu
|
||||
- **Kitty terminals** - a real command line with history and tab completion. Try `fastfetch`, `whoami` or `cowsay`.
|
||||
- **Desktop icons** - grouped into pages, terminal scripts and socials (GitHub, LinkedIn, Devpost, Discord).
|
||||
- **13 themes** - Flats, Retro 82, Tokyo Night, Catppuccin, Arch Linux, Gruvbox, Nord, Kanagawa, Wintry, Rose, Cerberus, Catppuccin Latte and Rose Pine Dawn, each with its own wallpaper.
|
||||
- **Portfolio** - about, portfolio, projects (open source, packages, hackathons, research), blog with RSS, 3D models and links with QR codes.
|
||||
- **Portfolio** - about, portfolio, projects (open source, packages, hackathons, research), blog with RSS and links with QR codes.
|
||||
- **Mobile friendly** - on small screens windows go fullscreen with tabs to switch between them.
|
||||
|
||||
## Keybinds
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
{ "number": 3, "name": "projects", "path": "/projects" },
|
||||
{ "number": 4, "name": "blog", "path": "/blog" },
|
||||
{ "number": 5, "name": "links", "path": "/links" },
|
||||
{ "number": 6, "name": "portfolio", "path": "/portfolio" },
|
||||
{ "number": 7, "name": "models", "path": "/models" }
|
||||
{ "number": 6, "name": "portfolio", "path": "/portfolio" }
|
||||
],
|
||||
"iconGroups": [
|
||||
{
|
||||
@@ -18,7 +17,6 @@
|
||||
{ "label": "projects", "icon": "ph:folder-open-duotone", "open": { "page": "/projects" } },
|
||||
{ "label": "hackathons", "icon": "ph:trophy-duotone", "open": { "page": "/projects/hackathons" } },
|
||||
{ "label": "blog", "icon": "ph:notebook-duotone", "open": { "page": "/blog" } },
|
||||
{ "label": "models", "icon": "ph:cube-duotone", "open": { "page": "/models" } },
|
||||
{ "label": "links", "icon": "ph:qr-code-duotone", "open": { "page": "/links" } }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "Bake Potato",
|
||||
"description": "A fun baked-potato 3D model for demos and testing.",
|
||||
"file": "/models/BakePotato.glb",
|
||||
"software": ["Blender"],
|
||||
"polyCount": "1k tris"
|
||||
},
|
||||
{
|
||||
"name": "Soda Can",
|
||||
"description": "A realistic soda can GLB used for materials/tests.",
|
||||
"file": "/models/Soda.glb",
|
||||
"software": ["Blender"],
|
||||
"polyCount": "800 tris"
|
||||
}
|
||||
]
|
||||
+1
-3
@@ -28,10 +28,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/svelte": "^5.2.3",
|
||||
"@types/three": "^0.185.4",
|
||||
"discord.js": "^14.27.0",
|
||||
"svelte-qrcode": "^1.0.1",
|
||||
"three": "^0.185.1"
|
||||
"svelte-qrcode": "^1.0.1"
|
||||
},
|
||||
"imports": {
|
||||
"#lib": "./src/lib/index.js",
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user