diff --git a/.devcontainer/Containerfile b/.devcontainer/Containerfile index 9a0ca483..e7b53e27 100644 --- a/.devcontainer/Containerfile +++ b/.devcontainer/Containerfile @@ -6,7 +6,14 @@ RUN sudo apt-get update \ && sudo apt-get -y install --no-install-recommends \ clang \ python3-venv \ - udev + udev \ + pkg-config \ + libcairo2-dev \ + libpango1.0-dev \ + libjpeg-dev \ + libgif-dev \ + librsvg2-dev \ + libpixman-1-dev ## Set up udev rules for PlatformIO RUN curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/system/99-platformio-udev.rules | sudo tee /etc/udev/rules.d/99-platformio-udev.rules diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..6dcaee19 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/git-lfs:1": { + "version": "1.2.5", + "resolved": "ghcr.io/devcontainers/features/git-lfs@sha256:71c2b371cf12ab7fcec47cf17369c6f59156100dad9abf9e4c593049d789de72", + "integrity": "sha256:71c2b371cf12ab7fcec47cf17369c6f59156100dad9abf9e4c593049d789de72" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0fb3f9f4..3e715523 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,20 +10,20 @@ }, "customizations": { "vscode": { - "settings": { - "terminal.integrated.defaultProfile.linux": "zsh" - }, "extensions": [ "ms-vscode.cpptools", - "platformio.platformio-ide" + "platformio.platformio-ide", + "Anthropic.claude-code" ] } }, "forwardPorts": [ - 8008 + 8008, + 5173 ], "mounts": [ - "source=/dev/,target=/dev/,type=bind,consistency=consistent" + "source=/dev/,target=/dev/,type=bind,consistency=consistent", + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached" ], "runArgs": [ "--privileged" @@ -34,5 +34,6 @@ }, "ghcr.io/devcontainers/features/git-lfs:1": "latest" }, - "postAttachCommand": "sudo service udev restart" + "postAttachCommand": "sudo service udev restart", + "postCreateCommand": "npm i --prefix worker && npm i --prefix ui" } \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3f9c08ea..741ad3cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ .pio -data/www/worker/* -tsc/node_modules -tsc/package-lock.json -tsc/dist -tsc/dist_packed -tsc/svgs/* +data/www/* +node_modules +worker/package-lock.json +worker/dist +worker/dist_packed +worker/svgs/* .DS_Store .vscode/c_cpp_properties.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index bd068d61..10c785b1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,13 +10,13 @@ "configurations": [ { "name": "Launch SVG Tester", - "program": "${workspaceFolder}/tsc/dist/tester.js", + "program": "${workspaceFolder}/worker/dist/tester.js", "request": "launch", "skipFiles": [ "/**" ], "type": "node", - "preLaunchTask": "tsc: build - tsc/tsconfig.json", + "preLaunchTask": "worker: build - worker/tsconfig.json", "env": { "server": "true" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index fc3f0e82..c879a9b5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,12 +3,12 @@ "tasks": [ { "type": "typescript", - "tsconfig": "tsc/tsconfig.json", + "tsconfig": "worker/tsconfig.json", "problemMatcher": [ "$tsc" ], "group": "build", - "label": "tsc: build - tsc/tsconfig.json" + "label": "worker: build - worker/tsconfig.json" } ] } \ No newline at end of file diff --git a/build.py b/build.py index ec94192c..44535840 100644 --- a/build.py +++ b/build.py @@ -1,13 +1,29 @@ +import gzip import os +import shutil Import("env") -print("Transpiling TS code") -env.Execute("rm data/www/worker/* || true") +print("Transpiling TS worker code") +env.Execute("rm -rf data/www || true") currentPath = os.getcwd() -os.chdir('./tsc') +os.chdir('./worker') env.Execute("npm run build") if not os.path.exists("../data/www/worker/"): os.makedirs("../data/www/worker/") env.Execute("cp dist_packed/main.js ../data/www/worker/worker.js") -os.chdir(currentPath) \ No newline at end of file +os.chdir(currentPath) + +print("Building React UI") +os.chdir('./ui') +env.Execute("npm run build") +os.chdir(currentPath) + +print("Gzip compressing static assets") +for dirpath, _, filenames in os.walk("data/www"): + for filename in filenames: + src = os.path.join(dirpath, filename) + dst = src + ".gz" + with open(src, "rb") as f_in, gzip.open(dst, "wb", compresslevel=9) as f_out: + shutil.copyfileobj(f_in, f_out) + os.remove(src) \ No newline at end of file diff --git a/data/www/client.js b/data/www/client.js deleted file mode 100644 index 6767c7e9..00000000 --- a/data/www/client.js +++ /dev/null @@ -1,38 +0,0 @@ -export async function leftRetractDown() { - await postCommand("l-ret"); -} - -export async function leftExtendDown() { - await postCommand("l-ext"); -} - -export async function rightRetractDown() { - await postCommand("r-ret"); -} - -export async function rightExtendDown() { - await postCommand("r-ext"); -} - -export async function leftRetractUp() { - await postCommand("l-0"); -} - -export async function leftExtendUp() { - await postCommand("l-0"); -} - -export async function rightRetractUp() { - await postCommand("r-0"); -} - -export async function rightExtendUp() { - await postCommand("r-0"); -} - -async function postCommand(command) { - $.post("/command", {command}).fail(function() { - alert("Command failed"); - location.reload(); - }); -} \ No newline at end of file diff --git a/data/www/dpad.less b/data/www/dpad.less deleted file mode 100644 index 11225dc2..00000000 --- a/data/www/dpad.less +++ /dev/null @@ -1,253 +0,0 @@ -.set { - overflow: hidden; - text-align: center; - .d-pad { margin-right: 40px; } - .d-pad, .o-pad { - display: inline-block; - // transform: scale(.7); - } - } - .set.setbg { background: #222; } - .set.setbg2 { background: #5f9837; } - - - @dpad-radius: 17%; - @dpad-radius-in: 20%; - @dpad-fg: #ddd; - @dpad-fg-hover: #eee; - @dpad-bg: #fff; - @arrowcolor: #aaa; - @tri-sml-a: 13px; - @tri-sml-b: 19px; - @tri-lrg-a: 13px; - @tri-lrg-b: 19px; - @dpad-arrow-shift: 5px; - @dpad-arrow-move: 35%; - .d-pad { - position: relative; - width: 200px; - height: 200px; - border-radius: 48%; - overflow: hidden; - &:before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - border-radius: 5%; - transform: translate(-50%, -50%); - width: 66.6%; - height: 66.6%; - background: @dpad-fg; - } - &:after { - content: ''; - position: absolute; - display: none; - z-index:2; - width: 20%; - height: 20%; - top: 50%; - left: 50%; - background: @dpad-fg; - border-radius: 50%; - transform: translate(-50%, -50%); - transition: all .25s; - cursor: pointer; - } - &:hover:after { - width: 30%; - height: 30%; - } - a { - display:block; - position: absolute; - -webkit-tap-highlight-color: rgba(255, 255, 255, 0); - width: 33.3%; - height: 43%; - line-height: 40%; - color: #fff; - background: @dpad-fg; - text-align: center; - &:hover { - background: @dpad-fg-hover; - } - &:before { - content: ''; - position: absolute; - width: 0; - height: 0; - border-radius: 5px; - border-style: solid; - transition: all .25s; - } - &:after { - content: ''; - position: absolute; - width: 102%; - height: 78%; - background: @dpad-bg; - border-radius: @dpad-radius-in; - } - } - a.left, a.right { - width: 43%; - height: 33%; - &:after { - width: 78%; - height: 102%; - } - } - - a.up { - top: 0; - left: 50%; - transform: translate(-50%, 0); - border-radius: @dpad-radius @dpad-radius 50% 50%; - &:hover { - background: linear-gradient(0deg, @dpad-fg 0%, @dpad-fg-hover 50%); - } - &:after { - left: 0; - top: 0; - transform: translate(-100%, 0); - border-top-left-radius: 50%; - pointer-events: none; - } - &:before { - top: 40%; - left: 50%; - transform: translate(-50%, -50%); - border-width: 0 @tri-sml-a @tri-sml-b @tri-sml-a; - border-color: transparent transparent @arrowcolor transparent; - } - &:active:before { - border-bottom-color: #333; - } - } - a.up:hover:before { top: @dpad-arrow-move; } - - a.down { - bottom: 0; - left: 50%; - transform: translate(-50%, 0); - border-radius: 50% 50% @dpad-radius @dpad-radius; - &:hover { - background: linear-gradient(180deg, @dpad-fg 0%, @dpad-fg-hover 50%); - } - &:after { - right: 0; - bottom: 0; - transform: translate(100%, 0); - border-bottom-right-radius: 50%; - pointer-events: none; - } - &:before { - bottom: 40%; - left: 50%; - transform: translate(-50%, 50%); - border-width: @tri-sml-b @tri-sml-a 0px @tri-sml-a; - border-color: @arrowcolor transparent transparent transparent; - } - &:active:before { - border-top-color: #333; - } - } - a.down:hover:before { bottom: @dpad-arrow-move; } - - a.left { - top: 50%; - left: 0; - transform: translate(0, -50%); - border-radius: @dpad-radius 50% 50% @dpad-radius; - &:hover { - background: linear-gradient(-90deg, @dpad-fg 0%, @dpad-fg-hover 50%); - } - &:after { - left: 0; - bottom: 0; - transform: translate(0, 100%); - border-bottom-left-radius: 50%; - pointer-events: none; - } - &:before { - left: 40%; - top: 50%; - transform: translate(-50%, -50%); - border-width: @tri-sml-a @tri-sml-b @tri-sml-a 0; - border-color: transparent @arrowcolor transparent transparent; - } - &:active:before { - border-right-color: #333; - } - } - a.left:hover:before { left: @dpad-arrow-move; } - - a.right { - top: 50%; - right: 0; - transform: translate(0, -50%); - border-radius: 50% @dpad-radius @dpad-radius 50%; - &:hover { - background: linear-gradient(90deg, @dpad-fg 0%, @dpad-fg-hover 50%); - } - &:after { - right: 0; - top: 0; - transform: translate(0, -100%); - border-top-right-radius: 50%; - pointer-events: none; - } - &:before { - right: 40%; - top: 50%; - transform: translate(50%, -50%); - border-width: @tri-sml-a 0 @tri-sml-a @tri-sml-b; - border-color: transparent transparent transparent @arrowcolor; - } - &:active:before { - border-left-color: #333; - } - } - a.right:hover:before { right: @dpad-arrow-move; } - } - .d-pad.up a.up:before { border-bottom-color: #333; } - .d-pad.down a.down:before { border-top-color: #333; } - .d-pad.left a.left:before { border-right-color: #333; } - .d-pad.right a.right:before { border-left-color: #333; } - - .blue { - @c: #1843ca; - @c-h: #143cb9; - @c-t: #ccc; - @c-t-a: rgba(255,255,255,1); - .d-pad { - &:before, a { background: @c; } - &:after { display: block; background: @c-t; } - a:after { border-radius: 10%; } - a.up:hover { background: linear-gradient(0deg, @c 0%, @c-h 50%); } - a.right:hover { background: linear-gradient(90deg, @c 0%, @c-h 50%); } - a.down:hover { background: linear-gradient(180deg, @c 0%, @c-h 50%); } - a.left:hover { background: linear-gradient(-90deg, @c 0%, @c-h 50%); } - a.up:before { border-bottom-color: @c-t; } - a.right:before { border-left-color: @c-t; } - a.down:before { border-top-color: @c-t; } - a.left:before { border-right-color: @c-t; } - a.up:active:before { border-bottom-color: @c-t-a; } - a.right:active:before { border-left-color: @c-t-a; } - a.down:active:before { border-top-color: @c-t-a; } - a.left:active:before { border-right-color: @c-t-a; } - } - } - - // set direction active state - - .d-pad.up a.up:before { border-bottom-color: #333; } - .d-pad.down a.down:before { border-top-color: #333; } - .d-pad.left a.left:before { border-right-color: #333; } - .d-pad.right a.right:before { border-left-color: #333; } - - .o-pad.up a.up:after { border-bottom-color: #333; } - .o-pad.down a.down:after { border-top-color: #333; } - .o-pad.left a.left:after { border-right-color: #333; } - .o-pad.right a.right:after { border-left-color: #333; } \ No newline at end of file diff --git a/data/www/index.html b/data/www/index.html deleted file mode 100644 index 31959a2d..00000000 --- a/data/www/index.html +++ /dev/null @@ -1,215 +0,0 @@ - - - - - - - Mural - - - - - - - - - - - - - - -
- - - - - - - - - - - - -
- - - - - \ No newline at end of file diff --git a/data/www/main.css b/data/www/main.css deleted file mode 100644 index c75ef380..00000000 --- a/data/www/main.css +++ /dev/null @@ -1,17 +0,0 @@ -html, -body { - height: 100%; -} - -body { - display: flex; - align-items: center; - padding-top: 40px; - padding-bottom: 40px; - background-color: #f5f5f5; -} - -.form-signin { - max-width: 330px; - padding: 15px; -} diff --git a/data/www/main.js b/data/www/main.js deleted file mode 100644 index 1cfd225e..00000000 --- a/data/www/main.js +++ /dev/null @@ -1,559 +0,0 @@ -import * as svgControl from './svgControl.js'; -import * as client from './client.js'; - -let currentState = null; - -let currentWorker = null; - -window.onload = function () { - init(); -}; - -let uploadConvertedCommands = null; - -async function checkIfExtendedToHome(extendToHomeTime) { - await new Promise(r => setTimeout(r, extendToHomeTime * 1000)); - - const waitPeriod = 2000; - let done = false; - while (!done) { - try { - const state = await $.get("/getState"); - if (state.phase !== 'ExtendToHome') { - adaptToState(state); - done = true; - } else { - await new Promise(r => setTimeout(r, waitPeriod)); - } - } catch (err) { - alert("Failed to get current phase: " + err); - location.reload(); - } - } -} - -function init() { - function doneWithPhase(custom) { - $(".muralSlide").hide(); - $("#loadingSlide").show(); - if (!custom) { - custom = { - url: "/doneWithPhase", - data: {}, - commandName: "Done With Phase", - }; - } - - $.post(custom.url, custom.data || {}, function(state) { - adaptToState(state); - }).fail(function() { - alert(`${custom.commandName} command failed`); - location.reload(); - }); - } - - $("#beltsRetracted").click(async function() { - await client.leftRetractUp(); - await client.rightRetractUp(); - doneWithPhase(); - }); - - $("#setDistance").click(function() { - const inputValue = parseInt($("#distanceInput").val()); - if (isNaN(inputValue)) { - throw new Error("input value is not a number"); - } - - doneWithPhase({ - url: "/setTopDistance", - data: {distance: inputValue}, - commandName: "Set Top Distance", - }); - }); - - $("#leftMotorToggle").change(function() { - if (this.checked) { - client.leftRetractDown(); - } else { - client.leftRetractUp(); - } - }); - - $("#rightMotorToggle").change(function() { - if (this.checked) { - client.rightRetractDown(); - } else { - client.rightRetractUp(); - } - }); - - $("#extendToHome").click(function() { - $(this).prop( "disabled", true); - $("#extendingSpinner").css('visibility', 'visible'); - $.post("/extendToHome", {}) - .always(async function(res) { - const extendToHomeTime = parseInt(res); - await checkIfExtendedToHome(extendToHomeTime); - }); - }); - - function getServoValueFromInputValue() { - const inputValue = parseInt($("#servoRange").val()); - const value = 90 - inputValue; - let normalizedValue; - if (value < 0) { - normalizedValue = 0; - } else if (value > 90) { - normalizedValue = 90; - } else { - normalizedValue = value; - } - - return normalizedValue; - } - - $("#servoRange").on('input', $.throttle(250, function (e) { - const servoValue = getServoValueFromInputValue(); - $.post("/setServo", {angle: servoValue}); - })); - - const stepVaule = 5; - $("#penMinus").click(function() { - $("#servoRange")[0].stepDown(stepVaule); - $("#servoRange").trigger('input'); - }); - - $("#penPlus").click(function() { - $("#servoRange")[0].stepUp(stepVaule); - $("#servoRange").trigger('input'); - }); - - $("#setPenDistance").click(function () { - const inputValue = getServoValueFromInputValue(); - doneWithPhase({ - url: "/setPenDistance", - data: {angle: inputValue}, - commandName: "Set Pen Distance", - }); - }); - - async function getUploadedSvgString() { - const [file] = $("#uploadSvg")[0].files; - if (file) { - return await file.text(); - } else { - return null; - } - } - - $("#uploadSvg").change(async function() { - const svgString = await getUploadedSvgString(); - if (svgString) { - svgControl.setSvgString(svgString, currentState); - - $(".svg-control").show(); - $("#preview").removeAttr("disabled"); - } else { - $("#preview").attr("disabled", "disabled"); - $(".svg-control").hide(); - $("#infillDensity").val(0); - $("#turdSize").val(2); - } - }); - - - let currentPreviewId = 0; - let rendererFn = null; - - async function render_VectorRasterVector() { - if (currentWorker) { - console.log("Terminating previous worker"); - currentWorker.terminate(); - } - currentPreviewId++; - const thisPreviewId = currentPreviewId; - - const svgString = await getUploadedSvgString(); - if (!svgString) { - throw new Error('No SVG string'); - } - - $("#progressBar").text("Rasterizing"); - const raster = await svgControl.getCurrentSvgImageData(); - - const vectorizeRequest = { - type: 'vectorize', - raster, - turdSize: getTurdSize(), - }; - - if (currentPreviewId == thisPreviewId) { - currentWorker = new Worker(`./worker/worker.js?v=${Date.now()}`); - - currentWorker.onmessage = (e) => { - if (e.data.type === 'status') { - $("#progressBar").text(e.data.payload); - } else if (e.data.type === 'vectorizer') { - const vectorizedSvg = e.data.payload.svg; - const scale = svgControl.getRenderScale(); - renderSvgInWorker( - currentWorker, - vectorizedSvg, - svgControl.getTargetWidth() * scale, - svgControl.getTargetHeight() * scale, - ); - } - else if (e.data.type === 'log') { - console.log(`Worker: ${e.data.payload}`); - } - } - - currentWorker.postMessage(vectorizeRequest); - } - } - - async function render_PathTracing() { - if (currentWorker) { - console.log("Terminating previous worker"); - currentWorker.terminate(); - } - currentPreviewId++; - const thisPreviewId = currentPreviewId; - - const svgString = await getUploadedSvgString(); - if (!svgString) { - throw new Error('No SVG string'); - } - - if (currentPreviewId == thisPreviewId) { - currentWorker = new Worker(`./worker/worker.js?v=${Date.now()}`); - currentWorker.onmessage = (e) => { - if (e.data.type === 'status') { - $("#progressBar").text(e.data.payload); - } - else if (e.data.type === 'log') { - console.log(`Worker: ${e.data.payload}`); - } - } - - const renderSvg = svgControl.getRenderSvg(); - const renderSvgString = new XMLSerializer().serializeToString(renderSvg); - renderSvgInWorker(currentWorker, renderSvgString, svgControl.getTargetWidth(), svgControl.getTargetHeight()); - } - } - - function renderSvgInWorker(worker, svg, svgWidth, svgHeight) { - const svgJson = svgControl.getSvgJson(svg); - - const renderRequest = { - type: "renderSvg", - svgJson, - width: svgControl.getTargetWidth(), - height: svgControl.getTargetHeight(), - svgWidth, - svgHeight, - homeX: currentState.homeX, - homeY: currentState.homeY, - infillDensity: getInfillDensity(), - flattenPaths: getFlattenPaths(), - } - - worker.onmessage = (e) => { - if (e.data.type === 'status') { - $("#progressBar").text(e.data.payload); - } else if (e.data.type === 'renderer') { - console.log("Worker finished!"); - - uploadConvertedCommands = e.data.payload.commands.join('\n'); - const resultSvgJson = e.data.payload.svgJson; - const resultDataUrl = svgControl.convertJsonToDataURL(resultSvgJson, svgControl.getTargetWidth(), svgControl.getTargetHeight()); - - const totalDistanceM = +(e.data.payload.distance / 1000).toFixed(1); - const drawDistanceM = +(e.data.payload.drawDistance / 1000).toFixed(1); - - deactivateProgressBar(); - $("#previewSvg").attr("src", resultDataUrl); - $("#distances").text(`Total: ${totalDistanceM}m / Draw: ${drawDistanceM}m`); - $(".svg-preview").show(); - $("#acceptSvg").removeAttr("disabled"); - } - }; - - worker.postMessage(renderRequest); - } - - function activateProgressBar() { - const bar = $("#progressBar"); - bar.addClass("progress-bar-striped"); - bar.addClass("progress-bar-animated"); - bar.removeClass("bg-success"); - bar.text(""); - } - - function deactivateProgressBar() { - const bar = $("#progressBar"); - bar.removeClass("progress-bar-striped"); - bar.removeClass("progress-bar-animated"); - bar.addClass("bg-success"); - bar.text("Success"); - } - - - $("#infillDensity,#turdSize,#flattenPathsCheckbox").on('input change', async function() { - activateProgressBar(); - $("#acceptSvg").attr("disabled", "disabled"); - await rendererFn(); - }); - - $("#preview").click(async function() { - $("#svgUploadSlide").hide(); - $("#chooseRendererSlide").show(); - }); - - $("#pathTracing").click(async function() { - $("label[for='turdSize'],#turdSize").hide(); - $("label[for='flattenPathsCheckbox'],#flattenPathsCheckbox").show(); - - $("#chooseRendererSlide").hide(); - $("#drawingPreviewSlide").show(); - rendererFn = render_PathTracing; - await rendererFn(); - }); - - $("#vectorRasterVector").click(async function() { - $("#flattenPathsCheckbox").prop("checked", false); - $("label[for='turdSize'],#turdSize").show(); - $("label[for='flattenPathsCheckbox'],#flattenPathsCheckbox").hide(); - - $("#chooseRendererSlide").hide(); - $("#drawingPreviewSlide").show(); - rendererFn = render_VectorRasterVector; - await rendererFn(); - }); - - $(".backToSvgSelect").click(function() { - uploadConvertedCommands = null; - - $(".loading").show(); - activateProgressBar(); - $("#previewSvg").removeAttr("src"); - $(".svg-preview").hide(); - $("#acceptSvg").attr("disabled", "disabled"); - - $("#svgUploadSlide").show(); - $("#drawingPreviewSlide").hide(); - $("#chooseRendererSlide").hide(); - }); - - $("#acceptSvg").click(function() { - if (!uploadConvertedCommands) { - throw new Error('Commands are empty'); - } - $("#acceptSvg").attr("disabled", "disabled"); - - const commandsBlob = new Blob([uploadConvertedCommands], { - type: "text/plain" - }); - - $(".muralSlide").hide(); - $("#uploadProgress").show(); - - const formData = new FormData(); - formData.append("commands", commandsBlob); - - $.ajax({ - url: "/uploadCommands", - data: formData, - processData: false, - contentType: false, - type: 'POST', - success: function(data) { - verifyUpload(data); - }, - error: function(err) { - alert('Upload to Mural failed! ' + err); - window.location.reload(); - }, - xhr: function () { - var xhr = new window.XMLHttpRequest(); - - xhr.upload.addEventListener("progress", function (evt) { - if (evt.lengthComputable) { - var percentComplete = evt.loaded / evt.total; - percentComplete = parseInt(percentComplete * 100); - $("#uploadProgress").attr("aria-valuemax", evt.total.toString()); - $("#uploadProgress").attr("aria-valuenow", evt.loaded.toString()); - $("#uploadProgress > .progress-bar").attr("style", `width: ${percentComplete}%`); - } - }, false); - - return xhr; - }, - }); - }); - - - - $("#beginDrawing").click(function() { - $(".muralSlide").hide(); - $("#drawingBegan").show(); - $.post("/run", {}); - }); - - $("#reset").click(function() { - doneWithPhase(); - location.reload(); - }); - - $("#leftMotorTool").on('input', function() { - const leftMotorDir = parseInt($("#leftMotorTool").val()); - if (leftMotorDir <= -1) { - client.leftRetractDown(); - } else if (leftMotorDir >= 1) { - client.leftExtendDown(); - } else { - client.leftRetractUp(); - } - }); - - $("#rightMotorTool").on('input', function() { - const rightMotorDir = parseInt($("#rightMotorTool").val()); - if (rightMotorDir <= -1) { - client.rightRetractDown(); - } else if (rightMotorDir >= 1) { - client.rightExtendDown(); - } else { - client.rightRetractUp(); - } - }); - - $("#parkServoTool").click(function() { - $.post("/setServo", {angle: 0}); - }); - - $("#estepsTool").click(function() { - $.post("/estepsCalibration", {}); - }); - - const toolsModal = $("#toolsModal")[0]; - - toolsModal.addEventListener('hidden.bs.modal', function (event) { - client.rightRetractUp(); - client.leftRetractUp(); - }); - - svgControl.initSvgControl(); - - $("#loadingSlide").show(); - - // adaptToState({ - // phase: "BeginDrawing", - // topDistance: 1727, - // safeWidth: 1000, - // homeX: 0, - // homeY: 0, - // }); - - $.get("/getState", function(data) { - adaptToState(data); - }).fail(function() { - alert("Failed to retrieve state"); - }); -} - -function verifyUpload(state) { - $.ajax({ - url: "/downloadCommands", - processData: false, - contentType: false, - type: 'GET', - success: function(data) { - const receivedData = data.split('\n'); - const sentData = uploadConvertedCommands.split('\n'); - if (receivedData.length !== sentData.length) { - alert("Data verification failed"); - window.location.reload(); - return; - } - for (let i = 0; i < receivedData.length; i++) { - if (receivedData[i] !== sentData[i]) { - alert("Data verification failed"); - window.location.reload(); - return; - } - } - setTimeout(function() { - adaptToState(state); - }, 1000); - }, - error: function(err) { - alert('Failed to download commands from Mural! ' + err); - window.location.reload(); - }, - xhr: function () { - var xhr = new window.XMLHttpRequest(); - xhr.addEventListener("progress", function (evt) { - if (evt.lengthComputable) { - var percentComplete = evt.loaded / evt.total; - percentComplete = parseInt(percentComplete * 100); - $("#verificationProgress").attr("aria-valuemax", evt.total.toString()); - $("#verificationProgress").attr("aria-valuenow", evt.loaded.toString()); - $("#verificationProgress > .progress-bar").attr("style", `width: ${percentComplete}%`); - } - }, false); - - return xhr; - }, - }); - -} - -function adaptToState(state) { - $(".muralSlide").hide(); - currentState = state; - switch(state.phase) { - case "RetractBelts": - $("#retractBeltsSlide").show(); - break; - case "SetTopDistance": - $("#distanceBetweenAnchorsSlide").show(); - break; - case "ExtendToHome": - $("#extendToHomeSlide").show(); - if (state.moving || state.startedHoming) { - $("#extendToHome").prop( "disabled", true); - $("#extendingSpinner").css('visibility', 'visible'); - checkIfExtendedToHome(); - } - break; - case "PenCalibration": - $.post("/setServo", {angle: 90}); - $("#penCalibrationSlide").show(); - break; - case "SvgSelect": - $("#svgUploadSlide").show(); - break; - case "BeginDrawing": - $("#beginDrawingSlide").show(); - break; - default: - alert("Unrecognized phase"); - } -} - -function getInfillDensity() { - const density = parseInt($("#infillDensity").val()); - if ([0, 1, 2, 3, 4].includes(density)) { - return density; - } else { - throw new Error('Invalid density'); - } -} - -function getTurdSize() { - return parseInt($("#turdSize").val()); -} - -function getFlattenPaths() { - return $("#flattenPathsCheckbox").is(":checked"); -} \ No newline at end of file diff --git a/data/www/svgControl.js b/data/www/svgControl.js deleted file mode 100644 index 35472396..00000000 --- a/data/www/svgControl.js +++ /dev/null @@ -1,265 +0,0 @@ -document.body.addEventListener("click", function(e) { - if(e.target && e.target.nodeName == "A" && e.target.parentElement.className == 'd-pad') { - const validDirection = ["up", "down", "left", "right"]; - if (validDirection.includes(e.target.className)) { - requestChangeInTransform(e.target.className); - } - } -}); - -export const renderScale = 2; - -export function initSvgControl() { - $("#zoomIn").click(function() { - requestChangeInTransform("in"); - }); - - $("#zoomOut").click(function() { - requestChangeInTransform("out"); - }); - - $("#resetTransform").click(function() { - requestChangeInTransform("reset"); - }); -} - -const affineTransform = [1, 0, 0, 1, 0, 0]; -// nudge by this fraction of the viewport's width and height -const nudgeByFactor = 0.025; -const zoomByFactor = 0.05; -function requestChangeInTransform(direction) { - switch (direction) { - case "up": - affineTransform[5] = affineTransform[5] - nudgeByFactor; - break; - case "down": - affineTransform[5] = affineTransform[5] + nudgeByFactor; - break; - case "left": - affineTransform[4] = affineTransform[4] - nudgeByFactor; - break; - case "right": - affineTransform[4] = affineTransform[4] + nudgeByFactor; - break; - case "in": - affineTransform[0] = affineTransform[0] + zoomByFactor; - affineTransform[3] = affineTransform[3] + zoomByFactor; - break; - case "out": - affineTransform[0] = affineTransform[0] - zoomByFactor; - affineTransform[3] = affineTransform[3] - zoomByFactor; - break; - case "reset": - resetTransform(); - break; - default: - console.log("Unrecognized transform direction"); - return; - } - applyTransform(); -} - -function resetTransform() { - affineTransform[0] = 1; - affineTransform[3] = 1; - affineTransform[4] = 0; - affineTransform[5] = 0; -} - -let originalSvg; -let transformedSvg; -let currentWidth; -let currentHeight; -export function setSvgString(svgString, currentState) { - resetTransform(); - - originalSvg = new DOMParser().parseFromString(svgString, 'image/svg+xml'); - currentWidth = currentState.safeWidth; - normalizeSvg(); - applyTransform(); -} - -const transformGroupID = "muralTransformGroup"; -function normalizeSvg() { - const svgElement = originalSvg.documentElement; - let width, height; - - if (svgElement.hasAttribute("width") && svgElement.hasAttribute("height")) { - width = convertUnitsToPx(svgElement.getAttribute("width")); - height = convertUnitsToPx(svgElement.getAttribute("height")); - } - - if (svgElement.hasAttribute("viewBox")) { - if (!width || !height) { - const viewBox = svgElement.getAttribute("viewBox").split(/[\s,]/).filter(s => s != "");; - width = parseFloat(viewBox[2]); - height = parseFloat(viewBox[3]); - } - } else { - svgElement.setAttribute("viewBox", `0, 0, ${width}, ${height}`); - } - - if (!width || !height) { - throw new Error("Invalid SVG"); - } - - currentHeight = currentWidth / width * height; - - svgElement.setAttribute("width", currentWidth); - svgElement.setAttribute("height", currentHeight); - - const transformGroup = document.createElementNS("http://www.w3.org/2000/svg", "g"); - transformGroup.id = transformGroupID; - while (svgElement.firstChild) { - transformGroup.appendChild(svgElement.firstChild); - } - svgElement.appendChild(transformGroup); -} - -function convertUnitsToPx(dimension) { - const unitConversionFactors = { - pt: 1.3333, // Points to pixels - pc: 16, // Picas to pixels - in: 96, // Inches to pixels - cm: 37.795, // Centimeters to pixels - mm: 3.7795, // Millimeters to pixels - px: 1, // Pixels to pixels - }; - - const match = dimension.match(/([\d.]+)([a-z%]*)/i); - if (!match) { - alert("Invalid SVG"); - throw new Error(`Invalid dimension: "${dimension}"`); - } - const value = parseFloat(match[1]); - const unit = match[2] || "px"; // Default to pixels if no unit is provided - const conversionFactor = unitConversionFactors[unit] || 1; - return value * conversionFactor; // Convert to pixels -} - -export function getTargetWidth() { - return currentWidth; -} - -export function getTargetHeight() { - return currentHeight; -} - -export function getRenderScale() { - return renderScale; -} - -export function getRenderSvg() { - return makeTransformedSvgWithHeight()[0]; -} - -function applyTransform() { - updateTransformText(); - - const [clonedSvg, newHeight] = makeTransformedSvgWithHeight(); - currentHeight = newHeight; - - const svgString = new XMLSerializer().serializeToString(clonedSvg); - const svgDataURL = `data:image/svg+xml;base64,${btoa(svgString)}`; - $("#sourceSvg")[0].src = svgDataURL; - - transformedSvg = clonedSvg; -} - -function makeTransformedSvgWithHeight() { - const clonedSvg = originalSvg.cloneNode(true); - const svgElement = clonedSvg.documentElement; - - const viewBox = svgElement.getAttribute("viewBox").split(/[\s,]/).filter(s => s != ""); - const vbWidth = parseFloat(viewBox[2]); - const vbHeight = parseFloat(viewBox[3]); - - const scaledAffine = [...affineTransform]; - scaledAffine[4] = scaledAffine[4] * vbWidth; - - let newHeight = parseFloat(svgElement.getAttribute("height")); - if (scaledAffine[5] > 0) { - // when shifting down increase height - const heightOffset = scaledAffine[5] * newHeight; - newHeight = newHeight + heightOffset; - svgElement.setAttribute("height", newHeight); - - scaledAffine[5] = scaledAffine[5] * vbHeight; - viewBox[3] = vbHeight + scaledAffine[5]; - svgElement.setAttribute("viewBox", viewBox.join(", ")); - } else { - scaledAffine[5] = scaledAffine[5] * vbHeight; - } - - const transfromGroup = clonedSvg.getElementById(transformGroupID); - transfromGroup.setAttribute("transform", `matrix(${scaledAffine.join(", ")})`); - - return [clonedSvg, newHeight]; -} - -function updateTransformText() { - function normalizeNumber(num) { - return +num.toFixed(2); - } - $("#transformText").text(`(${normalizeNumber(affineTransform[4] * 100)}, ${normalizeNumber(affineTransform[5] * 100)}) ${normalizeNumber(affineTransform[0])}x`); -} - -export async function getCurrentSvgImageData() { - const scaledHeight = currentHeight * renderScale; - const scaledWidth = currentWidth * renderScale; - - const svgString = new XMLSerializer().serializeToString(transformedSvg); - - const canvas = new OffscreenCanvas(scaledWidth, scaledHeight); - const canvasContext = canvas.getContext("2d",); - const img = await loadImage(`data:image/svg+xml;base64,${btoa(svgString)}`); - - const bitmap = await createImageBitmap(img, {resizeHeight: scaledHeight, resizeWidth: scaledWidth}); - - canvasContext.drawImage(bitmap, 0, 0, scaledWidth, scaledHeight); - - const imageData = canvasContext.getImageData(0, 0, canvas.width, canvas.height); - - return imageData; -} - -async function loadImage(src) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = src; - }); -} - -export function getSvgJson(svgString) { - const size = new paper.Size(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); - paper.setup(size); - const svg = paper.project.importSVG(svgString, { - expandShapes: true, - applyMatrix: true, - }); - const json = svg.exportJSON(); - paper.project.remove(); - - return json; -} - -export function convertJsonToDataURL(json, width, height) { - $("#previewCanvas").remove(); - $(document.body).append(``); - - paper.setup($("#previewCanvas")[0]); - paper.project.importJSON(json); - paper.view.draw(); - - const dataURL = $("#previewCanvas")[0].toDataURL(); - - paper.project.remove(); - $("#previewCanvas").remove(); - - return dataURL; -} - - - diff --git a/src/display.cpp b/src/display.cpp index 12f2504b..dc94eb4e 100644 --- a/src/display.cpp +++ b/src/display.cpp @@ -1,59 +1,59 @@ #include "display.h" #include -#define SCREEN_WIDTH 128 // OLED display width, in pixels -#define SCREEN_HEIGHT 64 // OLED display height, in pixels +#define SCREEN_WIDTH 128 +#define SCREEN_HEIGHT 32 +#define TEXT_HEIGHT 8 + Display::Display() { display = new Adafruit_SSD1306(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1); - if (!display->begin(SSD1306_SWITCHCAPVCC, 0x3C)) - { // Address 0x3D for 128x64 + if (!display->begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println(F("SSD1306 allocation failed")); throw std::invalid_argument("not ready"); } - delay(2000); - display->setRotation(2); + display->setRotation(0); display->clearDisplay(); display->setTextColor(WHITE); display->setTextSize(1); display->display(); } -void Display::displayText(String text) -{ - int16_t x1; - int16_t y1; - uint16_t width; - uint16_t height; - - display->getTextBounds(text, 0, 0, &x1, &y1, &width, &height); - - // display on horizontal and vertical center - display->clearDisplay(); // clear display - display->setCursor((SCREEN_WIDTH - width) / 2, (SCREEN_HEIGHT - height) / 2); - display->println(text); // text to display +void Display::drawLines(String lines[], int n) { + display->clearDisplay(); + int16_t x1, y1; + uint16_t w, h; + int zoneH = SCREEN_HEIGHT / n; + for (int i = 0; i < n; i++) { + int y = i * zoneH + (zoneH - TEXT_HEIGHT) / 2; + display->getTextBounds(lines[i], 0, 0, &x1, &y1, &w, &h); + display->setCursor((SCREEN_WIDTH - w) / 2, y); + display->print(lines[i]); + } display->display(); - Serial.println("Displayed " + text); } -void Display::displayHomeScreen(String ipLine, String orLine, String mdnsLine) { - display->clearDisplay(); - - int16_t x1; - int16_t y1; - uint16_t width; - uint16_t height; +void Display::showStarting() { + String lines[] = {"Starting up..."}; + drawLines(lines, 1); +} - display->getTextBounds(ipLine, 0, 0, &x1, &y1, &width, &height); - display->setCursor((SCREEN_WIDTH - width) / 2, 10); - display->println(ipLine); +void Display::showHotspot() { + String lines[] = {"Connect to hotspot", "SSID: Mural"}; + drawLines(lines, 2); +} - display->getTextBounds(orLine, 0, 0, &x1, &y1, &width, &height); - display->setCursor((SCREEN_WIDTH - width) / 2, 10 + SCREEN_HEIGHT / 3); - display->println(orLine); +void Display::showConnected(String ipAddress) { + ip = ipAddress; + String lines[] = {ip, "http://mural.local"}; + drawLines(lines, 2); +} - display->getTextBounds(mdnsLine, 0, 0, &x1, &y1, &width, &height); - display->setCursor((SCREEN_WIDTH - width) / 2, 10 + SCREEN_HEIGHT / 3 * 2); - display->println(mdnsLine); +void Display::showCalibration(double x, double y) { + String lines[] = {"X:" + String(x, 1) + " Y:" + String(y, 1)}; + drawLines(lines, 1); +} - display->display(); -} \ No newline at end of file +void Display::showDrawing(double x, double y, int progress) { + String lines[] = {"X:" + String((int)x) + " Y:" + String((int)y), String(progress) + "%"}; + drawLines(lines, 2); +} diff --git a/src/display.h b/src/display.h index b431b8b5..c86d4b71 100644 --- a/src/display.h +++ b/src/display.h @@ -5,9 +5,14 @@ class Display { private: Adafruit_SSD1306 *display; + String ip; + void drawLines(String lines[], int n); public: Display(); - void displayText(String text); - void displayHomeScreen(String ipLine, String orLine, String mdnsLine); + void showStarting(); + void showHotspot(); + void showConnected(String ipAddress); + void showCalibration(double x, double y); + void showDrawing(double x, double y, int progress); }; -#endif \ No newline at end of file +#endif diff --git a/src/gzip_file_reader.cpp b/src/gzip_file_reader.cpp new file mode 100644 index 00000000..89fbb3f4 --- /dev/null +++ b/src/gzip_file_reader.cpp @@ -0,0 +1,88 @@ +#include "gzip_file_reader.h" + +GzipFileReader::GzipFileReader(File f) : _file(f) {} + +bool GzipFileReader::open(File f) { + _file = f; + return begin(); +} + +bool GzipFileReader::begin() { + if (!_file) return false; + tinfl_init(&_decomp); + _inRemain = _inOffset = 0; + _writePos = _readPos = _dataCount = 0; + _done = false; + return true; +} + +bool GzipFileReader::available() const { + return _dataCount > 0 || !_done; +} + +void GzipFileReader::_pump() { + // Refill input buffer from file when exhausted + if (_inRemain == 0 && _file.available()) { + size_t n = min((size_t)IN_BUF_SIZE, (size_t)_file.available()); + _inRemain = _file.read(_inBuf, n); + _inOffset = 0; + } + if (_inRemain == 0) { + _done = true; + return; + } + + // Space from writePos to end of ring (never crosses the boundary in one call) + size_t outAvail = DICT_SIZE - _writePos; + + // Clear HAS_MORE_INPUT when the current _inBuf holds the final file bytes + mz_uint32 flags = (_file.available() > 0) ? TINFL_FLAG_HAS_MORE_INPUT : 0; + + size_t inSize = _inRemain; + tinfl_status status = tinfl_decompress( + &_decomp, + _inBuf + _inOffset, &inSize, // inSize → bytes consumed on return + _dict, + _dict + _writePos, &outAvail, // outAvail → bytes written on return + flags + ); + + _inOffset += inSize; + _inRemain -= inSize; + + _writePos = (_writePos + outAvail) % DICT_SIZE; + _dataCount += outAvail; + + if (status == TINFL_STATUS_DONE) _done = true; +} + +String GzipFileReader::readStringUntil(char terminator) { + String result; + result.reserve(64); + while (true) { + while (_dataCount == 0 && !_done) _pump(); + if (_dataCount == 0) break; + char c = (char)_dict[_readPos]; + _readPos = (_readPos + 1) % DICT_SIZE; + _dataCount--; + if (c == terminator) break; + result += c; + } + return result; +} + +size_t GzipFileReader::read(uint8_t* buf, size_t maxLen) { + size_t total = 0; + while (total < maxLen) { + while (_dataCount == 0 && !_done) _pump(); + if (_dataCount == 0) break; + // Data within a single pump cycle never wraps, but guard anyway + size_t contiguous = min(_dataCount, DICT_SIZE - _readPos); + size_t n = min(contiguous, maxLen - total); + memcpy(buf + total, _dict + _readPos, n); + _readPos = (_readPos + n) % DICT_SIZE; + _dataCount -= n; + total += n; + } + return total; +} diff --git a/src/gzip_file_reader.h b/src/gzip_file_reader.h new file mode 100644 index 00000000..c65ef08a --- /dev/null +++ b/src/gzip_file_reader.h @@ -0,0 +1,56 @@ +#pragma once +#include +#include +#include + +// Streaming deflate-raw decompressor wrapping a LittleFS File. +// Uses tinfl_decompress from ESP32 ROM — no extra library needed. +// +// Ring-buffer mode (no TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF) correctly +// handles deflate back-references up to 32 KB, matching the browser's +// CompressionStream("deflate-raw") default window. +class GzipFileReader { +public: + static constexpr size_t DICT_SIZE = 32768; // deflate sliding window + static constexpr size_t IN_BUF_SIZE = 1024; + + GzipFileReader() = default; + explicit GzipFileReader(File f); + + // Set file and initialize. Avoids constructing a temporary (which would + // put the 32 KB _dict on the stack and overflow small RTOS tasks). + bool open(File f); + + // Re-initialize with an already-set file (used internally by open()). + bool begin(); + + // True while there are unread decompressed bytes or more input to process. + bool available() const; + + // Read decompressed text up to (and consuming) terminator. + // Matches the Arduino File::readStringUntil interface. + String readStringUntil(char terminator); + + // Copy up to maxLen decompressed bytes into buf. Returns bytes copied. + size_t read(uint8_t* buf, size_t maxLen); + +private: + File _file; + tinfl_decompressor _decomp; + + uint8_t _inBuf[IN_BUF_SIZE]; + uint8_t _dict[DICT_SIZE]; // tinfl ring buffer (back-reference window) + + size_t _inRemain = 0; // bytes left in _inBuf not yet fed to tinfl + size_t _inOffset = 0; // read cursor into _inBuf + + size_t _writePos = 0; // next tinfl write position in _dict (ring) + size_t _readPos = 0; // caller's read cursor in _dict + size_t _dataCount = 0; // decompressed bytes available to caller + + bool _done = false; + + // Decompress one chunk: reads from _inBuf (refilling from _file if needed) + // and appends decompressed bytes into _dict at _writePos. + void _pump(); +}; diff --git a/src/main.cpp b/src/main.cpp index 97558866..e9831247 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -46,6 +46,7 @@ void setup() } display = new Display(); + display->showStarting(); Serial.println("Initialized display"); // initialize movement right away or the motors can start creeping due to floating output @@ -63,6 +64,9 @@ void setup() wifiManager.setTitle("Connect to WiFi"); wifiManager.setMenu(menu); wifiManager.setWebServerCallback(serverCallback); + wifiManager.setAPCallback([](WiFiManager*) { + display->showHotspot(); + }); wifiManager.autoConnect("Mural"); if (resetAfterConnect) { @@ -125,7 +129,7 @@ void setup() server.on( "/downloadCommands", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(LittleFS, "/commands", "text/plain"); + request->send(LittleFS, "/commands", "application/octet-stream"); } ); @@ -133,12 +137,12 @@ void setup() Serial.println("Finished setting up the server"); - phaseManager = new PhaseManager(movement, pen, runner, &server); + phaseManager = new PhaseManager(movement, pen, runner, &server, display); server.begin(); Serial.println("Server started"); - display->displayHomeScreen("http://" + WiFi.localIP().toString(), "or", "http://mural.local"); + display->showConnected(WiFi.localIP().toString()); } diff --git a/src/movement.h b/src/movement.h index 01d93116..2a5cf815 100644 --- a/src/movement.h +++ b/src/movement.h @@ -23,8 +23,8 @@ constexpr float homedStepOffsetMM = 40.0; // Length of fully retracted belt hi const int homedStepsOffset = int((homedStepOffsetMM / circumference) * stepsPerRotation); constexpr double mass_bot = 0.55; // Mass of the mural bot [kg]. constexpr double g_constant = 9.81; // Earth's gravitational acceleration constant [m/s^2]. Please adjust when running Mural on other planets! -constexpr double d_t = 76.027; // [mm] Distance of tangent points, where belts touch the pulleys. - // Calculated as (axis distance) 85.00 - (diameter) 12.69/sqrt(2). +constexpr double d_t = 76.927; // [mm] Distance of tangent points, where belts touch the pulleys. + // Calculated as (axis distance) 85.90 - (diameter) 12.69/sqrt(2). constexpr double d_p = 4.4866; // [mm] distance from Q to center of pen. Calculated as diameter/(2 * sqrt(2)). constexpr double d_m = 10.0 + d_p; // [mm] Distance from line connecting tangent points to center of mass of bot (projected onto wall plane). // The point where d_m and d_t meet shall be called Q. @@ -34,7 +34,7 @@ const int HOME_Y_OFFSET_MM = 350; // Y coordinate of mural home position in im // Margins used for transformations of the coordinate systems: -constexpr double safeYFraction = 0.2; // Top Margin: Image top to topDistance line. +constexpr double safeYFraction = 0.25; // Top Margin: Image top to topDistance line. constexpr double safeXFraction = 0.2; // Left and right margin: from draw area boundaries to line from each pin straight down. // Variables used for debugging: diff --git a/src/pen.cpp b/src/pen.cpp index d9a0fe12..5992f849 100644 --- a/src/pen.cpp +++ b/src/pen.cpp @@ -47,8 +47,8 @@ Pen::Pen() { servo = new Servo(); servo->attach(2); - servo->write(90); - currentPosition = 90; + servo->write(70); + currentPosition = 70; } void Pen::setRawValue(int rawValue) { diff --git a/src/phases/begindrawingphase.cpp b/src/phases/begindrawingphase.cpp index 20033a1a..aa8bb7be 100644 --- a/src/phases/begindrawingphase.cpp +++ b/src/phases/begindrawingphase.cpp @@ -6,7 +6,12 @@ BeginDrawingPhase::BeginDrawingPhase(PhaseManager* manager, Runner* runner, Asyn } void BeginDrawingPhase::run(AsyncWebServerRequest *request) { - runner->start(); + int speed = 800; + if (request->hasParam("speed", true)) { + speed = request->getParam("speed", true)->value().toInt(); + speed = max(300, min(3000, speed)); + } + runner->start(speed); request->send(200, "text/plain", "OK"); server->end(); } diff --git a/src/phases/pencalibrationphase.cpp b/src/phases/pencalibrationphase.cpp index e02ea9ad..fcb1e6c5 100644 --- a/src/phases/pencalibrationphase.cpp +++ b/src/phases/pencalibrationphase.cpp @@ -1,15 +1,72 @@ #include "pencalibrationphase.h" -PenCalibrationPhase::PenCalibrationPhase(PhaseManager* manager, Pen* pen) { + +PenCalibrationPhase::PenCalibrationPhase(PhaseManager* manager, Pen* pen, Movement* movement, Display* display) { this->manager = manager; this->pen = pen; - this->runner = runner; + this->movement = movement; + this->display = display; + this->jogInitialized = false; + this->jogX = 0.0; + this->jogY = 0.0; +} + +// max speed (37.4 mm/s) × update interval (0.1 s) ≈ 3.74 mm +constexpr double JOG_STEP_MM = 3.75; + +void PenCalibrationPhase::handleCommand(AsyncWebServerRequest *request) { + String command = request->arg("command"); + if (command != "jog") { + request->send(400, "text/plain", "Unsupported command"); + return; + } + + double vx = request->arg("vx").toDouble(); + double vy = request->arg("vy").toDouble(); + vx = max(-1.0, min(1.0, vx)); + vy = max(-1.0, min(1.0, vy)); + + if (!jogInitialized) { + Movement::Point home = movement->getHomeCoordinates(); + jogX = home.x; + jogY = home.y; + jogInitialized = true; + } + + if (vx == 0.0 && vy == 0.0) { + request->send(200, "text/plain", "OK"); + return; + } + + double newX = jogX + vx * JOG_STEP_MM; + double newY = jogY + vy * JOG_STEP_MM; + + double safeWidth = movement->getWidth(); + newX = max(0.0, min(safeWidth, newX)); + newY = max(0.0, newY); + + jogX = newX; + jogY = newY; + + double magnitude = sqrt(vx * vx + vy * vy); + int speed = (int)(min(1.0, magnitude) * printSpeedSteps); + + try { + Serial.printf("Jogging to %.1f, %.1f at speed %d\n", jogX, jogY, speed); + movement->beginLinearTravel(jogX, jogY, speed); + display->showCalibration(jogX, jogY); + } catch (...) { + request->send(500, "text/plain", "Movement error"); + return; + } + + request->send(200, "text/plain", "OK"); } void PenCalibrationPhase::setServo(AsyncWebServerRequest *request) { const AsyncWebParameter* p = request->getParam(0); int angle = p->value().toInt(); pen->setRawValue(angle); - request->send(200, "text/plain", "OK"); + request->send(200, "text/plain", "OK"); } void PenCalibrationPhase::setPenDistance(AsyncWebServerRequest *request) { @@ -23,4 +80,4 @@ void PenCalibrationPhase::setPenDistance(AsyncWebServerRequest *request) { const char* PenCalibrationPhase::getName() { return "PenCalibration"; -} \ No newline at end of file +} diff --git a/src/phases/pencalibrationphase.h b/src/phases/pencalibrationphase.h index 804fd3e0..faecbcb8 100644 --- a/src/phases/pencalibrationphase.h +++ b/src/phases/pencalibrationphase.h @@ -3,16 +3,23 @@ #include "notsupportedphase.h" #include "phasemanager.h" #include "pen.h" +#include "movement.h" +#include "display.h" + class PenCalibrationPhase : public NotSupportedPhase { private: PhaseManager* manager; Pen* pen; - Runner* runner; + Movement* movement; + Display* display; + bool jogInitialized; + double jogX; + double jogY; public: - PenCalibrationPhase(PhaseManager* manager, Pen* pen); + PenCalibrationPhase(PhaseManager* manager, Pen* pen, Movement* movement, Display* display); + void handleCommand(AsyncWebServerRequest *request); void setServo(AsyncWebServerRequest *request); void setPenDistance(AsyncWebServerRequest *request); const char* getName(); - }; -#endif \ No newline at end of file +#endif diff --git a/src/phases/phasemanager.cpp b/src/phases/phasemanager.cpp index 7d90d346..cff34afb 100644 --- a/src/phases/phasemanager.cpp +++ b/src/phases/phasemanager.cpp @@ -1,4 +1,5 @@ #include "phasemanager.h" +#include "LittleFS.h" #include "retractbeltsphase.h" #include "settopdistancephase.h" #include "extendtohomephase.h" @@ -9,16 +10,29 @@ #include "ArduinoJson.h" #include -PhaseManager::PhaseManager(Movement* movement, Pen* pen, Runner* runner, AsyncWebServer* server) { +PhaseManager::PhaseManager(Movement* movement, Pen* pen, Runner* runner, AsyncWebServer* server, Display* display) { retractBeltsPhase = new RetractBeltsPhase(this, movement); setTopDistancePhase = new SetTopDistancePhase(this, movement, pen); extendToHomePhase = new ExtendToHomePhase(this, movement); - penCalibrationPhase = new PenCalibrationPhase(this, pen); + penCalibrationPhase = new PenCalibrationPhase(this, pen, movement, display); svgSelectPhase = new SvgSelectPhase(this); beginDrawingPhase = new BeginDrawingPhase(this, runner, server); this->movement = movement; + this->display = display; reset(); + updateFreeKb(); +} + +void PhaseManager::updateFreeKb() { + size_t free = LittleFS.totalBytes() - LittleFS.usedBytes(); + if (LittleFS.exists("/commands")) { + File f = LittleFS.open("/commands"); + if (f) { free += f.size(); f.close(); } + } + Serial.printf("Free bytes: %d\n", free); + cachedFreeKb = (int)(free / 1024); + Serial.printf("Updated free KB: %d KB\n", cachedFreeKb); } Phase* PhaseManager::getCurrentPhase() { @@ -74,8 +88,11 @@ void PhaseManager::respondWithState(AsyncWebServerRequest *request) { root["moving"] = moving; root["topDistance"] = topDistance; root["safeWidth"] = safeWidth; + root["safeXFraction"] = safeXFraction; + root["safeYFraction"] = safeYFraction; root["homeX"] = homePosition.x; root["homeY"] = homePosition.y; + root["freeKb"] = cachedFreeKb; root.printTo(*response); request->send(response); diff --git a/src/phases/phasemanager.h b/src/phases/phasemanager.h index e148ee65..080548a0 100644 --- a/src/phases/phasemanager.h +++ b/src/phases/phasemanager.h @@ -4,6 +4,7 @@ #include "movement.h" #include "pen.h" #include "runner.h" +#include "display.h" #include class PhaseManager { private: @@ -15,12 +16,15 @@ class PhaseManager { Phase* svgSelectPhase; Phase* beginDrawingPhase; Movement* movement; + Display* display; + int cachedFreeKb; public: enum PhaseNames {RetractBelts, SetTopDistance, ExtendToHome, PenCalibration, SvgSelect, BeginDrawing}; - PhaseManager(Movement* movement, Pen* pen, Runner* runner, AsyncWebServer* server); + PhaseManager(Movement* movement, Pen* pen, Runner* runner, AsyncWebServer* server, Display* display); Phase* getCurrentPhase(); void setPhase(PhaseNames name); void respondWithState(AsyncWebServerRequest *request); + void updateFreeKb(); void reset(); }; #endif \ No newline at end of file diff --git a/src/phases/svgselectphase.cpp b/src/phases/svgselectphase.cpp index 422db4da..771b201b 100644 --- a/src/phases/svgselectphase.cpp +++ b/src/phases/svgselectphase.cpp @@ -36,6 +36,7 @@ void SvgSelectPhase::handleUpload(AsyncWebServerRequest *request, String filenam { request->_tempFile.close(); Serial.println("Upload finished"); + manager->updateFreeKb(); manager->setPhase(PhaseManager::RetractBelts); } } diff --git a/src/runner.cpp b/src/runner.cpp index 94ef0edc..a012b0f3 100644 --- a/src/runner.cpp +++ b/src/runner.cpp @@ -15,9 +15,8 @@ Runner::Runner(Movement *movement, Pen *pen, Display *display) { } void Runner::initTaskProvider() { - openedFile = LittleFS.open("/commands"); - if (!openedFile || !openedFile.available()) { - Serial.println("Failed to open file"); + if (!openedFile.open(LittleFS.open("/commands"))) { + Serial.println("Failed to open commands file"); throw std::invalid_argument("No File"); } @@ -45,10 +44,11 @@ void Runner::initTaskProvider() { startPosition = movement->getCoordinates(); auto homeCoordinates = movement->getHomeCoordinates(); - finishingSequence[0] = new InterpolatingMovementTask(movement, homeCoordinates); + finishingSequence[0] = new InterpolatingMovementTask(movement, homeCoordinates, speed); } -void Runner::start() { +void Runner::start(int speed) { + this->speed = speed; initTaskProvider(); currentTask = getNextTask(); currentTask->startRunning(); @@ -78,7 +78,7 @@ Task *Runner::getNextTask() auto x = line.substring(0, line.indexOf(" ")).toDouble(); auto y = line.substring(line.indexOf(" ") + 1).toDouble(); targetPosition = Movement::Point(x, y); - return new InterpolatingMovementTask(movement, targetPosition); + return new InterpolatingMovementTask(movement, targetPosition, speed); } } else @@ -117,8 +117,9 @@ void Runner::run() if (progress != newProgress) { Serial.println("Progress: " + String(newProgress)); progress = newProgress; - display->displayText(String(progress) + "%"); } + auto coords = movement->getCoordinates(); + display->showDrawing(coords.x, coords.y, progress); } delete currentTask; diff --git a/src/runner.h b/src/runner.h index aa936e15..0d18166a 100644 --- a/src/runner.h +++ b/src/runner.h @@ -5,6 +5,7 @@ #include "pen.h" #include "display.h" #include "LittleFS.h" +#include "gzip_file_reader.h" class Runner { private: Movement *movement; @@ -14,17 +15,18 @@ class Runner { Task* getNextTask(); Task* currentTask; bool stopped; - File openedFile; + GzipFileReader openedFile; double totalDistance; double distanceSoFar; Movement::Point startPosition; Movement::Point targetPosition; int progress; + int speed; Task *finishingSequence[1]; int sequenceIx = 0; public: Runner(Movement *movement, Pen *pen, Display *display); - void start(); + void start(int speed); void run(); void dryRun(); }; diff --git a/src/tasks/interpolatingmovementtask.cpp b/src/tasks/interpolatingmovementtask.cpp index ae3554a4..3c7ff4b8 100644 --- a/src/tasks/interpolatingmovementtask.cpp +++ b/src/tasks/interpolatingmovementtask.cpp @@ -18,16 +18,17 @@ bool arePointsEqual(Movement::Point point1, Movement::Point point2) { return point1.x == point2.x && point1.y == point2.y; } -InterpolatingMovementTask::InterpolatingMovementTask(Movement *movement, Movement::Point target) { +InterpolatingMovementTask::InterpolatingMovementTask(Movement *movement, Movement::Point target, int speed) { this->target = target; this->movement = movement; + this->speed = speed; } void InterpolatingMovementTask::startRunning() { Serial.printf("Starting the move to %.1f, %.1f\n", target.x, target.y); auto currentCoordinates = movement->getCoordinates(); auto incrementPoint = getNextIncrement(currentCoordinates, target); - movement->beginLinearTravel(incrementPoint.x, incrementPoint.y, printSpeedSteps); + movement->beginLinearTravel(incrementPoint.x, incrementPoint.y, this->speed); } bool InterpolatingMovementTask::isDone() { @@ -41,7 +42,7 @@ bool InterpolatingMovementTask::isDone() { } auto incrementPoint = getNextIncrement(movement->getCoordinates(), target); - movement->beginLinearTravel(incrementPoint.x, incrementPoint.y, printSpeedSteps); + movement->beginLinearTravel(incrementPoint.x, incrementPoint.y, this->speed); return false; } diff --git a/src/tasks/interpolatingmovementtask.h b/src/tasks/interpolatingmovementtask.h index 3698c341..54a0f95c 100644 --- a/src/tasks/interpolatingmovementtask.h +++ b/src/tasks/interpolatingmovementtask.h @@ -8,9 +8,10 @@ class InterpolatingMovementTask : public Task { Movement *movement; Movement::Point target; Movement::Point position; + int speed; public: const static char* NAME; - InterpolatingMovementTask(Movement *movement, Movement::Point target); + InterpolatingMovementTask(Movement *movement, Movement::Point target, int speed); bool isDone(); void startRunning(); const char* name() { diff --git a/tsc/src/measurer.ts b/tsc/src/measurer.ts deleted file mode 100644 index 9178349a..00000000 --- a/tsc/src/measurer.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Command } from "./types"; -import { distanceBetweenPoints, getLastPoint } from "./utils"; - -export function measureDistance(dedupedCommands: Command[]) { - let totalDistance = 0; - let drawDistance = 0; - let penUp = true; - - for (let i = 1; i < dedupedCommands.length; i++) { - const command = dedupedCommands[i]; - - if (typeof command !== 'string') { - const lastCommand = getLastPoint(dedupedCommands.slice(0, i)); - if (lastCommand) { - if (command.x !== lastCommand.x || command.y !== lastCommand.y) { - const distance = distanceBetweenPoints(lastCommand, command); - totalDistance += distance; - - if (!penUp) { - drawDistance += distance; - } - } - } - } else { - if (command === 'p0') { - penUp = true; - } else if (command === 'p1') { - penUp = false; - } - } - } - - return { - totalDistance, - drawDistance, - }; -} \ No newline at end of file diff --git a/tsc/src/optimizer.ts b/tsc/src/optimizer.ts deleted file mode 100644 index 176e1636..00000000 --- a/tsc/src/optimizer.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { loadPaper } from "./paperLoader"; -import { InfilledPath } from "./types"; - -const paper = loadPaper(); - -export function optimizePaths(infilledPaths: InfilledPath[], start_x: number, start_y: number): paper.Path[] { - const paths: paper.Path[] = []; - - function getLastPoint() { - if (paths.length === 0) { - throw new Error('no points found'); - } - - const lastPath = paths[paths.length - 1]; - return lastPath.closed ? lastPath.firstSegment.point : lastPath.lastSegment.point; - } - - const infilledPathsCopy = [...infilledPaths]; - - while (infilledPathsCopy.length > 0) { - - const infilledPathToProcess = getClosestInfilledPath(infilledPathsCopy, paths.length > 0 ? getLastPoint() : new paper.Point(start_x, start_y)); - const infilledPathIndex = infilledPathToProcess.infilledPathIndex; - let outlinePathIndex = infilledPathToProcess.index; - - const infilledPath = infilledPathsCopy[infilledPathIndex]; - const outlinePathsCopy = [...infilledPath.outlinePaths]; - - while (outlinePathsCopy.length > 0) - { - const currentOutlinePath = outlinePathsCopy[outlinePathIndex]; - paths.push(currentOutlinePath); - - outlinePathsCopy.splice(outlinePathIndex, 1); - - const nextPath = getClosestPath(outlinePathsCopy, getLastPoint(), false); - if (nextPath) { - outlinePathIndex = nextPath.index; - } - } - - const infillsCopy = [...infilledPath.infillPaths]; - while (infillsCopy.length > 0) { - const nextInfill = getClosestPath(infillsCopy, getLastPoint(), true); - - if (nextInfill.reverse) { - nextInfill.path.reverse(); - } - - paths.push(nextInfill.path); - - infillsCopy.splice(nextInfill.index, 1); - } - - infilledPathsCopy.splice(infilledPathIndex, 1); - } - return paths; -} - -function getClosestInfilledPath(infilledPaths: InfilledPath[], lastPoint: paper.Point) { - const infilledPathsCost = infilledPaths.map((ip, index) => { - // this could be optimized by considering all segments (and potentially drawing segments in reverse) - // at the expense of compute - - const closestOutlinePath = getClosestPath(ip.outlinePaths, lastPoint, false); - return { - infilledPath: ip, - infilledPathIndex: index, - ...closestOutlinePath, - } - }); - - return infilledPathsCost.sort((a, b) => a.cost - b.cost)[0]; -} - -function getClosestPath(paths: paper.Path[], lastPoint: paper.Point, canReverse: boolean) { - const pathCosts = paths.map((p, index) => { - const startPoint = p.firstSegment.point; - // cheaper to keep it squared - const startPointCost = startPoint.getDistance(lastPoint, true); - - if (canReverse) { - const endPoint = p.lastSegment.point; - const endPointCost = endPoint.getDistance(lastPoint, true); - - if (endPointCost >= startPointCost) { - return {path: p, cost: startPointCost, index, reverse: false}; - } else { - return {path: p, cost: endPointCost, index, reverse: true}; - } - } else { - return {path: p, cost: startPointCost, index, reverse: false}; - } - }); - - return pathCosts.sort((a, b) => a.cost - b.cost)[0]; -} - diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 00000000..963c01e0 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Mural + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 00000000..daa127d7 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2737 @@ +{ + "name": "mural-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mural-ui", + "version": "1.0.0", + "dependencies": { + "paper": "0.12.17", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/paper": "^0.11.9", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.40", + "tailwindcss": "^3.4.7", + "typescript": "^5.5.3", + "vite": "^5.3.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/paper": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@types/paper/-/paper-0.11.14.tgz", + "integrity": "sha512-R7KPu3tzoPOScBJgKj69eFho2hYSZcxgKT86Pcx+Gm9J+g5gHSe2ZMGpC2kFHRr6OPRXikvP2D/bNKo+TnISAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/paper": { + "version": "0.12.17", + "resolved": "https://registry.npmjs.org/paper/-/paper-0.12.17.tgz", + "integrity": "sha512-oCe+e1C2w8hKIcGoAqUjD0GGxGPv+itrRXlEFUmp3H8tY/NTnHOkYgpJFPGw6OJ8Q1Wa6+RgzlY7Dx/2WWHtkA==", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 00000000..30d3aba3 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "mural-ui", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "paper": "0.12.17", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/paper": "^0.11.9", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.40", + "tailwindcss": "^3.4.7", + "typescript": "^5.5.3", + "vite": "^5.3.5" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 00000000..443c9f80 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,231 @@ +import { useState, useEffect } from 'react' +import { getState, enableMockMode } from './api' +import { showErrorAlert } from './components/AlertModal' +import type { BackendState, UiPhase } from './types' +import type { SvgState } from './svgControl' +import type { RasterImageState } from './rasterControl' +import type { RendererDefinition } from './renderers/index' +import { getRenderersByInputType } from './renderers/index' + +import { LoadingScreen } from './screens/LoadingScreen' +import { RetractBeltsScreen } from './screens/RetractBeltsScreen' +import { SetTopDistanceScreen } from './screens/SetTopDistanceScreen' +import { ExtendToHomeScreen } from './screens/ExtendToHomeScreen' +import { PenCalibrationScreen } from './screens/PenCalibrationScreen' +import { InputSelectScreen } from './screens/InputSelectScreen' +import { SvgUploadScreen } from './screens/SvgUploadScreen' +import { RasterUploadScreen } from './screens/RasterUploadScreen' +import { ChooseRendererScreen } from './screens/ChooseRendererScreen' +import { DrawingPreviewScreen } from './screens/DrawingPreviewScreen' +import { UploadProgressScreen } from './screens/UploadProgressScreen' +import { BeginDrawingScreen } from './screens/BeginDrawingScreen' +import { DrawingBeganScreen } from './screens/DrawingBeganScreen' + +export default function App() { + const [backendState, setBackendState] = useState(null) + const [uiPhase, setUiPhase] = useState('loading') + + // SVG pipeline state + const [svgState, setSvgState] = useState(null) + const [rasterState, setRasterState] = useState(null) + const [inputType, setInputType] = useState<'svg' | 'raster' | null>(null) + const [selectedRenderer, setSelectedRenderer] = useState(null) + const [renderedBlob, setRenderedBlob] = useState(null) + + // The backend uses 'SvgSelect' as its "ready to draw" phase, but we intercept + // it to show our own InputSelect screen instead. + function backendPhaseToUiPhase(phase: string): UiPhase { + if (phase === 'SvgSelect') return 'InputSelect' + return phase as UiPhase + } + + useEffect(() => { + getState() + .then((s) => { + if (!s?.phase) throw new Error('No backend') + setBackendState(s) + setUiPhase(backendPhaseToUiPhase(s.phase)) + }) + .catch(() => { + if (import.meta.env.DEV) { + const mock = { phase: 'SvgSelect' as const, safeWidth: 1000, homeX: 500, homeY: 0, safeXFraction: 0.2, safeYFraction: 0.25 } + enableMockMode(mock) + setBackendState(mock) + setUiPhase('InputSelect') + } else { + showErrorAlert('Failed to retrieve state') + } + }) + }, []) + + function handleStateUpdate(s: BackendState) { + setBackendState(s) + setUiPhase(backendPhaseToUiPhase(s.phase)) + } + + function handleSelectSvg() { + setInputType('svg') + setUiPhase('SvgSelect') + } + + function handleSelectRaster() { + setInputType('raster') + setUiPhase('RasterSelect') + } + + function handleBackToInputSelect() { + setSvgState(null) + setRasterState(null) + setSelectedRenderer(null) + setInputType(null) + setUiPhase('InputSelect') + } + + function handleSvgPreview(state: SvgState) { + setSvgState(state) + setUiPhase('ChooseRenderer') + } + + function handleRasterPreview(state: RasterImageState) { + setRasterState(state) + const rasterRenderers = getRenderersByInputType('raster') + if (rasterRenderers.length === 1) { + setSelectedRenderer(rasterRenderers[0]) + setUiPhase('DrawingPreview') + } else { + setUiPhase('ChooseRenderer') + } + } + + function handleRendererChosen(renderer: RendererDefinition) { + setSelectedRenderer(renderer) + setUiPhase('DrawingPreview') + } + + function handleBackFromChooseRenderer() { + setUiPhase(inputType === 'raster' ? 'RasterSelect' : 'SvgSelect') + } + + function handleBackFromDrawingPreview() { + if (inputType === 'raster' && getRenderersByInputType('raster').length === 1) { + setUiPhase('RasterSelect') + } else { + setUiPhase('ChooseRenderer') + } + } + + function handleCommandsAccepted(blob: Blob) { + setRenderedBlob(blob) + setUiPhase('UploadProgress') + } + + function handleUploadDone(s: BackendState) { + setBackendState(s) + setUiPhase(s.phase) + } + + async function handleUploadError(message: string) { + await showErrorAlert(message) + } + + let currentScreen + switch (uiPhase) { + case 'loading': + currentScreen = + break + case 'RetractBelts': + currentScreen = + break + case 'SetTopDistance': + currentScreen = + break + case 'ExtendToHome': + currentScreen = + break + case 'PenCalibration': + currentScreen = + break + case 'InputSelect': + currentScreen = ( + + ) + break + case 'SvgSelect': + currentScreen = ( + + ) + break + case 'RasterSelect': + currentScreen = ( + + ) + break + case 'ChooseRenderer': + currentScreen = ( + + ) + break + case 'DrawingPreview': + currentScreen = ( + + ) + break + case 'UploadProgress': + currentScreen = ( + + ) + break + case 'BeginDrawing': + currentScreen = ( + setUiPhase('DrawingBegan')} + onReset={handleStateUpdate} + /> + ) + break + case 'DrawingBegan': + currentScreen = + break + default: + currentScreen = ( +
Unrecognized phase: {uiPhase}
+ ) + } + + return ( +
+
{currentScreen}
+ + Mural + +
+ ) +} diff --git a/ui/src/api.ts b/ui/src/api.ts new file mode 100644 index 00000000..9883bc73 --- /dev/null +++ b/ui/src/api.ts @@ -0,0 +1,158 @@ +import type { BackendState } from './types' + +// --------------------------------------------------------------------------- +// Mock mode — activated automatically in dev when no backend is reachable +// --------------------------------------------------------------------------- +let _mockMode = false +let _mockState: BackendState = { phase: 'SvgSelect', safeWidth: 1000, homeX: 500, homeY: 0, safeXFraction: 0.2, safeYFraction: 0.25 } +let _mockUploadedBlob: Blob | null = null + +export function enableMockMode(state: BackendState) { + _mockMode = true + _mockState = state +} + +export function isMockMode() { return _mockMode } + +// --------------------------------------------------------------------------- + +async function post(url: string, body: Record = {}): Promise { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(body as Record).toString(), + }) + const text = await res.text() + if (!res.ok) throw new Error(text || `${url} failed: ${res.status}`) + try { + return JSON.parse(text) as T + } catch { + return text as unknown as T + } +} + +async function get(url: string): Promise { + const res = await fetch(url) + const text = await res.text() + if (!res.ok) throw new Error(text || `${url} failed: ${res.status}`) + try { + return JSON.parse(text) as T + } catch { + return text as unknown as T + } +} + +export const getState = (): Promise => get('/getState') + +export const doneWithPhase = (): Promise => post('/doneWithPhase') + +export const setTopDistance = (distance: number): Promise => + post('/setTopDistance', { distance: String(distance) }) + +export const extendToHome = (): Promise => post('/extendToHome') + +export const setServo = (angle: number): Promise => + post('/setServo', { angle: String(angle) }) + +export const setPenDistance = (angle: number): Promise => + post('/setPenDistance', { angle: String(angle) }) + +export const estepsCalibration = (): Promise => + post('/estepsCalibration') + +export const postMotorCommand = (command: string): Promise => + post('/command', { command }) + +export const run = (speed: number): Promise => post('/run', { speed: String(speed) }) + +export function uploadCommands( + blob: Blob, + onUploadProgress: (pct: number) => void, +): Promise { + if (_mockMode) { + _mockUploadedBlob = blob + return new Promise((resolve) => { + let pct = 0 + const iv = setInterval(() => { + pct = Math.min(100, pct + 20) + onUploadProgress(pct) + if (pct === 100) { clearInterval(iv); resolve(_mockState) } + }, 80) + }) + } + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const formData = new FormData() + formData.append('commands', blob) + + xhr.upload.addEventListener('progress', (evt) => { + if (evt.lengthComputable) { + onUploadProgress(Math.round((evt.loaded / evt.total) * 100)) + } + }) + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText) as BackendState) + } catch { + reject(new Error('Invalid upload response')) + } + } else { + reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`)) + } + } + + xhr.onerror = () => reject(new Error('Upload network error')) + xhr.open('POST', '/uploadCommands') + xhr.send(formData) + }) +} + +export function downloadCommands( + onProgress: (pct: number) => void, +): Promise { + if (_mockMode) { + return new Promise((resolve) => { + let pct = 0 + const iv = setInterval(() => { + pct = Math.min(100, pct + 20) + onProgress(pct) + if (pct === 100) { clearInterval(iv); resolve(_mockUploadedBlob ?? new Blob()) } + }, 80) + }) + } + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.responseType = 'blob' + + xhr.addEventListener('progress', (evt) => { + if (evt.lengthComputable) { + onProgress(Math.round((evt.loaded / evt.total) * 100)) + } + }) + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.response as Blob) + } else { + reject(new Error(`Download failed: ${xhr.status}`)) + } + } + + xhr.onerror = () => reject(new Error('Download network error')) + xhr.open('GET', '/downloadCommands') + xhr.send() + }) +} + +// Motor helpers +export const sendJog = (vx: number, vy: number): Promise => + post('/command', { command: 'jog', vx: String(vx), vy: String(vy) }) + +export const leftRetractDown = () => postMotorCommand('l-ret') +export const leftExtendDown = () => postMotorCommand('l-ext') +export const leftStop = () => postMotorCommand('l-0') +export const rightRetractDown = () => postMotorCommand('r-ret') +export const rightExtendDown = () => postMotorCommand('r-ext') +export const rightStop = () => postMotorCommand('r-0') diff --git a/ui/src/commandCompression.ts b/ui/src/commandCompression.ts new file mode 100644 index 00000000..6f7fd9fc --- /dev/null +++ b/ui/src/commandCompression.ts @@ -0,0 +1,13 @@ +export async function compressCommands(input: string): Promise { + const cs = new CompressionStream('deflate-raw') + const writer = cs.writable.getWriter() + writer.write(new TextEncoder().encode(input)).then(() => writer.close()) + return new Response(cs.readable).blob() +} + +export async function decompressBlob(blob: Blob): Promise { + const ds = new DecompressionStream('deflate-raw') + const writer = ds.writable.getWriter() + writer.write(await blob.arrayBuffer()).then(() => writer.close()) + return new Response(ds.readable).text() +} diff --git a/ui/src/components/AlertModal.tsx b/ui/src/components/AlertModal.tsx new file mode 100644 index 00000000..f5f1a838 --- /dev/null +++ b/ui/src/components/AlertModal.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react' +import { createRoot } from 'react-dom/client' +import { Button } from './Button' + +interface AlertModalProps { + message: string + onDismiss: () => void +} + +function AlertModal({ message, onDismiss }: AlertModalProps) { + const btnRef = useRef(null) + + useEffect(() => { + btnRef.current?.focus() + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape' || e.key === 'Enter') onDismiss() + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [onDismiss]) + + return ( +
+
+

