595 lines
22 KiB
JavaScript
595 lines
22 KiB
JavaScript
// 修正:使用绝对路径引用,解决 "Failed to fetch" 问题
|
|
import { app } from "/scripts/app.js";
|
|
|
|
const directions = [
|
|
"front", "front-right quarter", "right side", "back-right quarter",
|
|
"back", "back-left quarter", "left side", "front-left quarter"
|
|
];
|
|
const angles = ["low-angle", "eye-level", "elevated", "high-angle"];
|
|
const shots = ["close-up", "medium shot", "wide shot"];
|
|
|
|
app.registerExtension({
|
|
name: "Rabbit.Prompt.Generator",
|
|
async beforeRegisterNodeDef(nodeDef, nodeData, app) {
|
|
if (nodeData.name === "RabbitCameraSelector") {
|
|
const onNodeCreated = nodeDef.prototype.onNodeCreated;
|
|
nodeDef.prototype.onNodeCreated = function () {
|
|
onNodeCreated?.apply(this, arguments);
|
|
|
|
// 节点设置为左右分栏布局 - 缩小整体高度
|
|
this.setSize([750, 380]);
|
|
const widgetInput = this.widgets[0];
|
|
|
|
// 大幅缩小文本框高度
|
|
widgetInput.computeSize = function() { return [this.width, 35]; };
|
|
if (widgetInput.element) {
|
|
widgetInput.element.style.height = "35px";
|
|
widgetInput.element.style.minHeight = "35px";
|
|
widgetInput.element.style.maxHeight = "35px";
|
|
widgetInput.element.style.fontSize = "11px";
|
|
widgetInput.element.style.padding = "6px 8px";
|
|
widgetInput.element.style.lineHeight = "1.2";
|
|
widgetInput.element.style.resize = "none";
|
|
}
|
|
widgetInput.options = widgetInput.options || {};
|
|
widgetInput.options.height = 35;
|
|
|
|
// === 主容器:左右分栏 ===
|
|
const mainContainer = document.createElement("div");
|
|
mainContainer.style.cssText = `
|
|
display:flex; gap:8px; padding:6px 6px 20px 6px; width:100%; height:calc(100% - 10px);
|
|
box-sizing:border-box; overflow:hidden; min-height:0;
|
|
margin-top:20px;
|
|
`;
|
|
|
|
// === 左侧:控制面板 ===
|
|
const leftPanel = document.createElement("div");
|
|
leftPanel.style.cssText = `
|
|
flex:0 1 45%; display:flex; flex-direction:column; gap:8px;
|
|
min-width:150px; max-width:45%; overflow:hidden;
|
|
`;
|
|
|
|
// 当前选择状态
|
|
let selectedDir = 0;
|
|
let selectedAngle = 1;
|
|
let selectedShot = 1;
|
|
|
|
// 创建下拉按钮
|
|
function createDropdown(label, options, initialIdx, onSelect) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.style.cssText = "position:relative; width:100%; min-width:0; flex-shrink:0;";
|
|
|
|
const btn = document.createElement("button");
|
|
btn.innerText = `${label}: ${options[initialIdx]}`;
|
|
btn.style.cssText = `
|
|
width:100%; padding:8px 10px; background:#2a2a2a; color:#fff;
|
|
border:1px solid #555; border-radius:4px; cursor:pointer;
|
|
font-size:12px; text-align:left; transition:all 0.2s;
|
|
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
box-sizing:border-box; min-height:32px;
|
|
`;
|
|
btn.onmouseenter = () => btn.style.background = "#3a3a3a";
|
|
btn.onmouseleave = () => btn.style.background = "#2a2a2a";
|
|
|
|
const dropdown = document.createElement("div");
|
|
dropdown.style.cssText = `
|
|
display:none; position:absolute; top:100%; left:0; right:0;
|
|
background:#2a2a2a; border:1px solid #555; border-radius:4px;
|
|
margin-top:2px; max-height:180px; overflow-y:auto; z-index:1000;
|
|
box-shadow:0 4px 12px rgba(0,0,0,0.6);
|
|
`;
|
|
|
|
options.forEach((opt, idx) => {
|
|
const item = document.createElement("div");
|
|
item.innerText = opt;
|
|
item.style.cssText = `
|
|
padding:8px 10px; cursor:pointer; font-size:11px;
|
|
color:#ddd; transition:background 0.15s;
|
|
`;
|
|
item.onmouseenter = () => item.style.background = "#444";
|
|
item.onmouseleave = () => item.style.background = "transparent";
|
|
item.onclick = () => {
|
|
btn.innerText = `${label}: ${opt}`;
|
|
dropdown.style.display = "none";
|
|
onSelect(idx);
|
|
};
|
|
dropdown.appendChild(item);
|
|
});
|
|
|
|
btn.onclick = (e) => {
|
|
e.stopPropagation();
|
|
const isOpen = dropdown.style.display === "block";
|
|
document.querySelectorAll('.rabbit-dropdown').forEach(d => d.style.display = "none");
|
|
dropdown.style.display = isOpen ? "none" : "block";
|
|
};
|
|
|
|
dropdown.className = "rabbit-dropdown";
|
|
wrapper.appendChild(btn);
|
|
wrapper.appendChild(dropdown);
|
|
return { wrapper, updateText: (text) => { btn.innerText = `${label}: ${text}`; } };
|
|
}
|
|
|
|
// 关闭所有下拉菜单
|
|
document.addEventListener("click", () => {
|
|
document.querySelectorAll('.rabbit-dropdown').forEach(d => d.style.display = "none");
|
|
});
|
|
|
|
// 三个按钮及其更新函数
|
|
const dirDropdown = createDropdown("Direction", directions, selectedDir, idx => {
|
|
selectedDir = idx;
|
|
updateRabbitView();
|
|
updatePromptFromSelections();
|
|
});
|
|
const angleDropdown = createDropdown("Angle", angles, selectedAngle, idx => {
|
|
selectedAngle = idx;
|
|
updateRabbitView();
|
|
updatePromptFromSelections();
|
|
});
|
|
const shotDropdown = createDropdown("Shot", shots, selectedShot, idx => {
|
|
selectedShot = idx;
|
|
updateRabbitView();
|
|
updatePromptFromSelections();
|
|
});
|
|
|
|
leftPanel.appendChild(dirDropdown.wrapper);
|
|
leftPanel.appendChild(angleDropdown.wrapper);
|
|
leftPanel.appendChild(shotDropdown.wrapper);
|
|
|
|
// 状态提示
|
|
const statusHint = document.createElement("div");
|
|
statusHint.style.cssText = `
|
|
margin-top:auto; padding:8px; background:#1a1a1a;
|
|
border:1px solid #333; border-radius:4px;
|
|
color:#888; font-size:10px; text-align:center; min-height:20px;
|
|
word-break:break-word; overflow:hidden; flex-shrink:0;
|
|
`;
|
|
statusHint.innerText = "Drag 3D rabbit →";
|
|
leftPanel.appendChild(statusHint);
|
|
|
|
// === 右侧:3D 视图 ===
|
|
const rightPanel = document.createElement("div");
|
|
rightPanel.style.cssText = `
|
|
flex:1 1 55%; background:#1a1a1a; border:1px solid #333;
|
|
border-radius:4px; position:relative; overflow:hidden;
|
|
min-width:0; min-height:0;
|
|
`;
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.style.cssText = "width:100%; height:100%; cursor:grab; display:block;";
|
|
rightPanel.appendChild(canvas);
|
|
|
|
const hint = document.createElement("div");
|
|
hint.style.cssText = `
|
|
position:absolute; bottom:6px; left:6px; right:6px;
|
|
text-align:center; color:#888; font-size:10px;
|
|
pointer-events:none; background:rgba(0,0,0,0.85);
|
|
padding:4px; border-radius:3px;
|
|
`;
|
|
hint.innerText = "Drag | Wheel to Zoom";
|
|
rightPanel.appendChild(hint);
|
|
|
|
// 组装主容器
|
|
mainContainer.appendChild(leftPanel);
|
|
mainContainer.appendChild(rightPanel);
|
|
this.addDOMWidget("rabbit_main", "HTML", mainContainer);
|
|
|
|
// 更新canvas尺寸以适应容器
|
|
function updateCanvasSize() {
|
|
const rect = rightPanel.getBoundingClientRect();
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
canvas.width = rect.width;
|
|
canvas.height = rect.height;
|
|
}
|
|
}
|
|
|
|
// 初始化canvas尺寸
|
|
setTimeout(() => updateCanvasSize(), 0);
|
|
|
|
// 监听窗口resize
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
updateCanvasSize();
|
|
});
|
|
resizeObserver.observe(rightPanel);
|
|
|
|
// 更新兔子视角(从按钮选择)
|
|
let updateRabbitFromButtons = null;
|
|
|
|
function updateRabbitView() {
|
|
if (updateRabbitFromButtons) {
|
|
updateRabbitFromButtons(selectedDir, selectedAngle, selectedShot);
|
|
}
|
|
}
|
|
|
|
// 更新提示词
|
|
function updatePromptFromSelections() {
|
|
const text = `<sks> ${directions[selectedDir]} view ${angles[selectedAngle]} shot ${shots[selectedShot]}`;
|
|
widgetInput.value = text;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
statusHint.innerText = "✓ COPIED: " + text;
|
|
statusHint.style.color = "#4ade80";
|
|
setTimeout(() => {
|
|
statusHint.style.color = "#888";
|
|
statusHint.innerText = "Drag 3D rabbit →";
|
|
}, 2000);
|
|
}).catch(() => {
|
|
statusHint.innerText = "✓ " + text;
|
|
statusHint.style.color = "#4ade80";
|
|
setTimeout(() => {
|
|
statusHint.style.color = "#888";
|
|
statusHint.innerText = "Drag 3D rabbit →";
|
|
}, 2000);
|
|
});
|
|
}
|
|
|
|
// 启动 3D 引擎
|
|
try {
|
|
updateRabbitFromButtons = initImproved3D(canvas, hint, (dir, ang, sht) => {
|
|
selectedDir = dir;
|
|
selectedAngle = ang;
|
|
selectedShot = sht;
|
|
dirDropdown.updateText(directions[dir]);
|
|
angleDropdown.updateText(angles[ang]);
|
|
shotDropdown.updateText(shots[sht]);
|
|
updatePromptFromSelections();
|
|
});
|
|
} catch (e) {
|
|
console.error("Rabbit Engine Error:", e);
|
|
hint.innerText = "Error starting engine";
|
|
hint.style.color = "#ef4444";
|
|
}
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
// === 改进的 3D 引擎 ===
|
|
function initImproved3D(canvas, hint, onUpdate) {
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
// 状态 - 初始朝向front (yaw=0)
|
|
let yaw = 0;
|
|
let pitch = 0.3;
|
|
let dist = 450;
|
|
let targetYaw = 0;
|
|
let targetPitch = 0.3;
|
|
let targetDist = 450;
|
|
|
|
// 交互
|
|
let isDown = false;
|
|
let lastX = 0, lastY = 0;
|
|
|
|
canvas.addEventListener('mousedown', e => {
|
|
isDown = true;
|
|
canvas.style.cursor = "grabbing";
|
|
const rect = canvas.getBoundingClientRect();
|
|
lastX = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
lastY = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
});
|
|
|
|
window.addEventListener('mouseup', () => {
|
|
if (isDown) {
|
|
isDown = false;
|
|
canvas.style.cursor = "grab";
|
|
onUpdate(...calcParams());
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', e => {
|
|
if (!isDown) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = (e.clientX - rect.left) * (canvas.width / rect.width);
|
|
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
|
|
|
|
// 修正:反转拖拽方向,使其跟随鼠标
|
|
targetYaw += (x - lastX) * 0.008; // 改为 +=
|
|
targetPitch -= (y - lastY) * 0.008;
|
|
targetPitch = Math.max(-0.8, Math.min(1.2, targetPitch));
|
|
|
|
lastX = x;
|
|
lastY = y;
|
|
});
|
|
|
|
canvas.addEventListener('wheel', e => {
|
|
e.preventDefault();
|
|
targetDist += e.deltaY * 0.3;
|
|
targetDist = Math.max(250, Math.min(800, targetDist));
|
|
onUpdate(...calcParams());
|
|
});
|
|
|
|
// 计算参数 - 修正方向逻辑
|
|
function calcParams() {
|
|
let normYaw = yaw % (Math.PI * 2);
|
|
if (normYaw < 0) normYaw += Math.PI * 2;
|
|
|
|
// yaw角度到方向的映射
|
|
// 0 = back (互换后)
|
|
// π/2 = right (互换后)
|
|
// π = front (互换后)
|
|
// 3π/2 = left (互换后)
|
|
|
|
let segment = Math.round(normYaw / (Math.PI / 4)) % 8;
|
|
|
|
// 映射: 前后互换,左右互换
|
|
// 0=back, 1=back-right, 2=right, 3=front-right, 4=front, 5=front-left, 6=left, 7=back-left
|
|
const dirMap = [4, 3, 2, 1, 0, 7, 6, 5];
|
|
let dIdx = dirMap[segment];
|
|
|
|
let aIdx = 1;
|
|
if (pitch < -0.2) aIdx = 0;
|
|
else if (pitch < 0.3) aIdx = 1;
|
|
else if (pitch < 0.7) aIdx = 2;
|
|
else aIdx = 3;
|
|
|
|
let sIdx = 1;
|
|
if (dist < 350) sIdx = 0;
|
|
else if (dist < 550) sIdx = 1;
|
|
else sIdx = 2;
|
|
|
|
return [dIdx, aIdx, sIdx];
|
|
}
|
|
|
|
// 从参数设置视角
|
|
function setViewFromParams(dirIdx, angIdx, shotIdx) {
|
|
// 方向到yaw的反向映射 (前后左右互换)
|
|
const dirToSegment = [4, 3, 2, 1, 0, 7, 6, 5];
|
|
const segment = dirToSegment[dirIdx];
|
|
targetYaw = (segment * Math.PI / 4);
|
|
|
|
// 角度映射到 pitch
|
|
const angleToPitch = [-0.4, 0.1, 0.5, 0.9];
|
|
targetPitch = angleToPitch[angIdx];
|
|
|
|
// 景别映射到 dist
|
|
const shotToDist = [300, 450, 650];
|
|
targetDist = shotToDist[shotIdx];
|
|
}
|
|
|
|
// 创建圆润的兔子模型
|
|
function createRabbit() {
|
|
const parts = [];
|
|
|
|
// 身体 (椭圆体)
|
|
parts.push({
|
|
type: 'ellipsoid',
|
|
center: {x: 0, y: 30, z: 0},
|
|
radius: {x: 45, y: 60, z: 35},
|
|
color: '#e8e8e8',
|
|
segments: 16
|
|
});
|
|
|
|
// 头部 (球体)
|
|
parts.push({
|
|
type: 'sphere',
|
|
center: {x: 0, y: -30, z: 5},
|
|
radius: 32,
|
|
color: '#f5f5f5',
|
|
segments: 16
|
|
});
|
|
|
|
// 左耳
|
|
parts.push({
|
|
type: 'ellipsoid',
|
|
center: {x: -15, y: -68, z: 8},
|
|
radius: {x: 8, y: 35, z: 6},
|
|
color: '#ffccd5',
|
|
segments: 12
|
|
});
|
|
|
|
// 右耳
|
|
parts.push({
|
|
type: 'ellipsoid',
|
|
center: {x: 15, y: -68, z: 8},
|
|
radius: {x: 8, y: 35, z: 6},
|
|
color: '#ffccd5',
|
|
segments: 12
|
|
});
|
|
|
|
// 鼻子 (红色指示器 - 指向front方向)
|
|
parts.push({
|
|
type: 'sphere',
|
|
center: {x: 0, y: -32, z: 35},
|
|
radius: 6,
|
|
color: '#ff6b6b',
|
|
segments: 8
|
|
});
|
|
|
|
// 左眼
|
|
parts.push({
|
|
type: 'sphere',
|
|
center: {x: -12, y: -38, z: 28},
|
|
radius: 4,
|
|
color: '#2a2a2a',
|
|
segments: 8
|
|
});
|
|
|
|
// 右眼
|
|
parts.push({
|
|
type: 'sphere',
|
|
center: {x: 12, y: -38, z: 28},
|
|
radius: 4,
|
|
color: '#2a2a2a',
|
|
segments: 8
|
|
});
|
|
|
|
// 尾巴
|
|
parts.push({
|
|
type: 'sphere',
|
|
center: {x: 0, y: 60, z: -30},
|
|
radius: 15,
|
|
color: '#f0f0f0',
|
|
segments: 12
|
|
});
|
|
|
|
return parts;
|
|
}
|
|
|
|
const rabbit = createRabbit();
|
|
|
|
// 生成网格顶点
|
|
function generateMesh(part) {
|
|
const vertices = [];
|
|
const faces = [];
|
|
const {center, segments} = part;
|
|
|
|
if (part.type === 'sphere') {
|
|
const r = part.radius;
|
|
for (let lat = 0; lat <= segments; lat++) {
|
|
const theta = (lat * Math.PI) / segments;
|
|
const sinTheta = Math.sin(theta);
|
|
const cosTheta = Math.cos(theta);
|
|
|
|
for (let lon = 0; lon <= segments; lon++) {
|
|
const phi = (lon * 2 * Math.PI) / segments;
|
|
const x = center.x + r * sinTheta * Math.cos(phi);
|
|
const y = center.y + r * cosTheta;
|
|
const z = center.z + r * sinTheta * Math.sin(phi);
|
|
vertices.push({x, y, z});
|
|
}
|
|
}
|
|
} else if (part.type === 'ellipsoid') {
|
|
const {x: rx, y: ry, z: rz} = part.radius;
|
|
for (let lat = 0; lat <= segments; lat++) {
|
|
const theta = (lat * Math.PI) / segments;
|
|
const sinTheta = Math.sin(theta);
|
|
const cosTheta = Math.cos(theta);
|
|
|
|
for (let lon = 0; lon <= segments; lon++) {
|
|
const phi = (lon * 2 * Math.PI) / segments;
|
|
const x = center.x + rx * sinTheta * Math.cos(phi);
|
|
const y = center.y + ry * cosTheta;
|
|
const z = center.z + rz * sinTheta * Math.sin(phi);
|
|
vertices.push({x, y, z});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 生成面
|
|
for (let lat = 0; lat < segments; lat++) {
|
|
for (let lon = 0; lon < segments; lon++) {
|
|
const first = lat * (segments + 1) + lon;
|
|
const second = first + segments + 1;
|
|
faces.push([first, second, second + 1, first + 1]);
|
|
}
|
|
}
|
|
|
|
return {vertices, faces, color: part.color};
|
|
}
|
|
|
|
const meshes = rabbit.map(generateMesh);
|
|
|
|
function project(p) {
|
|
// 旋转
|
|
const cosY = Math.cos(yaw), sinY = Math.sin(yaw);
|
|
const x1 = p.x * cosY - p.z * sinY;
|
|
const z1 = p.x * sinY + p.z * cosY;
|
|
|
|
const cosP = Math.cos(pitch), sinP = Math.sin(pitch);
|
|
const y2 = p.y * cosP - z1 * sinP;
|
|
const z2 = p.y * sinP + z1 * cosP;
|
|
|
|
// 投影
|
|
const w = canvas.width || 400;
|
|
const h = canvas.height || 350;
|
|
const scale = 300 / (dist + z2);
|
|
return {
|
|
x: x1 * scale + w / 2,
|
|
y: y2 * scale + h / 2,
|
|
z: z2
|
|
};
|
|
}
|
|
|
|
function draw() {
|
|
const w = canvas.width || 400;
|
|
const h = canvas.height || 350;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// 绘制网格地面
|
|
ctx.strokeStyle = "#2a2a2a";
|
|
ctx.lineWidth = 1;
|
|
for (let i = -5; i <= 5; i++) {
|
|
const p1 = project({x: i * 50, y: 100, z: -250});
|
|
const p2 = project({x: i * 50, y: 100, z: 250});
|
|
ctx.beginPath();
|
|
ctx.moveTo(p1.x, p1.y);
|
|
ctx.lineTo(p2.x, p2.y);
|
|
ctx.stroke();
|
|
|
|
const p3 = project({x: -250, y: 100, z: i * 50});
|
|
const p4 = project({x: 250, y: 100, z: i * 50});
|
|
ctx.beginPath();
|
|
ctx.moveTo(p3.x, p3.y);
|
|
ctx.lineTo(p4.x, p4.y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// 收集所有面
|
|
const allFaces = [];
|
|
meshes.forEach(mesh => {
|
|
const projVerts = mesh.vertices.map(project);
|
|
mesh.faces.forEach(face => {
|
|
const pts = face.map(i => projVerts[i]);
|
|
const avgZ = pts.reduce((sum, p) => sum + p.z, 0) / pts.length;
|
|
|
|
// 背面剔除
|
|
const v1 = {x: pts[1].x - pts[0].x, y: pts[1].y - pts[0].y};
|
|
const v2 = {x: pts[2].x - pts[1].x, y: pts[2].y - pts[1].y};
|
|
const cross = v1.x * v2.y - v1.y * v2.x;
|
|
|
|
if (cross > 0) {
|
|
allFaces.push({pts, z: avgZ, color: mesh.color});
|
|
}
|
|
});
|
|
});
|
|
|
|
// 按深度排序
|
|
allFaces.sort((a, b) => b.z - a.z);
|
|
|
|
// 渲染
|
|
allFaces.forEach(face => {
|
|
ctx.beginPath();
|
|
ctx.moveTo(face.pts[0].x, face.pts[0].y);
|
|
for (let i = 1; i < face.pts.length; i++) {
|
|
ctx.lineTo(face.pts[i].x, face.pts[i].y);
|
|
}
|
|
ctx.closePath();
|
|
|
|
// 光照效果
|
|
const brightness = Math.max(0.6, 1 - (face.z / 500));
|
|
const rgb = hexToRgb(face.color);
|
|
ctx.fillStyle = `rgba(${rgb.r * brightness}, ${rgb.g * brightness}, ${rgb.b * brightness}, 1)`;
|
|
ctx.fill();
|
|
ctx.strokeStyle = "rgba(80, 80, 80, 0.3)";
|
|
ctx.lineWidth = 0.5;
|
|
ctx.stroke();
|
|
});
|
|
|
|
// 方向指示器
|
|
const noseTip = project({x: 0, y: -32, z: 45});
|
|
ctx.fillStyle = "#ff6b6b";
|
|
ctx.beginPath();
|
|
ctx.arc(noseTip.x, noseTip.y, 4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
function hexToRgb(hex) {
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
return {r, g, b};
|
|
}
|
|
|
|
// 动画循环(平滑插值)
|
|
function animate() {
|
|
yaw += (targetYaw - yaw) * 0.15;
|
|
pitch += (targetPitch - pitch) * 0.15;
|
|
dist += (targetDist - dist) * 0.15;
|
|
|
|
draw();
|
|
requestAnimationFrame(animate);
|
|
}
|
|
|
|
animate();
|
|
|
|
// 返回控制函数供按钮调用
|
|
return setViewFromParams;
|
|
} |