Initial release
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-07-05
|
||||
|
||||
- Created the standalone `ComfyUI-TY-OrbitSphereLight` custom node.
|
||||
- Added `TY Orbit Sphere Light` / `TYOrbitSphereLightNode` under `TY/Relight`.
|
||||
- Kept final render output aligned with the original `Sphere-Light-Render-ComfyUI` look.
|
||||
- Added a node-only gizmo preview with orbit handles for `azimuth`, `elevation`, and `intensity`.
|
||||
- Hid `render_b64` from the ComfyUI sidebar and workflow serialization.
|
||||
- Added backend fallback rendering for API or headless use when the frontend preview buffer is unavailable.
|
||||
- Bundled `three.min.js` locally for offline installation.
|
||||
- Added `README.zh-CN.md` and a minimal example workflow.
|
||||
- Added paired frontend support through the TY light reference page and fixed clean download filenames.
|
||||
@@ -0,0 +1,94 @@
|
||||
# ComfyUI-TY-OrbitSphereLight
|
||||
|
||||
`TY Orbit Sphere Light` is an independent ComfyUI custom node for generating clean sphere-based relighting reference images.
|
||||
|
||||
## Overview
|
||||
|
||||
This node combines:
|
||||
|
||||
- the clean output style of `Sphere-Light-Render-ComfyUI`
|
||||
- a more intuitive orbital gizmo interaction style inspired by `comfyui_gaussian_splat`
|
||||
|
||||
The result is a new standalone TY node with:
|
||||
|
||||
- a sphere-centered preview
|
||||
- draggable handles for `azimuth`, `elevation`, and `intensity`
|
||||
- no handles, rings, or guides in the final output image
|
||||
|
||||
## Final Output Rules
|
||||
|
||||
- The exported `IMAGE` stays clean.
|
||||
- The gizmo is visible only inside the ComfyUI node preview.
|
||||
- The output keeps the original gray background, sphere, floor, and shadow look.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `azimuth`: `0..360`
|
||||
- `elevation`: `-30..90`
|
||||
- `intensity`: `0.2..3.0`
|
||||
- `render_b64`: internal hidden buffer used by the node preview render path
|
||||
|
||||
## Output
|
||||
|
||||
- `render`: clean `IMAGE` reference without preview controls
|
||||
|
||||
## Render Paths
|
||||
|
||||
- ComfyUI canvas mode:
|
||||
- the frontend preview renders the sphere and writes a hidden render buffer
|
||||
- API or headless workflow mode:
|
||||
- the Python fallback renderer is used automatically when `render_b64` is empty
|
||||
|
||||
## Workflow Serialization
|
||||
|
||||
- `render_b64` is hidden in the ComfyUI sidebar
|
||||
- `render_b64` is not saved into workflow `widgets_values`
|
||||
- saved workflows only keep the numeric controls needed to reopen the node cleanly
|
||||
|
||||
## Install
|
||||
|
||||
### Option 1: Folder Install
|
||||
|
||||
1. Copy `ComfyUI-TY-OrbitSphereLight` into `ComfyUI/custom_nodes/`
|
||||
2. Restart ComfyUI
|
||||
|
||||
### Option 2: Zip Install
|
||||
|
||||
1. Use the packaged file `ComfyUI-TY-OrbitSphereLight.zip`
|
||||
2. Extract it into `ComfyUI/custom_nodes/`
|
||||
3. Restart ComfyUI
|
||||
|
||||
## Verify
|
||||
|
||||
After ComfyUI starts:
|
||||
|
||||
1. Add `TY Orbit Sphere Light`
|
||||
2. Confirm the node appears under category `TY/Relight`
|
||||
3. Confirm the node preview shows the sphere and the three colored handles
|
||||
4. Confirm the right sidebar only shows `azimuth`, `elevation`, and `intensity`
|
||||
5. Confirm the final output image does not contain any gizmo graphics
|
||||
|
||||
## Files
|
||||
|
||||
- `__init__.py`: node registration and backend fallback renderer
|
||||
- `js/ty_orbit_sphere_light_widget.js`: ComfyUI frontend preview and gizmo logic
|
||||
- `js/vendor/three.min.js`: bundled Three.js runtime for offline use
|
||||
|
||||
## Bundled Extras
|
||||
|
||||
- `README.zh-CN.md`: Chinese install and usage guide
|
||||
- `examples/ty_orbit_sphere_light_basic_workflow.json`: minimal workflow example with `SaveImage`
|
||||
|
||||
## Frontend Pairing
|
||||
|
||||
This node can also be used from the paired TY frontend page at:
|
||||
|
||||
- `http://127.0.0.1:3000/ty-light-ref.html`
|
||||
|
||||
That page calls the `ty-orbit-sphere-light` workflow app and keeps the final render clean, matching the node output rules.
|
||||
|
||||
## Notes
|
||||
|
||||
- No extra Python packages are required beyond the normal ComfyUI image stack.
|
||||
- The original `Sphere-Light-Render-ComfyUI` plugin is not modified.
|
||||
- This plugin is designed to coexist with the original node as a separate TY node.
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
# ComfyUI-TY-OrbitSphereLight
|
||||
|
||||
`TY Orbit Sphere Light` 是一个独立的 ComfyUI 自定义节点,用来生成干净的球体打光参考图。
|
||||
|
||||
## 节点定位
|
||||
|
||||
这个节点把两类能力结合到了一起:
|
||||
|
||||
- 保留 `Sphere-Light-Render-ComfyUI` 的最终出图风格
|
||||
- 引入更直观的环绕式手柄交互体验
|
||||
|
||||
最终效果是:
|
||||
|
||||
- 节点内部可见球体预览
|
||||
- 可拖拽控制 `azimuth`、`elevation`、`intensity`
|
||||
- 最终输出图像中不包含手柄、轨道、辅助线
|
||||
|
||||
## 最终输出约束
|
||||
|
||||
- 输出图必须是干净球体图
|
||||
- 手柄只在 ComfyUI 节点预览中显示
|
||||
- 背景、球体、地面、阴影风格保持与原球体打光插件一致
|
||||
|
||||
## 输入参数
|
||||
|
||||
- `azimuth`: `0..360`
|
||||
- `elevation`: `-30..90`
|
||||
- `intensity`: `0.2..3.0`
|
||||
- `render_b64`: 内部隐藏缓冲字段,仅用于节点前端预览
|
||||
|
||||
## 输出参数
|
||||
|
||||
- `render`: 干净的 `IMAGE` 输出
|
||||
|
||||
## 运行模式
|
||||
|
||||
### 1. ComfyUI 画布模式
|
||||
|
||||
- 节点前端会在预览区渲染球体和手柄
|
||||
- 同步回写隐藏的 `render_b64`
|
||||
|
||||
### 2. API / 无前端模式
|
||||
|
||||
- 当 `render_b64` 为空时,自动走 Python 后端 fallback 渲染
|
||||
- 适合外部前端、自动化流程、headless 调用
|
||||
|
||||
## Workflow 保存行为
|
||||
|
||||
- `render_b64` 在 ComfyUI 右侧参数栏中隐藏
|
||||
- `render_b64` 不会写入 workflow 的 `widgets_values`
|
||||
- 保存后的 workflow 只保留数值参数,避免带入大段 base64
|
||||
|
||||
## 安装方法
|
||||
|
||||
### 方式一:文件夹安装
|
||||
|
||||
1. 把 `ComfyUI-TY-OrbitSphereLight` 复制到 `ComfyUI/custom_nodes/`
|
||||
2. 重启 ComfyUI
|
||||
|
||||
### 方式二:压缩包安装
|
||||
|
||||
1. 使用打包文件 `ComfyUI-TY-OrbitSphereLight.zip`
|
||||
2. 解压到 `ComfyUI/custom_nodes/`
|
||||
3. 重启 ComfyUI
|
||||
|
||||
## 使用步骤
|
||||
|
||||
1. 在 ComfyUI 中添加 `TY Orbit Sphere Light`
|
||||
2. 在分类 `TY/Relight` 下找到节点
|
||||
3. 通过滑条或节点预览中的三色手柄调节参数
|
||||
4. 将 `render` 输出连接到后续节点或 `SaveImage`
|
||||
|
||||
## 验证要点
|
||||
|
||||
启动后建议检查:
|
||||
|
||||
1. 节点预览区是否显示球体和三色手柄
|
||||
2. 右侧栏是否只显示 `azimuth`、`elevation`、`intensity`
|
||||
3. 输出图里是否完全没有手柄和轨道
|
||||
4. 保存并重开 workflow 后参数是否恢复正常
|
||||
|
||||
## 示例 Workflow
|
||||
|
||||
可直接参考:
|
||||
|
||||
- [examples/ty_orbit_sphere_light_basic_workflow.json](examples/ty_orbit_sphere_light_basic_workflow.json)
|
||||
|
||||
这个示例包含:
|
||||
|
||||
- `TY Orbit Sphere Light`
|
||||
- `SaveImage`
|
||||
- 一条最小可运行连接
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `__init__.py`: 节点注册与后端 fallback 渲染
|
||||
- `js/ty_orbit_sphere_light_widget.js`: 节点前端预览与手柄逻辑
|
||||
- `js/vendor/three.min.js`: 内置 Three.js,支持离线使用
|
||||
|
||||
## 附带资源
|
||||
|
||||
- `README.md`: 英文安装与说明
|
||||
- `CHANGELOG.md`: 当前版本改动记录
|
||||
- `examples/ty_orbit_sphere_light_basic_workflow.json`: 最小可运行 workflow 示例
|
||||
|
||||
## 配套前端页
|
||||
|
||||
如果你同时使用这套 TY 前端工具,也可以直接打开:
|
||||
|
||||
- `http://127.0.0.1:3000/ty-light-ref.html`
|
||||
|
||||
这个独立页会调用 `ty-orbit-sphere-light` workflow app,生成与节点输出规则一致的干净球体参考图。
|
||||
|
||||
## 备注
|
||||
|
||||
- 不会覆盖或修改原 `Sphere-Light-Render-ComfyUI`
|
||||
- 可与原插件同时安装
|
||||
- 如果你同时使用配套前端工具,可以直接通过独立页生成参考图
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
import base64
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
|
||||
RENDER_SIZE = 512
|
||||
OUTPUT_SIZE = 1024
|
||||
BACKGROUND_RGB = np.array([138, 138, 138], dtype=np.float32) / 255.0
|
||||
SPHERE_RGB = np.array([204, 204, 204], dtype=np.float32) / 255.0
|
||||
FALLBACK_BRIGHTNESS_OFFSET = 40
|
||||
FALLBACK_BLUR_RADIUS = 1.5
|
||||
|
||||
|
||||
def _normalize(vec):
|
||||
vec = np.asarray(vec, dtype=np.float32)
|
||||
norm = np.linalg.norm(vec, axis=-1, keepdims=True)
|
||||
norm = np.maximum(norm, 1e-6)
|
||||
return vec / norm
|
||||
|
||||
|
||||
def _build_camera_rays(size):
|
||||
cam_pos = np.array([0.0, 6.0, 8.0], dtype=np.float32)
|
||||
cam_target = np.array([0.0, -0.5, 0.0], dtype=np.float32)
|
||||
world_up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
|
||||
|
||||
forward = _normalize(cam_target - cam_pos)
|
||||
right = _normalize(np.cross(forward, world_up))
|
||||
up = _normalize(np.cross(right, forward))
|
||||
|
||||
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
|
||||
nx = ((xx + 0.5) / size) * 2.0 - 1.0
|
||||
ny = 1.0 - ((yy + 0.5) / size) * 2.0
|
||||
|
||||
scale = np.tan(np.deg2rad(35.0).astype(np.float32) / 2.0)
|
||||
dirs = (
|
||||
forward[None, None, :]
|
||||
+ nx[..., None] * scale * right[None, None, :]
|
||||
+ ny[..., None] * scale * up[None, None, :]
|
||||
)
|
||||
return cam_pos, _normalize(dirs)
|
||||
|
||||
|
||||
def _intersect_sphere(ray_origin, ray_dirs, center, radius):
|
||||
oc = ray_origin[None, None, :] - center[None, None, :]
|
||||
half_b = np.sum(ray_dirs * oc, axis=-1)
|
||||
c = np.sum(oc * oc, axis=-1) - radius * radius
|
||||
disc = half_b * half_b - c
|
||||
valid = disc > 0.0
|
||||
sqrt_disc = np.zeros_like(disc, dtype=np.float32)
|
||||
sqrt_disc[valid] = np.sqrt(disc[valid])
|
||||
|
||||
t = np.full(disc.shape, np.inf, dtype=np.float32)
|
||||
near = -half_b - sqrt_disc
|
||||
far = -half_b + sqrt_disc
|
||||
valid_near = valid & (near > 1e-4)
|
||||
valid_far = valid & ~valid_near & (far > 1e-4)
|
||||
t[valid_near] = near[valid_near]
|
||||
t[valid_far] = far[valid_far]
|
||||
return t
|
||||
|
||||
|
||||
def _intersect_plane_y(ray_origin, ray_dirs, plane_y):
|
||||
dy = ray_dirs[..., 1]
|
||||
t = np.full(dy.shape, np.inf, dtype=np.float32)
|
||||
valid = np.abs(dy) > 1e-6
|
||||
raw_t = (plane_y - ray_origin[1]) / dy
|
||||
t[valid & (raw_t > 1e-4)] = raw_t[valid & (raw_t > 1e-4)]
|
||||
return t
|
||||
|
||||
|
||||
def _batched_directional_shadow(points, light_dir, sphere_radius):
|
||||
if points.size == 0:
|
||||
return np.zeros((0,), dtype=np.float32)
|
||||
|
||||
helper = np.array([0.0, 1.0, 0.0], dtype=np.float32)
|
||||
if abs(np.dot(helper, light_dir)) > 0.95:
|
||||
helper = np.array([1.0, 0.0, 0.0], dtype=np.float32)
|
||||
|
||||
tangent_a = _normalize(np.cross(light_dir, helper)).reshape(3)
|
||||
tangent_b = _normalize(np.cross(light_dir, tangent_a)).reshape(3)
|
||||
offsets = [
|
||||
(0.0, 0.0),
|
||||
(0.055, 0.0),
|
||||
(-0.055, 0.0),
|
||||
(0.0, 0.055),
|
||||
(0.0, -0.055),
|
||||
]
|
||||
|
||||
occlusion = np.zeros((points.shape[0],), dtype=np.float32)
|
||||
|
||||
for off_a, off_b in offsets:
|
||||
sample_dir = _normalize(light_dir + tangent_a * off_a + tangent_b * off_b).reshape(3)
|
||||
origin = points + sample_dir[None, :] * 0.03
|
||||
proj = np.sum(origin * sample_dir[None, :], axis=1)
|
||||
c = np.sum(origin * origin, axis=1) - sphere_radius * sphere_radius
|
||||
disc = proj * proj - c
|
||||
hit = disc > 0.0
|
||||
sqrt_disc = np.zeros_like(disc, dtype=np.float32)
|
||||
sqrt_disc[hit] = np.sqrt(disc[hit])
|
||||
t = -proj - sqrt_disc
|
||||
occlusion += (hit & (t > 1e-4)).astype(np.float32)
|
||||
|
||||
return occlusion / float(len(offsets))
|
||||
|
||||
|
||||
def _render_backend_fallback(azimuth, elevation, intensity):
|
||||
cam_pos, ray_dirs = _build_camera_rays(RENDER_SIZE)
|
||||
sphere_center = np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
||||
sphere_radius = 1.0
|
||||
plane_y = -1.0
|
||||
|
||||
az = np.deg2rad(np.float32(azimuth))
|
||||
el = np.deg2rad(np.float32(elevation))
|
||||
light_pos = np.array(
|
||||
[
|
||||
10.0 * np.cos(el) * np.sin(az),
|
||||
10.0 * np.sin(el),
|
||||
10.0 * np.cos(el) * np.cos(az),
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
light_dir = _normalize(light_pos).reshape(3)
|
||||
|
||||
sphere_t = _intersect_sphere(cam_pos, ray_dirs, sphere_center, sphere_radius)
|
||||
plane_t = _intersect_plane_y(cam_pos, ray_dirs, plane_y)
|
||||
|
||||
image = np.broadcast_to(BACKGROUND_RGB, (RENDER_SIZE, RENDER_SIZE, 3)).copy()
|
||||
|
||||
sphere_hit = np.isfinite(sphere_t) & (sphere_t <= plane_t)
|
||||
plane_hit = np.isfinite(plane_t) & ~sphere_hit
|
||||
|
||||
if np.any(sphere_hit):
|
||||
sphere_points = cam_pos[None, None, :] + ray_dirs * sphere_t[..., None]
|
||||
sphere_points_hit = sphere_points[sphere_hit]
|
||||
normals_hit = _normalize(sphere_points_hit - sphere_center[None, :])
|
||||
view_dir_hit = _normalize(cam_pos[None, :] - sphere_points_hit)
|
||||
|
||||
diffuse = np.clip(np.sum(normals_hit * light_dir[None, :], axis=1), 0.0, 1.0)
|
||||
half_vec = _normalize(light_dir[None, :] + view_dir_hit)
|
||||
specular = np.clip(np.sum(normals_hit * half_vec, axis=1), 0.0, 1.0) ** 28.0
|
||||
|
||||
ambient = 0.2
|
||||
diffuse_term = 0.68 * diffuse * float(intensity)
|
||||
specular_term = 0.14 * specular * min(float(intensity), 2.0)
|
||||
sphere_color = SPHERE_RGB[None, :] * np.clip((ambient + diffuse_term)[:, None], 0.0, 1.35)
|
||||
sphere_color = np.clip(sphere_color + specular_term[:, None], 0.0, 1.0)
|
||||
image[sphere_hit] = sphere_color
|
||||
|
||||
if np.any(plane_hit):
|
||||
plane_points = cam_pos[None, None, :] + ray_dirs * plane_t[..., None]
|
||||
plane_points_hit = plane_points[plane_hit]
|
||||
occlusion = _batched_directional_shadow(plane_points_hit, light_dir, sphere_radius)
|
||||
shadow_strength = 0.42 + 0.18 * min(float(intensity) / 3.0, 1.0)
|
||||
plane_color = BACKGROUND_RGB[None, :] * (1.0 - shadow_strength * occlusion[:, None])
|
||||
image[plane_hit] = np.clip(plane_color, 0.0, 1.0)
|
||||
|
||||
rgb = np.clip(image * 255.0, 0.0, 255.0).astype(np.uint8)
|
||||
img = Image.fromarray(rgb, mode="RGB").resize((OUTPUT_SIZE, OUTPUT_SIZE), Image.LANCZOS)
|
||||
|
||||
# Match the original Sphere-Light-Render output feel more closely:
|
||||
# keep the backend fallback slightly brighter and softer than the raw analytic render.
|
||||
img = img.filter(ImageFilter.GaussianBlur(radius=FALLBACK_BLUR_RADIUS))
|
||||
img = img.point(lambda value: min(255, value + FALLBACK_BRIGHTNESS_OFFSET))
|
||||
return img
|
||||
|
||||
|
||||
class TYOrbitSphereLightNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"azimuth": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 360.0,
|
||||
"step": 1.0,
|
||||
"display": "slider",
|
||||
},
|
||||
),
|
||||
"elevation": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 45.0,
|
||||
"min": -30.0,
|
||||
"max": 90.0,
|
||||
"step": 1.0,
|
||||
"display": "slider",
|
||||
},
|
||||
),
|
||||
"intensity": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.5,
|
||||
"min": 0.2,
|
||||
"max": 3.0,
|
||||
"step": 0.1,
|
||||
"display": "slider",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"render_b64": ("STRING", {"default": "", "multiline": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("render",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "TY/Relight"
|
||||
OUTPUT_NODE = False
|
||||
DESCRIPTION = (
|
||||
"Interactive TY sphere light controller. "
|
||||
"The node preview shows draggable gizmos, but the output image stays clean."
|
||||
)
|
||||
|
||||
def execute(self, azimuth, elevation, intensity, render_b64=""):
|
||||
if render_b64 and render_b64.startswith("data:image"):
|
||||
try:
|
||||
_, data = render_b64.split(",", 1)
|
||||
img_bytes = base64.b64decode(data)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
img = img.resize((OUTPUT_SIZE, OUTPUT_SIZE), Image.LANCZOS)
|
||||
except Exception as exc:
|
||||
print(f"[TYOrbitSphereLightNode] Error decoding render image: {exc}")
|
||||
img = _render_backend_fallback(azimuth, elevation, intensity)
|
||||
else:
|
||||
img = _render_backend_fallback(azimuth, elevation, intensity)
|
||||
|
||||
arr = np.array(img).astype(np.float32) / 255.0
|
||||
tensor = torch.from_numpy(arr).unsqueeze(0)
|
||||
return (tensor,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"TYOrbitSphereLightNode": TYOrbitSphereLightNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"TYOrbitSphereLightNode": "TY Orbit Sphere Light",
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "TYOrbitSphereLightNode",
|
||||
"pos": [
|
||||
120,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
340,
|
||||
446
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "azimuth",
|
||||
"localized_name": "azimuth",
|
||||
"name": "azimuth",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "azimuth"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"label": "elevation",
|
||||
"localized_name": "elevation",
|
||||
"name": "elevation",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "elevation"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"label": "intensity",
|
||||
"localized_name": "intensity",
|
||||
"name": "intensity",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "intensity"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"label": "render_b64",
|
||||
"localized_name": "render_b64",
|
||||
"name": "render_b64",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "render_b64"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"label": "render",
|
||||
"localized_name": "render",
|
||||
"name": "render",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {}
|
||||
},
|
||||
"Node name for S&R": "TYOrbitSphereLightNode"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
45,
|
||||
1.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
520,
|
||||
160
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "images",
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"label": "filename_prefix",
|
||||
"localized_name": "filename_prefix",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"label": "images",
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.27.0",
|
||||
"ue_properties": {
|
||||
"widget_ue_connectable": {},
|
||||
"input_ue_unconnectable": {}
|
||||
},
|
||||
"Node name for S&R": "SaveImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"TY_Orbit_Sphere_Light"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ue_links": [],
|
||||
"ds": {
|
||||
"scale": 0.9,
|
||||
"offset": [
|
||||
416,
|
||||
110
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const LOCAL_THREE_URL = new URL("./vendor/three.min.js", import.meta.url).href;
|
||||
|
||||
const INTENSITY_MIN = 0.2;
|
||||
const INTENSITY_MAX = 3.0;
|
||||
const INTENSITY_T_MIN = 0.22;
|
||||
const INTENSITY_T_MAX = 0.88;
|
||||
const INTENSITY_AXIS_SCALE = 1.85;
|
||||
const AZIMUTH_RING_RX = 0.3;
|
||||
const AZIMUTH_RING_RY = 0.18;
|
||||
|
||||
const OVERLAY = {
|
||||
center: { x: 0, y: 0, z: 0 },
|
||||
ringY: 0,
|
||||
azimuthRadius: 1.7,
|
||||
elevationRadius: 1.55,
|
||||
elevationPlaneX: -1.35,
|
||||
lightVisualRadius: 2.55,
|
||||
cameraPos: { x: 4.2, y: 3.2, z: 4.2 },
|
||||
cameraTarget: { x: 0, y: -0.2, z: 0 },
|
||||
fov: 45,
|
||||
};
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function normalizeAzimuth(value) {
|
||||
let angle = Number(value);
|
||||
angle = ((angle % 360) + 360) % 360;
|
||||
return angle;
|
||||
}
|
||||
|
||||
function toRadians(deg) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function toDegrees(rad) {
|
||||
return (rad * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function vec3Add(a, b) {
|
||||
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
|
||||
}
|
||||
|
||||
function vec3Sub(a, b) {
|
||||
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
|
||||
}
|
||||
|
||||
function vec3Mul(v, scalar) {
|
||||
return { x: v.x * scalar, y: v.y * scalar, z: v.z * scalar };
|
||||
}
|
||||
|
||||
function vec3Dot(a, b) {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
|
||||
function vec3Cross(a, b) {
|
||||
return {
|
||||
x: a.y * b.z - a.z * b.y,
|
||||
y: a.z * b.x - a.x * b.z,
|
||||
z: a.x * b.y - a.y * b.x,
|
||||
};
|
||||
}
|
||||
|
||||
function vec3Norm(v) {
|
||||
const len = Math.hypot(v.x, v.y, v.z);
|
||||
if (len < 1e-6) {
|
||||
return { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
return { x: v.x / len, y: v.y / len, z: v.z / len };
|
||||
}
|
||||
|
||||
function loadThree() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.THREE) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = LOCAL_THREE_URL;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function buildCleanRenderScene() {
|
||||
const R = window.THREE;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
|
||||
const renderer = new R.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = R.PCFSoftShadowMap;
|
||||
renderer.setSize(512, 512, false);
|
||||
renderer.setClearColor(0x8a8a8a);
|
||||
renderer.outputEncoding = R.sRGBEncoding;
|
||||
|
||||
const scene = new R.Scene();
|
||||
scene.background = new R.Color(0x8a8a8a);
|
||||
|
||||
const camera = new R.PerspectiveCamera(35, 1, 0.1, 200);
|
||||
camera.position.set(0, 6, 8);
|
||||
camera.lookAt(0, -0.5, 0);
|
||||
|
||||
const plane = new R.Mesh(
|
||||
new R.PlaneGeometry(100, 100),
|
||||
new R.MeshStandardMaterial({ color: 0x8a8a8a, roughness: 1, metalness: 0 })
|
||||
);
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
plane.position.y = -1;
|
||||
plane.receiveShadow = true;
|
||||
scene.add(plane);
|
||||
|
||||
const sphere = new R.Mesh(
|
||||
new R.SphereGeometry(1, 64, 64),
|
||||
new R.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.8, metalness: 0 })
|
||||
);
|
||||
sphere.position.y = 0;
|
||||
sphere.castShadow = true;
|
||||
sphere.receiveShadow = true;
|
||||
scene.add(sphere);
|
||||
|
||||
scene.add(new R.AmbientLight(0xffffff, 0.2));
|
||||
|
||||
const dirLight = new R.DirectionalLight(0xffffff, 1.5);
|
||||
dirLight.castShadow = true;
|
||||
dirLight.shadow.mapSize.width = 2048;
|
||||
dirLight.shadow.mapSize.height = 2048;
|
||||
dirLight.shadow.camera.near = 0.1;
|
||||
dirLight.shadow.camera.far = 50;
|
||||
dirLight.shadow.camera.left = -8;
|
||||
dirLight.shadow.camera.right = 8;
|
||||
dirLight.shadow.camera.top = 8;
|
||||
dirLight.shadow.camera.bottom = -8;
|
||||
dirLight.shadow.bias = -0.0005;
|
||||
dirLight.shadow.radius = 2;
|
||||
scene.add(dirLight);
|
||||
|
||||
// This scene intentionally stays "clean" so the exported IMAGE matches
|
||||
// the original Sphere-Light-Render visual style without gizmos.
|
||||
return { renderer, scene, camera, dirLight, canvas };
|
||||
}
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((widget) => widget?.name === name) || null;
|
||||
}
|
||||
|
||||
function getWidgetValue(node, name, fallback) {
|
||||
const widget = findWidget(node, name);
|
||||
if (!widget) return fallback;
|
||||
const value = Number(widget.value);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function setWidgetValue(node, name, value) {
|
||||
const widget = findWidget(node, name);
|
||||
if (!widget) return;
|
||||
widget.value = value;
|
||||
widget.callback?.(value);
|
||||
}
|
||||
|
||||
function requestNodeRedraw(node) {
|
||||
node?.setDirtyCanvas?.(true, true);
|
||||
app?.graph?.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function getVisibleWidgetHeight(node, widget) {
|
||||
const width = node.size?.[0] || 320;
|
||||
if (widget.hidden || widget.type === "hidden") return 0;
|
||||
if (typeof widget.computeSize === "function") {
|
||||
const size = widget.computeSize(width);
|
||||
if (Array.isArray(size) && Number.isFinite(size[1])) {
|
||||
return size[1];
|
||||
}
|
||||
}
|
||||
return globalThis.LiteGraph?.NODE_WIDGET_HEIGHT ?? 20;
|
||||
}
|
||||
|
||||
function getTopWidgetsHeight(node) {
|
||||
let height = (globalThis.LiteGraph?.NODE_TITLE_HEIGHT ?? 30) + 8;
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (!widget) continue;
|
||||
height += getVisibleWidgetHeight(node, widget) + 4;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
function getPreviewRect(node) {
|
||||
const nodeWidth = Math.max(node.size?.[0] || 320, 320);
|
||||
const nodeHeight = Math.max(node.size?.[1] || 420, 420);
|
||||
const topHeight = getTopWidgetsHeight(node);
|
||||
const availableWidth = nodeWidth - 24;
|
||||
const availableHeight = nodeHeight - topHeight - 16;
|
||||
const side = Math.max(Math.min(availableWidth, availableHeight), 160);
|
||||
return {
|
||||
x: (nodeWidth - side) / 2,
|
||||
y: topHeight,
|
||||
side,
|
||||
};
|
||||
}
|
||||
|
||||
function getNodePose(node) {
|
||||
return {
|
||||
azimuth: normalizeAzimuth(getWidgetValue(node, "azimuth", 0)),
|
||||
elevation: clamp(getWidgetValue(node, "elevation", 45), -30, 90),
|
||||
intensity: clamp(getWidgetValue(node, "intensity", 1.5), INTENSITY_MIN, INTENSITY_MAX),
|
||||
};
|
||||
}
|
||||
|
||||
function projectPoint(projection, point) {
|
||||
const view = vec3Sub(point, projection.camPos);
|
||||
const xCam = vec3Dot(view, projection.right);
|
||||
const yCam = vec3Dot(view, projection.up);
|
||||
const zCam = vec3Dot(view, projection.forward);
|
||||
if (zCam <= 0.05) return null;
|
||||
return {
|
||||
x: projection.cx + (xCam / zCam) * projection.focal,
|
||||
y: projection.cy - (yCam / zCam) * projection.focal,
|
||||
z: zCam,
|
||||
};
|
||||
}
|
||||
|
||||
function getAzimuthHandlePoint(rect, center2D, azimuthDeg) {
|
||||
const azimuthRad = toRadians(azimuthDeg);
|
||||
if (!rect || !center2D) return null;
|
||||
const rx = rect.side * AZIMUTH_RING_RX;
|
||||
const ry = rect.side * AZIMUTH_RING_RY;
|
||||
return {
|
||||
// azimuth = 0 starts at the bottom-center of the ring.
|
||||
x: center2D.x + rx * Math.sin(azimuthRad),
|
||||
y: center2D.y + ry * Math.cos(azimuthRad),
|
||||
};
|
||||
}
|
||||
|
||||
function getIntensityAxisEndPoint(center2D, elevationArcCenter2D) {
|
||||
if (!center2D || !elevationArcCenter2D) return null;
|
||||
return {
|
||||
x: center2D.x + (elevationArcCenter2D.x - center2D.x) * INTENSITY_AXIS_SCALE,
|
||||
y: center2D.y + (elevationArcCenter2D.y - center2D.y) * INTENSITY_AXIS_SCALE,
|
||||
};
|
||||
}
|
||||
|
||||
function solveAzimuthFromHandlePosition(state, localPos) {
|
||||
if (!state || !Array.isArray(localPos) || localPos.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [targetX, targetY] = localPos;
|
||||
let bestAzimuth = state.pose.azimuth;
|
||||
let bestDistanceSq = Infinity;
|
||||
|
||||
for (let azimuth = 0; azimuth < 360; azimuth += 1) {
|
||||
const handle = getAzimuthHandlePoint(state.rect, state.center2D, azimuth);
|
||||
if (!handle) continue;
|
||||
const dx = handle.x - targetX;
|
||||
const dy = handle.y - targetY;
|
||||
const distanceSq = dx * dx + dy * dy;
|
||||
if (distanceSq < bestDistanceSq) {
|
||||
bestDistanceSq = distanceSq;
|
||||
bestAzimuth = azimuth;
|
||||
}
|
||||
}
|
||||
|
||||
return bestAzimuth;
|
||||
}
|
||||
|
||||
function screenPointToRay(projection, localPos) {
|
||||
if (!projection || !Array.isArray(localPos) || localPos.length < 2) return null;
|
||||
const [x, y] = localPos;
|
||||
const nx = (x - projection.cx) / projection.focal;
|
||||
const ny = (projection.cy - y) / projection.focal;
|
||||
const dir = vec3Norm(
|
||||
vec3Add(
|
||||
vec3Add(vec3Mul(projection.right, nx), vec3Mul(projection.up, ny)),
|
||||
projection.forward
|
||||
)
|
||||
);
|
||||
return { origin: projection.camPos, dir };
|
||||
}
|
||||
|
||||
function rayPlaneIntersection(ray, planeNormal, planePoint) {
|
||||
if (!ray) return null;
|
||||
const denom = vec3Dot(ray.dir, planeNormal);
|
||||
if (Math.abs(denom) < 1e-6) return null;
|
||||
const t = vec3Dot(vec3Sub(planePoint, ray.origin), planeNormal) / denom;
|
||||
if (!Number.isFinite(t) || t <= 0) return null;
|
||||
return vec3Add(ray.origin, vec3Mul(ray.dir, t));
|
||||
}
|
||||
|
||||
function drawRoundClip(ctx, x, y, width, height, radius) {
|
||||
ctx.beginPath();
|
||||
if (typeof ctx.roundRect === "function") {
|
||||
ctx.roundRect(x, y, width, height, radius);
|
||||
} else {
|
||||
ctx.rect(x, y, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
function drawHandle(ctx, x, y, radius, fill, glow, isActive = false) {
|
||||
ctx.save();
|
||||
const glowRadius = isActive ? radius * 2.2 : radius * 1.7;
|
||||
const gradient = ctx.createRadialGradient(x, y, radius * 0.25, x, y, glowRadius);
|
||||
gradient.addColorStop(0, glow);
|
||||
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, glowRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = fill;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, isActive ? radius * 1.08 : radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.45)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x - radius * 0.25, y - radius * 0.3, radius * 0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPolyline(ctx, points, color, width) {
|
||||
const valid = points.filter(Boolean);
|
||||
if (valid.length < 2) return;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(valid[0].x, valid[0].y);
|
||||
for (let i = 1; i < valid.length; i += 1) {
|
||||
ctx.lineTo(valid[i].x, valid[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function buildOverlayState(node, rect) {
|
||||
const pose = getNodePose(node);
|
||||
const center = OVERLAY.center;
|
||||
const azimuthRad = toRadians(pose.azimuth);
|
||||
const elevationRad = toRadians(pose.elevation);
|
||||
|
||||
const cameraPos = OVERLAY.cameraPos;
|
||||
const cameraTarget = OVERLAY.cameraTarget;
|
||||
const forward = vec3Norm(vec3Sub(cameraTarget, cameraPos));
|
||||
const right = vec3Norm(vec3Cross(forward, { x: 0, y: 1, z: 0 }));
|
||||
const up = vec3Cross(right, forward);
|
||||
|
||||
const previewCx = rect.x + rect.side * 0.5;
|
||||
const previewCy = rect.y + rect.side * 0.5;
|
||||
const focal = (rect.side * 0.5) / Math.tan(toRadians(OVERLAY.fov) * 0.5);
|
||||
|
||||
const projection = {
|
||||
camPos: cameraPos,
|
||||
forward,
|
||||
right,
|
||||
up,
|
||||
focal,
|
||||
cx: previewCx,
|
||||
cy: previewCy,
|
||||
};
|
||||
|
||||
const lightWorld = {
|
||||
x: OVERLAY.lightVisualRadius * Math.cos(elevationRad) * Math.sin(azimuthRad),
|
||||
y: OVERLAY.lightVisualRadius * Math.sin(elevationRad),
|
||||
z: OVERLAY.lightVisualRadius * Math.cos(elevationRad) * Math.cos(azimuthRad),
|
||||
};
|
||||
|
||||
const elevationHandleWorld = {
|
||||
x: OVERLAY.elevationPlaneX,
|
||||
y: OVERLAY.elevationRadius * Math.sin(elevationRad),
|
||||
z: OVERLAY.elevationRadius * Math.cos(elevationRad),
|
||||
};
|
||||
|
||||
const intensityNorm = (pose.intensity - INTENSITY_MIN) / (INTENSITY_MAX - INTENSITY_MIN);
|
||||
const intensityT =
|
||||
INTENSITY_T_MIN + (1 - intensityNorm) * (INTENSITY_T_MAX - INTENSITY_T_MIN);
|
||||
|
||||
const ringPoints = [];
|
||||
|
||||
const elevationArcPoints = [];
|
||||
for (let i = 0; i <= 48; i += 1) {
|
||||
const angle = toRadians(-30 + (120 * i) / 48);
|
||||
elevationArcPoints.push(
|
||||
projectPoint(projection, {
|
||||
x: OVERLAY.elevationPlaneX,
|
||||
y: OVERLAY.elevationRadius * Math.sin(angle),
|
||||
z: OVERLAY.elevationRadius * Math.cos(angle),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const center2D = projectPoint(projection, center);
|
||||
const light2D = projectPoint(projection, lightWorld);
|
||||
const elevationHandle2D = projectPoint(projection, elevationHandleWorld);
|
||||
const elevationArcCenter2D = projectPoint(projection, {
|
||||
x: OVERLAY.elevationPlaneX,
|
||||
y: center.y,
|
||||
z: center.z,
|
||||
});
|
||||
|
||||
if (!center2D || !light2D || !elevationHandle2D || !elevationArcCenter2D) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intensityAxisEnd2D = getIntensityAxisEndPoint(center2D, elevationArcCenter2D);
|
||||
if (!intensityAxisEnd2D) {
|
||||
return null;
|
||||
}
|
||||
const intensityHandle2D = {
|
||||
x: lerp(center2D.x, intensityAxisEnd2D.x, intensityT),
|
||||
y: lerp(center2D.y, intensityAxisEnd2D.y, intensityT),
|
||||
};
|
||||
|
||||
const azimuthHandle2D = getAzimuthHandlePoint(rect, center2D, pose.azimuth);
|
||||
if (!azimuthHandle2D) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ringPoints.length = 0;
|
||||
for (let i = 0; i <= 80; i += 1) {
|
||||
const point = getAzimuthHandlePoint(rect, center2D, (360 * i) / 80);
|
||||
if (point) {
|
||||
ringPoints.push(point);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rect,
|
||||
pose,
|
||||
centerWorld: center,
|
||||
projection,
|
||||
center2D,
|
||||
light2D,
|
||||
elevationArcCenter2D,
|
||||
intensityAxisEnd2D,
|
||||
ringPoints,
|
||||
elevationArcPoints,
|
||||
handles: {
|
||||
azimuth: { x: azimuthHandle2D.x, y: azimuthHandle2D.y, r: 9 },
|
||||
elevation: { x: elevationHandle2D.x, y: elevationHandle2D.y, r: 9 },
|
||||
intensity: { x: intensityHandle2D.x, y: intensityHandle2D.y, r: 9 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function drawOverlay(ctx, node, state) {
|
||||
const isActive = node._tyOrbitDragMode;
|
||||
const rect = state.rect;
|
||||
|
||||
// The gizmo overlay is preview-only. Nothing drawn here is written into render_b64.
|
||||
ctx.save();
|
||||
drawRoundClip(ctx, rect.x, rect.y, rect.side, rect.side, 8);
|
||||
ctx.clip();
|
||||
|
||||
drawPolyline(ctx, state.ringPoints, "rgba(233, 61, 130, 0.88)", 3.2);
|
||||
drawPolyline(ctx, state.elevationArcPoints, "rgba(0, 255, 208, 0.88)", 3.2);
|
||||
|
||||
ctx.strokeStyle = "rgba(255, 184, 0, 0.88)";
|
||||
ctx.lineWidth = 2.8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(state.center2D.x, state.center2D.y);
|
||||
ctx.lineTo(state.intensityAxisEnd2D.x, state.intensityAxisEnd2D.y);
|
||||
ctx.stroke();
|
||||
|
||||
drawHandle(
|
||||
ctx,
|
||||
state.handles.azimuth.x,
|
||||
state.handles.azimuth.y,
|
||||
state.handles.azimuth.r,
|
||||
"#E93D82",
|
||||
"rgba(233, 61, 130, 0.36)",
|
||||
isActive === "azimuth"
|
||||
);
|
||||
drawHandle(
|
||||
ctx,
|
||||
state.handles.elevation.x,
|
||||
state.handles.elevation.y,
|
||||
state.handles.elevation.r,
|
||||
"#00FFD0",
|
||||
"rgba(0, 255, 208, 0.36)",
|
||||
isActive === "elevation"
|
||||
);
|
||||
drawHandle(
|
||||
ctx,
|
||||
state.handles.intensity.x,
|
||||
state.handles.intensity.y,
|
||||
state.handles.intensity.r,
|
||||
"#FFB800",
|
||||
"rgba(255, 184, 0, 0.38)",
|
||||
isActive === "intensity"
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPreview(node, ctx) {
|
||||
const rect = getPreviewRect(node);
|
||||
node._tyOrbitOverlayState = buildOverlayState(node, rect);
|
||||
|
||||
ctx.save();
|
||||
drawRoundClip(ctx, rect.x, rect.y, rect.side, rect.side, 8);
|
||||
ctx.clip();
|
||||
|
||||
if (node._tyOrbitReady && node._tyOrbitCanvas) {
|
||||
ctx.drawImage(node._tyOrbitCanvas, rect.x, rect.y, rect.side, rect.side);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
if (node._tyOrbitOverlayState) {
|
||||
drawOverlay(ctx, node, node._tyOrbitOverlayState);
|
||||
}
|
||||
}
|
||||
|
||||
function hitTest(node, localPos) {
|
||||
const state = node._tyOrbitOverlayState;
|
||||
if (!state || !Array.isArray(localPos) || localPos.length < 2) return null;
|
||||
const [x, y] = localPos;
|
||||
|
||||
for (const [name, handle] of Object.entries(state.handles)) {
|
||||
const dx = x - handle.x;
|
||||
const dy = y - handle.y;
|
||||
if (dx * dx + dy * dy <= handle.r * handle.r) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
const rect = state.rect;
|
||||
if (x >= rect.x && x <= rect.x + rect.side && y >= rect.y && y <= rect.y + rect.side) {
|
||||
return "preview";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePose(node, nextPose) {
|
||||
setWidgetValue(node, "azimuth", Math.round(normalizeAzimuth(nextPose.azimuth)));
|
||||
setWidgetValue(node, "elevation", Math.round(clamp(nextPose.elevation, -30, 90)));
|
||||
setWidgetValue(
|
||||
node,
|
||||
"intensity",
|
||||
Math.round(clamp(nextPose.intensity, INTENSITY_MIN, INTENSITY_MAX) * 10) / 10
|
||||
);
|
||||
requestNodeRedraw(node);
|
||||
}
|
||||
|
||||
function updateAzimuth(node, localPos) {
|
||||
const state = node._tyOrbitOverlayState;
|
||||
const azimuth = solveAzimuthFromHandlePosition(state, localPos);
|
||||
if (azimuth === null) return;
|
||||
const pose = getNodePose(node);
|
||||
writePose(node, { ...pose, azimuth });
|
||||
}
|
||||
|
||||
function updateElevation(node, localPos) {
|
||||
const state = node._tyOrbitOverlayState;
|
||||
if (!state) return;
|
||||
|
||||
let elevation = null;
|
||||
const ray = screenPointToRay(state.projection, localPos);
|
||||
const hit = rayPlaneIntersection(
|
||||
ray,
|
||||
{ x: 1, y: 0, z: 0 },
|
||||
{ x: OVERLAY.elevationPlaneX, y: 0, z: 0 }
|
||||
);
|
||||
if (hit) {
|
||||
elevation = clamp(toDegrees(Math.atan2(hit.y, hit.z)), -30, 90);
|
||||
}
|
||||
|
||||
if (elevation === null && Array.isArray(localPos) && localPos.length >= 2) {
|
||||
const [x, y] = localPos;
|
||||
const dx = x - state.elevationArcCenter2D.x;
|
||||
const dy = state.elevationArcCenter2D.y - y;
|
||||
elevation = clamp(toDegrees(Math.atan2(dy, dx)), -30, 90);
|
||||
}
|
||||
|
||||
if (elevation === null) return;
|
||||
const pose = getNodePose(node);
|
||||
writePose(node, { ...pose, elevation });
|
||||
}
|
||||
|
||||
function updateIntensity(node, localPos) {
|
||||
const state = node._tyOrbitOverlayState;
|
||||
if (!state || !Array.isArray(localPos) || localPos.length < 2) return;
|
||||
const [x, y] = localPos;
|
||||
|
||||
const axisX = state.intensityAxisEnd2D.x - state.center2D.x;
|
||||
const axisY = state.intensityAxisEnd2D.y - state.center2D.y;
|
||||
const axisLenSq = axisX * axisX + axisY * axisY;
|
||||
if (axisLenSq < 1) return;
|
||||
|
||||
const proj =
|
||||
((x - state.center2D.x) * axisX + (y - state.center2D.y) * axisY) / axisLenSq;
|
||||
const t = clamp(proj, INTENSITY_T_MIN, INTENSITY_T_MAX);
|
||||
const norm = 1 - (t - INTENSITY_T_MIN) / (INTENSITY_T_MAX - INTENSITY_T_MIN);
|
||||
const intensity = INTENSITY_MIN + norm * (INTENSITY_MAX - INTENSITY_MIN);
|
||||
|
||||
const pose = getNodePose(node);
|
||||
writePose(node, { ...pose, intensity });
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.TYOrbitSphereLight",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "TYOrbitSphereLightNode") return;
|
||||
|
||||
await loadThree();
|
||||
|
||||
const renderCtx = buildCleanRenderScene();
|
||||
node._tyOrbitRenderCtx = renderCtx;
|
||||
node._tyOrbitCanvas = renderCtx.canvas;
|
||||
node._tyOrbitReady = false;
|
||||
node._tyOrbitOverlayState = null;
|
||||
node._tyOrbitDragMode = null;
|
||||
|
||||
const doRender = () => {
|
||||
const azimuth = getWidgetValue(node, "azimuth", 0);
|
||||
const elevation = getWidgetValue(node, "elevation", 45);
|
||||
const intensity = getWidgetValue(node, "intensity", 1.5);
|
||||
|
||||
const az = toRadians(azimuth);
|
||||
const el = toRadians(elevation);
|
||||
const radius = 10;
|
||||
|
||||
renderCtx.dirLight.position.set(
|
||||
radius * Math.cos(el) * Math.sin(az),
|
||||
radius * Math.sin(el),
|
||||
radius * Math.cos(el) * Math.cos(az)
|
||||
);
|
||||
renderCtx.dirLight.intensity = intensity;
|
||||
renderCtx.renderer.shadowMap.needsUpdate = true;
|
||||
renderCtx.renderer.render(renderCtx.scene, renderCtx.camera);
|
||||
|
||||
const renderWidget = findWidget(node, "render_b64");
|
||||
if (renderWidget) {
|
||||
renderWidget.value = renderCtx.canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
node._tyOrbitReady = true;
|
||||
requestNodeRedraw(node);
|
||||
};
|
||||
|
||||
let renderTimer = null;
|
||||
const scheduleRender = () => {
|
||||
clearTimeout(renderTimer);
|
||||
renderTimer = setTimeout(doRender, 30);
|
||||
};
|
||||
|
||||
const hookSliders = () => {
|
||||
for (const name of ["azimuth", "elevation", "intensity"]) {
|
||||
const widget = findWidget(node, name);
|
||||
if (!widget || widget._tyOrbitHooked) continue;
|
||||
widget._tyOrbitHooked = true;
|
||||
const originalCallback = widget.callback;
|
||||
widget.callback = function callback(value, ...args) {
|
||||
originalCallback?.call(this, value, ...args);
|
||||
scheduleRender();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSelectionIfNeeded = () => {
|
||||
if (!node.is_selected || !app?.canvas?.selectNode) return;
|
||||
app.canvas.selectNode(null);
|
||||
app.canvas.selectNode(node, false);
|
||||
};
|
||||
|
||||
const hideRenderWidget = () => {
|
||||
const widget = findWidget(node, "render_b64");
|
||||
if (!widget || widget._tyOrbitHidden) return;
|
||||
widget._tyOrbitHidden = true;
|
||||
widget.computeSize = () => [0, 0];
|
||||
widget.draw = () => {};
|
||||
widget.type = "hidden";
|
||||
|
||||
const input = node.inputs?.find((entry) => entry?.name === "render_b64");
|
||||
if (input) {
|
||||
input.hidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
const originalOnDrawForeground = node.onDrawForeground?.bind(node);
|
||||
node.onDrawForeground = function onDrawForeground(ctx) {
|
||||
drawPreview(this, ctx);
|
||||
return originalOnDrawForeground?.(ctx);
|
||||
};
|
||||
|
||||
const originalOnMouseDown = node.onMouseDown?.bind(node);
|
||||
node.onMouseDown = function onMouseDown(event, localPos, graphCanvas) {
|
||||
const hit = hitTest(this, localPos);
|
||||
if (hit === "azimuth") {
|
||||
this._tyOrbitDragMode = "azimuth";
|
||||
updateAzimuth(this, localPos);
|
||||
return true;
|
||||
}
|
||||
if (hit === "elevation") {
|
||||
this._tyOrbitDragMode = "elevation";
|
||||
updateElevation(this, localPos);
|
||||
return true;
|
||||
}
|
||||
if (hit === "intensity") {
|
||||
this._tyOrbitDragMode = "intensity";
|
||||
updateIntensity(this, localPos);
|
||||
return true;
|
||||
}
|
||||
return originalOnMouseDown?.(event, localPos, graphCanvas);
|
||||
};
|
||||
|
||||
const originalOnMouseMove = node.onMouseMove?.bind(node);
|
||||
node.onMouseMove = function onMouseMove(event, localPos, graphCanvas) {
|
||||
if (this._tyOrbitDragMode && event?.buttons === 0) {
|
||||
this._tyOrbitDragMode = null;
|
||||
}
|
||||
if (this._tyOrbitDragMode === "azimuth") {
|
||||
updateAzimuth(this, localPos);
|
||||
return true;
|
||||
}
|
||||
if (this._tyOrbitDragMode === "elevation") {
|
||||
updateElevation(this, localPos);
|
||||
return true;
|
||||
}
|
||||
if (this._tyOrbitDragMode === "intensity") {
|
||||
updateIntensity(this, localPos);
|
||||
return true;
|
||||
}
|
||||
return originalOnMouseMove?.(event, localPos, graphCanvas);
|
||||
};
|
||||
|
||||
const originalOnMouseUp = node.onMouseUp?.bind(node);
|
||||
node.onMouseUp = function onMouseUp(...args) {
|
||||
this._tyOrbitDragMode = null;
|
||||
return originalOnMouseUp?.(...args);
|
||||
};
|
||||
|
||||
const originalOnMouseWheel = node.onMouseWheel?.bind(node);
|
||||
node.onMouseWheel = function onMouseWheel(event, localPos, graphCanvas) {
|
||||
if (hitTest(this, localPos) === "preview") {
|
||||
return true;
|
||||
}
|
||||
return originalOnMouseWheel?.(event, localPos, graphCanvas);
|
||||
};
|
||||
|
||||
const originalOnResize = node.onResize?.bind(node);
|
||||
node.onResize = function onResize(size) {
|
||||
size[0] = Math.max(size[0], 340);
|
||||
const previewSide = clamp(size[0] - 24, 180, 360);
|
||||
const minHeight = getTopWidgetsHeight(this) + previewSide + 16;
|
||||
size[1] = Math.max(size[1], minHeight);
|
||||
requestNodeRedraw(this);
|
||||
return originalOnResize?.(size);
|
||||
};
|
||||
|
||||
const originalOnRemoved = node.onRemoved?.bind(node);
|
||||
node.onRemoved = function onRemoved(...args) {
|
||||
clearTimeout(renderTimer);
|
||||
this._tyOrbitRenderCtx?.renderer?.dispose?.();
|
||||
this._tyOrbitRenderCtx = null;
|
||||
this._tyOrbitCanvas = null;
|
||||
this._tyOrbitOverlayState = null;
|
||||
return originalOnRemoved?.(...args);
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
hideRenderWidget();
|
||||
hookSliders();
|
||||
doRender();
|
||||
const rect = getPreviewRect(node);
|
||||
const minHeight = getTopWidgetsHeight(node) + rect.side + 16;
|
||||
node.setSize?.([Math.max(node.size?.[0] || 340, 340), Math.max(node.size?.[1] || minHeight, minHeight)]);
|
||||
}, 50);
|
||||
|
||||
setTimeout(() => {
|
||||
hideRenderWidget();
|
||||
hookSliders();
|
||||
requestNodeRedraw(node);
|
||||
refreshSelectionIfNeeded();
|
||||
}, 300);
|
||||
},
|
||||
});
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user