| | import { app } from '../../../scripts/app.js' |
| |
|
| | |
| | export function makeUUID() { |
| | let dt = new Date().getTime() |
| | const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { |
| | const r = ((dt + Math.random() * 16) % 16) | 0 |
| | dt = Math.floor(dt / 16) |
| | return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) |
| | }) |
| | return uuid |
| | } |
| |
|
| | export const loadScript = ( |
| | FILE_URL, |
| | async = true, |
| | type = 'text/javascript', |
| | ) => { |
| | return new Promise((resolve, reject) => { |
| | try { |
| | |
| | const existingScript = document.querySelector(`script[src="${FILE_URL}"]`) |
| | if (existingScript) { |
| | resolve({ status: true, message: 'Script already loaded' }) |
| | return |
| | } |
| | |
| | const scriptEle = document.createElement('script') |
| | scriptEle.type = type |
| | scriptEle.async = async |
| | scriptEle.src = FILE_URL |
| | |
| | scriptEle.addEventListener('load', (ev) => { |
| | resolve({ status: true }) |
| | }) |
| | |
| | scriptEle.addEventListener('error', (ev) => { |
| | reject({ |
| | status: false, |
| | message: `Failed to load the script ${FILE_URL}`, |
| | }) |
| | }) |
| | |
| | document.body.appendChild(scriptEle) |
| | } catch (error) { |
| | reject(error) |
| | } |
| | }) |
| | } |
| | const create_documentation_stylesheet = () => { |
| | const tag = 'kj-splineditor-stylesheet' |
| |
|
| | let styleTag = document.head.querySelector(tag) |
| |
|
| | if (!styleTag) { |
| | styleTag = document.createElement('style') |
| | styleTag.type = 'text/css' |
| | styleTag.id = tag |
| | styleTag.innerHTML = ` |
| | .spline-editor { |
| | |
| | position: absolute; |
| | |
| | font: 12px monospace; |
| | line-height: 1.5em; |
| | padding: 10px; |
| | z-index: 0; |
| | overflow: hidden; |
| | } |
| | ` |
| | document.head.appendChild(styleTag) |
| | } |
| | } |
| |
|
| | loadScript('/kjweb_async/svg-path-properties.min.js').catch((e) => { |
| | console.log(e) |
| | }) |
| | loadScript('/kjweb_async/protovis.min.js').catch((e) => { |
| | console.log(e) |
| | }) |
| | create_documentation_stylesheet() |
| |
|
| | function chainCallback(object, property, callback) { |
| | if (object == undefined) { |
| | |
| | console.error("Tried to add callback to non-existant object") |
| | return; |
| | } |
| | if (property in object) { |
| | const callback_orig = object[property] |
| | object[property] = function () { |
| | const r = callback_orig.apply(this, arguments); |
| | callback.apply(this, arguments); |
| | return r |
| | }; |
| | } else { |
| | object[property] = callback; |
| | } |
| | } |
| | app.registerExtension({ |
| | name: 'KJNodes.SplineEditor', |
| | |
| | async beforeRegisterNodeDef(nodeType, nodeData) { |
| | if (nodeData?.name === 'SplineEditor') { |
| | chainCallback(nodeType.prototype, "onNodeCreated", function () { |
| | |
| | hideWidgetForGood(this, this.widgets.find(w => w.name === "coordinates")) |
| |
|
| | var element = document.createElement("div"); |
| | this.uuid = makeUUID() |
| | element.id = `spline-editor-${this.uuid}` |
| |
|
| | this.splineEditor = this.addDOMWidget(nodeData.name, "SplineEditorWidget", element, { |
| | serialize: false, |
| | hideOnZoom: false, |
| | }); |
| |
|
| | |
| | this.contextMenu = document.createElement("div"); |
| | this.contextMenu.id = "context-menu"; |
| | this.contextMenu.style.display = "none"; |
| | this.contextMenu.style.position = "absolute"; |
| | this.contextMenu.style.backgroundColor = "#202020"; |
| | this.contextMenu.style.minWidth = "100px"; |
| | this.contextMenu.style.boxShadow = "0px 8px 16px 0px rgba(0,0,0,0.2)"; |
| | this.contextMenu.style.zIndex = "100"; |
| | this.contextMenu.style.padding = "5px"; |
| |
|
| | function styleMenuItem(menuItem) { |
| | menuItem.style.display = "block"; |
| | menuItem.style.padding = "5px"; |
| | menuItem.style.color = "#FFF"; |
| | menuItem.style.fontFamily = "Arial, sans-serif"; |
| | menuItem.style.fontSize = "16px"; |
| | menuItem.style.textDecoration = "none"; |
| | menuItem.style.marginBottom = "5px"; |
| | } |
| | function createMenuItem(id, textContent) { |
| | let menuItem = document.createElement("a"); |
| | menuItem.href = "#"; |
| | menuItem.id = `menu-item-${id}`; |
| | menuItem.textContent = textContent; |
| | styleMenuItem(menuItem); |
| | return menuItem; |
| | } |
| | |
| | |
| | this.menuItems = [ |
| | createMenuItem(1, "Toggle handles"), |
| | createMenuItem(2, "Display sample points"), |
| | createMenuItem(3, "Switch point shape"), |
| | createMenuItem(4, "Background image"), |
| | createMenuItem(5, "Invert point order") |
| | ]; |
| | |
| | |
| | this.menuItems.forEach(menuItem => { |
| | menuItem.addEventListener('mouseover', function() { |
| | this.style.backgroundColor = "gray"; |
| | }); |
| | |
| | menuItem.addEventListener('mouseout', function() { |
| | this.style.backgroundColor = "#202020"; |
| | }); |
| | }); |
| | |
| | |
| | this.menuItems.forEach(menuItem => { |
| | this.contextMenu.appendChild(menuItem); |
| | }); |
| |
|
| | document.body.appendChild( this.contextMenu); |
| |
|
| | this.addWidget("button", "New spline", null, () => { |
| | if (!this.properties || !("points" in this.properties)) { |
| | createSplineEditor(this) |
| | this.addProperty("points", this.constructor.type, "string"); |
| | } |
| | else { |
| | createSplineEditor(this, true) |
| | } |
| | }); |
| | |
| | this.setSize([550, 950]); |
| | this.resizable = false; |
| | this.splineEditor.parentEl = document.createElement("div"); |
| | this.splineEditor.parentEl.className = "spline-editor"; |
| | this.splineEditor.parentEl.id = `spline-editor-${this.uuid}` |
| | element.appendChild(this.splineEditor.parentEl); |
| | |
| | chainCallback(this, "onConfigure", function() { |
| | createSplineEditor(this); |
| | }); |
| | |
| | }); |
| | } |
| | } |
| | }) |
| |
|
| |
|
| | function createSplineEditor(context, reset=false) { |
| | console.log("creatingSplineEditor") |
| |
|
| | |
| | function createContextMenu() { |
| | document.addEventListener('contextmenu', function(e) { |
| | e.preventDefault(); |
| | }); |
| |
|
| | document.addEventListener('click', function(e) { |
| | if (!context.contextMenu.contains(e.target)) { |
| | context.contextMenu.style.display = 'none'; |
| | } |
| | }); |
| |
|
| | context.menuItems.forEach((menuItem, index) => { |
| | menuItem.addEventListener('click', function(e) { |
| | e.preventDefault(); |
| | |
| | switch (index) { |
| | case 0: |
| | e.preventDefault(); |
| | if (!drawHandles) { |
| | drawHandles = true |
| | vis.add(pv.Line) |
| | .data(() => points.map((point, index) => ({ |
| | start: point, |
| | end: [index] |
| | }))) |
| | .left(d => d.start.x) |
| | .top(d => d.start.y) |
| | .interpolate("linear") |
| | .tension(0) |
| | .strokeStyle("#ff7f0e") |
| | .lineWidth(1) |
| | .visible(() => drawHandles); |
| | vis.render(); |
| | } else { |
| | drawHandles = false |
| | vis.render(); |
| | } |
| | context.contextMenu.style.display = 'none'; |
| | break; |
| | case 1: |
| | e.preventDefault(); |
| | drawSamplePoints = !drawSamplePoints; |
| | updatePath(); |
| | break; |
| | case 2: |
| | e.preventDefault(); |
| | if (dotShape == "circle"){ |
| | dotShape = "triangle" |
| | } |
| | else { |
| | dotShape = "circle" |
| | } |
| | console.log(dotShape) |
| | updatePath(); |
| | break; |
| | case 3: |
| | |
| | const fileInput = document.createElement('input'); |
| | fileInput.type = 'file'; |
| | fileInput.accept = 'image/*'; |
| |
|
| | |
| | fileInput.addEventListener('change', function(event) { |
| | const file = event.target.files[0]; |
| |
|
| | if (file) { |
| | |
| | const imageUrl = URL.createObjectURL(file); |
| | |
| | |
| | backgroundImage |
| | .url(imageUrl) |
| | .visible(true) |
| | .root.render(); |
| | } |
| | }); |
| |
|
| | |
| | if (backgroundImage.visible()) { |
| | backgroundImage.visible(false) |
| | .root.render(); |
| | } else { |
| | |
| | fileInput.click(); |
| | } |
| | context.contextMenu.style.display = 'none'; |
| | break; |
| | case 4: |
| | e.preventDefault(); |
| | points.reverse(); |
| | updatePath(); |
| | } |
| | }); |
| | }); |
| | } |
| |
|
| | var dotShape = "circle"; |
| | var drawSamplePoints = false; |
| | |
| | createContextMenu(); |
| | function updatePath() { |
| | if (samplingMethod != "controlpoints") { |
| | var coords = samplePoints(pathElements[0], points_to_sample, samplingMethod, w); |
| | } |
| | else { |
| | var coords = points |
| | } |
| |
|
| | if (drawSamplePoints) { |
| | if (pointsLayer) { |
| | |
| | pointsLayer.data(coords); |
| | } else { |
| | |
| | pointsLayer = vis.add(pv.Dot) |
| | .data(coords) |
| | .left(function(d) { return d.x; }) |
| | .top(function(d) { return d.y; }) |
| | .radius(5) |
| | .fillStyle("red") |
| | .strokeStyle("black") |
| | .lineWidth(1); |
| | } |
| | } else { |
| | if (pointsLayer) { |
| | |
| | pointsLayer.data([]); |
| | vis.render(); |
| | } |
| | } |
| | let coordsString = JSON.stringify(coords); |
| | pointsStoreWidget.value = JSON.stringify(points); |
| | if (coordWidget) { |
| | coordWidget.value = coordsString; |
| | } |
| | vis.render(); |
| | } |
| | |
| | if (reset && context.splineEditor.element) { |
| | context.splineEditor.element.innerHTML = ''; |
| | } |
| | const coordWidget = context.widgets.find(w => w.name === "coordinates"); |
| | const interpolationWidget = context.widgets.find(w => w.name === "interpolation"); |
| | const pointsWidget = context.widgets.find(w => w.name === "points_to_sample"); |
| | const pointsStoreWidget = context.widgets.find(w => w.name === "points_store"); |
| | const tensionWidget = context.widgets.find(w => w.name === "tension"); |
| | const minValueWidget = context.widgets.find(w => w.name === "min_value"); |
| | const maxValueWidget = context.widgets.find(w => w.name === "max_value"); |
| | const samplingMethodWidget = context.widgets.find(w => w.name === "sampling_method"); |
| | const widthWidget = context.widgets.find(w => w.name === "mask_width"); |
| | const heightWidget = context.widgets.find(w => w.name === "mask_height"); |
| | |
| |
|
| | var interpolation = interpolationWidget.value |
| | var tension = tensionWidget.value |
| | var points_to_sample = pointsWidget.value |
| | var rangeMin = minValueWidget.value |
| | var rangeMax = maxValueWidget.value |
| | var pointsLayer = null; |
| | var samplingMethod = samplingMethodWidget.value |
| | |
| | if (samplingMethod == "path") { |
| | dotShape = "triangle" |
| | } |
| | |
| | |
| | interpolationWidget.callback = () => { |
| | interpolation = interpolationWidget.value |
| | updatePath(); |
| | } |
| | samplingMethodWidget.callback = () => { |
| | samplingMethod = samplingMethodWidget.value |
| | if (samplingMethod == "path") { |
| | dotShape = "triangle" |
| | } |
| | else if (samplingMethod == "controlpoints") { |
| | dotShape = "circle" |
| | drawSamplePoints = true; |
| | } |
| | updatePath(); |
| | } |
| | tensionWidget.callback = () => { |
| | tension = tensionWidget.value |
| | updatePath(); |
| | } |
| | pointsWidget.callback = () => { |
| | points_to_sample = pointsWidget.value |
| | updatePath(); |
| | } |
| | minValueWidget.callback = () => { |
| | rangeMin = minValueWidget.value |
| | updatePath(); |
| | } |
| | maxValueWidget.callback = () => { |
| | rangeMax = maxValueWidget.value |
| | updatePath(); |
| | } |
| | widthWidget.callback = () => { |
| | w = widthWidget.value; |
| | if (w > 256) { |
| | context.setSize([w + 45, context.size[1]]); |
| | } |
| | vis.width(w); |
| | updatePath(); |
| | } |
| | heightWidget.callback = () => { |
| | h = heightWidget.value |
| | vis.height(h) |
| | context.setSize([context.size[0], h + 430]); |
| | updatePath(); |
| | } |
| | pointsStoreWidget.callback = () => { |
| | points = JSON.parse(pointsStoreWidget.value); |
| | updatePath(); |
| | } |
| | |
| | |
| | var drawHandles = false; |
| | var hoverIndex = -1; |
| | var isDragging = false; |
| | var w = widthWidget.value; |
| | var h = heightWidget.value; |
| | var i = 3; |
| | let points = []; |
| |
|
| | if (!reset && pointsStoreWidget.value != "") { |
| | points = JSON.parse(pointsStoreWidget.value); |
| | } else { |
| | points = pv.range(1, 4).map((i, index) => { |
| | if (index === 0) { |
| | |
| | return { x: 0, y: h }; |
| | } else if (index === 2) { |
| | |
| | return { x: w, y: 0 }; |
| | } else { |
| | |
| | return { |
| | x: i * w / 5, |
| | y: 50 + Math.random() * (h - 100) |
| | }; |
| | } |
| | }); |
| | pointsStoreWidget.value = JSON.stringify(points); |
| | } |
| | |
| | var vis = new pv.Panel() |
| | .width(w) |
| | .height(h) |
| | .fillStyle("#222") |
| | .strokeStyle("gray") |
| | .lineWidth(2) |
| | .antialias(false) |
| | .margin(10) |
| | .event("mousedown", function() { |
| | if (pv.event.shiftKey) { |
| | let scaledMouse = { |
| | x: this.mouse().x / app.canvas.ds.scale, |
| | y: this.mouse().y / app.canvas.ds.scale |
| | }; |
| | i = points.push(scaledMouse) - 1; |
| | updatePath(); |
| | return this; |
| | } |
| | else if (pv.event.ctrlKey) { |
| | |
| | let clickedPoint = { |
| | x: this.mouse().x / app.canvas.ds.scale, |
| | y: this.mouse().y / app.canvas.ds.scale |
| | }; |
| |
|
| | |
| | let { point1Index, point2Index } = findClosestPoints(points, clickedPoint); |
| |
|
| | |
| | let midpoint = { |
| | x: (points[point1Index].x + points[point2Index].x) / 2, |
| | y: (points[point1Index].y + points[point2Index].y) / 2 |
| | }; |
| |
|
| | |
| | points.splice(point2Index, 0, midpoint); |
| | i = point2Index; |
| | updatePath(); |
| | } |
| | else if (pv.event.button === 2) { |
| | context.contextMenu.style.display = 'block'; |
| | context.contextMenu.style.left = `${pv.event.clientX}px`; |
| | context.contextMenu.style.top = `${pv.event.clientY}px`; |
| | } |
| | }) |
| | var backgroundImage = vis.add(pv.Image) |
| | .visible(false) |
| | vis.add(pv.Rule) |
| | .data(pv.range(0, h, 64)) |
| | .bottom(d => d) |
| | .strokeStyle("gray") |
| | .lineWidth(3) |
| |
|
| | |
| | |
| | |
| | |
| | |
| |
|
| | vis.add(pv.Line) |
| | .data(() => points) |
| | .left(d => d.x) |
| | .top(d => d.y) |
| | .interpolate(() => interpolation) |
| | .tension(() => tension) |
| | .segmented(() => false) |
| | .strokeStyle(pv.Colors.category10().by(pv.index)) |
| | .lineWidth(3) |
| | |
| | vis.add(pv.Dot) |
| | .data(() => points) |
| | .left(d => d.x) |
| | .top(d => d.y) |
| | .radius(10) |
| | .shape(function() { |
| | return dotShape; |
| | }) |
| | .angle(function() { |
| | const index = this.index; |
| | let angle = 0; |
| |
|
| | if (dotShape === "triangle") { |
| | let dxNext = 0, dyNext = 0; |
| | if (index < points.length - 1) { |
| | dxNext = points[index + 1].x - points[index].x; |
| | dyNext = points[index + 1].y - points[index].y; |
| | } |
| |
|
| | let dxPrev = 0, dyPrev = 0; |
| | if (index > 0) { |
| | dxPrev = points[index].x - points[index - 1].x; |
| | dyPrev = points[index].y - points[index - 1].y; |
| | } |
| |
|
| | const dx = (dxNext + dxPrev) / 2; |
| | const dy = (dyNext + dyPrev) / 2; |
| |
|
| | angle = Math.atan2(dy, dx); |
| | angle -= Math.PI / 2; |
| | angle = (angle + 2 * Math.PI) % (2 * Math.PI); |
| | } |
| |
|
| | return angle; |
| | }) |
| | .cursor("move") |
| | .strokeStyle(function() { return i == this.index ? "#ff7f0e" : "#1f77b4"; }) |
| | .fillStyle(function() { return "rgba(100, 100, 100, 0.3)"; }) |
| | .event("mousedown", pv.Behavior.drag()) |
| | .event("dragstart", function() { |
| | i = this.index; |
| | hoverIndex = this.index; |
| | isDragging = true; |
| | if (pv.event.button === 2 && i !== 0 && i !== points.length - 1) { |
| | points.splice(i--, 1); |
| | vis.render(); |
| | } |
| | return this; |
| | }) |
| | .event("dragend", function() { |
| | if (this.pathElements !== null) { |
| | updatePath(); |
| | } |
| | isDragging = false; |
| | }) |
| | .event("drag", function() { |
| | let adjustedX = this.mouse().x / app.canvas.ds.scale; |
| | let adjustedY = this.mouse().y / app.canvas.ds.scale; |
| | |
| | const panelWidth = vis.width(); |
| | const panelHeight = vis.height(); |
| |
|
| | |
| | adjustedX = Math.max(0, Math.min(panelWidth, adjustedX)); |
| | adjustedY = Math.max(0, Math.min(panelHeight, adjustedY)); |
| | points[this.index] = { x: adjustedX, y: adjustedY }; |
| | vis.render(); |
| | }) |
| | .event("mouseover", function() { |
| | hoverIndex = this.index; |
| | vis.render(); |
| | }) |
| | .event("mouseout", function() { |
| | !isDragging && (hoverIndex = -1); |
| | vis.render(); |
| | }) |
| | .anchor("center") |
| | .add(pv.Label) |
| | .visible(function() { |
| | return hoverIndex === this.index; |
| | }) |
| | .left(d => d.x < w / 2 ? d.x + 80 : d.x - 70) |
| | .top(d => d.y < h / 2 ? d.y + 20 : d.y - 20) |
| | .font(12 + "px sans-serif") |
| | .text(d => { |
| | if (samplingMethod == "path") { |
| | return `X: ${Math.round(d.x)}, Y: ${Math.round(d.y)}`; |
| | } else { |
| | let frame = Math.round((d.x / w) * points_to_sample); |
| | let normalizedY = (1.0 - (d.y / h) - 0.0) * (rangeMax - rangeMin) + rangeMin; |
| | let normalizedX = (d.x / w); |
| | return `F: ${frame}, X: ${normalizedX.toFixed(2)}, Y: ${normalizedY.toFixed(2)}`; |
| | } |
| | }) |
| | .textStyle("orange") |
| | |
| | vis.render(); |
| | var svgElement = vis.canvas(); |
| | svgElement.style['zIndex'] = "2" |
| | svgElement.style['position'] = "relative" |
| | context.splineEditor.element.appendChild(svgElement); |
| | var pathElements = svgElement.getElementsByTagName('path'); |
| |
|
| | if (w > 256) { |
| | context.setSize([w + 45, context.size[1]]); |
| | } |
| | context.setSize([context.size[0], h + 430]); |
| | updatePath(); |
| | } |
| |
|
| | function samplePoints(svgPathElement, numSamples, samplingMethod, width) { |
| | var svgWidth = width; |
| | var pathLength = svgPathElement.getTotalLength(); |
| | var points = []; |
| |
|
| | for (var i = 0; i < numSamples; i++) { |
| | if (samplingMethod === "time") { |
| | |
| | var x = (svgWidth / (numSamples - 1)) * i; |
| | |
| | var point = findPointAtX(svgPathElement, x, pathLength); |
| | } |
| | else if (samplingMethod === "path") { |
| | |
| | var distance = (pathLength / (numSamples - 1)) * i; |
| | |
| | var point = svgPathElement.getPointAtLength(distance); |
| | } |
| |
|
| | |
| | points.push({ x: point.x, y: point.y }); |
| | } |
| | return points; |
| | } |
| |
|
| | function findClosestPoints(points, clickedPoint) { |
| | |
| | let distances = points.map(point => { |
| | let dx = clickedPoint.x - point.x; |
| | let dy = clickedPoint.y - point.y; |
| | return { index: points.indexOf(point), distance: Math.sqrt(dx * dx + dy * dy) }; |
| | }); |
| | |
| | let sortedDistances = distances.sort((a, b) => a.distance - b.distance); |
| | let closestPoint1Index = sortedDistances[0].index; |
| | let closestPoint2Index = sortedDistances[1].index; |
| | |
| | if (closestPoint1Index > closestPoint2Index) { |
| | [closestPoint1Index, closestPoint2Index] = [closestPoint2Index, closestPoint1Index]; |
| | } |
| | return { point1Index: closestPoint1Index, point2Index: closestPoint2Index }; |
| | } |
| |
|
| | function findPointAtX(svgPathElement, targetX, pathLength) { |
| | let low = 0; |
| | let high = pathLength; |
| | let bestPoint = svgPathElement.getPointAtLength(0); |
| |
|
| | while (low <= high) { |
| | let mid = low + (high - low) / 2; |
| | let point = svgPathElement.getPointAtLength(mid); |
| |
|
| | if (Math.abs(point.x - targetX) < 1) { |
| | return point; |
| | } |
| |
|
| | if (point.x < targetX) { |
| | low = mid + 1; |
| | } else { |
| | high = mid - 1; |
| | } |
| |
|
| | |
| | if (Math.abs(point.x - targetX) < Math.abs(bestPoint.x - targetX)) { |
| | bestPoint = point; |
| | } |
| | } |
| |
|
| | |
| | return bestPoint; |
| | } |
| |
|
| | |
| | export function hideWidgetForGood(node, widget, suffix = '') { |
| | widget.origType = widget.type |
| | widget.origComputeSize = widget.computeSize |
| | widget.origSerializeValue = widget.serializeValue |
| | widget.computeSize = () => [0, -4] |
| | widget.type = "converted-widget" + suffix |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | |
| | if (widget.linkedWidgets) { |
| | for (const w of widget.linkedWidgets) { |
| | hideWidgetForGood(node, w, ':' + widget.name) |
| | } |
| | } |
| | } |