Files

688 lines
28 KiB
JavaScript

import { app } from "../../scripts/app.js";
// --- Centripetal Catmull-Rom Spline Implementation ---
function centripetalCatmullRomSpline(points, numSamples = 100) {
const n = points.length;
if (n < 2) return points.map(p => [p.x, p.y]);
const out = [];
const pts = [
points[0],
...points,
points[points.length - 1]
];
function tj(ti, pi, pj) {
const dx = pj.x - pi.x, dy = pj.y - pi.y;
return ti + Math.sqrt(Math.hypot(dx, dy));
}
for (let i = 1; i < pts.length - 2; i++) {
const p0 = pts[i - 1], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2];
const t0 = 0;
const t1 = tj(t0, p0, p1);
const t2 = tj(t1, p1, p2);
const t3 = tj(t2, p2, p3);
for (let j = 0; j < numSamples; j++) {
const t = t1 + (t2 - t1) * (j / numSamples);
function lerp(pa, pb, ta, tb) {
if (tb - ta === 0) return { x: pa.x, y: pa.y };
const ratio = (t - ta) / (tb - ta);
return {
x: pa.x + (pb.x - pa.x) * ratio,
y: pa.y + (pb.y - pa.y) * ratio
};
}
const A1 = lerp(p0, p1, t0, t1);
const A2 = lerp(p1, p2, t1, t2);
const A3 = lerp(p2, p3, t2, t3);
const B1 = lerp(A1, A2, t0, t2);
const B2 = lerp(A2, A3, t1, t3);
const C = lerp(B1, B2, t1, t2);
out.push([
Math.max(0, Math.min(1, C.x)),
Math.max(0, Math.min(1, C.y))
]);
}
}
out.push([points[points.length - 1].x, points[points.length - 1].y]);
return out;
}
// --- Curve Generator Functions ---
function generateSimpleCurve(numPoints = 10) {
return Array.from({length: numPoints}, (_, i) => {
const t = i / (numPoints - 1);
return {
x: t,
y: 1.0 - t
};
});
}
function generateKarrasCurve(numPoints = 10) {
const sigma_min = 0.1;
const sigma_max = 10.0;
const rho = 7.0;
return Array.from({length: numPoints}, (_, i) => {
const t = 1.0 - (i / (numPoints - 1));
const sigma = sigma_max * Math.pow(sigma_min/sigma_max, t * rho);
const normalized = 1.0 - Math.min(1.0, Math.max(0.0,
(sigma - sigma_min) / (sigma_max - sigma_min)));
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generateExponentialCurve(numPoints = 10) {
const sigma_min = 0.1;
const sigma_max = 10.0;
return Array.from({length: numPoints}, (_, i) => {
const t = 1.0 - (i / (numPoints - 1));
const sigma = Math.pow(sigma_min, 1-t) * Math.pow(sigma_max, t);
const normalized = Math.min(1.0, Math.max(0.0,
(sigma - sigma_min) / (sigma_max - sigma_min)));
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generateVPCurve(numPoints = 10) {
const beta_d = 19.9;
const beta_min = 0.1;
const beta_max = 20.0;
return Array.from({length: numPoints}, (_, i) => {
const t = 1.0 - (i / (numPoints - 1));
const beta = beta_min + t * (beta_max - beta_min);
const alpha = Math.exp(-beta);
const alpha_cumprod = Math.exp(-(beta + 0.5 * t * t * beta_d));
const sigma = Math.sqrt((1 - alpha_cumprod) / alpha_cumprod);
const max_sigma = 10.0;
const normalized = Math.min(1.0, sigma / max_sigma);
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generateVECurve(numPoints = 10) {
const sigma_min = 0.02;
const sigma_max = 100.0;
return Array.from({length: numPoints}, (_, i) => {
const x_pos = i / (numPoints - 1);
const t = x_pos;
const sigma = Math.pow(sigma_min, t) * Math.pow(sigma_max, 1-t);
const normalized = 1.0 - Math.min(1.0, Math.max(0.0,
(sigma - sigma_min) / (sigma_max - sigma_min)));
return {
x: x_pos,
y: normalized
};
});
}
function generateLinearCurve(numPoints = 10) {
const sigma_min = 0.1;
const sigma_max = 10.0;
return Array.from({length: numPoints}, (_, i) => {
const t = 1.0 - (i / (numPoints - 1));
const sigma = sigma_min + t * (sigma_max - sigma_min);
const normalized = Math.min(1.0, sigma / sigma_max);
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generatePolyexponentialCurve(numPoints = 10) {
const sigma_min = 0.1;
const sigma_max = 10.0;
const rho = 3.0;
return Array.from({length: numPoints}, (_, i) => {
const t = 1.0 - (i / (numPoints - 1));
const sigma = sigma_min + (sigma_max - sigma_min) * (t ** (1.0 / rho));
const normalized = Math.min(1.0, sigma / sigma_max);
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generateDPMSolverFastCurve(numPoints = 10) {
return Array.from({length: numPoints}, (_, i) => {
const t = i / (numPoints - 1);
let noise_level;
if (t < 0.5) {
noise_level = 1.0 - 0.75 * (2 * t);
} else {
noise_level = 0.25 * (2.0 - 2 * t);
}
return {
x: t,
y: noise_level
};
});
}
// --- Node Implementation ---
app.registerExtension({
name: "InteractiveCentripetalCatmullRomGraph",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === "CustomSplineSigma") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const node = this;
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
if (!this.size) this.size = [340, 260];
// --- Graph area margins ---
this.graph_side_margin = 25;
this.graph_bottom_margin = 20;
// Initialize points before creating widgets
if (!this.points || !Array.isArray(this.points)) {
this.points = [
{ x: 0, y: 1 },
{ x: 1, y: 0 }
];
}
// --- Widget for curve data with callback ---
if (!this.widgets) this.widgets = [];
// Create the curve_data widget with callback directly
if (!this.widgets.find(w => w.name === "curve_data")) {
const curveDataCallback = (value) => {
let loaded = false;
if (value) {
try {
const data = JSON.parse(value);
if (data && Array.isArray(data.control_points) && data.control_points.length >= 2) {
node.points = data.control_points.map(pt => ({
x: Number(pt.x),
y: Number(pt.y)
}));
loaded = true;
} else if (data && Array.isArray(data.samples) && data.samples.length >= 2) {
node.points = [
{x: Number(data.samples[0][0]), y: Number(data.samples[0][1])},
{x: Number(data.samples[data.samples.length-1][0]), y: Number(data.samples[data.samples.length-1][1])}
];
loaded = true;
}
} catch (e) {}
}
if (!loaded) {
node.points = [
{ x: 0, y: 1 },
{ x: 1, y: 0 }
];
}
node.updateCurve();
node.setDirtyCanvas(true, true);
};
this.addWidget(
"string",
"curve_data",
"",
curveDataCallback,
{ multiline: false, disabled: false }
);
}
// Add preset selector widget
if (!this.widgets.find(w => w.name === "preset_selector")) {
this.addWidget("combo", "preset_selector", "Simple", (value) => {
if (value === "Custom") return;
// Apply the selected preset
switch(value) {
case "Simple":
node.points = generateSimpleCurve(10);
break;
case "Karras":
node.points = generateKarrasCurve(10);
break;
case "Exponential":
node.points = generateExponentialCurve(10);
break;
case "VP":
node.points = generateVPCurve(10);
break;
case "VE":
node.points = generateVECurve(10);
break;
case "Linear":
node.points = generateLinearCurve(10);
break;
case "Polyexponential":
node.points = generatePolyexponentialCurve(10);
break;
case "DPM-Solver-Fast":
node.points = generateDPMSolverFastCurve(10);
break;
default:
return;
}
// Store the last selected preset for potential reset
node.lastSelectedPreset = value;
// Update the curve and curve_data widget
node._ensureValidPoints();
node.updateCurve();
node.setDirtyCanvas(true, true);
app.graph.change();
}, {
values: ["Simple", "Karras", "Exponential", "VP", "VE", "Linear",
"Polyexponential", "DPM-Solver-Fast", "Custom"]
});
}
// --- Try to load points from the widget value (JSON) ---
const widget = this.widgets.find(w => w.name === "curve_data");
if (widget && widget.value) {
try {
const data = JSON.parse(widget.value);
if (data && Array.isArray(data.control_points) && data.control_points.length >= 2) {
this.points = data.control_points.map(pt => ({
x: Number(pt.x),
y: Number(pt.y)
}));
} else if (data && Array.isArray(data.samples) && data.samples.length >= 2) {
// Use endpoints as fallback
this.points = [
{x: Number(data.samples[0][0]), y: Number(data.samples[0][1])},
{x: Number(data.samples[data.samples.length-1][0]), y: Number(data.samples[data.samples.length-1][1])}
];
}
} catch (e) {
// ignore parse error, will use default points
}
}
this.dragState = null;
this.smoothedPoints = null;
this.hitRadius = 0.05;
this._ensureValidPoints();
this.updateCurve();
this._updateCurveWidget();
// --- Override mouse event handling ---
// Store the original methods
const originalOnMouseDown = this.onMouseDown;
const originalOnMouseMove = this.onMouseMove;
const originalOnMouseUp = this.onMouseUp;
// Create bound versions of our mouse handlers
this.onMouseDown = function(e, pos, canvas) {
console.log("CustomSplineSigma onMouseDown called", e, pos);
node.calcGraphArea();
// Check if click is inside the graph area
if (
pos[0] >= node.graph_area_left &&
pos[0] <= node.graph_area_left + node.graph_area_width &&
pos[1] >= node.graph_area_top &&
pos[1] <= node.graph_area_top + node.graph_area_height
) {
console.log("Click is inside graph area");
const graphPos = node.toGraphCoords(pos);
const pointIndex = node.points.findIndex(p =>
Math.hypot(p.x - graphPos.x, p.y - graphPos.y) < node.hitRadius
);
if (pointIndex >= 0) {
// Delete if Shift+Left click, else drag
if (e.button === 0 && e.shiftKey) {
if (node.points.length > 2) {
node.points.splice(pointIndex, 1);
node._setPresetToCustom();
node.updateCurve();
app.graph.change();
node.setDirtyCanvas(true, true);
}
return true;
} else if (e.button === 0) {
node.dragState = {
index: pointIndex,
offsetX: graphPos.x - node.points[pointIndex].x,
offsetY: graphPos.y - node.points[pointIndex].y
};
app.graph.change();
node.setDirtyCanvas(true, true);
return true;
}
} else if (e.button === 0) {
// Add new point
let newX = Math.max(0, Math.min(1, graphPos.x));
if (!node.points.some(p => Math.abs(p.x - newX) < 1e-4)) {
let newY = Math.max(0, Math.min(1, graphPos.y));
node.points.push({ x: newX, y: newY });
node._setPresetToCustom();
node.updateCurve();
app.graph.change();
node.setDirtyCanvas(true, true);
return true;
}
}
return true; // Prevent default behavior when clicking in graph area
}
// Call original handler for clicks outside graph area
if (originalOnMouseDown) {
return originalOnMouseDown.apply(this, arguments);
}
return false;
};
this.onMouseMove = function(e, pos, canvas) {
if (node.dragState) {
console.log("CustomSplineSigma onMouseMove with dragState");
node.calcGraphArea();
const graphPos = node.toGraphCoords(pos);
let newX = Math.max(0, Math.min(1, graphPos.x - node.dragState.offsetX));
let newY = Math.max(0, Math.min(1, graphPos.y - node.dragState.offsetY));
const i = node.dragState.index;
if (i === 0) newX = 0;
else if (i === node.points.length - 1) newX = 1;
else {
if (i > 0) newX = Math.max(node.points[i - 1].x + 1e-3, newX);
if (i < node.points.length - 1) newX = Math.min(node.points[i + 1].x - 1e-3, newX);
}
node.points[i] = { x: newX, y: newY };
node._setPresetToCustom();
node.updateCurve();
node.setDirtyCanvas(true, true);
return true;
}
if (originalOnMouseMove) {
return originalOnMouseMove.apply(this, arguments);
}
return false;
};
this.onMouseUp = function(e, pos, canvas) {
if (node.dragState) {
console.log("CustomSplineSigma onMouseUp releasing dragState");
node.dragState = null;
app.graph.change();
return true;
}
if (originalOnMouseUp) {
return originalOnMouseUp.apply(this, arguments);
}
return false;
};
};
// Add all the other methods to the prototype
Object.assign(nodeType.prototype, {
calcGraphArea() {
// Calculate the exact position where widgets end
let widgets_bottom = 10; // Space for node title
if (this.widgets && this.widgets.length) {
this.widgets.forEach(w => {
let widget_height = 30; // Default height
if (w.type === "combo") widget_height = 30;
else if (w.type === "number") widget_height = 30;
else if (w.type === "string" && w.options && w.options.multiline) {
widget_height = 80;
}
else if (w.type === "string") widget_height = 30;
widgets_bottom += widget_height;
});
widgets_bottom += 10;
}
// Reduced from 20px to 5px for less spacing
const extra_clearance = 5;
this.graph_area_top = widgets_bottom + extra_clearance;
// Calculate available space for graph
this.graph_area_height = this.size[1] - this.graph_area_top - this.graph_bottom_margin;
this.graph_area_width = this.size[0] - this.graph_side_margin * 2;
this.graph_area_left = this.graph_side_margin;
// Ensure minimum graph height
const min_graph_height = 10;
if (this.graph_area_height < min_graph_height) {
const additional_height = min_graph_height - this.graph_area_height;
this.size[1] += additional_height;
this.graph_area_height = min_graph_height;
if (this.setSize) {
this.setSize(this.size);
}
}
},
_ensureValidPoints() {
if (!Array.isArray(this.points) || this.points.length < 2) {
this.points = [
{ x: 0, y: 1 },
{ x: 1, y: 0 }
];
}
this.points = this.points
.map(p => ({
x: Math.max(0, Math.min(1, p.x)),
y: Math.max(0, Math.min(1, p.y))
}))
.sort((a, b) => a.x - b.x);
this.points = this.points.filter((pt, idx, arr) =>
idx === 0 || pt.x !== arr[idx - 1].x
);
this.points[0].x = 0;
this.points[this.points.length - 1].x = 1;
if (this.points.length < 2) {
this.points = [
{ x: 0, y: 1 },
{ x: 1, y: 0 }
];
}
},
toScreenCoords(point) {
this.calcGraphArea();
return [
this.graph_area_left + point.x * this.graph_area_width,
this.graph_area_top + (1 - point.y) * this.graph_area_height
];
},
toGraphCoords(pos) {
this.calcGraphArea();
return {
x: Math.max(0, Math.min(1, (pos[0] - this.graph_area_left) / this.graph_area_width)),
y: Math.max(0, Math.min(1, 1 - (pos[1] - this.graph_area_top) / this.graph_area_height))
};
},
onDrawForeground(ctx) {
this.calcGraphArea();
this._ensureValidPoints();
this.updateCurve();
// --- White background for the graph area ---
ctx.fillStyle = "#fff";
ctx.fillRect(
this.graph_area_left,
this.graph_area_top,
this.graph_area_width,
this.graph_area_height
);
// --- Draw grid ---
ctx.strokeStyle = "#eee";
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0.25; i < 1; i += 0.25) {
// Vertical grid lines
let x = this.graph_area_left + i * this.graph_area_width;
ctx.moveTo(x, this.graph_area_top);
ctx.lineTo(x, this.graph_area_top + this.graph_area_height);
// Horizontal grid lines
let y = this.graph_area_top + i * this.graph_area_height;
ctx.moveTo(this.graph_area_left, y);
ctx.lineTo(this.graph_area_left + this.graph_area_width, y);
}
ctx.stroke();
// --- Draw axes ---
ctx.strokeStyle = "#aaa";
ctx.beginPath();
// X axis (bottom)
ctx.moveTo(this.graph_area_left, this.graph_area_top + this.graph_area_height);
ctx.lineTo(this.graph_area_left + this.graph_area_width, this.graph_area_top + this.graph_area_height);
// Y axis (left)
ctx.moveTo(this.graph_area_left, this.graph_area_top + this.graph_area_height);
ctx.lineTo(this.graph_area_left, this.graph_area_top);
ctx.stroke();
// --- Draw the spline curve ---
if (this.smoothedPoints && this.smoothedPoints.length > 1) {
ctx.strokeStyle = "#3366FF";
ctx.lineWidth = 2;
ctx.beginPath();
let start = this.toScreenCoords({ x: this.smoothedPoints[0][0], y: this.smoothedPoints[0][1] });
ctx.moveTo(start[0], start[1]);
for (let i = 1; i < this.smoothedPoints.length; i++) {
let p = this.toScreenCoords({ x: this.smoothedPoints[i][0], y: this.smoothedPoints[i][1] });
ctx.lineTo(p[0], p[1]);
}
ctx.stroke();
}
// --- Draw control polygon ---
ctx.strokeStyle = "#FF8888";
ctx.lineWidth = 1;
ctx.beginPath();
if (this.points.length > 1) {
let first = this.toScreenCoords(this.points[0]);
ctx.moveTo(first[0], first[1]);
for (let i = 1; i < this.points.length; i++) {
let pt = this.toScreenCoords(this.points[i]);
ctx.lineTo(pt[0], pt[1]);
}
}
ctx.stroke();
// --- Draw points ---
ctx.fillStyle = "#FF5555";
for (const point of this.points) {
const [x, y] = this.toScreenCoords(point);
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#880000";
ctx.lineWidth = 1.5;
ctx.stroke();
}
// --- Draw user instruction just below the graph area ---
ctx.fillStyle = "#222";
ctx.font = "11px sans-serif";
ctx.fillText(
"Shift+Click to delete point",
this.graph_area_left + 5,
this.graph_area_top + this.graph_area_height + 16
);
},
updateCurve() {
this._ensureValidPoints();
this.smoothedPoints = centripetalCatmullRomSpline(this.points, 100);
this._updateCurveWidget();
},
_updateCurveWidget() {
if (!this.widgets) return;
const widget = this.widgets.find(w => w.name === "curve_data");
if (widget) {
const newValue = JSON.stringify({
control_points: this.points,
samples: this.smoothedPoints
});
if (widget.value !== newValue) {
widget.value = newValue;
if (this.setDirtyCanvas) this.setDirtyCanvas(true, true);
if (app && app.graph) app.graph.change();
}
}
},
onExecute() {
this.updateCurve();
},
onConfigure(info) {
if (info.curve_state && Array.isArray(info.curve_state)) {
try {
this.points = info.curve_state.map(pt => ({
x: Number(pt.x),
y: Number(pt.y)
}));
this._ensureValidPoints();
this.updateCurve();
} catch (e) {
console.error("Failed to load curve state:", e);
}
}
},
onSerialize(info) {
info.curve_state = this.points;
},
_setPresetToCustom() {
const presetWidget = this.widgets.find(w => w.name === "preset_selector");
if (presetWidget && presetWidget.value !== "Custom") {
presetWidget.value = "Custom";
if (this.onWidgetChanged) {
this.onWidgetChanged(presetWidget.name, presetWidget.value, presetWidget.last_value);
}
}
}
});
}
}
});