Rebuild PBR texture viewer
This commit is contained in:
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
## 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
@@ -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
@@ -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
@@ -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 +1,2 @@
|
||||
numpy>=1.25.0
|
||||
Pillow>=10.1.0
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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 */
|
||||
}
|
||||
Vendored
+1501
File diff suppressed because it is too large
Load Diff
Vendored
+3840
File diff suppressed because it is too large
Load Diff
Vendored
+4860
File diff suppressed because it is too large
Load Diff
Vendored
+308
@@ -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 };
|
||||
Vendored
+955
@@ -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 };
|
||||
Vendored
+1963
File diff suppressed because it is too large
Load Diff
Vendored
+496
@@ -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,
|
||||
};
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
+145
-165
@@ -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")
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user