{message}

+ +
+
+ ) +} + +/** Drop-in async replacement for window.alert(). + * Resolves only after the user dismisses the modal. */ +export function showAlert(message: string): Promise { + return new Promise((resolve) => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + function dismiss() { + root.unmount() + document.body.removeChild(container) + resolve() + } + + root.render() + }) +} + +/** Show an error alert then reload the page once the user dismisses it. */ +export async function showErrorAlert(message: string): Promise { + await showAlert(message) + location.reload() + // unreachable, but satisfies the return type so callers don't need extra casts + return new Promise(() => {}) +} diff --git a/ui/src/components/Button.tsx b/ui/src/components/Button.tsx new file mode 100644 index 00000000..b7d06bbc --- /dev/null +++ b/ui/src/components/Button.tsx @@ -0,0 +1,33 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react' + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' + fullWidth?: boolean +} + +export const Button = forwardRef(function Button( + { variant = 'primary', fullWidth = true, className = '', children, ...props }, + ref, +) { + const base = + 'rounded-xl px-5 py-3 sm:py-4 2xl:py-5 font-medium text-sm sm:text-base 2xl:text-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 disabled:opacity-40 disabled:cursor-not-allowed' + + const variants = { + primary: + 'bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white focus:ring-cyan-500', + secondary: + 'bg-gray-700 hover:bg-gray-600 active:bg-gray-800 text-gray-200 focus:ring-gray-500', + danger: + 'bg-red-700 hover:bg-red-600 active:bg-red-800 text-white focus:ring-red-500', + } + + return ( + + ) +}) diff --git a/ui/src/components/Card.tsx b/ui/src/components/Card.tsx new file mode 100644 index 00000000..e8c0e51c --- /dev/null +++ b/ui/src/components/Card.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' + +interface CardProps { + title?: string + subtitle?: string + children: ReactNode + className?: string +} + +export function Card({ title, subtitle, children, className = '' }: CardProps) { + return ( +
+ {(title || subtitle) && ( +
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+ )} + {children} +
+ ) +} diff --git a/ui/src/components/DPad.tsx b/ui/src/components/DPad.tsx new file mode 100644 index 00000000..e4698c12 --- /dev/null +++ b/ui/src/components/DPad.tsx @@ -0,0 +1,112 @@ +type NudgeDir = 'up' | 'down' | 'left' | 'right' +type ZoomDir = 'in' | 'out' | 'reset' + +interface DPadProps { + onNudge: (dir: NudgeDir) => void + onZoom: (dir: ZoomDir) => void + transformText?: string +} + +export function DPad({ onNudge, onZoom, transformText }: DPadProps) { + return ( +
+
+ + {/* Circle d-pad */} +
+ + {/* UP */} + + + {/* RIGHT */} + + + {/* DOWN */} + + + {/* LEFT */} + + + {/* Divider lines (diagonal, matching sector boundaries) */} +
+
+ + {/* Center decorative circle */} +
+
+ + {/* Zoom pill */} +
+ +
+ +
+ +
+ +
+ + {transformText && ( +

{transformText}

+ )} +
+ ) +} diff --git a/ui/src/components/ProgressBar.tsx b/ui/src/components/ProgressBar.tsx new file mode 100644 index 00000000..3bed641f --- /dev/null +++ b/ui/src/components/ProgressBar.tsx @@ -0,0 +1,27 @@ +interface ProgressBarProps { + label?: string + value: number + animated?: boolean + color?: 'indigo' | 'emerald' +} + +export function ProgressBar({ label, value, animated = false, color = 'indigo' }: ProgressBarProps) { + const colors = { + indigo: 'bg-cyan-500', + emerald: 'bg-emerald-500', + } + + return ( +
+ {label &&

{label}

} +
+
+
+
+ ) +} diff --git a/ui/src/components/Slider.tsx b/ui/src/components/Slider.tsx new file mode 100644 index 00000000..be41bb1e --- /dev/null +++ b/ui/src/components/Slider.tsx @@ -0,0 +1,41 @@ +interface SliderProps { + id?: string + label: string + min: number + max: number + step?: number + value: number + onChange: (value: number) => void + className?: string + disabled?: boolean +} + +export function Slider({ id, label, min, max, step = 1, value, onChange, className = '', disabled = false }: SliderProps) { + return ( +
+
+ + {value} +
+ { + const raw = Number(e.target.value) + const decimals = (step.toString().split('.')[1] ?? '').length + const snapped = parseFloat((Math.round(raw / step) * step).toFixed(decimals)) + const next = Math.max(min, Math.min(max, snapped)) + if (next !== value) onChange(next) + }} + className="w-full h-2 sm:h-3 2xl:h-4 rounded-full cursor-pointer accent-cyan-500" + /> +
+ ) +} diff --git a/ui/src/components/Thumbstick.tsx b/ui/src/components/Thumbstick.tsx new file mode 100644 index 00000000..de7dd147 --- /dev/null +++ b/ui/src/components/Thumbstick.tsx @@ -0,0 +1,106 @@ +import { useRef, useState, useCallback, useEffect } from 'react' + +interface ThumbstickProps { + onJog: (vx: number, vy: number) => void + intervalMs?: number + size?: number +} + +export function Thumbstick({ onJog, intervalMs = 100, size = 144 }: ThumbstickProps) { + const baseRef = useRef(null) + const intervalRef = useRef | null>(null) + const deflectionRef = useRef<{ vx: number; vy: number }>({ vx: 0, vy: 0 }) + const [thumbPos, setThumbPos] = useState({ x: 0, y: 0 }) + + const thumbRadius = size * 0.18 + const maxDeflect = size * 0.32 + + const startPolling = useCallback(() => { + if (intervalRef.current !== null) return + intervalRef.current = setInterval(() => { + const { vx, vy } = deflectionRef.current + onJog(vx, vy) + }, intervalMs) + }, [onJog, intervalMs]) + + const stopPolling = useCallback(() => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + deflectionRef.current = { vx: 0, vy: 0 } + setThumbPos({ x: 0, y: 0 }) + onJog(0, 0) + }, [onJog]) + + const computeDeflection = useCallback((clientX: number, clientY: number) => { + const base = baseRef.current + if (!base) return + const rect = base.getBoundingClientRect() + const dx = clientX - (rect.left + rect.width / 2) + const dy = clientY - (rect.top + rect.height / 2) + const dist = Math.sqrt(dx * dx + dy * dy) + const clamped = Math.min(dist, maxDeflect) + const angle = Math.atan2(dy, dx) + const magnitude = clamped / maxDeflect + deflectionRef.current = { + vx: +(Math.cos(angle) * magnitude).toFixed(3), + vy: +(Math.sin(angle) * magnitude).toFixed(3), + } + setThumbPos({ + x: Math.cos(angle) * clamped, + y: Math.sin(angle) * clamped, + }) + }, [maxDeflect]) + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId) + computeDeflection(e.clientX, e.clientY) + startPolling() + }, [computeDeflection, startPolling]) + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (e.buttons === 0) return + computeDeflection(e.clientX, e.clientY) + }, [computeDeflection]) + + const handlePointerUp = useCallback(() => { + stopPolling() + }, [stopPolling]) + + useEffect(() => () => { + if (intervalRef.current !== null) clearInterval(intervalRef.current) + }, []) + + const isCenter = thumbPos.x === 0 && thumbPos.y === 0 + + return ( +
+
+
+
+
+
+
+
+
+ ) +} diff --git a/ui/src/components/Toggle.tsx b/ui/src/components/Toggle.tsx new file mode 100644 index 00000000..130bc264 --- /dev/null +++ b/ui/src/components/Toggle.tsx @@ -0,0 +1,29 @@ +interface ToggleProps { + id?: string + label: string + checked: boolean + onChange: (checked: boolean) => void +} + +export function Toggle({ id, label, checked, onChange }: ToggleProps) { + return ( + + ) +} diff --git a/ui/src/components/ToolsModal.tsx b/ui/src/components/ToolsModal.tsx new file mode 100644 index 00000000..73b8c291 --- /dev/null +++ b/ui/src/components/ToolsModal.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import { + leftRetractDown, + leftExtendDown, + leftStop, + rightRetractDown, + rightExtendDown, + rightStop, + setServo, + estepsCalibration, +} from '../api' +import { Button } from './Button' + +interface ToolsModalProps { + isOpen: boolean + onClose: () => void +} + +export function ToolsModal({ isOpen, onClose }: ToolsModalProps) { + const [leftMotor, setLeftMotor] = useState(0) + const [rightMotor, setRightMotor] = useState(0) + + // Stop motors when modal closes + useEffect(() => { + if (!isOpen) { + leftStop() + rightStop() + setLeftMotor(0) + setRightMotor(0) + } + }, [isOpen]) + + function handleLeftMotor(v: number) { + setLeftMotor(v) + if (v <= -1) leftRetractDown() + else if (v >= 1) leftExtendDown() + else leftStop() + } + + function handleRightMotor(v: number) { + setRightMotor(v) + if (v <= -1) rightRetractDown() + else if (v >= 1) rightExtendDown() + else rightStop() + } + + if (!isOpen) return null + + return ( +
e.target === e.currentTarget && onClose()} + > +
+
+

