mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
312 lines
15 KiB
TypeScript
312 lines
15 KiB
TypeScript
// 3D 全景节点:等距柱状(equirectangular)360° 全景查看器。
|
|
// 三种取图方式:① 从上游图片节点自动取图 ② 本地上传 ③ AI 生成(复用宿主内置生成面板)。
|
|
// three.js 从 CDN 按需加载,不打进 bundle。AI 生成通过 useBuiltinPanel 声明,结果写回本节点。
|
|
import { definePlugin, useCallback, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
|
import type { CanvasNodeContentProps, CanvasNodeContext } from "@infinite-canvas/plugin-sdk";
|
|
|
|
// three 从 CDN 动态加载(env.d.ts 里声明为 any),不打进 bundle
|
|
let threePromise: Promise<any> | undefined;
|
|
function loadThree(): Promise<any> {
|
|
if (!threePromise) threePromise = import("https://esm.sh/three@0.180.0");
|
|
return threePromise;
|
|
}
|
|
|
|
// AI 生成的固定前置提示词:约束模型产出可用于球面贴图的 equirectangular 全景图。
|
|
// 用户只需描述场景内容,这段会自动拼在前面,保证 2:1、无缝、无畸变文字等。
|
|
const PANORAMA_SYSTEM_PROMPT =
|
|
"A seamless 360-degree equirectangular panorama, 2:1 aspect ratio, full spherical VR photo, " +
|
|
"horizontally wrapping seamlessly at the left and right edges, no visible seam, no distortion artifacts, " +
|
|
"even horizon, no text, no watermark. Scene: ";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 全景查看器:球体内壁贴等距柱状纹理,指针拖动转视角、滚轮缩放 fov。
|
|
// 自适应容器尺寸(ResizeObserver);超过 GPU 上限的大图先降采样,避免加载失败。
|
|
// ---------------------------------------------------------------------------
|
|
function PanoramaViewer({ src, ctx }: { src: string; ctx: CanvasNodeContext }) {
|
|
const mountRef = useRef<HTMLDivElement>(null);
|
|
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
|
|
|
useEffect(() => {
|
|
const mount = mountRef.current;
|
|
if (!mount || !src) return;
|
|
let disposed = false;
|
|
let frame = 0;
|
|
let renderer: any = null;
|
|
let geometry: any = null;
|
|
let material: any = null;
|
|
let texture: any = null;
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
let cleanupEvents = () => {};
|
|
|
|
setStatus("loading");
|
|
|
|
loadThree()
|
|
.then((THREE: any) => {
|
|
if (disposed || !mountRef.current) return;
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(70, 1, 1, 1100);
|
|
const target = new THREE.Vector3();
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
renderer.setClearColor(0x000000, 1);
|
|
const canvas: HTMLCanvasElement = renderer.domElement;
|
|
canvas.style.width = "100%";
|
|
canvas.style.height = "100%";
|
|
canvas.style.display = "block";
|
|
canvas.style.cursor = "grab";
|
|
mount.appendChild(canvas);
|
|
const maxTextureSide = Math.min(renderer.capabilities.maxTextureSize || 4096, 4096);
|
|
|
|
geometry = new THREE.SphereGeometry(500, 96, 64);
|
|
geometry.scale(-1, 1, 1); // 翻转法线,从球心向内看
|
|
material = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
|
scene.add(new THREE.Mesh(geometry, material));
|
|
|
|
// 视角状态
|
|
let lon = 0;
|
|
let lat = 0;
|
|
let fov = 70;
|
|
let dragging = false;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let startLon = 0;
|
|
let startLat = 0;
|
|
|
|
const onDown = (event: PointerEvent) => {
|
|
dragging = true;
|
|
startX = event.clientX;
|
|
startY = event.clientY;
|
|
startLon = lon;
|
|
startLat = lat;
|
|
canvas.style.cursor = "grabbing";
|
|
canvas.setPointerCapture?.(event.pointerId);
|
|
};
|
|
const onMove = (event: PointerEvent) => {
|
|
if (!dragging) return;
|
|
lon = startLon - (event.clientX - startX) * 0.12;
|
|
lat = Math.max(-84, Math.min(84, startLat + (event.clientY - startY) * 0.12));
|
|
};
|
|
const onUp = () => {
|
|
dragging = false;
|
|
canvas.style.cursor = "grab";
|
|
};
|
|
const onWheel = (event: WheelEvent) => {
|
|
event.preventDefault();
|
|
fov = Math.max(38, Math.min(86, fov + event.deltaY * 0.035));
|
|
};
|
|
canvas.addEventListener("pointerdown", onDown);
|
|
canvas.addEventListener("pointermove", onMove);
|
|
canvas.addEventListener("pointerup", onUp);
|
|
canvas.addEventListener("pointercancel", onUp);
|
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
cleanupEvents = () => {
|
|
canvas.removeEventListener("pointerdown", onDown);
|
|
canvas.removeEventListener("pointermove", onMove);
|
|
canvas.removeEventListener("pointerup", onUp);
|
|
canvas.removeEventListener("pointercancel", onUp);
|
|
canvas.removeEventListener("wheel", onWheel);
|
|
};
|
|
|
|
const resize = () => {
|
|
if (!renderer || !mountRef.current) return;
|
|
const width = Math.max(1, mountRef.current.clientWidth);
|
|
const height = Math.max(1, mountRef.current.clientHeight);
|
|
renderer.setSize(width, height, false);
|
|
camera.aspect = width / height;
|
|
camera.updateProjectionMatrix();
|
|
};
|
|
resizeObserver = new ResizeObserver(resize);
|
|
resizeObserver.observe(mount);
|
|
resize();
|
|
|
|
const render = () => {
|
|
if (disposed || !renderer) return;
|
|
if (!dragging) lon += 0.02; // 缓慢自转
|
|
camera.fov = fov;
|
|
camera.updateProjectionMatrix();
|
|
const phi = THREE.MathUtils.degToRad(90 - lat);
|
|
const theta = THREE.MathUtils.degToRad(lon);
|
|
target.set(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
|
|
camera.lookAt(target);
|
|
renderer.render(scene, camera);
|
|
frame = requestAnimationFrame(render);
|
|
};
|
|
render();
|
|
|
|
// 载入纹理:大图降采样到 GPU 上限,避免超限导致黑屏/失败
|
|
const image = new Image();
|
|
image.crossOrigin = "anonymous";
|
|
image.onload = () => {
|
|
if (disposed || !material) return;
|
|
try {
|
|
const w = image.naturalWidth || image.width;
|
|
const h = image.naturalHeight || image.height;
|
|
const scale = Math.min(1, maxTextureSide / w, maxTextureSide / h);
|
|
let source: HTMLImageElement | HTMLCanvasElement = image;
|
|
if (scale < 1) {
|
|
const c = document.createElement("canvas");
|
|
c.width = Math.max(1, Math.round(w * scale));
|
|
c.height = Math.max(1, Math.round(h * scale));
|
|
c.getContext("2d")?.drawImage(image, 0, 0, c.width, c.height);
|
|
source = c;
|
|
}
|
|
texture = source instanceof HTMLCanvasElement ? new THREE.CanvasTexture(source) : new THREE.Texture(source);
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
texture.minFilter = THREE.LinearFilter;
|
|
texture.needsUpdate = true;
|
|
material.map = texture;
|
|
material.needsUpdate = true;
|
|
setStatus("ready");
|
|
} catch {
|
|
setStatus("error");
|
|
}
|
|
};
|
|
image.onerror = () => !disposed && setStatus("error");
|
|
image.src = src;
|
|
})
|
|
.catch(() => !disposed && setStatus("error"));
|
|
|
|
return () => {
|
|
disposed = true;
|
|
cleanupEvents();
|
|
resizeObserver?.disconnect();
|
|
if (frame) cancelAnimationFrame(frame);
|
|
texture?.dispose?.();
|
|
material?.dispose?.();
|
|
geometry?.dispose?.();
|
|
if (renderer) {
|
|
renderer.domElement?.remove?.();
|
|
renderer.dispose?.();
|
|
renderer.forceContextLoss?.();
|
|
}
|
|
};
|
|
}, [src]);
|
|
|
|
return (
|
|
<div style={{ position: "relative", height: "100%", width: "100%", overflow: "hidden", borderRadius: 16, background: "#000" }}>
|
|
<div ref={mountRef} data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ position: "absolute", inset: 0 }} />
|
|
{status !== "ready" ? (
|
|
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", pointerEvents: "none", color: ctx.theme.node.placeholder, fontSize: 13, background: status === "error" ? "rgba(0,0,0,0.6)" : `linear-gradient(135deg, ${ctx.theme.node.fill}, #000)` }}>
|
|
{status === "error" ? "全景图读取失败,请换 2:1 的 JPG/PNG 全景图" : "正在加载全景…"}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 空态:两个明确按钮——「上传全景图」和「AI 生成」(打开下方内置生成面板)。
|
|
// 整块不可点,避免误触;拖动节点照常。
|
|
// ---------------------------------------------------------------------------
|
|
function PanoramaEmpty({ ctx }: { ctx: CanvasNodeContext }) {
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 本地上传:读为 dataURL 写入自身 metadata.content
|
|
const onPick = useCallback(
|
|
(file: File | undefined) => {
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => ctx.updateMetadata({ content: String(reader.result || "") });
|
|
reader.readAsDataURL(file);
|
|
},
|
|
[ctx],
|
|
);
|
|
|
|
const t = ctx.theme;
|
|
const baseBtn = {
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
cursor: "pointer",
|
|
borderRadius: 999,
|
|
padding: "9px 18px",
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
border: `1px solid ${t.node.stroke}`,
|
|
background: t.node.fill,
|
|
color: t.node.text,
|
|
} as const;
|
|
|
|
return (
|
|
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, padding: 20, borderRadius: 16, boxSizing: "border-box", background: t.node.fill }}>
|
|
<span style={{ fontSize: 34 }}>🌐</span>
|
|
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ display: "flex", gap: 10 }}>
|
|
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => onPick(e.target.files?.[0])} />
|
|
<button type="button" style={baseBtn} onClick={() => fileRef.current?.click()}>
|
|
🖼 上传全景图
|
|
</button>
|
|
<button type="button" style={{ ...baseBtn, border: "none", background: t.toolbar.activeBg, color: t.toolbar.activeText }} onClick={() => ctx.openPanel()}>
|
|
✨ AI 生成
|
|
</button>
|
|
</div>
|
|
<span style={{ fontSize: 12, color: t.node.placeholder, textAlign: "center" }}>支持 2:1 等距柱状全景图</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PanoramaContent({ ctx }: CanvasNodeContentProps) {
|
|
const upstreamImage = ctx
|
|
.getUpstream()
|
|
.map((node) => node.metadata?.content)
|
|
.find((content): content is string => typeof content === "string" && Boolean(content));
|
|
const source = (typeof ctx.node.metadata?.content === "string" ? ctx.node.metadata.content : "") || upstreamImage || "";
|
|
|
|
if (!source) return <PanoramaEmpty ctx={ctx} />;
|
|
return <PanoramaViewer src={source} ctx={ctx} />;
|
|
}
|
|
|
|
export default definePlugin({
|
|
id: "panorama",
|
|
name: "3D 全景节点",
|
|
version: "1.1.0",
|
|
description: "查看 360° 等距柱状全景图,支持上传与 AI 生成,可从上游图片节点取图",
|
|
nodes: [
|
|
{
|
|
type: "panorama:viewer",
|
|
title: "3D 全景",
|
|
icon: "🌐",
|
|
description: "360° 全景查看器(上传 / AI 生成)",
|
|
defaultSize: { width: 480, height: 300 },
|
|
defaultMetadata: {},
|
|
minimapColor: "#0ea5e9",
|
|
// 宿主自动提供「交互 ⇄ 移动」开关:默认移动(拖动节点),切到交互后可转全景视角
|
|
interactionToggle: true,
|
|
// 复用宿主内置生成面板(模型选择/设置/提示词库完全一致),生成结果写回本节点。
|
|
// 前缀约束模型产出可用于球面贴图的 equirectangular 全景图。
|
|
useBuiltinPanel: { mode: "image", promptPrefix: PANORAMA_SYSTEM_PROMPT, writeBackToSelf: true },
|
|
// 作为上游被消费时,输出自身全景图,供下游节点引用
|
|
resource: (node) => {
|
|
const content = node.metadata?.content;
|
|
return typeof content === "string" && content ? { kind: "image", url: content } : null;
|
|
},
|
|
Content: PanoramaContent,
|
|
// 工具条始终提供「AI 生成」(打开下方内置面板);有图时额外提供「换图」
|
|
toolbar: (ctx) => {
|
|
const hasOwnContent = typeof ctx.node.metadata?.content === "string" && Boolean(ctx.node.metadata?.content);
|
|
return [
|
|
{
|
|
id: "panorama-generate",
|
|
title: "用 AI 生成全景图(打开下方面板)",
|
|
label: "AI 生成",
|
|
icon: "✨",
|
|
onClick: () => ctx.openPanel(),
|
|
},
|
|
...(hasOwnContent
|
|
? [
|
|
{
|
|
id: "panorama-reset",
|
|
title: "清空当前全景图,重新上传",
|
|
label: "换图",
|
|
icon: "🔄",
|
|
onClick: () => ctx.updateMetadata({ content: "" }),
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
},
|
|
},
|
|
],
|
|
});
|