Spaces:
				
			
			
	
			
			
					
		Running
		
	
	
	
			
			
	
	
	
	
		
		
					
		Running
		
	| <!-- optimize for mobile --> | |
| <html> | |
| <body> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| tailwind.config = { | |
| darkMode: 'class', | |
| theme: { | |
| extend: { | |
| colors: { | |
| primary: '#5D5CDE', | |
| secondary: '#4F4FC9', | |
| darkbg: '#121212', | |
| darkelem: '#1e1e1e', | |
| darksecondary: '#252525' | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <script src="https://pfst.cf2.poecdn.net/base/text/17f93eb776a5479dfb702f9a920d9a49d3e83847a7e3394d57a8b4fe26585897?pmaid=369101293"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/OrbitControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/controls/TransformControls.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/OBJLoader.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/STLLoader.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/loaders/GLTFLoader.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/exporters/OBJExporter.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/exporters/STLExporter.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.132.2/examples/js/exporters/GLTFExporter.js"></script> | |
| <style> | |
| body { overflow: hidden; } | |
| .notification { transition: opacity 0.3s ease, transform 0.3s ease; opacity: 0; transform: translateY(20px); } | |
| .notification.show { opacity: 1; transform: translateY(0); } | |
| .custom-scrollbar::-webkit-scrollbar { width: 8px; } | |
| .custom-scrollbar::-webkit-scrollbar-track { background: #1e1e1e; } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { background: #555; border-radius: 4px; } | |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #777; } | |
| .transform-control-btn.active { border-color: #5D5CDE; background-color: rgba(93, 92, 222, 0.2); } | |
| .dark .transform-control-btn.active { border-color: #5D5CDE; background-color: rgba(93, 92, 222, 0.3); } | |
| .shape-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.25rem; } | |
| .sidebar-tabs { display: flex; border-bottom: 1px solid #e5e7eb; } | |
| .dark .sidebar-tabs { border-color: #374151; } | |
| .sidebar-tab { padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all 0.2s; } | |
| .sidebar-tab.active { color: #5D5CDE; border-bottom: 2px solid #5D5CDE; } | |
| .dark .sidebar-tab.active { color: #6d6ef8; border-bottom: 2px solid #6d6ef8; } | |
| .panel { display: none; } | |
| .panel.active { display: block; } | |
| .template-item { transition: all 0.2s; } | |
| .template-item:hover { transform: translateY(-2px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); } | |
| .dark .template-item:hover { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.3); } | |
| .floating-panel { backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } | |
| .dark .floating-panel { background-color: rgba(30, 30, 30, 0.8); } | |
| .tool-category { margin-bottom: 1rem; } | |
| .category-title { display: flex; align-items: center; padding: 0.5rem 0.25rem; cursor: pointer; user-select: none; } | |
| .category-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; } | |
| .category-content.expanded { max-height: 1000px; } | |
| .category-icon { transition: transform 0.3s ease; } | |
| .expanded .category-icon { transform: rotate(90deg); } | |
| </style> | |
| </head> | |
| <body class="font-sans bg-white dark:bg-darkbg text-gray-900 dark:text-gray-100 transition-colors duration-200"> | |
| <div class="flex h-screen" id="app-container"> | |
| <div class="w-64 bg-gray-100 dark:bg-darkelem shadow-md overflow-hidden z-10 flex flex-col"> | |
| <div class="sidebar-tabs"> | |
| <div class="sidebar-tab active" data-tab="tools">Tools</div> | |
| <div class="sidebar-tab" data-tab="assets">Assets</div> | |
| <div class="sidebar-tab" data-tab="settings">Settings</div> | |
| </div> | |
| <div class="panel active flex-grow overflow-y-auto custom-scrollbar p-4" id="tools-panel"> | |
| <div class="tool-category"> | |
| <div class="category-title"> | |
| <svg class="category-icon w-4 h-4 mr-1" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> | |
| </svg> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Common Shapes</h3> | |
| </div> | |
| <div class="category-content expanded"> | |
| <div class="shape-grid mt-2"> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="box">Cube</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="sphere">Sphere</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="cylinder">Cylinder</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="cone">Cone</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="torus">Torus</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="plane">Plane</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="circle">Circle</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="ring">Ring</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="text">Text</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addLight">Light</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="tetrahedron">Tetrahedron</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="octahedron">Octahedron</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="dodecahedron">Dodecahedron</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="icosahedron">Icosahedron</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="torusKnot">Torus Knot</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="capsule">Capsule</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="lathe">Lathe</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="extrude">Extrude</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="tube">Tube</div> | |
| <div class="tool-btn p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" data-action="addShape" data-shape="pyramid">Pyramid</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tool-category"> | |
| <div class="category-title"> | |
| <svg class="category-icon w-4 h-4 mr-1" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> | |
| </svg> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Customize</h3> | |
| </div> | |
| <div class="category-content expanded space-y-3 mt-2"> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Color</label> | |
| <div class="flex space-x-2"> | |
| <input type="color" id="object-color" class="w-8 h-8 rounded cursor-pointer" value="#00cc00"> | |
| <button id="apply-color" class="px-2 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">Apply</button> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Shape</label> | |
| <select id="object-shape" class="w-full p-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-base"> | |
| <option value="box">Box</option> | |
| <option value="sphere">Sphere</option> | |
| <option value="cylinder">Cylinder</option> | |
| <option value="cone">Cone</option> | |
| <option value="torus">Torus</option> | |
| <option value="torusKnot">Knot</option> | |
| <option value="plane">Plane</option> | |
| <option value="circle">Circle</option> | |
| <option value="ring">Ring</option> | |
| <option value="text">Fence</option> | |
| <option value="tetrahedron">Tetrahedron</option> | |
| <option value="octahedron">Octahedron</option> | |
| <option value="dodecahedron">Dodecahedron</option> | |
| <option value="icosahedron">Icosahedron</option> | |
| <option value="torusKnot">Torus Knot</option> | |
| <option value="capsule">Capsule</option> | |
| <option value="lathe">Lathe</option> | |
| <option value="extrude">Extrude</option> | |
| <option value="tube">Tube</option> | |
| <option value="pyramid">Pyramid</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Texture</label> | |
| <select id="object-texture" class="w-full p-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-base"> | |
| <option value="none">None</option> | |
| <option value="brick">Brick</option> | |
| <option value="metal">Metal</option> | |
| <option value="water">Water</option> | |
| <option value="concrete">Concrete</option> | |
| <option value="fabric">Fabric</option> | |
| <option value="leather">Leather</option> | |
| <option value="clouds">Clouds</option> | |
| <option value="checkerboard">Checkerboard</option> | |
| <option value="grid">Grid</option> | |
| <option value="dots">Dots</option> | |
| <option value="carbon">Carbon Fiber</option> | |
| <option value="rubber">Rubber</option> | |
| <option value="plastic">Plastic</option> | |
| <option value="earth">Earth</option> | |
| <option value="moon">Moon</option> | |
| <option value="custom">Custom Texture</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tool-category"> | |
| <div class="category-title"> | |
| <svg class="category-icon w-4 h-4 mr-1" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> | |
| </svg> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Import/Export</h3> | |
| </div> | |
| <div class="category-content expanded mt-2"> | |
| <div class="space-y-2"> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Import</label> | |
| <input type="file" id="import-file" accept=".obj,.stl,.glb,.gltf,.json,.png,.jpg,.jpeg" class="hidden"> | |
| <div class="grid grid-cols-2 gap-1"> | |
| <button id="import-obj" class="px-2 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">.OBJ</button> | |
| <button id="import-stl" class="px-2 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">.STL</button> | |
| <button id="import-glb" class="px-2 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">.GLB</button> | |
| <button id="import-textures" class="px-2 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">Textures</button> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Export</label> | |
| <div class="grid grid-cols-2 gap-1"> | |
| <button id="export-obj" class="px-2 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">.OBJ</button> | |
| <button id="export-stl" class="px-2 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">.STL</button> | |
| <button id="export-glb" class="px-2 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">.GLB</button> | |
| <button id="export-json" class="px-2 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">.JSON</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tool-category"> | |
| <div class="category-title"> | |
| <svg class="category-icon w-4 h-4 mr-1" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /> | |
| </svg> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Share</h3> | |
| </div> | |
| <div class="category-content expanded mt-2"> | |
| <ul class="space-y-1"> | |
| <li class="p-2 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors" id="screenshot-btn">Take Screenshot</li> | |
| <li class="p-2 rounded text-amber-500 dark:text-amber-400 cursor-not-allowed">PRO: Share to Gallery</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> | |
| <button id="subscribe-btn" class="w-full py-2 bg-gradient-to-r from-purple-600 to-primary text-white rounded hover:opacity-90 transition-opacity"> Subscribe to PRO </button> | |
| <br><br> | |
| <button id="modelStudio-btn" class="w-full py-2 bg-gradient-to-r from-purple-600 to-primary text-white rounded hover:opacity-90 transition-opacity"> 3D-Model-Studio </button> | |
| <br><br> | |
| <button id="objectGenerator-btn" class="w-full py-2 bg-gradient-to-r from-purple-600 to-primary text-white rounded hover:opacity-90 transition-opacity"> 3D-Object-Generator </button> | |
| </div> | |
| </div> | |
| <div class="panel flex-grow overflow-y-auto custom-scrollbar p-4" id="assets-panel"> | |
| <div class="mb-4"> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Templates</h3> | |
| <button id="open-templates" class="w-full py-2 bg-primary hover:bg-secondary text-white rounded transition-colors"> Browse Templates </button> | |
| </div> | |
| <div class="mb-4"> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Recent</h3> | |
| <div class="space-y-2"> | |
| <div class="p-2 rounded bg-white dark:bg-gray-800 shadow-sm flex items-center hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"> | |
| <div class="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded mr-2"></div> | |
| <div> | |
| <p class="text-sm font-medium">My Scene 1</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">2 days ago</p> | |
| </div> | |
| </div> | |
| <div class="p-2 rounded bg-white dark:bg-gray-800 shadow-sm flex items-center hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"> | |
| <div class="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded mr-2"></div> | |
| <div> | |
| <p class="text-sm font-medium">Character Test</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">5 days ago</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">My Assets</h3> | |
| <div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4 text-center"> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">Drag and drop files here or</p> | |
| <button id="upload-assets-btn" class="mt-2 px-3 py-1 bg-primary hover:bg-secondary text-white rounded text-sm transition-colors">Upload Files</button> | |
| <input type="file" id="asset-upload" class="hidden" accept=".png,.jpg,.jpeg,.json" multiple> | |
| </div> | |
| <div id="uploaded-assets" class="mt-3 grid grid-cols-3 gap-2"></div> | |
| </div> | |
| </div> | |
| <div class="panel flex-grow overflow-y-auto custom-scrollbar p-4" id="settings-panel"> | |
| <div class="mb-4"> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Appearance</h3> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm">Theme</span> | |
| <button id="theme-toggle" class="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"> | |
| <svg class="h-5 w-5 dark:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/> | |
| </svg> | |
| <svg class="h-5 w-5 hidden dark:block" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Viewport</h3> | |
| <div class="space-y-2"> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm">Show Grid</span> | |
| <label class="inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" id="show-grid" class="sr-only peer" checked> | |
| <div class="relative w-10 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-primary"></div> | |
| </label> | |
| </div> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm">Show Axes</span> | |
| <label class="inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" id="show-axes" class="sr-only peer" checked> | |
| <div class="relative w-10 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-primary"></div> | |
| </label> | |
| </div> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm">Shadows</span> | |
| <label class="inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" id="show-shadows" class="sr-only peer" checked> | |
| <div class="relative w-10 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-primary"></div> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Account</h3> | |
| <div class="p-3 bg-white dark:bg-gray-800 rounded shadow-sm"> | |
| <p class="text-sm font-medium">Free Account</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Upgrade to access premium features</p> | |
| <button id="upgrade-btn" class="mt-2 w-full py-2 px-4 bg-gradient-to-r from-purple-600 to-primary text-white rounded hover:opacity-90 transition-opacity"> Upgrade to PRO </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex-1 flex flex-col relative overflow-hidden"> | |
| <div class="bg-gray-200 dark:bg-darksecondary p-3 flex justify-between items-center shadow-sm"> | |
| <div class="flex space-x-2"> | |
| <button id="new-scene" class="px-3 py-1.5 bg-primary hover:bg-secondary text-white rounded transition-colors">New</button> | |
| <button id="save-scene" class="px-3 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors">Save</button> | |
| <button id="open-scene" class="px-3 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors">Open</button> | |
| <button id="delete-object" class="px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded transition-colors" disabled>Delete</button> | |
| </div> | |
| <div class="flex space-x-2"> | |
| <button id="undo-btn" class="px-3 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors" disabled>Undo</button> | |
| <button id="redo-btn" class="px-3 py-1.5 bg-gray-500 hover:bg-gray-600 text-white rounded transition-colors" disabled>Redo</button> | |
| </div> | |
| </div> | |
| <div class="absolute top-16 left-4 z-10 bg-white dark:bg-darkelem p-2 rounded shadow-lg flex flex-col space-y-2"> | |
| <button data-mode="translate" class="transform-control-btn w-8 h-8 flex items-center justify-center border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"> | |
| <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7l4-4m0 0l4 4m-4-4v18"/> | |
| </svg> | |
| </button> | |
| <button data-mode="rotate" class="transform-control-btn w-8 h-8 flex items-center justify-center border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"> | |
| <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> | |
| </svg> | |
| </button> | |
| <button data-mode="scale" class="transform-control-btn w-8 h-8 flex items-center justify-center border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div id="canvas-container" class="flex-1 bg-gray-300 dark:bg-gray-800"></div> | |
| <div id="screenshot-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 hidden"> | |
| <div class="bg-white dark:bg-darkelem rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col"> | |
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> | |
| <h3 class="text-lg font-medium">Screenshot Preview</h3> | |
| <button id="close-modal" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> | |
| <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="p-4 overflow-auto flex-1"> | |
| <img id="screenshot-img" class="max-w-full max-h-[70vh] mx-auto" alt="3D Scene Screenshot"> | |
| </div> | |
| <div class="p-4 border-t border-gray-200 dark:border-gray-700"> | |
| <p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Right-click on the image and select "Save image as..." to download this screenshot.</p> | |
| <div class="flex justify-end"> | |
| <button id="close-preview" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="text-input-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 hidden"> | |
| <div class="bg-white dark:bg-darkelem rounded-lg shadow-xl max-w-md w-full flex flex-col"> | |
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> | |
| <h3 class="text-lg font-medium">Add 3D Text</h3> | |
| <button id="close-text-modal" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> | |
| <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="p-4"> | |
| <div class="mb-4"> | |
| <label for="text-value" class="block text-sm font-medium mb-1">Your Text</label> | |
| <input type="text" id="text-value" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-base" placeholder="Type your text..."> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="text-size" class="block text-sm font-medium mb-1">Size</label> | |
| <input type="range" id="text-size" min="0.1" max="5" step="0.1" value="1" class="w-full"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>Small</span> | |
| <span>Large</span> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="text-depth" class="block text-sm font-medium mb-1">Depth</label> | |
| <input type="range" id="text-depth" min="0.1" max="2" step="0.1" value="0.2" class="w-full"> | |
| <div class="flex justify-between text-xs text-gray-500"> | |
| <span>Thin</span> | |
| <span>Thick</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-2"> | |
| <button id="cancel-text" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Cancel</button> | |
| <button id="add-text" class="px-4 py-2 bg-primary hover:bg-secondary text-white rounded transition-colors">Add Text</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="texture-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 hidden"> | |
| <div class="bg-white dark:bg-darkelem rounded-lg shadow-xl max-w-md w-full flex flex-col"> | |
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> | |
| <h3 class="text-lg font-medium">Upload Texture</h3> | |
| <button id="close-texture-modal" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> | |
| <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="p-4"> | |
| <div class="mb-4"> | |
| <label for="texture-file" class="block text-sm font-medium mb-1">Select Image</label> | |
| <input type="file" id="texture-file" accept=".png,.jpg,.jpeg" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded text-base"> | |
| </div> | |
| <div id="texture-preview" class="hidden mb-4"> | |
| <p class="block text-sm font-medium mb-1">Preview</p> | |
| <img id="texture-preview-img" class="max-w-full max-h-48 mx-auto border border-gray-300 dark:border-gray-600 rounded" alt="Texture Preview"> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="texture-name" class="block text-sm font-medium mb-1">Texture Name</label> | |
| <input type="text" id="texture-name" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-base" placeholder="e.g. My Custom Texture"> | |
| </div> | |
| </div> | |
| <div class="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-2"> | |
| <button id="cancel-texture" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Cancel</button> | |
| <button id="add-texture" class="px-4 py-2 bg-primary hover:bg-secondary text-white rounded transition-colors">Add Texture</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="templates-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 hidden"> | |
| <div class="bg-white dark:bg-darkelem rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col"> | |
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> | |
| <h3 class="text-lg font-medium">Choose a Template</h3> | |
| <button id="close-templates-modal" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> | |
| <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="p-4 overflow-auto flex-1"> | |
| <div class="grid grid-cols-2 md:grid-cols-3 gap-4"> | |
| <div class="template-item p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-primary dark:hover:border-primary" data-template="empty"> | |
| <div class="bg-gray-100 dark:bg-gray-800 rounded-lg aspect-video flex items-center justify-center"> | |
| <svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/> | |
| </svg> | |
| </div> | |
| <div class="mt-2"> | |
| <p class="text-sm font-medium">Empty Scene</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">Start with a blank canvas</p> | |
| </div> | |
| </div> | |
| <div class="template-item p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-primary dark:hover:border-primary" data-template="room"> | |
| <div class="bg-gray-100 dark:bg-gray-800 rounded-lg aspect-video"></div> | |
| <div class="mt-2"> | |
| <p class="text-sm font-medium">Room</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">Detailed room with furniture</p> | |
| </div> | |
| </div> | |
| <div class="template-item p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-primary dark:hover:border-primary" data-template="character"> | |
| <div class="bg-gray-100 dark:bg-gray-800 rounded-lg aspect-video"></div> | |
| <div class="mt-2"> | |
| <p class="text-sm font-medium">Character</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">Articulated character setup</p> | |
| </div> | |
| </div> | |
| <div class="template-item p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-primary dark:hover:border-primary" data-template="landscape"> | |
| <div class="bg-gray-100 dark:bg-gray-800 rounded-lg aspect-video"></div> | |
| <div class="mt-2"> | |
| <p class="text-sm font-medium">Landscape</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">Natural terrain environment</p> | |
| </div> | |
| </div> | |
| <div class="template-item p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-primary dark:hover:border-primary" data-template="vehicle"> | |
| <div class="bg-gray-100 dark:bg-gray-800 rounded-lg aspect-video"></div> | |
| <div class="mt-2"> | |
| <p class="text-sm font-medium">Vehicle</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">Detailed vehicle model</p> | |
| </div> | |
| </div> | |
| <div class="template-item p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer hover:border-primary dark:hover:border-primary" data-template="abstract"> | |
| <div class="bg-gray-100 dark:bg-gray-800 rounded-lg aspect-video"></div> | |
| <div class="mt-2"> | |
| <p class="text-sm font-medium">Abstract</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">Artistic abstract composition</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="open-file-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 hidden"> | |
| <div class="bg-white dark:bg-darkelem rounded-lg shadow-xl max-w-xl w-full flex flex-col"> | |
| <div class="p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> | |
| <h3 class="text-lg font-medium">Open File or Text</h3> | |
| <button id="close-open-file-modal" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> | |
| <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="p-4"> | |
| <div class="mb-4"> | |
| <div class="flex space-x-2 mb-4"> | |
| <button id="tab-file" class="flex-1 py-2 border-b-2 border-primary text-primary font-medium">File</button> | |
| <button id="tab-text" class="flex-1 py-2 border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">Text</button> | |
| </div> | |
| <div id="file-input-container"> | |
| <label for="open-file-input" class="block text-sm font-medium mb-1">Select 3D File</label> | |
| <input type="file" id="open-file-input" accept=".obj,.stl,.glb,.gltf,.json" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded text-base"> | |
| <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Supported formats: OBJ, STL, GLB/GLTF, JSON</p> | |
| </div> | |
| <div id="text-input-container" class="hidden"> | |
| <label for="open-text-format" class="block text-sm font-medium mb-1">Format</label> | |
| <select id="open-text-format" class="w-full p-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-base mb-2"> | |
| <option value="json">JSON</option> | |
| <option value="obj">OBJ</option> | |
| </select> | |
| <label for="open-text-content" class="block text-sm font-medium mb-1">Paste Content</label> | |
| <textarea id="open-text-content" class="w-full h-48 p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-base font-mono" placeholder="Paste your 3D model data here..."></textarea> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-2"> | |
| <button id="cancel-open-file" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">Cancel</button> | |
| <button id="confirm-open-file" class="px-4 py-2 bg-primary hover:bg-secondary text-white rounded transition-colors">Open</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="notification-container" class="fixed bottom-4 right-4 z-20 flex flex-col space-y-2"></div> | |
| <script> | |
| const state = { | |
| isDarkMode: window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches, | |
| selectedObject: null, | |
| history: [], | |
| historyIndex: -1, | |
| maxHistorySize: 50, | |
| snapToGrid: true, | |
| snapIncrement: 0.25, | |
| showGrid: true, | |
| showAxes: true, | |
| showShadows: true, | |
| activeTab: 'tools', | |
| currentMode: 'translate', | |
| objectCounter: 0, | |
| viewportMode: 'perspective', | |
| lastAction: null, | |
| customTextures: {}, | |
| assetFiles: [], | |
| currentOpenTab: 'file', | |
| }; | |
| if (state.isDarkMode) { | |
| document.documentElement.classList.add('dark'); | |
| } | |
| window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => { | |
| state.isDarkMode = event.matches; | |
| document.documentElement.classList.toggle('dark', event.matches); | |
| }); | |
| const canvasContainer = document.getElementById('canvas-container'); | |
| const scene = new THREE.Scene(); | |
| const renderer = new THREE.WebGLRenderer({antialias: true, preserveDrawingBuffer: true}); | |
| renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| canvasContainer.appendChild(renderer.domElement); | |
| const camera = new THREE.PerspectiveCamera(75, canvasContainer.clientWidth / canvasContainer.clientHeight, 0.1, 1000); | |
| camera.position.set(5, 5, 5); | |
| const orbitControls = new THREE.OrbitControls(camera, renderer.domElement); | |
| orbitControls.enableDamping = true; | |
| orbitControls.dampingFactor = 0.25; | |
| const transformControls = new THREE.TransformControls(camera, renderer.domElement); | |
| transformControls.addEventListener('dragging-changed', event => { | |
| orbitControls.enabled = !event.value; | |
| }); | |
| transformControls.addEventListener('objectChange', () => { | |
| if (state.selectedObject) { | |
| state.lastAction = { | |
| type: 'transform', | |
| object: state.selectedObject.uuid, | |
| position: state.selectedObject.position.clone(), | |
| rotation: state.selectedObject.rotation.clone(), | |
| scale: state.selectedObject.scale.clone() | |
| }; | |
| } | |
| }); | |
| transformControls.addEventListener('mouseUp', () => { | |
| if (state.selectedObject && state.lastAction) { | |
| addToHistory(state.lastAction); | |
| state.lastAction = null; | |
| } | |
| }); | |
| scene.add(transformControls); | |
| const ambientLight = new THREE.AmbientLight(0x404040, 0.5); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| directionalLight.position.set(5, 10, 7.5); | |
| directionalLight.castShadow = true; | |
| directionalLight.shadow.mapSize.width = 1024; | |
| directionalLight.shadow.mapSize.height = 1024; | |
| directionalLight.shadow.camera.near = 0.5; | |
| directionalLight.shadow.camera.far = 50; | |
| directionalLight.shadow.camera.left = -10; | |
| directionalLight.shadow.camera.right = 10; | |
| directionalLight.shadow.camera.top = 10; | |
| directionalLight.shadow.camera.bottom = -10; | |
| scene.add(directionalLight); | |
| const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x404040, 0.6); | |
| scene.add(hemisphereLight); | |
| const gridHelper = new THREE.GridHelper(20, 20, 0x888888, 0x444444); | |
| scene.add(gridHelper); | |
| const axesHelper = new THREE.AxesHelper(5); | |
| scene.add(axesHelper); | |
| const groundGeometry = new THREE.PlaneGeometry(100, 100); | |
| const groundMaterial = new THREE.MeshPhongMaterial({color: 0x999999, side: THREE.DoubleSide}); | |
| const ground = new THREE.Mesh(groundGeometry, groundMaterial); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.position.y = -0.01; | |
| ground.receiveShadow = true; | |
| scene.add(ground); | |
| const sceneObjects = {}; | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| let selectableObjects = []; | |
| const textureLoader = new THREE.TextureLoader(); | |
| function safeLoadTexture(url) { | |
| return new Promise((resolve, reject) => { | |
| const texture = textureLoader.load( | |
| url, | |
| (texture) => { | |
| resolve(texture); | |
| }, | |
| (progressEvent) => { | |
| }, | |
| (error) => { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 128; | |
| canvas.height = 128; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.fillStyle = '#555555'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = '#999999'; | |
| ctx.font = '14px Arial'; | |
| ctx.fillText('Error', 40, 64); | |
| const fallbackTexture = new THREE.CanvasTexture(canvas); | |
| fallbackTexture.sourceFile = 'error'; | |
| resolve(fallbackTexture); | |
| } | |
| ); | |
| }); | |
| } | |
| function storeObjectState(object) { | |
| if (!object) return null; | |
| return { | |
| uuid: object.uuid, | |
| position: object.position.clone(), | |
| rotation: object.rotation.clone(), | |
| scale: object.scale.clone(), | |
| material: object.material ? { | |
| color: object.material.color ? object.material.color.getHex() : null, | |
| map: object.material.map ? object.material.map.sourceFile : null | |
| } : null, | |
| geometry: object.geometry ? object.geometry.type : null, | |
| userData: JSON.parse(JSON.stringify(object.userData)) | |
| }; | |
| } | |
| function createGeometry(shape, params = {}) { | |
| switch (shape) { | |
| case 'box': | |
| return new THREE.BoxGeometry( | |
| params.width || 1, | |
| params.height || 2, | |
| params.depth || 1 | |
| ); | |
| case 'sphere': | |
| return new THREE.SphereGeometry( | |
| params.radius || 1, | |
| params.widthSegments || 32, | |
| params.heightSegments || 32 | |
| ); | |
| case 'cylinder': | |
| return new THREE.CylinderGeometry( | |
| params.radiusTop || 0.5, | |
| params.radiusBottom || 0.5, | |
| params.height || 2, | |
| params.radialSegments || 32 | |
| ); | |
| case 'cone': | |
| return new THREE.ConeGeometry( | |
| params.radius || 1, | |
| params.height || 2, | |
| params.radialSegments || 32 | |
| ); | |
| case 'torus': | |
| return new THREE.TorusGeometry( | |
| params.radius || 1, | |
| params.tube || 0.4, | |
| params.radialSegments || 16, | |
| params.tubularSegments || 32 | |
| ); | |
| case 'plane': | |
| return new THREE.PlaneGeometry( | |
| params.width || 2, | |
| params.height || 2 | |
| ); | |
| case 'circle': | |
| return new THREE.CircleGeometry( | |
| params.radius || 1, | |
| params.segments || 32 | |
| ); | |
| case 'ring': | |
| return new THREE.RingGeometry( | |
| params.innerRadius || 0.5, | |
| params.outerRadius || 1, | |
| params.thetaSegments || 32 | |
| ); | |
| case 'tetrahedron': | |
| return new THREE.TetrahedronGeometry(params.radius || 1); | |
| case 'octahedron': | |
| return new THREE.OctahedronGeometry(params.radius || 1); | |
| case 'dodecahedron': | |
| return new THREE.DodecahedronGeometry(params.radius || 1); | |
| case 'icosahedron': | |
| return new THREE.IcosahedronGeometry(params.radius || 1); | |
| case 'torusKnot': | |
| return new THREE.TorusKnotGeometry( | |
| params.radius || 1, | |
| params.tube || 0.4, | |
| params.tubularSegments || 64, | |
| params.radialSegments || 8, | |
| params.p || 2, | |
| params.q || 3 | |
| ); | |
| case 'capsule': | |
| return new THREE.CapsuleGeometry( | |
| params.radius || 0.5, | |
| params.length || 1, | |
| params.capSegments || 10, | |
| params.radialSegments || 16 | |
| ); | |
| case 'lathe': | |
| const points = []; | |
| for (let i = 0; i < 10; i++) { | |
| const y = i * 0.2; | |
| const x = Math.sin(i * 0.2) * 0.5 + 0.3; | |
| points.push(new THREE.Vector2(x, y)); | |
| } | |
| return new THREE.LatheGeometry( | |
| params.points || points, | |
| params.segments || 12, | |
| params.phiStart || 0, | |
| params.phiLength || Math.PI * 2 | |
| ); | |
| case 'extrude': | |
| const heartShape = new THREE.Shape(); | |
| heartShape.moveTo(0, 0); | |
| heartShape.bezierCurveTo(0, -0.5, -1, -0.5, -1, 0); | |
| heartShape.bezierCurveTo(-1, 0.5, 0, 1, 0, 1.5); | |
| heartShape.bezierCurveTo(0, 1, 1, 0.5, 1, 0); | |
| heartShape.bezierCurveTo(1, -0.5, 0, -0.5, 0, 0); | |
| return new THREE.ExtrudeGeometry( | |
| params.shape || heartShape, | |
| { | |
| depth: params.depth || 0.5, | |
| bevelEnabled: params.bevelEnabled !== undefined ? params.bevelEnabled : true, | |
| bevelThickness: params.bevelThickness || 0.1, | |
| bevelSize: params.bevelSize || 0.1, | |
| bevelSegments: params.bevelSegments || 3 | |
| } | |
| ); | |
| case 'tube': | |
| const path = new THREE.CatmullRomCurve3([ | |
| new THREE.Vector3(-1, 0, 0), | |
| new THREE.Vector3(-0.5, 0.5, 0.5), | |
| new THREE.Vector3(0, 0, 1), | |
| new THREE.Vector3(0.5, -0.5, 0.5), | |
| new THREE.Vector3(1, 0, 0) | |
| ]); | |
| return new THREE.TubeGeometry( | |
| params.path || path, | |
| params.tubularSegments || 64, | |
| params.radius || 0.2, | |
| params.radialSegments || 16, | |
| params.closed || false | |
| ); | |
| case 'pyramid': | |
| const pyramidGeometry = new THREE.BufferGeometry(); | |
| const vertices = new Float32Array([ | |
| -0.5, 0, -0.5, | |
| 0.5, 0, -0.5, | |
| 0.5, 0, 0.5, | |
| -0.5, 0, 0.5, | |
| 0, 1, 0 | |
| ]); | |
| const indices = [ | |
| 0, 1, 2, | |
| 0, 2, 3, | |
| 0, 4, 1, | |
| 1, 4, 2, | |
| 2, 4, 3, | |
| 3, 4, 0 | |
| ]; | |
| pyramidGeometry.setIndex(indices); | |
| pyramidGeometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); | |
| pyramidGeometry.computeVertexNormals(); | |
| return pyramidGeometry; | |
| default: | |
| return new THREE.BoxGeometry(1, 1, 1); | |
| } | |
| } | |
| async function loadTexture(textureName) { | |
| if (state.customTextures[textureName]) { | |
| return state.customTextures[textureName]; | |
| } | |
| const textureMappings = { | |
| 'brick': 'https://threejs.org/examples/textures/brick_diffuse.jpg', | |
| 'wood': 'https://threejs.org/examples/textures/wood.jpg', | |
| 'metal': 'https://threejs.org/examples/textures/metal.jpg', | |
| 'stone': 'https://threejs.org/examples/textures/stone.jpg', | |
| 'grass': 'https://threejs.org/examples/textures/grass.jpg', | |
| 'water': 'https://threejs.org/examples/textures/water.jpg', | |
| 'marble': 'https://threejs.org/examples/textures/marble.jpg', | |
| 'concrete': 'https://threejs.org/examples/textures/concrete.jpg', | |
| 'fabric': 'https://threejs.org/examples/textures/fabric.jpg', | |
| 'leather': 'https://threejs.org/examples/textures/leather.jpg', | |
| 'glass': 'https://threejs.org/examples/textures/glass.jpg', | |
| 'ice': 'https://threejs.org/examples/textures/ice.jpg', | |
| 'earth': 'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg', | |
| 'moon': 'https://threejs.org/examples/textures/planets/moon_1024.jpg', | |
| 'stars': 'https://threejs.org/examples/textures/stars.jpg' | |
| }; | |
| if (textureMappings[textureName]) { | |
| try { | |
| const texture = await safeLoadTexture(textureMappings[textureName]); | |
| texture.sourceFile = textureName; | |
| return texture; | |
| } catch (error) { | |
| return createProceduralTexture(textureName); | |
| } | |
| } | |
| switch (textureName) { | |
| case 'checkerboard': | |
| case 'grid': | |
| case 'dots': | |
| case 'carbon': | |
| case 'rubber': | |
| case 'plastic': | |
| case 'clouds': | |
| return createProceduralTexture(textureName); | |
| case 'none': | |
| default: | |
| return null; | |
| } | |
| } | |
| function createProceduralTexture(type) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 512; | |
| canvas.height = 512; | |
| const ctx = canvas.getContext('2d'); | |
| switch (type) { | |
| case 'checkerboard': | |
| const size = 64; | |
| for (let y = 0; y < canvas.height; y += size) { | |
| for (let x = 0; x < canvas.width; x += size) { | |
| ctx.fillStyle = ((x / size) + (y / size)) % 2 === 0 ? '#fff' : '#000'; | |
| ctx.fillRect(x, y, size, size); | |
| } | |
| } | |
| break; | |
| case 'grid': | |
| ctx.fillStyle = '#fff'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.strokeStyle = '#000'; | |
| ctx.lineWidth = 2; | |
| const gridSize = 32; | |
| for (let y = 0; y <= canvas.height; y += gridSize) { | |
| ctx.beginPath(); | |
| ctx.moveTo(0, y); | |
| ctx.lineTo(canvas.width, y); | |
| ctx.stroke(); | |
| } | |
| for (let x = 0; x <= canvas.width; x += gridSize) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, 0); | |
| ctx.lineTo(x, canvas.height); | |
| ctx.stroke(); | |
| } | |
| break; | |
| case 'dots': | |
| ctx.fillStyle = '#fff'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = '#000'; | |
| const dotSize = 5; | |
| const spacing = 32; | |
| for (let y = spacing/2; y < canvas.height; y += spacing) { | |
| for (let x = spacing/2; x < canvas.width; x += spacing) { | |
| ctx.beginPath(); | |
| ctx.arc(x, y, dotSize, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| break; | |
| case 'carbon': | |
| ctx.fillStyle = '#222'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = '#444'; | |
| const carbonSize = 10; | |
| for (let y = 0; y < canvas.height; y += carbonSize * 2) { | |
| for (let x = 0; x < canvas.width; x += carbonSize * 2) { | |
| ctx.beginPath(); | |
| ctx.ellipse(x + carbonSize, y + carbonSize, carbonSize, carbonSize * 0.5, Math.PI / 4, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| break; | |
| case 'rubber': | |
| ctx.fillStyle = '#333'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = 'rgba(255,255,255,0.05)'; | |
| for (let i = 0; i < 1000; i++) { | |
| const x = Math.random() * canvas.width; | |
| const y = Math.random() * canvas.height; | |
| const size = Math.random() * 3 + 1; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, size, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| break; | |
| case 'plastic': | |
| const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); | |
| gradient.addColorStop(0, '#fff'); | |
| gradient.addColorStop(1, '#ddd'); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = 'rgba(255,255,255,0.5)'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height / 2); | |
| break; | |
| case 'clouds': | |
| ctx.fillStyle = '#87CEEB'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| const cloudGradient = ctx.createRadialGradient( | |
| canvas.width / 2, canvas.height / 2, 0, | |
| canvas.width / 2, canvas.height / 2, canvas.width / 2 | |
| ); | |
| cloudGradient.addColorStop(0, 'rgba(255, 255, 255, 0.8)'); | |
| cloudGradient.addColorStop(0.4, 'rgba(255, 255, 255, 0.6)'); | |
| cloudGradient.addColorStop(0.7, 'rgba(255, 255, 255, 0.3)'); | |
| cloudGradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; | |
| for (let i = 0; i < 10; i++) { | |
| const x = Math.random() * canvas.width; | |
| const y = Math.random() * canvas.height * 0.7; | |
| const size = Math.random() * 100 + 50; | |
| for (let j = 0; j < 5; j++) { | |
| const offsetX = (Math.random() - 0.5) * size * 0.7; | |
| const offsetY = (Math.random() - 0.5) * size * 0.7; | |
| const circleSize = size * (0.5 + Math.random() * 0.5); | |
| ctx.beginPath(); | |
| ctx.arc(x + offsetX, y + offsetY, circleSize / 2, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| break; | |
| default: | |
| ctx.fillStyle = '#fff'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| } | |
| const texture = new THREE.CanvasTexture(canvas); | |
| texture.sourceFile = type; | |
| return texture; | |
| } | |
| async function createMaterial(color, textureName) { | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: new THREE.Color(color), | |
| shininess: 30, | |
| }); | |
| if (textureName && textureName !== 'none') { | |
| try { | |
| const texture = await loadTexture(textureName); | |
| if (texture) { | |
| material.map = texture; | |
| material.needsUpdate = true; | |
| } | |
| } catch (error) { | |
| } | |
| } | |
| return material; | |
| } | |
| async function applyTexture(object, textureName) { | |
| try { | |
| const texture = await loadTexture(textureName); | |
| if (texture) { | |
| object.material.map = texture; | |
| object.material.needsUpdate = true; | |
| } else { | |
| object.material.map = null; | |
| object.material.needsUpdate = true; | |
| } | |
| addToHistory({ | |
| type: 'changeTexture', | |
| object: object.uuid, | |
| texture: textureName | |
| }); | |
| } catch (error) { | |
| } | |
| } | |
| async function createObject(params = {}) { | |
| const shape = params.shape || document.getElementById('object-shape').value; | |
| const color = params.color || document.getElementById('object-color').value; | |
| const texture = params.texture || document.getElementById('object-texture').value; | |
| const position = params.position || new THREE.Vector3(0, 1, 0); | |
| const scale = params.scale || new THREE.Vector3(1, 1, 1); | |
| const rotation = params.rotation || new THREE.Euler(0, 0, 0); | |
| const type = params.type || 'object'; | |
| if (shape === 'text') { | |
| showTextInputModal(); | |
| return; | |
| } | |
| const geometry = createGeometry(shape, params.geometryParams || {}); | |
| const material = await createMaterial(color, texture); | |
| const object = new THREE.Mesh(geometry, material); | |
| object.position.copy(position); | |
| object.scale.copy(scale); | |
| object.rotation.copy(rotation); | |
| object.castShadow = true; | |
| object.receiveShadow = true; | |
| const objectId = `${type}_${++state.objectCounter}`; | |
| object.userData = { | |
| type: type, | |
| id: objectId, | |
| shape: shape, | |
| createdAt: Date.now() | |
| }; | |
| scene.add(object); | |
| sceneObjects[objectId] = object; | |
| selectableObjects.push(object); | |
| selectObject(object); | |
| addToHistory({ | |
| type: 'create', | |
| objectType: type, | |
| shape: shape, | |
| color: color, | |
| texture: texture, | |
| position: object.position.clone(), | |
| rotation: object.rotation.clone(), | |
| scale: object.scale.clone(), | |
| object: object.uuid | |
| }); | |
| return object; | |
| } | |
| function createText3D(text, size = 1, depth = 0.2) { | |
| const geometry = new THREE.BoxGeometry(text.length * 0.5 * size, size, depth); | |
| const material = createMaterial(document.getElementById('object-color').value, 'none'); | |
| const textMesh = new THREE.Mesh(geometry, material); | |
| textMesh.userData = { | |
| type: 'text', | |
| id: `text_${++state.objectCounter}`, | |
| textValue: text, | |
| textSize: size, | |
| textDepth: depth, | |
| createdAt: Date.now() | |
| }; | |
| textMesh.position.set(0, size/2, 0); | |
| textMesh.castShadow = true; | |
| textMesh.receiveShadow = true; | |
| scene.add(textMesh); | |
| sceneObjects[textMesh.userData.id] = textMesh; | |
| selectableObjects.push(textMesh); | |
| selectObject(textMesh); | |
| addToHistory({ | |
| type: 'create', | |
| objectType: 'text', | |
| textValue: text, | |
| textSize: size, | |
| textDepth: depth, | |
| position: textMesh.position.clone(), | |
| rotation: textMesh.rotation.clone(), | |
| scale: textMesh.scale.clone(), | |
| object: textMesh.uuid | |
| }); | |
| return textMesh; | |
| } | |
| function createLight(type = 'point', params = {}) { | |
| let light; | |
| if (params.replaceSceneLight && sceneObjects.light) { | |
| scene.remove(sceneObjects.light); | |
| disposeObject(sceneObjects.light); | |
| if (state.selectedObject === sceneObjects.light) { | |
| deselectObject(); | |
| } | |
| } | |
| switch (type) { | |
| case 'point': | |
| light = new THREE.PointLight(params.color || 0xffffff, params.intensity || 1); | |
| break; | |
| case 'spot': | |
| light = new THREE.SpotLight(params.color || 0xffffff, params.intensity || 1); | |
| light.angle = params.angle || Math.PI / 6; | |
| light.penumbra = params.penumbra || 0.2; | |
| break; | |
| case 'directional': | |
| light = new THREE.DirectionalLight(params.color || 0xffffff, params.intensity || 1); | |
| break; | |
| default: | |
| light = new THREE.PointLight(params.color || 0xffffff, params.intensity || 1); | |
| } | |
| light.position.set( | |
| params.position ? params.position.x : 0, | |
| params.position ? params.position.y : 5, | |
| params.position ? params.position.z : 0 | |
| ); | |
| light.castShadow = params.castShadow !== undefined ? params.castShadow : true; | |
| if (light.shadow) { | |
| light.shadow.mapSize.width = 1024; | |
| light.shadow.mapSize.height = 1024; | |
| light.shadow.camera.near = 0.5; | |
| light.shadow.camera.far = 50; | |
| } | |
| const sphereGeometry = new THREE.SphereGeometry(0.2, 16, 8); | |
| const sphereMaterial = new THREE.MeshBasicMaterial({ | |
| color: 0xffff00, | |
| transparent: true, | |
| opacity: 0.7 | |
| }); | |
| const lightMesh = new THREE.Mesh(sphereGeometry, sphereMaterial); | |
| light.add(lightMesh); | |
| light.userData = { | |
| type: 'light', | |
| lightType: type, | |
| id: `light_${++state.objectCounter}`, | |
| createdAt: Date.now() | |
| }; | |
| let helper; | |
| if (type === 'point') { | |
| helper = new THREE.PointLightHelper(light, 0.5); | |
| } else if (type === 'spot') { | |
| helper = new THREE.SpotLightHelper(light); | |
| } else if (type === 'directional') { | |
| helper = new THREE.DirectionalLightHelper(light, 1); | |
| } | |
| if (helper) { | |
| helper.userData = { | |
| isHelper: true, | |
| parentId: light.userData.id | |
| }; | |
| scene.add(helper); | |
| light.userData.helper = helper; | |
| } | |
| scene.add(light); | |
| sceneObjects[light.userData.id] = light; | |
| selectableObjects.push(light); | |
| if (params.replaceSceneLight) { | |
| sceneObjects.light = light; | |
| } | |
| selectObject(light); | |
| addToHistory({ | |
| type: 'create', | |
| objectType: 'light', | |
| lightType: type, | |
| position: light.position.clone(), | |
| object: light.uuid | |
| }); | |
| return light; | |
| } | |
| function disposeObject(object) { | |
| if (!object) return; | |
| const objectState = storeObjectState(object); | |
| if (object.userData && object.userData.helper) { | |
| scene.remove(object.userData.helper); | |
| } | |
| if (object.geometry) { | |
| object.geometry.dispose(); | |
| } | |
| if (object.material) { | |
| if (Array.isArray(object.material)) { | |
| object.material.forEach(material => disposeMaterial(material)); | |
| } else { | |
| disposeMaterial(object.material); | |
| } | |
| } | |
| const index = selectableObjects.indexOf(object); | |
| if (index !== -1) { | |
| selectableObjects.splice(index, 1); | |
| } | |
| if (object.userData && object.userData.id) { | |
| delete sceneObjects[object.userData.id]; | |
| } | |
| scene.remove(object); | |
| addToHistory({ | |
| type: 'delete', | |
| objectUUID: object.uuid, | |
| objectState: objectState | |
| }); | |
| if (state.selectedObject === object) { | |
| deselectObject(); | |
| } | |
| return objectState; | |
| } | |
| function disposeMaterial(material) { | |
| if (!material) return; | |
| if (material.map) { | |
| material.map.dispose(); | |
| } | |
| for (const key in material) { | |
| if (material[key] && material[key].isTexture) { | |
| material[key].dispose(); | |
| } | |
| } | |
| material.dispose(); | |
| } | |
| function selectObject(object) { | |
| if (object === state.selectedObject) return; | |
| state.selectedObject = object; | |
| transformControls.attach(object); | |
| updateObjectPropertiesUI(object); | |
| document.getElementById('delete-object').disabled = false; | |
| } | |
| function deselectObject() { | |
| transformControls.detach(); | |
| state.selectedObject = null; | |
| updateObjectPropertiesUI(null); | |
| document.getElementById('delete-object').disabled = true; | |
| } | |
| function updateObjectPropertiesUI(object) { | |
| if (!object) return; | |
| const objectShape = determineObjectShape(object); | |
| if (objectShape) { | |
| document.getElementById('object-shape').value = objectShape; | |
| } | |
| if (object.material && object.material.color) { | |
| const color = '#' + object.material.color.getHexString(); | |
| document.getElementById('object-color').value = color; | |
| } | |
| if (object.material && object.material.map) { | |
| const textureName = determineObjectTexture(object); | |
| document.getElementById('object-texture').value = textureName || 'none'; | |
| } else { | |
| document.getElementById('object-texture').value = 'none'; | |
| } | |
| } | |
| function determineObjectShape(object) { | |
| if (!object || !object.geometry) return null; | |
| if (object.userData && object.userData.shape) { | |
| return object.userData.shape; | |
| } | |
| if (object.geometry instanceof THREE.BoxGeometry) return 'box'; | |
| if (object.geometry instanceof THREE.SphereGeometry) return 'sphere'; | |
| if (object.geometry instanceof THREE.CylinderGeometry) return 'cylinder'; | |
| if (object.geometry instanceof THREE.ConeGeometry) return 'cone'; | |
| if (object.geometry instanceof THREE.TorusGeometry) return 'torus'; | |
| if (object.geometry instanceof THREE.PlaneGeometry) return 'plane'; | |
| if (object.geometry instanceof THREE.CircleGeometry) return 'circle'; | |
| if (object.geometry instanceof THREE.RingGeometry) return 'ring'; | |
| if (object.geometry instanceof THREE.TetrahedronGeometry) return 'tetrahedron'; | |
| if (object.geometry instanceof THREE.OctahedronGeometry) return 'octahedron'; | |
| if (object.geometry instanceof THREE.DodecahedronGeometry) return 'dodecahedron'; | |
| if (object.geometry instanceof THREE.IcosahedronGeometry) return 'icosahedron'; | |
| if (object.geometry instanceof THREE.TorusKnotGeometry) return 'torusKnot'; | |
| if (object.geometry instanceof THREE.CapsuleGeometry) return 'capsule'; | |
| if (object.geometry instanceof THREE.LatheGeometry) return 'lathe'; | |
| if (object.geometry instanceof THREE.ExtrudeGeometry) return 'extrude'; | |
| if (object.geometry instanceof THREE.TubeGeometry) return 'tube'; | |
| return null; | |
| } | |
| function determineObjectTexture(object) { | |
| if (!object || !object.material || !object.material.map) return null; | |
| const texture = object.material.map; | |
| if (texture.sourceFile) { | |
| return texture.sourceFile; | |
| } | |
| for (const [name, customTexture] of Object.entries(state.customTextures)) { | |
| if (texture === customTexture) { | |
| return name; | |
| } | |
| } | |
| const url = texture.image && texture.image.src ? texture.image.src : ''; | |
| if (url.includes('brick_diffuse')) return 'brick'; | |
| if (url.includes('wood')) return 'wood'; | |
| if (url.includes('metal')) return 'metal'; | |
| if (url.includes('stone')) return 'stone'; | |
| if (url.includes('grass')) return 'grass'; | |
| if (url.includes('water')) return 'water'; | |
| if (url.includes('marble')) return 'marble'; | |
| if (url.includes('concrete')) return 'concrete'; | |
| if (url.includes('fabric')) return 'fabric'; | |
| if (url.includes('leather')) return 'leather'; | |
| if (url.includes('glass')) return 'glass'; | |
| if (url.includes('ice')) return 'ice'; | |
| if (url.includes('earth_atmos')) return 'earth'; | |
| if (url.includes('moon')) return 'moon'; | |
| if (url.includes('stars')) return 'stars'; | |
| return 'none'; | |
| } | |
| function newScene() { | |
| const objectsToRemove = []; | |
| scene.children.forEach(child => { | |
| if (child !== ground && child !== ambientLight && | |
| child !== directionalLight && child !== hemisphereLight && | |
| child !== gridHelper && child !== axesHelper && child !== transformControls) { | |
| objectsToRemove.push(child); | |
| } | |
| }); | |
| objectsToRemove.forEach(object => { | |
| scene.remove(object); | |
| disposeObject(object); | |
| }); | |
| Object.keys(sceneObjects).forEach(key => { | |
| delete sceneObjects[key]; | |
| }); | |
| selectableObjects = []; | |
| deselectObject(); | |
| state.history = []; | |
| state.historyIndex = -1; | |
| state.objectCounter = 0; | |
| updateHistoryButtonStates(); | |
| createObject({ | |
| shape: 'box', | |
| color: '#00cc00', | |
| texture: 'none', | |
| position: new THREE.Vector3(0, 1, 0), | |
| type: 'object' | |
| }); | |
| } | |
| function saveScene() { | |
| takeScreenshot(); | |
| const sceneJson = { | |
| objects: [], | |
| settings: { | |
| showGrid: state.showGrid, | |
| showAxes: state.showAxes, | |
| showShadows: state.showShadows | |
| } | |
| }; | |
| Object.values(sceneObjects).forEach(obj => { | |
| if (obj.userData && obj.userData.type) { | |
| const objectData = { | |
| type: obj.userData.type, | |
| id: obj.userData.id, | |
| position: { | |
| x: obj.position.x, | |
| y: obj.position.y, | |
| z: obj.position.z | |
| }, | |
| rotation: { | |
| x: obj.rotation.x, | |
| y: obj.rotation.y, | |
| z: obj.rotation.z | |
| }, | |
| scale: { | |
| x: obj.scale.x, | |
| y: obj.scale.y, | |
| z: obj.scale.z | |
| } | |
| }; | |
| if (obj.userData.type === 'light') { | |
| objectData.lightType = obj.userData.lightType; | |
| objectData.intensity = obj.intensity; | |
| objectData.color = obj.color ? '#' + obj.color.getHexString() : '#ffffff'; | |
| } else if (obj.userData.shape) { | |
| objectData.shape = obj.userData.shape; | |
| if (obj.material) { | |
| objectData.material = { | |
| color: '#' + obj.material.color.getHexString(), | |
| texture: obj.material.map ? determineObjectTexture(obj) : 'none' | |
| }; | |
| } | |
| } | |
| sceneJson.objects.push(objectData); | |
| } | |
| }); | |
| const jsonContent = JSON.stringify(sceneJson, null, 2); | |
| } | |
| function exportToObj() { | |
| if (!state.selectedObject) { | |
| return; | |
| } | |
| const exporter = new THREE.OBJExporter(); | |
| const result = exporter.parse(state.selectedObject); | |
| } | |
| function exportToJson() { | |
| saveScene(); | |
| } | |
| function deleteSelectedObject() { | |
| if (!state.selectedObject) { | |
| return; | |
| } | |
| const objectState = storeObjectState(state.selectedObject); | |
| addToHistory({ | |
| type: 'delete', | |
| objectUUID: state.selectedObject.uuid, | |
| objectState: objectState | |
| }); | |
| scene.remove(state.selectedObject); | |
| disposeObject(state.selectedObject); | |
| deselectObject(); | |
| } | |
| function addToHistory(action) { | |
| if (state.historyIndex < state.history.length - 1) { | |
| state.history = state.history.slice(0, state.historyIndex + 1); | |
| } | |
| if (action.type === 'transform') { | |
| const object = findObjectByUUID(action.object); | |
| if (object) { | |
| let previousState = null; | |
| for (let i = state.history.length - 1; i >= 0; i--) { | |
| const historyItem = state.history[i]; | |
| if ((historyItem.type === 'create' || historyItem.type === 'transform') && | |
| historyItem.object === action.object) { | |
| previousState = { | |
| position: historyItem.position.clone(), | |
| rotation: historyItem.rotation.clone(), | |
| scale: historyItem.scale.clone() | |
| }; | |
| break; | |
| } | |
| } | |
| if (previousState) { | |
| action.previousState = previousState; | |
| } | |
| } | |
| } | |
| state.history.push(action); | |
| if (state.history.length > state.maxHistorySize) { | |
| state.history.shift(); | |
| } else { | |
| state.historyIndex++; | |
| } | |
| updateHistoryButtonStates(); | |
| } | |
| function updateHistoryButtonStates() { | |
| document.getElementById('undo-btn').disabled = state.historyIndex < 0; | |
| document.getElementById('redo-btn').disabled = state.historyIndex >= state.history.length - 1; | |
| } | |
| function undoAction() { | |
| if (state.historyIndex < 0) return; | |
| const action = state.history[state.historyIndex]; | |
| switch (action.type) { | |
| case 'create': | |
| const createdObject = findObjectByUUID(action.object); | |
| if (createdObject) { | |
| scene.remove(createdObject); | |
| const index = selectableObjects.indexOf(createdObject); | |
| if (index !== -1) { | |
| selectableObjects.splice(index, 1); | |
| } | |
| if (createdObject.userData && createdObject.userData.id) { | |
| delete sceneObjects[createdObject.userData.id]; | |
| } | |
| if (state.selectedObject === createdObject) { | |
| deselectObject(); | |
| } | |
| } | |
| break; | |
| case 'delete': | |
| if (action.objectState) { | |
| const geometry = createGeometry(action.objectState.userData.shape || 'box'); | |
| const material = new THREE.MeshPhongMaterial({ | |
| color: action.objectState.material && action.objectState.material.color || 0x00cc00, | |
| shininess: 30 | |
| }); | |
| const restoredObject = new THREE.Mesh(geometry, material); | |
| restoredObject.position.copy(action.objectState.position); | |
| restoredObject.rotation.copy(action.objectState.rotation); | |
| restoredObject.scale.copy(action.objectState.scale); | |
| restoredObject.userData = action.objectState.userData; | |
| scene.add(restoredObject); | |
| sceneObjects[restoredObject.userData.id] = restoredObject; | |
| selectableObjects.push(restoredObject); | |
| if (action.objectState.material && action.objectState.material.map) { | |
| applyTexture(restoredObject, action.objectState.material.map); | |
| } | |
| selectObject(restoredObject); | |
| } | |
| break; | |
| case 'transform': | |
| if (action.previousState) { | |
| const object = findObjectByUUID(action.object); | |
| if (object) { | |
| object.position.copy(action.previousState.position); | |
| object.rotation.copy(action.previousState.rotation); | |
| object.scale.copy(action.previousState.scale); | |
| if (object.userData.helper) { | |
| object.userData.helper.update(); | |
| } | |
| } | |
| } | |
| break; | |
| case 'changeColor': | |
| if (action.previousColor) { | |
| const object = findObjectByUUID(action.object); | |
| if (object && object.material) { | |
| object.material.color.set(action.previousColor); | |
| } | |
| } | |
| break; | |
| case 'changeTexture': | |
| if (action.previousTexture) { | |
| const object = findObjectByUUID(action.object); | |
| if (object) { | |
| applyTexture(object, action.previousTexture); | |
| } | |
| } | |
| break; | |
| case 'changeShape': | |
| break; | |
| } | |
| state.historyIndex--; | |
| updateHistoryButtonStates(); | |
| } | |
| function redoAction() { | |
| if (state.historyIndex >= state.history.length - 1) return; | |
| state.historyIndex++; | |
| const action = state.history[state.historyIndex]; | |
| switch (action.type) { | |
| case 'create': | |
| if (action.objectType === 'object') { | |
| createObject({ | |
| shape: action.shape, | |
| color: action.color, | |
| texture: action.texture, | |
| position: action.position, | |
| rotation: action.rotation, | |
| scale: action.scale, | |
| type: action.objectType | |
| }); | |
| } else if (action.objectType === 'light') { | |
| createLight(action.lightType, { | |
| position: action.position | |
| }); | |
| } else if (action.objectType === 'text') { | |
| createText3D(action.textValue, action.textSize, action.textDepth); | |
| } | |
| break; | |
| case 'delete': | |
| const objectToDelete = findObjectByUUID(action.objectUUID); | |
| if (objectToDelete) { | |
| scene.remove(objectToDelete); | |
| disposeObject(objectToDelete); | |
| } | |
| break; | |
| case 'transform': | |
| const object = findObjectByUUID(action.object); | |
| if (object) { | |
| object.position.copy(action.position); | |
| object.rotation.copy(action.rotation); | |
| object.scale.copy(action.scale); | |
| if (object.userData.helper) { | |
| object.userData.helper.update(); | |
| } | |
| } | |
| break; | |
| case 'changeColor': | |
| const colorObj = findObjectByUUID(action.object); | |
| if (colorObj && colorObj.material) { | |
| colorObj.material.color.set(action.color); | |
| } | |
| break; | |
| case 'changeTexture': | |
| const textureObj = findObjectByUUID(action.object); | |
| if (textureObj) { | |
| applyTexture(textureObj, action.texture); | |
| } | |
| break; | |
| } | |
| updateHistoryButtonStates(); | |
| } | |
| function findObjectByUUID(uuid) { | |
| let result = null; | |
| scene.traverse(function (object) { | |
| if (object.uuid === uuid) { | |
| result = object; | |
| } | |
| }); | |
| return result; | |
| } | |
| function showOpenFileModal() { | |
| document.getElementById('open-file-modal').classList.remove('hidden'); | |
| document.getElementById('tab-file').click(); | |
| } | |
| function closeOpenFileModal() { | |
| document.getElementById('open-file-modal').classList.add('hidden'); | |
| } | |
| function switchOpenTab(tab) { | |
| state.currentOpenTab = tab; | |
| if (tab === 'file') { | |
| document.getElementById('tab-file').classList.add('border-primary', 'text-primary'); | |
| document.getElementById('tab-file').classList.remove('border-transparent', 'text-gray-500'); | |
| document.getElementById('tab-text').classList.remove('border-primary', 'text-primary'); | |
| document.getElementById('tab-text').classList.add('border-transparent', 'text-gray-500'); | |
| document.getElementById('file-input-container').classList.remove('hidden'); | |
| document.getElementById('text-input-container').classList.add('hidden'); | |
| } else { | |
| document.getElementById('tab-text').classList.add('border-primary', 'text-primary'); | |
| document.getElementById('tab-text').classList.remove('border-transparent', 'text-gray-500'); | |
| document.getElementById('tab-file').classList.remove('border-primary', 'text-primary'); | |
| document.getElementById('tab-file').classList.add('border-transparent', 'text-gray-500'); | |
| document.getElementById('text-input-container').classList.remove('hidden'); | |
| document.getElementById('file-input-container').classList.add('hidden'); | |
| } | |
| } | |
| function processOpenFile() { | |
| if (state.currentOpenTab === 'file') { | |
| const fileInput = document.getElementById('open-file-input'); | |
| if (fileInput.files.length > 0) { | |
| const file = fileInput.files[0]; | |
| handleFileImport({ target: { files: [file] } }); | |
| closeOpenFileModal(); | |
| } else { | |
| } | |
| } else { | |
| const format = document.getElementById('open-text-format').value; | |
| const content = document.getElementById('open-text-content').value.trim(); | |
| if (!content) { | |
| return; | |
| } | |
| try { | |
| if (format === 'json') { | |
| const jsonData = JSON.parse(content); | |
| loadSceneFromJson(jsonData); | |
| } else if (format === 'obj') { | |
| const blob = new Blob([content], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const loader = new THREE.OBJLoader(); | |
| loader.load( | |
| url, | |
| (object) => { | |
| addImportedObject(object, 'Pasted OBJ'); | |
| URL.revokeObjectURL(url); | |
| }, | |
| null, | |
| (error) => { | |
| URL.revokeObjectURL(url); | |
| } | |
| ); | |
| } | |
| closeOpenFileModal(); | |
| } catch (error) { | |
| } | |
| } | |
| } | |
| function showNotification(message, type = 'info', duration = 3000) { | |
| } | |
| function takeScreenshot() { | |
| const wasAttached = transformControls.object !== null; | |
| let attachedObject = null; | |
| if (wasAttached) { | |
| attachedObject = transformControls.object; | |
| transformControls.detach(); | |
| } | |
| renderer.render(scene, camera); | |
| const dataURL = renderer.domElement.toDataURL('image/png'); | |
| document.getElementById('screenshot-img').src = dataURL; | |
| document.getElementById('screenshot-modal').classList.remove('hidden'); | |
| if (wasAttached && attachedObject) { | |
| transformControls.attach(attachedObject); | |
| } | |
| } | |
| function importModel(fileType) { | |
| const importFileInput = document.getElementById('import-file'); | |
| importFileInput.accept = fileType === 'obj' ? '.obj' : | |
| fileType === 'stl' ? '.stl' : | |
| fileType === 'textures' ? '.png,.jpg,.jpeg' : | |
| '.glb,.gltf'; | |
| importFileInput.click(); | |
| } | |
| function handleFileImport(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| const extension = file.name.split('.').pop().toLowerCase(); | |
| if (['png', 'jpg', 'jpeg'].includes(extension)) { | |
| handleTextureImport(file); | |
| return; | |
| } | |
| if (extension === 'json') { | |
| reader.onload = function(e) { | |
| try { | |
| const sceneData = JSON.parse(e.target.result); | |
| loadSceneFromJson(sceneData); | |
| } catch (error) { | |
| } | |
| }; | |
| reader.readAsText(file); | |
| return; | |
| } | |
| reader.onload = function(e) { | |
| let loader; | |
| switch (extension) { | |
| case 'obj': | |
| loader = new THREE.OBJLoader(); | |
| loader.load( | |
| URL.createObjectURL(file), | |
| function(object) { | |
| addImportedObject(object, file.name); | |
| }, | |
| null, | |
| function(error) { | |
| } | |
| ); | |
| break; | |
| case 'stl': | |
| loader = new THREE.STLLoader(); | |
| loader.load( | |
| URL.createObjectURL(file), | |
| function(geometry) { | |
| const material = createMaterial(document.getElementById('object-color').value, 'none'); | |
| const mesh = new THREE.Mesh(geometry, material); | |
| addImportedObject(mesh, file.name); | |
| }, | |
| null, | |
| function(error) { | |
| } | |
| ); | |
| break; | |
| case 'glb': | |
| case 'gltf': | |
| loader = new THREE.GLTFLoader(); | |
| loader.load( | |
| URL.createObjectURL(file), | |
| function(gltf) { | |
| addImportedObject(gltf.scene, file.name); | |
| }, | |
| null, | |
| function(error) { | |
| } | |
| ); | |
| break; | |
| default: | |
| return; | |
| } | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| } | |
| function handleTextureImport(file) { | |
| document.getElementById('texture-modal').classList.remove('hidden'); | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const previewImg = document.getElementById('texture-preview-img'); | |
| previewImg.src = e.target.result; | |
| document.getElementById('texture-preview').classList.remove('hidden'); | |
| const fileName = file.name.split('.')[0]; | |
| document.getElementById('texture-name').value = fileName; | |
| document.getElementById('texture-file').file = file; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function addCustomTexture() { | |
| const textureFile = document.getElementById('texture-file').file; | |
| const textureName = document.getElementById('texture-name').value.trim(); | |
| if (!textureFile || !textureName) { | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const image = new Image(); | |
| image.src = e.target.result; | |
| image.onload = function() { | |
| const texture = new THREE.Texture(image); | |
| texture.needsUpdate = true; | |
| texture.sourceFile = textureName; | |
| state.customTextures[textureName] = texture; | |
| const textureSelect = document.getElementById('object-texture'); | |
| let exists = false; | |
| for (let i = 0; i < textureSelect.options.length; i++) { | |
| if (textureSelect.options[i].value === textureName) { | |
| exists = true; | |
| break; | |
| } | |
| } | |
| if (!exists) { | |
| const option = document.createElement('option'); | |
| option.value = textureName; | |
| option.textContent = textureName + ' (Custom)'; | |
| const customOption = Array.from(textureSelect.options).find(opt => opt.value === 'custom'); | |
| if (customOption) { | |
| textureSelect.insertBefore(option, customOption); | |
| } else { | |
| textureSelect.appendChild(option); | |
| } | |
| } | |
| document.getElementById('texture-modal').classList.add('hidden'); | |
| addAssetToPanel(textureName, e.target.result, 'texture'); | |
| if (state.selectedObject && state.selectedObject.material) { | |
| applyTexture(state.selectedObject, textureName); | |
| } | |
| }; | |
| }; | |
| reader.readAsDataURL(textureFile); | |
| } | |
| function addAssetToPanel(name, url, type) { | |
| const assetsContainer = document.getElementById('uploaded-assets'); | |
| const assetItem = document.createElement('div'); | |
| assetItem.className = 'p-1 border border-gray-200 dark:border-gray-700 rounded'; | |
| assetItem.dataset.assetName = name; | |
| assetItem.dataset.assetType = type; | |
| const thumbnail = document.createElement('div'); | |
| thumbnail.className = 'w-full aspect-square bg-gray-100 dark:bg-gray-800 rounded overflow-hidden'; | |
| if (type === 'texture') { | |
| const img = document.createElement('img'); | |
| img.src = url; | |
| img.className = 'w-full h-full object-cover'; | |
| thumbnail.appendChild(img); | |
| } else { | |
| thumbnail.innerHTML = '<div class="w-full h-full flex items-center justify-center text-gray-400">File</div>'; | |
| } | |
| const assetName = document.createElement('p'); | |
| assetName.className = 'text-xs mt-1 truncate'; | |
| assetName.textContent = name; | |
| assetItem.appendChild(thumbnail); | |
| assetItem.appendChild(assetName); | |
| if (type === 'texture') { | |
| assetItem.addEventListener('click', () => { | |
| if (state.selectedObject && state.selectedObject.material) { | |
| applyTexture(state.selectedObject, name); | |
| } else { | |
| } | |
| }); | |
| } | |
| assetsContainer.appendChild(assetItem); | |
| } | |
| function loadSceneFromJson(sceneData) { | |
| try { | |
| newScene(); | |
| if (sceneData.settings) { | |
| if (sceneData.settings.showGrid !== undefined) { | |
| state.showGrid = sceneData.settings.showGrid; | |
| gridHelper.visible = state.showGrid; | |
| document.getElementById('show-grid').checked = state.showGrid; | |
| } | |
| if (sceneData.settings.showAxes !== undefined) { | |
| state.showAxes = sceneData.settings.showAxes; | |
| axesHelper.visible = state.showAxes; | |
| document.getElementById('show-axes').checked = state.showAxes; | |
| } | |
| if (sceneData.settings.showShadows !== undefined) { | |
| state.showShadows = sceneData.settings.showShadows; | |
| renderer.shadowMap.enabled = state.showShadows; | |
| document.getElementById('show-shadows').checked = state.showShadows; | |
| } | |
| } | |
| if (Array.isArray(sceneData.objects)) { | |
| sceneData.objects.forEach(objData => { | |
| if (objData.type === 'light') { | |
| createLight(objData.lightType || 'point', { | |
| position: new THREE.Vector3( | |
| objData.position.x || 0, | |
| objData.position.y || 5, | |
| objData.position.z || 0 | |
| ), | |
| intensity: objData.intensity || 1, | |
| color: objData.color || 0xffffff | |
| }); | |
| } else { | |
| createObject({ | |
| shape: objData.shape || 'box', | |
| color: objData.material && objData.material.color ? objData.material.color : '#00cc00', | |
| texture: objData.material && objData.material.texture ? objData.material.texture : 'none', | |
| position: new THREE.Vector3( | |
| objData.position.x || 0, | |
| objData.position.y || 1, | |
| objData.position.z || 0 | |
| ), | |
| rotation: new THREE.Euler( | |
| objData.rotation.x || 0, | |
| objData.rotation.y || 0, | |
| objData.rotation.z || 0 | |
| ), | |
| scale: new THREE.Vector3( | |
| objData.scale.x || 1, | |
| objData.scale.y || 1, | |
| objData.scale.z || 1 | |
| ), | |
| type: objData.type || 'object' | |
| }); | |
| } | |
| }); | |
| } | |
| } catch (error) { | |
| } | |
| } | |
| function addImportedObject(object, filename) { | |
| object.traverse(function(child) { | |
| if (child.isMesh) { | |
| child.castShadow = true; | |
| child.receiveShadow = true; | |
| } | |
| }); | |
| const box = new THREE.Box3().setFromObject(object); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| object.position.sub(center); | |
| object.position.y = 0; | |
| const size = box.getSize(new THREE.Vector3()); | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| if (maxDim > 5) { | |
| const scale = 5 / maxDim; | |
| object.scale.multiplyScalar(scale); | |
| } | |
| const objectId = `imported_${++state.objectCounter}`; | |
| object.userData = { | |
| type: 'imported', | |
| id: objectId, | |
| filename: filename, | |
| importedAt: Date.now() | |
| }; | |
| scene.add(object); | |
| sceneObjects[objectId] = object; | |
| selectableObjects.push(object); | |
| selectObject(object); | |
| addToHistory({ | |
| type: 'importModel', | |
| objectType: 'imported', | |
| filename: filename, | |
| object: object.uuid | |
| }); | |
| } | |
| function exportModel(fileType) { | |
| if (!state.selectedObject) { | |
| return; | |
| } | |
| let result; | |
| switch (fileType) { | |
| case 'obj': | |
| const objExporter = new THREE.OBJExporter(); | |
| result = objExporter.parse(state.selectedObject); | |
| break; | |
| case 'stl': | |
| const stlExporter = new THREE.STLExporter(); | |
| result = stlExporter.parse(state.selectedObject, { binary: true }); | |
| break; | |
| case 'glb': | |
| const gltfExporter = new THREE.GLTFExporter(); | |
| gltfExporter.parse(state.selectedObject, function(gltf) { | |
| }, { binary: true }); | |
| break; | |
| case 'json': | |
| exportToJson(); | |
| break; | |
| default: | |
| return; | |
| } | |
| } | |
| function showTextInputModal() { | |
| document.getElementById('text-input-modal').classList.remove('hidden'); | |
| } | |
| function closeTextInputModal() { | |
| document.getElementById('text-input-modal').classList.add('hidden'); | |
| } | |
| function processTextInput() { | |
| const text = document.getElementById('text-value').value; | |
| const size = parseFloat(document.getElementById('text-size').value); | |
| const depth = parseFloat(document.getElementById('text-depth').value); | |
| if (!text) { | |
| return; | |
| } | |
| createText3D(text, size, depth); | |
| closeTextInputModal(); | |
| } | |
| function showTemplatesModal() { | |
| document.getElementById('templates-modal').classList.remove('hidden'); | |
| } | |
| function closeTemplatesModal() { | |
| document.getElementById('templates-modal').classList.add('hidden'); | |
| } | |
| function handleKeyDown(event) { | |
| if (event.key === 'Delete' && state.selectedObject) { | |
| deleteSelectedObject(); | |
| } | |
| if (event.key === 'z' && (event.ctrlKey || event.metaKey) && !event.shiftKey) { | |
| event.preventDefault(); | |
| undoAction(); | |
| } | |
| if ((event.key === 'y' && (event.ctrlKey || event.metaKey)) || | |
| (event.key === 'z' && (event.ctrlKey || event.metaKey) && event.shiftKey)) { | |
| event.preventDefault(); | |
| redoAction(); | |
| } | |
| } | |
| function loadTemplate(templateName) { | |
| newScene(); | |
| switch (templateName) { | |
| case 'empty': | |
| break; | |
| case 'room': | |
| createRoomTemplate(); | |
| break; | |
| case 'character': | |
| createCharacterTemplate(); | |
| break; | |
| case 'landscape': | |
| createLandscapeTemplate(); | |
| break; | |
| case 'vehicle': | |
| createVehicleTemplate(); | |
| break; | |
| case 'abstract': | |
| createAbstractTemplate(); | |
| break; | |
| default: | |
| return; | |
| } | |
| } | |
| async function createRoomTemplate() { | |
| await createObject({ | |
| shape: 'box', | |
| color: '#8B4513', | |
| texture: 'wood', | |
| position: new THREE.Vector3(0, -0.25, 0), | |
| scale: new THREE.Vector3(10, 0.5, 10), | |
| type: 'floor' | |
| }); | |
| await createObject({ | |
| shape: 'box', | |
| color: '#F5F5DC', | |
| texture: 'concrete', | |
| position: new THREE.Vector3(0, 2.5, -5), | |
| scale: new THREE.Vector3(10, 5, 0.5), | |
| type: 'wall' | |
| }); | |
| await createObject({ | |
| shape: 'box', | |
| color: '#F5F5DC', | |
| texture: 'concrete', | |
| position: new THREE.Vector3(-5, 2.5, 0), | |
| scale: new THREE.Vector3(0.5, 5, 10), | |
| type: 'wall' | |
| }); | |
| await createObject({ | |
| shape: 'box', | |
| color: '#87CEFA', | |
| texture: 'plastic', | |
| position: new THREE.Vector3(0, 3, -4.8), | |
| scale: new THREE.Vector3(3, 2, 0.1), | |
| type: 'window' | |
| }); | |
| await createObject({ | |
| shape: 'box', | |
| color: '#8B4513', | |
| texture: 'wood', | |
| position: new THREE.Vector3(0, 1, 0), | |
| scale: new THREE.Vector3(2, 0.2, 1.5), | |
| type: 'table' | |
| }); | |
| for (let x = -0.8; x <= 0.8; x += 1.6) { | |
| for (let z = -0.6; z <= 0.6; z += 1.2) { | |
| await createObject({ | |
| shape: 'cylinder', | |
| color: '#8B4513', | |
| texture: 'wood', | |
| position: new THREE.Vector3(x, 0.5, z), | |
| scale: new THREE.Vector3(0.1, 1, 0.1), | |
| type: 'leg' | |
| }); | |
| } | |
| } | |
| createLight('point', { | |
| position: new THREE.Vector3(0.5, 2, 0), | |
| intensity: 1.5 | |
| }); | |
| } | |
| async function createCharacterTemplate() { | |
| await createObject({ | |
| shape: 'box', | |
| color: '#4169E1', | |
| texture: 'fabric', | |
| position: new THREE.Vector3(0, 1.5, 0), | |
| scale: new THREE.Vector3(1, 1.5, 0.5), | |
| type: 'body' | |
| }); | |
| await createObject({ | |
| shape: 'sphere', | |
| color: '#FFA07A', | |
| texture: 'none', | |
| position: new THREE.Vector3(0, 3, 0), | |
| scale: new THREE.Vector3(0.5, 0.5, 0.5), | |
| type: 'head' | |
| }); | |
| } | |
| async function createLandscapeTemplate() { | |
| await createObject({ | |
| shape: 'box', | |
| color: '#228B22', | |
| texture: 'grass', | |
| position: new THREE.Vector3(0, -0.5, 0), | |
| scale: new THREE.Vector3(20, 1, 20), | |
| type: 'ground' | |
| }); | |
| for (let i = 0; i < 7; i++) { | |
| const x = Math.random() * 16 - 8; | |
| const z = Math.random() * 16 - 8; | |
| const height = Math.random() * 2 + 1; | |
| const width = Math.random() * 3 + 2; | |
| await createObject({ | |
| shape: 'sphere', | |
| color: '#228B22', | |
| texture: 'grass', | |
| position: new THREE.Vector3(x, height/2 - 0.5, z), | |
| scale: new THREE.Vector3(width, height, width), | |
| type: 'hill' | |
| }); | |
| } | |
| } | |
| async function createVehicleTemplate() { | |
| await createObject({ | |
| shape: 'box', | |
| color: '#FF0000', | |
| texture: 'metal', | |
| position: new THREE.Vector3(0, 0.5, 0), | |
| scale: new THREE.Vector3(4, 0.8, 2), | |
| type: 'car_body' | |
| }); | |
| await createObject({ | |
| shape: 'box', | |
| color: '#FF0000', | |
| texture: 'metal', | |
| position: new THREE.Vector3(-0.2, 1.3, 0), | |
| scale: new THREE.Vector3(2, 0.8, 1.8), | |
| type: 'car_top' | |
| }); | |
| await createObject({ | |
| shape: 'box', | |
| color: '#87CEFA', | |
| texture: 'plastic', | |
| position: new THREE.Vector3(0.7, 1.3, 0), | |
| rotation: new THREE.Euler(-Math.PI/10, 0, 0), | |
| scale: new THREE.Vector3(0.1, 0.7, 1.7), | |
| type: 'windshield' | |
| }); | |
| } | |
| async function createAbstractTemplate() { | |
| const shapes = ['sphere', 'box', 'cone', 'cylinder', 'torus', 'tetrahedron', 'octahedron']; | |
| const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#FF8000']; | |
| const textures = ['none', 'checkerboard', 'grid', 'dots', 'carbon', 'rubber', 'plastic']; | |
| await createObject({ | |
| shape: 'torusKnot', | |
| color: '#FF0000', | |
| texture: 'metal', | |
| position: new THREE.Vector3(0, 3, 0), | |
| scale: new THREE.Vector3(1.5, 1.5, 1.5), | |
| type: 'central' | |
| }); | |
| for (let i = 0; i < 20; i++) { | |
| const angle = (i / 20) * Math.PI * 2; | |
| const radius = 3 + Math.random() * 2; | |
| const height = 1 + Math.sin(i * 0.5) * 3; | |
| const x = Math.cos(angle) * radius; | |
| const z = Math.sin(angle) * radius; | |
| const shapeIndex = Math.floor(Math.random() * shapes.length); | |
| const colorIndex = Math.floor(Math.random() * colors.length); | |
| const textureIndex = Math.floor(Math.random() * textures.length); | |
| const size = 0.2 + Math.random() * 0.6; | |
| await createObject({ | |
| shape: shapes[shapeIndex], | |
| color: colors[colorIndex], | |
| texture: textures[textureIndex], | |
| position: new THREE.Vector3(x, height, z), | |
| rotation: new THREE.Euler( | |
| Math.random() * Math.PI * 2, | |
| Math.random() * Math.PI * 2, | |
| Math.random() * Math.PI * 2 | |
| ), | |
| scale: new THREE.Vector3(size, size, size), | |
| type: 'abstract' | |
| }); | |
| } | |
| createLight('point', { | |
| position: new THREE.Vector3(0, 5, 0), | |
| intensity: 1.5, | |
| color: 0xFF8000 | |
| }); | |
| createLight('point', { | |
| position: new THREE.Vector3(5, 2, 0), | |
| intensity: 0.8, | |
| color: 0x0080FF | |
| }); | |
| createLight('point', { | |
| position: new THREE.Vector3(-5, 2, 0), | |
| intensity: 0.8, | |
| color: 0xFF0080 | |
| }); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| orbitControls.update(); | |
| Object.values(sceneObjects).forEach(obj => { | |
| if (obj.userData && obj.userData.type === 'light' && obj.userData.helper) { | |
| obj.userData.helper.update(); | |
| } | |
| }); | |
| renderer.render(scene, camera); | |
| } | |
| function onWindowResize() { | |
| camera.aspect = canvasContainer.clientWidth / canvasContainer.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight); | |
| } | |
| function onMouseClick(event) { | |
| const rect = renderer.domElement.getBoundingClientRect(); | |
| mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; | |
| mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersects = raycaster.intersectObjects(selectableObjects); | |
| if (intersects.length > 0) { | |
| selectObject(intersects[0].object); | |
| } else { | |
| deselectObject(); | |
| } | |
| } | |
| async function showUpgradeBenefits() { | |
| try { | |
| window.Poe.registerHandler("upgrade-info-handler", (result) => { | |
| if (result.status === "complete" && result.responses.length > 0) { | |
| const response = result.responses[0]; | |
| if (response.status === "complete") { | |
| } else if (response.status === "error") { | |
| } | |
| } | |
| }); | |
| await window.Poe.sendUserMessage( | |
| "@Video-Generator-PRO sub", | |
| { | |
| handler: "upgrade-info-handler", | |
| stream: false, | |
| openChat: false | |
| } | |
| ); | |
| } catch (error) { | |
| } | |
| } | |
| // Function for the modelStudio button | |
| async function startModelStudio() { | |
| try { | |
| await window.Poe.sendUserMessage( | |
| "@3D-Model-Studio start", | |
| { openChat: false } | |
| ); | |
| } catch (error) { | |
| console.error("Error starting model studio:", error); | |
| } | |
| } | |
| // Function for the objectGenerator button | |
| async function startObjectGenerator() { | |
| try { | |
| await window.Poe.sendUserMessage( | |
| "@3D-Object-Generator start", | |
| { openChat: false } | |
| ); | |
| } catch (error) { | |
| console.error("Error starting object generator:", error); | |
| } | |
| } | |
| function initEventListeners() { | |
| window.addEventListener('resize', onWindowResize); | |
| window.addEventListener('keydown', handleKeyDown); | |
| renderer.domElement.addEventListener('click', onMouseClick); | |
| document.getElementById('theme-toggle').addEventListener('click', () => { | |
| state.isDarkMode = !state.isDarkMode; | |
| document.documentElement.classList.toggle('dark', state.isDarkMode); | |
| }); | |
| document.querySelectorAll('.sidebar-tab').forEach(tab => { | |
| tab.addEventListener('click', () => { | |
| const tabId = tab.dataset.tab; | |
| document.querySelectorAll('.sidebar-tab').forEach(t => { | |
| t.classList.toggle('active', t === tab); | |
| }); | |
| document.querySelectorAll('.panel').forEach(panel => { | |
| panel.classList.toggle('active', panel.id === `${tabId}-panel`); | |
| }); | |
| state.activeTab = tabId; | |
| }); | |
| }); | |
| document.querySelectorAll('.category-title').forEach(title => { | |
| title.addEventListener('click', () => { | |
| const content = title.nextElementSibling; | |
| content.classList.toggle('expanded'); | |
| title.querySelector('.category-icon').classList.toggle('expanded'); | |
| }); | |
| }); | |
| document.querySelectorAll('.transform-control-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('.transform-control-btn').forEach(b => { | |
| b.classList.remove('active') | |
| }); | |
| btn.classList.add('active'); | |
| const mode = btn.dataset.mode; | |
| transformControls.setMode(mode); | |
| state.currentMode = mode; | |
| }); | |
| }); | |
| document.querySelector('[data-mode="translate"]').classList.add('active'); | |
| transformControls.setMode('translate'); | |
| document.querySelectorAll('[data-action="addShape"]').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const shape = btn.dataset.shape; | |
| document.getElementById('object-shape').value = shape; | |
| if (shape === 'text') { | |
| showTextInputModal(); | |
| } else { | |
| createObject({ shape: shape }); | |
| } | |
| }); | |
| }); | |
| document.querySelector('[data-action="addLight"]').addEventListener('click', () => { | |
| createLight('point'); | |
| }); | |
| document.getElementById('apply-color').addEventListener('click', () => { | |
| if (state.selectedObject && state.selectedObject.material) { | |
| const color = document.getElementById('object-color').value; | |
| const previousColor = state.selectedObject.material.color.getHex(); | |
| state.selectedObject.material.color.set(color); | |
| addToHistory({ | |
| type: 'changeColor', | |
| object: state.selectedObject.uuid, | |
| color: color, | |
| previousColor: previousColor | |
| }); | |
| } else { | |
| } | |
| }); | |
| document.getElementById('object-shape').addEventListener('change', () => { | |
| const shape = document.getElementById('object-shape').value; | |
| if (state.selectedObject) { | |
| if (shape === 'text') { | |
| showTextInputModal(); | |
| return; | |
| } | |
| const type = state.selectedObject.userData.type; | |
| const position = state.selectedObject.position.clone(); | |
| const rotation = state.selectedObject.rotation.clone(); | |
| const scale = state.selectedObject.scale.clone(); | |
| const color = state.selectedObject.material ? state.selectedObject.material.color.getHex() : 0x00cc00; | |
| const texture = state.selectedObject.material && state.selectedObject.material.map ? | |
| determineObjectTexture(state.selectedObject) : 'none'; | |
| const oldUUID = state.selectedObject.uuid; | |
| scene.remove(state.selectedObject); | |
| disposeObject(state.selectedObject); | |
| const newObject = createObject({ | |
| shape: shape, | |
| color: '#' + color.toString(16).padStart(6, '0'), | |
| texture: texture, | |
| position: position, | |
| rotation: rotation, | |
| scale: scale, | |
| type: type | |
| }); | |
| addToHistory({ | |
| type: 'changeShape', | |
| oldObject: oldUUID, | |
| newObject: newObject.uuid, | |
| shape: shape | |
| }); | |
| } | |
| }); | |
| document.getElementById('object-texture').addEventListener('change', () => { | |
| const textureName = document.getElementById('object-texture').value; | |
| if (textureName === 'custom') { | |
| document.getElementById('texture-modal').classList.remove('hidden'); | |
| return; | |
| } | |
| if (state.selectedObject && state.selectedObject.material) { | |
| const previousTexture = state.selectedObject.material.map ? | |
| determineObjectTexture(state.selectedObject) : 'none'; | |
| applyTexture(state.selectedObject, textureName); | |
| addToHistory({ | |
| type: 'changeTexture', | |
| object: state.selectedObject.uuid, | |
| texture: textureName, | |
| previousTexture: previousTexture | |
| }); | |
| } | |
| }); | |
| document.getElementById('new-scene').addEventListener('click', newScene); | |
| document.getElementById('save-scene').addEventListener('click', saveScene); | |
| document.getElementById('open-scene').addEventListener('click', showOpenFileModal); | |
| document.getElementById('delete-object').addEventListener('click', deleteSelectedObject); | |
| document.getElementById('undo-btn').addEventListener('click', undoAction); | |
| document.getElementById('redo-btn').addEventListener('click', redoAction); | |
| document.getElementById('screenshot-btn').addEventListener('click', takeScreenshot); | |
| document.getElementById('close-modal').addEventListener('click', () => { | |
| document.getElementById('screenshot-modal').classList.add('hidden') | |
| }); | |
| document.getElementById('close-preview').addEventListener('click', () => { | |
| document.getElementById('screenshot-modal').classList.add('hidden') | |
| }); | |
| document.getElementById('close-text-modal').addEventListener('click', closeTextInputModal); | |
| document.getElementById('cancel-text').addEventListener('click', closeTextInputModal); | |
| document.getElementById('add-text').addEventListener('click', processTextInput); | |
| document.getElementById('close-texture-modal').addEventListener('click', () => { | |
| document.getElementById('texture-modal').classList.add('hidden'); | |
| }); | |
| document.getElementById('cancel-texture').addEventListener('click', () => { | |
| document.getElementById('texture-modal').classList.add('hidden'); | |
| }); | |
| document.getElementById('add-texture').addEventListener('click', addCustomTexture); | |
| document.getElementById('open-templates').addEventListener('click', showTemplatesModal); | |
| document.getElementById('close-templates-modal').addEventListener('click', closeTemplatesModal); | |
| document.querySelectorAll('.template-item').forEach(item => { | |
| item.addEventListener('click', () => { | |
| const template = item.dataset.template; | |
| loadTemplate(template); | |
| closeTemplatesModal(); | |
| }); | |
| }); | |
| document.getElementById('import-obj').addEventListener('click', () => importModel('obj')); | |
| document.getElementById('import-stl').addEventListener('click', () => importModel('stl')); | |
| document.getElementById('import-glb').addEventListener('click', () => importModel('glb')); | |
| document.getElementById('import-textures').addEventListener('click', () => importModel('textures')); | |
| document.getElementById('import-file').addEventListener('change', handleFileImport); | |
| document.getElementById('export-obj').addEventListener('click', () => exportModel('obj')); | |
| document.getElementById('export-stl').addEventListener('click', () => exportModel('stl')); | |
| document.getElementById('export-glb').addEventListener('click', () => exportModel('glb')); | |
| document.getElementById('export-json').addEventListener('click', () => exportModel('json')); | |
| document.getElementById('upload-assets-btn').addEventListener('click', () => { | |
| document.getElementById('asset-upload').click(); | |
| }); | |
| document.getElementById('asset-upload').addEventListener('change', (event) => { | |
| const files = event.target.files; | |
| if (!files || files.length === 0) return; | |
| for (let i = 0; i < files.length; i++) { | |
| const file = files[i]; | |
| const extension = file.name.split('.').pop().toLowerCase(); | |
| if (['png', 'jpg', 'jpeg'].includes(extension)) { | |
| handleTextureImport(file); | |
| } else if (extension === 'json') { | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| try { | |
| const data = JSON.parse(e.target.result); | |
| addAssetToPanel(file.name, null, 'json'); | |
| state.assetFiles.push({ | |
| name: file.name, | |
| type: 'json', | |
| data: data | |
| }); | |
| } catch (error) { | |
| } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| } | |
| }); | |
| document.getElementById('show-grid').addEventListener('change', (e) => { | |
| gridHelper.visible = e.target.checked; | |
| state.showGrid = e.target.checked; | |
| }); | |
| document.getElementById('show-axes').addEventListener('change', (e) => { | |
| axesHelper.visible = e.target.checked; | |
| state.showAxes = e.target.checked; | |
| }); | |
| document.getElementById('show-shadows').addEventListener('change', (e) => { | |
| renderer.shadowMap.enabled = e.target.checked; | |
| state.showShadows = e.target.checked; | |
| scene.traverse(obj => { | |
| if (obj.isMesh) { | |
| obj.castShadow = e.target.checked; | |
| obj.receiveShadow = e.target.checked; | |
| } else if (obj.isLight && obj.shadow) { | |
| obj.castShadow = e.target.checked; | |
| } | |
| }); | |
| renderer.shadowMap.needsUpdate = true; | |
| }); | |
| document.getElementById('upgrade-btn').addEventListener('click', showUpgradeBenefits); | |
| document.getElementById('subscribe-btn').addEventListener('click', showUpgradeBenefits); | |
| // Add the correct event listeners for the Model Studio and Object Generator buttons | |
| document.getElementById('modelStudio-btn').addEventListener('click', startModelStudio); | |
| document.getElementById('objectGenerator-btn').addEventListener('click', startObjectGenerator); | |
| document.getElementById('close-open-file-modal').addEventListener('click', closeOpenFileModal); | |
| document.getElementById('cancel-open-file').addEventListener('click', closeOpenFileModal); | |
| document.getElementById('confirm-open-file').addEventListener('click', processOpenFile); | |
| document.getElementById('tab-file').addEventListener('click', () => switchOpenTab('file')); | |
| document.getElementById('tab-text').addEventListener('click', () => switchOpenTab('text')); | |
| } | |
| function init() { | |
| initEventListeners(); | |
| animate(); | |
| createObject(); | |
| updateHistoryButtonStates(); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> | 