Tools

+ +
+ +
+
+ + {leftMotor} +
+ handleLeftMotor(Number(e.target.value))} + className="w-full accent-cyan-500" + /> +
+ +
+
+ + {rightMotor} +
+ handleRightMotor(Number(e.target.value))} + className="w-full accent-cyan-500" + /> +
+ + + + + + +
+
+ ) +} diff --git a/ui/src/hooks/useWorkerRenderer.ts b/ui/src/hooks/useWorkerRenderer.ts new file mode 100644 index 00000000..2ace14b8 --- /dev/null +++ b/ui/src/hooks/useWorkerRenderer.ts @@ -0,0 +1,61 @@ +import { useState, useRef, useCallback, useEffect } from 'react' +import type { BackendState, RenderResult } from '../types' +import type { SvgState } from '../svgControl' +import type { RasterImageState } from '../rasterControl' +import type { RendererDefinition, RendererParamValue } from '../renderers/index' + +export interface WorkerRendererState { + isRendering: boolean + status: string + result: RenderResult | null +} + +export function useWorkerRenderer() { + const [state, setState] = useState({ + isRendering: false, + status: '', + result: null, + }) + const workerRef = useRef(null) + + const render = useCallback( + async ( + renderer: RendererDefinition, + inputState: { svgState?: SvgState; imageState?: RasterImageState }, + params: Record, + backendState: BackendState, + ) => { + if (workerRef.current) { + workerRef.current.terminate() + } + + setState({ isRendering: true, status: '', result: null }) + + const worker = new Worker(`./worker/worker.js?v=${Date.now()}`) + workerRef.current = worker + + try { + const result = await renderer.execute({ + svgState: inputState.svgState, + imageState: inputState.imageState, + params, + backendState, + onStatus: (status) => setState((s) => ({ ...s, status })), + worker, + }) + setState({ isRendering: false, status: 'Done', result }) + } catch (err) { + console.error('Render failed:', err) + setState({ isRendering: false, status: 'Error', result: null }) + } + }, + [], + ) + + // Terminate worker on unmount + useEffect(() => { + return () => workerRef.current?.terminate() + }, []) + + return { render, ...state } +} diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 00000000..a99b7204 --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1,11 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-gray-950 text-gray-100 min-h-screen; +} + +input[type='range'] { + @apply accent-cyan-500; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 00000000..db032b74 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/ui/src/rasterControl.ts b/ui/src/rasterControl.ts new file mode 100644 index 00000000..a1007906 --- /dev/null +++ b/ui/src/rasterControl.ts @@ -0,0 +1,94 @@ +import { type AffineTransform, DEFAULT_TRANSFORM } from './svgControl' + +export type { AffineTransform } + +const PREVIEW_SIZE = 800 // preview canvas width in px + +export interface RasterImageState { + /** Source File object */ + file: File + /** Object URL for the raw file — call URL.revokeObjectURL on cleanup */ + previewUrl: string + /** Drawing width in mm (= safeWidth from backend) */ + width: number + /** Drawing height in mm (aspect-ratio scaled from naturalWidth/naturalHeight) */ + height: number + /** Original image pixel width */ + naturalWidth: number + /** Original image pixel height */ + naturalHeight: number + /** Current pan/zoom/crop transform */ + transform: AffineTransform +} + +/** Create a RasterImageState from an uploaded file */ +export async function parseRasterImage(file: File, safeWidth: number): Promise { + const bitmap = await createImageBitmap(file) + const naturalWidth = bitmap.width + const naturalHeight = bitmap.height + bitmap.close() + + const height = (safeWidth / naturalWidth) * naturalHeight + const previewUrl = URL.createObjectURL(file) + + return { + file, + previewUrl, + width: safeWidth, + height, + naturalWidth, + naturalHeight, + transform: [...DEFAULT_TRANSFORM] as AffineTransform, + } +} + +/** + * Render a transformed preview of the image. Returns a blob URL — caller must + * revoke it when done (URL.revokeObjectURL). + * + * Transform semantics (same as SVG): + * t[0]/t[3] = scale (1 = image fills canvas exactly) + * t[4] = x offset as fraction of canvas width (positive = shift right) + * t[5] = y offset as fraction of canvas height (positive = shift down) + * Areas not covered by the image appear white (= blank canvas = no drawing). + */ +export async function getRasterPreviewDataUrl(state: RasterImageState): Promise { + const t = state.transform + const W = PREVIEW_SIZE + const H = Math.round(W * (state.height / state.width)) + const extraH = t[5] > 0 ? Math.round(t[5] * H) : 0 + + const bitmap = await createImageBitmap(state.file) + const canvas = new OffscreenCanvas(W, H + extraH) + const ctx = canvas.getContext('2d')! + + ctx.fillStyle = 'white' + ctx.fillRect(0, 0, W, H + extraH) + ctx.drawImage(bitmap, t[4] * W, t[5] * H, t[0] * W, t[3] * H) + bitmap.close() + + const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 }) + return URL.createObjectURL(blob) +} + +/** + * Render image to full-resolution ImageData for sending to the worker, with + * the current transform applied (pan / zoom / crop). + */ +export async function rasterizeImage(state: RasterImageState): Promise { + const t = state.transform + const W = state.naturalWidth + const H = state.naturalHeight + const extraH = t[5] > 0 ? Math.round(t[5] * H) : 0 + + const bitmap = await createImageBitmap(state.file) + const canvas = new OffscreenCanvas(W, H + extraH) + const ctx = canvas.getContext('2d')! + + // No white fill — preserve source alpha so the worker can distinguish + // transparent pixels (pen lift) from opaque white pixels (flat line). + ctx.drawImage(bitmap, t[4] * W, t[5] * H, t[0] * W, t[3] * H) + bitmap.close() + + return ctx.getImageData(0, 0, W, H + extraH) +} diff --git a/ui/src/renderers/index.ts b/ui/src/renderers/index.ts new file mode 100644 index 00000000..1d6b2bf4 --- /dev/null +++ b/ui/src/renderers/index.ts @@ -0,0 +1,90 @@ +import type { BackendState, InfillDensity, RenderResult } from '../types' +import type { SvgState } from '../svgControl' +import type { RasterImageState } from '../rasterControl' + +export type RendererParamValue = number | boolean | string + +export type RendererParamLeaf = + | { + type: 'slider' + id: string + label: string + min: number + max: number + step: number + default: number + disabledWhen?: { id: string; value: RendererParamValue } + } + | { type: 'checkbox'; id: string; label: string; default: boolean; disabledWhen?: { id: string; value: RendererParamValue } } + | { + type: 'select' + id: string + label: string + options: { value: string; label: string }[] + default: string + } + +export type RendererParam = + | RendererParamLeaf + | { type: 'row'; items: RendererParamLeaf[] } + +export interface ExecuteOpts { + svgState?: SvgState + imageState?: RasterImageState + params: Record + backendState: BackendState + onStatus: (status: string) => void + worker: Worker +} + +export interface RendererDefinition { + id: string + label: string + description?: string + inputType: 'svg' | 'raster' + params: RendererParam[] + execute(opts: ExecuteOpts): Promise +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +export function getInfillDensity(params: Record): InfillDensity { + const v = Number(params['infillDensity'] ?? 0) + if (v === 0 || v === 1 || v === 2 || v === 3 || v === 4) return v + throw new Error('Invalid infill density') +} + +export function listenForRendererResult( + worker: Worker, + onStatus: (s: string) => void, +): Promise { + return new Promise((resolve, reject) => { + worker.onmessage = (e: MessageEvent) => { + const { type, payload } = e.data as { type: string; payload: unknown } + if (type === 'status') onStatus(payload as string) + else if (type === 'renderer') resolve(payload as RenderResult) + else if (type === 'log') console.log(`Worker: ${payload}`) + } + worker.onerror = (e) => reject(e) + }) +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +import { pathTracingRenderer } from './pathTracing' +import { vectorRasterVectorRenderer } from './vectorRasterVector' +import { rasterZigZagRenderer } from './rasterZigZag' + +export const RENDERERS: RendererDefinition[] = [ + pathTracingRenderer, + vectorRasterVectorRenderer, + rasterZigZagRenderer, +] + +export function getRenderersByInputType(type: 'svg' | 'raster'): RendererDefinition[] { + return RENDERERS.filter((r) => r.inputType === type) +} diff --git a/ui/src/renderers/pathTracing.ts b/ui/src/renderers/pathTracing.ts new file mode 100644 index 00000000..1ae5d16d --- /dev/null +++ b/ui/src/renderers/pathTracing.ts @@ -0,0 +1,43 @@ +import { svgStringToJson, getTransformedSvgString } from '../svgControl' +import { + getInfillDensity, + listenForRendererResult, + type RendererDefinition, + type ExecuteOpts, +} from './index' + +async function execute({ svgState, params, backendState, onStatus, worker }: ExecuteOpts) { + if (!svgState) throw new Error('pathTracing requires svgState') + const svgString = getTransformedSvgString(svgState) + onStatus('Preparing SVG') + const svgJson = svgStringToJson(svgString) + + const resultPromise = listenForRendererResult(worker, onStatus) + + worker.postMessage({ + type: 'renderSvg', + svgJson, + width: svgState.width, + height: svgState.baseHeight, + svgWidth: svgState.width, + svgHeight: svgState.baseHeight, + homeX: backendState.homeX ?? 0, + homeY: backendState.homeY ?? 0, + infillDensity: getInfillDensity(params), + flattenPaths: Boolean(params['flattenPaths']), + }) + + return resultPromise +} + +export const pathTracingRenderer: RendererDefinition = { + id: 'pathTracing', + label: 'Path Tracing', + description: 'Works well for most drawings', + inputType: 'svg', + params: [ + { type: 'slider', id: 'infillDensity', label: 'Infill Density', min: 0, max: 4, step: 1, default: 0 }, + { type: 'checkbox', id: 'flattenPaths', label: 'Flatten paths', default: false }, + ], + execute, +} diff --git a/ui/src/renderers/rasterZigZag.ts b/ui/src/renderers/rasterZigZag.ts new file mode 100644 index 00000000..0e478055 --- /dev/null +++ b/ui/src/renderers/rasterZigZag.ts @@ -0,0 +1,83 @@ +import { rasterizeImage } from '../rasterControl' +import { listenForRendererResult, type ExecuteOpts, type RendererDefinition } from './index' + +async function execute({ imageState, params, backendState, onStatus, worker }: ExecuteOpts) { + if (!imageState) throw new Error('rasterZigZag requires imageState') + onStatus('Loading image') + const imageData = await rasterizeImage(imageState) + const resultPromise = listenForRendererResult(worker, onStatus) + + // Compute where the image content actually lives within the canvas (mm), + // accounting for any pan/zoom/crop the user applied. + // heightMm is derived from imageData pixel height to account for canvas expansion + // when the image is shifted down (t[5] > 0). + const t = imageState.transform + const W = imageState.width + const H = imageState.height + const heightMm = imageData.height * (H / imageState.naturalHeight) + const imageLeft = Math.max(0, t[4] * W) + const imageTop = Math.max(0, t[5] * H) + const imageRight = Math.min(W, (t[4] + t[0]) * W) + const imageBottom = Math.min(heightMm, (t[5] + t[3]) * H) + + worker.postMessage( + { + type: 'renderRasterZigZag', + imageData, + widthMm: imageState.width, + heightMm, + homeX: backendState.homeX ?? 0, + homeY: backendState.homeY ?? 0, + lineSpacing: Number(params['lineSpacing'] ?? 8), + amplitude: Number(params['amplitude'] ?? 3), + brightness: Number(params['brightness'] ?? 0), + contrast: Number(params['contrast'] ?? 0), + blackPoint: Number(params['blackPoint'] ?? 0) / 100, + whitePoint: Number(params['whitePoint'] ?? 100) / 100, + angle: Number(params['angle'] ?? 0), + continuousPath: Boolean(params['continuousPath'] ?? false), + liftOnTransparent: Boolean(params['liftOnTransparent'] ?? false), + minHalfPeriod: Number(params['minHalfPeriod'] ?? 0.5), + maxHalfPeriod: Number(params['maxHalfPeriod'] ?? 2.0), + useAmFm: Boolean(params['useAmFm'] ?? true), + imageLeft, + imageTop, + imageRight, + imageBottom, + }, + [imageData.data.buffer], + ) + return resultPromise +} + +export const rasterZigZagRenderer: RendererDefinition = { + id: 'rasterZigZag', + label: 'Raster Zig-Zag', + description: 'Converts a photo to zig-zag scan lines — darker areas = taller zig-zags', + inputType: 'raster', + params: [ + { type: 'row', items: [ + { type: 'slider', id: 'lineSpacing', label: 'Spacing (mm)', min: 1, max: 20, step: 1, default: 5 }, + { type: 'slider', id: 'amplitude', label: 'Amplitude (mm)', min: 0.5, max: 10, step: 0.5, default: 3.5 }, + ]}, + { type: 'row', items: [ + { type: 'slider', id: 'brightness', label: 'Brightness', min: -100, max: 100, step: 5, default: 0 }, + { type: 'slider', id: 'contrast', label: 'Contrast', min: -100, max: 100, step: 5, default: 0 }, + ]}, + { type: 'row', items: [ + { type: 'slider', id: 'blackPoint', label: 'Black Point', min: 0, max: 90, step: 5, default: 0 }, + { type: 'slider', id: 'whitePoint', label: 'White Point', min: 10, max: 100, step: 5, default: 100 }, + ]}, + { type: 'row', items: [ + { type: 'slider', id: 'angle', label: 'Angle (°)', min: -90, max: 90, step: 15, default: 45 }, + { type: 'checkbox', id: 'liftOnTransparent', label: 'Lift On Transparent', default: false }, + { type: 'checkbox', id: 'continuousPath', label: 'Link Scan Lines', default: true }, + ]}, + { type: 'row', items: [ + { type: 'checkbox', id: 'useAmFm', label: 'AM + FM', default: false }, + { type: 'slider', id: 'minHalfPeriod', label: 'Min peak spacing (mm)', min: 0.1, max: 2.0, step: 0.1, default: 0.5, disabledWhen: { id: 'useAmFm', value: false } }, + { type: 'slider', id: 'maxHalfPeriod', label: 'Max peak spacing (mm)', min: 0.5, max: 8.0, step: 0.5, default: 2.0, disabledWhen: { id: 'useAmFm', value: false } }, + ]}, + ], + execute, +} diff --git a/ui/src/renderers/vectorRasterVector.ts b/ui/src/renderers/vectorRasterVector.ts new file mode 100644 index 00000000..04c21dc2 --- /dev/null +++ b/ui/src/renderers/vectorRasterVector.ts @@ -0,0 +1,72 @@ +import { rasterizeSvg, svgStringToJson, RENDER_SCALE } from '../svgControl' +import { + getInfillDensity, + listenForRendererResult, + type RendererDefinition, + type ExecuteOpts, +} from './index' +import type { RenderResult } from '../types' + +async function execute({ svgState, params, backendState, onStatus, worker }: ExecuteOpts): Promise { + if (!svgState) throw new Error('vectorRasterVector requires svgState') + // Step 1: Rasterize SVG → ImageData + onStatus('Rasterizing') + const raster = await rasterizeSvg(svgState) + + // Step 2: Vectorize (ImageData → SVG string via Potrace) + const vectorizedSvg = await new Promise((resolve, reject) => { + worker.onmessage = (e: MessageEvent) => { + const { type, payload } = e.data as { type: string; payload: unknown } + if (type === 'status') onStatus(payload as string) + else if (type === 'vectorizer') resolve((payload as { svg: string }).svg) + else if (type === 'log') console.log(`Worker: ${payload}`) + } + worker.onerror = (e) => reject(e) + worker.postMessage({ + type: 'vectorize', + raster, + turdSize: Number(params['turdSize'] ?? 2), + }) + }) + + // Step 3: Render vectorized SVG → commands + onStatus('Preparing vectorized SVG') + const svgJson = svgStringToJson(vectorizedSvg) + + const resultPromise = listenForRendererResult(worker, onStatus) + + worker.postMessage({ + type: 'renderSvg', + svgJson, + width: svgState.width, + height: svgState.baseHeight, + svgWidth: svgState.width * RENDER_SCALE, + svgHeight: svgState.baseHeight * RENDER_SCALE, + homeX: backendState.homeX ?? 0, + homeY: backendState.homeY ?? 0, + infillDensity: getInfillDensity(params), + flattenPaths: false, + }) + + return resultPromise +} + +export const vectorRasterVectorRenderer: RendererDefinition = { + id: 'vectorRasterVector', + label: 'Vector → Raster → Vector', + description: 'Preserves stroke width', + inputType: 'svg', + params: [ + { type: 'slider', id: 'infillDensity', label: 'Infill Density', min: 0, max: 4, step: 1, default: 0 }, + { + type: 'slider', + id: 'turdSize', + label: 'Despeckle', + min: 2, + max: 200, + step: 11, + default: 2, + }, + ], + execute, +} diff --git a/ui/src/screens/BeginDrawingScreen.tsx b/ui/src/screens/BeginDrawingScreen.tsx new file mode 100644 index 00000000..4f6e04fe --- /dev/null +++ b/ui/src/screens/BeginDrawingScreen.tsx @@ -0,0 +1,52 @@ +import { useState, useEffect } from 'react' +import { run, doneWithPhase } from '../api' +import type { BackendState } from '../types' +import { Card } from '../components/Card' +import { Button } from '../components/Button' +import { Slider } from '../components/Slider' + +interface Props { + onBegin: () => void + onReset: (state: BackendState) => void +} + +export function BeginDrawingScreen({ onBegin, onReset }: Props) { + const [busy, setBusy] = useState(false) + const [speed, setSpeed] = useState(() => + parseInt(localStorage.getItem('muralSpeed') ?? '500', 10) + ) + + useEffect(() => { + localStorage.setItem('muralSpeed', String(speed)) + }, [speed]) + + async function handleBegin() { + setBusy(true) + await run(speed) + onBegin() + } + + async function handleReset() { + setBusy(true) + await doneWithPhase() + location.reload() + } + + return ( + + +

+ ⚠ Speed control is experimental. Use 500 for best results. +

+ + +
+ ) +} diff --git a/ui/src/screens/ChooseRendererScreen.tsx b/ui/src/screens/ChooseRendererScreen.tsx new file mode 100644 index 00000000..75730770 --- /dev/null +++ b/ui/src/screens/ChooseRendererScreen.tsx @@ -0,0 +1,33 @@ +import type { RendererDefinition } from '../renderers/index' +import { Card } from '../components/Card' +import { Button } from '../components/Button' + +interface Props { + renderers: RendererDefinition[] + onChoose: (renderer: RendererDefinition) => void + onBack: () => void +} + +export function ChooseRendererScreen({ renderers, onChoose, onBack }: Props) { + return ( + +
+ {renderers.map((r) => ( + + ))} +
+ +
+ ) +} diff --git a/ui/src/screens/DrawingBeganScreen.tsx b/ui/src/screens/DrawingBeganScreen.tsx new file mode 100644 index 00000000..397ee188 --- /dev/null +++ b/ui/src/screens/DrawingBeganScreen.tsx @@ -0,0 +1,12 @@ +import { Card } from '../components/Card' + +export function DrawingBeganScreen() { + return ( + +

+ Mural will not be accessible via your browser while the drawing is in progress. + Please refresh the page once the drawing is finished. +

+
+ ) +} diff --git a/ui/src/screens/DrawingPreviewScreen.tsx b/ui/src/screens/DrawingPreviewScreen.tsx new file mode 100644 index 00000000..1a34cb47 --- /dev/null +++ b/ui/src/screens/DrawingPreviewScreen.tsx @@ -0,0 +1,365 @@ +import { useState, useEffect } from 'react' +import type { BackendState } from '../types' +import type { SvgState } from '../svgControl' +import type { RasterImageState } from '../rasterControl' +import { jsonToPreviewDataUrl } from '../svgControl' +import { compressCommands } from '../commandCompression' +import { type RendererDefinition, type RendererParamLeaf, type RendererParamValue } from '../renderers/index' +import { useWorkerRenderer } from '../hooks/useWorkerRenderer' +import { Card } from '../components/Card' +import { Slider } from '../components/Slider' +import { Button } from '../components/Button' + + +const hatchStyle: React.CSSProperties = { + backgroundColor: '#111', + backgroundImage: [ + 'repeating-linear-gradient(45deg, rgba(255,255,255,0.05) 0, rgba(255,255,255,0.05) 1px, transparent 0, transparent 50%)', + 'repeating-linear-gradient(-45deg, rgba(255,255,255,0.05) 0, rgba(255,255,255,0.05) 1px, transparent 0, transparent 50%)', + ].join(', '), + backgroundSize: '8px 8px', +} + +interface Props { + state: BackendState + svgState?: SvgState + imageState?: RasterImageState + renderer: RendererDefinition + onAccept: (blob: Blob) => void + onBack: () => void +} + +function defaultParams(renderer: RendererDefinition): Record { + const p: Record = {} + for (const param of renderer.params) { + if (param.type === 'row') { + for (const item of param.items) p[item.id] = item.default + } else { + p[param.id] = param.default + } + } + return p +} + +function renderLeaf( + param: RendererParamLeaf, + params: Record, + onChange: (id: string, value: RendererParamValue) => void, +) { + if (param.type === 'slider') { + const disabled = param.disabledWhen ? params[param.disabledWhen.id] === param.disabledWhen.value : false + return ( + onChange(param.id, v)} + disabled={disabled} + /> + ) + } + if (param.type === 'checkbox') { + const disabled = param.disabledWhen ? params[param.disabledWhen.id] === param.disabledWhen.value : false + return ( + + ) + } + if (param.type === 'select') { + return ( +
+ + +
+ ) + } + return null +} + +export function DrawingPreviewScreen({ state, svgState, imageState, renderer, onAccept, onBack }: Props) { + const [params, setParams] = useState>(() => { + const defaults = defaultParams(renderer) + try { + const saved = localStorage.getItem(`muralParams_${renderer.id}`) + if (saved) return { ...defaults, ...JSON.parse(saved) } + } catch {} + return defaults + }) + const { render, isRendering, status, result } = useWorkerRenderer() + + const [previewUrl, setPreviewUrl] = useState(null) + const [enlarged, setEnlarged] = useState(false) + const [showContext, setShowContext] = useState(false) + const [penWidthMm, setPenWidthMm] = useState(() => + parseFloat(localStorage.getItem('muralPenWidthMm') ?? '1.5'), + ) + const [compressedBlob, setCompressedBlob] = useState(null) + + const previewWidth = svgState?.width ?? imageState!.width + const previewHeight = svgState?.baseHeight ?? imageState!.height + + // Context-view geometry (only valid when topDistance is known) + const topDist = (state.topDistance && state.topDistance > 0) ? state.topDistance : null + const ctxOffsetX = topDist ? topDist * state.safeXFraction : 0 + const ctxOffsetY = topDist ? topDist * state.safeYFraction : 0 + const ctxTotalH = ctxOffsetY + previewHeight + const ctxDrawingLeftPct = topDist ? (ctxOffsetX / topDist) * 100 : 0 + const ctxDrawingTopPct = topDist ? (ctxOffsetY / ctxTotalH) * 100 : 0 + const ctxDrawingWidthPct = topDist ? (previewWidth / topDist) * 100 : 100 + const ctxDrawingHeightPct = topDist ? (previewHeight / ctxTotalH) * 100 : 100 + + const contextActive = showContext && !!topDist && !!previewUrl + + // Trigger initial render + useEffect(() => { + triggerRender(params) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + localStorage.setItem(`muralParams_${renderer.id}`, JSON.stringify(params)) + }, [params, renderer.id]) + + useEffect(() => { + localStorage.setItem('muralPenWidthMm', String(penWidthMm)) + }, [penWidthMm]) + + // Generate preview data URL when result or pen width changes + useEffect(() => { + if (result) { + const url = jsonToPreviewDataUrl(result.svgJson, previewWidth, previewHeight, penWidthMm) + setPreviewUrl(url) + } + }, [result, previewWidth, previewHeight, penWidthMm]) + + // Compress result once — blob is reused for upload, size drives the thermometer. + // Don't clear the stale blob while re-rendering; the Accept button is already + // disabled via isRendering, so showing the old size avoids a layout shift that + // scrolls the page on every slider drag. + useEffect(() => { + if (!result) return + let stale = false + compressCommands(result.commands.join('\n')).then((blob) => { + if (!stale) setCompressedBlob(blob) + }) + return () => { stale = true } + }, [result]) + + function triggerRender(p: Record) { + render(renderer, { svgState, imageState }, p, state) + } + + function handleParamChange(id: string, value: RendererParamValue) { + let newParams = { ...params, [id]: value } + setParams(newParams) + triggerRender(newParams) + } + + function handleAccept() { + if (!compressedBlob) return + onAccept(compressedBlob) + } + + function handleReset() { + const defaults = defaultParams(renderer) + setParams(defaults) + triggerRender(defaults) + } + + const contextDrawingStyle: React.CSSProperties = { + position: 'absolute', + left: `${ctxDrawingLeftPct}%`, + top: `${ctxDrawingTopPct}%`, + width: `${ctxDrawingWidthPct}%`, + height: `${ctxDrawingHeightPct}%`, + } + + return ( + + {/* Preview area — height is pinned to the drawing's own aspect ratio so toggling context view doesn't cause a scroll jump */} +
+ {contextActive ? ( +
{ if (!isRendering) setEnlarged(true) }} + > + {/* Canvas context — scaled to fit the stable container */} +
+ {/* Safe-area dashed outline */} +
+ {/* Motor pulley dots */} +
+
+ {/* Drawing image */} + Render preview +
+ {isRendering && ( +
+
+
+ )} +
+ ) : ( + <> + {previewUrl && ( + Render preview { if (!isRendering) setEnlarged(true) }} + className={`absolute inset-0 w-full h-full object-contain transition-opacity duration-150 ${isRendering ? 'opacity-40 cursor-wait' : 'opacity-100 cursor-zoom-in'}`} + /> + )} + {isRendering && ( +
+
+
+ )} + + )} +
+ + {/* Enlarged modal */} + {enlarged && previewUrl && ( +
setEnlarged(false)} + > + Render preview enlarged +
+ )} + + {/* Status row — always present to prevent layout shift */} +

