CREATE TABLE models (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) UNIQUE NOT NULL, -- For public URLs
title VARCHAR(255) NOT NULL,
description TEXT,
author_id BIGINT NOT NULL,
category VARCHAR(100),
model_type VARCHAR(50) NOT NULL, -- STL, OBJ, PLY, etc.
file_path VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL, -- in bytes
thumbnail_path VARCHAR(500),
preview_image_path VARCHAR(500),
license VARCHAR(100) DEFAULT 'CC-BY',
status ENUM('processing', 'ready', 'failed') DEFAULT 'processing',
is_public BOOLEAN DEFAULT TRUE,
is_featured BOOLEAN DEFAULT FALSE,
processing_error TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_models_author (author_id),
INDEX idx_models_category (category),
INDEX idx_models_status (status),
INDEX idx_models_public (is_public, created_at),
INDEX idx_models_featured (is_featured, created_at),
FULLTEXT idx_models_search (title, description),
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE model_tags (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_id BIGINT NOT NULL,
tag VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_model_tag (model_id, tag),
INDEX idx_tag_lookup (tag),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
);
CREATE TABLE model_categories (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) UNIQUE NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
icon VARCHAR(100), -- FontAwesome icon class
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_categories_active (is_active, sort_order)
);
CREATE TABLE model_processing_jobs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_id BIGINT NOT NULL,
job_type ENUM('thumbnail', 'virus_scan', 'format_validation', 'metadata_extraction') NOT NULL,
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
result JSON,
error_message TEXT,
started_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_processing_jobs_model (model_id),
INDEX idx_processing_jobs_status (status),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
);
CREATE TABLE model_comments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
parent_comment_id BIGINT NULL, -- For replies (self-referencing)
text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL, -- Soft delete
INDEX idx_model_comments (model_id, created_at),
INDEX idx_parent_comments (parent_comment_id),
INDEX idx_user_comments (user_id),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (parent_comment_id) REFERENCES model_comments(id) ON DELETE CASCADE
);
-- Model Likes
CREATE TABLE model_likes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_model_user_like (model_id, user_id),
INDEX idx_model_likes (model_id),
INDEX idx_user_likes (user_id),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Comment Likes
CREATE TABLE comment_likes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
comment_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_comment_user_like (comment_id, user_id),
INDEX idx_comment_likes (comment_id),
INDEX idx_user_comment_likes (user_id),
FOREIGN KEY (comment_id) REFERENCES model_comments(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE model_stats (
model_id BIGINT PRIMARY KEY,
likes_count INT DEFAULT 0,
comments_count INT DEFAULT 0,
downloads_count INT DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
);
page (default: 1)limit (default: 12, max: 50)search - Text search in title/descriptioncategory - Filter by model categorytags - Comma-separated tag filterssort (newest|popular|downloads|alphabetical)author_id - Filter by specific author{
"data": [
{
"id": 1,
"title": "Mechanical Gear Assembly",
"description": "High-precision gear mechanism...",
"author": {
"id": 123,
"name": "Alex Rodriguez",
"avatar": "https://cdn.example.com/avatars/123.jpg"
},
"thumbnail": "https://cdn.example.com/thumbnails/model_1_thumb.jpg",
"tags": ["mechanical", "gear", "engineering"],
"likes": 142,
"comments": 18,
"downloads": 85,
"isLiked": false,
"isSaved": true,
"modelType": "STL",
"fileSize": "2.4 MB",
"createdAt": "2025-01-20T10:00:00Z"
}
],
"meta": {
"currentPage": 1,
"totalPages": 8,
"totalCount": 94,
"hasMore": true
}
}
Content-Type: multipart/form-data
{
"title": "My Latest 3D Creation",
"description": "Detailed description of the model...",
"tags": ["creative", "artistic", "sculpture"],
"category": "art",
"license": "CC-BY",
"model_file": [Binary STL/OBJ file],
"preview_image": [Binary JPG/PNG file] // Optional
}
{
"data": {
"id": 95,
"title": "My Latest 3D Creation",
"description": "Detailed description of the model...",
"author": {
"id": 789,
"name": "Current User",
"avatar": "https://cdn.example.com/avatars/789.jpg"
},
"thumbnail": "https://cdn.example.com/thumbnails/model_95_thumb.jpg",
"tags": ["creative", "artistic", "sculpture"],
"likes": 0,
"comments": 0,
"downloads": 0,
"isLiked": false,
"isSaved": false,
"modelType": "STL",
"fileSize": "3.7 MB",
"status": "processing", // processing|ready|failed
"createdAt": "2025-01-24T09:00:00Z"
}
}
{
"data": {
"id": 1,
"title": "Mechanical Gear Assembly",
"description": "...",
"author": {
"id": 123,
"name": "Alex Rodriguez",
"avatar": "https://cdn.example.com/avatars/123.jpg"
},
"tags": ["mechanical", "gear"],
"likes": 142,
"comments": 18,
"downloads": 85,
"isLiked": false,
"isSaved": true,
"modelType": "STL",
"createdAt": "2025-01-20T10:00:00Z"
}
}
page (default: 1)limit (default: 10, max: 50)sort (newest|oldest|popular){
"data": [
{
"id": 1,
"text": "Amazing work!",
"author": {
"id": 456,
"name": "Sarah Chen",
"avatar": "https://cdn.example.com/avatars/456.jpg"
},
"createdAt": "2025-01-23T18:00:00Z",
"likes": 3,
"isLiked": false,
"replies": [
{
"id": 101,
"text": "Thanks!",
"author": {
"id": 123,
"name": "Alex Rodriguez",
"avatar": "https://cdn.example.com/avatars/123.jpg"
},
"createdAt": "2025-01-23T18:30:00Z",
"likes": 1,
"isLiked": true
}
]
}
],
"meta": {
"currentPage": 1,
"totalPages": 2,
"totalCount": 18,
"hasMore": true
}
}
{
"text": "This is an amazing model!",
"parentCommentId": null // Optional: for replies
}
{
"data": {
"id": 19,
"text": "This is an amazing model!",
"author": {
"id": 789,
"name": "Current User",
"avatar": "https://cdn.example.com/avatars/789.jpg"
},
"createdAt": "2025-01-24T09:00:00Z",
"likes": 0,
"isLiked": false,
"replies": []
}
}
{
"data": {
"liked": true,
"likesCount": 143
}
}
{
"data": {
"liked": true,
"likesCount": 4
}
}
// app/Models/Model.php
class Model extends Eloquent {
protected $fillable = ['title', 'description', 'author_id', 'model_type'];
public function author() {
return $this->belongsTo(User::class, 'author_id');
}
public function comments() {
return $this->hasMany(ModelComment::class)->whereNull('parent_comment_id');
}
public function likes() {
return $this->hasMany(ModelLike::class);
}
public function stats() {
return $this->hasOne(ModelStats::class);
}
public function isLikedBy(User $user) {
return $this->likes()->where('user_id', $user->id)->exists();
}
}
// app/Models/ModelComment.php
class ModelComment extends Eloquent {
protected $fillable = ['model_id', 'user_id', 'parent_comment_id', 'text'];
public function model() {
return $this->belongsTo(Model::class);
}
public function author() {
return $this->belongsTo(User::class, 'user_id');
}
public function replies() {
return $this->hasMany(ModelComment::class, 'parent_comment_id');
}
public function parent() {
return $this->belongsTo(ModelComment::class, 'parent_comment_id');
}
public function likes() {
return $this->hasMany(CommentLike::class, 'comment_id');
}
public function isLikedBy(User $user) {
return $this->likes()->where('user_id', $user->id)->exists();
}
}
// app/Http/Controllers/Api/ModelController.php
class ModelController extends Controller {
public function index(Request $request) {
$query = Model::query()
->with(['author', 'stats'])
->where('status', 'ready')
->where('is_public', true);
// Search functionality
if ($search = $request->get('search')) {
$query->whereFullText(['title', 'description'], $search);
}
// Category filter
if ($category = $request->get('category')) {
$query->where('category', $category);
}
// Tags filter
if ($tags = $request->get('tags')) {
$tagArray = explode(',', $tags);
$query->whereHas('tags', function($q) use ($tagArray) {
$q->whereIn('tag', $tagArray);
});
}
// Author filter
if ($authorId = $request->get('author_id')) {
$query->where('author_id', $authorId);
}
// Sorting
$sort = $request->get('sort', 'newest');
switch ($sort) {
case 'popular':
$query->join('model_stats', 'models.id', '=', 'model_stats.model_id')
->orderByDesc('model_stats.likes_count');
break;
case 'downloads':
$query->join('model_stats', 'models.id', '=', 'model_stats.model_id')
->orderByDesc('model_stats.downloads_count');
break;
case 'alphabetical':
$query->orderBy('title');
break;
default: // newest
$query->orderByDesc('created_at');
}
$models = $query->paginate($request->get('limit', 12));
return ModelResource::collection($models);
}
public function show(Model $model) {
$model->load(['author', 'stats', 'tags']);
// Check if current user has liked/saved this model
if ($user = auth()->user()) {
$model->isLiked = $model->isLikedBy($user);
$model->isSaved = $user->savedModels()->where('model_id', $model->id)->exists();
}
return new ModelResource($model);
}
public function store(StoreModelRequest $request) {
$user = auth()->user();
// Create model record
$model = Model::create([
'uuid' => Str::uuid(),
'title' => $request->title,
'description' => $request->description,
'author_id' => $user->id,
'category' => $request->category,
'model_type' => $request->model_file->getClientOriginalExtension(),
'license' => $request->license ?? 'CC-BY',
'status' => 'processing'
]);
// Handle file upload
$this->handleFileUpload($model, $request);
// Process tags
if ($request->tags) {
foreach ($request->tags as $tag) {
$model->tags()->create(['tag' => strtolower(trim($tag))]);
}
}
// Queue processing jobs
ProcessModelFileJob::dispatch($model);
GenerateThumbnailJob::dispatch($model);
return new ModelResource($model->load(['author', 'tags']));
}
public function like(Model $model) {
$user = auth()->user();
$like = $model->likes()->firstOrCreate(['user_id' => $user->id]);
// Update stats asynchronously
UpdateModelStatsJob::dispatch($model);
return response()->json([
'data' => [
'liked' => true,
'likesCount' => $model->likes()->count()
]
]);
}
public function unlike(Model $model) {
$user = auth()->user();
$model->likes()->where('user_id', $user->id)->delete();
// Update stats asynchronously
UpdateModelStatsJob::dispatch($model);
return response()->json([
'data' => [
'liked' => false,
'likesCount' => $model->likes()->count()
]
]);
}
public function download(Model $model) {
if ($model->status !== 'ready') {
return response()->json(['error' => 'Model not ready for download'], 422);
}
// Increment download count
IncrementDownloadCountJob::dispatch($model);
// Generate signed URL for secure download
$downloadUrl = Storage::temporaryUrl($model->file_path, now()->addMinutes(10));
return response()->json([
'data' => [
'downloadUrl' => $downloadUrl,
'fileName' => $model->title . '.' . $model->model_type,
'fileSize' => $model->file_size
]
]);
}
private function handleFileUpload(Model $model, StoreModelRequest $request) {
// Upload to S3 with organized structure
$fileName = $model->uuid . '.' . $request->model_file->getClientOriginalExtension();
$filePath = "models/{$model->author_id}/{$fileName}";
$path = $request->model_file->storeAs($filePath, $fileName, 's3');
$model->update([
'file_path' => $path,
'file_size' => $request->model_file->getSize()
]);
// Handle preview image if provided
if ($request->hasFile('preview_image')) {
$previewFileName = $model->uuid . '_preview.' . $request->preview_image->getClientOriginalExtension();
$previewPath = "models/{$model->author_id}/previews/{$previewFileName}";
$previewStoredPath = $request->preview_image->storeAs($previewPath, $previewFileName, 's3');
$model->update(['preview_image_path' => $previewStoredPath]);
}
}
}
// app/Http/Controllers/Api/CommentController.php
class CommentController extends Controller {
public function index(Model $model, Request $request) {
$comments = $model->comments()
->with(['author', 'replies.author', 'likes'])
->withCount('likes')
->orderBy($this->getSortColumn($request->get('sort', 'newest')))
->paginate($request->get('limit', 10));
return CommentResource::collection($comments);
}
public function store(Model $model, StoreCommentRequest $request) {
$comment = $model->comments()->create([
'user_id' => auth()->id(),
'text' => $request->text,
'parent_comment_id' => $request->parentCommentId,
]);
// Update model stats
$this->updateModelStats($model);
return new CommentResource($comment->load('author'));
}
public function like(ModelComment $comment) {
$user = auth()->user();
$like = $comment->likes()->firstOrCreate(['user_id' => $user->id]);
return response()->json([
'data' => [
'liked' => true,
'likesCount' => $comment->likes()->count()
]
]);
}
public function unlike(ModelComment $comment) {
$user = auth()->user();
$comment->likes()->where('user_id', $user->id)->delete();
return response()->json([
'data' => [
'liked' => false,
'likesCount' => $comment->likes()->count()
]
]);
}
}
-- Composite indexes for common queries
CREATE INDEX idx_model_comments_created (model_id, created_at DESC);
CREATE INDEX idx_comment_likes_count (comment_id, user_id);
CREATE INDEX idx_model_stats_lookup (model_id, likes_count, comments_count);
// Cache model stats for 5 minutes
public function getModelStats(Model $model) {
return Cache::remember("model_stats_{$model->id}", 300, function() use ($model) {
return [
'likes' => $model->likes()->count(),
'comments' => $model->comments()->count(),
];
});
}
// app/Jobs/UpdateModelStats.php
class UpdateModelStats implements ShouldQueue {
public function handle(Model $model) {
$model->stats()->updateOrCreate([], [
'likes_count' => $model->likes()->count(),
'comments_count' => $model->comments()->count(),
]);
// Clear related caches
Cache::forget("model_stats_{$model->id}");
}
}
// app/Jobs/ProcessModelFileJob.php
class ProcessModelFileJob implements ShouldQueue {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $model;
public function __construct(Model $model) {
$this->model = $model;
}
public function handle() {
try {
// Virus scan
$this->performVirusScan();
// Validate file format
$this->validateFileFormat();
// Extract metadata
$this->extractMetadata();
// Mark as ready
$this->model->update(['status' => 'ready']);
// Generate initial stats
$this->model->stats()->create([
'likes_count' => 0,
'comments_count' => 0,
'downloads_count' => 0
]);
} catch (Exception $e) {
$this->model->update([
'status' => 'failed',
'processing_error' => $e->getMessage()
]);
Log::error('Model processing failed', [
'model_id' => $this->model->id,
'error' => $e->getMessage()
]);
}
}
private function performVirusScan() {
// Integration with ClamAV or similar
$filePath = Storage::path($this->model->file_path);
if (!$this->isFileClean($filePath)) {
throw new Exception('File failed virus scan');
}
}
private function validateFileFormat() {
$allowedTypes = ['stl', 'obj', 'ply', '3mf'];
if (!in_array(strtolower($this->model->model_type), $allowedTypes)) {
throw new Exception('Unsupported file format');
}
// Additional format-specific validation
$this->validateFileStructure();
}
private function extractMetadata() {
// Extract polygon count, dimensions, etc.
$metadata = $this->parseModelFile();
$this->model->processing_jobs()->create([
'job_type' => 'metadata_extraction',
'status' => 'completed',
'result' => $metadata,
'completed_at' => now()
]);
}
}
// app/Jobs/GenerateThumbnailJob.php
class GenerateThumbnailJob implements ShouldQueue {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $model;
public function __construct(Model $model) {
$this->model = $model;
}
public function handle() {
try {
// Use Three.js headless renderer or similar to generate thumbnail
$thumbnailPath = $this->generateThumbnail();
$this->model->update(['thumbnail_path' => $thumbnailPath]);
$this->model->processing_jobs()->create([
'job_type' => 'thumbnail',
'status' => 'completed',
'completed_at' => now()
]);
} catch (Exception $e) {
$this->model->processing_jobs()->create([
'job_type' => 'thumbnail',
'status' => 'failed',
'error_message' => $e->getMessage(),
'completed_at' => now()
]);
}
}
private function generateThumbnail() {
// Use puppeteer + three.js to generate thumbnail
$thumbnailFileName = $this->model->uuid . '_thumb.jpg';
$thumbnailPath = "models/{$this->model->author_id}/thumbnails/{$thumbnailFileName}";
// Implementation would use headless browser to render 3D model
$thumbnailData = $this->renderModelThumbnail();
Storage::put($thumbnailPath, $thumbnailData);
return $thumbnailPath;
}
}
// app/Http/Requests/StoreModelRequest.php
class StoreModelRequest extends FormRequest {
public function rules() {
return [
'title' => 'required|string|min:3|max:255',
'description' => 'required|string|min:10|max:5000',
'category' => 'required|string|exists:model_categories,slug',
'tags' => 'required|array|min:1|max:10',
'tags.*' => 'string|min:2|max:30|alpha_dash',
'license' => 'nullable|string|in:CC-BY,CC-BY-SA,CC-BY-NC,CC-BY-ND,MIT,GPL-3.0',
'model_file' => 'required|file|max:102400|mimes:stl,obj,ply,3mf', // 100MB max
'preview_image' => 'nullable|image|max:10240|mimes:jpg,jpeg,png,webp' // 10MB max
];
}
public function messages() {
return [
'model_file.max' => 'Model file cannot exceed 100MB',
'model_file.mimes' => 'Only STL, OBJ, PLY, and 3MF files are supported',
'tags.min' => 'At least one tag is required',
'tags.max' => 'Maximum 10 tags allowed'
];
}
}
// app/Http/Requests/StoreCommentRequest.php
class StoreCommentRequest extends FormRequest {
public function rules() {
return [
'text' => 'required|string|min:1|max:2000',
'parentCommentId' => 'nullable|exists:model_comments,id',
];
}
}
// routes/api.php
Route::middleware(['auth:sanctum'])->group(function () {
// Model management
Route::get('/models', [ModelController::class, 'index']);
Route::get('/models/{model:uuid}', [ModelController::class, 'show']);
Route::post('/models/upload', [ModelController::class, 'store'])->middleware('throttle:uploads');
Route::get('/models/{model:uuid}/download', [ModelController::class, 'download']);
// Model interactions
Route::post('/models/{model:uuid}/like', [ModelController::class, 'like'])->middleware('throttle:likes');
Route::delete('/models/{model:uuid}/like', [ModelController::class, 'unlike'])->middleware('throttle:likes');
// Comments
Route::get('/models/{model:uuid}/comments', [CommentController::class, 'index']);
Route::post('/models/{model:uuid}/comments', [CommentController::class, 'store'])->middleware('throttle:comments');
Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->middleware('throttle:likes');
Route::delete('/comments/{comment}/like', [CommentController::class, 'unlike'])->middleware('throttle:likes');
});
// Rate limiters in app/Providers/RouteServiceProvider.php
RateLimiter::for('uploads', function (Request $request) {
return Limit::perHour(10)->by($request->user()->id); // 10 uploads per hour
});
RateLimiter::for('comments', function (Request $request) {
return Limit::perMinute(5)->by($request->user()->id); // 5 comments per minute
});
RateLimiter::for('likes', function (Request $request) {
return Limit::perMinute(30)->by($request->user()->id); // 30 likes per minute
});
// tests/Feature/CommentTest.php
class CommentTest extends TestCase {
public function test_user_can_create_comment() {
$user = User::factory()->create();
$model = Model::factory()->create();
$response = $this->actingAs($user)
->postJson("/api/v1/models/{$model->id}/comments", [
'text' => 'Great model!'
]);
$response->assertStatus(201)
->assertJsonStructure(['data' => ['id', 'text', 'author']]);
}
public function test_user_can_like_comment() {
$user = User::factory()->create();
$comment = ModelComment::factory()->create();
$response = $this->actingAs($user)
->postJson("/api/v1/comments/{$comment->id}/like");
$response->assertStatus(200)
->assertJson(['data' => ['liked' => true]]);
}
}
s3://your-bucket/
├── models/
│ ├── {user_id}/
│ │ ├── {uuid}.stl # Original model files
│ │ ├── thumbnails/
│ │ │ └── {uuid}_thumb.jpg # Generated thumbnails
│ │ └── previews/
│ │ └── {uuid}_preview.jpg # User-uploaded previews
├── avatars/
│ └── {user_id}.jpg # User profile pictures
└── temp-uploads/ # Temporary upload storage
This comprehensive implementation provides a complete 3D model sharing platform with: