Rebuild PBR texture viewer

This commit is contained in:
gokayfem
2026-07-28 18:42:52 +03:00
parent 13381c5c81
commit ed602e1b4a
24 changed files with 15196 additions and 1067 deletions
+30
View File
@@ -0,0 +1,30 @@
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12", "3.14"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: python -m pip install -r requirements.txt pytest
- run: python -m compileall -q .
- run: pytest -q
- run: node --input-type=module --check < web/visualization.js
- run: node --check web/js/threeVisualizer.mjs
- run: node --check web/vendor/GLTFLoader.mjs
+5 -4
View File
@@ -11,11 +11,12 @@ jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
uses: Comfy-Org/publish-node-action@1.0.1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+46 -19
View File
@@ -1,35 +1,62 @@
[DualView](https://dualview.ai)
# ComfyUI Texture Simple
| Tool | Description |
|------|-------------|
| [DualView](https://dualview.ai) | Free side-by-side comparison tool for images, videos, audios and AI prompts |
An interactive PBR material viewer inside ComfyUI. Preview any combination of
color, displacement, normal, ambient-occlusion, metalness, roughness, and alpha
maps on built-in primitives or your own GLB/OBJ mesh.
# ComfyUI-Texture-Simple
Visualize your textures inside ComfyUI
![Texture Viewer](https://github.com/gokayfem/ComfyUI-Texture-Simple/assets/88277926/594f4b2b-12a6-40a9-9ecc-8f56c5c0448f)
## Features
- Modern ComfyUI DOM-widget integration
- Sphere, cube, torus, plane, and multi-object showcase
- Local GLB and OBJ mesh loading in the browser
- Batch-aware texture-map selection with single-map broadcasting
- Live roughness, metalness, displacement, normal, AO, repeat, and background controls
- Optional auto-rotation
- PNG screenshots and GLB, GLTF, or OBJ export
- Pinned local Three.js assets with no CDN dependency
- Correct copy/paste, collapse, resize, removal, and WebGL cleanup
- Stale-load cancellation and visible errors
## Installation
```
cd custom_nodes
Install with ComfyUI Manager, or clone manually:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/gokayfem/ComfyUI-Texture-Simple.git
python -m pip install -r ComfyUI-Texture-Simple/requirements.txt
```
## Upload any texture map and visualize it inside ComfyUI
Restart ComfyUI after installation.
![image](https://github.com/gokayfem/ComfyUI-Texture-Simple/assets/88277926/594f4b2b-12a6-40a9-9ecc-8f56c5c0448f)
## Usage
## Suggestions
- If you want to reset the scene, unconnect a texture then queue prompt and connect it again queue prompt.
1. Add **Texture Viewer** from `visualization/3D`.
2. Connect any texture maps you have and queue the workflow.
3. Choose a built-in mesh, or select **Load GLB/OBJ** for a local model.
4. Open **Material** to tune PBR values and texture tiling.
- If you see a black screen, clear your browser cache.
When one texture input contains a single image and another contains a batch, the
single texture is reused for every frame. Other mismatched batch sizes produce
a clear error.
## Example workflows for creating textures inside ComfyUI
[SKB Workflow](https://openart.ai/workflows/RI9P1EtnxtC6SpKldOeW)
GLB is the recommended export format. OBJ contains geometry only. The glTF
material standard does not support displacement maps, so displacement remains a
live preview control rather than a baked glTF property.
[mtb nodes workflow](https://github.com/melMass/comfy_mtb/blob/main/examples/05-seamless_texture.json)
## Development
[Jags Workflow](https://openart.ai/workflows/koala_jealous_20/3d-material-texture-generator-using-sdxl-and-xy-tiling--seamless/b5B0a8OEAbypScLAC8ch)
```bash
python -m pip install pytest
pytest -q
```
## Acknowledgments
The browser assets are vendored from Three.js 0.185.1. Its MIT license is in
`web/vendor/THREE-LICENSE.txt`.
[MrForExample](https://github.com/MrForExample)
## Acknowledgements
Thanks to [MrForExample](https://github.com/MrForExample) and the ComfyUI
community for the original viewer patterns and feedback.
+115 -105
View File
@@ -1,18 +1,75 @@
import sys
from os import path
"""Interactive material and texture-map preview node for ComfyUI."""
sys.path.insert(0, path.dirname(__file__))
from folder_paths import get_save_image_path, get_output_directory
from PIL import Image
from __future__ import annotations
import os
from typing import Any
import folder_paths
import numpy as np
from PIL import Image
MAP_MODES = {
"color": "RGB",
"displacement": "L",
"normal": "RGB",
"ao": "L",
"metalness": "L",
"roughness": "L",
"alpha": "L",
}
def _as_pil(image: Any, mode: str) -> Image.Image:
array = image.detach().cpu().float().numpy()
array = np.nan_to_num(array, nan=0.0, posinf=1.0, neginf=0.0)
array = np.clip(array, 0.0, 1.0)
if array.ndim == 2:
source_mode = "L"
elif array.ndim == 3 and array.shape[-1] == 1:
array = array[..., 0]
source_mode = "L"
elif array.ndim == 3 and array.shape[-1] >= 3:
array = array[..., :3]
source_mode = "RGB"
else:
raise ValueError(f"Expected an HxW, HxWx1, or HxWx3+ image, got {array.shape}.")
converted = Image.fromarray(
(array * 255.0).round().astype(np.uint8),
mode=source_mode,
)
return converted.convert(mode)
def _save_map(
image: Image.Image,
*,
map_type: str,
batch_number: int,
) -> dict[str, str]:
output_dir = folder_paths.get_temp_directory()
full_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
"texture_viewer",
output_dir,
image.width,
image.height,
)
filename = filename.replace("%batch_num%", str(batch_number))
image_name = f"{filename}_{counter:05}_{map_type}.png"
image.save(os.path.join(full_folder, image_name), compress_level=1)
return {"filename": image_name, "subfolder": subfolder, "type": "temp"}
class TextureViewer:
"""Preview PBR texture maps on built-in or browser-loaded 3D meshes."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
},
"required": {},
"optional": {
"color_map": ("IMAGE",),
"displacement_map": ("IMAGE",),
@@ -24,112 +81,65 @@ class TextureViewer:
},
}
def __init__(self):
self.saved_color = []
self.saved_displacement = []
self.saved_normal = []
self.saved_ao = []
self.saved_metalness = []
self.saved_roughness = []
self.saved_alpha = []
self.full_output_folder, self.filename, self.counter, self.subfolder, self.filename_prefix = get_save_image_path("imagesave", get_output_directory())
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "process_images"
CATEGORY = "TextureViewer"
def process_images(self, color_map=None, displacement_map=None, normal_map=None, ao_map=None, metalness_map=None, roughness_map=None, alpha_map=None):
self.saved_color.clear()
self.saved_displacement.clear()
self.saved_normal.clear()
self.saved_ao.clear()
self.saved_metalness.clear()
self.saved_roughness.clear()
self.saved_alpha.clear()
CATEGORY = "visualization/3D"
DESCRIPTION = (
"Interactively previews PBR texture maps on selectable primitives or a "
"browser-loaded GLB/OBJ mesh. Supports batches, screenshots, and mesh export."
)
if color_map is not None:
color_map = color_map[0].detach().cpu().numpy()
color_map = Image.fromarray(np.clip(255. * color_map, 0, 255).astype(np.uint8)).convert('RGB')
if displacement_map is not None:
displacement_map = displacement_map[0].detach().cpu().numpy()
displacement_map = Image.fromarray(np.clip(255. * displacement_map, 0, 255).astype(np.uint8)).convert('RGB')
if normal_map is not None:
normal_map = normal_map[0].detach().cpu().numpy()
normal_map = Image.fromarray(np.clip(255. * normal_map, 0, 255).astype(np.uint8)).convert('RGB')
if ao_map is not None:
ao_map = ao_map[0].detach().cpu().numpy()
ao_map = Image.fromarray(np.clip(255. * ao_map, 0, 255).astype(np.uint8)).convert('RGB')
if metalness_map is not None:
metalness_map = metalness_map[0].detach().cpu().numpy()
metalness_map = Image.fromarray(np.clip(255. * metalness_map, 0, 255).astype(np.uint8)).convert('RGB')
if roughness_map is not None:
roughness_map = roughness_map[0].detach().cpu().numpy()
roughness_map = Image.fromarray(np.clip(255. * roughness_map, 0, 255).astype(np.uint8)).convert('RGB')
if alpha_map is not None:
alpha_map = alpha_map[0].detach().cpu().numpy()
alpha_map = Image.fromarray(np.clip(255. * alpha_map, 0, 255).astype(np.uint8)).convert('RGB')
return self.display([color_map], [displacement_map], [normal_map], [ao_map], [metalness_map], [roughness_map], [alpha_map])
def save_and_append(self, image_map, image_type):
saved_images = []
if image_map is not None:
for (batch_number, single_image) in enumerate(image_map):
filename_with_batch_num = self.filename.replace("%batch_num%", str(batch_number))
image_file = f"{filename_with_batch_num}_{self.counter:05}_{image_type}.png"
single_image.save(path.join(self.full_output_folder, image_file))
saved_images.append({
"filename": image_file,
"subfolder": self.subfolder,
"type": "output"
})
return saved_images
def display(self, color_map=None, displacement_map=None, normal_map=None, ao_map=None, metalness_map=None, roughness_map=None, alpha_map=None):
map_types = {
'color': color_map,
'displacement': displacement_map,
'normal': normal_map,
'ao': ao_map,
'metalness': metalness_map,
'roughness': roughness_map,
'alpha': alpha_map
def process_images(
self,
color_map=None,
displacement_map=None,
normal_map=None,
ao_map=None,
metalness_map=None,
roughness_map=None,
alpha_map=None,
):
provided = {
"color": color_map,
"displacement": displacement_map,
"normal": normal_map,
"ao": ao_map,
"metalness": metalness_map,
"roughness": roughness_map,
"alpha": alpha_map,
}
counts = [len(batch) for batch in provided.values() if batch is not None]
target_count = max(counts, default=0)
saved_maps = {
'color': self.saved_color,
'displacement': self.saved_displacement,
'normal': self.saved_normal,
'ao': self.saved_ao,
'metalness': self.saved_metalness,
'roughness': self.saved_roughness,
'alpha': self.saved_alpha
for map_type, batch in provided.items():
if batch is not None and len(batch) not in (1, target_count):
raise ValueError(
f"{map_type}_map has {len(batch)} images, but the largest input "
f"batch has {target_count}. Inputs must match or contain one image."
)
saved: dict[str, list[dict[str, str]]] = {
map_type: [] for map_type in provided
}
for map_type, batch in provided.items():
if batch is None:
continue
mode = MAP_MODES[map_type]
saved[map_type] = [
_save_map(
_as_pil(image, mode),
map_type=map_type,
batch_number=index,
)
for index, image in enumerate(batch)
]
for map_type, maps in map_types.items():
if maps and maps[0] is not None:
saved_maps[map_type] = self.save_and_append(maps, map_type)
return {"ui": saved}
self.counter += 1
return {"ui": saved_maps}
NODE_CLASS_MAPPINGS = {
"TextureViewer": TextureViewer,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TextureViewer": "TextureViewer",
}
NODE_CLASS_MAPPINGS = {"TextureViewer": TextureViewer}
NODE_DISPLAY_NAME_MAPPINGS = {"TextureViewer": "Texture Viewer"}
WEB_DIRECTORY = "./web"
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+12
View File
@@ -0,0 +1,12 @@
"""Minimal ComfyUI module shim used while pytest collects this package."""
from __future__ import annotations
import sys
import types
try:
import folder_paths # noqa: F401
except ModuleNotFoundError:
sys.modules["folder_paths"] = types.ModuleType("folder_paths")
+8 -3
View File
@@ -1,12 +1,17 @@
[project]
name = "comfyui-texture-simple"
description = "Visualize your textures inside ComfyUI"
version = "1.0.2"
description = "Interactive, batch-aware PBR texture and custom-mesh viewer for ComfyUI"
version = "2.0.0"
requires-python = ">=3.10"
license = { file = "LICENSE" }
dependencies = ["Pillow>=10.1.0"]
dependencies = [
"numpy>=1.25.0",
"Pillow>=10.1.0",
]
[project.urls]
Repository = "https://github.com/gokayfem/ComfyUI-Texture-Simple"
Issues = "https://github.com/gokayfem/ComfyUI-Texture-Simple/issues"
[tool.comfy]
PublisherId = "gokayfem"
+1
View File
@@ -1 +1,2 @@
numpy>=1.25.0
Pillow>=10.1.0
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
ROOT = Path(__file__).parents[1]
def test_only_extension_entrypoint_uses_js_suffix():
javascript_files = sorted(
path.relative_to(ROOT).as_posix() for path in (ROOT / "web").rglob("*.js")
)
assert javascript_files == ["web/visualization.js"]
def test_frontend_has_no_runtime_cdn_dependency():
frontend_text = "\n".join(
path.read_text(encoding="utf-8")
for path in (ROOT / "web").rglob("*")
if path.suffix in {".html", ".js", ".mjs", ".css"}
and "vendor" not in path.parts
)
assert "https://" not in frontend_text
assert "http://" not in frontend_text
assert "@latest" not in frontend_text
def test_vendored_three_modules_are_present():
vendor = ROOT / "web" / "vendor"
expected = {
"three.module.min.mjs",
"OrbitControls.mjs",
"GLTFLoader.mjs",
"OBJLoader.mjs",
"GLTFExporter.mjs",
"OBJExporter.mjs",
"BufferGeometryUtils.mjs",
"SkeletonUtils.mjs",
"THREE-LICENSE.txt",
}
assert expected.issubset({path.name for path in vendor.iterdir()})
def test_gltf_loader_uses_local_mjs_dependencies():
loader = (ROOT / "web" / "vendor" / "GLTFLoader.mjs").read_text(
encoding="utf-8"
)
assert "./BufferGeometryUtils.mjs" in loader
assert "./SkeletonUtils.mjs" in loader
assert "../utils/" not in loader
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import importlib.util
import sys
import types
from pathlib import Path
import numpy as np
import pytest
class FakeTensor:
def __init__(self, array):
self.array = np.asarray(array, dtype=np.float32)
def detach(self):
return self
def cpu(self):
return self
def float(self):
return self
def numpy(self):
return self.array
class FakeBatch:
def __init__(self, arrays):
self.items = [FakeTensor(array) for array in arrays]
def __len__(self):
return len(self.items)
def __iter__(self):
return iter(self.items)
@pytest.fixture()
def texture_module(tmp_path, monkeypatch):
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_temp_directory = lambda: str(tmp_path)
def get_save_image_path(prefix, output_dir, width, height):
assert width > 0 and height > 0
return output_dir, f"{prefix}_%batch_num%", 1, "", prefix
folder_paths.get_save_image_path = get_save_image_path
monkeypatch.setitem(sys.modules, "folder_paths", folder_paths)
module_path = Path(__file__).parents[1] / "__init__.py"
spec = importlib.util.spec_from_file_location(
"texture_viewer_test_module", module_path
)
module = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(module)
return module, tmp_path
def test_processes_all_maps_and_preserves_batches(texture_module):
module, output_dir = texture_module
color = FakeBatch(
[
np.zeros((8, 8, 3)),
np.ones((8, 8, 3)),
]
)
roughness = FakeBatch([np.full((8, 8, 1), 0.5)])
result = module.TextureViewer().process_images(
color_map=color,
roughness_map=roughness,
)["ui"]
assert len(result["color"]) == 2
assert len(result["roughness"]) == 1
assert result["normal"] == []
for descriptor in result["color"] + result["roughness"]:
assert descriptor["type"] == "temp"
assert (output_dir / descriptor["filename"]).is_file()
def test_rejects_non_broadcastable_batch_size(texture_module):
module, _ = texture_module
color = FakeBatch([np.zeros((4, 4, 3)) for _ in range(2)])
normal = FakeBatch([np.zeros((4, 4, 3)) for _ in range(3)])
with pytest.raises(ValueError, match="Inputs must match"):
module.TextureViewer().process_images(color_map=color, normal_map=normal)
def test_all_inputs_are_optional(texture_module):
module, _ = texture_module
result = module.TextureViewer().process_images()["ui"]
assert set(result) == set(module.MAP_MODES)
assert all(value == [] for value in result.values())
+70 -34
View File
@@ -1,39 +1,75 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link type="text/css" rel="stylesheet" href="/extensions/ComfyUI-Texture-Simple/style/threeStyle.css">
<link type="text/css" rel="stylesheet" href="/extensions/ComfyUI-Texture-Simple/style/progressStyle.css">
</head>
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1"
>
<title>Texture Viewer</title>
<link rel="stylesheet" href="../style/threeStyle.css">
<script type="importmap">
{
"imports": {
"three": "../vendor/three.module.min.mjs"
}
}
</script>
</head>
<body>
<main id="viewer" aria-label="Interactive material preview">
<div id="canvas-container"></div>
<div id="status" role="status" aria-live="polite">Queue texture maps to preview them.</div>
<div id="error" role="alert" hidden></div>
<body>
<div id="progress-container">
<dialog open id="progress-dialog">
<p>
<label for="progress-indicator">Loading scene...</label>
</p>
<progress max="100" id="progress-indicator"></progress>
</dialog>
</div>
<section id="material-panel" hidden aria-label="Material controls">
<div class="panel-heading">
<strong>Material</strong>
<button id="close-material" type="button" aria-label="Hide material controls">×</button>
</div>
<label>Roughness <input id="roughness" type="range" min="0" max="1" step="0.01" value="0.65"></label>
<label>Metalness <input id="metalness" type="range" min="0" max="1" step="0.01" value="0"></label>
<label>Displacement <input id="displacement" type="range" min="-2" max="2" step="0.01" value="0.15"></label>
<label>Normal strength <input id="normal-strength" type="range" min="0" max="3" step="0.05" value="1"></label>
<label>AO strength <input id="ao-strength" type="range" min="0" max="3" step="0.05" value="1"></label>
<label>Repeat X <input id="repeat-x" type="number" min="0.1" max="20" step="0.1" value="1"></label>
<label>Repeat Y <input id="repeat-y" type="number" min="0.1" max="20" step="0.1" value="1"></label>
<label>Background <input id="background" type="color" value="#252a31"></label>
<label class="checkbox"><input id="auto-rotate" type="checkbox"> Auto rotate</label>
</section>
<div id="container"></div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@latest/build/three.module.js",
"three/addons/": "https://unpkg.com/three@latest/examples/jsm/"
}
}
</script>
<script id="visualizer" type="module" filepath="" crossorigin src="/extensions/ComfyUI-Texture-Simple/js/threeVisualizer.js"></script>
<button id="screenshotButton">Take Screenshot</button>
<select id="exportFormat">
<option value="gltf">GLTF</option>
<option value="glb">GLB</option>
<option value="obj">OBJ</option>
</select>
<button id="downloadButton">Download</button>
</body>
<section class="toolbar" aria-label="Texture viewer controls">
<label>
Frame
<select id="batch-select" disabled></select>
</label>
<label>
Mesh
<select id="mesh-select">
<option value="sphere">Sphere</option>
<option value="cube">Cube</option>
<option value="torus">Torus</option>
<option value="plane">Plane</option>
<option value="showcase">Showcase</option>
</select>
</label>
<input id="mesh-file" type="file" accept=".glb,.obj" hidden>
<button id="load-mesh" type="button">Load GLB/OBJ</button>
<button id="toggle-material" type="button">Material</button>
<button id="reset-camera" type="button">Reset</button>
<button id="screenshot" type="button">PNG</button>
<label>
Export
<select id="export-format">
<option value="glb">GLB</option>
<option value="gltf">GLTF</option>
<option value="obj">OBJ</option>
</select>
</label>
<button id="export-mesh" type="button">Save</button>
</section>
</main>
<script type="module" src="../js/threeVisualizer.mjs"></script>
</body>
</html>
-562
View File
@@ -1,562 +0,0 @@
import * as THREE from 'three';
import { api } from '../../../scripts/api.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import * as dat from 'three/addons/libs/lil-gui.module.min'
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';
const visualizer = document.getElementById("visualizer");
const container = document.getElementById("container");
const progressDialog = document.getElementById("progress-dialog");
const progressIndicator = document.getElementById("progress-indicator");
const renderer = new THREE.WebGLRenderer({ antialias: true, extensions: {
derivatives: true
}});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
container.appendChild(renderer.domElement);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
// scene
const scene = new THREE.Scene();
scene.background = new THREE.Color( 0x444444 );
scene.environment = pmremGenerator.fromScene(new RoomEnvironment(renderer), 0.04).texture;
const camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 1, 200 );
camera.position.z = 60;
const ambientLight = new THREE.AmbientLight( 0x000000 );
scene.add( ambientLight );
const light1 = new THREE.DirectionalLight( 0xffffff, 3 );
light1.position.set( 0, 200, 0 );
scene.add( light1 );
const light2 = new THREE.DirectionalLight( 0xffffff, 3 );
light2.position.set( 100, 200, 100 );
scene.add( light2 );
const light3 = new THREE.DirectionalLight( 0xffffff, 3 );
light3.position.set( - 100, - 200, - 100 );
scene.add( light3 );
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.update();
controls.enablePan = true;
controls.enableDamping = true;
// Handle window resize event
window.onresize = function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
};
const clock = new THREE.Clock()
var lastMaps = {
color: "",
displacement: "",
normal: "",
ao: "",
metalness: "",
roughness: "",
alpha: ""
};
var needUpdate = false;
let isMaterialSideAdded = false;
let animationFrameId;
let isAnimationRunning = false;
function frameUpdate(sphere, cube, torus) {
var currentMaps = {
color: visualizer.getAttribute("color_map"),
displacement: visualizer.getAttribute("displacement_map"),
normal: visualizer.getAttribute("normal_map"),
ao: visualizer.getAttribute("ao_map"),
metalness: visualizer.getAttribute("metalness_map"),
roughness: visualizer.getAttribute("roughness_map"),
alpha: visualizer.getAttribute("alpha_map")
};
if (JSON.stringify(currentMaps) === JSON.stringify(lastMaps)) {
if (needUpdate) {
const elapsedTime = clock.getElapsedTime();
[sphere, cube, torus].forEach(shape => {
if (shape && shape.rotation) {
shape.rotation.y = 0.1 * elapsedTime;
shape.rotation.x = -0.15 * elapsedTime;
}
});
controls.update();
renderer.render(scene, camera);
}
} else {
needUpdate = false;
scene.clear();
progressDialog.open = true;
lastMaps = {...currentMaps};
main(...Object.values(currentMaps).map(JSON.parse));
}
animationFrameId = requestAnimationFrame(() => frameUpdate(sphere, cube, torus));
}
const onProgress = function (xhr) {
if (xhr.lengthComputable) {
progressIndicator.value = xhr.loaded / xhr.total * 100;
}
};
const onError = function (e) {
console.error(e);
};
async function loadTexture(params) {
if (!params?.filename) return null;
const url = api.apiURL('/view?' + new URLSearchParams(params)).replace(/extensions.*\//, "");
const ext = params.filename.slice(params.filename.lastIndexOf(".") + 1);
if (ext === "png" || ext === "jpg" || ext === "jpeg") {
const loader = new THREE.TextureLoader();
return await loader.loadAsync(url);
}
return null;
}
function applyTextureSettings(texture, params) {
if (texture) {
texture.wrapS = params.wrapS;
texture.wrapT = params.wrapT;
texture.repeat.set(params.repeatX, params.repeatY);
}
}
let material;
let sphere, cube, torus;
async function main(colorMapParams, displacementMapParams, normalMapParams, aoMapParams, metalnessMapParams, roughnessMapParams, alphaMapParams) {
const colorTexture = colorMapParams ? await loadTexture(colorMapParams) : null;
const displacementTexture = displacementMapParams ? await loadTexture(displacementMapParams) : null;
const normalTexture = normalMapParams ? await loadTexture(normalMapParams) : null;
const aoTexture = aoMapParams ? await loadTexture(aoMapParams) : null;
const metalnessTexture = metalnessMapParams ? await loadTexture(metalnessMapParams) : null;
const roughnessTexture = roughnessMapParams ? await loadTexture(roughnessMapParams) : null;
const alphaTexture = alphaMapParams ? await loadTexture(alphaMapParams) : null;
// MeshStandardMaterial
material = new THREE.MeshPhysicalMaterial()
material.metalness = 1
material.roughness = 1
// Check if color_map is available
if (colorTexture) {
colorTexture.colorSpace = THREE.SRGBColorSpace;
material.map = colorTexture;
applyTextureSettings(colorTexture, params);
}
// Check if displacement_map is available
if (displacementTexture) {
material.displacementMap = displacementTexture;
material.displacementScale = params.displacementScale;
applyTextureSettings(displacementTexture, params);
}
// Check if normal_map is available
if (normalTexture) {
material.normalMap = normalTexture;
material.normalScale.set(params.normalScale, params.normalScale);
applyTextureSettings(normalTexture, params);
}
// Check if ao_map is available
if (aoTexture) {
material.aoMap = aoTexture;
material.aoMapIntensity = params.aoMapIntensity;
applyTextureSettings(aoTexture, params);
}
// Check if metalness_map is available
if (metalnessTexture) {
material.metalnessMap = metalnessTexture;
applyTextureSettings(metalnessTexture, params);
}
// Check if roughness_map is available
if (roughnessTexture) {
material.roughnessMap = roughnessTexture;
applyTextureSettings(roughnessTexture, params);
}
// Check if alpha_map is available
if (alphaTexture) {
material.alphaMap = alphaTexture;
material.transparent = params.transparent
applyTextureSettings(alphaTexture, params);
}
sphere = new THREE.Mesh(
new THREE.SphereGeometry(10, 128, 128),
material
)
sphere.position.x = - 35
cube = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 15, 64, 64, 64),
material
)
torus = new THREE.Mesh(
new THREE.TorusGeometry(10, 3, 128, 64),
material
)
torus.position.x = 35
scene.add(sphere, cube, torus);
needUpdate = true;
scene.add(ambientLight);
scene.add(camera);
progressDialog.close();
frameUpdate(sphere, cube, torus, material);
}
const gui = new dat.GUI({ width: 250 });
const params = {
side: THREE.DoubleSide,
color: '#ffffff', // Default color
emissive: '#000000', // Default emissive color
roughness: 1,
metalness: 0,
ior: 1.5,
reflectivity: 0.5,
iridescence: 0,
iridescenceIOR: 1.5,
sheen: 0,
sheenRoughness: 0.5,
sheenColor: '#ffffff', // Default sheen color
clearcoat: 0,
clearcoatRoughness: 0,
specularIntensity: 0,
specularColor: '#ffffff', // Default specular color
flatShading: false,
wireframe: false,
vertexColors: false,
displacementScale: 0.1,
normalScale: 0.5,
aoMapIntensity: 1,
visibleTorus: true,
visibleSphere: true,
visibleCube: true,
backgroundColor: '#444444',
opacity: 1,
transparent: false,
repeatX: 1,
repeatY: 1,
wrapS: THREE.RepeatWrapping,
wrapT: THREE.RepeatWrapping,
};
const colorFolder = gui.addFolder('Colors');
const visibilityFolder = gui.addFolder('Visibility');
const materialFolder = gui.addFolder('Material');
materialFolder.add(params, 'side', { Front: THREE.FrontSide, Back: THREE.BackSide, Double: THREE.DoubleSide }).name('Material Side').onChange((value) => {
material.side = parseInt(value);
needUpdate = true;
});
colorFolder .addColor(params, 'color').name('Material Color').onChange((value) => {
material.color.set(value);
needUpdate = true;
});
colorFolder.addColor(params, 'emissive').name('Emissive Color').onChange((value) => {
material.emissive.set(value);
needUpdate = true;
});
materialFolder.add(params, 'roughness', 0, 1).name('Roughness').onChange((value) => {
material.roughness = value;
needUpdate = true;
});
materialFolder.add(params, 'metalness', 0, 1).name('Metalness').onChange((value) => {
material.metalness = value;
needUpdate = true;
});
materialFolder.add(params, 'ior', 1, 2.333).name('Index of Refraction').onChange((value) => {
material.ior = value;
needUpdate = true;
});
materialFolder.add(params, 'reflectivity', 0, 1).name('Reflectivity').onChange((value) => {
material.reflectivity = value;
needUpdate = true;
});
materialFolder.add(params, 'iridescence', 0, 1).name('Iridescence').onChange((value) => {
material.iridescence = value;
needUpdate = true;
});
materialFolder.add(params, 'iridescenceIOR', 1, 2.333).name('Iridescence IOR').onChange((value) => {
material.iridescenceIOR = value;
needUpdate = true;
});
materialFolder.add(params, 'sheen', 0, 1).name('Sheen').onChange((value) => {
material.sheen = value;
needUpdate = true;
});
colorFolder.addColor(params, 'sheenColor').name('Sheen Color').onChange((value) => {
material.sheenColor.set(value);
needUpdate = true;
});
materialFolder.add(params, 'sheenRoughness', 0, 1).name('Sheen Roughness').onChange((value) => {
material.sheenRoughness = value;
needUpdate = true;
});
materialFolder.add(params, 'clearcoat', 0, 1).name('Clearcoat').onChange((value) => {
material.clearcoat = value;
needUpdate = true;
});
materialFolder.add(params, 'clearcoatRoughness', 0, 1).name('Clearcoat Roughness').onChange((value) => {
material.clearcoatRoughness = value;
needUpdate = true;
});
materialFolder.add(params, 'specularIntensity', 0, 1).name('Specular Intensity').onChange((value) => {
material.specularIntensity = value;
needUpdate = true;
});
colorFolder.addColor(params, 'specularColor').name('Specular Color').onChange((value) => {
material.specularColor.set(value);
needUpdate = true;
});
materialFolder.add(params, 'flatShading').name('Flat Shading').onChange((value) => {
material.flatShading = value;
needUpdate = true;
});
materialFolder.add(params, 'wireframe').name('Wireframe').onChange((value) => {
material.wireframe = value;
needUpdate = true;
});
materialFolder.add(params, 'vertexColors').name('Vertex Colors').onChange((value) => {
material.vertexColors = value;
needUpdate = true;
});
materialFolder.add(params, 'displacementScale', 0, 1).name('Displacement Scale').onChange((value) => {
material.displacementScale = value;
needUpdate = true;
});
materialFolder.add(params, 'normalScale', 0, 1).name('Normal Scale').onChange((value) => {
material.normalScale = value;
needUpdate = true;
});
materialFolder.add(params, 'aoMapIntensity', 0, 1).name('AO Map Intensity').onChange((value) => {
material.aoMapIntensity = value;
needUpdate = true;
});
visibilityFolder.add(params, 'visibleTorus').name('Torus Visibility').onChange((value) => {
torus.visible = value;
needUpdate = true;
});
visibilityFolder.add(params, 'visibleSphere').name('Sphere Visibility').onChange((value) => {
sphere.visible = value;
needUpdate = true;
});
visibilityFolder.add(params, 'visibleCube').name('Cube Visibility').onChange((value) => {
cube.visible = value;
needUpdate = true;
});
colorFolder.addColor(params, 'backgroundColor').name('Background Color').onChange((value) => {
scene.background.set(value);
needUpdate = true;
});
materialFolder.add(params, 'opacity', 0, 1).name('Opacity').onChange((value) => {
material.opacity = value;
needUpdate = true;
});
materialFolder.add(params, 'transparent').name('Transparent').onChange((value) => {
material.transparent = value;
needUpdate = true;
});
function updateAllTextureWraps(wrapS, wrapT) {
materialFolder.add(params, wrapS, { ClampToEdgeWrapping: THREE.ClampToEdgeWrapping, RepeatWrapping: THREE.RepeatWrapping, MirroredRepeatWrapping: THREE.MirroredRepeatWrapping }).name('Wrap S').onChange((value) => {
const textures = ['map', 'displacementMap', 'normalMap', 'aoMap', 'metalnessMap', 'roughnessMap', 'alphaMap'];
textures.forEach((textureProperty) => {
if (material[textureProperty]) {
material[textureProperty].wrapS = parseInt(value);
material[textureProperty].needsUpdate = true;
}
});
needUpdate = true;
});
materialFolder.add(params, wrapT, { ClampToEdgeWrapping: THREE.ClampToEdgeWrapping, RepeatWrapping: THREE.RepeatWrapping, MirroredRepeatWrapping: THREE.MirroredRepeatWrapping }).name('Wrap T').onChange((value) => {
const textures = ['map', 'displacementMap', 'normalMap', 'aoMap', 'metalnessMap', 'roughnessMap', 'alphaMap'];
textures.forEach((textureProperty) => {
if (material[textureProperty]) {
material[textureProperty].wrapT = parseInt(value);
material[textureProperty].needsUpdate = true;
}
});
needUpdate = true;
});
}
updateAllTextureWraps('wrapS', 'wrapT');
function updateAllTextureRepeats(repeatX, repeatY) {
materialFolder.add(params, repeatX, 1, 10, 1).name('Repeat X').onChange((value) => {
const textures = ['map', 'displacementMap', 'normalMap', 'aoMap', 'metalnessMap', 'roughnessMap', 'alphaMap'];
textures.forEach((textureProperty) => {
if (material[textureProperty]) {
material[textureProperty].repeat.set(value, material[textureProperty].repeat.y);
material[textureProperty].needsUpdate = true;
}
});
needUpdate = true;
});
materialFolder.add(params, repeatY, 1, 10, 1).name('Repeat Y').onChange((value) => {
const textures = ['map', 'displacementMap', 'normalMap', 'aoMap', 'metalnessMap', 'roughnessMap', 'alphaMap'];
textures.forEach((textureProperty) => {
if (material[textureProperty]) {
material[textureProperty].repeat.set(material[textureProperty].repeat.x, value);
material[textureProperty].needsUpdate = true;
}
});
needUpdate = true;
});
}
updateAllTextureRepeats('repeatX', 'repeatY');
// Function to stop the animation
function toggleAnimation() {
if (isAnimationRunning) {
// Stop the animation
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
console.log("Animation stopped");
}
} else {
// Start the animation
if (!animationFrameId) { // Only start if not already running
frameUpdate(sphere, cube, torus); // Ensure these are accessible
console.log("Animation started");
}
}
isAnimationRunning = !isAnimationRunning; // Toggle the state
}
// Add a toggle button to the GUI
gui.add({toggleAnimation}, 'toggleAnimation').name('Toggle Animation');
document.getElementById('downloadButton').addEventListener('click', download);
function download() {
const format = document.getElementById('exportFormat').value;
const exporter = format === 'glb' || format === 'gltf' ? new GLTFExporter() : new OBJExporter(); // Assuming OBJExporter is available
if (format === 'gltf' || format === 'glb') {
exporter.parse(scene, function (gltfJson) {
const blob = new Blob([format === 'glb' ? gltfJson : JSON.stringify(gltfJson)], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `scene.${format}`;
link.click();
URL.revokeObjectURL(url);
console.log("Download requested");
}, {
binary: format === 'glb'
});
} else if (format === 'obj') {
// This is a simplified example. You might need to adjust based on how OBJExporter works in your setup.
const objString = exporter.parse(scene);
const blob = new Blob([objString], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = "scene.obj";
link.click();
URL.revokeObjectURL(url);
console.log("Download requested");
}
}
document.getElementById('screenshotButton').addEventListener('click', takeScreenshot);
function takeScreenshot() {
renderer.render(scene, camera);
const dataURL = renderer.domElement.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataURL;
link.download = "screenshot.png";
link.click();
console.log("Screenshot taken");
}
main();
+515
View File
@@ -0,0 +1,515 @@
import * as THREE from "three";
import { OrbitControls } from "../vendor/OrbitControls.mjs";
import { GLTFLoader } from "../vendor/GLTFLoader.mjs";
import { OBJLoader } from "../vendor/OBJLoader.mjs";
import { GLTFExporter } from "../vendor/GLTFExporter.mjs";
import { OBJExporter } from "../vendor/OBJExporter.mjs";
const SOURCE = "gokayfem.texture-simple";
const MAP_NAMES = [
"color",
"displacement",
"normal",
"ao",
"metalness",
"roughness",
"alpha",
];
const MATERIAL_SLOTS = {
color: "map",
displacement: "displacementMap",
normal: "normalMap",
ao: "aoMap",
metalness: "metalnessMap",
roughness: "roughnessMap",
alpha: "alphaMap",
};
const container = document.querySelector("#canvas-container");
const statusElement = document.querySelector("#status");
const errorElement = document.querySelector("#error");
const batchSelect = document.querySelector("#batch-select");
const meshSelect = document.querySelector("#mesh-select");
const meshFile = document.querySelector("#mesh-file");
const materialPanel = document.querySelector("#material-panel");
const renderer = new THREE.WebGLRenderer({
antialias: true,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
container.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x252a31);
const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 5000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.screenSpacePanning = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x263044, 2.4));
const keyLight = new THREE.DirectionalLight(0xffffff, 3);
keyLight.position.set(4, 7, 6);
keyLight.castShadow = true;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x89a8ff, 1.2);
fillLight.position.set(-5, 2, -4);
scene.add(fillLight);
const previewMaterial = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
roughness: 0.65,
metalness: 0,
side: THREE.DoubleSide,
});
previewMaterial.normalScale.set(1, 1);
let channel = null;
let viewUrl = null;
let output = Object.fromEntries(MAP_NAMES.map((name) => [name, []]));
let previewRoot = null;
let fittedBox = null;
let updateVersion = 0;
let animationFrame = null;
let disposed = false;
let textures = new Map();
const clock = new THREE.Clock();
function setStatus(message) {
statusElement.textContent = message;
statusElement.hidden = false;
errorElement.hidden = true;
}
function setError(error) {
console.error("[Texture Viewer]", error);
errorElement.textContent = error instanceof Error ? error.message : String(error);
errorElement.hidden = false;
statusElement.hidden = true;
}
function resize() {
const width = Math.max(container.clientWidth, 1);
const height = Math.max(container.clientHeight, 1);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(container);
resize();
function prepareGeometry(geometry) {
if (geometry.attributes.uv && !geometry.attributes.uv1) {
geometry.setAttribute("uv1", geometry.attributes.uv.clone());
}
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
return geometry;
}
function disposeOriginalMaterial(material) {
if (!material || material === previewMaterial) {
return;
}
for (const value of Object.values(material)) {
if (value?.isTexture) {
value.dispose();
}
}
material.dispose();
}
function applyPreviewMaterial(root) {
root.traverse((child) => {
if (!child.isMesh) {
return;
}
prepareGeometry(child.geometry);
const originals = Array.isArray(child.material)
? child.material
: [child.material];
originals.forEach(disposeOriginalMaterial);
child.material = previewMaterial;
child.castShadow = true;
child.receiveShadow = true;
});
}
function disposeRoot(root) {
root?.traverse((child) => {
child.geometry?.dispose();
if (Array.isArray(child.material)) {
child.material.forEach(disposeOriginalMaterial);
} else {
disposeOriginalMaterial(child.material);
}
});
}
function fitCamera() {
if (!previewRoot) {
return;
}
fittedBox = new THREE.Box3().setFromObject(previewRoot);
if (fittedBox.isEmpty()) {
return;
}
const sphere = fittedBox.getBoundingSphere(new THREE.Sphere());
const distance = Math.max(
sphere.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)),
0.5,
);
camera.near = Math.max(distance / 1000, 0.001);
camera.far = Math.max(distance * 100, 100);
camera.updateProjectionMatrix();
camera.position.copy(
sphere.center.clone().add(new THREE.Vector3(0.75, 0.45, 1).normalize().multiplyScalar(distance * 1.25)),
);
controls.target.copy(sphere.center);
controls.minDistance = Math.max(sphere.radius * 0.05, 0.01);
controls.maxDistance = Math.max(sphere.radius * 20, 10);
controls.update();
}
function replacePreview(root) {
if (previewRoot) {
scene.remove(previewRoot);
disposeRoot(previewRoot);
}
previewRoot = root;
applyPreviewMaterial(previewRoot);
scene.add(previewRoot);
fitCamera();
}
function primitiveMesh(geometry) {
return new THREE.Mesh(prepareGeometry(geometry), previewMaterial);
}
function buildPrimitive(kind) {
const root = new THREE.Group();
if (kind === "cube") {
root.add(primitiveMesh(new THREE.BoxGeometry(3.5, 3.5, 3.5, 48, 48, 48)));
} else if (kind === "torus") {
root.add(primitiveMesh(new THREE.TorusGeometry(2.2, 0.8, 96, 192)));
} else if (kind === "plane") {
root.add(primitiveMesh(new THREE.PlaneGeometry(5, 5, 192, 192)));
} else if (kind === "showcase") {
const sphere = primitiveMesh(new THREE.SphereGeometry(1.7, 128, 96));
sphere.position.x = -4.2;
const cube = primitiveMesh(new THREE.BoxGeometry(2.8, 2.8, 2.8, 40, 40, 40));
const torus = primitiveMesh(new THREE.TorusGeometry(1.7, 0.65, 80, 160));
torus.position.x = 4.2;
root.add(sphere, cube, torus);
} else {
root.add(primitiveMesh(new THREE.SphereGeometry(2.4, 160, 112)));
}
replacePreview(root);
}
function imageUrl(descriptor) {
if (!viewUrl) {
throw new Error("The ComfyUI API URL has not been initialized.");
}
const url = new URL(viewUrl, window.location.origin);
url.search = new URLSearchParams({
filename: descriptor.filename,
subfolder: descriptor.subfolder ?? "",
type: descriptor.type ?? "temp",
}).toString();
return url.href;
}
function loadTexture(name, descriptor) {
return new Promise((resolve, reject) => {
new THREE.TextureLoader().load(
imageUrl(descriptor),
(texture) => {
texture.colorSpace = name === "color"
? THREE.SRGBColorSpace
: THREE.NoColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.anisotropy = Math.min(
8,
renderer.capabilities.getMaxAnisotropy(),
);
resolve([name, texture]);
},
undefined,
() => reject(new Error(`Unable to load ${descriptor.filename}.`)),
);
});
}
function updateTextureRepeats() {
const repeatX = Number(document.querySelector("#repeat-x").value);
const repeatY = Number(document.querySelector("#repeat-y").value);
for (const texture of textures.values()) {
texture.repeat.set(repeatX, repeatY);
texture.needsUpdate = true;
}
}
function applyTextures(nextTextures) {
for (const texture of textures.values()) {
texture.dispose();
}
textures = nextTextures;
for (const [name, slot] of Object.entries(MATERIAL_SLOTS)) {
previewMaterial[slot] = textures.get(name) ?? null;
}
previewMaterial.transparent = textures.has("alpha");
previewMaterial.alphaTest = textures.has("alpha") ? 0.01 : 0;
previewMaterial.needsUpdate = true;
updateTextureRepeats();
}
async function showFrame(index) {
const requests = [];
for (const name of MAP_NAMES) {
const descriptors = output[name] ?? [];
const descriptor = descriptors[index] ?? descriptors[0];
if (descriptor) {
requests.push(loadTexture(name, descriptor));
}
}
const version = ++updateVersion;
if (!requests.length) {
applyTextures(new Map());
setStatus("No maps connected. Showing the base material.");
return;
}
setStatus(`Loading material frame ${index + 1}…`);
try {
const loaded = await Promise.all(requests);
if (version !== updateVersion || disposed) {
loaded.forEach(([, texture]) => texture.dispose());
return;
}
applyTextures(new Map(loaded));
statusElement.hidden = true;
} catch (error) {
if (version === updateVersion) {
setError(error);
}
}
}
function setOutput(nextOutput) {
output = Object.fromEntries(
MAP_NAMES.map((name) => [name, nextOutput?.[name] ?? []]),
);
const count = Math.max(
1,
...MAP_NAMES.map((name) => output[name].length),
);
batchSelect.replaceChildren();
for (let index = 0; index < count; index += 1) {
const option = document.createElement("option");
option.value = String(index);
option.textContent = `${index + 1} / ${count}`;
batchSelect.append(option);
}
batchSelect.disabled = count === 1;
batchSelect.value = "0";
void showFrame(0);
}
function ensureCustomOption() {
let option = meshSelect.querySelector('option[value="custom"]');
if (!option) {
option = document.createElement("option");
option.value = "custom";
option.textContent = "Custom mesh";
meshSelect.append(option);
}
meshSelect.value = "custom";
}
async function loadCustomMesh(file) {
const extension = file.name.split(".").pop()?.toLowerCase();
setStatus(`Loading ${file.name}…`);
let root;
if (extension === "glb") {
const buffer = await file.arrayBuffer();
const result = await new Promise((resolve, reject) => {
new GLTFLoader().parse(buffer, "", resolve, reject);
});
root = result.scene;
} else if (extension === "obj") {
root = new OBJLoader().parse(await file.text());
} else {
throw new Error("Choose a .glb or .obj mesh.");
}
ensureCustomOption();
replacePreview(root);
statusElement.hidden = true;
}
function updateMaterialSettings() {
previewMaterial.roughness = Number(document.querySelector("#roughness").value);
previewMaterial.metalness = Number(document.querySelector("#metalness").value);
previewMaterial.displacementScale = Number(
document.querySelector("#displacement").value,
);
const normalStrength = Number(
document.querySelector("#normal-strength").value,
);
previewMaterial.normalScale.set(normalStrength, normalStrength);
previewMaterial.aoMapIntensity = Number(
document.querySelector("#ao-strength").value,
);
updateTextureRepeats();
}
function download(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function exportMesh() {
if (!previewRoot) {
throw new Error("There is no mesh to export.");
}
const format = document.querySelector("#export-format").value;
if (format === "obj") {
const data = new OBJExporter().parse(previewRoot);
download(new Blob([data], { type: "text/plain" }), "texture-preview.obj");
return;
}
const binary = format === "glb";
const data = await new GLTFExporter().parseAsync(previewRoot, { binary });
const blob = binary
? new Blob([data], { type: "model/gltf-binary" })
: new Blob([JSON.stringify(data, null, 2)], {
type: "model/gltf+json",
});
download(blob, `texture-preview.${format}`);
}
function takeScreenshot() {
renderer.render(scene, camera);
renderer.domElement.toBlob((blob) => {
if (blob) {
download(blob, "texture-preview.png");
}
}, "image/png");
}
function animate() {
if (disposed) {
return;
}
animationFrame = requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.1);
if (
document.querySelector("#auto-rotate").checked
&& previewRoot
&& document.visibilityState === "visible"
) {
previewRoot.rotation.y += delta * 0.45;
}
if (document.visibilityState === "visible") {
controls.update();
renderer.render(scene, camera);
}
}
buildPrimitive("sphere");
animate();
batchSelect.addEventListener("change", () => {
void showFrame(Number(batchSelect.value));
});
meshSelect.addEventListener("change", () => {
if (meshSelect.value !== "custom") {
buildPrimitive(meshSelect.value);
}
});
document.querySelector("#load-mesh").addEventListener("click", () => {
meshFile.click();
});
meshFile.addEventListener("change", () => {
const [file] = meshFile.files;
if (file) {
void loadCustomMesh(file).catch(setError);
}
meshFile.value = "";
});
document.querySelector("#toggle-material").addEventListener("click", () => {
materialPanel.hidden = !materialPanel.hidden;
});
document.querySelector("#close-material").addEventListener("click", () => {
materialPanel.hidden = true;
});
document.querySelector("#reset-camera").addEventListener("click", fitCamera);
document.querySelector("#screenshot").addEventListener("click", takeScreenshot);
document.querySelector("#export-mesh").addEventListener("click", () => {
void exportMesh().catch(setError);
});
for (const selector of [
"#roughness",
"#metalness",
"#displacement",
"#normal-strength",
"#ao-strength",
"#repeat-x",
"#repeat-y",
]) {
document.querySelector(selector).addEventListener("input", updateMaterialSettings);
}
document.querySelector("#background").addEventListener("input", (event) => {
scene.background.set(event.target.value);
});
window.addEventListener("message", (event) => {
if (
event.origin !== window.location.origin
|| event.source !== window.parent
|| event.data?.source !== SOURCE
) {
return;
}
if (event.data.type === "connect") {
channel = event.data.channel;
window.parent.postMessage(
{ source: SOURCE, channel, type: "ready" },
window.location.origin,
);
return;
}
if (event.data.channel !== channel) {
return;
}
if (event.data.type === "initialize") {
viewUrl = event.data.viewUrl;
} else if (event.data.type === "update") {
setOutput(event.data.output);
} else if (event.data.type === "dispose") {
disposed = true;
updateVersion += 1;
cancelAnimationFrame(animationFrame);
for (const texture of textures.values()) {
texture.dispose();
}
textures.clear();
if (previewRoot) {
scene.remove(previewRoot);
disposeRoot(previewRoot);
}
previewMaterial.dispose();
controls.dispose();
renderer.dispose();
}
});
-36
View File
@@ -1,36 +0,0 @@
dialog {
width: 100%;
text-align: center;
max-width: 20em;
color: white;
background-color: #000;
border: none;
position: relative;
transform: translate(-50%, -50%);
}
#progress-container {
position: absolute;
top: 50%;
left: 50%;
}
progress {
width: 100%;
height: 1em;
border: none;
background-color: #fff;
color: #eee;
}
progress::-webkit-progress-bar {
background-color: #333;
}
progress::-webkit-progress-value {
background-color: #eee;
}
progress::-moz-progress-bar {
background-color: #eee;
}
+151 -139
View File
@@ -1,154 +1,166 @@
body {
margin: 0;
background-color: #000;
color: #fff;
font-family: Monospace;
font-size: 13px;
line-height: 24px;
overscroll-behavior: none;
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #14171c;
color: #f2f5f8;
}
a {
color: #ff0;
text-decoration: none;
* {
box-sizing: border-box;
}
a:hover {
text-decoration: underline;
html,
body,
#viewer,
#canvas-container {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
#viewer {
position: relative;
min-height: 380px;
background:
radial-gradient(circle at 50% 15%, #303640 0, #14171c 60%);
}
#canvas-container canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
#status,
#error {
position: absolute;
inset: 0.75rem auto auto 50%;
max-width: min(30rem, calc(100% - 2rem));
padding: 0.55rem 0.8rem;
border: 1px solid #46505e;
border-radius: 8px;
background: rgb(20 23 28 / 88%);
box-shadow: 0 8px 30px rgb(0 0 0 / 30%);
transform: translateX(-50%);
text-align: center;
pointer-events: none;
}
#error {
border-color: #b64c5c;
color: #ffd7dc;
}
#material-panel {
position: absolute;
inset: 0.75rem 0.75rem auto auto;
display: grid;
width: min(20rem, calc(100% - 1.5rem));
max-height: calc(100% - 6rem);
gap: 0.65rem;
overflow: auto;
padding: 0.8rem 1rem;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 12px;
background: rgb(20 23 28 / 90%);
box-shadow: 0 12px 40px rgb(0 0 0 / 40%);
backdrop-filter: blur(12px);
}
#material-panel[hidden] {
display: none;
}
.panel-heading {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
}
#close-material {
width: 2rem;
padding: 0;
font-size: 1.25rem;
}
#material-panel label:not(.checkbox) {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.55rem;
align-items: center;
color: #d0d6df;
font-size: 0.78rem;
}
#material-panel input[type="range"],
#material-panel input[type="number"] {
width: 100%;
min-width: 0;
}
.checkbox {
display: flex;
gap: 0.45rem;
align-items: center;
font-size: 0.8rem;
}
.toolbar {
position: absolute;
inset: auto 0.65rem 0.65rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
padding: 0.5rem;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 10px;
background: rgb(20 23 28 / 88%);
box-shadow: 0 8px 30px rgb(0 0 0 / 35%);
backdrop-filter: blur(10px);
}
.toolbar label {
display: flex;
gap: 0.35rem;
align-items: center;
color: #ccd3dc;
font-size: 0.72rem;
}
button,
select,
input[type="number"] {
min-height: 2rem;
padding: 0.35rem 0.55rem;
border: 1px solid #46505e;
border-radius: 6px;
background: #252b34;
color: inherit;
font: inherit;
}
button {
cursor: pointer;
text-transform: uppercase;
}
#info {
position: absolute;
top: 0px;
width: 100%;
padding: 10px;
box-sizing: border-box;
text-align: center;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
z-index: 1; /* TODO Solve this in HTML */
}
a, button, input, select {
pointer-events: auto;
}
.lil-gui {
z-index: 2 !important; /* TODO Solve this in HTML */
}
@media all and ( max-width: 640px ) {
.lil-gui.root {
right: auto;
top: auto;
max-height: 50%;
max-width: 80%;
bottom: 0;
left: 0;
}
}
#overlay {
position: absolute;
font-size: 16px;
z-index: 2;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: rgba(0,0,0,0.7);
}
#overlay button {
background: transparent;
border: 0;
border: 1px solid rgb(255, 255, 255);
border-radius: 4px;
color: #ffffff;
padding: 12px 18px;
text-transform: uppercase;
cursor: pointer;
}
#notSupported {
width: 50%;
margin: auto;
background-color: #f00;
margin-top: 20px;
padding: 10px;
}
#downloadButton {
position: absolute;
bottom: 10px; /* Adjust as needed */
right: 10px; /* Adjust as needed */
z-index: 10; /* Ensure this is above the canvas's z-index */
padding: 5px 5px;
border: none;
border-radius: 5px;
background: linear-gradient(145deg, #007bff, #0056b3);
color: #ffffff;
font-size: 10px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background 0.3s ease-in-out, transform 0.2s ease;
}
#downloadButton:hover {
background: linear-gradient(145deg, #0056b3, #007bff);
transform: translateY(-2px);
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
button:hover:not(:disabled) {
border-color: #67a7d8;
background: #303a47;
}
#downloadButton:active {
background: #0056b3;
transform: translateY(1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2);
button:focus-visible,
select:focus-visible,
input:focus-visible {
outline: 2px solid #71b9eb;
outline-offset: 2px;
}
#screenshotButton {
position: absolute;
bottom: 10px; /* Adjust as needed */
left: 10px; /* Position to the left */
z-index: 10; /* Ensure this is above the canvas's z-index */
padding: 5px 5px;
border: none;
border-radius: 5px;
background: linear-gradient(145deg, #007bff, #0056b3);
color: #ffffff;
font-size: 10px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background 0.3s ease-in-out, transform 0.2s ease;
button:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
#screenshotButton:hover {
background: linear-gradient(145deg, #0056b3, #007bff);
transform: translateY(-2px);
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
}
#screenshotButton:active {
background: #0056b3;
transform: translateY(1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2);
}
#exportFormat {
position: absolute;
bottom: 40px; /* Adjust as needed */
right: 10px; /* Adjust as needed */
z-index: 1000; /* Ensure it's high enough to be on top */
}
+1501
View File
File diff suppressed because it is too large Load Diff
+3840
View File
File diff suppressed because it is too large Load Diff
+4860
View File
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
import {
Color,
ColorManagement,
Matrix3,
SRGBColorSpace,
Vector2,
Vector3
} from 'three';
/**
* An exporter for OBJ.
*
* `OBJExporter` is not able to export material data into MTL files so only geometry data are supported.
*
* ```js
* const exporter = new OBJExporter();
* const data = exporter.parse( scene );
* ```
*
* @three_import import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';
*/
class OBJExporter {
/**
* Parses the given 3D object and generates the OBJ output.
*
* If the 3D object is composed of multiple children and geometry, they are merged into a single mesh in the file.
*
* @param {Object3D} object - The 3D object to export.
* @return {string} The exported OBJ.
*/
parse( object ) {
let output = '';
let indexVertex = 0;
let indexVertexUvs = 0;
let indexNormals = 0;
const vertex = new Vector3();
const color = new Color();
const normal = new Vector3();
const uv = new Vector2();
const face = [];
function parseMesh( mesh ) {
let nbVertex = 0;
let nbNormals = 0;
let nbVertexUvs = 0;
const geometry = mesh.geometry;
const normalMatrixWorld = new Matrix3();
// shortcuts
const vertices = geometry.getAttribute( 'position' );
const normals = geometry.getAttribute( 'normal' );
const uvs = geometry.getAttribute( 'uv' );
const indices = geometry.getIndex();
// name of the mesh object
output += 'o ' + mesh.name + '\n';
// name of the mesh material
if ( mesh.material && mesh.material.name ) {
output += 'usemtl ' + mesh.material.name + '\n';
}
// vertices
if ( vertices !== undefined ) {
for ( let i = 0, l = vertices.count; i < l; i ++, nbVertex ++ ) {
vertex.fromBufferAttribute( vertices, i );
// transform the vertex to world space
vertex.applyMatrix4( mesh.matrixWorld );
// transform the vertex to export format
output += 'v ' + vertex.x + ' ' + vertex.y + ' ' + vertex.z + '\n';
}
}
// uvs
if ( uvs !== undefined ) {
for ( let i = 0, l = uvs.count; i < l; i ++, nbVertexUvs ++ ) {
uv.fromBufferAttribute( uvs, i );
// transform the uv to export format
output += 'vt ' + uv.x + ' ' + uv.y + '\n';
}
}
// normals
if ( normals !== undefined ) {
normalMatrixWorld.getNormalMatrix( mesh.matrixWorld );
for ( let i = 0, l = normals.count; i < l; i ++, nbNormals ++ ) {
normal.fromBufferAttribute( normals, i );
// transform the normal to world space
normal.applyMatrix3( normalMatrixWorld ).normalize();
// transform the normal to export format
output += 'vn ' + normal.x + ' ' + normal.y + ' ' + normal.z + '\n';
}
}
// faces
if ( indices !== null ) {
for ( let i = 0, l = indices.count; i < l; i += 3 ) {
for ( let m = 0; m < 3; m ++ ) {
const j = indices.getX( i + m ) + 1;
face[ m ] = ( indexVertex + j ) + ( normals || uvs ? '/' + ( uvs ? ( indexVertexUvs + j ) : '' ) + ( normals ? '/' + ( indexNormals + j ) : '' ) : '' );
}
// transform the face to export format
output += 'f ' + face.join( ' ' ) + '\n';
}
} else {
for ( let i = 0, l = vertices.count; i < l; i += 3 ) {
for ( let m = 0; m < 3; m ++ ) {
const j = i + m + 1;
face[ m ] = ( indexVertex + j ) + ( normals || uvs ? '/' + ( uvs ? ( indexVertexUvs + j ) : '' ) + ( normals ? '/' + ( indexNormals + j ) : '' ) : '' );
}
// transform the face to export format
output += 'f ' + face.join( ' ' ) + '\n';
}
}
// update index
indexVertex += nbVertex;
indexVertexUvs += nbVertexUvs;
indexNormals += nbNormals;
}
function parseLine( line ) {
let nbVertex = 0;
const geometry = line.geometry;
const type = line.type;
// shortcuts
const vertices = geometry.getAttribute( 'position' );
// name of the line object
output += 'o ' + line.name + '\n';
if ( vertices !== undefined ) {
for ( let i = 0, l = vertices.count; i < l; i ++, nbVertex ++ ) {
vertex.fromBufferAttribute( vertices, i );
// transform the vertex to world space
vertex.applyMatrix4( line.matrixWorld );
// transform the vertex to export format
output += 'v ' + vertex.x + ' ' + vertex.y + ' ' + vertex.z + '\n';
}
}
if ( type === 'Line' ) {
output += 'l ';
for ( let j = 1, l = vertices.count; j <= l; j ++ ) {
output += ( indexVertex + j ) + ' ';
}
output += '\n';
}
if ( type === 'LineSegments' ) {
for ( let j = 1, k = j + 1, l = vertices.count; j < l; j += 2, k = j + 1 ) {
output += 'l ' + ( indexVertex + j ) + ' ' + ( indexVertex + k ) + '\n';
}
}
// update index
indexVertex += nbVertex;
}
function parsePoints( points ) {
let nbVertex = 0;
const geometry = points.geometry;
const vertices = geometry.getAttribute( 'position' );
const colors = geometry.getAttribute( 'color' );
output += 'o ' + points.name + '\n';
if ( vertices !== undefined ) {
for ( let i = 0, l = vertices.count; i < l; i ++, nbVertex ++ ) {
vertex.fromBufferAttribute( vertices, i );
vertex.applyMatrix4( points.matrixWorld );
output += 'v ' + vertex.x + ' ' + vertex.y + ' ' + vertex.z;
if ( colors !== undefined ) {
color.fromBufferAttribute( colors, i );
ColorManagement.workingToColorSpace( color, SRGBColorSpace );
output += ' ' + color.r + ' ' + color.g + ' ' + color.b;
}
output += '\n';
}
output += 'p ';
for ( let j = 1, l = vertices.count; j <= l; j ++ ) {
output += ( indexVertex + j ) + ' ';
}
output += '\n';
}
// update index
indexVertex += nbVertex;
}
object.traverse( function ( child ) {
if ( child.isMesh === true ) {
parseMesh( child );
}
if ( child.isLine === true ) {
parseLine( child );
}
if ( child.isPoints === true ) {
parsePoints( child );
}
} );
return output;
}
}
export { OBJExporter };
+955
View File
@@ -0,0 +1,955 @@
import {
BufferGeometry,
FileLoader,
Float32BufferAttribute,
Group,
LineBasicMaterial,
LineSegments,
Loader,
Material,
Mesh,
MeshPhongMaterial,
Points,
PointsMaterial,
Vector3,
Color,
SRGBColorSpace
} from 'three';
// o object_name | g group_name
const _object_pattern = /^[og]\s*(.+)?/;
// mtllib file_reference
const _material_library_pattern = /^mtllib /;
// usemtl material_name
const _material_use_pattern = /^usemtl /;
// usemap map_name
const _map_use_pattern = /^usemap /;
const _face_vertex_data_separator_pattern = /\s+/;
const _vA = new Vector3();
const _vB = new Vector3();
const _vC = new Vector3();
const _ab = new Vector3();
const _cb = new Vector3();
const _color = new Color();
function ParserState() {
const state = {
objects: [],
object: {},
vertices: [],
normals: [],
colors: [],
uvs: [],
materials: {},
materialLibraries: [],
startObject: function ( name, fromDeclaration ) {
// If the current object (initial from reset) is not from a g/o declaration in the parsed
// file. We need to use it for the first parsed g/o to keep things in sync.
if ( this.object && this.object.fromDeclaration === false ) {
this.object.name = name;
this.object.fromDeclaration = ( fromDeclaration !== false );
return;
}
const previousMaterial = ( this.object && typeof this.object.currentMaterial === 'function' ? this.object.currentMaterial() : undefined );
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
this.object = {
name: name || '',
fromDeclaration: ( fromDeclaration !== false ),
geometry: {
vertices: [],
normals: [],
colors: [],
uvs: [],
hasUVIndices: false
},
materials: [],
smooth: true,
startMaterial: function ( name, libraries ) {
const previous = this._finalize( false );
// New usemtl declaration overwrites an inherited material, except if faces were declared
// after the material, then it must be preserved for proper MultiMaterial continuation.
if ( previous && ( previous.inherited || previous.groupCount <= 0 ) ) {
this.materials.splice( previous.index, 1 );
}
const material = {
index: this.materials.length,
name: name || '',
mtllib: ( Array.isArray( libraries ) && libraries.length > 0 ? libraries[ libraries.length - 1 ] : '' ),
smooth: ( previous !== undefined ? previous.smooth : this.smooth ),
groupStart: ( previous !== undefined ? previous.groupEnd : 0 ),
groupEnd: - 1,
groupCount: - 1,
inherited: false,
clone: function ( index ) {
const cloned = {
index: ( typeof index === 'number' ? index : this.index ),
name: this.name,
mtllib: this.mtllib,
smooth: this.smooth,
groupStart: 0,
groupEnd: - 1,
groupCount: - 1,
inherited: false
};
cloned.clone = this.clone.bind( cloned );
return cloned;
}
};
this.materials.push( material );
return material;
},
currentMaterial: function () {
if ( this.materials.length > 0 ) {
return this.materials[ this.materials.length - 1 ];
}
return undefined;
},
_finalize: function ( end ) {
const lastMultiMaterial = this.currentMaterial();
if ( lastMultiMaterial && lastMultiMaterial.groupEnd === - 1 ) {
lastMultiMaterial.groupEnd = this.geometry.vertices.length / 3;
lastMultiMaterial.groupCount = lastMultiMaterial.groupEnd - lastMultiMaterial.groupStart;
lastMultiMaterial.inherited = false;
}
// Ignore objects tail materials if no face declarations followed them before a new o/g started.
if ( end && this.materials.length > 1 ) {
for ( let mi = this.materials.length - 1; mi >= 0; mi -- ) {
if ( this.materials[ mi ].groupCount <= 0 ) {
this.materials.splice( mi, 1 );
}
}
}
// Guarantee at least one empty material, this makes the creation later more straight forward.
if ( end && this.materials.length === 0 ) {
this.materials.push( {
name: '',
smooth: this.smooth
} );
}
return lastMultiMaterial;
}
};
// Inherit previous objects material.
// Spec tells us that a declared material must be set to all objects until a new material is declared.
// If a usemtl declaration is encountered while this new object is being parsed, it will
// overwrite the inherited material. Exception being that there was already face declarations
// to the inherited material, then it will be preserved for proper MultiMaterial continuation.
if ( previousMaterial && previousMaterial.name && typeof previousMaterial.clone === 'function' ) {
const declared = previousMaterial.clone( 0 );
declared.inherited = true;
this.object.materials.push( declared );
}
this.objects.push( this.object );
},
finalize: function () {
if ( this.object && typeof this.object._finalize === 'function' ) {
this.object._finalize( true );
}
},
parseVertexIndex: function ( value, len ) {
const index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseNormalIndex: function ( value, len ) {
const index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
},
parseUVIndex: function ( value, len ) {
const index = parseInt( value, 10 );
return ( index >= 0 ? index - 1 : index + len / 2 ) * 2;
},
addVertex: function ( a, b, c ) {
const src = this.vertices;
const dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addVertexPoint: function ( a ) {
const src = this.vertices;
const dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addVertexLine: function ( a ) {
const src = this.vertices;
const dst = this.object.geometry.vertices;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
},
addNormal: function ( a, b, c ) {
const src = this.normals;
const dst = this.object.geometry.normals;
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addFaceNormal: function ( a, b, c ) {
const src = this.vertices;
const dst = this.object.geometry.normals;
_vA.fromArray( src, a );
_vB.fromArray( src, b );
_vC.fromArray( src, c );
_cb.subVectors( _vC, _vB );
_ab.subVectors( _vA, _vB );
_cb.cross( _ab );
_cb.normalize();
dst.push( _cb.x, _cb.y, _cb.z );
dst.push( _cb.x, _cb.y, _cb.z );
dst.push( _cb.x, _cb.y, _cb.z );
},
addColor: function ( a, b, c ) {
const src = this.colors;
const dst = this.object.geometry.colors;
if ( src[ a ] !== undefined ) dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
if ( src[ b ] !== undefined ) dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
if ( src[ c ] !== undefined ) dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
},
addUV: function ( a, b, c ) {
const src = this.uvs;
const dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
dst.push( src[ b + 0 ], src[ b + 1 ] );
dst.push( src[ c + 0 ], src[ c + 1 ] );
},
addDefaultUV: function () {
const dst = this.object.geometry.uvs;
dst.push( 0, 0 );
dst.push( 0, 0 );
dst.push( 0, 0 );
},
addUVLine: function ( a ) {
const src = this.uvs;
const dst = this.object.geometry.uvs;
dst.push( src[ a + 0 ], src[ a + 1 ] );
},
addFace: function ( a, b, c, ua, ub, uc, na, nb, nc ) {
const vLen = this.vertices.length;
let ia = this.parseVertexIndex( a, vLen );
let ib = this.parseVertexIndex( b, vLen );
let ic = this.parseVertexIndex( c, vLen );
this.addVertex( ia, ib, ic );
this.addColor( ia, ib, ic );
// normals
if ( na !== undefined && na !== '' ) {
const nLen = this.normals.length;
ia = this.parseNormalIndex( na, nLen );
ib = this.parseNormalIndex( nb, nLen );
ic = this.parseNormalIndex( nc, nLen );
this.addNormal( ia, ib, ic );
} else {
this.addFaceNormal( ia, ib, ic );
}
// uvs
if ( ua !== undefined && ua !== '' ) {
const uvLen = this.uvs.length;
ia = this.parseUVIndex( ua, uvLen );
ib = this.parseUVIndex( ub, uvLen );
ic = this.parseUVIndex( uc, uvLen );
this.addUV( ia, ib, ic );
this.object.geometry.hasUVIndices = true;
} else {
// add placeholder values (for inconsistent face definitions)
this.addDefaultUV();
}
},
addPointGeometry: function ( vertices ) {
this.object.geometry.type = 'Points';
const vLen = this.vertices.length;
for ( let vi = 0, l = vertices.length; vi < l; vi ++ ) {
const index = this.parseVertexIndex( vertices[ vi ], vLen );
this.addVertexPoint( index );
this.addColor( index );
}
},
addLineGeometry: function ( vertices, uvs ) {
this.object.geometry.type = 'Line';
const vLen = this.vertices.length;
const uvLen = this.uvs.length;
for ( let vi = 0, l = vertices.length; vi < l; vi ++ ) {
this.addVertexLine( this.parseVertexIndex( vertices[ vi ], vLen ) );
}
for ( let uvi = 0, l = uvs.length; uvi < l; uvi ++ ) {
this.addUVLine( this.parseUVIndex( uvs[ uvi ], uvLen ) );
}
}
};
state.startObject( '', false );
return state;
}
/**
* A loader for the OBJ format.
*
* The [OBJ format](https://en.wikipedia.org/wiki/Wavefront_.obj_file) is a simple data-format that
* represents 3D geometry in a human readable format as the position of each vertex, the UV position of
* each texture coordinate vertex, vertex normals, and the faces that make each polygon defined as a list
* of vertices, and texture vertices.
*
* ```js
* const loader = new OBJLoader();
* const object = await loader.loadAsync( 'models/monster.obj' );
* scene.add( object );
* ```
*
* @augments Loader
* @three_import import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
*/
class OBJLoader extends Loader {
/**
* Constructs a new OBJ loader.
*
* @param {LoadingManager} [manager] - The loading manager.
*/
constructor( manager ) {
super( manager );
/**
* A reference to a material creator.
*
* @type {?MaterialCreator}
* @default null
*/
this.materials = null;
}
/**
* Starts loading from the given URL and passes the loaded OBJ asset
* to the `onLoad()` callback.
*
* @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
* @param {function(Group)} onLoad - Executed when the loading process has been finished.
* @param {onProgressCallback} onProgress - Executed while the loading is in progress.
* @param {onErrorCallback} onError - Executed when errors occur.
*/
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new FileLoader( this.manager );
loader.setPath( this.path );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
/**
* Sets the material creator for this OBJ. This object is loaded via {@link MTLLoader}.
*
* @param {MaterialCreator} materials - An object that creates the materials for this OBJ.
* @return {OBJLoader} A reference to this loader.
*/
setMaterials( materials ) {
this.materials = materials;
return this;
}
/**
* Parses the given OBJ data and returns the resulting group.
*
* @param {string} text - The raw OBJ data as a string.
* @return {Group} The parsed OBJ.
*/
parse( text ) {
const state = new ParserState();
if ( text.indexOf( '\r\n' ) !== - 1 ) {
// This is faster than String.split with regex that splits on both
text = text.replace( /\r\n/g, '\n' );
}
if ( text.indexOf( '\\\n' ) !== - 1 ) {
// join lines separated by a line continuation character (\)
text = text.replace( /\\\n/g, '' );
}
const lines = text.split( '\n' );
let result = [];
for ( let i = 0, l = lines.length; i < l; i ++ ) {
const line = lines[ i ].trimStart();
if ( line.length === 0 ) continue;
const lineFirstChar = line.charAt( 0 );
// @todo invoke passed in handler if any
if ( lineFirstChar === '#' ) continue; // skip comments
if ( lineFirstChar === 'v' ) {
const data = line.split( _face_vertex_data_separator_pattern );
switch ( data[ 0 ] ) {
case 'v':
state.vertices.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] ),
parseFloat( data[ 3 ] )
);
if ( data.length >= 7 ) {
_color.setRGB(
parseFloat( data[ 4 ] ),
parseFloat( data[ 5 ] ),
parseFloat( data[ 6 ] ),
SRGBColorSpace
);
state.colors.push( _color.r, _color.g, _color.b );
} else {
// if no colors are defined, add placeholders so color and vertex indices match
state.colors.push( undefined, undefined, undefined );
}
break;
case 'vn':
state.normals.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] ),
parseFloat( data[ 3 ] )
);
break;
case 'vt':
state.uvs.push(
parseFloat( data[ 1 ] ),
parseFloat( data[ 2 ] )
);
break;
}
} else if ( lineFirstChar === 'f' ) {
const lineData = line.slice( 1 ).trim();
const vertexData = lineData.split( _face_vertex_data_separator_pattern );
const faceVertices = [];
// Parse the face vertex data into an easy to work with format
for ( let j = 0, jl = vertexData.length; j < jl; j ++ ) {
const vertex = vertexData[ j ];
if ( vertex.length > 0 ) {
const vertexParts = vertex.split( '/' );
faceVertices.push( vertexParts );
}
}
// Draw an edge between the first vertex and all subsequent vertices to form an n-gon
const v1 = faceVertices[ 0 ];
for ( let j = 1, jl = faceVertices.length - 1; j < jl; j ++ ) {
const v2 = faceVertices[ j ];
const v3 = faceVertices[ j + 1 ];
state.addFace(
v1[ 0 ], v2[ 0 ], v3[ 0 ],
v1[ 1 ], v2[ 1 ], v3[ 1 ],
v1[ 2 ], v2[ 2 ], v3[ 2 ]
);
}
} else if ( lineFirstChar === 'l' ) {
const lineParts = line.substring( 1 ).trim().split( ' ' );
let lineVertices = [];
const lineUVs = [];
if ( line.indexOf( '/' ) === - 1 ) {
lineVertices = lineParts;
} else {
for ( let li = 0, llen = lineParts.length; li < llen; li ++ ) {
const parts = lineParts[ li ].split( '/' );
if ( parts[ 0 ] !== '' ) lineVertices.push( parts[ 0 ] );
if ( parts[ 1 ] !== '' ) lineUVs.push( parts[ 1 ] );
}
}
state.addLineGeometry( lineVertices, lineUVs );
} else if ( lineFirstChar === 'p' ) {
const lineData = line.slice( 1 ).trim();
const pointData = lineData.split( ' ' );
state.addPointGeometry( pointData );
} else if ( ( result = _object_pattern.exec( line ) ) !== null ) {
// o object_name
// or
// g group_name
// WORKAROUND: https://bugs.chromium.org/p/v8/issues/detail?id=2869
// let name = result[ 0 ].slice( 1 ).trim();
const name = ( ' ' + result[ 0 ].slice( 1 ).trim() ).slice( 1 );
state.startObject( name );
} else if ( _material_use_pattern.test( line ) ) {
// material
state.object.startMaterial( line.substring( 7 ).trim(), state.materialLibraries );
} else if ( _material_library_pattern.test( line ) ) {
// mtl file
state.materialLibraries.push( line.substring( 7 ).trim() );
} else if ( _map_use_pattern.test( line ) ) {
// the line is parsed but ignored since the loader assumes textures are defined MTL files
// (according to https://www.okino.com/conv/imp_wave.htm, 'usemap' is the old-style Wavefront texture reference method)
console.warn( 'THREE.OBJLoader: Rendering identifier "usemap" not supported. Textures must be defined in MTL files.' );
} else if ( lineFirstChar === 's' ) {
result = line.split( ' ' );
// smooth shading
// @todo Handle files that have varying smooth values for a set of faces inside one geometry,
// but does not define a usemtl for each face set.
// This should be detected and a dummy material created (later MultiMaterial and geometry groups).
// This requires some care to not create extra material on each smooth value for "normal" obj files.
// where explicit usemtl defines geometry groups.
// Example asset: examples/models/obj/cerberus/Cerberus.obj
/*
* http://paulbourke.net/dataformats/obj/
*
* From chapter "Grouping" Syntax explanation "s group_number":
* "group_number is the smoothing group number. To turn off smoothing groups, use a value of 0 or off.
* Polygonal elements use group numbers to put elements in different smoothing groups. For free-form
* surfaces, smoothing groups are either turned on or off; there is no difference between values greater
* than 0."
*/
if ( result.length > 1 ) {
const value = result[ 1 ].trim().toLowerCase();
state.object.smooth = ( value !== '0' && value !== 'off' );
} else {
// ZBrush can produce "s" lines #11707
state.object.smooth = true;
}
const material = state.object.currentMaterial();
if ( material ) material.smooth = state.object.smooth;
} else {
// Handle null terminated files without exception
if ( line === '\0' ) continue;
console.warn( 'THREE.OBJLoader: Unexpected line: "' + line + '"' );
}
}
state.finalize();
const container = new Group();
container.materialLibraries = [].concat( state.materialLibraries );
const hasPrimitives = ! ( state.objects.length === 1 && state.objects[ 0 ].geometry.vertices.length === 0 );
if ( hasPrimitives === true ) {
for ( let i = 0, l = state.objects.length; i < l; i ++ ) {
const object = state.objects[ i ];
const geometry = object.geometry;
const materials = object.materials;
const isLine = ( geometry.type === 'Line' );
const isPoints = ( geometry.type === 'Points' );
let hasVertexColors = false;
// Skip o/g line declarations that did not follow with any faces
if ( geometry.vertices.length === 0 ) continue;
const buffergeometry = new BufferGeometry();
buffergeometry.setAttribute( 'position', new Float32BufferAttribute( geometry.vertices, 3 ) );
if ( geometry.normals.length > 0 ) {
buffergeometry.setAttribute( 'normal', new Float32BufferAttribute( geometry.normals, 3 ) );
}
if ( geometry.colors.length > 0 ) {
hasVertexColors = true;
buffergeometry.setAttribute( 'color', new Float32BufferAttribute( geometry.colors, 3 ) );
}
if ( geometry.hasUVIndices === true ) {
buffergeometry.setAttribute( 'uv', new Float32BufferAttribute( geometry.uvs, 2 ) );
}
// Create materials
const createdMaterials = [];
for ( let mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
const sourceMaterial = materials[ mi ];
const materialHash = sourceMaterial.name + '_' + sourceMaterial.smooth + '_' + hasVertexColors;
let material = state.materials[ materialHash ];
if ( this.materials !== null ) {
material = this.materials.create( sourceMaterial.name );
// mtl etc. loaders probably can't create line materials correctly, copy properties to a line material.
if ( isLine && material && ! ( material instanceof LineBasicMaterial ) ) {
const materialLine = new LineBasicMaterial();
Material.prototype.copy.call( materialLine, material );
materialLine.color.copy( material.color );
material = materialLine;
} else if ( isPoints && material && ! ( material instanceof PointsMaterial ) ) {
const materialPoints = new PointsMaterial( { size: 10, sizeAttenuation: false } );
Material.prototype.copy.call( materialPoints, material );
materialPoints.color.copy( material.color );
materialPoints.map = material.map;
material = materialPoints;
}
}
if ( material === undefined ) {
if ( isLine ) {
material = new LineBasicMaterial();
} else if ( isPoints ) {
material = new PointsMaterial( { size: 1, sizeAttenuation: false } );
} else {
material = new MeshPhongMaterial();
}
material.name = sourceMaterial.name;
material.flatShading = sourceMaterial.smooth ? false : true;
material.vertexColors = hasVertexColors;
state.materials[ materialHash ] = material;
}
createdMaterials.push( material );
}
// Create mesh
let mesh;
if ( createdMaterials.length > 1 ) {
for ( let mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
const sourceMaterial = materials[ mi ];
buffergeometry.addGroup( sourceMaterial.groupStart, sourceMaterial.groupCount, mi );
}
if ( isLine ) {
mesh = new LineSegments( buffergeometry, createdMaterials );
} else if ( isPoints ) {
mesh = new Points( buffergeometry, createdMaterials );
} else {
mesh = new Mesh( buffergeometry, createdMaterials );
}
} else {
if ( isLine ) {
mesh = new LineSegments( buffergeometry, createdMaterials[ 0 ] );
} else if ( isPoints ) {
mesh = new Points( buffergeometry, createdMaterials[ 0 ] );
} else {
mesh = new Mesh( buffergeometry, createdMaterials[ 0 ] );
}
}
mesh.name = object.name;
container.add( mesh );
}
} else {
// if there is only the default parser state object with no geometry data, interpret data as point cloud
if ( state.vertices.length > 0 ) {
const material = new PointsMaterial( { size: 1, sizeAttenuation: false } );
const buffergeometry = new BufferGeometry();
buffergeometry.setAttribute( 'position', new Float32BufferAttribute( state.vertices, 3 ) );
if ( state.colors.length > 0 && state.colors[ 0 ] !== undefined ) {
buffergeometry.setAttribute( 'color', new Float32BufferAttribute( state.colors, 3 ) );
material.vertexColors = true;
}
const points = new Points( buffergeometry, material );
container.add( points );
}
}
return container;
}
}
export { OBJLoader };
+1963
View File
File diff suppressed because it is too large Load Diff
+496
View File
@@ -0,0 +1,496 @@
import {
AnimationClip,
AnimationMixer,
Matrix4,
Quaternion,
QuaternionKeyframeTrack,
SkeletonHelper,
Vector3,
VectorKeyframeTrack
} from 'three';
/**
* @module SkeletonUtils
* @three_import import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
*/
function getBoneName( bone, options ) {
if ( options.getBoneName !== undefined ) {
return options.getBoneName( bone );
}
return options.names[ bone.name ];
}
/**
* Retargets the skeleton from the given source to the target.
*
* Both `target` and `source` can be a 3D object with a skeleton property (e.g. a skinned mesh)
* or a {@link Skeleton} directly.
*
* @param {Object3D|Skeleton} target - The target object.
* @param {Object3D|Skeleton} source - The source object.
* @param {module:SkeletonUtils~RetargetOptions} options - The options.
*/
function retarget( target, source, options = {} ) {
const quat = new Quaternion(),
scale = new Vector3(),
relativeMatrix = new Matrix4(),
globalMatrix = new Matrix4();
options.preserveBoneMatrix = options.preserveBoneMatrix !== undefined ? options.preserveBoneMatrix : true;
options.preserveBonePositions = options.preserveBonePositions !== undefined ? options.preserveBonePositions : true;
options.useTargetMatrix = options.useTargetMatrix !== undefined ? options.useTargetMatrix : false;
options.hip = options.hip !== undefined ? options.hip : 'hip';
options.hipInfluence = options.hipInfluence !== undefined ? options.hipInfluence : new Vector3( 1, 1, 1 );
options.scale = options.scale !== undefined ? options.scale : 1;
options.names = options.names || {};
const sourceBones = source.isObject3D ? source.skeleton.bones : getBones( source ),
bones = target.isObject3D ? target.skeleton.bones : getBones( target );
let bone, name, boneTo,
bonesPosition;
// reset bones
if ( target.isObject3D ) {
target.skeleton.pose();
} else {
options.useTargetMatrix = true;
options.preserveBoneMatrix = false;
}
if ( options.preserveBonePositions ) {
bonesPosition = [];
for ( let i = 0; i < bones.length; i ++ ) {
bonesPosition.push( bones[ i ].position.clone() );
}
}
if ( options.preserveBoneMatrix ) {
// reset matrix
target.updateMatrixWorld();
target.matrixWorld.identity();
// reset children matrix
for ( let i = 0; i < target.children.length; ++ i ) {
target.children[ i ].updateMatrixWorld( true );
}
}
for ( let i = 0; i < bones.length; ++ i ) {
bone = bones[ i ];
name = getBoneName( bone, options );
boneTo = getBoneByName( name, sourceBones );
globalMatrix.copy( bone.matrixWorld );
if ( boneTo ) {
boneTo.updateMatrixWorld();
if ( options.useTargetMatrix ) {
relativeMatrix.copy( boneTo.matrixWorld );
} else {
relativeMatrix.copy( target.matrixWorld ).invert();
relativeMatrix.multiply( boneTo.matrixWorld );
}
// ignore scale to extract rotation
scale.setFromMatrixScale( relativeMatrix );
relativeMatrix.scale( scale.set( 1 / scale.x, 1 / scale.y, 1 / scale.z ) );
// apply to global matrix
globalMatrix.makeRotationFromQuaternion( quat.setFromRotationMatrix( relativeMatrix ) );
if ( target.isObject3D ) {
if ( options.localOffsets ) {
if ( options.localOffsets[ bone.name ] ) {
globalMatrix.multiply( options.localOffsets[ bone.name ] );
}
}
}
globalMatrix.copyPosition( relativeMatrix );
}
if ( name === options.hip ) {
globalMatrix.elements[ 12 ] *= options.scale * options.hipInfluence.x;
globalMatrix.elements[ 13 ] *= options.scale * options.hipInfluence.y;
globalMatrix.elements[ 14 ] *= options.scale * options.hipInfluence.z;
if ( options.hipPosition !== undefined ) {
globalMatrix.elements[ 12 ] += options.hipPosition.x * options.scale;
globalMatrix.elements[ 13 ] += options.hipPosition.y * options.scale;
globalMatrix.elements[ 14 ] += options.hipPosition.z * options.scale;
}
}
if ( bone.parent ) {
bone.matrix.copy( bone.parent.matrixWorld ).invert();
bone.matrix.multiply( globalMatrix );
} else {
bone.matrix.copy( globalMatrix );
}
bone.matrix.decompose( bone.position, bone.quaternion, bone.scale );
bone.updateMatrixWorld();
}
if ( options.preserveBonePositions ) {
for ( let i = 0; i < bones.length; ++ i ) {
bone = bones[ i ];
name = getBoneName( bone, options ) || bone.name;
if ( name !== options.hip ) {
bone.position.copy( bonesPosition[ i ] );
}
}
}
if ( options.preserveBoneMatrix ) {
// restore matrix
target.updateMatrixWorld( true );
}
}
/**
* Retargets the animation clip of the source to the target 3D object.
*
* The `source` can be a 3D object with a skeleton property (e.g. a skinned mesh)
* or a {@link Skeleton} directly.
*
* @param {Object3D} target - The target 3D object. Must have a `skeleton` property.
* @param {Object3D|Skeleton} source - The source object.
* @param {AnimationClip} clip - The animation clip.
* @param {module:SkeletonUtils~RetargetOptions} options - The options.
* @return {AnimationClip} The retargeted animation clip.
*/
function retargetClip( target, source, clip, options = {} ) {
options.useFirstFramePosition = options.useFirstFramePosition !== undefined ? options.useFirstFramePosition : false;
// Calculate the fps from the source clip based on the track with the most frames, unless fps is already provided.
options.fps = options.fps !== undefined ? options.fps : ( Math.max( ...clip.tracks.map( track => track.times.length ) ) / clip.duration );
options.names = options.names || [];
if ( ! source.isObject3D ) {
source = getHelperFromSkeleton( source );
}
const numFrames = Math.round( clip.duration * ( options.fps / 1000 ) * 1000 ),
delta = clip.duration / ( numFrames - 1 ),
convertedTracks = [],
mixer = new AnimationMixer( source ),
bones = getBones( target.skeleton ),
boneDatas = [];
let positionOffset,
bone, boneTo, boneData,
name;
mixer.clipAction( clip ).play();
// trim
let start = 0, end = numFrames;
if ( options.trim !== undefined ) {
start = Math.round( options.trim[ 0 ] * options.fps );
end = Math.min( Math.round( options.trim[ 1 ] * options.fps ), numFrames ) - start;
mixer.update( options.trim[ 0 ] );
} else {
mixer.update( 0 );
}
source.updateMatrixWorld();
//
for ( let frame = 0; frame < end; ++ frame ) {
const time = frame * delta;
retarget( target, source, options );
for ( let j = 0; j < bones.length; ++ j ) {
bone = bones[ j ];
name = getBoneName( bone, options ) || bone.name;
boneTo = getBoneByName( name, source.skeleton );
if ( boneTo ) {
boneData = boneDatas[ j ] = boneDatas[ j ] || { bone: bone };
if ( options.hip === name ) {
if ( ! boneData.pos ) {
boneData.pos = {
times: new Float32Array( end ),
values: new Float32Array( end * 3 )
};
}
if ( options.useFirstFramePosition ) {
if ( frame === 0 ) {
positionOffset = bone.position.clone();
}
bone.position.sub( positionOffset );
}
boneData.pos.times[ frame ] = time;
bone.position.toArray( boneData.pos.values, frame * 3 );
}
if ( ! boneData.quat ) {
boneData.quat = {
times: new Float32Array( end ),
values: new Float32Array( end * 4 )
};
}
boneData.quat.times[ frame ] = time;
bone.quaternion.toArray( boneData.quat.values, frame * 4 );
}
}
if ( frame === end - 2 ) {
// last mixer update before final loop iteration
// make sure we do not go over or equal to clip duration
mixer.update( delta - 0.0000001 );
} else {
mixer.update( delta );
}
source.updateMatrixWorld();
}
for ( let i = 0; i < boneDatas.length; ++ i ) {
boneData = boneDatas[ i ];
if ( boneData ) {
if ( boneData.pos ) {
convertedTracks.push( new VectorKeyframeTrack(
'.bones[' + boneData.bone.name + '].position',
boneData.pos.times,
boneData.pos.values
) );
}
convertedTracks.push( new QuaternionKeyframeTrack(
'.bones[' + boneData.bone.name + '].quaternion',
boneData.quat.times,
boneData.quat.values
) );
}
}
mixer.uncacheAction( clip );
return new AnimationClip( clip.name, - 1, convertedTracks );
}
/**
* Clones the given 3D object and its descendants, ensuring that any `SkinnedMesh` instances are
* correctly associated with their bones. Bones are also cloned, and must be descendants of the
* object passed to this method. Other data, like geometries and materials, are reused by reference.
*
* @param {Object3D} source - The 3D object to clone.
* @return {Object3D} The cloned 3D object.
*/
function clone( source ) {
const sourceLookup = new Map();
const cloneLookup = new Map();
const clone = source.clone();
parallelTraverse( source, clone, function ( sourceNode, clonedNode ) {
sourceLookup.set( clonedNode, sourceNode );
cloneLookup.set( sourceNode, clonedNode );
} );
clone.traverse( function ( node ) {
if ( ! node.isSkinnedMesh ) return;
const clonedMesh = node;
const sourceMesh = sourceLookup.get( node );
const sourceBones = sourceMesh.skeleton.bones;
clonedMesh.skeleton = sourceMesh.skeleton.clone();
clonedMesh.bindMatrix.copy( sourceMesh.bindMatrix );
clonedMesh.skeleton.bones = sourceBones.map( function ( bone ) {
return cloneLookup.get( bone );
} );
clonedMesh.bind( clonedMesh.skeleton, clonedMesh.bindMatrix );
} );
return clone;
}
// internal helper
function getBoneByName( name, skeleton ) {
for ( let i = 0, bones = getBones( skeleton ); i < bones.length; i ++ ) {
if ( name === bones[ i ].name )
return bones[ i ];
}
}
function getBones( skeleton ) {
return Array.isArray( skeleton ) ? skeleton : skeleton.bones;
}
function getHelperFromSkeleton( skeleton ) {
const source = new SkeletonHelper( skeleton.bones[ 0 ] );
source.skeleton = skeleton;
return source;
}
function parallelTraverse( a, b, callback ) {
callback( a, b );
for ( let i = 0; i < a.children.length; i ++ ) {
parallelTraverse( a.children[ i ], b.children[ i ], callback );
}
}
/**
* Retarget options of `SkeletonUtils`.
*
* @typedef {Object} module:SkeletonUtils~RetargetOptions
* @property {boolean} [useFirstFramePosition=false] - Whether to use the position of the first frame or not.
* @property {number} [fps] - The FPS of the clip.
* @property {Object<string,string>} [names] - A dictionary for mapping target to source bone names.
* @property {function(string):string} [getBoneName] - A function for mapping bone names. Alternative to `names`.
* @property {Array<number>} [trim] - Whether to trim the clip or not. If set the array should hold two values for the start and end.
* @property {boolean} [preserveBoneMatrix=true] - Whether to preserve bone matrices or not.
* @property {boolean} [preserveBonePositions=true] - Whether to preserve bone positions or not.
* @property {boolean} [useTargetMatrix=false] - Whether to use the target matrix or not.
* @property {string} [hip='hip'] - The name of the source's hip bone.
* @property {Vector3} [hipInfluence=(1,1,1)] - The hip influence.
* @property {number} [scale=1] - The scale.
* @property {Object<string,Matrix4>} [localOffsets] - Per-bone local offset matrices, keyed by bone name.
* @property {Vector3} [hipPosition] - An additional position offset applied to the hip bone.
**/
export {
retarget,
retargetClip,
clone,
};
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2026 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
+145 -165
View File
@@ -1,183 +1,163 @@
import { app } from "../../scripts/app.js"
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
class Visualizer {
constructor(node, container, visualSrc) {
this.node = node
const EXTENSION_NAME = "gokayfem.texture-simple";
const PATCHED = Symbol("textureViewerPatched");
const VIEWER_URL = new URL("./html/threeVisualizer.html", import.meta.url).href;
const MAP_NAMES = [
"color",
"displacement",
"normal",
"ao",
"metalness",
"roughness",
"alpha",
];
this.iframe = document.createElement('iframe')
Object.assign(this.iframe, {
scrolling: "no",
overflow: "hidden",
})
this.iframe.src = "/extensions/ComfyUI-Texture-Simple/html/" + visualSrc + ".html"
container.appendChild(this.iframe)
}
updateVisual(params) {
const iframeDocument = this.iframe.contentWindow.document;
const previewScript = iframeDocument.getElementById('visualizer');
previewScript.setAttribute("color_map", JSON.stringify(params.color_map));
previewScript.setAttribute("displacement_map", JSON.stringify(params.displacement_map));
previewScript.setAttribute("normal_map", JSON.stringify(params.normal_map));
previewScript.setAttribute("ao_map", JSON.stringify(params.ao_map));
previewScript.setAttribute("metalness_map", JSON.stringify(params.metalness_map));
previewScript.setAttribute("roughness_map", JSON.stringify(params.roughness_map));
previewScript.setAttribute("alpha_map", JSON.stringify(params.alpha_map));
// Update the reference image and depth map
}
remove() {
this.container.remove()
}
function chainCallback(previous, next) {
return function chainedCallback(...args) {
const result = previous?.apply(this, args);
next.apply(this, args);
return result;
};
}
function createVisualizer(node, inputName, typeName, inputData, app) {
node.name = inputName
function createViewer(node) {
const container = document.createElement("div");
Object.assign(container.style, {
width: "100%",
height: "100%",
minHeight: "480px",
overflow: "hidden",
borderRadius: "8px",
background: "#14171c",
});
const widget = {
type: typeName,
name: "preview3d",
callback: () => {},
draw : function(ctx, node, widgetWidth, widgetY, widgetHeight) {
const margin = 10
const top_offset = 5
const visible = app.canvas.ds.scale > 0.5 && this.type === typeName
const w = widgetWidth - margin * 4
const clientRectBound = ctx.canvas.getBoundingClientRect()
const transform = new DOMMatrix()
.scaleSelf(
clientRectBound.width / ctx.canvas.width,
clientRectBound.height / ctx.canvas.height
)
.multiplySelf(ctx.getTransform())
.translateSelf(margin, margin + widgetY )
Object.assign(this.visualizer.style, {
left: `${transform.a * margin + transform.e}px`,
top: `${transform.d + transform.f + top_offset}px`,
width: `${(w * transform.a)}px`,
height: `${(w * transform.d - widgetHeight * 5 - (margin * 15) * transform.d)}px`,
position: "absolute",
overflow: "hidden",
zIndex: app.graph._nodes.indexOf(node),
})
const iframe = document.createElement("iframe");
iframe.title = "Interactive PBR texture preview";
iframe.src = VIEWER_URL;
iframe.loading = "eager";
iframe.setAttribute(
"sandbox",
"allow-scripts allow-same-origin allow-downloads",
);
Object.assign(iframe.style, {
width: "100%",
height: "100%",
border: "0",
display: "block",
background: "#14171c",
});
container.append(iframe);
Object.assign(this.visualizer.children[0].style, {
transformOrigin: "50% 50%",
width: '100%',
height: '100%',
border: '0 none',
})
const channel = globalThis.crypto?.randomUUID?.()
?? `texture-${Date.now()}-${Math.random()}`;
let ready = false;
let pendingOutput = null;
this.visualizer.hidden = !visible
const post = (type, payload = {}) => {
iframe.contentWindow?.postMessage(
{
source: EXTENSION_NAME,
channel,
type,
...payload,
},
window.location.origin,
);
};
const initialize = () => {
post("initialize", { viewUrl: api.apiURL("/view") });
if (pendingOutput) {
post("update", { output: pendingOutput });
pendingOutput = null;
}
};
const onMessage = (event) => {
if (
event.origin !== window.location.origin
|| event.source !== iframe.contentWindow
|| event.data?.source !== EXTENSION_NAME
|| event.data?.channel !== channel
|| event.data?.type !== "ready"
) {
return;
}
ready = true;
initialize();
};
window.addEventListener("message", onMessage);
iframe.addEventListener("load", () => {
ready = false;
post("connect");
});
const widget = node.addDOMWidget(
"texture_preview",
"TEXTURE_PREVIEW",
container,
{
canvasOnly: true,
hideOnZoom: false,
},
);
widget.serialize = false;
widget.computeLayoutSize = () => ({
minWidth: 580,
minHeight: 480,
});
const currentWidth = node.size?.[0] ?? 0;
const currentHeight = node.size?.[1] ?? 0;
if (currentWidth < 620 || currentHeight < 600) {
node.setSize([
Math.max(currentWidth, 620),
Math.max(currentHeight, 600),
]);
}
const container = document.createElement('div')
container.id = `Comfy3D_${inputName}`
node.visualizer = new Visualizer(node, container, typeName)
widget.visualizer = container
widget.parent = node
document.body.appendChild(widget.visualizer)
node.addCustomWidget(widget)
node.updateParameters = (params) => {
node.visualizer.updateVisual(params);
}
// Events for drawing backgound
node.onDrawBackground = function (ctx) {
if (!this.flags.collapsed) {
node.visualizer.iframe.hidden = false
} else {
node.visualizer.iframe.hidden = true
node.__textureViewerUpdate = (output) => {
if (!ready) {
pendingOutput = output;
return;
}
}
post("update", { output });
};
// Make sure visualization iframe is always inside the node when resize the node
node.onResize = function () {
let [w, h] = this.size
if (w <= 600) w = 600
if (h <= 500) h = 500
if (w > 600) {
h = w - 100
}
this.size = [w, h]
}
// Events for remove nodes
node.onRemoved = () => {
for (let w in node.widgets) {
if (node.widgets[w].visualizer) {
node.widgets[w].visualizer.remove()
}
}
}
return {
widget: widget,
}
}
function registerVisualizer(nodeType, nodeData, nodeClassName, typeName) {
if (nodeData.name == nodeClassName) {
console.log("[3D Visualizer] Registering node: " + nodeData.name)
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = async function() {
const r = onNodeCreated
? onNodeCreated.apply(this, arguments)
: undefined
let Preview3DNode = app.graph._nodes.filter(
(wi) => wi.type == nodeClassName
)
let nodeName = `Preview3DNode_${Preview3DNode.length}`
console.log(`[Comfy3D] Create: ${nodeName}`)
const result = await createVisualizer.apply(this, [this, nodeName, typeName, {}, app])
this.setSize([600, 500])
return r
}
nodeType.prototype.onExecuted = async function(message) {
const params = {}
const mapTypes = ["color", "displacement", "normal", "ao", "metalness", "roughness", "alpha"];
for (let mapType of mapTypes) {
if (message[mapType] && message[mapType].length >= 1) {
params[mapType + '_map'] = message[mapType][0];
} else {
params[mapType + '_map'] = null;
}
}
this.updateParameters(params);
}
}
node.onRemoved = chainCallback(node.onRemoved, () => {
window.removeEventListener("message", onMessage);
post("dispose");
iframe.src = "about:blank";
});
}
app.registerExtension({
name: "Mr.ForExample.Visualizer.TextureViewer",
name: EXTENSION_NAME,
async init (app) {
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "TextureViewer" || nodeType.prototype[PATCHED]) {
return;
}
nodeType.prototype[PATCHED] = true;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function onTextureViewerCreated(...args) {
const result = onNodeCreated?.apply(this, args);
createViewer(this);
return result;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function onTextureViewerExecuted(message) {
const result = onExecuted?.apply(this, arguments);
const output = Object.fromEntries(
MAP_NAMES.map((name) => [name, message?.[name] ?? []]),
);
this.__textureViewerUpdate?.(output);
return result;
};
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
registerVisualizer(nodeType, nodeData, "TextureViewer", "threeVisualizer")
},
})
});