box layers added

This commit is contained in:
JJ
2025-11-16 20:26:35 +00:00
parent 458a001db9
commit 73972a3ddc
9 changed files with 948 additions and 387 deletions
+11
View File
@@ -1,6 +1,17 @@
# ComfyUI-WanVideoWrapper_QQ
## Updates
**Version 1.0.7** - 2025-11-13
- **Box layer type**: new layer type that can scale "points" type layer points when driving them (only points for now)
- Right click drag in inner border of boxes to scale each one individually.
- If updated spline editor messes up workflow , open it ,delete node , refresh page ,create new.
https://github.com/user-attachments/assets/e5849627-5f1a-4056-a941-a15b40bfc6c4
**Version 1.0.6** - 2025-11-13
- **New Wan22FMLF**: A better way to add midframe control from https://github.com/wallen0322/ComfyUI-Wan22FMLF + amplitude test in one node
- **Lora high.low filter**: Clicking the circle icon cycles through: ◎ → ◐ → ◑ → ◎ + right icon right
+486 -222
View File
File diff suppressed because it is too large Load Diff
+123 -67
View File
@@ -7,6 +7,8 @@ import math
from torchvision import transforms
from ..utility.driver_utils import apply_driver_offset
BOX_BASE_RADIUS = 56
class PowerSplineEditor:
@classmethod
def INPUT_TYPES(cls):
@@ -394,23 +396,36 @@ class PowerSplineEditor:
all_coord_types: list = []
all_coord_start_frames = list(incoming_coord_start_frames)
all_coord_end_frames = list(incoming_coord_end_frames)
all_box_paths = []
all_box_names: list = []
all_box_types: list = []
all_box_start_frames = []
all_box_end_frames = []
all_p_offsets = [] # Initialize list for p_coordinates offsets
all_coord_offsets = [] # Initialize list for coordinates offsets
all_box_offsets = [] # Initialize list for box offsets
all_p_interpolations = [] # Initialize list for p_coordinates interpolations
all_coord_interpolations = [] # Initialize list for coordinates interpolations
all_box_interpolations = [] # Initialize list for box interpolations
all_p_easing_functions = [] # Initialize list for p_coordinates easing functions
all_coord_easing_functions = [] # Initialize list for coordinates easing functions
all_box_easing_functions = [] # Initialize list for box easing functions
all_p_easing_paths = [] # Initialize list for p_coordinates easing paths
all_coord_easing_paths = [] # Initialize list for coordinates easing paths
all_box_easing_paths = [] # Initialize list for box easing paths
all_p_easing_strengths = [] # Initialize list for p_coordinates easing strengths
all_coord_easing_strengths = [] # Initialize list for coordinates easing strengths
all_box_easing_strengths = [] # Initialize list for box easing strengths
all_p_accelerations = [] # Initialize list for p_coordinates accelerations
all_coord_accelerations = [] # Initialize list for coordinates accelerations
all_box_accelerations = [] # Initialize list for box accelerations
all_p_scales = [] # Initialize list for p_coordinates scales
all_coord_scales = [] # Initialize list for coordinates scales
all_box_scales = [] # Initialize list for box scales
all_p_drivers = [] # Driver info for p_coordinates
all_coord_drivers = [] # Driver info for coordinates
all_box_drivers = [] # Driver info for box coordinates
# Build layer lookup map for driver processing
# Map layer names to their processed coordinates for driving
@@ -546,6 +561,7 @@ class PowerSplineEditor:
driver_acceleration = 0.00 # Default
# Search for the driver layer in all_splines to get its interpolation parameters
driver_type = 'linear'
for driver_spline_data in all_splines:
if isinstance(driver_spline_data, dict) and driver_spline_data.get('name', '') == driver_name:
driver_easing_function = driver_spline_data.get('easing', 'linear')
@@ -553,6 +569,7 @@ class PowerSplineEditor:
driver_easing_path = driver_easing_config.get('path', 'full')
driver_easing_strength = driver_easing_config.get('strength', 1.0)
driver_acceleration = driver_easing_config.get('acceleration', 0.00)
driver_type = driver_spline_data.get('interpolation', 'linear')
break
# Gather driver's own timing so consumers can respect it
@@ -560,8 +577,37 @@ class PowerSplineEditor:
driver_end_pause = int(driver_spline_data.get('z_pause', 0))
driver_offset_val = int(driver_spline_data.get('offset', 0))
# Store driver info for ALL interpolation modes
# Let draw_shapes.py apply the offset at the right time
def extract_point_scale(point):
if not isinstance(point, dict):
return 1.0
for key in ('boxScale', 'scale'):
value = point.get(key)
if isinstance(value, (int, float)):
return float(value)
return 1.0
driver_scale_profile = []
driver_scale_factor = 1.0
if driver_coords and isinstance(driver_coords, list) and len(driver_coords) > 0:
base_scale = extract_point_scale(driver_coords[0]) or 1.0
if abs(base_scale) < 1e-6:
base_scale = 1.0
for pt in driver_coords:
scale_val = extract_point_scale(pt)
ratio = scale_val / base_scale if base_scale else 1.0
driver_scale_profile.append(ratio)
driver_scale_factor = max(driver_scale_profile) if driver_scale_profile else 1.0
driver_radius_delta = BOX_BASE_RADIUS * (driver_scale_factor - 1.0)
driver_pivot = None
if driver_coords and isinstance(driver_coords[0], dict):
try:
driver_pivot = (
float(driver_coords[0].get('x', 0.0)),
float(driver_coords[0].get('y', 0.0))
)
except (TypeError, ValueError):
driver_pivot = None
driver_info_for_layer = {
"path": driver_coords,
"rotate": driver_rotate,
@@ -573,7 +619,12 @@ class PowerSplineEditor:
# Driver timing (used by DrawShapeOnPath to delay/advance driver motion)
"start_pause": driver_start_pause,
"end_pause": driver_end_pause,
"offset": driver_offset_val
"offset": driver_offset_val,
"driver_type": driver_type,
"driver_scale_profile": driver_scale_profile,
"driver_scale_factor": driver_scale_factor,
"driver_radius_delta": driver_radius_delta,
"driver_pivot": driver_pivot
}
print(f"Stored driver '{driver_name}' for layer '{current_spline_name}' (mode={spline_interpolation}, rotate={driver_rotate}°, d_scale={driver_d_scale}, easing={driver_easing_function})")
else:
@@ -611,6 +662,20 @@ class PowerSplineEditor:
all_p_accelerations.append(acceleration) # Collect acceleration value
all_p_scales.append(scale) # Collect scale value
all_p_drivers.append(driver_info_for_layer) # Collect driver info (None if no driver)
elif spline_interpolation == 'box':
all_box_paths.append(spline_coords)
all_box_names.append(spline_data.get('name', ''))
all_box_types.append('box')
all_box_start_frames.append(start_frames)
all_box_end_frames.append(end_frames)
all_box_offsets.append(offset)
all_box_interpolations.append(spline_interpolation)
all_box_easing_functions.append(easing_function)
all_box_easing_paths.append(easing_path)
all_box_easing_strengths.append(easing_strength)
all_box_accelerations.append(acceleration)
all_box_scales.append(scale)
all_box_drivers.append(driver_info_for_layer)
else:
all_coord_paths.append(spline_coords)
all_coord_names.append(spline_data.get('name', ''))
@@ -640,93 +705,84 @@ class PowerSplineEditor:
coord_out_data = {}
# Add p_coordinates if present
p_start_out = []
p_end_out = []
if all_p_paths:
if len(all_p_paths) > 1:
# Multiple paths - output as list of lists
coord_out_data["p_coordinates"] = all_p_paths
p_start_out = all_p_start_frames
p_end_out = all_p_end_frames
elif len(all_p_paths) == 1:
# Single path - output as simple list for backward compatibility
else:
coord_out_data["p_coordinates"] = all_p_paths[0]
p_start_out = all_p_start_frames[0] if all_p_start_frames else 0
p_end_out = all_p_end_frames[0] if all_p_end_frames else 0
else:
p_start_out = []
p_end_out = []
# Add coordinates if present
c_start_out = []
c_end_out = []
if all_coord_paths:
if len(all_coord_paths) > 1:
# Multiple paths - output as list of lists
coord_out_data["coordinates"] = all_coord_paths
c_start_out = all_coord_start_frames
c_end_out = all_coord_end_frames
elif len(all_coord_paths) == 1:
# Single path - output as simple list for backward compatibility
else:
coord_out_data["coordinates"] = all_coord_paths[0]
c_start_out = all_coord_start_frames[0] if all_coord_start_frames else 0
c_end_out = all_coord_end_frames[0] if all_coord_end_frames else 0
else:
c_start_out = []
c_end_out = []
# Add pause frames, offsets, interpolations, easing params, scales, and drivers based on what exists
if all_p_paths and all_coord_paths:
# Both types exist - use new format with separate pause frames, offsets, interpolations, easing params, scales, and drivers
coord_out_data["start_p_frames"] = {"p": p_start_out, "c": c_start_out}
coord_out_data["end_p_frames"] = {"p": p_end_out, "c": c_end_out}
coord_out_data["offsets"] = {"p": all_p_offsets, "c": all_coord_offsets}
coord_out_data["interpolations"] = {"p": all_p_interpolations, "c": all_coord_interpolations}
coord_out_data["easing_functions"] = {"p": all_p_easing_functions, "c": all_coord_easing_functions}
coord_out_data["easing_paths"] = {"p": all_p_easing_paths, "c": all_coord_easing_paths}
coord_out_data["easing_strengths"] = {"p": all_p_easing_strengths, "c": all_coord_easing_strengths}
coord_out_data["accelerations"] = {"p": all_p_accelerations, "c": all_coord_accelerations}
coord_out_data["scales"] = {"p": all_p_scales, "c": all_coord_scales}
coord_out_data["drivers"] = {"p": all_p_drivers, "c": all_coord_drivers}
coord_out_data["names"] = {"p": all_p_names, "c": all_coord_names}
coord_out_data["types"] = {"p": all_p_types, "c": all_coord_types}
elif all_p_paths:
# Only p_coordinates - use new format for consistency
coord_out_data["start_p_frames"] = {"p": p_start_out, "c": []}
coord_out_data["end_p_frames"] = {"p": p_end_out, "c": []}
coord_out_data["offsets"] = {"p": all_p_offsets, "c": []}
coord_out_data["interpolations"] = {"p": all_p_interpolations, "c": []}
coord_out_data["easing_functions"] = {"p": all_p_easing_functions, "c": []}
coord_out_data["easing_paths"] = {"p": all_p_easing_paths, "c": []}
coord_out_data["easing_strengths"] = {"p": all_p_easing_strengths, "c": []}
coord_out_data["accelerations"] = {"p": all_p_accelerations, "c": []}
coord_out_data["scales"] = {"p": all_p_scales, "c": []}
coord_out_data["drivers"] = {"p": all_p_drivers, "c": []}
coord_out_data["names"] = {"p": all_p_names, "c": []}
coord_out_data["types"] = {"p": all_p_types, "c": []}
elif all_coord_paths:
# Only coordinates - use new format for consistency
coord_out_data["start_p_frames"] = {"p": [], "c": c_start_out}
coord_out_data["end_p_frames"] = {"p": [], "c": c_end_out}
coord_out_data["offsets"] = {"p": [], "c": all_coord_offsets}
coord_out_data["interpolations"] = {"p": [], "c": all_coord_interpolations}
coord_out_data["easing_functions"] = {"p": [], "c": all_coord_easing_functions}
coord_out_data["easing_paths"] = {"p": [], "c": all_coord_easing_paths}
coord_out_data["easing_strengths"] = {"p": [], "c": all_coord_easing_strengths}
coord_out_data["accelerations"] = {"p": [], "c": all_coord_accelerations}
coord_out_data["scales"] = {"p": [], "c": all_coord_scales}
coord_out_data["drivers"] = {"p": [], "c": all_coord_drivers}
coord_out_data["names"] = {"p": [], "c": all_coord_names}
coord_out_data["types"] = {"p": [], "c": all_coord_types}
else:
# Add box coordinates if present
b_start_out = []
b_end_out = []
if all_box_paths:
if len(all_box_paths) > 1:
coord_out_data["box_coordinates"] = all_box_paths
b_start_out = all_box_start_frames
b_end_out = all_box_end_frames
else:
coord_out_data["box_coordinates"] = all_box_paths[0]
b_start_out = all_box_start_frames[0] if all_box_start_frames else 0
b_end_out = all_box_end_frames[0] if all_box_end_frames else 0
def assemble_meta(p_has, p_val, c_has, c_val, b_has, b_val):
if not (p_has or c_has or b_has):
return []
return {
"p": p_val if p_has else [],
"c": c_val if c_has else [],
"b": b_val if b_has else []
}
has_p = bool(all_p_paths)
has_c = bool(all_coord_paths)
has_b = bool(all_box_paths)
if not (has_p or has_c or has_b):
# No paths at all
coord_out_data["start_p_frames"] = 0
coord_out_data["end_p_frames"] = 0
coord_out_data["offsets"] = [] # Default empty list
coord_out_data["interpolations"] = [] # Default empty list
coord_out_data["easing_functions"] = [] # Default empty list for easing functions
coord_out_data["easing_paths"] = [] # Default empty list for easing paths
coord_out_data["easing_strengths"] = [] # Default empty list for easing strengths
coord_out_data["accelerations"] = [] # Default empty list for accelerations
coord_out_data["scales"] = [] # Default empty list for scales
coord_out_data["drivers"] = [] # Default empty list
coord_out_data["offsets"] = [] # Default empty list
coord_out_data["interpolations"] = [] # Default empty list
coord_out_data["easing_functions"] = [] # Default empty list for easing functions
coord_out_data["easing_paths"] = [] # Default empty list for easing paths
coord_out_data["easing_strengths"] = [] # Default empty list for easing strengths
coord_out_data["accelerations"] = [] # Default empty list for accelerations
coord_out_data["scales"] = [] # Default empty list for scales
coord_out_data["drivers"] = [] # Default empty list
coord_out_data["names"] = {"p": [], "c": [], "b": []}
coord_out_data["types"] = {"p": [], "c": [], "b": []}
print("Warning: No paths to output")
else:
coord_out_data["start_p_frames"] = assemble_meta(has_p, p_start_out, has_c, c_start_out, has_b, b_start_out)
coord_out_data["end_p_frames"] = assemble_meta(has_p, p_end_out, has_c, c_end_out, has_b, b_end_out)
coord_out_data["offsets"] = assemble_meta(has_p, all_p_offsets, has_c, all_coord_offsets, has_b, all_box_offsets)
coord_out_data["interpolations"] = assemble_meta(has_p, all_p_interpolations, has_c, all_coord_interpolations, has_b, all_box_interpolations)
coord_out_data["easing_functions"] = assemble_meta(has_p, all_p_easing_functions, has_c, all_coord_easing_functions, has_b, all_box_easing_functions)
coord_out_data["easing_paths"] = assemble_meta(has_p, all_p_easing_paths, has_c, all_coord_easing_paths, has_b, all_box_easing_paths)
coord_out_data["easing_strengths"] = assemble_meta(has_p, all_p_easing_strengths, has_c, all_coord_easing_strengths, has_b, all_box_easing_strengths)
coord_out_data["accelerations"] = assemble_meta(has_p, all_p_accelerations, has_c, all_coord_accelerations, has_b, all_box_accelerations)
coord_out_data["scales"] = assemble_meta(has_p, all_p_scales, has_c, all_coord_scales, has_b, all_box_scales)
coord_out_data["drivers"] = assemble_meta(has_p, all_p_drivers, has_c, all_coord_drivers, has_b, all_box_drivers)
coord_out_data["names"] = assemble_meta(has_p, all_p_names, has_c, all_coord_names, has_b, all_box_names)
coord_out_data["types"] = assemble_meta(has_p, all_p_types, has_c, all_coord_types, has_b, all_box_types)
# Include coordinate space dimensions so DrawShapeOnPath can scale if needed
# Coordinates from the frontend are in normalized 0-1 range
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "wanvideowrapper_qq"
description = "A collection of custom nodes for ComfyUI providing video manipulation, LoRA loading, path animation, and visual drawing tools for WanVideo workflows."
version = "1.0.6"
version = "1.0.7"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
+1 -1
View File
@@ -496,7 +496,7 @@ export function showInterpolationMenu(event, widget, position) {
transform: translateX(-40%) !important;
`;
const options = ['linear', 'cardinal', 'basis', 'points'];
const options = ['linear', 'cardinal', 'basis', 'points', 'box'];
const current = widget.value.interpolation || 'linear';
const list = document.createElement('div');
+100 -72
View File
@@ -1,3 +1,5 @@
import { BOX_BASE_RADIUS, POINT_BASE_RADIUS } from './spline_utils.js';
/**
* @class LayerRenderer
* A dedicated class to handle the rendering of active and inactive spline layers
@@ -28,6 +30,27 @@ export class LayerRenderer {
}, 80);
}
clampPointScale(value) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return 1;
}
return Math.max(0.2, Math.min(3.0, value));
}
getPointScale(point, forBox = false) {
if (!point) return 1;
if (forBox && typeof point.boxScale === 'number' && !Number.isNaN(point.boxScale)) {
return this.clampPointScale(point.boxScale);
}
if (!forBox && typeof point.pointScale === 'number' && !Number.isNaN(point.pointScale)) {
return this.clampPointScale(point.pointScale);
}
if (typeof point.scale === 'number' && !Number.isNaN(point.scale)) {
return this.clampPointScale(point.scale);
}
return 1;
}
// Apply/update marching-dash on inactive paths via DOM attributes
updateInactiveDash() {
if (!this.splineEditor) return;
@@ -100,7 +123,10 @@ export class LayerRenderer {
list.push({ p, len });
}
const purpleMetas = (this.inactiveLayerMetadata || []).filter(d => d?.widget?.value?.type === 'handdraw');
const orangeMetas = (this.inactiveLayerMetadata || []).filter(d => (d?.widget?.value?.type !== 'handdraw') && ((d?.widget?.value?.interpolation || 'linear') !== 'points'));
const orangeMetas = (this.inactiveLayerMetadata || []).filter(d => {
const interp = d?.widget?.value?.interpolation || 'linear';
return (d?.widget?.value?.type !== 'handdraw') && (interp !== 'points' && interp !== 'box');
});
const applyFor = (pairs, metas) => {
const count = Math.min(pairs.length, metas.length);
for (let idx = 0; idx < count; idx++) {
@@ -289,10 +315,23 @@ export class LayerRenderer {
* @param {object} widget - The active spline widget.
*/
drawActiveLayer(widget) {
const points = this.splineEditor.points;
if (!points || points.length === 0) return;
const points = Array.isArray(this.splineEditor.points) ? this.splineEditor.points : [];
if (!points.length) return;
const pointIndexMap = new Map(points.map((p, i) => [p, i]));
const interpolation = widget.value.interpolation || 'linear';
const isPointMode = (interpolation === 'points' || interpolation === 'box');
const isBoxPointMode = interpolation === 'box';
const pointMarkerShape = isBoxPointMode ? 'square' : 'circle';
const orderedDots = isPointMode
? [...points].sort((a, b) => this.splineEditor.getBoxPointRadius(b) - this.splineEditor.getBoxPointRadius(a))
: points;
const getPointIndex = (dot) => {
if (!dot) return -1;
if (pointIndexMap.has(dot)) return pointIndexMap.get(dot);
if (this.splineEditor.resolvePointIndex) return this.splineEditor.resolvePointIndex(dot);
return this.splineEditor.points.indexOf(dot);
};
// Treat preview drawing as handdraw for styling when armed
const isHanddraw = (widget?.value?.type === 'handdraw') || !!this.splineEditor._handdrawActive;
@@ -302,7 +341,7 @@ export class LayerRenderer {
this.activeLayerPanel.add(pv.Line)
.data(() => {
// Special handling for curve rendering when points are highlighted
if (interpolation !== 'points' && interpolation !== 'linear') {
if (!isPointMode && interpolation !== 'linear') {
const hasAnyHighlighted = points.some(p => p.highlighted);
if (hasAnyHighlighted) {
// Insert duplicate points at highlighted positions to force linear segments
@@ -350,7 +389,7 @@ export class LayerRenderer {
})
.left(d => d.x)
.top(d => d.y)
.interpolate(() => interpolation === 'points' ? 'linear' : interpolation)
.interpolate(() => isPointMode ? 'linear' : interpolation)
.strokeStyle(() => {
if (isHanddraw) {
return '#d7c400'; // muted yellow for active handdraw
@@ -360,21 +399,28 @@ export class LayerRenderer {
.lineWidth(() => {
// Handdraw (active) should have same thickness as normal lines
if (isHanddraw) return 3;
return interpolation === 'points' ? 1 : 3;
return isPointMode ? 1 : 3;
});
// ALWAYS draw dots - bigger for points mode, all circles
// Use closures to ensure drag handlers can find points via indexOf()
this.activeLayerPanel.add(pv.Dot)
.data(() => points)
.data(() => orderedDots)
.left(d => d.x)
.top(d => d.y)
.radius(() => {
if (isHanddraw) return 2; // make yellow hand-draw points smaller
return interpolation === 'points' ? 9 : 6;
.radius((dot) => {
if (isHanddraw) return 2;
if (interpolation === 'box') {
return BOX_BASE_RADIUS * this.getPointScale(dot, true);
}
if (interpolation === 'points') {
return POINT_BASE_RADIUS;
}
return 6;
})
.shape((dot) => {
if (interpolation === 'points') return "circle";
if (interpolation === 'box') return "square";
// If interpolation is 'basis' AND the point is highlighted, draw a square
if (interpolation === 'basis' && dot.highlighted) return "square";
const index = this.splineEditor.resolvePointIndex
@@ -382,7 +428,7 @@ export class LayerRenderer {
: this.splineEditor.points.indexOf(dot);
return index === 0 ? "triangle" : "circle";
}) .angle((dot) => {
if (interpolation === 'points') return 0;
if (isPointMode) return 0;
const index = this.splineEditor.resolvePointIndex
? this.splineEditor.resolvePointIndex(dot)
: this.splineEditor.points.indexOf(dot);
@@ -396,26 +442,26 @@ export class LayerRenderer {
}
return 0;
})
.cursor("move")
.cursor(() => (isPointMode ? "default" : "move"))
.strokeStyle((dot) => {
const index = this.splineEditor.resolvePointIndex
? this.splineEditor.resolvePointIndex(dot)
: this.splineEditor.points.indexOf(dot);
if (!isHanddraw && index === 0 && interpolation !== 'points') {
if (!isHanddraw && index === 0 && !isPointMode) {
return "green";
}
if (isHanddraw) return '#d7c400';
return interpolation === 'points' ? "#139613" : "#1f77b4";
return isPointMode ? "#139613" : "#1f77b4";
})
.fillStyle((dot) => {
const index = this.splineEditor.resolvePointIndex
? this.splineEditor.resolvePointIndex(dot)
: this.splineEditor.points.indexOf(dot);
if (!isHanddraw && index === 0 && interpolation !== 'points') {
if (!isHanddraw && index === 0 && !isPointMode) {
return "rgba(0, 255, 0, 0.5)";
}
if (isHanddraw) return "rgba(215, 196, 0, 0.45)";
return interpolation === 'points' ? "rgba(19, 150, 19, 0.5)" : "rgba(100, 100, 100, 0.5)";
return isPointMode ? "rgba(19, 150, 19, 0.1)" : "rgba(100, 100, 100, 0.5)";
})
.event("mousedown", (dot) => {
if (this.splineEditor.handlePointPointerDown) {
@@ -424,6 +470,22 @@ export class LayerRenderer {
})
.event("mouseover", this.splineEditor.mouseOverHandler)
.event("mouseout", this.splineEditor.mouseOutHandler);
if (interpolation === 'box') {
this.activeLayerPanel.add(pv.Label)
.data(() => orderedDots)
.left(d => d.x - (BOX_BASE_RADIUS * this.getPointScale(d, true)) + 6)
.top(d => d.y - (BOX_BASE_RADIUS * this.getPointScale(d, true)) + 7)
.text((dot) => {
const idx = getPointIndex(dot);
return idx >= 0 ? `${idx + 1}` : "";
})
.textAlign("left")
.textBaseline("top")
.font("14px monospace")
.textStyle("#4dc55a")
.events("none");
}
// No labels for any mode - removed the label anchor
}
@@ -465,8 +527,11 @@ export class LayerRenderer {
});
const interpolation = widget.value.interpolation || 'linear';
const isPointMode = (interpolation === 'points' || interpolation === 'box');
const isBoxPointMode = interpolation === 'box';
const pointMarkerShape = isBoxPointMode ? 'square' : 'circle';
// For inactive points layers (non-handdraw), reduce line opacity to 20%
if (!isHanddraw && !isOff && interpolation === 'points') {
if (!isHanddraw && !isOff && isPointMode) {
strokeColor = "rgba(255, 127, 14, 0.2)";
}
@@ -475,7 +540,7 @@ export class LayerRenderer {
// For inactive layers, we also need to handle highlighted points for consistency
let renderPoints = pointsSnapshot;
if (interpolation !== 'points' && interpolation !== 'linear') {
if (!isPointMode && interpolation !== 'linear') {
const hasAnyHighlighted = points.some(p => p.highlighted);
if (hasAnyHighlighted) {
// Insert duplicate points at highlighted positions to force linear segments
@@ -510,12 +575,12 @@ export class LayerRenderer {
}
// ALWAYS draw line
const lineWidthMain = (interpolation === 'points') ? (isHanddraw ? 3 : 1) : 3;
const lineWidthMain = isPointMode ? (isHanddraw ? 3 : 1) : 3;
const inactiveLine = layerPanel.add(pv.Line).events("none")
.data(renderPoints)
.left(d => d.x)
.top(d => d.y)
.interpolate(interpolation === 'points' ? 'linear' : interpolation)
.interpolate(isPointMode ? 'linear' : interpolation)
.strokeStyle(strokeColor)
.lineWidth(lineWidthMain);
// Note: dash animation is applied post-render via updateInactiveDash()
@@ -526,7 +591,7 @@ export class LayerRenderer {
}
// Handle different interpolation modes for inactive layers
if (interpolation === 'points') {
if (isPointMode) {
const lineWidthPts = isHanddraw ? 3 : 1;
layerPanel.add(pv.Line).events("none")
.data(pointsSnapshot)
@@ -537,62 +602,25 @@ export class LayerRenderer {
.lineWidth(lineWidthPts);
// For points-type layers, also show point markers when inactive (non-handdraw only)
if (!isHanddraw && pointsSnapshot.length > 0) {
const inactiveDots = isPointMode
? [...pointsSnapshot].sort((a, b) => this.splineEditor.getBoxPointRadius(b) - this.splineEditor.getBoxPointRadius(a))
: pointsSnapshot;
layerPanel.add(pv.Dot).events("none")
.data(pointsSnapshot)
.data(inactiveDots)
.left(d => d.x)
.top(d => d.y)
.radius(7.0)
.shape('circle')
.strokeStyle(strokeColor)
.fillStyle(fillColor);
// Keep first-point triangle marker for direction
layerPanel.add(pv.Dot).events("none")
.data([pointsSnapshot[0]])
.left(d => d.x)
.top(d => d.y)
.radius(6)
.shape("triangle")
.strokeStyle(strokeColor)
.fillStyle(fillColor)
.angle((dot) => {
const points = pointsSnapshot;
if (points.length <= 1) return 0;
const firstPoint = points[0];
const secondPoint = points[1];
const dx = secondPoint.x - firstPoint.x;
const dy = secondPoint.y - firstPoint.y;
if (dx !== 0 || dy !== 0) {
const angle = Math.atan2(dy, dx) - Math.PI / 2;
return (angle + 2 * Math.PI) % (2 * Math.PI);
}
return 0;
});
.radius((dot) => {
if (isBoxPointMode) {
return BOX_BASE_RADIUS * this.getPointScale(dot, true);
}
return POINT_BASE_RADIUS;
})
.shape(pointMarkerShape)
.strokeStyle(strokeColor)
.fillStyle(isPointMode ? "rgba(19, 150, 19, 0.1)" : fillColor);
}
} else {
// ONLY draw triangle at first point for all other modes
if (!isHanddraw && pointsSnapshot.length > 0) {
layerPanel.add(pv.Dot)
.data([pointsSnapshot[0]])
.left(d => d.x)
.top(d => d.y)
.radius(6)
.shape("triangle")
.strokeStyle(strokeColor)
.fillStyle(fillColor)
.angle((dot) => {
const points = pointsSnapshot;
if (points.length <= 1) return 0;
const firstPoint = points[0];
const secondPoint = points[1];
const dx = secondPoint.x - firstPoint.x;
const dy = secondPoint.y - firstPoint.y;
if (dx !== 0 || dy !== 0) {
const angle = Math.atan2(dy, dx) - Math.PI / 2;
return (angle + 2 * Math.PI) % (2 * Math.PI);
}
return 0;
});
}
// No triangles for other modes
}
}
+213 -16
View File
@@ -1,5 +1,6 @@
import { app } from '../../../scripts/app.js';
import { LayerRenderer } from './layer_renderer.js';
import { BOX_BASE_RADIUS, BOX_BORDER_BAND } from './spline_utils.js';
export default class SplineEditor2 {
constructor(context, reset = false) {
@@ -129,7 +130,13 @@ export default class SplineEditor2 {
try {
const points = JSON.parse(activeWidget.value.points_store || '[]');
const denorm = this.denormalizePoints(points);
return denorm;
this.ensurePointScaleFields(denorm);
this.ensurePointUids(denorm);
return denorm.map(point => ({
...point,
scale: (typeof point.scale === 'number' ? point.scale : 1.0),
uid: point.uid
}));
} catch (e) {
console.error("[SplineEditor] getActivePoints: failed to parse points_store", e);
return [];
@@ -142,6 +149,7 @@ export default class SplineEditor2 {
console.warn("[SplineEditor] setActivePoints: no active widget");
return;
}
this.ensurePointUids(points);
activeWidget.value.points_store = JSON.stringify(this.normalizePoints(points));
// Only call updatePath if vis is already created
if (this.vis) {
@@ -260,12 +268,79 @@ export default class SplineEditor2 {
this.vis.render();
}
this.nextPointUid = 1;
this.ensurePointUids = (points) => {
if (!Array.isArray(points)) return;
let nextUid = this.nextPointUid || 1;
for (const point of points) {
if (!point) continue;
const rawUid = Number(point.uid);
const hasUid = (point.uid !== undefined && point.uid !== null && !Number.isNaN(rawUid));
if (hasUid) {
const candidate = Math.max(nextUid, rawUid + 1);
nextUid = candidate;
} else {
point.uid = nextUid;
nextUid += 1;
}
}
this.nextPointUid = nextUid;
};
this.clampScaleValue = (value) => {
if (typeof value !== 'number' || Number.isNaN(value)) return 1;
return Math.max(0.2, Math.min(3.0, value));
};
this.ensurePointScaleFields = (points) => {
if (!Array.isArray(points)) return;
for (const point of points) {
if (!point) continue;
const boxRaw = (typeof point.boxScale === 'number' && !Number.isNaN(point.boxScale))
? point.boxScale
: ((typeof point.scale === 'number' && !Number.isNaN(point.scale)) ? point.scale : 1);
point.boxScale = this.clampScaleValue(boxRaw);
const pointRaw = (typeof point.pointScale === 'number' && !Number.isNaN(point.pointScale))
? point.pointScale
: 1;
point.pointScale = this.clampScaleValue(pointRaw);
if (typeof point.scale !== 'number' || Number.isNaN(point.scale)) {
point.scale = point.boxScale;
} else {
point.scale = this.clampScaleValue(point.scale);
}
}
};
this.getPointScaleForMode = (point, forBox = true) => {
if (!point) return 1;
const candidate = (() => {
if (forBox && typeof point.boxScale === 'number' && !Number.isNaN(point.boxScale)) {
return point.boxScale;
}
if (!forBox && typeof point.pointScale === 'number' && !Number.isNaN(point.pointScale)) {
return point.pointScale;
}
if (typeof point.scale === 'number' && !Number.isNaN(point.scale)) {
return point.scale;
}
return 1;
})();
return this.clampScaleValue(candidate);
};
// Initialize or reset points array
this.drawRuler = true;
this.hoverIndex = -1;
this.isDragging = false;
this.isDraggingAll = false; // Flag for Alt+middle-drag (translation)
this.isScalingAll = false; // Flag for Alt+right-drag (scaling)
this.isScalingPoint = false; // Flag for Alt+right-drag when box mode (point scale)
this.scalingPointIndex = -1;
this.scalingPointBaseScale = 1.0;
this.scalingPointInitialDistance = 0;
this.isRotatingAll = false; // Flag for Alt+left-drag (rotation)
this.dragStartPos = null; // Store initial drag position
this.initialXDistance = 0; // Store initial X distance for horizontal scaling
@@ -290,16 +365,18 @@ export default class SplineEditor2 {
// Two points: center and 40px right
const centerX = this.width / 2;
const centerY = this.height / 2;
this.points = [
{ x: centerX, y: centerY, highlighted: false },
{ x: centerX + 40, y: centerY, highlighted: false }
this.points = [
{ x: centerX, y: centerY, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0 },
{ x: centerX + 40, y: centerY, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0 }
];
this.ensurePointUids(this.points);
} else {
// Default initial points (bottom-left, top-right)
this.points = [
{ x: 0, y: this.height, highlighted: false },
{ x: this.width, y: 0, highlighted: false }
{ x: 0, y: this.height, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0 },
{ x: this.width, y: 0, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0 }
];
this.ensurePointUids(this.points);
}
this.setActivePoints(this.points);
}
@@ -415,7 +492,11 @@ export default class SplineEditor2 {
let scaledMouse = {
x: mouseX,
y: mouseY,
highlighted: false
highlighted: false,
scale: 1.0,
boxScale: 1.0,
pointScale: 1.0,
uid: this.nextPointUid++
};
this.i = this.points.push(scaledMouse) - 1;
this.updatePath();
@@ -435,7 +516,11 @@ export default class SplineEditor2 {
let midpoint = {
x: (this.points[point1Index].x + this.points[point2Index].x) / 2,
y: (this.points[point1Index].y + this.points[point2Index].y) / 2,
highlighted: false
highlighted: false,
scale: 1.0,
boxScale: 1.0,
pointScale: 1.0,
uid: this.nextPointUid++
};
// Insert the midpoint into the array
@@ -502,11 +587,61 @@ export default class SplineEditor2 {
return this;
}
})
.event("mousemove", (e) => {
const coords = this._getPointerCoords(pv.event || e);
this.updateBoxCursor(coords);
})
.event("mouseout", () => {
const canvasEl = this.vis?.canvas?.();
if (canvasEl) canvasEl.style.cursor = 'default';
})
this.getBoxPointRadius = (point) => {
const boxScale = this.getPointScaleForMode(point, true);
return BOX_BASE_RADIUS * boxScale;
};
this.pickBoxPointFromCoords = (coords) => {
if (!coords || !Array.isArray(this.points)) return null;
const bandWidth = BOX_BORDER_BAND;
let best = null;
let bestEdgeDist = Infinity;
for (let idx = 0; idx < this.points.length; idx++) {
const point = this.points[idx];
if (!point) continue;
const dx = Math.abs(coords.x - (point.x ?? 0));
const dy = Math.abs(coords.y - (point.y ?? 0));
const maxDist = Math.max(dx, dy); // square border distance
const radius = this.getBoxPointRadius(point);
const inner = Math.max(radius - bandWidth, 0);
if (maxDist < inner || maxDist > radius) {
continue;
}
const edgeDist = Math.abs(radius - maxDist);
if (
edgeDist < bestEdgeDist ||
(Math.abs(edgeDist - bestEdgeDist) < 1e-3 && radius < (best?.radius ?? Infinity))
) {
bestEdgeDist = edgeDist;
best = { point, index: idx, radius };
}
}
return best;
};
this.resolvePointIndex = (dot) => {
if (!Array.isArray(this.points) || !dot) {
return -1;
}
const dotUid = dot?.uid;
if (dotUid !== undefined && dotUid !== null) {
for (let idx = 0; idx < this.points.length; idx++) {
const p = this.points[idx];
if (p && (p.uid === dotUid)) {
return idx;
}
}
}
const directIdx = this.points.indexOf(dot);
if (directIdx !== -1) {
return directIdx;
@@ -546,20 +681,46 @@ export default class SplineEditor2 {
};
};
this.updateBoxCursor = (coords) => {
const canvasEl = this.vis?.canvas?.();
if (!canvasEl) return;
const activeInterp = this.getActiveWidget()?.value?.interpolation || this.interpolation;
let desired = 'default';
if (activeInterp === 'box' && coords) {
const picked = this.pickBoxPointFromCoords(coords);
if (picked) {
desired = 'move';
}
}
if (canvasEl.style.cursor !== desired) {
canvasEl.style.cursor = desired;
}
};
this.handlePointPointerDown = (dot, pvEvent) => {
const event = pvEvent || window.event;
if (!event) return;
const { x, y } = this._getPointerCoords(event);
const activeInterp = this.getActiveWidget()?.value?.interpolation || this.interpolation;
let dragDot = dot;
if (activeInterp === 'box') {
const picked = this.pickBoxPointFromCoords({ x, y });
if (!picked) {
return;
}
dragDot = picked.point;
}
event.preventDefault?.();
event.stopPropagation?.();
const { x, y } = this._getPointerCoords(event);
this.dragStartHandler(dot, x, y, event);
this.dragStartHandler(dragDot, x, y, event);
const dragTarget = dragDot;
const needsTracking = this.isDragging || this.isDraggingAll || this.isScalingAll || this.isRotatingAll;
if (!needsTracking) return;
const usePointer = typeof PointerEvent !== 'undefined';
const move = (evt) => {
evt.preventDefault?.();
const coords = this._getPointerCoords(evt);
this.dragHandler(dot, coords.x, coords.y);
this.dragHandler(dragTarget, coords.x, coords.y);
};
const end = (evt) => {
cleanup();
@@ -647,6 +808,23 @@ export default class SplineEditor2 {
// Alt+Right-Click: Scaling relative to clicked point (any point)
if ((rawEvent?.altKey) && (rawEvent?.button === 2)) {
const activeInterp = this.getActiveWidget()?.value?.interpolation || this.interpolation;
if (activeInterp === 'box') {
this.isScalingPoint = true;
this.scalingPointIndex = this.i;
const targetPoint = this.points[this.i];
if (!targetPoint) {
this.isScalingPoint = false;
return;
}
this.scalingPointBaseScale = (targetPoint.scale || 1.0);
this.scalingPointInitialDistance = localX - targetPoint.x;
if (Math.abs(this.scalingPointInitialDistance) < 10) {
this.scalingPointInitialDistance = this.scalingPointInitialDistance >= 0 ? 10 : -10;
}
this.isDragging = true;
return;
}
this.isScalingAll = true;
this.originalPoints = this.points.map(p => ({ ...p }));
this.anchorPoint = { ...this.points[this.i] }; // Use clicked point as anchor
@@ -682,6 +860,10 @@ export default class SplineEditor2 {
this.isDragging = false;
this.isDraggingAll = false;
this.isScalingAll = false;
this.isScalingPoint = false;
this.scalingPointIndex = -1;
this.scalingPointBaseScale = 1.0;
this.scalingPointInitialDistance = 0;
this.isRotatingAll = false;
this.dragStartPos = null;
this.initialXDistance = 0;
@@ -744,9 +926,24 @@ export default class SplineEditor2 {
return;
}
if (this.isScalingPoint && this.scalingPointIndex >= 0 && this.scalingPointInitialDistance !== 0) {
const point = this.points[this.scalingPointIndex];
if (point) {
const currentXDistance = adjustedX - point.x;
const scaleFactor = currentXDistance / this.scalingPointInitialDistance;
const dampingFactor = 0.1;
const dampedFactor = 1.0 + (scaleFactor - 1.0) * dampingFactor;
const newScale = Math.max(0.2, Math.min(3.0, this.scalingPointBaseScale * dampedFactor));
point.boxScale = newScale;
point.scale = newScale;
this.layerRenderer.render();
}
return;
}
if (this.isScalingAll && this.anchorPoint && this.originalPoints && this.initialXDistance !== 0) {
const currentXDistance = adjustedX - this.anchorPoint.x;
const scaleFactor = currentXDistance / this.initialXDistance;
const currentXDistance = adjustedX - this.anchorPoint.x;
const scaleFactor = currentXDistance / this.initialXDistance;
const dampingFactor = 0.1;
const dampedScaleFactor = 1.0 + (scaleFactor - 1.0) * dampingFactor;
const clampedScaleFactor = Math.max(0.1, Math.min(10, dampedScaleFactor));
@@ -807,7 +1004,7 @@ export default class SplineEditor2 {
// Helper to get render points with duplicates at highlighted positions
// This forces hard corners while keeping the curve continuous
this.getRenderPoints = () => {
if (this.interpolation === 'linear' || this.interpolation === 'points') {
if (this.interpolation === 'linear' || this.interpolation === 'points' || this.interpolation === 'box') {
return this.points;
}
@@ -1226,7 +1423,7 @@ export default class SplineEditor2 {
}
}
if (widget.value.interpolation === 'points' || !usePathSampling) {
if (widget.value.interpolation === 'points' || widget.value.interpolation === 'box' || !usePathSampling) {
sampledCoords = controlPoints;
} else if (pathElement) {
sampledCoords = [];
+9 -4
View File
@@ -32,6 +32,10 @@ export {
};
//from melmass
export const BOX_BASE_RADIUS = 40;
export const POINT_BASE_RADIUS = 8;
export const BOX_BORDER_BAND = 20;
export function makeUUID() {
let dt = new Date().getTime()
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
@@ -637,8 +641,8 @@ export class InterpolationWidget extends RgthreeBaseWidget {
const interpValue = interpWidget ? interpWidget.value : "linear";
// Interpolation modes - display abbreviated versions
const interpModes = ['linear', 'cardinal', 'basis', 'points'];
const interpShortNames = ['linear', 'cardinal', 'basis', 'points'];
const interpModes = ['linear', 'cardinal', 'basis', 'points', 'box'];
const interpShortNames = ['linear', 'cardinal', 'basis', 'points', 'box'];
const interpIndex = interpModes.indexOf(interpValue);
const interpDisplayText = interpIndex >= 0 ? interpShortNames[interpIndex] : 'linear';
@@ -656,7 +660,8 @@ export class InterpolationWidget extends RgthreeBaseWidget {
ctx.measureText("cardinal").width,
ctx.measureText("linear").width,
ctx.measureText("basis").width,
ctx.measureText("points").width
ctx.measureText("points").width,
ctx.measureText("box").width
) + 20; // Add padding
const arrowWidth = 9;
@@ -698,7 +703,7 @@ export class InterpolationWidget extends RgthreeBaseWidget {
stepInterp(node, step) {
const widget = node.widgets?.find(w => w.name === "interpolation");
if (widget) {
const modes = ['linear', 'cardinal', 'basis', 'points'];
const modes = ['linear', 'cardinal', 'basis', 'points', 'box'];
const currentIndex = modes.indexOf(widget.value);
const newIndex = (currentIndex + step + modes.length) % modes.length;
widget.value = modes[newIndex];
+4 -4
View File
@@ -192,8 +192,8 @@ export class PowerSplineWidget extends RgthreeBaseWidget {
rposX -= drawNumberWidgetPart.WIDTH_TOTAL + 10;
// Draw interpolation selector
const interpModes = ['linear', 'cardinal', 'basis', 'points'];
const interpShortNames = ['linear', 'cardinal', 'basis', 'points'];
const interpModes = ['linear', 'cardinal', 'basis', 'points', 'box'];
const interpShortNames = ['linear', 'cardinal', 'basis', 'points', 'box'];
const interpIndex = interpModes.indexOf(this.value.interpolation || 'linear');
const interpDisplayText = interpIndex >= 0 ? interpShortNames[interpIndex] : 'linear';
const interpTextWidth = 70;
@@ -340,7 +340,7 @@ export class PowerSplineWidget extends RgthreeBaseWidget {
}
onInterpDec() {
const modes = ['linear', 'cardinal', 'basis', 'points'];
const modes = ['linear', 'cardinal', 'basis', 'points', 'box'];
const currentIndex = modes.indexOf(this.value.interpolation || 'linear');
const newIndex = (currentIndex - 1 + modes.length) % modes.length;
this.value.interpolation = modes[newIndex];
@@ -355,7 +355,7 @@ export class PowerSplineWidget extends RgthreeBaseWidget {
}
onInterpInc() {
const modes = ['linear', 'cardinal', 'basis', 'points'];
const modes = ['linear', 'cardinal', 'basis', 'points', 'box'];
const currentIndex = modes.indexOf(this.value.interpolation || 'linear');
const newIndex = (currentIndex + 1) % modes.length;
this.value.interpolation = modes[newIndex];