From 89404882ab7a9a100eec09837dffdc98dd4bccc1 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Sun, 12 Apr 2026 23:13:10 +0000 Subject: [PATCH 01/38] progress so far --- .gitignore | 4 +- build.py | 7 +- data/www/client.js | 38 - data/www/dpad.less | 253 --- data/www/index.html | 224 +- data/www/main.css | 17 - data/www/main.js | 559 ----- data/www/svgControl.js | 265 --- ui/index.html | 12 + ui/package-lock.json | 2737 +++++++++++++++++++++++ ui/package.json | 27 + ui/postcss.config.js | 6 + ui/src/App.tsx | 148 ++ ui/src/api.ts | 117 + ui/src/components/AlertModal.tsx | 50 + ui/src/components/Button.tsx | 33 + ui/src/components/Card.tsx | 22 + ui/src/components/DPad.tsx | 77 + ui/src/components/ProgressBar.tsx | 27 + ui/src/components/Slider.tsx | 33 + ui/src/components/Toggle.tsx | 29 + ui/src/components/ToolsModal.tsx | 115 + ui/src/hooks/useWorkerRenderer.ts | 59 + ui/src/index.css | 11 + ui/src/main.tsx | 10 + ui/src/renderers/index.ts | 77 + ui/src/renderers/pathTracing.ts | 41 + ui/src/renderers/vectorRasterVector.ts | 70 + ui/src/screens/BeginDrawingScreen.tsx | 40 + ui/src/screens/ChooseRendererScreen.tsx | 32 + ui/src/screens/DrawingBeganScreen.tsx | 12 + ui/src/screens/DrawingPreviewScreen.tsx | 161 ++ ui/src/screens/ExtendToHomeScreen.tsx | 73 + ui/src/screens/LoadingScreen.tsx | 8 + ui/src/screens/PenCalibrationScreen.tsx | 89 + ui/src/screens/RetractBeltsScreen.tsx | 49 + ui/src/screens/SetTopDistanceScreen.tsx | 59 + ui/src/screens/SvgUploadScreen.tsx | 91 + ui/src/screens/UploadProgressScreen.tsx | 84 + ui/src/svgControl.ts | 225 ++ ui/src/types.ts | 40 + ui/tailwind.config.js | 8 + ui/tsconfig.json | 18 + ui/tsconfig.tsbuildinfo | 1 + ui/vite.config.ts | 11 + 45 files changed, 4721 insertions(+), 1348 deletions(-) delete mode 100644 data/www/client.js delete mode 100644 data/www/dpad.less delete mode 100644 data/www/main.css delete mode 100644 data/www/main.js delete mode 100644 data/www/svgControl.js create mode 100644 ui/index.html create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/postcss.config.js create mode 100644 ui/src/App.tsx create mode 100644 ui/src/api.ts create mode 100644 ui/src/components/AlertModal.tsx create mode 100644 ui/src/components/Button.tsx create mode 100644 ui/src/components/Card.tsx create mode 100644 ui/src/components/DPad.tsx create mode 100644 ui/src/components/ProgressBar.tsx create mode 100644 ui/src/components/Slider.tsx create mode 100644 ui/src/components/Toggle.tsx create mode 100644 ui/src/components/ToolsModal.tsx create mode 100644 ui/src/hooks/useWorkerRenderer.ts create mode 100644 ui/src/index.css create mode 100644 ui/src/main.tsx create mode 100644 ui/src/renderers/index.ts create mode 100644 ui/src/renderers/pathTracing.ts create mode 100644 ui/src/renderers/vectorRasterVector.ts create mode 100644 ui/src/screens/BeginDrawingScreen.tsx create mode 100644 ui/src/screens/ChooseRendererScreen.tsx create mode 100644 ui/src/screens/DrawingBeganScreen.tsx create mode 100644 ui/src/screens/DrawingPreviewScreen.tsx create mode 100644 ui/src/screens/ExtendToHomeScreen.tsx create mode 100644 ui/src/screens/LoadingScreen.tsx create mode 100644 ui/src/screens/PenCalibrationScreen.tsx create mode 100644 ui/src/screens/RetractBeltsScreen.tsx create mode 100644 ui/src/screens/SetTopDistanceScreen.tsx create mode 100644 ui/src/screens/SvgUploadScreen.tsx create mode 100644 ui/src/screens/UploadProgressScreen.tsx create mode 100644 ui/src/svgControl.ts create mode 100644 ui/src/types.ts create mode 100644 ui/tailwind.config.js create mode 100644 ui/tsconfig.json create mode 100644 ui/tsconfig.tsbuildinfo create mode 100644 ui/vite.config.ts diff --git a/.gitignore b/.gitignore index 3f9c08ea..8fc6b9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .pio -data/www/worker/* -tsc/node_modules +data/www/* +node_modules tsc/package-lock.json tsc/dist tsc/dist_packed diff --git a/build.py b/build.py index ec94192c..f9c1e1cf 100644 --- a/build.py +++ b/build.py @@ -1,7 +1,7 @@ import os Import("env") -print("Transpiling TS code") +print("Transpiling TS worker code") env.Execute("rm data/www/worker/* || true") currentPath = os.getcwd() @@ -10,4 +10,9 @@ 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) + +print("Building React UI") +os.chdir('./ui') +env.Execute("npm run build") os.chdir(currentPath) \ 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 index 31959a2d..0ea9e176 100644 --- a/data/www/index.html +++ b/data/www/index.html @@ -1,215 +1,13 @@ - - - - - Mural - - - - - - - - - - - - - - -
- - - - - - - - - - - -
Mural
-
- - - - - \ No newline at end of file + + + + Mural + + + + +
+ + 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/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..bbeec747 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,148 @@ +import { useState, useEffect } from 'react' +import { getState } from './api' +import { showAlert } from './components/AlertModal' +import type { BackendState, UiPhase } from './types' +import type { SvgState } from './svgControl' +import type { RendererDefinition } 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 { SvgUploadScreen } from './screens/SvgUploadScreen' +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 [selectedRenderer, setSelectedRenderer] = useState(null) + const [renderedCommands, setRenderedCommands] = useState(null) + + useEffect(() => { + getState() + .then((s) => { + setBackendState(s) + setUiPhase(s.phase) + }) + .catch(() => { + showAlert('Failed to retrieve state') + }) + }, []) + + function handleStateUpdate(s: BackendState) { + setBackendState(s) + setUiPhase(s.phase) + } + + function handleSvgPreview(state: SvgState) { + setSvgState(state) + setUiPhase('ChooseRenderer') + } + + function handleRendererChosen(renderer: RendererDefinition) { + setSelectedRenderer(renderer) + setUiPhase('DrawingPreview') + } + + function handleBackToSvgSelect() { + setRenderedCommands(null) + setUiPhase('SvgSelect') + } + + function handleCommandsAccepted(commands: string) { + setRenderedCommands(commands) + setUiPhase('UploadProgress') + } + + function handleUploadDone(s: BackendState) { + setBackendState(s) + setUiPhase('BeginDrawing') + } + + async function handleUploadError(message: string) { + await showAlert(message) + setUiPhase('DrawingPreview') + } + + 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 'SvgSelect': + 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..d9e66b49 --- /dev/null +++ b/ui/src/api.ts @@ -0,0 +1,117 @@ +import type { BackendState } from './types' + +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(), + }) + if (!res.ok) throw new Error(`${url} failed: ${res.status}`) + const text = await res.text() + 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) + if (!res.ok) throw new Error(`${url} failed: ${res.status}`) + const text = await res.text() + 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 = (): Promise => post('/run') + +export function uploadCommands( + blob: Blob, + onUploadProgress: (pct: number) => void, +): Promise { + 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 { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + + 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.responseText) + } 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 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/components/AlertModal.tsx b/ui/src/components/AlertModal.tsx new file mode 100644 index 00000000..5ff074b7 --- /dev/null +++ b/ui/src/components/AlertModal.tsx @@ -0,0 +1,50 @@ +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() + }) +} diff --git a/ui/src/components/Button.tsx b/ui/src/components/Button.tsx new file mode 100644 index 00000000..39bf5b27 --- /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 font-medium text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-40 disabled:cursor-not-allowed' + + const variants = { + primary: + 'bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white focus:ring-indigo-500', + secondary: + 'bg-slate-700 hover:bg-slate-600 active:bg-slate-800 text-slate-200 focus:ring-slate-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..96a452f3 --- /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..1e5ea2d1 --- /dev/null +++ b/ui/src/components/DPad.tsx @@ -0,0 +1,77 @@ +type NudgeDir = 'up' | 'down' | 'left' | 'right' +type ZoomDir = 'in' | 'out' | 'reset' + +interface DPadProps { + onNudge: (dir: NudgeDir) => void + onZoom: (dir: ZoomDir) => void + transformText?: string +} + +function DPadButton({ + onClick, + children, + className = '', +}: { + onClick: () => void + children: React.ReactNode + className?: string +}) { + return ( + + ) +} + +export function DPad({ onNudge, onZoom, transformText }: DPadProps) { + return ( +
+
+ {/* D-pad grid */} +
+ {/* Row 1 */} +
+ onNudge('up')} className="w-full h-full"> + ▲ + +
+ {/* Row 2 */} + onNudge('left')} className="w-full h-full"> + ◀ + +
+ onNudge('right')} className="w-full h-full"> + ▶ + + {/* Row 3 */} +
+ onNudge('down')} className="w-full h-full"> + ▼ + +
+
+ + {/* Zoom controls */} +
+ onZoom('in')} className="w-full aspect-square text-lg"> + + + + onZoom('out')} className="w-full aspect-square text-lg"> + − + + onZoom('reset')} className="w-full aspect-square text-xs"> + ↺ + +
+
+ + {transformText && ( +

{transformText}

+ )} +
+ ) +} diff --git a/ui/src/components/ProgressBar.tsx b/ui/src/components/ProgressBar.tsx new file mode 100644 index 00000000..2221bd82 --- /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-indigo-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..9bdd3fc7 --- /dev/null +++ b/ui/src/components/Slider.tsx @@ -0,0 +1,33 @@ +interface SliderProps { + id?: string + label: string + min: number + max: number + step?: number + value: number + onChange: (value: number) => void + className?: string +} + +export function Slider({ id, label, min, max, step = 1, value, onChange, className = '' }: SliderProps) { + return ( +
+
+ + {value} +
+ onChange(Number(e.target.value))} + className="w-full h-2 rounded-full cursor-pointer accent-indigo-500" + /> +
+ ) +} diff --git a/ui/src/components/Toggle.tsx b/ui/src/components/Toggle.tsx new file mode 100644 index 00000000..7902396c --- /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..1b38d34d --- /dev/null +++ b/ui/src/components/ToolsModal.tsx @@ -0,0 +1,115 @@ +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))} + onMouseUp={() => handleLeftMotor(0)} + onTouchEnd={() => handleLeftMotor(0)} + className="w-full accent-indigo-500" + /> +
+ +
+
+ + {rightMotor} +
+ handleRightMotor(Number(e.target.value))} + onMouseUp={() => handleRightMotor(0)} + onTouchEnd={() => handleRightMotor(0)} + className="w-full accent-indigo-500" + /> +
+ + + + + + +
+
+ ) +} diff --git a/ui/src/hooks/useWorkerRenderer.ts b/ui/src/hooks/useWorkerRenderer.ts new file mode 100644 index 00000000..521ae5f6 --- /dev/null +++ b/ui/src/hooks/useWorkerRenderer.ts @@ -0,0 +1,59 @@ +import { useState, useRef, useCallback, useEffect } from 'react' +import type { BackendState, RenderResult } from '../types' +import type { SvgState } from '../svgControl' +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, + svgState: SvgState, + 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, + 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..48f38605 --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1,11 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-slate-950 text-slate-100 min-h-screen; +} + +input[type='range'] { + @apply accent-indigo-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/renderers/index.ts b/ui/src/renderers/index.ts new file mode 100644 index 00000000..de20a4e7 --- /dev/null +++ b/ui/src/renderers/index.ts @@ -0,0 +1,77 @@ +import type { BackendState, InfillDensity, RenderResult } from '../types' +import type { SvgState } from '../svgControl' + +export type RendererParamValue = number | boolean | string + +export type RendererParam = + | { + type: 'slider' + id: string + label: string + min: number + max: number + step: number + default: number + } + | { type: 'checkbox'; id: string; label: string; default: boolean } + | { + type: 'select' + id: string + label: string + options: { value: string; label: string }[] + default: string + } + +export interface ExecuteOpts { + svgState: SvgState + params: Record + backendState: BackendState + onStatus: (status: string) => void + worker: Worker +} + +export interface RendererDefinition { + id: string + label: string + description?: string + 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' + +export const RENDERERS: RendererDefinition[] = [ + pathTracingRenderer, + vectorRasterVectorRenderer, + // Add new renderer definitions here — no other files need to change +] diff --git a/ui/src/renderers/pathTracing.ts b/ui/src/renderers/pathTracing.ts new file mode 100644 index 00000000..ca0943a1 --- /dev/null +++ b/ui/src/renderers/pathTracing.ts @@ -0,0 +1,41 @@ +import { svgStringToJson, getTransformedSvgString } from '../svgControl' +import { + getInfillDensity, + listenForRendererResult, + type RendererDefinition, + type ExecuteOpts, +} from './index' + +async function execute({ svgState, params, backendState, onStatus, worker }: ExecuteOpts) { + 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', + 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/vectorRasterVector.ts b/ui/src/renderers/vectorRasterVector.ts new file mode 100644 index 00000000..dbaae9de --- /dev/null +++ b/ui/src/renderers/vectorRasterVector.ts @@ -0,0 +1,70 @@ +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 { + // 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', + 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..37dd69cc --- /dev/null +++ b/ui/src/screens/BeginDrawingScreen.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react' +import { run, doneWithPhase } from '../api' +import type { BackendState } from '../types' +import { Card } from '../components/Card' +import { Button } from '../components/Button' + +interface Props { + onBegin: () => void + onReset: (state: BackendState) => void +} + +export function BeginDrawingScreen({ onBegin, onReset }: Props) { + const [busy, setBusy] = useState(false) + + async function handleBegin() { + setBusy(true) + await run() + onBegin() + } + + async function handleReset() { + setBusy(true) + await doneWithPhase() + location.reload() + } + + return ( + + + + + ) +} diff --git a/ui/src/screens/ChooseRendererScreen.tsx b/ui/src/screens/ChooseRendererScreen.tsx new file mode 100644 index 00000000..a4cad219 --- /dev/null +++ b/ui/src/screens/ChooseRendererScreen.tsx @@ -0,0 +1,32 @@ +import { RENDERERS, type RendererDefinition } from '../renderers/index' +import { Card } from '../components/Card' +import { Button } from '../components/Button' + +interface Props { + onChoose: (renderer: RendererDefinition) => void + onBack: () => void +} + +export function ChooseRendererScreen({ 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..a7ee8c23 --- /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..6f4fd475 --- /dev/null +++ b/ui/src/screens/DrawingPreviewScreen.tsx @@ -0,0 +1,161 @@ +import { useState, useEffect } from 'react' +import type { BackendState } from '../types' +import type { SvgState } from '../svgControl' +import { jsonToPreviewDataUrl } from '../svgControl' +import { type RendererDefinition, 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' +import { ProgressBar } from '../components/ProgressBar' + +interface Props { + state: BackendState + svgState: SvgState + renderer: RendererDefinition + onAccept: (commands: string) => void + onBack: () => void +} + +function defaultParams(renderer: RendererDefinition): Record { + const p: Record = {} + for (const param of renderer.params) { + p[param.id] = param.default + } + return p +} + +export function DrawingPreviewScreen({ state, svgState, renderer, onAccept, onBack }: Props) { + const [params, setParams] = useState>(() => + defaultParams(renderer), + ) + const { render, isRendering, status, result } = useWorkerRenderer() + + const [previewUrl, setPreviewUrl] = useState(null) + + // Trigger initial render + useEffect(() => { + triggerRender(params) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Generate preview data URL when result arrives + useEffect(() => { + if (result) { + const url = jsonToPreviewDataUrl(result.svgJson, svgState.width, svgState.baseHeight) + setPreviewUrl(url) + } + }, [result, svgState.width, svgState.baseHeight]) + + function triggerRender(p: Record) { + setPreviewUrl(null) + render(renderer, svgState, p, state) + } + + function handleParamChange(id: string, value: RendererParamValue) { + const newParams = { ...params, [id]: value } + setParams(newParams) + triggerRender(newParams) + } + + function handleAccept() { + if (!result) return + onAccept(result.commands.join('\n')) + } + + return ( + + {previewUrl ? ( + Render preview + ) : ( +
+
+
+ )} + + {/* Render progress */} + {isRendering && ( + + )} + + {/* Distance stats */} + {result && !isRendering && ( +

+ Total:{' '} + + {(result.distance / 1000).toFixed(1)}m + {' '} + / Draw:{' '} + + {(result.drawDistance / 1000).toFixed(1)}m + +

+ )} + + {/* Dynamic renderer params */} +
+ {renderer.params.map((param) => { + if (param.type === 'slider') { + return ( + handleParamChange(param.id, v)} + /> + ) + } + if (param.type === 'checkbox') { + return ( + + ) + } + if (param.type === 'select') { + return ( +
+ + +
+ ) + } + return null + })} +
+ + + + + ) +} diff --git a/ui/src/screens/ExtendToHomeScreen.tsx b/ui/src/screens/ExtendToHomeScreen.tsx new file mode 100644 index 00000000..1d66eaef --- /dev/null +++ b/ui/src/screens/ExtendToHomeScreen.tsx @@ -0,0 +1,73 @@ +import { useState, useEffect } from 'react' +import { extendToHome, getState } from '../api' +import { showAlert } 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 showAlert('Failed to get current phase') + location.reload() + 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/LoadingScreen.tsx b/ui/src/screens/LoadingScreen.tsx new file mode 100644 index 00000000..663d906e --- /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..a81f6dd2 --- /dev/null +++ b/ui/src/screens/PenCalibrationScreen.tsx @@ -0,0 +1,89 @@ +import { useState, useEffect, useRef } from 'react' +import { setServo, setPenDistance } from '../api' +import type { BackendState } from '../types' +import { Card } from '../components/Card' +import { Button } from '../components/Button' +import { Slider } from '../components/Slider' + +interface Props { + onDone: (state: BackendState) => void +} + +// UI slider value (0–90) maps to servo angle as: servo = 90 - sliderValue +// When slider = 0 → servo = 90 (full up / retracted) +// When slider = 90 → servo = 0 (pen touching wall) +const STEP = 5 + +function toServoAngle(sliderVal: number) { + return Math.max(0, Math.min(90, 90 - 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(90) + }, []) + + 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) + } + } + + async function handleConfirm() { + setBusy(true) + const state = await setPenDistance(toServoAngle(sliderVal)) + onDone(state) + } + + return ( + + + +
+ + +
+ + +
+ ) +} 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..8f0f2089 --- /dev/null +++ b/ui/src/screens/SetTopDistanceScreen.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { setTopDistance } from '../api' +import type { BackendState } from '../types' +import { showAlert } 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) + const state = await setTopDistance(val) + onDone(state) + } + + return ( + <> + {/* Gear icon trigger */} + + + + setDistance(e.target.value)} + placeholder="e.g. 1500" + className="w-full bg-slate-800 border border-slate-700 text-white rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 placeholder:text-slate-600" + /> + + + + setToolsOpen(false)} /> + + ) +} diff --git a/ui/src/screens/SvgUploadScreen.tsx b/ui/src/screens/SvgUploadScreen.tsx new file mode 100644 index 00000000..1c8caed3 --- /dev/null +++ b/ui/src/screens/SvgUploadScreen.tsx @@ -0,0 +1,91 @@ +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 + onPreview: (svgState: SvgState) => void +} + +export function SvgUploadScreen({ state, onPreview }: Props) { + const [svgState, setSvgState] = useState(null) + const [previewUrl, setPreviewUrl] = useState(null) + const [txText, setTxText] = useState('') + 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..703626e3 --- /dev/null +++ b/ui/src/screens/UploadProgressScreen.tsx @@ -0,0 +1,84 @@ +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 { + commands: string + onDone: (state: BackendState) => void + onError: (message: string) => void +} + +export function UploadProgressScreen({ commands, 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 + let state: BackendState + try { + const blob = new Blob([commands], { type: 'text/plain' }) + state = await uploadCommands(blob, setUploadPct) + setUploadPct(100) + } catch (err) { + onError(`Upload to Mural failed: ${err}`) + return + } + + // Verify + setPhase('verifying') + try { + const received = await downloadCommands(setVerifyPct) + setVerifyPct(100) + + const receivedLines = received.split('\n') + const sentLines = commands.split('\n') + + if (receivedLines.length !== sentLines.length) { + onError('Data verification failed: length mismatch') + return + } + for (let i = 0; i < receivedLines.length; i++) { + if (receivedLines[i] !== sentLines[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..cb595010 --- /dev/null +++ b/ui/src/svgControl.ts @@ -0,0 +1,225 @@ +import paper from 'paper' + +export const RENDER_SCALE = 2 + +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 svgStr = new XMLSerializer().serializeToString(doc) + const dataUrl = `data:image/svg+xml;base64,${btoa(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,${btoa(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 { + const scope = new paper.PaperScope() + scope.setup({ width: 10000, height: 10000 } as paper.Size) + const svg = scope.project.importSVG(svgString, { + 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 */ +export function jsonToPreviewDataUrl(json: string, width: number, height: number): string { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + canvas.style.display = 'none' + document.body.appendChild(canvas) + + const scope = new paper.PaperScope() + scope.setup(canvas) + scope.project.importJSON(json) + ;(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..6cadbbb5 --- /dev/null +++ b/ui/src/types.ts @@ -0,0 +1,40 @@ +export type Phase = + | 'RetractBelts' + | 'SetTopDistance' + | 'ExtendToHome' + | 'PenCalibration' + | 'SvgSelect' + | 'BeginDrawing' + +export interface BackendState { + phase: Phase + topDistance?: number + safeWidth?: number + homeX?: number + homeY?: number + moving?: boolean + startedHoming?: boolean +} + +/** Internal UI phase — extends backend phases with SVG pipeline sub-states */ +export type UiPhase = + | 'loading' + | Phase + | '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/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..09709dc2 --- /dev/null +++ b/ui/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/svgControl.ts","./src/types.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/Toggle.tsx","./src/components/ToolsModal.tsx","./src/hooks/useWorkerRenderer.ts","./src/renderers/index.ts","./src/renderers/pathTracing.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/LoadingScreen.tsx","./src/screens/PenCalibrationScreen.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..c04ebf5f --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + build: { + outDir: '../data/www', + emptyOutDir: false, + }, + base: './', +}) From 2b1f7b31e9fba8c66827f11aaa1f21f648feaa29 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Sun, 12 Apr 2026 23:27:28 +0000 Subject: [PATCH 02/38] fixed screen order --- build.py | 2 +- data/www/index.html | 13 ------------- ui/src/App.tsx | 2 +- 3 files changed, 2 insertions(+), 15 deletions(-) delete mode 100644 data/www/index.html diff --git a/build.py b/build.py index f9c1e1cf..a86aaf9b 100644 --- a/build.py +++ b/build.py @@ -2,7 +2,7 @@ Import("env") print("Transpiling TS worker code") -env.Execute("rm data/www/worker/* || true") +env.Execute("rm -rf data/www || true") currentPath = os.getcwd() os.chdir('./tsc') diff --git a/data/www/index.html b/data/www/index.html deleted file mode 100644 index 0ea9e176..00000000 --- a/data/www/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Mural - - - - -
- - diff --git a/ui/src/App.tsx b/ui/src/App.tsx index bbeec747..f0a58bc8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -64,7 +64,7 @@ export default function App() { function handleUploadDone(s: BackendState) { setBackendState(s) - setUiPhase('BeginDrawing') + setUiPhase(s.phase) } async function handleUploadError(message: string) { From f11a9bf50bf8dedd386a33671afda9f369731cf2 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Sun, 12 Apr 2026 23:52:46 +0000 Subject: [PATCH 03/38] fixed bugs and layout sizing --- ui/src/App.tsx | 13 ++++++------- ui/src/api.ts | 4 ++-- ui/src/components/AlertModal.tsx | 14 +++++++++++--- ui/src/components/Button.tsx | 2 +- ui/src/components/Card.tsx | 6 +++--- ui/src/components/DPad.tsx | 6 +++--- ui/src/components/Slider.tsx | 6 +++--- ui/src/components/ToolsModal.tsx | 14 +++++++------- ui/src/screens/DrawingPreviewScreen.tsx | 2 +- ui/src/screens/ExtendToHomeScreen.tsx | 5 ++--- ui/src/screens/SetTopDistanceScreen.tsx | 11 ++++++++--- ui/src/screens/SvgUploadScreen.tsx | 2 +- 12 files changed, 48 insertions(+), 37 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f0a58bc8..0cb14dda 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { getState } from './api' -import { showAlert } from './components/AlertModal' +import { showAlert, showErrorAlert } from './components/AlertModal' import type { BackendState, UiPhase } from './types' import type { SvgState } from './svgControl' import type { RendererDefinition } from './renderers/index' @@ -33,7 +33,7 @@ export default function App() { setUiPhase(s.phase) }) .catch(() => { - showAlert('Failed to retrieve state') + showErrorAlert('Failed to retrieve state') }) }, []) @@ -68,8 +68,7 @@ export default function App() { } async function handleUploadError(message: string) { - await showAlert(message) - setUiPhase('DrawingPreview') + await showErrorAlert(message) } let currentScreen @@ -135,11 +134,11 @@ export default function App() { } return ( -
-
{currentScreen}
+
+
{currentScreen}
Mural diff --git a/ui/src/api.ts b/ui/src/api.ts index d9e66b49..7c009115 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -6,8 +6,8 @@ async function post(url: string, body: Record headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body as Record).toString(), }) - if (!res.ok) throw new Error(`${url} failed: ${res.status}`) const text = await res.text() + if (!res.ok) throw new Error(text || `${url} failed: ${res.status}`) try { return JSON.parse(text) as T } catch { @@ -17,8 +17,8 @@ async function post(url: string, body: Record async function get(url: string): Promise { const res = await fetch(url) - if (!res.ok) throw new Error(`${url} failed: ${res.status}`) const text = await res.text() + if (!res.ok) throw new Error(text || `${url} failed: ${res.status}`) try { return JSON.parse(text) as T } catch { diff --git a/ui/src/components/AlertModal.tsx b/ui/src/components/AlertModal.tsx index 5ff074b7..46168d37 100644 --- a/ui/src/components/AlertModal.tsx +++ b/ui/src/components/AlertModal.tsx @@ -20,9 +20,9 @@ function AlertModal({ message, onDismiss }: AlertModalProps) { }, [onDismiss]) return ( -
-
-

{message}

+
+
+

{message}

@@ -48,3 +48,11 @@ export function showAlert(message: string): Promise { 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 index 39bf5b27..309718dd 100644 --- a/ui/src/components/Button.tsx +++ b/ui/src/components/Button.tsx @@ -10,7 +10,7 @@ export const Button = forwardRef(function Button ref, ) { const base = - 'rounded-xl px-5 py-3 font-medium text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-40 disabled:cursor-not-allowed' + 'rounded-xl px-5 py-3 sm:py-4 font-medium text-sm sm:text-base transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-40 disabled:cursor-not-allowed' const variants = { primary: diff --git a/ui/src/components/Card.tsx b/ui/src/components/Card.tsx index 96a452f3..8a77d2db 100644 --- a/ui/src/components/Card.tsx +++ b/ui/src/components/Card.tsx @@ -9,11 +9,11 @@ interface CardProps { export function Card({ title, subtitle, children, className = '' }: CardProps) { return ( -
+
{(title || subtitle) && (
- {title &&

{title}

} - {subtitle &&

{subtitle}

} + {title &&

{title}

} + {subtitle &&

{subtitle}

}
)} {children} diff --git a/ui/src/components/DPad.tsx b/ui/src/components/DPad.tsx index 1e5ea2d1..6acb6161 100644 --- a/ui/src/components/DPad.tsx +++ b/ui/src/components/DPad.tsx @@ -32,7 +32,7 @@ export function DPad({ onNudge, onZoom, transformText }: DPadProps) {
{/* D-pad grid */} -
+
{/* Row 1 */}
onNudge('up')} className="w-full h-full"> @@ -56,7 +56,7 @@ export function DPad({ onNudge, onZoom, transformText }: DPadProps) {
{/* Zoom controls */} -
+
onZoom('in')} className="w-full aspect-square text-lg"> + @@ -70,7 +70,7 @@ export function DPad({ onNudge, onZoom, transformText }: DPadProps) {
{transformText && ( -

{transformText}

+

{transformText}

)}
) diff --git a/ui/src/components/Slider.tsx b/ui/src/components/Slider.tsx index 9bdd3fc7..0db71576 100644 --- a/ui/src/components/Slider.tsx +++ b/ui/src/components/Slider.tsx @@ -13,10 +13,10 @@ export function Slider({ id, label, min, max, step = 1, value, onChange, classNa return (
-
onChange(Number(e.target.value))} - className="w-full h-2 rounded-full cursor-pointer accent-indigo-500" + className="w-full h-2 sm:h-3 rounded-full cursor-pointer accent-indigo-500" />
) diff --git a/ui/src/components/ToolsModal.tsx b/ui/src/components/ToolsModal.tsx index 1b38d34d..d3ae5d6c 100644 --- a/ui/src/components/ToolsModal.tsx +++ b/ui/src/components/ToolsModal.tsx @@ -48,12 +48,12 @@ export function ToolsModal({ isOpen, onClose }: ToolsModalProps) { return (
e.target === e.currentTarget && onClose()} > -
+
-

Tools

+

Tools

- ) -} - export function DPad({ onNudge, onZoom, transformText }: DPadProps) { return (
-
- {/* D-pad grid */} -
- {/* Row 1 */} -
- onNudge('up')} className="w-full h-full"> - ▲ - -
- {/* Row 2 */} - onNudge('left')} className="w-full h-full"> - ◀︎ - -
- onNudge('right')} className="w-full h-full"> - ▶︎ - - {/* Row 3 */} -
- onNudge('down')} className="w-full h-full"> - ▼ - -
+
+ + {/* Circle d-pad */} +
+ + {/* UP */} + + + {/* RIGHT */} + + + {/* DOWN */} + + + {/* LEFT */} + + + {/* Divider lines (diagonal, matching sector boundaries) */} +
+
+ + {/* Center decorative circle */} +
- {/* Zoom controls */} -
- onZoom('in')} className="w-full aspect-square text-lg"> - + - - onZoom('out')} className="w-full aspect-square text-lg"> - − - - onZoom('reset')} className="w-full aspect-square text-xs"> - ↺ - + {/* Zoom pill */} +
+ +
+ +
+
+
{transformText && ( diff --git a/ui/src/screens/SvgUploadScreen.tsx b/ui/src/screens/SvgUploadScreen.tsx index a19cc2b2..9f3284a5 100644 --- a/ui/src/screens/SvgUploadScreen.tsx +++ b/ui/src/screens/SvgUploadScreen.tsx @@ -62,7 +62,7 @@ export function SvgUploadScreen({ state, onPreview }: Props) { SVG preview )} diff --git a/ui/src/svgControl.ts b/ui/src/svgControl.ts index cb595010..d820f8f8 100644 --- a/ui/src/svgControl.ts +++ b/ui/src/svgControl.ts @@ -134,6 +134,12 @@ function makeTransformedDoc(state: SvgState): { doc: Document; renderedHeight: n /** 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,${btoa(svgStr)}` return { dataUrl, renderedHeight } From fbd3492e27e78fc1bd6ab4b439b1b139e68f023d Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Tue, 14 Apr 2026 01:58:38 +0000 Subject: [PATCH 08/38] compress static assets --- build.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/build.py b/build.py index 3429a96e..44535840 100644 --- a/build.py +++ b/build.py @@ -1,4 +1,6 @@ +import gzip import os +import shutil Import("env") print("Transpiling TS worker code") @@ -15,4 +17,13 @@ print("Building React UI") os.chdir('./ui') env.Execute("npm run build") -os.chdir(currentPath) \ No newline at end of file +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 From bc75293c5aa99d93c9a40e5bb00184941511d675 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Thu, 16 Apr 2026 02:09:22 +0000 Subject: [PATCH 09/38] drawing commands stored compressed --- .devcontainer/devcontainer.json | 3 - src/gzip_file_reader.cpp | 88 +++++++++++++++++++++++++ src/gzip_file_reader.h | 56 ++++++++++++++++ src/main.cpp | 2 +- src/runner.cpp | 5 +- src/runner.h | 3 +- ui/src/api.ts | 5 +- ui/src/screens/UploadProgressScreen.tsx | 19 +++++- 8 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 src/gzip_file_reader.cpp create mode 100644 src/gzip_file_reader.h diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4e1c987c..0cafa152 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,9 +10,6 @@ }, "customizations": { "vscode": { - "settings": { - "terminal.integrated.defaultProfile.linux": "zsh" - }, "extensions": [ "ms-vscode.cpptools", "platformio.platformio-ide", 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..600b6541 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -125,7 +125,7 @@ void setup() server.on( "/downloadCommands", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(LittleFS, "/commands", "text/plain"); + request->send(LittleFS, "/commands", "application/octet-stream"); } ); diff --git a/src/runner.cpp b/src/runner.cpp index 94ef0edc..897e19d2 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"); } diff --git a/src/runner.h b/src/runner.h index aa936e15..501896f9 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,7 +15,7 @@ class Runner { Task* getNextTask(); Task* currentTask; bool stopped; - File openedFile; + GzipFileReader openedFile; double totalDistance; double distanceSoFar; Movement::Point startPosition; diff --git a/ui/src/api.ts b/ui/src/api.ts index 7c009115..3ff74e2f 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -84,9 +84,10 @@ export function uploadCommands( export function downloadCommands( onProgress: (pct: number) => void, -): Promise { +): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() + xhr.responseType = 'blob' xhr.addEventListener('progress', (evt) => { if (evt.lengthComputable) { @@ -96,7 +97,7 @@ export function downloadCommands( xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { - resolve(xhr.responseText) + resolve(xhr.response as Blob) } else { reject(new Error(`Download failed: ${xhr.status}`)) } diff --git a/ui/src/screens/UploadProgressScreen.tsx b/ui/src/screens/UploadProgressScreen.tsx index 703626e3..28cffa01 100644 --- a/ui/src/screens/UploadProgressScreen.tsx +++ b/ui/src/screens/UploadProgressScreen.tsx @@ -10,6 +10,20 @@ interface Props { onError: (message: string) => void } +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() +} + +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() +} + export function UploadProgressScreen({ commands, onDone, onError }: Props) { const [uploadPct, setUploadPct] = useState(0) const [verifyPct, setVerifyPct] = useState(0) @@ -24,7 +38,7 @@ export function UploadProgressScreen({ commands, onDone, onError }: Props) { // Upload let state: BackendState try { - const blob = new Blob([commands], { type: 'text/plain' }) + const blob = await compressCommands(commands) state = await uploadCommands(blob, setUploadPct) setUploadPct(100) } catch (err) { @@ -35,8 +49,9 @@ export function UploadProgressScreen({ commands, onDone, onError }: Props) { // Verify setPhase('verifying') try { - const received = await downloadCommands(setVerifyPct) + const receivedBlob = await downloadCommands(setVerifyPct) setVerifyPct(100) + const received = await decompressBlob(receivedBlob) const receivedLines = received.split('\n') const sentLines = commands.split('\n') From 08726f451b41cb23008bc68feeed653b8f76aea1 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Fri, 17 Apr 2026 03:04:30 +0000 Subject: [PATCH 10/38] first pass --- ui/src/App.tsx | 39 ++++- ui/src/hooks/useWorkerRenderer.ts | 6 +- ui/src/rasterControl.ts | 37 +++++ ui/src/renderers/index.ts | 13 +- ui/src/renderers/pathTracing.ts | 1 + ui/src/renderers/rasterZigZag.ts | 50 ++++++ ui/src/renderers/vectorRasterVector.ts | 1 + ui/src/screens/DrawingPreviewScreen.tsx | 203 ++++++++++++++---------- ui/src/screens/RasterUploadScreen.tsx | 74 +++++++++ ui/src/screens/SvgUploadScreen.tsx | 8 +- ui/src/types.ts | 1 + ui/tsconfig.tsbuildinfo | 2 +- worker/src/main.ts | 31 ++++ worker/src/measurer.ts | 36 ++--- worker/src/types.ts | 16 ++ worker/src/zigzag.ts | 188 ++++++++++++++++++++++ 16 files changed, 591 insertions(+), 115 deletions(-) create mode 100644 ui/src/rasterControl.ts create mode 100644 ui/src/renderers/rasterZigZag.ts create mode 100644 ui/src/screens/RasterUploadScreen.tsx create mode 100644 worker/src/zigzag.ts diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0cb14dda..2f162188 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,8 +1,9 @@ import { useState, useEffect } from 'react' import { getState } from './api' -import { showAlert, showErrorAlert } from './components/AlertModal' +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 { LoadingScreen } from './screens/LoadingScreen' @@ -11,6 +12,7 @@ import { SetTopDistanceScreen } from './screens/SetTopDistanceScreen' import { ExtendToHomeScreen } from './screens/ExtendToHomeScreen' import { PenCalibrationScreen } from './screens/PenCalibrationScreen' 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' @@ -23,6 +25,7 @@ export default function App() { // SVG pipeline state const [svgState, setSvgState] = useState(null) + const [rasterState, setRasterState] = useState(null) const [selectedRenderer, setSelectedRenderer] = useState(null) const [renderedCommands, setRenderedCommands] = useState(null) @@ -47,6 +50,19 @@ export default function App() { setUiPhase('ChooseRenderer') } + function handleSwitchToRaster() { + setUiPhase('RasterSelect') + } + + function handleRasterPreview(state: RasterImageState) { + setRasterState(state) + setUiPhase('ChooseRenderer') + } + + function handleBackFromRaster() { + setUiPhase('SvgSelect') + } + function handleRendererChosen(renderer: RendererDefinition) { setSelectedRenderer(renderer) setUiPhase('DrawingPreview') @@ -54,6 +70,7 @@ export default function App() { function handleBackToSvgSelect() { setRenderedCommands(null) + setRasterState(null) setUiPhase('SvgSelect') } @@ -89,7 +106,22 @@ export default function App() { currentScreen = break case 'SvgSelect': - currentScreen = + currentScreen = ( + + ) + break + case 'RasterSelect': + currentScreen = ( + + ) break case 'ChooseRenderer': currentScreen = ( @@ -100,7 +132,8 @@ export default function App() { currentScreen = ( , backendState: BackendState, ) => { @@ -35,7 +36,8 @@ export function useWorkerRenderer() { try { const result = await renderer.execute({ - svgState, + svgState: inputState.svgState, + imageState: inputState.imageState, params, backendState, onStatus: (status) => setState((s) => ({ ...s, status })), diff --git a/ui/src/rasterControl.ts b/ui/src/rasterControl.ts new file mode 100644 index 00000000..7e9879ed --- /dev/null +++ b/ui/src/rasterControl.ts @@ -0,0 +1,37 @@ +export interface RasterImageState { + /** Source File object */ + file: File + /** Object URL for preview — 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 +} + +/** 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 } +} + +/** Render image to full-resolution ImageData for sending to the worker */ +export async function rasterizeImage(state: RasterImageState): Promise { + const bitmap = await createImageBitmap(state.file) + const canvas = new OffscreenCanvas(state.naturalWidth, state.naturalHeight) + const ctx = canvas.getContext('2d')! + ctx.drawImage(bitmap, 0, 0, state.naturalWidth, state.naturalHeight) + bitmap.close() + return ctx.getImageData(0, 0, state.naturalWidth, state.naturalHeight) +} diff --git a/ui/src/renderers/index.ts b/ui/src/renderers/index.ts index de20a4e7..56e318d6 100644 --- a/ui/src/renderers/index.ts +++ b/ui/src/renderers/index.ts @@ -1,9 +1,10 @@ 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 RendererParam = +export type RendererParamLeaf = | { type: 'slider' id: string @@ -22,8 +23,13 @@ export type RendererParam = default: string } +export type RendererParam = + | RendererParamLeaf + | { type: 'row'; items: RendererParamLeaf[] } + export interface ExecuteOpts { - svgState: SvgState + svgState?: SvgState + imageState?: RasterImageState params: Record backendState: BackendState onStatus: (status: string) => void @@ -69,9 +75,10 @@ export function listenForRendererResult( import { pathTracingRenderer } from './pathTracing' import { vectorRasterVectorRenderer } from './vectorRasterVector' +import { rasterZigZagRenderer } from './rasterZigZag' export const RENDERERS: RendererDefinition[] = [ pathTracingRenderer, vectorRasterVectorRenderer, - // Add new renderer definitions here — no other files need to change + rasterZigZagRenderer, ] diff --git a/ui/src/renderers/pathTracing.ts b/ui/src/renderers/pathTracing.ts index ca0943a1..abf138fc 100644 --- a/ui/src/renderers/pathTracing.ts +++ b/ui/src/renderers/pathTracing.ts @@ -7,6 +7,7 @@ import { } 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) diff --git a/ui/src/renderers/rasterZigZag.ts b/ui/src/renderers/rasterZigZag.ts new file mode 100644 index 00000000..7343b92b --- /dev/null +++ b/ui/src/renderers/rasterZigZag.ts @@ -0,0 +1,50 @@ +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) + worker.postMessage( + { + type: 'renderRasterZigZag', + imageData, + widthMm: imageState.width, + heightMm: imageState.height, + 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), + angle: Number(params['angle'] ?? 0), + continuousPath: Boolean(params['continuousPath'] ?? false), + trimWhite: Boolean(params['trimWhite'] ?? false), + }, + [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', + params: [ + { type: 'row', items: [ + { type: 'slider', id: 'lineSpacing', label: 'Spacing (mm)', min: 1, max: 20, step: 1, default: 8 }, + { type: 'slider', id: 'amplitude', label: 'Amplitude (mm)', min: 0.5, max: 10, step: 0.5, default: 3 }, + ]}, + { 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: 'angle', label: 'Angle (°)', min: -90, max: 90, step: 15, default: 0 }, + { type: 'checkbox', id: 'trimWhite', label: 'Trim White Lines', default: false }, + { type: 'checkbox', id: 'continuousPath', label: 'No Pen Lift', default: false }, + ]}, + ], + execute, +} diff --git a/ui/src/renderers/vectorRasterVector.ts b/ui/src/renderers/vectorRasterVector.ts index dbaae9de..af84c072 100644 --- a/ui/src/renderers/vectorRasterVector.ts +++ b/ui/src/renderers/vectorRasterVector.ts @@ -8,6 +8,7 @@ import { 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) diff --git a/ui/src/screens/DrawingPreviewScreen.tsx b/ui/src/screens/DrawingPreviewScreen.tsx index 9acbbbc0..0c6750ec 100644 --- a/ui/src/screens/DrawingPreviewScreen.tsx +++ b/ui/src/screens/DrawingPreviewScreen.tsx @@ -1,17 +1,19 @@ 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 { type RendererDefinition, type RendererParamValue } from '../renderers/index' +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' -import { ProgressBar } from '../components/ProgressBar' + interface Props { state: BackendState - svgState: SvgState + svgState?: SvgState + imageState?: RasterImageState renderer: RendererDefinition onAccept: (commands: string) => void onBack: () => void @@ -20,18 +22,76 @@ interface Props { function defaultParams(renderer: RendererDefinition): Record { const p: Record = {} for (const param of renderer.params) { - p[param.id] = param.default + if (param.type === 'row') { + for (const item of param.items) p[item.id] = item.default + } else { + p[param.id] = param.default + } } return p } -export function DrawingPreviewScreen({ state, svgState, renderer, onAccept, onBack }: Props) { +function renderLeaf( + param: RendererParamLeaf, + params: Record, + onChange: (id: string, value: RendererParamValue) => void, +) { + if (param.type === 'slider') { + return ( + onChange(param.id, v)} + /> + ) + } + if (param.type === 'checkbox') { + return ( + + ) + } + if (param.type === 'select') { + return ( +
+ + +
+ ) + } + return null +} + +export function DrawingPreviewScreen({ state, svgState, imageState, renderer, onAccept, onBack }: Props) { const [params, setParams] = useState>(() => defaultParams(renderer), ) const { render, isRendering, status, result } = useWorkerRenderer() const [previewUrl, setPreviewUrl] = useState(null) + const [enlarged, setEnlarged] = useState(false) + + const previewWidth = svgState?.width ?? imageState!.width + const previewHeight = svgState?.baseHeight ?? imageState!.height // Trigger initial render useEffect(() => { @@ -42,14 +102,13 @@ export function DrawingPreviewScreen({ state, svgState, renderer, onAccept, onBa // Generate preview data URL when result arrives useEffect(() => { if (result) { - const url = jsonToPreviewDataUrl(result.svgJson, svgState.width, svgState.baseHeight) + const url = jsonToPreviewDataUrl(result.svgJson, previewWidth, previewHeight) setPreviewUrl(url) } - }, [result, svgState.width, svgState.baseHeight]) + }, [result, previewWidth, previewHeight]) function triggerRender(p: Record) { - setPreviewUrl(null) - render(renderer, svgState, p, state) + render(renderer, { svgState, imageState }, p, state) } function handleParamChange(id: string, value: RendererParamValue) { @@ -65,88 +124,70 @@ export function DrawingPreviewScreen({ state, svgState, renderer, onAccept, onBa return ( - {previewUrl ? ( - Render preview - ) : ( -
-
-
- )} +
+ {previewUrl ? ( + Render preview { if (!isRendering) setEnlarged(true) }} + className={`w-full rounded-lg border border-slate-700 object-contain max-h-56 sm:max-h-72 lg:max-h-96 transition-opacity duration-150 ${isRendering ? 'opacity-40 cursor-wait' : 'opacity-100 cursor-zoom-in'}`} + /> + ) : ( +
+ )} + {isRendering && ( +
+
+
+ )} +
- {/* Render progress */} - {isRendering && ( - + {enlarged && previewUrl && ( +
setEnlarged(false)} + > + Render preview enlarged +
)} - {/* Distance stats */} - {result && !isRendering && ( -

- Total:{' '} - - {(result.distance / 1000).toFixed(1)}m - {' '} - / Draw:{' '} - - {(result.drawDistance / 1000).toFixed(1)}m - -

- )} + {/* 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} +

{/* Dynamic renderer params */}
- {renderer.params.map((param) => { - if (param.type === 'slider') { - return ( - handleParamChange(param.id, v)} - /> - ) - } - if (param.type === 'checkbox') { - return ( - - ) - } - if (param.type === 'select') { + {renderer.params.map((param, idx) => { + if (param.type === 'row') { return ( -
- - +
+ {param.items.map((item) => ( +
+ {renderLeaf(item, params, handleParamChange)} +
+ ))}
) } - return null + return ( +
+ {renderLeaf(param, params, handleParamChange)} +
+ ) })}
diff --git a/ui/src/screens/RasterUploadScreen.tsx b/ui/src/screens/RasterUploadScreen.tsx new file mode 100644 index 00000000..a50ff831 --- /dev/null +++ b/ui/src/screens/RasterUploadScreen.tsx @@ -0,0 +1,74 @@ +import { useState, useEffect, useRef } from 'react' +import type { BackendState } from '../types' +import { parseRasterImage, type RasterImageState } from '../rasterControl' +import { showAlert } from '../components/AlertModal' +import { Card } from '../components/Card' +import { Button } from '../components/Button' + +interface Props { + state: BackendState + onPreview: (imageState: RasterImageState) => void + onBack: () => void +} + +export function RasterUploadScreen({ state, onPreview, onBack }: Props) { + const [imageState, setImageState] = useState(null) + const fileRef = useRef(null) + + // Revoke previous object URL when imageState changes or component unmounts + useEffect(() => { + return () => { + if (imageState) URL.revokeObjectURL(imageState.previewUrl) + } + }, [imageState]) + + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) { + setImageState(null) + return + } + try { + const parsed = await parseRasterImage(file, state.safeWidth ?? 1000) + setImageState(parsed) + } catch (err) { + await showAlert(`Failed to load image: ${err}`) + } + } + + return ( + + {imageState && ( + Image preview + )} + + {imageState && ( +

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

+ )} + + + + + +
+ ) +} diff --git a/ui/src/screens/SvgUploadScreen.tsx b/ui/src/screens/SvgUploadScreen.tsx index 9f3284a5..96fa6219 100644 --- a/ui/src/screens/SvgUploadScreen.tsx +++ b/ui/src/screens/SvgUploadScreen.tsx @@ -9,9 +9,10 @@ import { DPad } from '../components/DPad' interface Props { state: BackendState onPreview: (svgState: SvgState) => void + onSwitchToRaster?: () => void } -export function SvgUploadScreen({ state, onPreview }: Props) { +export function SvgUploadScreen({ state, onPreview, onSwitchToRaster }: Props) { const [svgState, setSvgState] = useState(null) const [previewUrl, setPreviewUrl] = useState(null) const [txText, setTxText] = useState('') @@ -86,6 +87,11 @@ export function SvgUploadScreen({ state, onPreview }: Props) { > Preview drawing + {onSwitchToRaster && ( + + )} ) } diff --git a/ui/src/types.ts b/ui/src/types.ts index 6cadbbb5..e139f881 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -20,6 +20,7 @@ export interface BackendState { export type UiPhase = | 'loading' | Phase + | 'RasterSelect' | 'ChooseRenderer' | 'DrawingPreview' | 'UploadProgress' diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 09709dc2..32f07246 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/svgControl.ts","./src/types.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/Toggle.tsx","./src/components/ToolsModal.tsx","./src/hooks/useWorkerRenderer.ts","./src/renderers/index.ts","./src/renderers/pathTracing.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/LoadingScreen.tsx","./src/screens/PenCalibrationScreen.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 +{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/rasterControl.ts","./src/svgControl.ts","./src/types.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/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/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/worker/src/main.ts b/worker/src/main.ts index 874192f3..b327d912 100644 --- a/worker/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,34 @@ 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.angle === 'number' + && typeof obj.continuousPath === 'boolean' + && typeof obj.trimWhite === 'boolean'; +} + 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 index 9178349a..00a3be28 100644 --- a/worker/src/measurer.ts +++ b/worker/src/measurer.ts @@ -1,37 +1,25 @@ -import { Command } from "./types"; -import { distanceBetweenPoints, getLastPoint } from "./utils"; +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 (let i = 1; i < dedupedCommands.length; i++) { - const command = dedupedCommands[i]; - + for (const command of dedupedCommands) { 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; - } - } + 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; - } + if (command === 'p0') penUp = true; + else if (command === 'p1') penUp = false; } } - return { - totalDistance, - drawDistance, - }; + return { totalDistance, drawDistance }; } \ No newline at end of file diff --git a/worker/src/types.ts b/worker/src/types.ts index 3903134b..aa1bd131 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -41,4 +41,20 @@ 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, + angle: number, + continuousPath: boolean, + trimWhite: boolean, + } } \ No newline at end of file diff --git a/worker/src/zigzag.ts b/worker/src/zigzag.ts new file mode 100644 index 00000000..cd5650f4 --- /dev/null +++ b/worker/src/zigzag.ts @@ -0,0 +1,188 @@ +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; + return 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; +} + +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, angle, continuousPath, trimWhite } = 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; + + 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 numSamples = Math.ceil(tMax - tMin) + 1; + + // Build points LTR (from tMin to tMax), using position index for zig-zag phase + const points: { x: number; y: number }[] = []; + const darknesses: number[] = []; + 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 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); + const t_norm = adjustBrightnessContrast(luma, brightness, contrast); + const darkness = 1 - t_norm; + darknesses.push(darkness); + + const localAmplitude = darkness * amplitude; + const sign = j % 2 === 0 ? 1 : -1; + + points.push({ + x: Math.max(0, Math.min(widthMm, x_mm + sign * localAmplitude * perpDx)), + y: Math.max(0, Math.min(heightMm, y_mm + sign * localAmplitude * perpDy)), + }); + } + + // Trim leading/trailing flat (white) samples before reversing so + // trimming is always relative to physical position along the line + let activePoints = points; + if (trimWhite) { + const FLAT = 0.05; + let first = 0; + let last = points.length - 1; + while (first < points.length && darknesses[first] < FLAT) first++; + while (last > first && darknesses[last] < FLAT) last--; + if (first >= points.length) continue; // entire line is white — skip + activePoints = points.slice(first, last + 1); + } + + // Boustrophedon: reverse every other line to minimise pen travel + if (lineIdx % 2 !== 0) activePoints.reverse(); + + if (continuousPath) { + if (!penDown) { + rawCommands.push({ x: activePoints[0].x, y: activePoints[0].y }); + rawCommands.push('p1'); + penDown = true; + for (let k = 1; k < activePoints.length; k++) rawCommands.push({ x: activePoints[k].x, y: activePoints[k].y }); + } else { + for (let k = 0; k < activePoints.length; k++) rawCommands.push({ x: activePoints[k].x, y: activePoints[k].y }); + } + } else { + rawCommands.push('p0'); + rawCommands.push({ x: activePoints[0].x, y: activePoints[0].y }); + rawCommands.push('p1'); + for (let k = 1; k < activePoints.length; k++) rawCommands.push({ x: activePoints[k].x, y: activePoints[k].y }); + } + } + + rawCommands.push('p0'); + + // Perimeter rectangle — always appended when continuousPath is on + if (continuousPath) { + rawCommands.push({ x: 0, y: 0 }); + rawCommands.push('p1'); + rawCommands.push({ x: widthMm, y: 0 }); + rawCommands.push({ x: widthMm, y: heightMm }); + rawCommands.push({ x: 0, y: heightMm }); + rawCommands.push({ x: 0, y: 0 }); + 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), + }; +} From 7d944fe3192c43661b3e4eeac941a1613d61da34 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Fri, 17 Apr 2026 03:26:24 +0000 Subject: [PATCH 11/38] better sizing --- ui/src/App.tsx | 2 +- ui/src/components/Button.tsx | 2 +- ui/src/components/Card.tsx | 6 +++--- ui/src/components/Slider.tsx | 6 +++--- ui/src/screens/DrawingPreviewScreen.tsx | 8 ++++---- ui/src/screens/RasterUploadScreen.tsx | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2f162188..bbfefcb9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -168,7 +168,7 @@ export default function App() { return (
-
{currentScreen}
+
{currentScreen}
(function Button ref, ) { const base = - 'rounded-xl px-5 py-3 sm:py-4 font-medium text-sm sm:text-base transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-40 disabled:cursor-not-allowed' + '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-slate-900 disabled:opacity-40 disabled:cursor-not-allowed' const variants = { primary: diff --git a/ui/src/components/Card.tsx b/ui/src/components/Card.tsx index 8a77d2db..ec7cc0bd 100644 --- a/ui/src/components/Card.tsx +++ b/ui/src/components/Card.tsx @@ -9,11 +9,11 @@ interface CardProps { export function Card({ title, subtitle, children, className = '' }: CardProps) { return ( -
+
{(title || subtitle) && (
- {title &&

{title}

} - {subtitle &&

{subtitle}

} + {title &&

{title}

} + {subtitle &&

{subtitle}

}
)} {children} diff --git a/ui/src/components/Slider.tsx b/ui/src/components/Slider.tsx index 0db71576..72002068 100644 --- a/ui/src/components/Slider.tsx +++ b/ui/src/components/Slider.tsx @@ -13,10 +13,10 @@ export function Slider({ id, label, min, max, step = 1, value, onChange, classNa return (
-
onChange(Number(e.target.value))} - className="w-full h-2 sm:h-3 rounded-full cursor-pointer accent-indigo-500" + className="w-full h-2 sm:h-3 2xl:h-4 rounded-full cursor-pointer accent-indigo-500" />
) diff --git a/ui/src/screens/DrawingPreviewScreen.tsx b/ui/src/screens/DrawingPreviewScreen.tsx index 0c6750ec..41ee3477 100644 --- a/ui/src/screens/DrawingPreviewScreen.tsx +++ b/ui/src/screens/DrawingPreviewScreen.tsx @@ -130,7 +130,7 @@ export function DrawingPreviewScreen({ state, svgState, imageState, renderer, on src={previewUrl} alt="Render preview" onClick={() => { if (!isRendering) setEnlarged(true) }} - className={`w-full rounded-lg border border-slate-700 object-contain max-h-56 sm:max-h-72 lg:max-h-96 transition-opacity duration-150 ${isRendering ? 'opacity-40 cursor-wait' : 'opacity-100 cursor-zoom-in'}`} + className={`w-full rounded-lg border border-slate-700 object-contain max-h-56 sm:max-h-72 lg:max-h-96 2xl:max-h-[60vh] transition-opacity duration-150 ${isRendering ? 'opacity-40 cursor-wait' : 'opacity-100 cursor-zoom-in'}`} /> ) : (
@@ -144,19 +144,19 @@ export function DrawingPreviewScreen({ state, svgState, imageState, renderer, on {enlarged && previewUrl && (
setEnlarged(false)} > Render preview enlarged
)} {/* Status row — always present to prevent layout shift */} -

+

{isRendering ? (status || 'Rendering…') : result diff --git a/ui/src/screens/RasterUploadScreen.tsx b/ui/src/screens/RasterUploadScreen.tsx index a50ff831..f694a46a 100644 --- a/ui/src/screens/RasterUploadScreen.tsx +++ b/ui/src/screens/RasterUploadScreen.tsx @@ -42,7 +42,7 @@ export function RasterUploadScreen({ state, onPreview, onBack }: Props) { Image preview )} From c961008ab1772ad25061c7a7dd9022c9433c1174 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Fri, 17 Apr 2026 04:59:42 +0000 Subject: [PATCH 12/38] finished zigzag renderer --- .devcontainer/devcontainer.json | 3 +- ui/src/App.tsx | 91 +++++++++++++++++++------ ui/src/api.ts | 37 ++++++++++ ui/src/components/Slider.tsx | 8 ++- ui/src/rasterControl.ts | 67 ++++++++++++++++-- ui/src/renderers/index.ts | 5 ++ ui/src/renderers/pathTracing.ts | 1 + ui/src/renderers/rasterZigZag.ts | 16 +++++ ui/src/renderers/vectorRasterVector.ts | 1 + ui/src/screens/ChooseRendererScreen.tsx | 13 ++-- ui/src/screens/DrawingPreviewScreen.tsx | 4 +- ui/src/screens/InputSelectScreen.tsx | 29 ++++++++ ui/src/screens/RasterUploadScreen.tsx | 80 ++++++++++++++++++---- ui/src/screens/SvgUploadScreen.tsx | 21 +++--- ui/src/types.ts | 1 + ui/src/vite-env.d.ts | 1 + ui/tsconfig.tsbuildinfo | 2 +- ui/vite.config.ts | 20 +++++- worker/src/main.ts | 6 +- worker/src/types.ts | 6 ++ worker/src/zigzag.ts | 66 +++++++++++++----- 21 files changed, 399 insertions(+), 79 deletions(-) create mode 100644 ui/src/screens/InputSelectScreen.tsx create mode 100644 ui/src/vite-env.d.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0cafa152..274e6440 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,7 +18,8 @@ } }, "forwardPorts": [ - 8008 + 8008, + 5173 ], "mounts": [ "source=/dev/,target=/dev/,type=bind,consistency=consistent", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index bbfefcb9..6d1caf43 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,16 +1,18 @@ import { useState, useEffect } from 'react' -import { getState } from './api' +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' @@ -26,41 +28,73 @@ export default function App() { // 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 [renderedCommands, setRenderedCommands] = 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(s.phase) + setUiPhase(backendPhaseToUiPhase(s.phase)) }) .catch(() => { - showErrorAlert('Failed to retrieve state') + if (import.meta.env.DEV) { + const mock = { phase: 'SvgSelect' as const, safeWidth: 1000, homeX: 500, homeY: 0 } + enableMockMode(mock) + setBackendState(mock) + setUiPhase('InputSelect') + } else { + showErrorAlert('Failed to retrieve state') + } }) }, []) function handleStateUpdate(s: BackendState) { setBackendState(s) - setUiPhase(s.phase) + setUiPhase(backendPhaseToUiPhase(s.phase)) } - function handleSvgPreview(state: SvgState) { - setSvgState(state) - setUiPhase('ChooseRenderer') + function handleSelectSvg() { + setInputType('svg') + setUiPhase('SvgSelect') } - function handleSwitchToRaster() { + function handleSelectRaster() { + setInputType('raster') setUiPhase('RasterSelect') } - function handleRasterPreview(state: RasterImageState) { - setRasterState(state) + function handleBackToInputSelect() { + setSvgState(null) + setRasterState(null) + setSelectedRenderer(null) + setInputType(null) + setUiPhase('InputSelect') + } + + function handleSvgPreview(state: SvgState) { + setSvgState(state) setUiPhase('ChooseRenderer') } - function handleBackFromRaster() { - setUiPhase('SvgSelect') + 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) { @@ -68,10 +102,16 @@ export default function App() { setUiPhase('DrawingPreview') } - function handleBackToSvgSelect() { - setRenderedCommands(null) - setRasterState(null) - setUiPhase('SvgSelect') + function handleBackFromChooseRenderer() { + setUiPhase(inputType === 'raster' ? 'RasterSelect' : 'SvgSelect') + } + + function handleBackFromDrawingPreview() { + if (inputType === 'raster' && getRenderersByInputType('raster').length === 1) { + setUiPhase('RasterSelect') + } else { + setUiPhase('ChooseRenderer') + } } function handleCommandsAccepted(commands: string) { @@ -105,12 +145,18 @@ export default function App() { case 'PenCalibration': currentScreen = break + case 'InputSelect': + currentScreen = ( + + ) + break case 'SvgSelect': currentScreen = ( ) break @@ -118,14 +164,19 @@ export default function App() { currentScreen = ( ) break case 'ChooseRenderer': currentScreen = ( - + ) break case 'DrawingPreview': @@ -136,7 +187,7 @@ export default function App() { imageState={rasterState ?? undefined} renderer={selectedRenderer!} onAccept={handleCommandsAccepted} - onBack={handleBackToSvgSelect} + onBack={handleBackFromDrawingPreview} /> ) break diff --git a/ui/src/api.ts b/ui/src/api.ts index 3ff74e2f..3e6e65e9 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -1,5 +1,21 @@ 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 } +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', @@ -53,6 +69,17 @@ 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() @@ -85,6 +112,16 @@ export function uploadCommands( 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' diff --git a/ui/src/components/Slider.tsx b/ui/src/components/Slider.tsx index 72002068..a30bbe33 100644 --- a/ui/src/components/Slider.tsx +++ b/ui/src/components/Slider.tsx @@ -23,9 +23,13 @@ export function Slider({ id, label, min, max, step = 1, value, onChange, classNa type="range" min={min} max={max} - step={step} + step="any" value={value} - onChange={(e) => onChange(Number(e.target.value))} + onChange={(e) => { + const raw = Number(e.target.value) + const snapped = Math.round(raw / step) * step + onChange(Math.max(min, Math.min(max, snapped))) + }} className="w-full h-2 sm:h-3 2xl:h-4 rounded-full cursor-pointer accent-indigo-500" />

diff --git a/ui/src/rasterControl.ts b/ui/src/rasterControl.ts index 7e9879ed..5b40648e 100644 --- a/ui/src/rasterControl.ts +++ b/ui/src/rasterControl.ts @@ -1,7 +1,13 @@ +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 preview — call URL.revokeObjectURL on cleanup */ + /** Object URL for the raw file — call URL.revokeObjectURL on cleanup */ previewUrl: string /** Drawing width in mm (= safeWidth from backend) */ width: number @@ -11,6 +17,8 @@ export interface RasterImageState { naturalWidth: number /** Original image pixel height */ naturalHeight: number + /** Current pan/zoom/crop transform */ + transform: AffineTransform } /** Create a RasterImageState from an uploaded file */ @@ -23,15 +31,62 @@ export async function parseRasterImage(file: File, safeWidth: number): Promise { + const t = state.transform + const W = PREVIEW_SIZE + const H = Math.round(W * (state.height / state.width)) + + const bitmap = await createImageBitmap(state.file) + const canvas = new OffscreenCanvas(W, H) + const ctx = canvas.getContext('2d')! + + ctx.fillStyle = 'white' + ctx.fillRect(0, 0, W, H) + 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 */ +/** + * 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 bitmap = await createImageBitmap(state.file) - const canvas = new OffscreenCanvas(state.naturalWidth, state.naturalHeight) + const canvas = new OffscreenCanvas(W, H) const ctx = canvas.getContext('2d')! - ctx.drawImage(bitmap, 0, 0, state.naturalWidth, state.naturalHeight) + + ctx.fillStyle = 'white' + ctx.fillRect(0, 0, W, H) + ctx.drawImage(bitmap, t[4] * W, t[5] * H, t[0] * W, t[3] * H) bitmap.close() - return ctx.getImageData(0, 0, state.naturalWidth, state.naturalHeight) + + return ctx.getImageData(0, 0, W, H) } diff --git a/ui/src/renderers/index.ts b/ui/src/renderers/index.ts index 56e318d6..0f4b3e89 100644 --- a/ui/src/renderers/index.ts +++ b/ui/src/renderers/index.ts @@ -40,6 +40,7 @@ export interface RendererDefinition { id: string label: string description?: string + inputType: 'svg' | 'raster' params: RendererParam[] execute(opts: ExecuteOpts): Promise } @@ -82,3 +83,7 @@ export const RENDERERS: RendererDefinition[] = [ 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 index abf138fc..1ae5d16d 100644 --- a/ui/src/renderers/pathTracing.ts +++ b/ui/src/renderers/pathTracing.ts @@ -34,6 +34,7 @@ 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 }, diff --git a/ui/src/renderers/rasterZigZag.ts b/ui/src/renderers/rasterZigZag.ts index 7343b92b..6c74e510 100644 --- a/ui/src/renderers/rasterZigZag.ts +++ b/ui/src/renderers/rasterZigZag.ts @@ -6,6 +6,17 @@ async function execute({ imageState, params, backendState, onStatus, worker }: E 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. + const t = imageState.transform + const W = imageState.width + const H = imageState.height + 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(H, (t[5] + t[3]) * H) + worker.postMessage( { type: 'renderRasterZigZag', @@ -21,6 +32,10 @@ async function execute({ imageState, params, backendState, onStatus, worker }: E angle: Number(params['angle'] ?? 0), continuousPath: Boolean(params['continuousPath'] ?? false), trimWhite: Boolean(params['trimWhite'] ?? false), + imageLeft, + imageTop, + imageRight, + imageBottom, }, [imageData.data.buffer], ) @@ -31,6 +46,7 @@ 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: 8 }, diff --git a/ui/src/renderers/vectorRasterVector.ts b/ui/src/renderers/vectorRasterVector.ts index af84c072..04c21dc2 100644 --- a/ui/src/renderers/vectorRasterVector.ts +++ b/ui/src/renderers/vectorRasterVector.ts @@ -55,6 +55,7 @@ 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 }, { diff --git a/ui/src/screens/ChooseRendererScreen.tsx b/ui/src/screens/ChooseRendererScreen.tsx index a4cad219..21fe1aca 100644 --- a/ui/src/screens/ChooseRendererScreen.tsx +++ b/ui/src/screens/ChooseRendererScreen.tsx @@ -1,25 +1,26 @@ -import { RENDERERS, type RendererDefinition } from '../renderers/index' +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({ onChoose, onBack }: Props) { +export function ChooseRendererScreen({ renderers, onChoose, onBack }: Props) { return (
- {RENDERERS.map((r) => ( + {renderers.map((r) => ( ))} diff --git a/ui/src/screens/DrawingPreviewScreen.tsx b/ui/src/screens/DrawingPreviewScreen.tsx index 41ee3477..21bf7b82 100644 --- a/ui/src/screens/DrawingPreviewScreen.tsx +++ b/ui/src/screens/DrawingPreviewScreen.tsx @@ -112,7 +112,9 @@ export function DrawingPreviewScreen({ state, svgState, imageState, renderer, on } function handleParamChange(id: string, value: RendererParamValue) { - const newParams = { ...params, [id]: value } + let newParams = { ...params, [id]: value } + if (id === 'trimWhite' && value === true) newParams = { ...newParams, continuousPath: false } + if (id === 'continuousPath' && value === true) newParams = { ...newParams, trimWhite: false } setParams(newParams) triggerRender(newParams) } diff --git a/ui/src/screens/InputSelectScreen.tsx b/ui/src/screens/InputSelectScreen.tsx new file mode 100644 index 00000000..8d8321ef --- /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/RasterUploadScreen.tsx b/ui/src/screens/RasterUploadScreen.tsx index f694a46a..db06bd2b 100644 --- a/ui/src/screens/RasterUploadScreen.tsx +++ b/ui/src/screens/RasterUploadScreen.tsx @@ -1,58 +1,114 @@ import { useState, useEffect, useRef } from 'react' import type { BackendState } from '../types' -import { parseRasterImage, type RasterImageState } from '../rasterControl' +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, onPreview, onBack }: Props) { - const [imageState, setImageState] = useState(null) +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 previous object URL when imageState changes or component unmounts + // 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 ( - {imageState && ( + {previewDataUrl && ( Image preview )} {imageState && ( -

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

+ <> +
+ +
+

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

+ )} void - onSwitchToRaster?: () => void + onBack: () => void } -export function SvgUploadScreen({ state, onPreview, onSwitchToRaster }: Props) { - const [svgState, setSvgState] = useState(null) - const [previewUrl, setPreviewUrl] = useState(null) - const [txText, setTxText] = useState('') +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) { @@ -87,11 +90,9 @@ export function SvgUploadScreen({ state, onPreview, onSwitchToRaster }: Props) { > Preview drawing - {onSwitchToRaster && ( - - )} +
) } diff --git a/ui/src/types.ts b/ui/src/types.ts index e139f881..7e6dcb51 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -20,6 +20,7 @@ export interface BackendState { export type UiPhase = | 'loading' | Phase + | 'InputSelect' | 'RasterSelect' | 'ChooseRenderer' | 'DrawingPreview' 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/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 32f07246..eb4cfa5d 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/rasterControl.ts","./src/svgControl.ts","./src/types.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/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/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 +{"root":["./src/App.tsx","./src/api.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/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 index c04ebf5f..76e283fc 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,8 +1,26 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import path from 'path' +import fs from 'fs' export default defineConfig({ - plugins: [react()], + 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, diff --git a/worker/src/main.ts b/worker/src/main.ts index b327d912..4f81fb33 100644 --- a/worker/src/main.ts +++ b/worker/src/main.ts @@ -93,7 +93,11 @@ function isRenderRasterZigZagRequest(obj: any): obj is RequestTypes.RenderRaster && typeof obj.contrast === 'number' && typeof obj.angle === 'number' && typeof obj.continuousPath === 'boolean' - && typeof obj.trimWhite === 'boolean'; + && typeof obj.trimWhite === '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 { diff --git a/worker/src/types.ts b/worker/src/types.ts index aa1bd131..e1460803 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -56,5 +56,11 @@ export namespace RequestTypes { angle: number, continuousPath: boolean, trimWhite: 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, } } \ No newline at end of file diff --git a/worker/src/zigzag.ts b/worker/src/zigzag.ts index cd5650f4..e7a8462a 100644 --- a/worker/src/zigzag.ts +++ b/worker/src/zigzag.ts @@ -54,7 +54,7 @@ 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, angle, continuousPath, trimWhite } = request; + const { imageData, widthMm, heightMm, homeX, homeY, lineSpacing, amplitude, brightness, contrast, angle, continuousPath, trimWhite, imageLeft, imageTop, imageRight, imageBottom } = request; const { data, width: imgW, height: imgH } = imageData; const angleRad = (angle * Math.PI) / 180; @@ -74,6 +74,13 @@ export function renderRasterZigZag( const numLines = Math.ceil((dMax - dMin) / lineSpacing) + 1; const rawCommands: Command[] = []; let penDown = false; + // Axis-aligned bounding box of all drawn scan line endpoints (unperturbed centers, + // not amplitude-displaced points). Accumulated across every drawn line so it + // works correctly at any angle — for angle=0 it gives first/last scan-line Y; + // for angle=45 it collapses to the image rectangle. + let minX = Infinity, maxX = -Infinity; + let minY = Infinity, maxY = -Infinity; + let drawnLineCount = 0; // counts only lines that survive all skips rawCommands.push('p0'); rawCommands.push({ x: homeX, y: homeY }); @@ -119,21 +126,43 @@ export function renderRasterZigZag( }); } - // Trim leading/trailing flat (white) samples before reversing so - // trimming is always relative to physical position along the line - let activePoints = points; + // Always trim points outside the image bounds (pan/zoom/crop region). + // Points outside have no image data regardless of trimWhite setting. + let bFirst = 0; + let bLast = points.length - 1; + while (bFirst <= bLast && (points[bFirst].x < imageLeft || points[bFirst].x > imageRight || points[bFirst].y < imageTop || points[bFirst].y > imageBottom)) bFirst++; + while (bLast >= bFirst && (points[bLast].x < imageLeft || points[bLast].x > imageRight || points[bLast].y < imageTop || points[bLast].y > imageBottom)) bLast--; + if (bFirst > bLast) continue; // entire line outside image — skip + + let activePoints = points.slice(bFirst, bLast + 1); + + // Optionally also trim leading/trailing flat (white) samples within the image if (trimWhite) { const FLAT = 0.05; + const activeDarknesses = darknesses.slice(bFirst, bLast + 1); let first = 0; - let last = points.length - 1; - while (first < points.length && darknesses[first] < FLAT) first++; - while (last > first && darknesses[last] < FLAT) last--; - if (first >= points.length) continue; // entire line is white — skip - activePoints = points.slice(first, last + 1); + let last = activePoints.length - 1; + while (first <= last && activeDarknesses[first] < FLAT) first++; + while (last >= first && activeDarknesses[last] < FLAT) last--; + if (first > last) continue; + activePoints = activePoints.slice(first, last + 1); } - // Boustrophedon: reverse every other line to minimise pen travel - if (lineIdx % 2 !== 0) activePoints.reverse(); + // Accumulate axis-aligned bounding box of scan line endpoints (unperturbed centers) + const t_bFirst = Math.min(tMin + bFirst, tMax); + const t_bLast = Math.min(tMin + bLast, tMax); + const ex1 = ox + t_bFirst * lineDx, ey1 = oy + t_bFirst * lineDy; + const ex2 = ox + t_bLast * lineDx, ey2 = oy + t_bLast * lineDy; + minX = Math.min(minX, ex1, ex2); + maxX = Math.max(maxX, ex1, ex2); + minY = Math.min(minY, ey1, ey2); + maxY = Math.max(maxY, ey1, ey2); + + // 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(); + drawnLineCount++; if (continuousPath) { if (!penDown) { @@ -154,14 +183,15 @@ export function renderRasterZigZag( rawCommands.push('p0'); - // Perimeter rectangle — always appended when continuousPath is on - if (continuousPath) { - rawCommands.push({ x: 0, y: 0 }); + // Perimeter — traced along the actual endpoints of the first and last drawn scan + // lines, so the border coincides exactly with the zig-zag line endpoints. + if (continuousPath && minX < Infinity) { + rawCommands.push({ x: minX, y: minY }); rawCommands.push('p1'); - rawCommands.push({ x: widthMm, y: 0 }); - rawCommands.push({ x: widthMm, y: heightMm }); - rawCommands.push({ x: 0, y: heightMm }); - rawCommands.push({ x: 0, y: 0 }); + rawCommands.push({ x: maxX, y: minY }); + rawCommands.push({ x: maxX, y: maxY }); + rawCommands.push({ x: minX, y: maxY }); + rawCommands.push({ x: minX, y: minY }); rawCommands.push('p0'); } From 8d0610ff6ba7ae3f62b21fef7120b898c88e68b9 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Thu, 23 Apr 2026 02:31:49 +0000 Subject: [PATCH 13/38] black and white point, pen width --- src/movement.h | 2 +- ui/src/renderers/rasterZigZag.ts | 6 ++++ ui/src/screens/DrawingPreviewScreen.tsx | 39 +++++++++++++++++++++---- ui/src/svgControl.ts | 18 +++++++++--- worker/src/main.ts | 2 ++ worker/src/types.ts | 2 ++ worker/src/zigzag.ts | 34 ++++----------------- 7 files changed, 63 insertions(+), 40 deletions(-) diff --git a/src/movement.h b/src/movement.h index 01d93116..75c5fa0e 100644 --- a/src/movement.h +++ b/src/movement.h @@ -6,7 +6,7 @@ #include "display.h" // Motor driver parameters. -constexpr int printSpeedSteps = 500; +constexpr int printSpeedSteps = 750; constexpr int moveSpeedSteps = 1500; constexpr long INFINITE_STEPS = 999999999; constexpr long acceleration = 999999999; // Essentially infinite, causing instant stop / start diff --git a/ui/src/renderers/rasterZigZag.ts b/ui/src/renderers/rasterZigZag.ts index 6c74e510..800076f6 100644 --- a/ui/src/renderers/rasterZigZag.ts +++ b/ui/src/renderers/rasterZigZag.ts @@ -29,6 +29,8 @@ async function execute({ imageState, params, backendState, onStatus, worker }: E 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), trimWhite: Boolean(params['trimWhite'] ?? false), @@ -56,6 +58,10 @@ export const rasterZigZagRenderer: RendererDefinition = { { 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: 0 }, { type: 'checkbox', id: 'trimWhite', label: 'Trim White Lines', default: false }, diff --git a/ui/src/screens/DrawingPreviewScreen.tsx b/ui/src/screens/DrawingPreviewScreen.tsx index 21bf7b82..aeeb77a7 100644 --- a/ui/src/screens/DrawingPreviewScreen.tsx +++ b/ui/src/screens/DrawingPreviewScreen.tsx @@ -82,13 +82,21 @@ function renderLeaf( } export function DrawingPreviewScreen({ state, svgState, imageState, renderer, onAccept, onBack }: Props) { - const [params, setParams] = useState>(() => - defaultParams(renderer), - ) + 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 [penWidthMm, setPenWidthMm] = useState(() => + parseFloat(localStorage.getItem('muralPenWidthMm') ?? '1.5'), + ) const previewWidth = svgState?.width ?? imageState!.width const previewHeight = svgState?.baseHeight ?? imageState!.height @@ -99,13 +107,21 @@ export function DrawingPreviewScreen({ state, svgState, imageState, renderer, on // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Generate preview data URL when result arrives + 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) + const url = jsonToPreviewDataUrl(result.svgJson, previewWidth, previewHeight, penWidthMm) setPreviewUrl(url) } - }, [result, previewWidth, previewHeight]) + }, [result, previewWidth, previewHeight, penWidthMm]) function triggerRender(p: Record) { render(renderer, { svgState, imageState }, p, state) @@ -193,6 +209,17 @@ export function DrawingPreviewScreen({ state, svgState, imageState, renderer, on })}
+
+ +
+ diff --git a/ui/src/svgControl.ts b/ui/src/svgControl.ts index d820f8f8..6c3f554f 100644 --- a/ui/src/svgControl.ts +++ b/ui/src/svgControl.ts @@ -187,17 +187,27 @@ export function svgStringToJson(svgString: string): string { return json } -/** Converts Paper.js JSON back to a canvas data URL for preview */ -export function jsonToPreviewDataUrl(json: string, width: number, height: number): string { +/** 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 = width - canvas.height = height + 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() diff --git a/worker/src/main.ts b/worker/src/main.ts index 4f81fb33..a10c2008 100644 --- a/worker/src/main.ts +++ b/worker/src/main.ts @@ -91,6 +91,8 @@ function isRenderRasterZigZagRequest(obj: any): obj is RequestTypes.RenderRaster && 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.trimWhite === 'boolean' diff --git a/worker/src/types.ts b/worker/src/types.ts index e1460803..bb16b41b 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -53,6 +53,8 @@ export namespace RequestTypes { amplitude: number, brightness: number, contrast: number, + blackPoint: number, + whitePoint: number, angle: number, continuousPath: boolean, trimWhite: boolean, diff --git a/worker/src/zigzag.ts b/worker/src/zigzag.ts index e7a8462a..86e9304e 100644 --- a/worker/src/zigzag.ts +++ b/worker/src/zigzag.ts @@ -54,7 +54,7 @@ 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, angle, continuousPath, trimWhite, imageLeft, imageTop, imageRight, imageBottom } = request; + const { imageData, widthMm, heightMm, homeX, homeY, lineSpacing, amplitude, brightness, contrast, blackPoint, whitePoint, angle, continuousPath, trimWhite, imageLeft, imageTop, imageRight, imageBottom } = request; const { data, width: imgW, height: imgH } = imageData; const angleRad = (angle * Math.PI) / 180; @@ -74,12 +74,6 @@ export function renderRasterZigZag( const numLines = Math.ceil((dMax - dMin) / lineSpacing) + 1; const rawCommands: Command[] = []; let penDown = false; - // Axis-aligned bounding box of all drawn scan line endpoints (unperturbed centers, - // not amplitude-displaced points). Accumulated across every drawn line so it - // works correctly at any angle — for angle=0 it gives first/last scan-line Y; - // for angle=45 it collapses to the image rectangle. - let minX = Infinity, maxX = -Infinity; - let minY = Infinity, maxY = -Infinity; let drawnLineCount = 0; // counts only lines that survive all skips rawCommands.push('p0'); @@ -113,7 +107,10 @@ export function renderRasterZigZag( const py = Math.min(Math.max(Math.round((y_mm / heightMm) * (imgH - 1)), 0), imgH - 1); const luma = getPixelLuma(data, imgW, px, py); - const t_norm = adjustBrightnessContrast(luma, brightness, contrast); + let t_norm = adjustBrightnessContrast(luma, brightness, contrast); + t_norm = whitePoint > blackPoint + ? (Math.max(blackPoint, Math.min(whitePoint, t_norm)) - blackPoint) / (whitePoint - blackPoint) + : 0.5; const darkness = 1 - t_norm; darknesses.push(darkness); @@ -148,16 +145,6 @@ export function renderRasterZigZag( activePoints = activePoints.slice(first, last + 1); } - // Accumulate axis-aligned bounding box of scan line endpoints (unperturbed centers) - const t_bFirst = Math.min(tMin + bFirst, tMax); - const t_bLast = Math.min(tMin + bLast, tMax); - const ex1 = ox + t_bFirst * lineDx, ey1 = oy + t_bFirst * lineDy; - const ex2 = ox + t_bLast * lineDx, ey2 = oy + t_bLast * lineDy; - minX = Math.min(minX, ex1, ex2); - maxX = Math.max(maxX, ex1, ex2); - minY = Math.min(minY, ey1, ey2); - maxY = Math.max(maxY, ey1, ey2); - // 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). @@ -183,17 +170,6 @@ export function renderRasterZigZag( rawCommands.push('p0'); - // Perimeter — traced along the actual endpoints of the first and last drawn scan - // lines, so the border coincides exactly with the zig-zag line endpoints. - if (continuousPath && minX < Infinity) { - rawCommands.push({ x: minX, y: minY }); - rawCommands.push('p1'); - rawCommands.push({ x: maxX, y: minY }); - rawCommands.push({ x: maxX, y: maxY }); - rawCommands.push({ x: minX, y: maxY }); - rawCommands.push({ x: minX, y: minY }); - rawCommands.push('p0'); - } const trimmed = trimCommands(rawCommands); const deduped = dedupeCommands(trimmed); From 48ccae85b34c018d1c75515e5305ce3a80023685 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Thu, 23 Apr 2026 02:39:51 +0000 Subject: [PATCH 14/38] speed selector --- src/phases/begindrawingphase.cpp | 7 ++++++- src/runner.cpp | 7 ++++--- src/runner.h | 3 ++- src/tasks/interpolatingmovementtask.cpp | 7 ++++--- src/tasks/interpolatingmovementtask.h | 3 ++- ui/src/api.ts | 2 +- ui/src/screens/BeginDrawingScreen.tsx | 13 +++++++++++-- 7 files changed, 30 insertions(+), 12 deletions(-) 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/runner.cpp b/src/runner.cpp index 897e19d2..356c4e5a 100644 --- a/src/runner.cpp +++ b/src/runner.cpp @@ -44,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(); @@ -77,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 diff --git a/src/runner.h b/src/runner.h index 501896f9..0d18166a 100644 --- a/src/runner.h +++ b/src/runner.h @@ -21,11 +21,12 @@ class Runner { 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/ui/src/api.ts b/ui/src/api.ts index 3e6e65e9..fb00c62d 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -63,7 +63,7 @@ export const estepsCalibration = (): Promise => export const postMotorCommand = (command: string): Promise => post('/command', { command }) -export const run = (): Promise => post('/run') +export const run = (speed: number): Promise => post('/run', { speed: String(speed) }) export function uploadCommands( blob: Blob, diff --git a/ui/src/screens/BeginDrawingScreen.tsx b/ui/src/screens/BeginDrawingScreen.tsx index 37dd69cc..96b952d4 100644 --- a/ui/src/screens/BeginDrawingScreen.tsx +++ b/ui/src/screens/BeginDrawingScreen.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' +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 @@ -11,10 +12,17 @@ interface Props { export function BeginDrawingScreen({ onBegin, onReset }: Props) { const [busy, setBusy] = useState(false) + const [speed, setSpeed] = useState(() => + parseInt(localStorage.getItem('muralSpeed') ?? '800', 10) + ) + + useEffect(() => { + localStorage.setItem('muralSpeed', String(speed)) + }, [speed]) async function handleBegin() { setBusy(true) - await run() + await run(speed) onBegin() } @@ -29,6 +37,7 @@ export function BeginDrawingScreen({ onBegin, onReset }: Props) { title="Mural is Ready" subtitle="The drawing commands have been uploaded and verified." > + From fdf627151d5f373ead9df62e00a6bb0bc3985832 Mon Sep 17 00:00:00 2001 From: Nik Ivanov <2centnik@gmail.com> Date: Thu, 23 Apr 2026 03:04:30 +0000 Subject: [PATCH 15/38] compress once --- ui/src/App.tsx | 8 ++--- ui/src/commandCompression.ts | 13 ++++++++ ui/src/screens/DrawingPreviewScreen.tsx | 43 ++++++++++++++++++++++--- ui/src/screens/UploadProgressScreen.tsx | 36 ++++++--------------- ui/tsconfig.tsbuildinfo | 2 +- 5 files changed, 66 insertions(+), 36 deletions(-) create mode 100644 ui/src/commandCompression.ts diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6d1caf43..5cb7ee74 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -30,7 +30,7 @@ export default function App() { const [rasterState, setRasterState] = useState(null) const [inputType, setInputType] = useState<'svg' | 'raster' | null>(null) const [selectedRenderer, setSelectedRenderer] = useState(null) - const [renderedCommands, setRenderedCommands] = 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. @@ -114,8 +114,8 @@ export default function App() { } } - function handleCommandsAccepted(commands: string) { - setRenderedCommands(commands) + function handleCommandsAccepted(blob: Blob) { + setRenderedBlob(blob) setUiPhase('UploadProgress') } @@ -194,7 +194,7 @@ export default function App() { case 'UploadProgress': currentScreen = ( 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/screens/DrawingPreviewScreen.tsx b/ui/src/screens/DrawingPreviewScreen.tsx index aeeb77a7..7f66035d 100644 --- a/ui/src/screens/DrawingPreviewScreen.tsx +++ b/ui/src/screens/DrawingPreviewScreen.tsx @@ -3,6 +3,7 @@ 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' @@ -15,7 +16,7 @@ interface Props { svgState?: SvgState imageState?: RasterImageState renderer: RendererDefinition - onAccept: (commands: string) => void + onAccept: (blob: Blob) => void onBack: () => void } @@ -97,6 +98,7 @@ export function DrawingPreviewScreen({ state, svgState, imageState, renderer, on 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 @@ -123,6 +125,16 @@ export function DrawingPreviewScreen({ state, svgState, imageState, renderer, on } }, [result, previewWidth, previewHeight, penWidthMm]) + // Compress result once — blob is reused for upload, size drives the thermometer + useEffect(() => { + if (!result) { setCompressedBlob(null); 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) } @@ -136,8 +148,8 @@ export function DrawingPreviewScreen({ state, svgState, imageState, renderer, on } function handleAccept() { - if (!result) return - onAccept(result.commands.join('\n')) + if (!compressedBlob) return + onAccept(compressedBlob) } return ( @@ -220,7 +232,30 @@ export function DrawingPreviewScreen({ state, svgState, imageState, renderer, on />
-
diff --git a/ui/src/components/Button.tsx b/ui/src/components/Button.tsx index 48d34f2c..b7d06bbc 100644 --- a/ui/src/components/Button.tsx +++ b/ui/src/components/Button.tsx @@ -10,13 +10,13 @@ export const Button = forwardRef(function Button 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-slate-900 disabled:opacity-40 disabled:cursor-not-allowed' + '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-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white focus:ring-indigo-500', + 'bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white focus:ring-cyan-500', secondary: - 'bg-slate-700 hover:bg-slate-600 active:bg-slate-800 text-slate-200 focus:ring-slate-500', + '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', } diff --git a/ui/src/components/Card.tsx b/ui/src/components/Card.tsx index ec7cc0bd..e8c0e51c 100644 --- a/ui/src/components/Card.tsx +++ b/ui/src/components/Card.tsx @@ -9,11 +9,11 @@ interface CardProps { export function Card({ title, subtitle, children, className = '' }: CardProps) { return ( -
+
{(title || subtitle) && (
{title &&

{title}

} - {subtitle &&

{subtitle}

} + {subtitle &&

{subtitle}

}
)} {children} diff --git a/ui/src/components/DPad.tsx b/ui/src/components/DPad.tsx index a1a42f4f..e4698c12 100644 --- a/ui/src/components/DPad.tsx +++ b/ui/src/components/DPad.tsx @@ -13,17 +13,17 @@ export function DPad({ onNudge, onZoom, transformText }: DPadProps) {
{/* Circle d-pad */} -
+
{/* UP */} @@ -33,10 +33,10 @@ export function DPad({ onNudge, onZoom, transformText }: DPadProps) { type="button" onClick={() => onNudge('right')} style={{ clipPath: 'polygon(50% 50%, 100% 0%, 100% 100%)' }} - className="absolute inset-0 bg-slate-700 hover:bg-slate-600 active:bg-slate-900 transition-colors select-none" + className="absolute inset-0 bg-gray-700 hover:bg-gray-600 active:bg-gray-900 transition-colors select-none" > ▶︎ @@ -46,10 +46,10 @@ export function DPad({ onNudge, onZoom, transformText }: DPadProps) { type="button" onClick={() => onNudge('down')} style={{ clipPath: 'polygon(50% 50%, 100% 100%, 0% 100%)' }} - className="absolute inset-0 bg-slate-700 hover:bg-slate-600 active:bg-slate-900 transition-colors select-none" + className="absolute inset-0 bg-gray-700 hover:bg-gray-600 active:bg-gray-900 transition-colors select-none" > @@ -59,53 +59,53 @@ export function DPad({ onNudge, onZoom, transformText }: DPadProps) { type="button" onClick={() => onNudge('left')} style={{ clipPath: 'polygon(50% 50%, 0% 100%, 0% 0%)' }} - className="absolute inset-0 bg-slate-700 hover:bg-slate-600 active:bg-slate-900 transition-colors select-none" + className="absolute inset-0 bg-gray-700 hover:bg-gray-600 active:bg-gray-900 transition-colors select-none" > ◀︎ {/* Divider lines (diagonal, matching sector boundaries) */}
{/* Center decorative circle */} -
+
{/* Zoom pill */} -
+
-
+
-
+
{transformText && ( -

{transformText}

+

{transformText}

)}
) diff --git a/ui/src/components/ProgressBar.tsx b/ui/src/components/ProgressBar.tsx index 2221bd82..3bed641f 100644 --- a/ui/src/components/ProgressBar.tsx +++ b/ui/src/components/ProgressBar.tsx @@ -7,14 +7,14 @@ interface ProgressBarProps { export function ProgressBar({ label, value, animated = false, color = 'indigo' }: ProgressBarProps) { const colors = { - indigo: 'bg-indigo-500', + indigo: 'bg-cyan-500', emerald: 'bg-emerald-500', } return (
- {label &&

{label}

} -
+ {label &&

{label}

} +
-
) diff --git a/ui/src/components/Toggle.tsx b/ui/src/components/Toggle.tsx index 7902396c..130bc264 100644 --- a/ui/src/components/Toggle.tsx +++ b/ui/src/components/Toggle.tsx @@ -8,14 +8,14 @@ interface ToggleProps { export function Toggle({ id, label, checked, onChange }: ToggleProps) { return (