+ {isRendering + ? (status || 'Rendering…') + : result + ? <> + Total:{' '} + {(result.distance / 1000).toFixed(1)}m + {' '}/ Draw:{' '} + {(result.drawDistance / 1000).toFixed(1)}m + + : null} +

+ + {/* Canvas context toggle + reset */} +
+ {topDist && ( + + )} + +
+ + {/* Dynamic renderer params */} +
+ {renderer.params.map((param, idx) => { + if (param.type === 'row') { + return ( +
+ {param.items.map((item) => ( +
+ {renderLeaf(item, params, handleParamChange)} +
+ ))} +
+ ) + } + return ( +
+ {renderLeaf(param, params, handleParamChange)} +
+ ) + })} +
+ +
+ +
+ + {compressedBlob !== null && (() => { + const MAX_KB = state.freeKb ?? 2600 + const WARN_KB = Math.round(MAX_KB * 0.8) + const sizeKb = Math.round(compressedBlob.size / 1024) + const pct = Math.min(100, (sizeKb / MAX_KB) * 100) + const barColor = sizeKb >= MAX_KB ? 'bg-red-500' : sizeKb >= WARN_KB ? 'bg-yellow-400' : 'bg-emerald-500' + const labelColor = sizeKb >= MAX_KB ? 'text-red-400' : sizeKb >= WARN_KB ? 'text-yellow-400' : 'text-gray-400' + return ( +
+
+

Upload size

+

{sizeKb} / {MAX_KB} kb

+
+
+
+
+ {sizeKb >= MAX_KB && ( +

Warning: upload exceeds device storage. Drawing may fail.

+ )} +
+ ) + })()} + + + + + ) +} diff --git a/ui/src/screens/ExtendToHomeScreen.tsx b/ui/src/screens/ExtendToHomeScreen.tsx new file mode 100644 index 00000000..22bb9d60 --- /dev/null +++ b/ui/src/screens/ExtendToHomeScreen.tsx @@ -0,0 +1,72 @@ +import { useState, useEffect } from 'react' +import { extendToHome, getState } from '../api' +import { showErrorAlert } from '../components/AlertModal' +import type { BackendState } from '../types' +import { Card } from '../components/Card' +import { Button } from '../components/Button' + +interface Props { + state: BackendState + onDone: (state: BackendState) => void +} + +export function ExtendToHomeScreen({ state, onDone }: Props) { + const [extending, setExtending] = useState(state.moving === true || state.startedHoming === true) + + useEffect(() => { + if (extending) { + startPolling() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + async function startPolling(estimatedMs?: number) { + if (estimatedMs) { + await delay(estimatedMs) + } + const waitPeriod = 2000 + while (true) { + try { + const s = await getState() + if (s.phase !== 'ExtendToHome') { + onDone(s) + return + } + } catch { + await showErrorAlert('Failed to get current phase') + return + } + await delay(waitPeriod) + } + } + + async function handleExtend() { + setExtending(true) + try { + const seconds = await extendToHome() + startPolling(seconds * 1000) + } catch { + startPolling() + } + } + + return ( + + {extending ? ( +
+
+

Extending to home position…

+
+ ) : ( + + )} + + ) +} + +function delay(ms: number) { + return new Promise((r) => setTimeout(r, ms)) +} diff --git a/ui/src/screens/InputSelectScreen.tsx b/ui/src/screens/InputSelectScreen.tsx new file mode 100644 index 00000000..69b93b07 --- /dev/null +++ b/ui/src/screens/InputSelectScreen.tsx @@ -0,0 +1,29 @@ +import { Card } from '../components/Card' + +interface Props { + onSelectSvg: () => void + onSelectRaster: () => void +} + +export function InputSelectScreen({ onSelectSvg, onSelectRaster }: Props) { + return ( + +
+ + +
+
+ ) +} diff --git a/ui/src/screens/LoadingScreen.tsx b/ui/src/screens/LoadingScreen.tsx new file mode 100644 index 00000000..478a4188 --- /dev/null +++ b/ui/src/screens/LoadingScreen.tsx @@ -0,0 +1,8 @@ +export function LoadingScreen() { + return ( +
+
+

Connecting…

+
+ ) +} diff --git a/ui/src/screens/PenCalibrationScreen.tsx b/ui/src/screens/PenCalibrationScreen.tsx new file mode 100644 index 00000000..76fa488f --- /dev/null +++ b/ui/src/screens/PenCalibrationScreen.tsx @@ -0,0 +1,99 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { setServo, setPenDistance, sendJog } from '../api' +import type { BackendState } from '../types' +import { Card } from '../components/Card' +import { Button } from '../components/Button' +import { Slider } from '../components/Slider' +import { Thumbstick } from '../components/Thumbstick' + +interface Props { + onDone: (state: BackendState) => void +} + +// UI slider value (0–70) maps to servo angle as: servo = 70 - sliderValue +// When slider = 0 → servo = 70 (full up / retracted) +// When slider = 70 → servo = 0 (pen touching wall) +const STEP = 5 + +function toServoAngle(sliderVal: number) { + return Math.max(0, Math.min(70, 70 - sliderVal)) +} + +export function PenCalibrationScreen({ onDone }: Props) { + const [sliderVal, setSliderVal] = useState(0) + const [busy, setBusy] = useState(false) + const throttleRef = useRef | null>(null) + const lastCallRef = useRef(0) + + // Park servo at max angle on mount + useEffect(() => { + setServo(70) + }, []) + + function handleSliderChange(val: number) { + setSliderVal(val) + // Throttle to ~250ms + const now = Date.now() + if (now - lastCallRef.current >= 250) { + lastCallRef.current = now + setServo(toServoAngle(val)) + } else { + if (throttleRef.current) clearTimeout(throttleRef.current) + throttleRef.current = setTimeout(() => { + lastCallRef.current = Date.now() + setServo(toServoAngle(val)) + }, 250) + } + } + + const handleJog = useCallback((vx: number, vy: number) => { + sendJog(vx, vy).catch(() => {}) + }, []) + + async function handleConfirm() { + setBusy(true) + const state = await setPenDistance(toServoAngle(sliderVal)) + onDone(state) + } + + return ( + + + +
+ + +
+ +
+ +

Drag to move the robot

+
+ + +
+ ) +} diff --git a/ui/src/screens/RasterUploadScreen.tsx b/ui/src/screens/RasterUploadScreen.tsx new file mode 100644 index 00000000..161c8f0b --- /dev/null +++ b/ui/src/screens/RasterUploadScreen.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect, useRef } from 'react' +import type { BackendState } from '../types' +import { parseRasterImage, getRasterPreviewDataUrl, type RasterImageState } from '../rasterControl' +import { nudge, zoom, transformText, type AffineTransform } from '../svgControl' +import { showAlert } from '../components/AlertModal' +import { Card } from '../components/Card' +import { Button } from '../components/Button' +import { DPad } from '../components/DPad' + +interface Props { + state: BackendState + initialState?: RasterImageState + onPreview: (imageState: RasterImageState) => void + onBack: () => void +} + +export function RasterUploadScreen({ state, initialState, onPreview, onBack }: Props) { + const [imageState, setImageState] = useState(initialState ?? null) + const [previewDataUrl, setPreviewDataUrl] = useState(null) + const [txText, setTxText] = useState(initialState ? transformText(initialState.transform) : '') + const fileRef = useRef(null) + + // Revoke raw file object URL when imageState changes or component unmounts + useEffect(() => { + return () => { + if (imageState) URL.revokeObjectURL(imageState.previewUrl) + } + }, [imageState]) + + // Regenerate transformed preview whenever imageState changes + useEffect(() => { + if (!imageState) { + setPreviewDataUrl(null) + return + } + + let cancelled = false + let blobUrl: string | null = null + + getRasterPreviewDataUrl(imageState).then((url) => { + if (cancelled) { + URL.revokeObjectURL(url) + } else { + setPreviewDataUrl((prev) => { + if (prev) URL.revokeObjectURL(prev) + return url + }) + blobUrl = url + } + }) + + return () => { + cancelled = true + if (blobUrl) URL.revokeObjectURL(blobUrl) + } + }, [imageState]) + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) { + setImageState(null) + setTxText('') + return + } + try { + const parsed = await parseRasterImage(file, state.safeWidth ?? 1000) + setImageState(parsed) + setTxText(transformText(parsed.transform)) + } catch (err) { + await showAlert(`Failed to load image: ${err}`) + } + } + + function applyTransform(t: AffineTransform) { + if (!imageState) return + setImageState({ ...imageState, transform: t }) + setTxText(transformText(t)) + } + + function handleNudge(dir: Parameters[0]) { + if (!imageState) return + applyTransform(nudge(dir, imageState.transform)) + } + + function handleZoom(dir: Parameters[0]) { + if (!imageState) return + applyTransform(zoom(dir, imageState.transform)) + } + + return ( + + {previewDataUrl && ( + Image preview + )} + + {imageState && ( + <> +
+ +
+

+ {imageState.naturalWidth} × {imageState.naturalHeight}px →{' '} + + {imageState.width.toFixed(0)} × {imageState.height.toFixed(0)} mm + +

+ + )} + + + + + +
+ ) +} diff --git a/ui/src/screens/RetractBeltsScreen.tsx b/ui/src/screens/RetractBeltsScreen.tsx new file mode 100644 index 00000000..d0b5e3d2 --- /dev/null +++ b/ui/src/screens/RetractBeltsScreen.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import { leftRetractDown, leftStop, rightRetractDown, rightStop, doneWithPhase } from '../api' +import type { BackendState } from '../types' +import { Card } from '../components/Card' +import { Toggle } from '../components/Toggle' +import { Button } from '../components/Button' + +interface Props { + onDone: (state: BackendState) => void +} + +export function RetractBeltsScreen({ onDone }: Props) { + const [leftOn, setLeftOn] = useState(false) + const [rightOn, setRightOn] = useState(false) + const [busy, setBusy] = useState(false) + + function handleLeft(on: boolean) { + setLeftOn(on) + on ? leftRetractDown() : leftStop() + } + + function handleRight(on: boolean) { + setRightOn(on) + on ? rightRetractDown() : rightStop() + } + + async function handleDone() { + setBusy(true) + await leftStop() + await rightStop() + const state = await doneWithPhase() + onDone(state) + } + + return ( + +
+ + +
+ +
+ ) +} diff --git a/ui/src/screens/SetTopDistanceScreen.tsx b/ui/src/screens/SetTopDistanceScreen.tsx new file mode 100644 index 00000000..d3a41bf8 --- /dev/null +++ b/ui/src/screens/SetTopDistanceScreen.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { setTopDistance } from '../api' +import type { BackendState } from '../types' +import { showAlert, showErrorAlert } from '../components/AlertModal' +import { Card } from '../components/Card' +import { Button } from '../components/Button' +import { ToolsModal } from '../components/ToolsModal' + +interface Props { + onDone: (state: BackendState) => void +} + +export function SetTopDistanceScreen({ onDone }: Props) { + const [distance, setDistance] = useState('') + const [busy, setBusy] = useState(false) + const [toolsOpen, setToolsOpen] = useState(false) + + async function handleSet() { + const val = parseInt(distance) + if (isNaN(val) || val <= 0) { + await showAlert('Please enter a valid distance in millimeters') + return + } + setBusy(true) + try { + const state = await setTopDistance(val) + onDone(state) + } catch (err) { + setBusy(false) + await showErrorAlert(`Failed to set distance: ${err}`) + } + } + + return ( + <> + {/* Gear icon trigger */} + + + + setDistance(e.target.value)} + placeholder="e.g. 1500" + className="w-full bg-gray-800 border border-gray-700 text-white rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-cyan-500 placeholder:text-gray-600" + /> + + + + setToolsOpen(false)} /> + + ) +} diff --git a/ui/src/screens/SvgUploadScreen.tsx b/ui/src/screens/SvgUploadScreen.tsx new file mode 100644 index 00000000..bf3d5584 --- /dev/null +++ b/ui/src/screens/SvgUploadScreen.tsx @@ -0,0 +1,98 @@ +import { useState, useRef } from 'react' +import type { BackendState } from '../types' +import { parseSvg, nudge, zoom, getPreviewDataUrl, transformText, type SvgState, type AffineTransform, DEFAULT_TRANSFORM } from '../svgControl' +import { showAlert } from '../components/AlertModal' +import { Card } from '../components/Card' +import { Button } from '../components/Button' +import { DPad } from '../components/DPad' + +interface Props { + state: BackendState + initialState?: SvgState + onPreview: (svgState: SvgState) => void + onBack: () => void +} + +export function SvgUploadScreen({ state, initialState, onPreview, onBack }: Props) { + const [svgState, setSvgState] = useState(initialState ?? null) + const [previewUrl, setPreviewUrl] = useState( + initialState ? getPreviewDataUrl(initialState).dataUrl : null, + ) + const [txText, setTxText] = useState(initialState ? transformText(initialState.transform) : '') + const fileRef = useRef(null) + + function applyTransform(doc: SvgState['originalDoc'], width: number, baseHeight: number, t: AffineTransform) { + const newState: SvgState = { originalDoc: doc, width, baseHeight, transform: t } + const { dataUrl } = getPreviewDataUrl(newState) + setSvgState(newState) + setPreviewUrl(dataUrl) + setTxText(transformText(t)) + } + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) { + setSvgState(null) + setPreviewUrl(null) + return + } + try { + const svgString = await file.text() + const parsed = parseSvg(svgString, state.safeWidth ?? 1000) + const { dataUrl } = getPreviewDataUrl(parsed) + setSvgState(parsed) + setPreviewUrl(dataUrl) + setTxText(transformText(DEFAULT_TRANSFORM)) + } catch (err) { + await showAlert(`Failed to parse SVG: ${err}`) + } + } + + function handleNudge(dir: Parameters[0]) { + if (!svgState) return + const t = nudge(dir, svgState.transform) + applyTransform(svgState.originalDoc, svgState.width, svgState.baseHeight, t) + } + + function handleZoom(dir: Parameters[0]) { + if (!svgState) return + const t = zoom(dir, svgState.transform) + applyTransform(svgState.originalDoc, svgState.width, svgState.baseHeight, t) + } + + return ( + + {previewUrl && ( + SVG preview + )} + + {svgState && ( +
+ +
+ )} + + + + + +
+ ) +} diff --git a/ui/src/screens/UploadProgressScreen.tsx b/ui/src/screens/UploadProgressScreen.tsx new file mode 100644 index 00000000..2c3f7c56 --- /dev/null +++ b/ui/src/screens/UploadProgressScreen.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react' +import { uploadCommands, downloadCommands } from '../api' +import type { BackendState } from '../types' +import { Card } from '../components/Card' +import { ProgressBar } from '../components/ProgressBar' + +interface Props { + blob: Blob + onDone: (state: BackendState) => void + onError: (message: string) => void +} + +export function UploadProgressScreen({ blob, onDone, onError }: Props) { + const [uploadPct, setUploadPct] = useState(0) + const [verifyPct, setVerifyPct] = useState(0) + const [phase, setPhase] = useState<'uploading' | 'verifying' | 'done'>('uploading') + + useEffect(() => { + run() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + async function run() { + // Upload pre-compressed blob directly + let state: BackendState + try { + state = await uploadCommands(blob, setUploadPct) + setUploadPct(100) + } catch (err) { + onError(`Upload to Mural failed: ${err}`) + return + } + + // Verify by comparing received bytes against sent bytes + setPhase('verifying') + try { + const receivedBlob = await downloadCommands(setVerifyPct) + setVerifyPct(100) + const sentBytes = new Uint8Array(await blob.arrayBuffer()) + const receivedBytes = new Uint8Array(await receivedBlob.arrayBuffer()) + if (sentBytes.length !== receivedBytes.length) { + onError('Data verification failed: length mismatch') + return + } + for (let i = 0; i < sentBytes.length; i++) { + if (sentBytes[i] !== receivedBytes[i]) { + onError('Data verification failed: content mismatch') + return + } + } + } catch (err) { + onError(`Failed to download commands from Mural: ${err}`) + return + } + + setPhase('done') + setTimeout(() => onDone(state), 800) + } + + return ( + +
+ + +
+ {phase === 'done' && ( +

Upload complete

+ )} +
+ ) +} diff --git a/ui/src/svgControl.ts b/ui/src/svgControl.ts new file mode 100644 index 00000000..19a17635 --- /dev/null +++ b/ui/src/svgControl.ts @@ -0,0 +1,253 @@ +import paper from 'paper' + +export const RENDER_SCALE = 2 + +function svgToBase64(svgStr: string): string { + const bytes = new TextEncoder().encode(svgStr) + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) + return btoa(binary) +} + +const TRANSFORM_GROUP_ID = 'muralTransformGroup' +const NUDGE_FACTOR = 0.025 +const ZOOM_FACTOR = 0.05 + +export type AffineTransform = [number, number, number, number, number, number] +export const DEFAULT_TRANSFORM: AffineTransform = [1, 0, 0, 1, 0, 0] + +export interface SvgState { + /** Parsed + normalized SVG document. Has width/height attrs set and muralTransformGroup added. */ + originalDoc: Document + /** Target drawing width in mm (= safeWidth from backend) */ + width: number + /** Computed height from aspect ratio */ + baseHeight: number + /** Current affine transform */ + transform: AffineTransform +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +export function parseSvg(svgString: string, safeWidth: number): SvgState { + const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml') + const svgEl = doc.documentElement + let w: number | undefined, h: number | undefined + + if (svgEl.hasAttribute('width') && svgEl.hasAttribute('height')) { + w = convertUnitsToPx(svgEl.getAttribute('width')!) + h = convertUnitsToPx(svgEl.getAttribute('height')!) + } + + if (svgEl.hasAttribute('viewBox')) { + if (!w || !h) { + const vb = svgEl.getAttribute('viewBox')!.split(/[\s,]+/).filter(Boolean) + w = parseFloat(vb[2]) + h = parseFloat(vb[3]) + } + } else if (w && h) { + svgEl.setAttribute('viewBox', `0, 0, ${w}, ${h}`) + } + + if (!w || !h) throw new Error('Invalid SVG: could not determine dimensions') + + const baseHeight = (safeWidth / w) * h + svgEl.setAttribute('width', String(safeWidth)) + svgEl.setAttribute('height', String(baseHeight)) + + // Wrap all content in a transform group + const group = document.createElementNS('http://www.w3.org/2000/svg', 'g') + group.id = TRANSFORM_GROUP_ID + while (svgEl.firstChild) group.appendChild(svgEl.firstChild) + svgEl.appendChild(group) + + return { originalDoc: doc, width: safeWidth, baseHeight, transform: DEFAULT_TRANSFORM } +} + +// --------------------------------------------------------------------------- +// Transforms +// --------------------------------------------------------------------------- + +export function nudge( + dir: 'up' | 'down' | 'left' | 'right', + t: AffineTransform, +): AffineTransform { + const n = [...t] as AffineTransform + switch (dir) { + case 'up': n[5] -= NUDGE_FACTOR; break + case 'down': n[5] += NUDGE_FACTOR; break + case 'left': n[4] -= NUDGE_FACTOR; break + case 'right': n[4] += NUDGE_FACTOR; break + } + return n +} + +export function zoom( + dir: 'in' | 'out' | 'reset', + t: AffineTransform, +): AffineTransform { + const n = [...t] as AffineTransform + switch (dir) { + case 'in': + n[0] += ZOOM_FACTOR; n[3] += ZOOM_FACTOR; break + case 'out': + n[0] -= ZOOM_FACTOR; n[3] -= ZOOM_FACTOR; break + case 'reset': + return [...DEFAULT_TRANSFORM] as AffineTransform + } + return n +} + +export function transformText(t: AffineTransform): string { + const fix = (n: number) => +(n.toFixed(2)) + return `(${fix(t[4] * 100)}, ${fix(t[5] * 100)}) ${fix(t[0])}x` +} + +// --------------------------------------------------------------------------- +// Rendering helpers +// --------------------------------------------------------------------------- + +function makeTransformedDoc(state: SvgState): { doc: Document; renderedHeight: number } { + const clone = state.originalDoc.cloneNode(true) as Document + const svgEl = clone.documentElement + const vb = svgEl.getAttribute('viewBox')!.split(/[\s,]+/).filter(Boolean) + const vbW = parseFloat(vb[2]) + const vbH = parseFloat(vb[3]) + + const scaled = [...state.transform] as AffineTransform + scaled[4] = scaled[4] * vbW + + let renderedHeight = parseFloat(svgEl.getAttribute('height')!) + + if (scaled[5] > 0) { + const heightOffset = scaled[5] * renderedHeight + renderedHeight = renderedHeight + heightOffset + svgEl.setAttribute('height', String(renderedHeight)) + scaled[5] = scaled[5] * vbH + const newVbH = vbH + scaled[5] + svgEl.setAttribute('viewBox', `${vb[0]}, ${vb[1]}, ${vb[2]}, ${newVbH}`) + } else { + scaled[5] = scaled[5] * vbH + } + + const group = clone.getElementById(TRANSFORM_GROUP_ID)! + group.setAttribute('transform', `matrix(${scaled.join(', ')})`) + + return { doc: clone, renderedHeight } +} + +/** Returns a data URL for SVG preview and the current rendered height */ +export function getPreviewDataUrl(state: SvgState): { dataUrl: string; renderedHeight: number } { + const { doc, renderedHeight } = makeTransformedDoc(state) + const svgEl = doc.documentElement + const bg = doc.createElementNS('http://www.w3.org/2000/svg', 'rect') + bg.setAttribute('width', '100%') + bg.setAttribute('height', '100%') + bg.setAttribute('fill', 'white') + svgEl.insertBefore(bg, svgEl.firstChild) + const svgStr = new XMLSerializer().serializeToString(doc) + const dataUrl = `data:image/svg+xml;base64,${svgToBase64(svgStr)}` + return { dataUrl, renderedHeight } +} + +/** Returns the transformed SVG serialized as a string (for path-tracing renderer) */ +export function getTransformedSvgString(state: SvgState): string { + const { doc } = makeTransformedDoc(state) + return new XMLSerializer().serializeToString(doc) +} + +/** Rasterizes the SVG to ImageData at 2× scale (for vector-raster-vector renderer) */ +export async function rasterizeSvg(state: SvgState): Promise { + const scaledW = state.width * RENDER_SCALE + const { doc } = makeTransformedDoc(state) + const svgStr = new XMLSerializer().serializeToString(doc) + const { renderedHeight } = makeTransformedDoc(state) + const scaledH = renderedHeight * RENDER_SCALE + + const dataUrl = `data:image/svg+xml;base64,${svgToBase64(svgStr)}` + const canvas = new OffscreenCanvas(scaledW, scaledH) + const ctx = canvas.getContext('2d')! + + const img = await loadImage(dataUrl) + const bitmap = await createImageBitmap(img, { resizeWidth: scaledW, resizeHeight: scaledH }) + ctx.drawImage(bitmap, 0, 0, scaledW, scaledH) + + return ctx.getImageData(0, 0, scaledW, scaledH) +} + +// --------------------------------------------------------------------------- +// Paper.js helpers +// --------------------------------------------------------------------------- + +/** Converts an SVG string to Paper.js JSON using an isolated scope */ +export function svgStringToJson(svgString: string): string { + // Remove elements since they cannot be rendered or even instantiated + const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml') + doc.querySelectorAll('image').forEach(el => el.remove()) + const cleanSvg = new XMLSerializer().serializeToString(doc) + + const scope = new paper.PaperScope() + scope.setup({ width: 1000, height: 1000 } as paper.Size) + const svg = scope.project.importSVG(cleanSvg, { + expandShapes: true, + applyMatrix: true, + }) + const json = svg.exportJSON() + scope.project.clear() + return json +} + +/** Converts Paper.js JSON back to a canvas data URL for preview. + * penWidthMm: physical pen stroke width — used to set correct stroke thickness and + * upscale the canvas so thin strokes remain visible. Defaults to 1mm. */ +export function jsonToPreviewDataUrl(json: string, width: number, height: number, penWidthMm = 1): string { + // Scale canvas up so that penWidthMm renders as at least 1.5px + const scale = Math.min(3, Math.max(1, 1.5 / penWidthMm)) + const canvas = document.createElement('canvas') + canvas.width = Math.round(width * scale) + canvas.height = Math.round(height * scale) + canvas.style.display = 'none' + document.body.appendChild(canvas) + + const scope = new paper.PaperScope() + scope.setup(canvas) + // Scale the view so the drawing fills the upscaled canvas + scope.view.zoom = scale + scope.view.center = new paper.Point(width / 2, height / 2) + scope.project.importJSON(json) + // Override stroke width to match actual pen so ink coverage per area is correct + scope.project.getItems({ match: (item: any) => item.strokeColor != null }) + .forEach((item: any) => { item.strokeWidth = penWidthMm }) + ;(scope.view as unknown as { draw(): void }).draw() + const dataUrl = canvas.toDataURL() + + scope.project.clear() + document.body.removeChild(canvas) + return dataUrl +} + +// --------------------------------------------------------------------------- +// Internal utils +// --------------------------------------------------------------------------- + +function convertUnitsToPx(dimension: string): number { + const factors: Record = { + pt: 1.3333, pc: 16, in: 96, cm: 37.795, mm: 3.7795, px: 1, + } + const match = dimension.match(/([\d.]+)([a-z%]*)/i) + if (!match) throw new Error(`Invalid dimension: "${dimension}"`) + const value = parseFloat(match[1]) + const unit = match[2] || 'px' + return value * (factors[unit] ?? 1) +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = reject + img.src = src + }) +} diff --git a/ui/src/types.ts b/ui/src/types.ts new file mode 100644 index 00000000..0cf6d65d --- /dev/null +++ b/ui/src/types.ts @@ -0,0 +1,45 @@ +export type Phase = + | 'RetractBelts' + | 'SetTopDistance' + | 'ExtendToHome' + | 'PenCalibration' + | 'SvgSelect' + | 'BeginDrawing' + +export interface BackendState { + phase: Phase + topDistance?: number + safeWidth?: number + safeXFraction: number + safeYFraction: number + homeX?: number + homeY?: number + moving?: boolean + startedHoming?: boolean + freeKb?: number +} + +/** Internal UI phase — extends backend phases with SVG pipeline sub-states */ +export type UiPhase = + | 'loading' + | Phase + | 'InputSelect' + | 'RasterSelect' + | 'ChooseRenderer' + | 'DrawingPreview' + | 'UploadProgress' + | 'DrawingBegan' + +export type InfillDensity = 0 | 1 | 2 | 3 | 4 + +export interface RenderResult { + commands: string[] + svgJson: string + distance: number + drawDistance: number +} + +export interface WorkerMessage { + type: 'status' | 'log' | 'vectorizer' | 'renderer' + payload: unknown +} diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js new file mode 100644 index 00000000..efc19013 --- /dev/null +++ b/ui/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 00000000..b4d37cf9 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo new file mode 100644 index 00000000..8042936b --- /dev/null +++ b/ui/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/api.ts","./src/commandCompression.ts","./src/main.tsx","./src/rasterControl.ts","./src/svgControl.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/AlertModal.tsx","./src/components/Button.tsx","./src/components/Card.tsx","./src/components/DPad.tsx","./src/components/ProgressBar.tsx","./src/components/Slider.tsx","./src/components/Thumbstick.tsx","./src/components/Toggle.tsx","./src/components/ToolsModal.tsx","./src/hooks/useWorkerRenderer.ts","./src/renderers/index.ts","./src/renderers/pathTracing.ts","./src/renderers/rasterZigZag.ts","./src/renderers/vectorRasterVector.ts","./src/screens/BeginDrawingScreen.tsx","./src/screens/ChooseRendererScreen.tsx","./src/screens/DrawingBeganScreen.tsx","./src/screens/DrawingPreviewScreen.tsx","./src/screens/ExtendToHomeScreen.tsx","./src/screens/InputSelectScreen.tsx","./src/screens/LoadingScreen.tsx","./src/screens/PenCalibrationScreen.tsx","./src/screens/RasterUploadScreen.tsx","./src/screens/RetractBeltsScreen.tsx","./src/screens/SetTopDistanceScreen.tsx","./src/screens/SvgUploadScreen.tsx","./src/screens/UploadProgressScreen.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 00000000..76e283fc --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' +import fs from 'fs' + +export default defineConfig({ + plugins: [ + react(), + { + name: 'serve-worker', + configureServer(server) { + server.middlewares.use('/worker/worker.js', (_req, res, next) => { + const workerPath = path.resolve(__dirname, '../worker/dist_packed/main.js') + if (fs.existsSync(workerPath)) { + res.setHeader('Content-Type', 'application/javascript') + res.end(fs.readFileSync(workerPath)) + } else { + next() + } + }) + }, + }, + ], + build: { + outDir: '../data/www', + emptyOutDir: false, + }, + base: './', +}) diff --git a/tsc/package.json b/worker/package.json similarity index 100% rename from tsc/package.json rename to worker/package.json diff --git a/tsc/src/deduplicator.ts b/worker/src/deduplicator.ts similarity index 100% rename from tsc/src/deduplicator.ts rename to worker/src/deduplicator.ts diff --git a/tsc/src/flattener.ts b/worker/src/flattener.ts similarity index 100% rename from tsc/src/flattener.ts rename to worker/src/flattener.ts diff --git a/tsc/src/generator.ts b/worker/src/generator.ts similarity index 100% rename from tsc/src/generator.ts rename to worker/src/generator.ts diff --git a/tsc/src/infill.ts b/worker/src/infill.ts similarity index 100% rename from tsc/src/infill.ts rename to worker/src/infill.ts diff --git a/tsc/src/main.ts b/worker/src/main.ts similarity index 67% rename from tsc/src/main.ts rename to worker/src/main.ts index 874192f3..1f8cb076 100644 --- a/tsc/src/main.ts +++ b/worker/src/main.ts @@ -1,6 +1,7 @@ import { renderCommandsToSvgJson } from "./toSvgJson"; import { renderSvgJsonToCommands } from "./toCommands"; import { vectorizeImageData } from './vectorizer'; +import { renderRasterZigZag } from './zigzag'; import { InfillDensities, RequestTypes } from "./types"; const updateStatusFn = (status: string) => { @@ -15,6 +16,8 @@ self.onmessage = async (e: MessageEvent) => { vectorize(e.data); } else if (isRenderSvgRequest(e.data)) { await render(e.data); + } else if (isRenderRasterZigZagRequest(e.data)) { + renderZigZag(e.data); } else { throw new Error("Bad request"); } @@ -65,6 +68,39 @@ function isVectorizeRequest(obj: any): obj is RequestTypes.VectorizeRequest { } +function renderZigZag(request: RequestTypes.RenderRasterZigZagRequest) { + const result = renderRasterZigZag(request, updateStatusFn); + self.postMessage({ + type: "renderer", + payload: { + commands: result.commands, + svgJson: result.svgJson, + distance: result.distance, + drawDistance: result.drawDistance, + } + }); +} + +function isRenderRasterZigZagRequest(obj: any): obj is RequestTypes.RenderRasterZigZagRequest { + return typeof obj === 'object' && obj !== null && obj.type === 'renderRasterZigZag' + && typeof obj.widthMm === 'number' + && typeof obj.heightMm === 'number' + && typeof obj.homeX === 'number' + && typeof obj.homeY === 'number' + && typeof obj.lineSpacing === 'number' + && typeof obj.amplitude === 'number' + && typeof obj.brightness === 'number' + && typeof obj.contrast === 'number' + && typeof obj.blackPoint === 'number' + && typeof obj.whitePoint === 'number' + && typeof obj.angle === 'number' + && typeof obj.continuousPath === 'boolean' + && typeof obj.imageLeft === 'number' + && typeof obj.imageTop === 'number' + && typeof obj.imageRight === 'number' + && typeof obj.imageBottom === 'number'; +} + function isRenderSvgRequest(obj: any): obj is RequestTypes.RenderSVGRequest { if (!('type' in obj) || obj.type !== 'renderSvg') { return false; diff --git a/worker/src/measurer.ts b/worker/src/measurer.ts new file mode 100644 index 00000000..00a3be28 --- /dev/null +++ b/worker/src/measurer.ts @@ -0,0 +1,25 @@ +import { Command, CoordinateCommand } from "./types"; +import { distanceBetweenPoints } from "./utils"; + +export function measureDistance(dedupedCommands: Command[]) { + let totalDistance = 0; + let drawDistance = 0; + let penUp = true; + let lastPoint: CoordinateCommand | undefined; + + for (const command of dedupedCommands) { + if (typeof command !== 'string') { + if (lastPoint && (command.x !== lastPoint.x || command.y !== lastPoint.y)) { + const distance = distanceBetweenPoints(lastPoint, command); + totalDistance += distance; + if (!penUp) drawDistance += distance; + } + lastPoint = command; + } else { + if (command === 'p0') penUp = true; + else if (command === 'p1') penUp = false; + } + } + + return { totalDistance, drawDistance }; +} \ No newline at end of file diff --git a/worker/src/optimizer.ts b/worker/src/optimizer.ts new file mode 100644 index 00000000..5b66431e --- /dev/null +++ b/worker/src/optimizer.ts @@ -0,0 +1,215 @@ +import { loadPaper } from "./paperLoader"; +import { InfilledPath } from "./types"; + +const paper = loadPaper(); + +type GridEntry = { + infilledPath: InfilledPath; + x: number; + y: number; +}; + +class SpatialGrid { + private cells = new Map>(); + private reverseMap = new Map(); + private totalEntries = 0; + private cellSize: number; + private minX: number; + private minY: number; + + constructor(infilledPaths: InfilledPath[], cellSize: number, minX: number, minY: number) { + this.cellSize = cellSize; + this.minX = minX; + this.minY = minY; + + for (const ip of infilledPaths) { + const entries: GridEntry[] = []; + for (const path of ip.outlinePaths) { + for (const pt of [path.firstSegment.point, path.lastSegment.point]) { + const entry: GridEntry = { infilledPath: ip, x: pt.x, y: pt.y }; + const key = this.cellKey(entry.x, entry.y); + let cell = this.cells.get(key); + if (!cell) { + cell = new Set(); + this.cells.set(key, cell); + } + cell.add(entry); + entries.push(entry); + this.totalEntries++; + } + } + this.reverseMap.set(ip, entries); + } + } + + private cellKey(x: number, y: number): string { + return `${Math.floor((x - this.minX) / this.cellSize)},${Math.floor((y - this.minY) / this.cellSize)}`; + } + + private cellCoords(x: number, y: number): [number, number] { + return [ + Math.floor((x - this.minX) / this.cellSize), + Math.floor((y - this.minY) / this.cellSize), + ]; + } + + remove(ip: InfilledPath): void { + const entries = this.reverseMap.get(ip); + if (!entries) return; + for (const entry of entries) { + this.cells.get(this.cellKey(entry.x, entry.y))?.delete(entry); + } + this.totalEntries -= entries.length; + this.reverseMap.delete(ip); + } + + // Ring-expansion nearest-neighbor search. + // Stops expanding once the nearest possible point in the next ring cannot beat bestCost. + findNearest(point: paper.Point): InfilledPath | null { + if (this.totalEntries === 0) return null; + + const [qCol, qRow] = this.cellCoords(point.x, point.y); + let bestCost = Infinity; + let bestIp: InfilledPath | null = null; + + for (let ring = 0; ; ring++) { + // Before scanning ring r, check if any point outside the (r-1) bounding box + // can improve on bestCost. The minimum Euclidean distance from `point` to + // any cell in ring r+ is the distance from `point` to the nearest face of + // the (r-1) bounding box (clamped to 0 if point is already outside it). + if (ring > 0 && bestCost < Infinity) { + const rPrev = ring - 1; + const boxLeft = (qCol - rPrev) * this.cellSize + this.minX; + const boxRight = (qCol + rPrev + 1) * this.cellSize + this.minX; + const boxTop = (qRow - rPrev) * this.cellSize + this.minY; + const boxBottom = (qRow + rPrev + 1) * this.cellSize + this.minY; + const minDist = Math.max(0, Math.min( + point.x - boxLeft, + boxRight - point.x, + point.y - boxTop, + boxBottom - point.y, + )); + if (minDist * minDist > bestCost) break; + } + + for (let dc = -ring; dc <= ring; dc++) { + for (let dr = -ring; dr <= ring; dr++) { + if (Math.max(Math.abs(dc), Math.abs(dr)) !== ring) continue; + const cell = this.cells.get(`${qCol + dc},${qRow + dr}`); + if (!cell) continue; + for (const entry of cell) { + const dx = entry.x - point.x; + const dy = entry.y - point.y; + const cost = dx * dx + dy * dy; + if (cost < bestCost) { + bestCost = cost; + bestIp = entry.infilledPath; + } + } + } + } + } + + return bestIp; + } +} + +export function optimizePaths(infilledPaths: InfilledPath[], start_x: number, start_y: number): paper.Path[] { + const paths: paper.Path[] = []; + + function getLastPoint() { + if (paths.length === 0) throw new Error('no points found'); + const lastPath = paths[paths.length - 1]; + return lastPath.closed ? lastPath.firstSegment.point : lastPath.lastSegment.point; + } + + if (infilledPaths.length === 0) return paths; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const ip of infilledPaths) { + for (const p of ip.outlinePaths) { + const b = p.bounds; + if (b.x < minX) minX = b.x; + if (b.y < minY) minY = b.y; + if (b.x + b.width > maxX) maxX = b.x + b.width; + if (b.y + b.height > maxY) maxY = b.y + b.height; + } + } + + const n = infilledPaths.length; + const canvasSize = Math.max(maxX - minX, maxY - minY, 1); + const cellSize = Math.max(canvasSize / Math.sqrt(n), 1); + + const grid = new SpatialGrid(infilledPaths, cellSize, minX, minY); + const infilledPathsCopy = [...infilledPaths]; + + while (infilledPathsCopy.length > 0) { + const currentPoint = paths.length > 0 ? getLastPoint() : new paper.Point(start_x, start_y); + + // findNearest returns null when all remaining paths have empty outlinePaths; + // fall back to picking the first remaining path so the outer loop can drain. + const nearestIp = grid.findNearest(currentPoint) ?? infilledPathsCopy[0]; + const infilledPathIndex = infilledPathsCopy.indexOf(nearestIp); + const exactEntry = nearestIp.outlinePaths.length > 0 + ? getClosestPath(nearestIp.outlinePaths, currentPoint, true) + : undefined; + let outlinePathIndex = exactEntry?.index ?? 0; + + const infilledPath = infilledPathsCopy[infilledPathIndex]; + const outlinePathsCopy = [...infilledPath.outlinePaths]; + + if (exactEntry?.reverse && outlinePathsCopy.length > 0) { + outlinePathsCopy[outlinePathIndex].reverse(); + } + + while (outlinePathsCopy.length > 0) { + const currentOutlinePath = outlinePathsCopy[outlinePathIndex]; + paths.push(currentOutlinePath); + + outlinePathsCopy.splice(outlinePathIndex, 1); + + const nextPath = getClosestPath(outlinePathsCopy, getLastPoint(), false); + if (nextPath) { + outlinePathIndex = nextPath.index; + } + } + + const infillsCopy = [...infilledPath.infillPaths]; + while (infillsCopy.length > 0) { + const nextInfill = getClosestPath(infillsCopy, getLastPoint(), true); + if (nextInfill.reverse) { + nextInfill.path.reverse(); + } + paths.push(nextInfill.path); + infillsCopy.splice(nextInfill.index, 1); + } + + grid.remove(infilledPath); + infilledPathsCopy.splice(infilledPathIndex, 1); + } + + return paths; +} + +function getClosestPath(paths: paper.Path[], lastPoint: paper.Point, canReverse: boolean) { + const pathCosts = paths.map((p, index) => { + const startPoint = p.firstSegment.point; + // cheaper to keep it squared + const startPointCost = startPoint.getDistance(lastPoint, true); + + if (canReverse) { + const endPoint = p.lastSegment.point; + const endPointCost = endPoint.getDistance(lastPoint, true); + + if (endPointCost >= startPointCost) { + return { path: p, cost: startPointCost, index, reverse: false }; + } else { + return { path: p, cost: endPointCost, index, reverse: true }; + } + } else { + return { path: p, cost: startPointCost, index, reverse: false }; + } + }); + + return pathCosts.sort((a, b) => a.cost - b.cost)[0]; +} diff --git a/tsc/src/paperLoader.ts b/worker/src/paperLoader.ts similarity index 100% rename from tsc/src/paperLoader.ts rename to worker/src/paperLoader.ts diff --git a/tsc/src/renderer.ts b/worker/src/renderer.ts similarity index 100% rename from tsc/src/renderer.ts rename to worker/src/renderer.ts diff --git a/tsc/src/tester.ts b/worker/src/tester.ts similarity index 100% rename from tsc/src/tester.ts rename to worker/src/tester.ts diff --git a/tsc/src/toCommands.ts b/worker/src/toCommands.ts similarity index 100% rename from tsc/src/toCommands.ts rename to worker/src/toCommands.ts diff --git a/tsc/src/toSvgJson.ts b/worker/src/toSvgJson.ts similarity index 100% rename from tsc/src/toSvgJson.ts rename to worker/src/toSvgJson.ts diff --git a/tsc/src/tracer.js b/worker/src/tracer.js similarity index 100% rename from tsc/src/tracer.js rename to worker/src/tracer.js diff --git a/tsc/src/trimmer.ts b/worker/src/trimmer.ts similarity index 100% rename from tsc/src/trimmer.ts rename to worker/src/trimmer.ts diff --git a/tsc/src/types.ts b/worker/src/types.ts similarity index 51% rename from tsc/src/types.ts rename to worker/src/types.ts index 3903134b..70a4fec1 100644 --- a/tsc/src/types.ts +++ b/worker/src/types.ts @@ -41,4 +41,33 @@ export namespace RequestTypes { raster: ImageData, turdSize: number, } + + export type RenderRasterZigZagRequest = { + type: 'renderRasterZigZag', + imageData: ImageData, + widthMm: number, + heightMm: number, + homeX: number, + homeY: number, + lineSpacing: number, + amplitude: number, + brightness: number, + contrast: number, + blackPoint: number, + whitePoint: number, + angle: number, + continuousPath: boolean, + liftOnTransparent: boolean, + /** Bounding box of the actual image content within the canvas (mm). + * Points outside this rectangle are always skipped, regardless of trimWhite. */ + imageLeft: number, + imageTop: number, + imageRight: number, + imageBottom: number, + /** FM: distance (mm) between consecutive zigzag vertices in the darkest areas. */ + minHalfPeriod: number, + /** FM: distance (mm) between consecutive zigzag vertices in the lightest areas. */ + maxHalfPeriod: number, + useAmFm: boolean, + } } \ No newline at end of file diff --git a/tsc/src/utils.ts b/worker/src/utils.ts similarity index 100% rename from tsc/src/utils.ts rename to worker/src/utils.ts diff --git a/tsc/src/vectorizer.ts b/worker/src/vectorizer.ts similarity index 100% rename from tsc/src/vectorizer.ts rename to worker/src/vectorizer.ts diff --git a/worker/src/zigzag.ts b/worker/src/zigzag.ts new file mode 100644 index 00000000..791c80ee --- /dev/null +++ b/worker/src/zigzag.ts @@ -0,0 +1,244 @@ +import { Command, RequestTypes, updateStatusFn } from './types'; +import { trimCommands } from './trimmer'; +import { dedupeCommands } from './deduplicator'; +import { measureDistance } from './measurer'; +import { renderCommandsToSvgJson } from './toSvgJson'; + +function getPixelLuma(data: Uint8ClampedArray, imgW: number, px: number, py: number): number { + const i = (py * imgW + px) * 4; + const alpha = data[i + 3] / 255; + // Composite against white so that transparent areas read as white (not black). + const luma = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; + return luma * alpha + 255 * (1 - alpha); +} + +function adjustBrightnessContrast(luma: number, brightness: number, contrast: number): number { + luma += brightness * (128 / 100); + if (contrast !== 0) { + const factor = (259 * (contrast + 255)) / (255 * (259 - contrast)); + luma = factor * (luma - 127.5) + 127.5; + } + return Math.max(0, Math.min(255, luma)) / 255; // 0=black, 1=white +} + +/** + * Clip parametric line P(t) = (ox + t*dx, oy + t*dy) to the rectangle + * [0, w] × [0, h]. Returns the [tMin, tMax] interval, or null if no intersection. + */ +function clipLine( + ox: number, oy: number, dx: number, dy: number, + w: number, h: number, +): { tMin: number; tMax: number } | null { + let tMin = -Infinity, tMax = Infinity; + + if (Math.abs(dx) < 1e-9) { + if (ox < 0 || ox > w) return null; + } else { + const t1 = (0 - ox) / dx; + const t2 = (w - ox) / dx; + tMin = Math.max(tMin, Math.min(t1, t2)); + tMax = Math.min(tMax, Math.max(t1, t2)); + } + + if (Math.abs(dy) < 1e-9) { + if (oy < 0 || oy > h) return null; + } else { + const t1 = (0 - oy) / dy; + const t2 = (h - oy) / dy; + tMin = Math.max(tMin, Math.min(t1, t2)); + tMax = Math.min(tMax, Math.max(t1, t2)); + } + + if (tMin >= tMax - 1e-9) return null; + return { tMin, tMax }; +} + +export function renderRasterZigZag( + request: RequestTypes.RenderRasterZigZagRequest, + updateStatus: updateStatusFn, +): { commands: string[]; svgJson: string; distance: number; drawDistance: number } { + const { imageData, widthMm, heightMm, homeX, homeY, lineSpacing, amplitude, brightness, contrast, blackPoint, whitePoint, angle, continuousPath, liftOnTransparent, imageLeft, imageTop, imageRight, imageBottom, minHalfPeriod, maxHalfPeriod, useAmFm } = request; + const { data, width: imgW, height: imgH } = imageData; + + const angleRad = (angle * Math.PI) / 180; + // lineDir: direction along the scan line + // perpDir: direction between scan lines (zig-zag displacement direction) + const lineDx = Math.cos(angleRad); + const lineDy = Math.sin(angleRad); + const perpDx = -Math.sin(angleRad); + const perpDy = Math.cos(angleRad); + + // Project canvas corners onto perpDir to find the full perpendicular coverage + const corners = [[0, 0], [widthMm, 0], [widthMm, heightMm], [0, heightMm]]; + const projections = corners.map(([cx, cy]) => cx * perpDx + cy * perpDy); + const dMin = Math.min(...projections); + const dMax = Math.max(...projections); + + const numLines = Math.ceil((dMax - dMin) / lineSpacing) + 1; + const rawCommands: Command[] = []; + let penDown = false; + let lastPenPos: { x: number; y: number } | null = null; + let drawnLineCount = 0; // counts only lines that survive all skips + + rawCommands.push('p0'); + rawCommands.push({ x: homeX, y: homeY }); + + for (let lineIdx = 0; lineIdx < numLines; lineIdx++) { + updateStatus(`Generating line ${lineIdx + 1} of ${numLines}`); + + const d = dMin + lineIdx * lineSpacing; + + // Origin of this scan line: d * perpDir + const ox = d * perpDx; + const oy = d * perpDy; + + const clip = clipLine(ox, oy, lineDx, lineDy, widthMm, heightMm); + if (!clip) continue; + + const { tMin, tMax } = clip; + + const points: { x: number; y: number }[] = []; + const rawPoints: { x: number; y: number }[] = []; // unclamped, used for boundary trimming + + const samplePixel = (x_mm: number, y_mm: number): { darkness: number; alpha: number } => { + const px = Math.min(Math.max(Math.round((x_mm / widthMm) * (imgW - 1)), 0), imgW - 1); + const py = Math.min(Math.max(Math.round((y_mm / heightMm) * (imgH - 1)), 0), imgH - 1); + const luma = getPixelLuma(data, imgW, px, py); + let t_norm = adjustBrightnessContrast(luma, brightness, contrast); + t_norm = whitePoint > blackPoint + ? (Math.max(blackPoint, Math.min(whitePoint, t_norm)) - blackPoint) / (whitePoint - blackPoint) + : 0.5; + return { darkness: 1 - t_norm, alpha: data[(py * imgW + px) * 4 + 3] }; + }; + + const alphas: number[] = []; + + if (useAmFm) { + // AM+FM sinusoidal squiggle: quarter-period steps (4 points/cycle), + // frequency and amplitude both scale with local darkness. + let t = tMin; + let phase = 0; + while (t <= tMax) { + const x_mm = ox + t * lineDx; + const y_mm = oy + t * lineDy; + const { darkness, alpha } = samplePixel(x_mm, y_mm); + alphas.push(alpha); + const halfPeriod = Math.max(maxHalfPeriod - darkness * (maxHalfPeriod - minHalfPeriod), 0.01); + const disp = Math.sin(2 * Math.PI * phase) * darkness * amplitude; + const rawX = x_mm + disp * perpDx; + const rawY = y_mm + disp * perpDy; + rawPoints.push({ x: rawX, y: rawY }); + points.push({ + x: Math.max(imageLeft, Math.min(imageRight, rawX)), + y: Math.max(imageTop, Math.min(imageBottom, rawY)), + }); + phase += 0.25; + t += halfPeriod / 2; + } + } else { + // Original AM-only: fixed 1 mm steps, triangle wave. + const numSamples = Math.ceil(tMax - tMin) + 1; + for (let j = 0; j < numSamples; j++) { + const t = Math.min(tMin + j, tMax); + const x_mm = ox + t * lineDx; + const y_mm = oy + t * lineDy; + const { darkness, alpha } = samplePixel(x_mm, y_mm); + alphas.push(alpha); + const sign = j % 2 === 0 ? 1 : -1; + const rawX = x_mm + sign * darkness * amplitude * perpDx; + const rawY = y_mm + sign * darkness * amplitude * perpDy; + rawPoints.push({ x: rawX, y: rawY }); + points.push({ + x: Math.max(imageLeft, Math.min(imageRight, rawX)), + y: Math.max(imageTop, Math.min(imageBottom, rawY)), + }); + } + } + + // Trim leading/trailing points where the unclamped displacement exits the image + // bounds. Using rawPoints (not points) is essential: clamped points always satisfy + // the bounds check, so trimming against them is a no-op — which causes clamped + // endpoint clusters to form a visible perimeter line at non-0/90° angles. + let bFirst = 0; + let bLast = points.length - 1; + while (bFirst <= bLast && (rawPoints[bFirst].x < imageLeft || rawPoints[bFirst].x > imageRight || rawPoints[bFirst].y < imageTop || rawPoints[bFirst].y > imageBottom)) bFirst++; + while (bLast >= bFirst && (rawPoints[bLast].x < imageLeft || rawPoints[bLast].x > imageRight || rawPoints[bLast].y < imageTop || rawPoints[bLast].y > imageBottom)) bLast--; + if (bFirst > bLast) continue; // entire line outside image — skip + + let activePoints = points.slice(bFirst, bLast + 1); + let activeAlphas = alphas.slice(bFirst, bLast + 1); + + // Boustrophedon: reverse every other *drawn* line to minimise pen travel. + // Must use drawnLineCount, not lineIdx — skipped lines still increment lineIdx + // and would corrupt the even/odd pattern (especially visible at non-zero angles). + if (drawnLineCount % 2 !== 0) { + activePoints.reverse(); + activeAlphas.reverse(); + } + drawnLineCount++; + + // Split the line into sub-segments wherever a fully transparent pixel is encountered. + const segments: { x: number; y: number }[][] = []; + if (liftOnTransparent) { + let current: { x: number; y: number }[] = []; + for (let i = 0; i < activePoints.length; i++) { + if (activeAlphas[i] === 0) { + if (current.length > 0) { segments.push(current); current = []; } + } else { + current.push(activePoints[i]); + } + } + if (current.length > 0) segments.push(current); + } else { + segments.push(activePoints); + } + if (segments.length === 0) continue; + + for (let si = 0; si < segments.length; si++) { + const seg = segments[si]; + const gapTooBig = lastPenPos !== null + && Math.hypot(seg[0].x - lastPenPos.x, seg[0].y - lastPenPos.y) > 2 * lineSpacing; + + if (continuousPath && si === 0 && penDown && !gapTooBig) { + for (const pt of seg) rawCommands.push({ x: pt.x, y: pt.y }); + } else if (continuousPath && si === 0 && !penDown) { + rawCommands.push({ x: seg[0].x, y: seg[0].y }); + rawCommands.push('p1'); + penDown = true; + for (let k = 1; k < seg.length; k++) rawCommands.push({ x: seg[k].x, y: seg[k].y }); + } else { + // Non-continuous, gap too large, or segment after a transparent break. + if (penDown) { rawCommands.push('p0'); penDown = false; } + rawCommands.push({ x: seg[0].x, y: seg[0].y }); + rawCommands.push('p1'); + penDown = true; + for (let k = 1; k < seg.length; k++) rawCommands.push({ x: seg[k].x, y: seg[k].y }); + } + lastPenPos = seg[seg.length - 1]; + } + } + + rawCommands.push('p0'); + + + const trimmed = trimCommands(rawCommands); + const deduped = dedupeCommands(trimmed); + + deduped.unshift(`h${heightMm}`); + const distances = measureDistance(deduped); + const totalDistance = +distances.totalDistance.toFixed(1); + deduped.unshift(`d${totalDistance}`); + + const commandStrings = deduped.map((c) => + typeof c === 'string' ? c : `${c.x} ${c.y}`, + ); + + const svgJson = renderCommandsToSvgJson(commandStrings, widthMm, heightMm, updateStatus); + + return { + commands: commandStrings, + svgJson, + distance: totalDistance, + drawDistance: +distances.drawDistance.toFixed(1), + }; +} diff --git a/tsc/tsconfig.json b/worker/tsconfig.json similarity index 100% rename from tsc/tsconfig.json rename to worker/tsconfig.json diff --git a/tsc/webpack.config.js b/worker/webpack.config.js similarity index 100% rename from tsc/webpack.config.js rename to worker/webpack.config.js