// 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 | undefined; function loadThree(): Promise { 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(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 (
e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ position: "absolute", inset: 0 }} /> {status !== "ready" ? (
{status === "error" ? "全景图读取失败,请换 2:1 的 JPG/PNG 全景图" : "正在加载全景…"}
) : null}
); } // --------------------------------------------------------------------------- // 空态:两个明确按钮——「上传全景图」和「AI 生成」(打开下方内置生成面板)。 // 整块不可点,避免误触;拖动节点照常。 // --------------------------------------------------------------------------- function PanoramaEmpty({ ctx }: { ctx: CanvasNodeContext }) { const fileRef = useRef(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 (
🌐
e.stopPropagation()} style={{ display: "flex", gap: 10 }}> onPick(e.target.files?.[0])} />
支持 2:1 等距柱状全景图
); } 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 ; return ; } 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: "" }), }, ] : []), ]; }, }, ], });