Files
ComfyUI_frontend/src/extensions/core/load3d/MeshModelAdapter.ts
Terry Jia 6bf75b4cf0 refactor(load3d): introduce ModelAdapter abstraction for the loader switch (#11627)
> 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)
2026-04-26 18:32:51 -04:00

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
}
}