From b1785642e2effa83933804c611d4058a224d9f60 Mon Sep 17 00:00:00 2001 From: Grudge Studio <90799999+MolochDaGod@users.noreply.github.com> Date: Sat, 23 May 2026 21:11:43 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20responsive=20viewport=20=E2=80=94=20?= =?UTF-8?q?mobile/tablet=20layout=20with=20panel=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout used a fixed 3-column grid (260px + 1fr + 300px) that required 560px minimum width before the 3D viewport got any space. On mobile, the canvas was crushed to zero and sidebars overflowed off-screen. Changes: - Tablet (โ‰ค900px): 2-column grid, right panel drops below viewport - Mobile (โ‰ค768px): single-column flex layout, panels hidden by default with toggle buttons (โ˜ฐ Chars / ๐Ÿ“Š Stats) in the header - Small phone (โ‰ค480px): further font/grid reductions - 3D viewport gets a 4:3 aspect ratio container on mobile - Bottom panel stacks vertically instead of overlaying - Toggle buttons hidden on desktop via CSS (zero desktop impact) Co-Authored-By: Oz --- index.html | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/index.html b/index.html index 3117dad..96a663c 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,105 @@ transition: all .15s; color: var(--muted); } .wep-type-btn:hover { border-color: var(--accent); color: var(--text); } .wep-type-btn.active { border-color: var(--accent); background: rgba(110,231,183,.1); color: var(--accent); } + + /* โ”€โ”€ Mobile toggle button (hidden on desktop) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + .mobile-toggle { display: none; } + + /* โ”€โ”€ Responsive: tablet (โ‰ค 900px) โ€” drop right panel below โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + @media (max-width: 900px) { + #app { + grid-template-columns: 220px 1fr; + grid-template-rows: 50px 1fr auto; + } + #rightPanel { + grid-column: 1 / -1; + grid-row: 3; + max-height: 40vh; + border-top: 2px solid var(--border); + } + #bottomPanel { grid-column: 2; } + } + + /* โ”€โ”€ Responsive: mobile (โ‰ค 768px) โ€” single column, panels toggle โ”€โ”€ */ + @media (max-width: 768px) { + body { overflow: auto; height: auto; } + #app { + display: flex; + flex-direction: column; + height: auto; + min-height: 100vh; + } + header { + flex-wrap: wrap; + padding: 8px 12px; + gap: 8px; + } + header h1 { font-size: .95rem; } + header .status { width: 100%; margin-left: 0; font-size: .7rem; } + + /* Toggle button visible on mobile */ + .mobile-toggle { + display: inline-flex; + align-items: center; gap: 4px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 4px; + background: rgba(0,0,0,.3); + color: var(--accent); + cursor: pointer; + font-size: .8rem; + font-family: 'Jost', sans-serif; + } + .mobile-toggle:hover { border-color: var(--accent); } + + /* Panels hidden by default on mobile, shown when toggled */ + #leftPanel, #rightPanel { + display: none; + max-height: none; + border: none; + border-bottom: 1px solid var(--border); + } + #leftPanel.mobile-open, #rightPanel.mobile-open { + display: block; + } + + /* Viewport fills available width, fixed aspect ratio */ + #viewport { + width: 100%; + height: 0; + padding-bottom: 75%; /* 4:3 aspect */ + position: relative; + } + #viewport canvas { + position: absolute; + inset: 0; + } + + /* Bottom panel stacks vertically */ + #bottomPanel { + position: static; + flex-direction: column; + max-height: none; + background: var(--card); + pointer-events: auto; + } + #bottomPanel > * { + max-height: none; + border-top: 1px solid var(--border); + } + + /* Stat grid single column on very narrow screens */ + .stat-grid { grid-template-columns: 1fr; } + .wep-type-grid { grid-template-columns: repeat(3, 1fr); } + } + + /* โ”€โ”€ Responsive: small phone (โ‰ค 480px) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + @media (max-width: 480px) { + header h1 { font-size: .8rem; } + .panel h3 { font-size: .8rem; } + .tab-btn { font-size: .6rem; padding: 4px 6px; } + .wep-type-grid { grid-template-columns: repeat(2, 1fr); } + } @@ -186,6 +285,8 @@

GRUDGE CHARACTER CREATOR

v2.0 + + Select a race to begin @@ -303,6 +404,17 @@

Admin Panel

+ From 1abc63716ecae403c473c4fa64fd1a5a43d8c9b2 Mon Sep 17 00:00:00 2001 From: Grudge Studio <90799999+MolochDaGod@users.noreply.github.com> Date: Sun, 24 May 2026 03:05:28 -0500 Subject: [PATCH 2/2] feat: Forge mode + universal loader + auto-texture pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 โ€” Rendering Pipeline Upgrade: - SmartLoader now supports all model formats: FBX, GLTF/GLB (Draco+KTX2), OBJ+MTL, Collada (DAE), STL, USDZ/USDC - GLTFLoader wired with DRACOLoader (Google CDN decoders) + KTX2Loader (Basis Universal transcoder) for compressed meshes and GPU textures - Renderer upgraded: SRGBColorSpace, high-performance power preference, KTX2 auto-init on boot - HDR environment map loader (RGBELoader) ready for IBL Phase 2 โ€” Forge Panel (Model Inspector): - New ForgePanel.js module with: - Drag-and-drop model intake (drop any file onto 3D viewport) - File input button for direct file selection - Recursive hierarchy tree with collapsible nodes - Auto-labels 20 appendage types (head/arm_L/hand_R/leg_L/foot_R/etc.) using bone name heuristics with color-coded badges - Per-node: type icon, vertex/face count, material info, texture channels - Visibility toggle (eye icon) and camera focus (target icon) per node - Export hierarchy as JSON with full stats - Forge panel added to bottom toolbar between Animations and Admin Phase 3 โ€” Texture Auto-Attach / Find / Generate: - New TextureResolver.js with 3-stage pipeline: 1. Discovery: scans sibling files by naming convention ({model}_diffuse, _normal, _roughness, _metallic, _ao, _emissive) 2. Auto-attach: maps discovered textures to correct PBR channels 3. Fallback generation based on poly count: - Low-poly (<5000 verts) โ†’ MeshToonMaterial (cel-shaded, 3-step gradient) - High-poly (โ‰ฅ5000 verts) โ†’ MeshStandardMaterial with name-based roughness/metalness heuristics (10 material categories: metal, wood, cloth, stone, glass, skin, bone, etc.) - Vertex color sampling โ†’ base color; hash-color fallback - Pipeline runs automatically on every model load Co-Authored-By: Oz --- index.html | 21 +++ src/main.js | 33 +++- src/modules/ForgePanel.js | 306 ++++++++++++++++++++++++++++++++ src/modules/SmartLoader.js | 206 ++++++++++++++++++---- src/modules/TextureResolver.js | 307 +++++++++++++++++++++++++++++++++ 5 files changed, 836 insertions(+), 37 deletions(-) create mode 100644 src/modules/ForgePanel.js create mode 100644 src/modules/TextureResolver.js diff --git a/index.html b/index.html index 96a663c..7319e6f 100644 --- a/index.html +++ b/index.html @@ -180,6 +180,20 @@ .wep-type-btn:hover { border-color: var(--accent); color: var(--text); } .wep-type-btn.active { border-color: var(--accent); background: rgba(110,231,183,.1); color: var(--accent); } + /* โ”€โ”€ Forge tree styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + .forge-node { display: flex; align-items: center; gap: 4px; padding: 2px 4px; + border-bottom: 1px solid rgba(42,49,80,.3); cursor: default; font-size: .65rem; } + .forge-node:hover { background: rgba(110,231,183,.05); } + .forge-node.forge-hidden { opacity: .4; } + .forge-toggle { cursor: pointer; font-size: .6rem; min-width: 12px; user-select: none; } + .forge-name { color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; } + .forge-badge { font-size: .5rem; padding: 1px 4px; border-radius: 3px; color: #000; font-weight: 600; } + .forge-geo { font-size: .55rem; color: var(--muted); } + .forge-mat { font-size: .55rem; color: #8b5cf6; } + .forge-vis, .forge-focus { background: none; border: none; cursor: pointer; font-size: .6rem; + padding: 1px 3px; border-radius: 2px; margin-left: auto; } + .forge-vis:hover, .forge-focus:hover { background: rgba(110,231,183,.15); } + /* โ”€โ”€ Mobile toggle button (hidden on desktop) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .mobile-toggle { display: none; } @@ -386,6 +400,13 @@

Animations

+
+

โš’๏ธ Forge

+

Drop any model onto the 3D viewport, or use the button:

+ +
+ +

Admin Panel

diff --git a/src/main.js b/src/main.js index 6939f61..042bc9d 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,7 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { SkeletonHelper } from 'three'; -import { loadModel, loadAnimationClips, prepareModel, fbxLoader } from './modules/SmartLoader.js'; +import { loadModel, loadModelFromFile, loadAnimationClips, prepareModel, fbxLoader, initKTX2, SUPPORTED_EXTENSIONS } from './modules/SmartLoader.js'; import { EquipmentManager } from './modules/EquipmentManager.js'; import { getAllRaces, WEAPON_ANIMATION_PACKS, loadManifest, getAnimationPacks } from './modules/FactionRegistry.js'; @@ -24,6 +24,8 @@ import { PostFX } from './modules/PostFX.js'; import { BoneAttachment } from './modules/BoneAttachment.js'; import { BossFight } from './modules/BossFight.js'; import { VFXManager } from './modules/VFXManager.js'; +import { ForgePanel } from './modules/ForgePanel.js'; +import { resolveTextures, classifyStyle } from './modules/TextureResolver.js'; // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // State @@ -51,6 +53,7 @@ let vfxMgr = null; let postfx = null; let boneAttach = new BoneAttachment(); let bossFight = null; +let forgePanel = null; // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // Scene Setup @@ -67,16 +70,20 @@ function initScene() { camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 200); camera.position.set(0, 2.5, 5); - // Renderer - renderer = new THREE.WebGLRenderer({ antialias: true }); + // Renderer โ€” best-in-class settings + renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' }); renderer.setSize(container.clientWidth, container.clientHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.2; + renderer.outputColorSpace = THREE.SRGBColorSpace; container.appendChild(renderer.domElement); + // Initialize Draco-compressed GLTF + KTX2 GPU texture support + initKTX2(renderer); + // Controls controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 1, 0); @@ -135,6 +142,19 @@ function initScene() { // Init boss fight controller bossFight = new BossFight(scene, camera, { postfx, updateStatus }); + + // Init Forge panel (drag-drop model inspector) + forgePanel = new ForgePanel(scene, camera, controls, updateStatus); + forgePanel.initDropZone(container); + + // Forge file input + export button + document.getElementById('forgeFileInput')?.addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (file && forgePanel) await forgePanel.loadDroppedFile(file); + }); + document.getElementById('forgeExport')?.addEventListener('click', () => { + forgePanel?.exportJSON(); + }); } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -164,6 +184,13 @@ async function loadCharacterModel(raceConfig) { // Scale, center, shadows via SmartLoader helper prepareModel(model); + // Auto-resolve missing textures (toon for low-poly, PBR for high-poly) + const texResult = await resolveTextures(model, raceConfig.model); + if (texResult.discovered || texResult.generated) { + const style = classifyStyle(model); + console.log(`[TextureResolver] ${texResult.discovered} discovered, ${texResult.generated} generated (${style} style)`); + } + scene.add(model); currentModel = model; diff --git a/src/modules/ForgePanel.js b/src/modules/ForgePanel.js new file mode 100644 index 0000000..d639f65 --- /dev/null +++ b/src/modules/ForgePanel.js @@ -0,0 +1,306 @@ +/** + * ForgePanel โ€” Admin model inspector + hierarchy breakdown tool. + * + * Features: + * - Drag-and-drop any model file onto the viewport + * - Recursive hierarchy tree with collapsible nodes + * - Auto-labels appendages (head, arms, legs, hands, feet, torso, hips) + * - Per-node isolation (click to focus), visibility toggle + * - Export hierarchy as JSON + * - Geometry + material stats per node + */ + +import * as THREE from 'three'; +import { loadModelFromFile, prepareModel, SUPPORTED_EXTENSIONS } from './SmartLoader.js'; + +// โ”€โ”€ Appendage classification heuristics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const APPENDAGE_PATTERNS = [ + { label: 'head', re: /head|skull|cranium/i, color: '#ef4444' }, + { label: 'neck', re: /neck/i, color: '#f97316' }, + { label: 'torso', re: /spine|chest|torso|ribcage/i, color: '#3b82f6' }, + { label: 'hips', re: /hip|pelvis|root/i, color: '#8b5cf6' }, + { label: 'arm_R', re: /r.*(arm|upper.?arm|shoulder)/i, color: '#22c55e' }, + { label: 'arm_L', re: /l.*(arm|upper.?arm|shoulder)/i, color: '#22c55e' }, + { label: 'forearm_R', re: /r.*(forearm|fore.?arm)/i, color: '#14b8a6' }, + { label: 'forearm_L', re: /l.*(forearm|fore.?arm)/i, color: '#14b8a6' }, + { label: 'hand_R', re: /r.*(hand|palm|fist)/i, color: '#eab308' }, + { label: 'hand_L', re: /l.*(hand|palm|fist)/i, color: '#eab308' }, + { label: 'finger_R', re: /r.*(finger|thumb|index|middle|ring|pinky)/i, color: '#f59e0b' }, + { label: 'finger_L', re: /l.*(finger|thumb|index|middle|ring|pinky)/i, color: '#f59e0b' }, + { label: 'leg_R', re: /r.*(thigh|upper.?leg|leg)/i, color: '#06b6d4' }, + { label: 'leg_L', re: /l.*(thigh|upper.?leg|leg)/i, color: '#06b6d4' }, + { label: 'shin_R', re: /r.*(shin|calf|lower.?leg)/i, color: '#0ea5e9' }, + { label: 'shin_L', re: /l.*(shin|calf|lower.?leg)/i, color: '#0ea5e9' }, + { label: 'foot_R', re: /r.*(foot|toe|ankle)/i, color: '#a855f7' }, + { label: 'foot_L', re: /l.*(foot|toe|ankle)/i, color: '#a855f7' }, + { label: 'weapon', re: /weapon|sword|axe|shield|bow|staff|container/i, color: '#fbbf24' }, + { label: 'utility', re: /bag|quiver|cape|cloak|belt/i, color: '#94a3b8' }, +]; + +function classifyAppendage(name) { + for (const p of APPENDAGE_PATTERNS) { + if (p.re.test(name)) return p; + } + return null; +} + +// โ”€โ”€ Node stats โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function getNodeStats(obj) { + const stats = { type: 'Object3D', vertices: 0, faces: 0, materials: [], textures: [] }; + + if (obj.isBone) stats.type = 'Bone'; + else if (obj.isSkinnedMesh) stats.type = 'SkinnedMesh'; + else if (obj.isMesh) stats.type = 'Mesh'; + else if (obj.isGroup) stats.type = 'Group'; + else if (obj.isLight) stats.type = 'Light'; + else if (obj.isCamera) stats.type = 'Camera'; + + if (obj.geometry) { + const pos = obj.geometry.getAttribute('position'); + if (pos) stats.vertices = pos.count; + const idx = obj.geometry.index; + stats.faces = idx ? idx.count / 3 : Math.floor(stats.vertices / 3); + } + + const mats = obj.material + ? (Array.isArray(obj.material) ? obj.material : [obj.material]) + : []; + for (const m of mats) { + stats.materials.push(m.type || 'unknown'); + for (const key of ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap']) { + if (m[key]) stats.textures.push(key); + } + } + + return stats; +} + +// โ”€โ”€ Hierarchy tree builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildTreeData(obj, depth = 0) { + const appendage = classifyAppendage(obj.name); + if (appendage) obj.userData.appendage = appendage.label; + + const stats = getNodeStats(obj); + const node = { + name: obj.name || `(unnamed ${stats.type})`, + type: stats.type, + uuid: obj.uuid, + appendage: appendage?.label || null, + appendageColor: appendage?.color || null, + vertices: stats.vertices, + faces: stats.faces, + materials: stats.materials, + textures: stats.textures, + visible: obj.visible, + depth, + children: [], + }; + + for (const child of obj.children) { + node.children.push(buildTreeData(child, depth + 1)); + } + + return node; +} + +function treeToHTML(node, flat = []) { + const indent = node.depth * 16; + const hasChildren = node.children.length > 0; + const toggle = hasChildren ? 'โ–ถ' : ''; + const badge = node.appendage + ? `${node.appendage}` + : ''; + const typeIcon = { + Bone: '๐Ÿฆด', Mesh: '๐Ÿ”ท', SkinnedMesh: '๐Ÿงฌ', Group: '๐Ÿ“ฆ', Light: '๐Ÿ’ก', Camera: '๐Ÿ“ท', + }[node.type] || 'โฌœ'; + const geoInfo = node.vertices > 0 ? `${node.vertices}v / ${node.faces}f` : ''; + const matInfo = node.materials.length > 0 + ? `${node.materials[0]}${node.textures.length ? ' [' + node.textures.join(',') + ']' : ''}` + : ''; + const vis = node.visible ? '' : ' forge-hidden'; + + flat.push(`
+ ${toggle} ${typeIcon} ${node.name} ${badge} ${geoInfo} ${matInfo} + + +
`); + + for (const child of node.children) { + treeToHTML(child, flat); + } + + return flat; +} + +// โ”€โ”€ Total poly count โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function countTotalPolys(obj) { + let verts = 0, faces = 0; + obj.traverse(child => { + if (child.geometry) { + const pos = child.geometry.getAttribute('position'); + if (pos) verts += pos.count; + const idx = child.geometry.index; + faces += idx ? idx.count / 3 : (pos ? Math.floor(pos.count / 3) : 0); + } + }); + return { verts, faces }; +} + +// โ”€โ”€ ForgePanel class โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export class ForgePanel { + /** + * @param {THREE.Scene} scene + * @param {THREE.PerspectiveCamera} camera + * @param {import('three/addons/controls/OrbitControls.js').OrbitControls} controls + * @param {(msg: string) => void} updateStatus + */ + constructor(scene, camera, controls, updateStatus) { + this.scene = scene; + this.camera = camera; + this.controls = controls; + this.updateStatus = updateStatus; + this.forgeModel = null; + this.treeData = null; + this._objectMap = new Map(); + } + + // โ”€โ”€ Drag and drop setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + initDropZone(viewportEl) { + const overlay = document.createElement('div'); + overlay.id = 'forgeDropOverlay'; + overlay.innerHTML = '

Drop model file here
FBX ยท GLB ยท GLTF ยท OBJ ยท DAE ยท STL ยท USDZ

'; + overlay.style.cssText = ` + display:none; position:absolute; inset:0; z-index:100; + background:rgba(110,231,183,.15); border:3px dashed var(--accent); + font-size:1.2rem; color:var(--accent); pointer-events:none; + justify-content:center; align-items:center; flex-direction:column; + `; + viewportEl.style.position = 'relative'; + viewportEl.appendChild(overlay); + + viewportEl.addEventListener('dragover', (e) => { + e.preventDefault(); + overlay.style.display = 'flex'; + }); + viewportEl.addEventListener('dragleave', () => { + overlay.style.display = 'none'; + }); + viewportEl.addEventListener('drop', async (e) => { + e.preventDefault(); + overlay.style.display = 'none'; + const file = e.dataTransfer?.files?.[0]; + if (!file) return; + await this.loadDroppedFile(file); + }); + } + + async loadDroppedFile(file) { + this.updateStatus(`Forge: Loading ${file.name}...`); + try { + const { scene: model, animations, format } = await loadModelFromFile(file); + prepareModel(model); + + // Remove previous forge model + if (this.forgeModel) this.scene.remove(this.forgeModel); + + this.scene.add(model); + this.forgeModel = model; + + // Build hierarchy + this._objectMap.clear(); + model.traverse(obj => this._objectMap.set(obj.uuid, obj)); + + this.treeData = buildTreeData(model); + const { verts, faces } = countTotalPolys(model); + + this.updateStatus(`Forge: ${file.name} (${format}) โ€” ${verts} verts, ${faces} faces, ${animations.length} anims`); + this.renderTree(); + } catch (err) { + this.updateStatus(`Forge error: ${err.message}`); + console.error('[Forge]', err); + } + } + + // โ”€โ”€ Tree rendering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + renderTree() { + const container = document.getElementById('forgeTree'); + if (!container || !this.treeData) return; + + const lines = treeToHTML(this.treeData); + container.innerHTML = lines.join(''); + + // Wire click events + container.addEventListener('click', (e) => { + const togBtn = e.target.closest('.forge-toggle'); + if (togBtn) { + const row = togBtn.closest('.forge-node'); + const depth = parseInt(row.dataset.depth); + let sibling = row.nextElementSibling; + const collapsing = togBtn.textContent === 'โ–ผ'; + togBtn.textContent = collapsing ? 'โ–ถ' : 'โ–ผ'; + while (sibling && parseInt(sibling.dataset.depth) > depth) { + sibling.style.display = collapsing ? 'none' : ''; + sibling = sibling.nextElementSibling; + } + return; + } + + const visBtn = e.target.closest('.forge-vis'); + if (visBtn) { + const obj = this._objectMap.get(visBtn.dataset.uuid); + if (obj) { + obj.visible = !obj.visible; + visBtn.closest('.forge-node').classList.toggle('forge-hidden', !obj.visible); + } + return; + } + + const focusBtn = e.target.closest('.forge-focus'); + if (focusBtn) { + const obj = this._objectMap.get(focusBtn.dataset.uuid); + if (obj) this.focusOn(obj); + return; + } + }); + } + + // โ”€โ”€ Camera focus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + focusOn(obj) { + const box = new THREE.Box3().setFromObject(obj); + if (box.isEmpty()) return; + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z, 0.5); + + this.controls.target.copy(center); + this.camera.position.copy(center).add(new THREE.Vector3(maxDim * 1.5, maxDim, maxDim * 1.5)); + this.controls.update(); + this.updateStatus(`Focused: ${obj.name || obj.uuid}`); + } + + // โ”€โ”€ Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + exportJSON() { + if (!this.treeData) { + this.updateStatus('Forge: Nothing to export'); + return; + } + const json = JSON.stringify(this.treeData, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = (this.forgeModel?.name || 'model') + '_hierarchy.json'; + a.click(); + URL.revokeObjectURL(url); + this.updateStatus('Forge: Hierarchy exported'); + } +} diff --git a/src/modules/SmartLoader.js b/src/modules/SmartLoader.js index cb39da5..a89914c 100644 --- a/src/modules/SmartLoader.js +++ b/src/modules/SmartLoader.js @@ -1,44 +1,97 @@ /** - * SmartLoader โ€” Unified model/animation loader that auto-detects format. + * SmartLoader โ€” Universal model/animation loader with auto-format detection. * - * Supports: .fbx, .gltf, .glb - * Returns a consistent { scene, animations, mixer } shape regardless of format. + * Supports: .fbx, .gltf, .glb (+ Draco + KTX2), .obj (+.mtl), .dae, .stl, .usdz + * Returns a consistent { scene, animations, format } shape regardless of format. */ import * as THREE from 'three'; import { FBXLoader } from 'three/addons/loaders/FBXLoader.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; +import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'; +import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; +import { MTLLoader } from 'three/addons/loaders/MTLLoader.js'; +import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js'; +import { STLLoader } from 'three/addons/loaders/STLLoader.js'; +import { USDZLoader } from 'three/addons/loaders/USDZLoader.js'; +import { RGBELoader } from 'three/addons/loaders/RGBELoader.js'; + +// โ”€โ”€ Singleton loaders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const fbxLoader = new FBXLoader(); +const objLoader = new OBJLoader(); +const mtlLoader = new MTLLoader(); +const colladaLoader = new ColladaLoader(); +const stlLoader = new STLLoader(); +const usdzLoader = new USDZLoader(); +const rgbeLoader = new RGBELoader(); + +// GLTF with Draco decoder +const dracoLoader = new DRACOLoader(); +dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/'); +dracoLoader.setDecoderConfig({ type: 'js' }); + const gltfLoader = new GLTFLoader(); +gltfLoader.setDRACOLoader(dracoLoader); + +let ktx2Ready = false; /** - * Detect format from URL. - * @param {string} url - * @returns {'fbx'|'gltf'|'glb'|'unknown'} + * Initialize KTX2 texture support (call once after renderer is created). + * @param {THREE.WebGLRenderer} renderer */ +export function initKTX2(renderer) { + if (ktx2Ready) return; + try { + const ktx2Loader = new KTX2Loader(); + ktx2Loader.setTranscoderPath('https://www.gstatic.com/basis-universal/versioned/2021-04-15-ba1c3e4/'); + ktx2Loader.detectSupport(renderer); + gltfLoader.setKTX2Loader(ktx2Loader); + ktx2Ready = true; + } catch (e) { + console.warn('[SmartLoader] KTX2 init failed (non-fatal):', e.message); + } +} + +// โ”€โ”€ Format detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const FORMAT_MAP = { + '.fbx': 'fbx', '.gltf': 'gltf', '.glb': 'glb', + '.obj': 'obj', '.dae': 'dae', '.stl': 'stl', + '.usdz': 'usdz', '.usdc': 'usdz', +}; + function detectFormat(url) { - const lower = url.toLowerCase().split('?')[0]; - if (lower.endsWith('.fbx')) return 'fbx'; - if (lower.endsWith('.gltf')) return 'gltf'; - if (lower.endsWith('.glb')) return 'glb'; + const clean = url.toLowerCase().split('?')[0].split('#')[0]; + for (const [ext, fmt] of Object.entries(FORMAT_MAP)) { + if (clean.endsWith(ext)) return fmt; + } return 'unknown'; } +function dirOf(url) { + const i = url.lastIndexOf('/'); + return i >= 0 ? url.slice(0, i + 1) : ''; +} + +// โ”€โ”€ Core load โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + /** - * Load any 3D model (FBX, GLTF, GLB) and return a normalized result. - * + * Load any 3D model and return a normalized result. * @param {string} url * @param {(e: ProgressEvent) => void} [onProgress] - * @returns {Promise<{scene: THREE.Group, animations: THREE.AnimationClip[]}>} + * @param {object} [opts] + * @param {string} [opts.mtlUrl] - explicit .mtl path for OBJ files + * @returns {Promise<{scene: THREE.Group, animations: THREE.AnimationClip[], format: string}>} */ -export async function loadModel(url, onProgress) { +export async function loadModel(url, onProgress, opts = {}) { const fmt = detectFormat(url); if (fmt === 'fbx') { return new Promise((resolve, reject) => { fbxLoader.load(url, (fbx) => { - resolve({ scene: fbx, animations: fbx.animations || [] }); + resolve({ scene: fbx, animations: fbx.animations || [], format: 'fbx' }); }, onProgress, reject); }); } @@ -46,51 +99,134 @@ export async function loadModel(url, onProgress) { if (fmt === 'gltf' || fmt === 'glb') { return new Promise((resolve, reject) => { gltfLoader.load(url, (gltf) => { - resolve({ scene: gltf.scene, animations: gltf.animations || [] }); + resolve({ scene: gltf.scene, animations: gltf.animations || [], format: fmt }); + }, onProgress, reject); + }); + } + + if (fmt === 'obj') { + const mtlUrl = opts.mtlUrl || url.replace(/\.obj$/i, '.mtl'); + let materials = null; + try { + materials = await new Promise((resolve) => { + mtlLoader.setPath(dirOf(mtlUrl)); + mtlLoader.load(mtlUrl.split('/').pop(), (mtl) => { + mtl.preload(); resolve(mtl); + }, undefined, () => resolve(null)); + }); + } catch { /* no MTL */ } + if (materials) objLoader.setMaterials(materials); + return new Promise((resolve, reject) => { + objLoader.load(url, (obj) => { + resolve({ scene: obj, animations: [], format: 'obj' }); }, onProgress, reject); }); } - throw new Error(`[SmartLoader] Unknown format for: ${url}`); + if (fmt === 'dae') { + return new Promise((resolve, reject) => { + colladaLoader.load(url, (collada) => { + resolve({ scene: collada.scene, animations: collada.scene.animations || [], format: 'dae' }); + }, onProgress, reject); + }); + } + + if (fmt === 'stl') { + return new Promise((resolve, reject) => { + stlLoader.load(url, (geometry) => { + const mat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.6, metalness: 0.2, side: THREE.DoubleSide }); + const mesh = new THREE.Mesh(geometry, mat); + mesh.name = url.split('/').pop()?.replace(/\.stl$/i, '') || 'stl_model'; + const group = new THREE.Group(); + group.add(mesh); + resolve({ scene: group, animations: [], format: 'stl' }); + }, onProgress, reject); + }); + } + + if (fmt === 'usdz') { + return new Promise((resolve, reject) => { + usdzLoader.load(url, (usd) => { + resolve({ scene: usd.scene || usd, animations: [], format: 'usdz' }); + }, onProgress, reject); + }); + } + + throw new Error(`[SmartLoader] Unsupported format: ${url}`); } /** - * Load an animation file and return its clips. - * @param {string} url - * @returns {Promise} + * Load a model from a File/Blob (drag-and-drop). + * @param {File} file + * @returns {Promise<{scene: THREE.Group, animations: THREE.AnimationClip[], format: string}>} */ +export async function loadModelFromFile(file) { + const ext = '.' + file.name.split('.').pop().toLowerCase(); + const fmt = FORMAT_MAP[ext]; + if (!fmt) throw new Error(`[SmartLoader] Unsupported file: ${file.name}`); + + const url = URL.createObjectURL(file); + // For FBX, rename blob URL so the loader can detect the format + const fakeUrl = url + '#/' + file.name; + try { + return await loadModel(fakeUrl, undefined, {}); + } finally { + setTimeout(() => URL.revokeObjectURL(url), 5000); + } +} + +// โ”€โ”€ Animation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + export async function loadAnimationClips(url) { const { animations } = await loadModel(url); return animations; } +// โ”€โ”€ HDR Environment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + /** - * Prepare a loaded model for the scene: scale, center, enable shadows. - * + * Load an HDR environment map and apply it to the scene. + * @param {string} url + * @param {THREE.Scene} scene + * @param {THREE.WebGLRenderer} renderer + * @param {object} [opts] + * @returns {Promise} + */ +export async function loadHDREnvironment(url, scene, renderer, opts = {}) { + const { backgroundBlurriness = 0.5, backgroundIntensity = 0.4, envIntensity = 1.0 } = opts; + return new Promise((resolve, reject) => { + rgbeLoader.load(url, (texture) => { + texture.mapping = THREE.EquirectangularReflectionMapping; + scene.environment = texture; + scene.environmentIntensity = envIntensity; + scene.background = texture; + scene.backgroundBlurriness = backgroundBlurriness; + scene.backgroundIntensity = backgroundIntensity; + resolve(texture); + }, undefined, reject); + }); +} + +// โ”€โ”€ Model preparation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Scale, center, and enable shadows on a loaded model. * @param {THREE.Object3D} model * @param {object} [opts] * @param {number} [opts.targetHeight=2.0] * @param {boolean} [opts.shadows=true] - * @returns {THREE.Object3D} */ export function prepareModel(model, opts = {}) { const { targetHeight = 2.0, shadows = true } = opts; - - // Scale to target height const box = new THREE.Box3().setFromObject(model); const height = box.max.y - box.min.y; - if (height > 0) { - const scale = targetHeight / height; - model.scale.setScalar(scale); - } + if (height > 0) model.scale.setScalar(targetHeight / height); - // Center on ground const scaledBox = new THREE.Box3().setFromObject(model); model.position.y = -scaledBox.min.y; model.position.x = -(scaledBox.min.x + scaledBox.max.x) / 2; model.position.z = -(scaledBox.min.z + scaledBox.max.z) / 2; - // Shadows + double-sided materials if (shadows) { model.traverse(child => { if (child.isMesh || child.isSkinnedMesh) { @@ -103,9 +239,11 @@ export function prepareModel(model, opts = {}) { } }); } - return model; } -// Re-export loaders for direct use if needed -export { fbxLoader, gltfLoader }; +// โ”€โ”€ Exports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const SUPPORTED_EXTENSIONS = Object.keys(FORMAT_MAP); +export const ACCEPT_TYPES = SUPPORTED_EXTENSIONS.map(e => e.slice(1)).join(','); +export { fbxLoader, gltfLoader, dracoLoader, rgbeLoader }; diff --git a/src/modules/TextureResolver.js b/src/modules/TextureResolver.js new file mode 100644 index 0000000..c4d0fcd --- /dev/null +++ b/src/modules/TextureResolver.js @@ -0,0 +1,307 @@ +/** + * TextureResolver โ€” Auto-discover, auto-attach, and fallback-generate textures. + * + * Pipeline (runs after loadModel): + * 1. Scan each mesh's material for missing texture channels + * 2. Attempt to discover sibling texture files by naming convention + * 3. Auto-attach discovered textures to the correct PBR channel + * 4. If nothing found, generate procedural fallback materials: + * - Low-poly (< 5000 verts) โ†’ Toon/stylized (flat color, cel outline) + * - High-poly (โ‰ฅ 5000 verts) โ†’ Realistic PBR (roughness/metalness from name heuristics) + */ + +import * as THREE from 'three'; + +const textureLoader = new THREE.TextureLoader(); + +// โ”€โ”€ Poly threshold for toon vs PBR โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const TOON_THRESHOLD = 5000; // Total vertex count below this โ†’ toon style + +// โ”€โ”€ Texture naming conventions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const CHANNEL_PATTERNS = { + map: [ + /_diffuse\./i, /_basecolor\./i, /_albedo\./i, /_color\./i, /_diff\./i, + /_col\./i, /_base\./i, /diffuse\./i, /albedo\./i, /color\./i, + ], + normalMap: [ + /_normal\./i, /_norm\./i, /_nrm\./i, /normal\./i, /_bump\./i, + ], + roughnessMap: [ + /_roughness\./i, /_rough\./i, /_rgh\./i, /roughness\./i, + ], + metalnessMap: [ + /_metallic\./i, /_metalness\./i, /_metal\./i, /_mtl\./i, /metallic\./i, + ], + aoMap: [ + /_ao\./i, /_ambient.?occlusion\./i, /_occlusion\./i, /ao\./i, + ], + emissiveMap: [ + /_emissive\./i, /_emission\./i, /_glow\./i, /emissive\./i, + ], +}; + +const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.tga']; + +// โ”€โ”€ Material name heuristics for procedural fallback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const MATERIAL_HEURISTICS = [ + { re: /metal|iron|steel|chrome|gold|silver|copper|bronze/i, metalness: 0.9, roughness: 0.3 }, + { re: /wood|bark|log|plank|timber/i, metalness: 0.0, roughness: 0.8 }, + { re: /cloth|fabric|linen|silk|cotton|wool|leather/i, metalness: 0.0, roughness: 0.95 }, + { re: /stone|rock|brick|concrete|marble|granite/i, metalness: 0.0, roughness: 0.85 }, + { re: /glass|crystal|gem|diamond|ruby/i, metalness: 0.1, roughness: 0.1 }, + { re: /skin|flesh|face|body/i, metalness: 0.0, roughness: 0.6 }, + { re: /hair|fur/i, metalness: 0.0, roughness: 0.7 }, + { re: /bone|tooth|tusk|ivory/i, metalness: 0.0, roughness: 0.5 }, + { re: /water|liquid/i, metalness: 0.0, roughness: 0.05 }, + { re: /plastic|rubber|polymer/i, metalness: 0.0, roughness: 0.4 }, +]; + +function guessPhysicalProps(materialName) { + for (const h of MATERIAL_HEURISTICS) { + if (h.re.test(materialName)) return { metalness: h.metalness, roughness: h.roughness }; + } + return { metalness: 0.0, roughness: 0.6 }; // generic default +} + +// โ”€โ”€ Deterministic color from string โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function hashColor(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const h = Math.abs(hash) % 360; + return new THREE.Color().setHSL(h / 360, 0.5, 0.5); +} + +// โ”€โ”€ Toon material generator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function createToonMaterial(mesh, existingMat) { + let baseColor = new THREE.Color(0x888888); + + // Try vertex colors first + if (mesh.geometry?.getAttribute('color')) { + baseColor = sampleVertexColor(mesh.geometry); + } else if (existingMat?.color && !existingMat.color.equals(new THREE.Color(1, 1, 1))) { + baseColor = existingMat.color.clone(); + } else { + baseColor = hashColor(mesh.name || mesh.uuid); + } + + // Three.js MeshToonMaterial for cel-shaded look + const gradientTex = createGradientTexture(3); + const mat = new THREE.MeshToonMaterial({ + color: baseColor, + gradientMap: gradientTex, + side: THREE.DoubleSide, + }); + mat.name = `toon_${mesh.name || 'auto'}`; + return mat; +} + +function createGradientTexture(steps) { + const canvas = document.createElement('canvas'); + canvas.width = steps; + canvas.height = 1; + const ctx = canvas.getContext('2d'); + for (let i = 0; i < steps; i++) { + const val = Math.floor((i / (steps - 1)) * 255); + ctx.fillStyle = `rgb(${val},${val},${val})`; + ctx.fillRect(i, 0, 1, 1); + } + const tex = new THREE.CanvasTexture(canvas); + tex.minFilter = THREE.NearestFilter; + tex.magFilter = THREE.NearestFilter; + tex.generateMipmaps = false; + return tex; +} + +// โ”€โ”€ PBR material generator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function createPBRMaterial(mesh, existingMat) { + let baseColor = new THREE.Color(0x888888); + + if (mesh.geometry?.getAttribute('color')) { + baseColor = sampleVertexColor(mesh.geometry); + } else if (existingMat?.color && !existingMat.color.equals(new THREE.Color(1, 1, 1))) { + baseColor = existingMat.color.clone(); + } else { + baseColor = hashColor(mesh.name || mesh.uuid); + } + + const matName = existingMat?.name || mesh.name || ''; + const { metalness, roughness } = guessPhysicalProps(matName); + + const mat = new THREE.MeshStandardMaterial({ + color: baseColor, + roughness, + metalness, + side: THREE.DoubleSide, + }); + mat.name = `pbr_${mesh.name || 'auto'}`; + return mat; +} + +// โ”€โ”€ Vertex color sampling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function sampleVertexColor(geometry) { + const colorAttr = geometry.getAttribute('color'); + if (!colorAttr || colorAttr.count === 0) return new THREE.Color(0x888888); + + // Average the first 10 vertices + let r = 0, g = 0, b = 0; + const n = Math.min(colorAttr.count, 10); + for (let i = 0; i < n; i++) { + r += colorAttr.getX(i); + g += colorAttr.getY(i); + b += colorAttr.getZ(i); + } + return new THREE.Color(r / n, g / n, b / n); +} + +// โ”€โ”€ Main resolve function โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Count total vertices in a model. + * @param {THREE.Object3D} root + * @returns {number} + */ +function countVertices(root) { + let total = 0; + root.traverse(child => { + if (child.geometry) { + const pos = child.geometry.getAttribute('position'); + if (pos) total += pos.count; + } + }); + return total; +} + +/** + * Check if a material has a meaningful texture on the diffuse channel. + * @param {THREE.Material} mat + * @returns {boolean} + */ +function hasTexture(mat) { + if (!mat) return false; + if (mat.map && mat.map.image) return true; + return false; +} + +/** + * Try to load a texture from a URL pattern, returning null on failure. + * @param {string} url + * @returns {Promise} + */ +async function tryLoadTexture(url) { + return new Promise((resolve) => { + textureLoader.load(url, (tex) => { + tex.colorSpace = THREE.SRGBColorSpace; + tex.flipY = false; + resolve(tex); + }, undefined, () => resolve(null)); + }); +} + +/** + * Attempt to discover and attach textures by naming convention. + * @param {THREE.Mesh} mesh + * @param {string} modelUrl - URL the model was loaded from (for sibling lookup) + * @returns {Promise} true if any texture was attached + */ +async function discoverTextures(mesh, modelUrl) { + if (!modelUrl) return false; + + const mat = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; + if (!mat || !(mat instanceof THREE.MeshStandardMaterial || mat instanceof THREE.MeshPhysicalMaterial)) { + return false; + } + + const baseDir = modelUrl.substring(0, modelUrl.lastIndexOf('/') + 1); + const modelBase = modelUrl.split('/').pop()?.replace(/\.[^.]+$/, '') || ''; + let attached = false; + + for (const [channel, patterns] of Object.entries(CHANNEL_PATTERNS)) { + if (mat[channel]) continue; // already has a texture + + for (const ext of TEXTURE_EXTS) { + for (const pattern of patterns) { + // Build candidate: modelName_diffuse.png, modelName_normal.png, etc. + const suffix = pattern.source.replace(/[\\^$.|?*+()[\]{}]/g, '').replace(/^_/, ''); + const candidate = `${baseDir}${modelBase}_${suffix}${ext}`; + const tex = await tryLoadTexture(candidate); + if (tex) { + mat[channel] = tex; + mat.needsUpdate = true; + attached = true; + break; + } + } + if (mat[channel]) break; + } + } + + return attached; +} + +// โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Run the full texture resolution pipeline on a loaded model. + * + * 1. Try to discover sibling textures by naming convention + * 2. For meshes with no textures, generate procedural materials + * (toon for low-poly, PBR for high-poly) + * + * @param {THREE.Object3D} model + * @param {string} [modelUrl] - original URL for sibling texture lookup + * @returns {Promise<{discovered: number, generated: number}>} + */ +export async function resolveTextures(model, modelUrl) { + const totalVerts = countVertices(model); + const useToon = totalVerts < TOON_THRESHOLD; + let discovered = 0; + let generated = 0; + + const meshes = []; + model.traverse(child => { + if (child.isMesh || child.isSkinnedMesh) meshes.push(child); + }); + + for (const mesh of meshes) { + const mat = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; + + // Skip meshes that already have a proper texture + if (hasTexture(mat)) continue; + + // Phase 1: Try to discover textures from sibling files + if (modelUrl) { + const found = await discoverTextures(mesh, modelUrl); + if (found) { discovered++; continue; } + } + + // Phase 2: Generate procedural material + if (useToon) { + mesh.material = createToonMaterial(mesh, mat); + } else { + mesh.material = createPBRMaterial(mesh, mat); + } + generated++; + } + + return { discovered, generated }; +} + +/** + * Classify whether a model is "low-poly" (toon) or "high-poly" (PBR). + * @param {THREE.Object3D} model + * @returns {'toon'|'pbr'} + */ +export function classifyStyle(model) { + return countVertices(model) < TOON_THRESHOLD ? 'toon' : 'pbr'; +} + +export { TOON_THRESHOLD, CHANNEL_PATTERNS, MATERIAL_HEURISTICS };