feat: add 3D preview component and integrate it into the designer page
All checks were successful
Deploy Production / deploy (push) Successful in 2m2s

This commit is contained in:
Frank John Begornia
2026-01-14 21:13:13 +08:00
parent b6403bde4f
commit a8b1ea64fb
5 changed files with 209 additions and 2 deletions

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
const props = defineProps<{
modelPath: string;
canvasTexture?: HTMLCanvasElement | null;
}>();
const containerRef = ref<HTMLDivElement | null>(null);
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let model: THREE.Object3D | null = null;
let jerseyMaterial: THREE.MeshStandardMaterial | null = null;
let animationFrameId: number;
const setupScene = () => {
if (!containerRef.value) return;
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e293b);
// Camera
camera = new THREE.PerspectiveCamera(
45,
containerRef.value.clientWidth / containerRef.value.clientHeight,
0.1,
1000
);
camera.position.set(0, 1.5, 3);
// Renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
containerRef.value.appendChild(renderer.domElement);
// Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.autoRotate = true;
controls.autoRotateSpeed = 2;
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(5, 5, 5);
directionalLight.castShadow = true;
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight2.position.set(-5, 5, -5);
scene.add(directionalLight2);
// Load model
loadModel();
// Animation loop
const animate = () => {
animationFrameId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
// Handle resize
window.addEventListener('resize', handleResize);
};
const handleResize = () => {
if (!containerRef.value) return;
camera.aspect = containerRef.value.clientWidth / containerRef.value.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight);
};
const loadModel = () => {
const loader = new GLTFLoader();
loader.load(
props.modelPath,
(gltf) => {
model = gltf.scene;
// Find the mesh that should receive the texture
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
// Create a material for the jersey surface
jerseyMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
roughness: 0.7,
metalness: 0.1,
});
mesh.material = jerseyMaterial;
mesh.castShadow = true;
mesh.receiveShadow = true;
// Apply initial texture if provided
if (props.canvasTexture) {
updateTexture(props.canvasTexture);
}
}
});
scene.add(model);
// Center and scale model
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
model.scale.multiplyScalar(scale);
},
undefined,
(error) => {
console.error('Error loading model:', error);
}
);
};
const updateTexture = (canvas: HTMLCanvasElement) => {
if (!jerseyMaterial) return;
// Create texture from canvas
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
texture.colorSpace = THREE.SRGBColorSpace;
// Apply texture to material
jerseyMaterial.map = texture;
jerseyMaterial.needsUpdate = true;
};
// Watch for texture updates
watch(() => props.canvasTexture, (newCanvas) => {
if (newCanvas) {
updateTexture(newCanvas);
}
});
onMounted(() => {
setupScene();
});
onBeforeUnmount(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
window.removeEventListener('resize', handleResize);
renderer?.dispose();
controls?.dispose();
});
</script>
<template>
<div ref="containerRef" class="w-full h-full rounded-lg overflow-hidden"></div>
</template>

View File

@@ -3,6 +3,7 @@ import DesignerCanvas from "~/components/designer/DesignerCanvas.vue";
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
import TemplatePicker from "~/components/designer/TemplatePicker.vue";
import ThreeDPreview from "~/components/designer/ThreeDPreview.vue";
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
import type { ExportedDesign } from "~/composables/useSlipmatDesigner";
@@ -40,14 +41,21 @@ const {
zoomIn,
zoomOut,
resetZoom,
getCanvasElement,
} = useSlipmatDesigner();
// Active view selector for multi-canvas design
const activeView = ref<"front" | "top" | "left" | "right">("front");
const canvasElement = ref<HTMLCanvasElement | null>(null);
const setActiveView = (view: "front" | "top" | "left" | "right") => {
activeView.value = view;
setActiveCanvas(view);
updateCanvasElement();
};
const updateCanvasElement = () => {
canvasElement.value = getCanvasElement();
};
const DESIGN_PRICE_USD = 39.99;
@@ -257,8 +265,17 @@ watch(
{ immediate: true }
);
// Update canvas element reference when canvas changes
watch(
() => getCanvasElement(),
(newCanvas) => {
canvasElement.value = newCanvas;
}
);
onMounted(() => {
initAuth();
updateCanvasElement();
});
const handleTemplateSelect = (templateId: string) => {
@@ -507,6 +524,21 @@ const handleCheckout = async () => {
</p>
</div>
</div>
<!-- 3D Preview Section -->
<div class="rounded-3xl border border-slate-200 bg-white shadow-xl p-6">
<h3 class="mb-4 text-lg font-bold text-slate-900">3D Preview</h3>
<div class="aspect-square w-full rounded-lg overflow-hidden bg-gradient-to-br from-slate-700 to-slate-900">
<ThreeDPreview
model-path="/LAMESA.glb"
:canvas-texture="canvasElement"
@mounted="updateCanvasElement"
/>
</div>
<p class="mt-4 text-sm text-slate-600">
Your design will appear on the 3D model in real-time. Rotate to view from different angles.
</p>
</div>
</div>
<DesignerPreview

View File

@@ -120,7 +120,7 @@ onMounted(() => {
class="flex h-full w-full items-center justify-center rounded-2xl border-2 border-slate-300 bg-linear-to-br from-slate-700 to-slate-900 shadow-xl overflow-hidden"
>
<model-viewer
src="/LAMESA.glb"
src="/LAMESA-2.glb"
alt="Table with Jersey"
auto-rotate
rotation-per-second="15deg"

View File

@@ -872,6 +872,7 @@ export const useSlipmatDesigner = () => {
refreshPreview,
schedulePreviewRefresh,
applyTemplateToCanvas,
getCanvasElement: () => canvas.value?.lowerCanvasEl || null,
};
};

BIN
public/LAMESA-2.glb Normal file

Binary file not shown.