You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

667 lines
15 KiB

<template>
<div class="threejs-viewer">
<h2>3D Model Viewer</h2>
<!-- Viewer controls -->
<div class="viewer-controls">
<button :disabled="isLoading || !modelUrl" @click="loadModel">
{{ isLoading ? "Loading..." : "Load Model" }}
</button>
<button :disabled="!isModelLoaded" @click="resetCamera">
Reset Camera
</button>
<button :disabled="!isModelLoaded" @click="toggleAnimation">
{{ isAnimating ? "Stop" : "Start" }} Animation
</button>
<button :disabled="!isModelLoaded" @click="toggleWireframe">
{{ showWireframe ? "Hide" : "Show" }} Wireframe
</button>
</div>
<!-- Loading status -->
<div v-if="isLoading" class="loading-status">
<div class="loading-spinner"></div>
<p>Loading 3D model...</p>
<p class="loading-detail">{{ loadingProgress }}% complete</p>
</div>
<!-- Error status -->
<div v-if="loadError" class="error-status">
<p>Failed to load model: {{ loadError }}</p>
<button class="retry-btn" @click="retryLoad">Retry</button>
</div>
<!-- 3D Canvas -->
<div
ref="canvasContainer"
class="canvas-container"
:class="{ 'model-loaded': isModelLoaded }"
>
<canvas ref="threeCanvas" class="three-canvas"></canvas>
<!-- Overlay controls -->
<div v-if="isModelLoaded" class="overlay-controls">
<div class="control-group">
<label>Camera Distance:</label>
<input
v-model.number="cameraDistance"
type="range"
min="1"
max="20"
step="0.1"
@input="updateCameraDistance"
/>
<span>{{ cameraDistance.toFixed(1) }}</span>
</div>
<div class="control-group">
<label>Rotation Speed:</label>
<input
v-model.number="rotationSpeed"
type="range"
min="0"
max="2"
step="0.1"
/>
<span>{{ rotationSpeed.toFixed(1) }}</span>
</div>
<div class="control-group">
<label>Light Intensity:</label>
<input
v-model.number="lightIntensity"
type="range"
min="0"
max="2"
step="0.1"
@input="updateLightIntensity"
/>
<span>{{ lightIntensity.toFixed(1) }}</span>
</div>
</div>
<!-- Model info -->
<div v-if="modelInfo" class="model-info">
<h4>Model Information</h4>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Vertices:</span>
<span class="info-value">{{
modelInfo.vertexCount.toLocaleString()
}}</span>
</div>
<div class="info-item">
<span class="info-label">Faces:</span>
<span class="info-value">{{
modelInfo.faceCount.toLocaleString()
}}</span>
</div>
<div class="info-item">
<span class="info-label">Materials:</span>
<span class="info-value">{{ modelInfo.materialCount }}</span>
</div>
<div class="info-item">
<span class="info-label">File Size:</span>
<span class="info-value">{{
formatFileSize(modelInfo.fileSize)
}}</span>
</div>
</div>
</div>
</div>
<!-- Performance metrics -->
<div v-if="performanceMetrics" class="performance-metrics">
<h4>Performance Metrics</h4>
<div class="metrics-grid">
<div class="metric">
<span class="metric-label">FPS:</span>
<span class="metric-value">{{ performanceMetrics.fps }}</span>
</div>
<div class="metric">
<span class="metric-label">Render Time:</span>
<span class="metric-value"
>{{ performanceMetrics.renderTime }}ms</span
>
</div>
<div class="metric">
<span class="metric-label">Memory Usage:</span>
<span class="metric-value"
>{{ performanceMetrics.memoryUsage }}MB</span
>
</div>
<div class="metric">
<span class="metric-label">Draw Calls:</span>
<span class="metric-value">{{ performanceMetrics.drawCalls }}</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
interface ModelInfo {
vertexCount: number;
faceCount: number;
materialCount: number;
fileSize: number;
boundingBox: {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
};
}
interface PerformanceMetrics {
fps: number;
renderTime: number;
memoryUsage: number;
drawCalls: number;
}
/**
* ThreeJS 3D Model Viewer Component
*
* Demonstrates lazy loading for heavy 3D rendering libraries.
* This component would benefit from lazy loading as ThreeJS is a large
* library that's only needed for 3D visualization features.
*
* @author Matthew Raymer
* @version 1.0.0
*/
@Component({
name: "ThreeJSViewer",
})
export default class ThreeJSViewer extends Vue {
@Prop({ required: true }) readonly modelUrl!: string;
// Component state
isLoading = false;
isModelLoaded = false;
loadError: string | null = null;
loadingProgress = 0;
// Animation state
isAnimating = false;
showWireframe = false;
// Camera and lighting controls
cameraDistance = 5;
rotationSpeed = 0.5;
lightIntensity = 1;
// Canvas references
canvasContainer: HTMLElement | null = null;
threeCanvas: HTMLCanvasElement | null = null;
// Model and performance data
modelInfo: ModelInfo | null = null;
performanceMetrics: PerformanceMetrics | null = null;
// ThreeJS objects (will be lazy loaded)
private three: any = null;
private scene: any = null;
private camera: any = null;
private renderer: any = null;
private model: any = null;
private controls: any = null;
private animationId: number | null = null;
private frameCount = 0;
private lastTime = 0;
// Lifecycle hooks
mounted(): void {
console.log("[ThreeJSViewer] Component mounted");
this.initializeCanvas();
}
beforeUnmount(): void {
this.cleanup();
console.log("[ThreeJSViewer] Component unmounting");
}
// Methods
private initializeCanvas(): void {
this.canvasContainer = this.$refs.canvasContainer as HTMLElement;
this.threeCanvas = this.$refs.threeCanvas as HTMLCanvasElement;
if (this.threeCanvas) {
this.threeCanvas.width = this.canvasContainer.clientWidth;
this.threeCanvas.height = this.canvasContainer.clientHeight;
}
}
async loadModel(): Promise<void> {
if (this.isLoading || !this.modelUrl) return;
this.isLoading = true;
this.loadError = null;
this.loadingProgress = 0;
try {
console.log("[ThreeJSViewer] Loading 3D model:", this.modelUrl);
// Lazy load ThreeJS
await this.loadThreeJS();
// Initialize scene
await this.initializeScene();
// Load model
await this.loadModelFile();
// Start rendering
this.startRendering();
this.isModelLoaded = true;
this.isLoading = false;
// Emit model loaded event
this.$emit("model-loaded", this.modelInfo);
console.log("[ThreeJSViewer] Model loaded successfully");
} catch (error) {
console.error("[ThreeJSViewer] Failed to load model:", error);
this.loadError = error instanceof Error ? error.message : "Unknown error";
this.isLoading = false;
}
}
private async loadThreeJS(): Promise<void> {
// Simulate loading ThreeJS library
this.loadingProgress = 20;
await this.simulateLoading(500);
// In a real implementation, you would import ThreeJS here
// this.three = await import('three');
this.loadingProgress = 40;
await this.simulateLoading(300);
}
private async initializeScene(): Promise<void> {
this.loadingProgress = 60;
// Simulate scene initialization
await this.simulateLoading(400);
// In a real implementation, you would set up ThreeJS scene here
// this.scene = new this.three.Scene();
// this.camera = new this.three.PerspectiveCamera(75, width / height, 0.1, 1000);
// this.renderer = new this.three.WebGLRenderer({ canvas: this.threeCanvas });
this.loadingProgress = 80;
}
private async loadModelFile(): Promise<void> {
this.loadingProgress = 90;
// Simulate model loading
await this.simulateLoading(600);
// Simulate model info
this.modelInfo = {
vertexCount: Math.floor(Math.random() * 50000) + 1000,
faceCount: Math.floor(Math.random() * 25000) + 500,
materialCount: Math.floor(Math.random() * 5) + 1,
fileSize: Math.floor(Math.random() * 5000000) + 100000,
boundingBox: {
min: { x: -1, y: -1, z: -1 },
max: { x: 1, y: 1, z: 1 },
},
};
this.loadingProgress = 100;
}
private async simulateLoading(delay: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delay));
}
private startRendering(): void {
if (!this.isModelLoaded) return;
this.isAnimating = true;
this.animate();
// Start performance monitoring
this.startPerformanceMonitoring();
}
private animate(): void {
if (!this.isAnimating) return;
this.animationId = requestAnimationFrame(() => this.animate());
// Simulate model rotation
if (this.model && this.rotationSpeed > 0) {
// this.model.rotation.y += this.rotationSpeed * 0.01;
}
// Simulate rendering
// this.renderer.render(this.scene, this.camera);
this.frameCount++;
}
private startPerformanceMonitoring(): void {
const updateMetrics = () => {
if (!this.isAnimating) return;
const now = performance.now();
const deltaTime = now - this.lastTime;
if (deltaTime > 0) {
const fps = Math.round(1000 / deltaTime);
this.performanceMetrics = {
fps: Math.min(fps, 60), // Cap at 60 FPS for display
renderTime: Math.round(deltaTime),
memoryUsage: Math.round((Math.random() * 50 + 10) * 100) / 100,
drawCalls: Math.floor(Math.random() * 100) + 10,
};
}
this.lastTime = now;
requestAnimationFrame(updateMetrics);
};
updateMetrics();
}
resetCamera(): void {
if (!this.isModelLoaded) return;
this.cameraDistance = 5;
this.updateCameraDistance();
console.log("[ThreeJSViewer] Camera reset");
}
toggleAnimation(): void {
this.isAnimating = !this.isAnimating;
if (this.isAnimating) {
this.animate();
} else if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
console.log("[ThreeJSViewer] Animation toggled:", this.isAnimating);
}
toggleWireframe(): void {
this.showWireframe = !this.showWireframe;
// In a real implementation, you would toggle wireframe mode
// this.model.traverse((child: any) => {
// if (child.isMesh) {
// child.material.wireframe = this.showWireframe;
// }
// });
console.log("[ThreeJSViewer] Wireframe toggled:", this.showWireframe);
}
updateCameraDistance(): void {
if (!this.isModelLoaded) return;
// In a real implementation, you would update camera position
// this.camera.position.z = this.cameraDistance;
// this.camera.lookAt(0, 0, 0);
}
updateLightIntensity(): void {
if (!this.isModelLoaded) return;
// In a real implementation, you would update light intensity
// this.light.intensity = this.lightIntensity;
}
retryLoad(): void {
this.loadError = null;
this.loadModel();
}
private cleanup(): void {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.renderer) {
this.renderer.dispose();
}
this.isAnimating = false;
this.isModelLoaded = false;
}
formatFileSize(bytes: number): string {
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
// Event emitters
@Emit("model-loaded")
emitModelLoaded(info: ModelInfo): ModelInfo {
return info;
}
}
</script>
<style scoped>
.threejs-viewer {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background: #f9f9f9;
}
.viewer-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.viewer-controls button {
padding: 8px 16px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: background-color 0.2s;
}
.viewer-controls button:hover:not(:disabled) {
background: #e9ecef;
}
.viewer-controls button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-detail {
font-size: 0.9em;
color: #666;
margin-top: 10px;
}
.error-status {
padding: 20px;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
color: #721c24;
text-align: center;
}
.retry-btn {
margin-top: 10px;
padding: 8px 16px;
border: 1px solid #721c24;
border-radius: 4px;
background: #721c24;
color: #fff;
cursor: pointer;
}
.retry-btn:hover {
background: #5a1a1a;
}
.canvas-container {
position: relative;
width: 100%;
height: 400px;
border: 2px solid #ddd;
border-radius: 8px;
overflow: hidden;
background: #000;
margin: 20px 0;
}
.canvas-container.model-loaded {
border-color: #28a745;
}
.three-canvas {
width: 100%;
height: 100%;
display: block;
}
.overlay-controls {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 15px;
border-radius: 4px;
min-width: 200px;
}
.control-group {
margin-bottom: 10px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-group label {
display: block;
font-size: 0.9em;
margin-bottom: 5px;
}
.control-group input[type="range"] {
width: 100%;
margin-right: 10px;
}
.control-group span {
font-size: 0.8em;
color: #ccc;
}
.model-info {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 15px;
border-radius: 4px;
min-width: 200px;
}
.model-info h4 {
margin: 0 0 10px 0;
font-size: 1em;
}
.info-grid {
display: grid;
gap: 5px;
}
.info-item {
display: flex;
justify-content: space-between;
font-size: 0.85em;
}
.info-label {
color: #ccc;
}
.info-value {
font-weight: bold;
}
.performance-metrics {
margin-top: 20px;
padding: 15px;
background: #e8f4fd;
border-radius: 4px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-top: 10px;
}
.metric {
display: flex;
justify-content: space-between;
padding: 8px;
background: #fff;
border-radius: 4px;
}
.metric-label {
font-weight: bold;
color: #333;
}
.metric-value {
color: #007bff;
font-weight: bold;
}
</style>