mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-25 15:15:47 +00:00
> Prerequisite work for improved PLY / 3D Gaussian Splatting support — the per-format loader logic needs to live behind a stable seam before splat-specific fixes (orientation, async-decoder waits, GPU dispose, custom bounds) and capability-driven UX gating can be added without touching `LoaderManager`'s switch every time. ## Summary Pure refactor. Extracts the per-extension switch inside `LoaderManager` into three `ModelAdapter` implementations and wires the manager to dispatch through them. **No behavior change** — same loader code paths, same outputs, same fallbacks. Sixth in the series splitting up the https://github.com/Comfy-Org/ComfyUI_frontend/pull/11495. ## Changes - **What**: - `ModelAdapter.ts` (new): defines the `ModelAdapter` interface (`kind`, `extensions`, `capabilities`, `load`), a `ModelLoadContext` that exposes only the `SceneModelManager` surface adapters need (`setOriginalModel`, `registerOriginalMaterial`, `standardMaterial`, `materialMode`), and a shared `fetchModelData(path, filename)` helper. - `MeshModelAdapter.ts` (new): owns `stl`, `fbx`, `obj`, `gltf`, `glb`. Each branch is a 1:1 lift of the corresponding `case` from `LoaderManager.loadModelInternal` on `main`. - `PointCloudModelAdapter.ts` (new): owns `ply`. Includes the existing `FastPLYLoader` / `PLYLoader` fallback and the `pointCloud` vs mesh branching logic. - `SplatModelAdapter.ts` (new): owns `spz`, `splat`, `ksplat`. Wraps the `SplatMesh` in a `Group` exactly like the previous `loadSplat` did. - `LoaderManager.ts`: now owns just an adapter array (default = the three above) and a small dispatch path. `pickAdapter` matches by extension and routes PLY → splat when the `Comfy.Load3D.PLYEngine` setting is `sparkjs` (preserving the previous routing). `getCurrentAdapter()` is the new public reader used by `Load3d`. - `Load3d.isSplatModel` / `isPlyModel` now query `loaderManager.getCurrentAdapter()?.kind` instead of doing tree-introspection (`containsSplatMesh`) or `instanceof THREE.BufferGeometry` checks. Same return values, decoupled from the model shape. - `LoaderManagerInterface` no longer exposes the per-format loader fields (`gltfLoader`, `objLoader`, etc.); those are now adapter-internal. - `SceneModelManager` is **unchanged** in this PR. Its existing `containsSplatMesh()` traversal and PLY material-mode rebuild stay put; a follow-up PR refactors them once capability gating is in place. ## Review Focus - **Loader equivalence**: the body of every `case` in `main`'s `LoaderManager.loadModelInternal` is now in the corresponding adapter's `load()` method. Easiest way to verify: diff `main`'s `LoaderManager.loadModelInternal` against the four `load()` bodies and confirm each branch's behavior (file fetch + parse + material wiring + group wrapping) is byte-identical. - **Dispatch parity**: `pickAdapter` produces the same routing as `main` — extension match first, then the PLYEngine === 'sparkjs' override hoisted up from inside the old `loadPLY`. - **Capability fields are dormant**: the `ModelAdapterCapabilities` record (`fitToViewer`, `materialModes`, `fitTargetSize`, …) is declared on every adapter but **not consumed anywhere in this PR**. SceneModelManager / Load3d / Load3DControls still read no capability data. The follow-up PR turns these on. - **`setOriginalModel` / `registerOriginalMaterial`**: adapters now go through the `ModelLoadContext` getter rather than reaching into `modelManager` directly. The context's `standardMaterial` and `materialMode` are exposed via getters so a late-bound `materialMode` is read at the actual call site, not snapshotted at context creation. ## Coverage | File | Stmts | Branch | Funcs | Lines | |---|---|---|---|---| | `ModelAdapter.ts` (new) | **100%** | **100%** | **100%** | **100%** | | `MeshModelAdapter.ts` (new) | **100%** | **100%** | **100%** | **100%** | | `PointCloudModelAdapter.ts` (new) | 97.22% | 61.11% | 75% | 97.22% | | `SplatModelAdapter.ts` (new) | **100%** | **100%** | **100%** | **100%** | | `LoaderManager.ts` (modified) | **100%** | 91.17% | 86.66% | **100%** | | `Load3d.ts` (modified) | 6.63% | 0% | 13.68% | 6.7% | All four new files are at or near 100% via dedicated unit tests for each adapter (load happy path, error propagation, extension declarations, capability shape). `LoaderManager.test.ts` exercises the dispatch logic — extension matching, the `ply → splat` sparkjs override, the stale-load discard, the load-context proxying — across 34 cases. The two changed `Load3d.ts` methods (`isSplatModel`, `isPlyModel`) get dedicated tests verifying they read the current adapter's `kind` and fall back to `false` when none is loaded. `Load3d.ts`'s overall 6.7% number is the pre-existing baseline — the existing `Load3d.test.ts` covers façade methods via prototype injection rather than instantiating the class (the constructor needs `THREE.WebGLRenderer`, which happy-dom can't provide). PR-F's surface in `Load3d.ts` is two method bodies, both covered by the new adapter-driven kind queries test. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11627-refactor-load3d-introduce-ModelAdapter-abstraction-for-the-loader-switch-34d6d73d3650811b8a1ccc55b45100f2) by [Unito](https://www.unito.io)
156 lines
4.4 KiB
TypeScript
156 lines
4.4 KiB
TypeScript
import * as THREE from 'three'
|
|
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
|
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
|
|
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
|
|
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
|
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
|
|
// Use pre-bundled worker module (has all dependencies included).
|
|
// The unbundled 'wwobjloader2/worker' has ES imports that fail in production builds.
|
|
import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
|
|
|
import type {
|
|
ModelAdapter,
|
|
ModelAdapterCapabilities,
|
|
ModelLoadContext
|
|
} from './ModelAdapter'
|
|
|
|
export class MeshModelAdapter implements ModelAdapter {
|
|
readonly kind = 'mesh' as const
|
|
readonly extensions = ['stl', 'fbx', 'obj', 'gltf', 'glb'] as const
|
|
readonly capabilities: ModelAdapterCapabilities = {
|
|
fitToViewer: true,
|
|
requiresMaterialRebuild: false,
|
|
gizmoTransform: true,
|
|
lighting: true,
|
|
exportable: true,
|
|
materialModes: ['original', 'normal', 'wireframe'],
|
|
fitTargetSize: 5
|
|
}
|
|
|
|
private readonly gltfLoader = new GLTFLoader()
|
|
private readonly objLoader: OBJLoader2Parallel
|
|
private readonly mtlLoader = new MTLLoader()
|
|
private readonly fbxLoader = new FBXLoader()
|
|
private readonly stlLoader = new STLLoader()
|
|
|
|
constructor() {
|
|
this.objLoader = new OBJLoader2Parallel()
|
|
this.objLoader.setWorkerUrl(
|
|
true,
|
|
new URL(OBJLoader2WorkerUrl, import.meta.url)
|
|
)
|
|
}
|
|
|
|
async load(
|
|
ctx: ModelLoadContext,
|
|
path: string,
|
|
filename: string
|
|
): Promise<THREE.Object3D | null> {
|
|
const extension = filename.split('.').pop()?.toLowerCase()
|
|
switch (extension) {
|
|
case 'stl':
|
|
return this.loadSTL(ctx, path, filename)
|
|
case 'fbx':
|
|
return this.loadFBX(ctx, path, filename)
|
|
case 'obj':
|
|
return this.loadOBJ(ctx, path, filename)
|
|
case 'gltf':
|
|
case 'glb':
|
|
return this.loadGLTF(ctx, path, filename)
|
|
}
|
|
return null
|
|
}
|
|
|
|
private async loadSTL(
|
|
ctx: ModelLoadContext,
|
|
path: string,
|
|
filename: string
|
|
): Promise<THREE.Object3D> {
|
|
this.stlLoader.setPath(path)
|
|
const geometry = await this.stlLoader.loadAsync(filename)
|
|
ctx.setOriginalModel(geometry)
|
|
geometry.computeVertexNormals()
|
|
|
|
const mesh = new THREE.Mesh(geometry, ctx.standardMaterial)
|
|
const group = new THREE.Group()
|
|
group.add(mesh)
|
|
return group
|
|
}
|
|
|
|
private async loadFBX(
|
|
ctx: ModelLoadContext,
|
|
path: string,
|
|
filename: string
|
|
): Promise<THREE.Object3D> {
|
|
this.fbxLoader.setPath(path)
|
|
const fbxModel = await this.fbxLoader.loadAsync(filename)
|
|
ctx.setOriginalModel(fbxModel)
|
|
|
|
fbxModel.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
ctx.registerOriginalMaterial(child, child.material)
|
|
if (child instanceof THREE.SkinnedMesh) {
|
|
child.frustumCulled = false
|
|
}
|
|
}
|
|
})
|
|
|
|
return fbxModel
|
|
}
|
|
|
|
private async loadOBJ(
|
|
ctx: ModelLoadContext,
|
|
path: string,
|
|
filename: string
|
|
): Promise<THREE.Object3D> {
|
|
if (ctx.materialMode === 'original') {
|
|
try {
|
|
this.mtlLoader.setPath(path)
|
|
const mtlFileName = filename.replace(/\.obj$/i, '.mtl')
|
|
const materials = await this.mtlLoader.loadAsync(mtlFileName)
|
|
materials.preload()
|
|
const materialsFromMtl =
|
|
MtlObjBridge.addMaterialsFromMtlLoader(materials)
|
|
this.objLoader.setMaterials(materialsFromMtl)
|
|
} catch {
|
|
console.log(
|
|
'No MTL file found or error loading it, continuing without materials'
|
|
)
|
|
}
|
|
}
|
|
|
|
const objUrl = path + encodeURIComponent(filename)
|
|
const model = await this.objLoader.loadAsync(objUrl)
|
|
|
|
model.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
ctx.registerOriginalMaterial(child, child.material)
|
|
}
|
|
})
|
|
|
|
return model
|
|
}
|
|
|
|
private async loadGLTF(
|
|
ctx: ModelLoadContext,
|
|
path: string,
|
|
filename: string
|
|
): Promise<THREE.Object3D> {
|
|
this.gltfLoader.setPath(path)
|
|
const gltf = await this.gltfLoader.loadAsync(filename)
|
|
ctx.setOriginalModel(gltf)
|
|
|
|
gltf.scene.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
child.geometry.computeVertexNormals()
|
|
ctx.registerOriginalMaterial(child, child.material)
|
|
if (child instanceof THREE.SkinnedMesh) {
|
|
child.frustumCulled = false
|
|
}
|
|
}
|
|
})
|
|
|
|
return gltf.scene
|
|
}
|
|
}
|