feat: add 3D preview component and integrate it into the designer page
All checks were successful
Deploy Production / deploy (push) Successful in 2m2s
All checks were successful
Deploy Production / deploy (push) Successful in 2m2s
This commit is contained in:
174
app/components/designer/ThreeDPreview.vue
Normal file
174
app/components/designer/ThreeDPreview.vue
Normal 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>
|
||||||
@@ -3,6 +3,7 @@ import DesignerCanvas from "~/components/designer/DesignerCanvas.vue";
|
|||||||
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
|
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
|
||||||
import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
|
import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
|
||||||
import TemplatePicker from "~/components/designer/TemplatePicker.vue";
|
import TemplatePicker from "~/components/designer/TemplatePicker.vue";
|
||||||
|
import ThreeDPreview from "~/components/designer/ThreeDPreview.vue";
|
||||||
|
|
||||||
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
|
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
|
||||||
import type { ExportedDesign } from "~/composables/useSlipmatDesigner";
|
import type { ExportedDesign } from "~/composables/useSlipmatDesigner";
|
||||||
@@ -40,14 +41,21 @@ const {
|
|||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut,
|
zoomOut,
|
||||||
resetZoom,
|
resetZoom,
|
||||||
|
getCanvasElement,
|
||||||
} = useSlipmatDesigner();
|
} = useSlipmatDesigner();
|
||||||
|
|
||||||
// Active view selector for multi-canvas design
|
// Active view selector for multi-canvas design
|
||||||
const activeView = ref<"front" | "top" | "left" | "right">("front");
|
const activeView = ref<"front" | "top" | "left" | "right">("front");
|
||||||
|
const canvasElement = ref<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
const setActiveView = (view: "front" | "top" | "left" | "right") => {
|
const setActiveView = (view: "front" | "top" | "left" | "right") => {
|
||||||
activeView.value = view;
|
activeView.value = view;
|
||||||
setActiveCanvas(view);
|
setActiveCanvas(view);
|
||||||
|
updateCanvasElement();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCanvasElement = () => {
|
||||||
|
canvasElement.value = getCanvasElement();
|
||||||
};
|
};
|
||||||
|
|
||||||
const DESIGN_PRICE_USD = 39.99;
|
const DESIGN_PRICE_USD = 39.99;
|
||||||
@@ -257,8 +265,17 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update canvas element reference when canvas changes
|
||||||
|
watch(
|
||||||
|
() => getCanvasElement(),
|
||||||
|
(newCanvas) => {
|
||||||
|
canvasElement.value = newCanvas;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initAuth();
|
initAuth();
|
||||||
|
updateCanvasElement();
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleTemplateSelect = (templateId: string) => {
|
const handleTemplateSelect = (templateId: string) => {
|
||||||
@@ -507,8 +524,23 @@ const handleCheckout = async () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<DesignerPreview
|
<DesignerPreview
|
||||||
:is-checkout-pending="isCheckoutPending"
|
:is-checkout-pending="isCheckoutPending"
|
||||||
:checkout-price="DESIGN_PRICE_USD"
|
:checkout-price="DESIGN_PRICE_USD"
|
||||||
|
|||||||
@@ -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"
|
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
|
<model-viewer
|
||||||
src="/LAMESA.glb"
|
src="/LAMESA-2.glb"
|
||||||
alt="Table with Jersey"
|
alt="Table with Jersey"
|
||||||
auto-rotate
|
auto-rotate
|
||||||
rotation-per-second="15deg"
|
rotation-per-second="15deg"
|
||||||
|
|||||||
@@ -872,6 +872,7 @@ export const useSlipmatDesigner = () => {
|
|||||||
refreshPreview,
|
refreshPreview,
|
||||||
schedulePreviewRefresh,
|
schedulePreviewRefresh,
|
||||||
applyTemplateToCanvas,
|
applyTemplateToCanvas,
|
||||||
|
getCanvasElement: () => canvas.value?.lowerCanvasEl || null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
BIN
public/LAMESA-2.glb
Normal file
BIN
public/LAMESA-2.glb
Normal file
Binary file not shown.
Reference in New Issue
Block a user